【譯】爲何 context.Value 重要,如何進行改進

爲何 context.Value 重要,如何進行改進

以爲文章太長能夠看這裏:我認爲 context.Value 解決了描寫無狀態這個重要用例 - 並且它的抽象仍是可擴展的。我相信
dynamic scoping#Dynamic_scoping) 能夠提供一樣的好處,同時解決對當前實現的大多數爭議。 所以,我將試圖從其具體實施到它的潛在問題進行討論。
html

這篇博文有點長。我建議你跳過你以爲無聊的部分前端


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

  • 即便有一些中間函數沒有用到它,但它依然要求這些函數包含 context.Context。這引發了 API 的混亂的同時還須要普遍的深刻修改 API,好比,ctx context.Context 會在入參中重複出現屢次。
  • context.Value 不是靜態類型安全的,老是須要類型斷言。
  • 它不容許你靜態地表達關於上下文內容的關鍵依賴。
  • 因爲須要全局命名空間,它容易出現名稱衝突。
  • 這是一個以鏈表形式實現的字典,所以效率很低。

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

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

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

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


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

  • 當你構建一個可擴展的 web 服務時,你可能會有一個爲你作一些相似認證、權鑑和解析等的無狀態前端服務。它容許你輕鬆的擴展外部接口,若是負載增長到後端不能承受,也能夠直接在前端優雅的拒絕。
  • 微服務將大型應用分紅小的個體分別來處理每一個特定的請求,拆分出更多的請求到其它微服務裏面。這些請求一般是獨立的,能夠根據需求輕鬆的將各個微服務上下的擴展,從而在實例之間進行負載均衡,並解決透明代理中的一些問題。
  • 函數及服務走的更遠一步:你編寫一個無狀態的方法來轉換數據,平臺使其可擴展並更效率的執行。
  • 甚至CSP,Go 內置的併發模型也能夠體現這一方式。即程序員執行單獨的『進程』來描述他的問題,運行時則會更效率的執行它。
  • 函數式程序設計做爲一種範型。函數結果只依賴於入參的這一律念意味着不存在共享態和獨立執行。
  • 這個 Go 的 Request Oriented Collector 設計也有着徹底相同的猜測和理論。

全部這些狀況的想法都是想經過減小共享狀態的同時保持資源的共享來增長擴展性(不管是分佈在機器之間,線程之間或者只是代碼中)。golang

Go 採起了一個措施來靠近這個特性。但它不會像某些函數式編程語言那樣禁止或者阻礙可變狀態。它容許在線程之間共享內存並與互斥體進行同步,而不徹底依賴於通道。可是它也絕對想成爲一種(或惟一)編寫現代可擴展服務的語言。所以,它須要成爲一種很好的語言來編寫無狀態的服務,它須要至少在必定程度上可以達到請求隔離級別而不是進程隔離。web

(附註:這彷佛是上述文章做者的聲明,他聲稱上下文主要對服務做者有用。我不一樣意。通常抽象發生在不少層面。好比 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 的這一天真的來了,咱們應該問的問題是『咱們是否想要有一個規範的方法去管理請求做用域的值,其代價又是什麼』。只有這樣咱們才能開始探討最佳實現會是什麼樣的,或者是否移除它。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索