[譯]為什麼context.Value重要,如何進行改進
- 原文地址:Why context.Value matters and how to improve it
- 原文作者:Axel Wagner
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:星辰
- 校對者:lsvih,leviding
覺得文章太長可以看這裡:我認為 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
的這一天真的來了,我們應該問的問題是『我們是否想要有一個規範的方法去管理請求作用域的值,其代價又是什麼』。只有這樣我們才能開始探討最佳實現會是什麼樣的,或者是否移除它。
相關文章
- [譯] 一文教你什麼是漸進增強,為什麼它很重要?
- 譯文 | 為什麼軟體架構如此重要?架構
- ITAM是什麼?為什麼它很重要?
- 為什麼DNS安全很重要DNS
- 為什麼Lisp語言如此先進?(譯文)Lisp
- 為什麼凸優化這麼重要?優化
- [譯] 為什麼 Flutter 能最好地改變移動開發Flutter移動開發
- 為什麼那麼多人都想進入IT行業?行業
- 什麼是客戶分析,為什麼它很重要?
- 為什麼去中心化很重要?中心化
- 為什麼Web3如此重要?Web
- 為什麼小資料更重要?
- 轉-為什麼Lisp語言如此先進?(譯文)Lisp
- 為什麼要早點進入IT行業?行業
- 為什麼資料備份那麼重要?
- 什麼是Python解包?如何進行解包?Python
- 對於重要的資料檔案,用什麼方法進行加密?加密
- 為什麼代理伺服器很重要?伺服器
- 為什麼區塊鏈橋很重要?區塊鏈
- Java對Internet為什麼重要(轉)Java
- iOS應用加固為什麼也那麼重要?iOS
- 企業為什麼要進行專案控制?
- 為什麼要選擇代理來進行抓取?
- 什麼是翻譯平臺最重要的地方?
- 我們為什麼需要原型設計,該如何進行原型設計呢?原型
- 雲同步: 什麼是雲同步以及為什麼它是如此重要?
- 什麼是DNS劫持?如何進行有效應對?DNS
- [譯] Robinhood 為什麼使用 AirflowAI
- 五個為什麼(譯文)
- 如何進行 Python效能分析,你才能如魚得水?Python
- 為什麼特徵相關性非常的重要?特徵
- 為什麼企業資料整合很重要
- 為什麼資料視覺化很重要視覺化
- 為什麼進行統計分析執行效率反而更差呢?
- 為什麼Scrum變得不那麼重要了? - LogRocketScrum
- 為什麼需要定期進行伺服器備份?伺服器
- 為什麼需要用代理進行網頁抓取?網頁
- []==''返回?為什麼?運算子==進行了什麼操作?