公眾號首發地址:https://mp.weixin.qq.com/s/TGNG34qJTI7SZOENidYBOA
我曾在《Go 中空結構體慣用法,我幫你總結全了!》一文中介紹過空結構體的多種用法,本文再來補充一種慣用法:將空結構體作為 Context 的 key 來進行安全傳值。
NOTE:
如果你對 Go 語言中的 Context 不夠熟悉,可以閲讀我的另一篇文章《Go 併發控制:context 源碼解讀》。
使用 Context 進行傳值
我們知道 Context 主要有兩種用法,控制鏈路和安全傳值。
在此我來演示下如何使用 Context 進行安全傳值:
package main
import (
"context"
"fmt"
)
const requestIdKey = "request-id"
func main() {
ctx := context.Background()
// NOTE: 通過 context 傳遞 request id 信息
// 設置值
ctx = context.WithValue(ctx, requestIdKey, "req-123")
// 獲取值
fmt.Printf("request-id: %s\n", ctx.Value(requestIdKey))
}
通過 Context 進行傳值的方式非常簡單,context.WithValue(ctx, key, value) 函數可以為一個已存在的 ctx 對象,附加一個鍵值對(注意:這裏的 key 和 value 都是 any 類型)。然後,可以使用 ctx.Value(key) 來獲取 key 對應的 value。
這裏我們使用字符串 request-id 作為 key,值為 req-123。
執行示例代碼,得到輸出如下:
$ go run main.go
request-id: req-123
題外話,之所以説 Context 可以進行安全傳值,是因為它的源碼實現是併發安全的,你可以在《Go 併發控制:context 源碼解讀》中學習其實現原理。
但是,從代碼編寫者的角度來説,通過 Context 傳值並不都是“安全”的,咱們接着往下看。
key 衝突問題
既然 Context 的 key 可以是任意類型,那麼固然也可以是任意值。我們在寫代碼的時候,經常會用一些如 i、info、data 等作為變量,那麼我們也很有可能使用 data 這樣類似的字符串值作為 Context 的 key:
package main
import (
"context"
"fmt"
)
// NOTE: 這個 key 非常容易衝突
const dataKey = "data"
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, dataKey, "some data")
fmt.Printf("data: %s\n", ctx.Value(dataKey))
userDataKey := "data" // 與 dataKey 值相同
ctx = context.WithValue(ctx, userDataKey, "user data")
fmt.Printf("user data: %s\n", ctx.Value(userDataKey))
// 再次查看 dataKey 的值
fmt.Printf("data: %s\n", ctx.Value(dataKey))
}
在這個示例中,dataKey 的值為 data,我們將其作為 key 存入 Context。稍後,又將 userDataKey 變量作為 Context 的 key 存入 Context。最終,ctx.Value(dataKey) 返回的值是什麼呢?
執行示例代碼,得到輸出如下:
$ data: some data
user data: user data
data: user data
結果很明顯,雖然 dataKey 和 userDataKey 這兩個 key 的變量名不一樣,但是它們的值同為 data,最終導致 ctx.Value(dataKey) 返回的結果與 ctx.Value(userDataKey) 返回結果相同。
即 dataKey 的值已經被 userDataKey 所覆蓋。所以我才説,通過 Context 傳值並不都是“安全”的,因為你的鍵值對可能會被覆蓋。
解決 key 衝突問題
如何有效避免 Context 傳值是 key 衝突的問題呢?
最簡單的方案,就是為 key 定義一個具有業務屬性的前綴,比如用户相關的數據 key 為 user-data,文章相關的數據 key 為 post-data:
package main
import (
"context"
"fmt"
)
// NOTE: 為了避免 key 衝突,我們通常可以為 key 定義一個業務屬性的前綴
const (
userDataKey = "user-data"
postDataKey = "post-data"
)
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, userDataKey, "user data")
fmt.Printf("user-data: %s\n", ctx.Value(userDataKey))
ctx = context.WithValue(ctx, postDataKey, "post data")
fmt.Printf("post-data: %s\n", ctx.Value(postDataKey))
}
執行示例代碼,得到輸出如下:
$ go run main.go
user-data: user data
post-data: post data
這樣不同業務的 key 就不會互相干擾了。
你也許還想到了更好的方式,比如將所有 key 定義為常量,並統一放在一個叫 constant 的包中,這也是一種很常見的解決問題的方式。
但我個人極其不推薦這種做法,雖然表面上看將所有常量統一放在一個包中,集中管理,更方便維護。但當常量一多,這個包簡直是災難。Go 更推崇將代碼中的變量、常量等定義在其使用的地方,而不是統一放在一個文件中管理,為自己增加心智負擔。
其實我們還有更加優雅的解決辦法,是時候讓空結構體登場了。
使用空結構體作為 key
Context 的 key 可以是任意類型,那麼空結構體就是絕佳方案。
我們可以使用空結構體定義一個自定義類型,然後作為 Context 的 key:
package main
import (
"context"
"fmt"
)
// NOTE: 使用空結構體作為 context key
type emptyKey struct{}
type anotherEmpty struct{}
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, emptyKey{}, "empty struct data")
fmt.Printf("empty data: %s\n", ctx.Value(emptyKey{}))
ctx = context.WithValue(ctx, anotherEmpty{}, "another empty struct data")
fmt.Printf("another empty data: %s\n", ctx.Value(anotherEmpty{}))
// 再次查看 emptyKey 對應的 value
fmt.Printf("empty data: %s\n", ctx.Value(emptyKey{}))
}
執行示例代碼,得到輸出如下:
$ go run main.go
empty data: empty struct data
another empty data: another empty struct data
empty data: empty struct data
這一次,沒有出現覆蓋情況。
空結構體作為 key 的錯誤用法
現在,我們來換一種用法,不再自定義新的類型,而是直接將空結構體變量作為 Context 的 key,示例如下:
package main
import (
"context"
"fmt"
)
// NOTE: 空結構體作為 context key 的錯誤用法
func main() {
ctx := context.Background()
key1 := struct{}{}
ctx = context.WithValue(ctx, key1, "data1")
fmt.Printf("key1 data: %s\n", ctx.Value(key1))
key2 := struct{}{}
ctx = context.WithValue(ctx, key2, "data2")
fmt.Printf("key2 data: %s\n", ctx.Value(key2))
// 再次查看 key1 對應的 value
fmt.Printf("key1 data: %s\n", ctx.Value(key1))
}
執行示例代碼,得到輸出如下:
$ go run main.go
key1 data: data1
key2 data: data2
key1 data: data2
可以發現,這次又出現了 key2 鍵值對覆蓋 key1 鍵值對的情況。
所以,你有沒有注意到,使用空結構體作為 Context 的 key,最關鍵的步驟,其實是要基於空結構體定義一個新的類型。我們使用這個新類型的實例對象作為 key,而不是直接使用空結構體變量作為 key,這二者是有本質區別的。
這是兩個不同的類型:
type emptyKey struct{}
type anotherEmpty struct{}
它們相同點只不過是二者都沒有屬性和方法,都是一個空的結構體。
而 emptyKey{} 和 anotherEmpty{} 一定不相等,因為它們是不同的類型。
這是兩個空結構體變量:
key1 := struct{}{}
key2 := struct{}{}
顯然,key1 等於 key2,因為它們的值相等,並且類型也相同。
有很多人看到將空結構體作為 Context 的 key 時,第一想法是擔心衝突,以為空結構體作為 key 時只能保存一個鍵值對,設置多個鍵值對時,後面的空結構體 key 會覆蓋之前的空結構體 key。
實則不然,每一個 key 都是一個新的類型,而非常量(const),這一點很重要,非常容易讓人誤解。
很多文章或教程並沒有強調這一點,所以導致很多人看了教程以後,覺得這個特性沒什麼用,或產生困惑。其實這也是編程的魅力所在,編程是一門非常注重實操的學科,看一遍和寫一遍完全是不同概念,這決定了你對某項技術理解程度。
使用自定義類型作為 key
既然我們在使用空結構體作為 Context 的 key 時,是定義了一個新的類型,那麼我們是否也可以使用其他自定義類型作為 Context 的 key 呢?
答案是肯定的,因為 Context 的 key 本身就是 any 類型。
使用基於 string 自定義類型,作為 Context 的 key 示例如下:
package main
import (
"context"
"fmt"
)
// NOTE: 基於 string 自定義類型,作為 context key
type key1 string
type key2 string
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, key1(""), "data1")
fmt.Printf("key1 data: %s\n", ctx.Value(key1("")))
ctx = context.WithValue(ctx, key2(""), "data2")
fmt.Printf("key2 data: %s\n", ctx.Value(key2("")))
// 再次查看 key1 對應的 value
fmt.Printf("key1 data: %s\n", ctx.Value(key1("")))
}
那麼你認為這段代碼執行結果如何呢?這就交給你自行去測試了。
項目實戰
上面介紹了幾種可以作為 Context 的 key 進行傳值的做法,有推薦做法,也有踩坑做法。
接下來我們一起看下真實的企業級項目 OneX 中是如何定義和使用 Context 進行安全傳值的。
使用空結構體作為 key 在 Context 中傳遞事務
OneX 中巧妙的使用了 Context 來傳遞 GORM 的事務對象 tx,其實現如下:
https://github.com/onexstack/onex/blob/feature/onex-v2/internal/usercenter/store/store.go#L40
// transactionKey is the key used to store transaction context in context.Context.
type transactionKey struct{}
// NewStore initializes a singleton instance of type IStore.
// It ensures that the datastore is only created once using sync.Once.
func NewStore(db *gorm.DB) *datastore {
// Initialize the singleton datastore instance only once.
once.Do(func() {
S = &datastore{db}
})
return S
}
// DB filters the database instance based on the input conditions (wheres).
// If no conditions are provided, the function returns the database instance
// from the context (transaction instance or core database instance).
func (store *datastore) DB(ctx context.Context, wheres ...where.Where) *gorm.DB {
db := store.core
// Attempt to retrieve the transaction instance from the context.
if tx, ok := ctx.Value(transactionKey{}).(*gorm.DB); ok {
db = tx
}
// Apply each provided 'where' condition to the query.
for _, whr := range wheres {
db = whr.Where(db)
}
return db
}
// TX starts a new transaction instance.
// nolint: fatcontext
func (store *datastore) TX(ctx context.Context, fn func(ctx context.Context) error) error {
return store.core.WithContext(ctx).Transaction(
func(tx *gorm.DB) error {
ctx = context.WithValue(ctx, transactionKey{}, tx)
return fn(ctx)
},
)
}
OneX 的 store 層用來操作數據庫進行 CRUD,你可以閲讀令飛老師的《簡潔架構設計:如何設計一個合理的軟件架構?》這篇文章來了解 OneX 的架構設計。
在 store 的源碼中定義了空結構體類型 transactionKey 作為 Context 的 key,在調用 store.TX 方法時,方法內部會將事務對象 tx 作為 value 保存到 Context 中。
在調用 store.DB 對數據庫進行操作時,就會優先判斷當前是否處於事務中,如果 ctx.Value(transactionKey{}) 有值,則説明當前事務正在進行,使用 tx 對象繼續操作,否則説明是一個簡單的數據庫操作,直接返回 db 對象。
使用示例如下:
https://github.com/onexstack/onex/blob/feature/onex-v2/internal/usercenter/biz/v1/user/user.go#L69
// Create implements the Create method of the UserBiz.
func (b *userBiz) Create(ctx context.Context, rq *v1.CreateUserRequest) (*v1.CreateUserResponse, error) {
var userM model.UserM
_ = core.Copy(&userM, rq) // Copy request data to the User model.
// Start a transaction for creating the user and secret.
err := b.store.TX(ctx, func(ctx context.Context) error {
// Attempt to create the user in the data store.
if err := b.store.User().Create(ctx, &userM); err != nil {
// Handle duplicate entry error for username.
match, _ := regexp.MatchString("Duplicate entry '.*' for key 'username'", err.Error())
if match {
return v1.ErrorUserAlreadyExists("user %q already exists", userM.Username)
}
return v1.ErrorUserCreateFailed("create user failed: %s", err.Error())
}
// Create a secret for the newly created user.
secretM := &model.SecretM{
UserID: userM.UserID,
Name: "generated",
Expires: 0,
Description: "automatically generated when user is created",
}
if err := b.store.Secret().Create(ctx, secretM); err != nil {
return v1.ErrorSecretCreateFailed("create secret failed: %s", err.Error())
}
return nil
})
if err != nil {
return nil, err // Return any error from the transaction.
}
return &v1.CreateUserResponse{UserID: userM.UserID}, nil
}
當創建用户對象 user 時,會同步創建一條 secret 記錄,此時就用到了事務。
b.store.TX 用來開啓事務,b.store.User().Create() 創建 user 對象時,Create() 方法內部,其實會返回 tx 對象,對於b.store.Secret().Create() 的調用同理,這樣就完成了事務操作。
OneX 實際上實現了一個泛型版本的 Create() 方法:
https://github.com/onexstack/onex/blob/feature/onex-v2/staging/src/github.com/onexstack/onexstack/pkg/store/store.go#L63
// Create inserts a new object into the database.
func (s *Store[T]) Create(ctx context.Context, obj *T) error {
if err := s.db(ctx).Create(obj).Error; err != nil {
s.logger.Error(ctx, err, "Failed to insert object into database", "object", obj)
return err
}
return nil
}
// db retrieves the database instance and applies the provided where conditions.
func (s *Store[T]) db(ctx context.Context, wheres ...where.Where) *gorm.DB {
dbInstance := s.storage.DB(ctx)
for _, whr := range wheres {
if whr != nil {
dbInstance = whr.Where(dbInstance)
}
}
return dbInstance
}
無論是調用 b.store.User().Create(),還是調用 b.store.Secret().Create(),其實最終都會調用此方法,而 s.db(ctx) 內部又調用了 s.storage.DB(ctx),這裏的 DB 方法,其實就是 *datastor.DB。
至此,在 OneX 中使用空結構體作為 key 在 Context 中傳遞事務的主體脈絡就理清了。如果你對 OneX 整體架構不夠清晰,可能對這部分的講解比較困惑,那麼可以看看其源碼,這是一個非常優秀的開源項目。
contextx 包
此外,OneX 項目還專門抽象出一個 contextx 包,用來定義公共的 Context 操作。
比如可以使用 Context 傳遞 userID:
https://github.com/onexstack/onex/blob/feature/onex-v2/internal/pkg/contextx/contextx.go
type (
userKey struct{}
...
)
// WithUserID put userID into context.
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userKey{}, userID)
}
// UserID extract userID from context.
func UserID(ctx context.Context) string {
userID, _ := ctx.Value(userKey{}).(string)
return userID
}
...
contextx 包中有很多類似實現,你可以查看源碼學習更多使用技巧。
總結
本文講解了在 Go 中使用空結構體作為 Context 的 key 進行安全傳值的小技巧。雖然這是一個不太起眼的小技巧,並且面試中也不會被問到,但正是這些微小的細節,決定了你寫的代碼最終質量。如果你想寫出優秀的項目,那麼每一個細節都值得深入思考,找到更優解。
使用 Context 對象傳值時,其 key 可以是任意類型,那麼為什麼使用空結構體是更好的選擇呢?因為空結構體不佔內存空間,並且滿足了唯一性的要求,所以空結構體是最優解。
並且,我們還可以像 contextx 包那樣,將常用的傳值操作放在一起,對外只暴露 Set/Get 兩個方法,而將 Context 的 key 定義為未導出(unexported)類型,那麼就不可能出現 key 衝突的情況。
當然,我們應該儘量避免使用 Context 來傳遞值,只在必要時使用。顯式勝於隱式,當隱式代碼變多,也將是災難。
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
- 本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/struct/empty/context-key
- 本文永久地址:https://jianghushinian.cn/2025/06/08/empty-struct-as-key-of-ctx/
聯繫我
- 公眾號:Go編程世界
- 微信:jianghushinian
- 郵箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn
- GitHub:https://github.com/jianghushinian