[譯]為什麼context.Value重要,如何進行改進

玄學醬發表於2017-10-16
本文講的是[譯] 為什麼 context.Value 重要,如何進行改進,

覺得文章太長可以看這裡:我認為 context.Value 解決了描寫無狀態這個重要用例 – 而且它的抽象還是可擴充套件的。我相信 dynamic scoping#Dynamic_scoping) 可以提供同樣的好處,同時解決對當前實現的大多數爭議。 因此,我將試圖從其具體實施到它的潛在問題進行討論。

這篇博文有點長。我建議你跳過你覺得無聊的部分


最近這篇博文已經在幾個 Go 論壇上被探討過。它提出了幾個很好的論據來反對 context-package

  • 即使有一些中間函式沒有用到它,但它依然要求這些函式包含 context.Context。這引起了 API 的混亂的同時還需要廣泛的深入修改 API,比如,ctx context.Context 會在入參中重複出現多次。
  • context.Value 不是靜態型別安全的,總是需要型別斷言。
  • 它不允許你靜態地表達關於上下文內容的關鍵依賴。
  • 由於需要全域性名稱空間,它容易出現名稱衝突。
  • 這是一個以連結串列形式實現的字典,因此效率很低。

然而,在對 context 被設計來解決的問題的探討中,我認為它做的不夠好,它主要探討的是取消機制,而對於 Context.Value 只進行了簡單的說明。

[…] 設計你的 API,而不考慮 ctx.Value,可以讓你永遠有選擇的餘地。

我認為這個問題提的很不公正。想要關於 context.Value 的論證是理性的,需要雙方都參與進來考慮。無論你對當前 API 的看法如何:經驗豐富且智慧的工程師們在鄭重思考後覺得Context.Value 是需要的,這意味著這個問題值得被關注。

我將嘗試描述我對 content 包在嘗試解決什麼樣的問題的看法,目前存在哪些替代方案,以及為什麼我找到了它們的不足之處,同時我正在為一種未來的語言演進描述一種替代設計。它將解決相同的問題,同時避免一些學習 content 包時的負面影響。但這並不是意味著它將會是 Go 2 的一個具體方案(我在這的考慮還為時過早),只是為了表現一種平衡的觀點,使得語言設計界有更多可能,更容易考慮到全部可能。


這些 context 要去解決的問題是將問題抽象為獨立執行的、由系統的不同部分處理的單元,以及如何將資料作用域應用到這些單元的某一個上。很難清楚的定義我說的這些抽象,所以我會給出一些例子。

  • 當你構建一個可擴充套件的 web 服務時,你可能會有一個為你做一些類似認證、權鑑和解析等的無狀態前端服務。它允許你輕鬆的擴充套件外部介面,如果負載增加到後端不能承受,也可以直接在前端優雅的拒絕。
  • 微服務將大型應用分成小的個體分別來處理每個特定的請求,拆分出更多的請求到其它微服務裡面。這些請求通常是獨立的,可以根據需求輕鬆的將各個微服務上下的擴充套件,從而在例項之間進行負載均衡,並解決透明代理中的一些問題。
  • 函式及服務走的更遠一步:你編寫一個無狀態的方法來轉換資料,平臺使其可擴充套件並更效率的執行。
  • 甚至CSP,Go 內建的併發模型也可以體現這一方式。即程式設計師執行單獨的『程式』來描述他的問題,執行時則會更效率的執行它。
  • 函式式程式設計作為一種範型。函式結果只依賴於入參的這一概念意味著不存在共享態和獨立執行。
  • 這個 Go 的 Request Oriented Collector 設計也有著完全相同的猜想和理論。

所有這些情況的想法都是想通過減少共享狀態的同時保持資源的共享來增加擴充套件性(無論是分佈在機器之間,執行緒之間或者只是程式碼中)。

Go 採取了一個措施來靠近這個特性。但它不會像某些函數語言程式設計語言那樣禁止或者阻礙可變狀態。它允許線上程之間共享記憶體並與互斥體進行同步,而不完全依賴於通道。但是它也絕對想成為一種(或唯一)編寫現代可擴充套件服務的語言。因此,它需要成為一種很好的語言來編寫無狀態的服務,它需要至少在一定程度上能夠達到請求隔離級別而不是程式隔離。

(附註:這似乎是上述文章作者的宣告,他聲稱上下文主要對服務作者有用。我不同意。一般抽象發生在很多層面。比如 GUI 的一次點選就像這個請求的抽象一樣,作為一個 HTTP 請求。)

