Stories

Detail Return Return

在 Go 中為什麼推薦使用空結構體作為 Context 的 key - Stories Detail

公眾號首發地址: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 對象,附加一個鍵值對(注意:這裏的 keyvalue 都是 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 可以是任意類型,那麼固然也可以是任意值。我們在寫代碼的時候,經常會用一些如 iinfodata 等作為變量,那麼我們也很有可能使用 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

結果很明顯,雖然 dataKeyuserDataKey 這兩個 key 的變量名不一樣,但是它們的值同為 data,最終導致 ctx.Value(dataKey) 返回的結果與 ctx.Value(userDataKey) 返回結果相同。

dataKey 的值已經被 userDataKey 所覆蓋。所以我才説,通過 Context 傳值並不都是“安全”的,因為你的鍵值對可能會被覆蓋。

解決 key 衝突問題

如何有效避免 Context 傳值是 key 衝突的問題呢?

最簡單的方案,就是為 key 定義一個具有業務屬性的前綴,比如用户相關的數據 keyuser-data,文章相關的數據 keypost-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

Add a new Comments

Some HTML is okay.