這帶來了能在請求級別儲存一些資料的需求。一個簡單的例子就是RPC 框架中的身份驗證。不同的請求將具有不同的功能。如果一個請求來自於管理員,它應該比未認證使用者擁有更高的許可權。這是從根本上的請求作用域內的資料而不是過程,服務或者應用作用域。RPC 框架應該將這些資料視為不透明的。它是應用程式特指的,不僅是資料看起來有多詳細,還有什麼樣的資料是需要的。

就像一個 HTTP 代理或者框架不需要知道它不適用的請求引數和頭一樣,RPC 框架不應該知道應用程式所需要的請求作用域的資料。


讓我們來試試在不引入上下文的情況下解決(可能)這個問題,例如,我們來看看編寫 HTTP 中介軟體的問題。我們希望以裝飾一個 http.Handler(或其變體)的方式來允許裝飾器附加資料給請求。

為了獲得靜態型別安全性,我們可以試著新增一些型別給我們的 handlers。我們可以有一個包含我們想要保留請求作用域內所有資料的型別,並通過我們的 handler 傳遞:

type Data struct {
    Username string
    Log *log.Logger
    // …
}

func HandleA(d Data, res http.ResponseWriter, req *http.Request) {
    // …
    d.Username = "admin"
    HandleB(d, req, res)
    // …
}

func HandleB(d Data, res http.ResponseWriter, req *http.Request) {
    // …
}

但是,這將阻止我們編寫可重用的中介軟體。任何這樣的中介軟體都需要用 HandleA 包好。但是因為它將是可重用的,所以它不應該知道引數的型別。有可以將 Data 引數設定為interface{} 型別,並需要型別斷言。但這不允許中介軟體注入自己的資料。你可能覺得介面型別斷言可以解決這個問題,但是它們還有它們自己的一堆問題沒解決。所以結果是,這種方法不能帶給你真正的型別安全。

我們可以儲存由請求鍵入的狀態。例如身份驗證中介軟體可以實現

type Authenticator struct {
    mu sync.Mutex
    users map[*http.Request]string
    wrapped http.Handler
}

func (a *Authenticator) ServeHTTP(res http.ResponseWriter, req *http.Request) {
    // …
    a.mu.Lock()
    a.users[req] = "admin"
    a.mu.Unlock()
    defer func() {
        a.mu.Lock()
        delete(a.users, req)
        a.mu.Unlock()
    }()
    a.wrapped.ServeHTTP(res, req)
}

func (a *Authenticator) Username(req *http.Request) string {
    a.mu.Lock()
    defer a.mu.Unlock()
    return a.users[req]
}

這與上下文相比有一些好處:

  • 它更加型別安全。
  • 雖然我們還是不能對認證使用者表達要求,但是我們對認證者表達要求。
  • 這樣不太可能命名衝突了。

然而,我們已經認同它的共享可變狀態和相關的鎖爭用。如果其中一箇中間處理程式決定建立一個新的請求,那麼可以使用一種很微妙的方式破解,比如 http.StripPrefix 將要做的那樣。

最後我們可能會考慮將這些資料儲存在 *http.Request 本身中,例如通過將其新增為字串的URL parameter,但這也有幾個缺點。事實上,它基本檢測到了 context.Context 的每個單獨 item 的缺點。表示式是一個連結串列。即使有那樣的優點,它的執行緒安全也無法忽略,如果該請求被傳遞給不同的 goroutine 中的程式處理,我們會遇到麻煩。

(附註:所有的這一切也使我們瞭解了為什麼 context 包被使用連結串列的方式實現。它允許儲存在其中的所有資料都是隻讀的,因此肯定執行緒安全,在上下文中儲存的共享狀態永遠不會出現鎖爭用,因為壓根不需要鎖。)

所以我們看到,解決這個問題是非常困難的(如果可以解決),實現在獨立執行的處理程式附加資料給請求時,也是優於 context.Value 的。無論是否相信這個問題值得解決,它都是有爭議的。但是如果你想獲得這種可擴充套件的抽象,你將不得不依賴於類似於 context.Value東西


無論你現在相信 context.Value 確實無用,或者你仍有疑慮:在這兩種情況下,這些缺點顯然都不能被忽略。但是我們可以試著找到一些方法去改進它。消除一些缺點,同時保持其有用的屬性。

一種方法(在 Go 2 中)將是引入動態作用域#Dynamic_scoping)變數。語義上,每個動態作用域變數表示一個單獨的棧,每次你改變它的值,新的值被推入棧。在你方法返回之後它會再次出棧。比如:

// 讓我們創造點語法,只一點點哦。
dyn x = 23

func Foo() {
    fmt.Println("Foo:", x)
}

func Bar() {
    fmt.Println("Bar:", x)
    x = 42
    fmt.Println("Bar:", x)
    Baz()
    fmt.Println("Bar:", x)
}

func Baz() {
    fmt.Println("Baz:", x)
    x = 1337
    fmt.Println("Baz:", x)
}

func main() {
    fmt.Println("main:", x)
    Foo()
    Bar()
    Baz()
    fmt.Println("main:", x)
}

// 輸出:
main: 23
Foo: 23
Bar: 23
Bar: 42
Baz: 42
Baz: 1337
Bar: 42
Baz: 23
Baz: 1337
main: 23

我想到這裡的語義有一些需要注意的地方。

  • 我只允許在包的作用域宣告 dyn 這個型別。鑑於沒有辦法引用不同功能的本地識別符號,這似乎是合乎邏輯的。
  • 新產生的 goroutine 將會繼承其父方法的動態值。如果我們通過連結串列實現它(像context.Context 一樣),共享的資料將是隻讀的。頭指標需要儲存在某種型別的 goroutine-local 的儲存中。這樣,寫入只會修改此本地儲存(和全域性堆),因此不需要特意的同步本次修改。
  • 動態作用域將會獨立於宣告變數的包。也就是說,如果 foo.A 修改了一個動態的bar.X,那麼這個修改對後來的 foo.A 的被呼叫者都是不可見的,不管它們是否在bar 內。
  • 動態作用域的變數不可定址。否則我們會鬆動併發安全性和動態作用域界定的清晰『入棧』語義。不過仍然可以宣告 dyn x *int 來讓可變狀態傳遞。
  • 編譯器將為棧分配必要的記憶體,初始化到它們的初始化器,併發出必要的指令,以便在寫入和返回時 push 和 pop 值。為了對 panic 和過早的返回有個交代,需要類似 defer的機制。
  • 這個設計和包作用域有一些令人迷惑的重疊。最值得注意的是,從 foo.X = Y 來看,你無法判斷 foo.X 是否有動態作用域。就我個人而言,我會通過從語言中移除包作用域變數來解決此問題。它們仍然可以通過宣告一個動態作用域指標,而不修改它來模仿。那麼它的指標就是一個共享變數。但是,大多數包作用域變數的用法就僅僅是使用動態作用域變數。

將此設計同 context 的一系列缺點進行比較是很有啟發性的。

  • 避免了 API 的雜亂,因為請求作用域的資料現在將成為語言的一部分,而不需要明確的傳遞。
  • 動態區域變數是靜態型別安全的。每個 dyn 宣告都有一個明確的型別。
  • 仍然不可能對動態作用域變數表達關鍵的依賴關係。但也不能沒有。最糟糕的,它們會有零值。
  • 命名衝突被消除。識別符號就像變數名一樣,識別符號有恰當的作用域。
  • 簡單的實現任然非連結串列莫屬,並不會很低效。每個 dyn 宣告都有它自己的鏈,只有頭指標需要被操作。
  • 這個設計在一定程度上仍然很『魔幻』。但是『魔幻』是固有問題(至少如果我正確的理解批評的話)。魔法就是通過 API 邊界透明地傳遞價值的一種可能性。

最後,我想提一下取消機制。索然在上述文章中,作者提到了很多關於取消機制的內容,但我迄今為止都忽略了它。那是因為我相信取消機制在好的 context.Value 實現之上是可以實現的。比如:

// $GOROOT/src/done
package done

// 噹噹前執行的上下文(比如請求)被取消時,C 被關閉。
dyn C <-chan struct{}

// 當 C 被關閉或者取消被呼叫時,CancelFunc 返回一個關閉的通道。
func CancelFunc() (c <-chan struct, cancel func()) {
    // 我們不能在這改變 C,應為它的作用域是動態的,這就是為什麼我們返回一個呼叫者應該儲存的新通道。
    ch := make(chan struct)

    var o sync.Once
    cancel = func() { o.Do(close(ch)) }
    if C != nil {
        go func() {
            <-C
            cancel()
        }()
    }
    return ch, cancel
}

// $GOPATH/example.com/foo
package foo

func Foo() {
    var cancel func()
    done.C, cancel = done.CancelFunc()
    defer cancel()
    // Do things
}

這種取消機制現在可以從任何想要的庫中使用,而不需要確認其 API 明確支援。這讓它可以很簡單的追加取消的能力。


無論你喜不喜歡這個設計,至少我們不應該急於要求刪除 context 包。刪除它只是一種可能解決它缺點的方法之一。

如果移除 context.Context 的這一天真的來了,我們應該問的問題是『我們是否想要有一個規範的方法去管理請求作用域的值,其代價又是什麼』。只有這樣我們才能開始探討最佳實現會是什麼樣的,或者是否移除它。





原文釋出時間為:2017年9月8日

本文來自雲棲社群合作伙伴掘金,瞭解相關資訊可以關注掘金網站。


相關文章