您現(xiàn)在的位置:首頁 > 綜合 > 特別關(guān)注 > 正文

[Golang]正確使用Context 環(huán)球新視野

時(shí)間:2023-03-15 12:19:17    來源:騰訊云    

01 為什么要引入Context

context.Context是Go中定義的一個(gè)接口類型,從1.7版本中開始引入。其主要作用是在一次請(qǐng)求經(jīng)過的所有協(xié)程或函數(shù)間傳遞取消信號(hào)及共享數(shù)據(jù),以達(dá)到父協(xié)程對(duì)子協(xié)程的管理和控制的目的。

需要注意的是context.Context的作用范圍是一次請(qǐng)求的生命周期,即隨著請(qǐng)求的產(chǎn)生而產(chǎn)生,隨著本次請(qǐng)求的結(jié)束而結(jié)束。如圖所示:

02 什么是context.Context

在context包中,我們看到context.Context的定義實(shí)際上是一個(gè)接口類型,該接口定義了獲取上下文的Deadline的函數(shù),根據(jù)key獲取value值的函數(shù)、還有獲取done通道的函數(shù)。如下:


(資料圖片僅供參考)

typeContextinterface{Deadline()(deadlinetime.Time,okbool)Done()<-chanstruct{}Err()error  Value(key interface{}) interface{}}

由定義的接口函數(shù)可知,對(duì)于傳遞取消信號(hào)的行為我們可以描述為:當(dāng)協(xié)程運(yùn)行時(shí)間達(dá)到Deadline時(shí),就會(huì)調(diào)用取消函數(shù),關(guān)閉done通道,往done通道中輸入一個(gè)空結(jié)構(gòu)體消息struct{}{},這時(shí)所有監(jiān)聽done通道的子協(xié)程都會(huì)收到該消息,便知道父協(xié)程已經(jīng)關(guān)閉,需要自己也結(jié)束運(yùn)行。

下面是一個(gè)使用Context的簡(jiǎn)易示例,我們通過該示例來說明父子協(xié)程之間是如何傳遞取消信號(hào)的。

func main() {    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)    defer cancel()    go doSomethingCool(ctx)    select {    case <-ctx.Done():        fmt.Println("oh no, I"ve exceeded the deadline")    }}func doSomethingCool(ctx context.Context) {    for {    select {    case <-ctx.Done():      fmt.Println("timed out")      return    default:      fmt.Println("doing something cool")    }    time.Sleep(500 * time.Millisecond)    }}

由示例可知,main協(xié)程和doSomething函數(shù)之間的唯一關(guān)聯(lián)就是ctx.Done()。當(dāng)子協(xié)程從ctx.Done()通道中接收到輸出時(shí)(因?yàn)槌瑫r(shí)自動(dòng)取消或主動(dòng)調(diào)用了cancel函數(shù)),即認(rèn)為是父協(xié)程不再需要子協(xié)程返回的結(jié)果了,子協(xié)程就會(huì)直接返回,不再執(zhí)行其他的邏輯。

03 Context的作用一:協(xié)程間傳遞信號(hào)

3.1 如何創(chuàng)建帶可以傳遞信號(hào)的Context

在開頭處我們得知Context本質(zhì)是一個(gè)接口類型。接口類型是需要具體的結(jié)構(gòu)體起來實(shí)現(xiàn)的。那我們需要自定義結(jié)構(gòu)體類型來實(shí)現(xiàn)這些接口嗎?答案是不需要。因?yàn)樵赾ontext包中已經(jīng)定義好了所需場(chǎng)景的結(jié)構(gòu)體,這些結(jié)構(gòu)體已經(jīng)幫我們實(shí)現(xiàn)了Context接口的方法,在項(xiàng)目中就已經(jīng)夠用了。

在context包中定義有emptyCtx、cancelCtx、timerCtx、valueCtx

四種結(jié)構(gòu)體。其中cancelCtx、timerCtx實(shí)現(xiàn)了給子協(xié)程傳遞取消信號(hào)。valueCtx結(jié)構(gòu)體實(shí)現(xiàn)了父協(xié)程和子協(xié)程傳遞共享數(shù)據(jù)相關(guān)。本節(jié)我們重點(diǎn)來看跟傳遞信號(hào)相關(guān)的Context。

在上面示例中,我們通過context.WithTimeout函數(shù)創(chuàng)建了一個(gè)帶定時(shí)取消功能的Context實(shí)例,該示例本質(zhì)上是創(chuàng)建了一個(gè)timerCtx結(jié)構(gòu)體的實(shí)例。在context包中還有WithCancel、WithDeadline函數(shù)也可以創(chuàng)建對(duì)應(yīng)的結(jié)構(gòu)體,其定義如下:

//創(chuàng)建帶有取消功能的Contextfunc WithCancel(parent Context) (ctx Context, cancel CancelFunc) //創(chuàng)建帶有定時(shí)自動(dòng)取消功能的Contextfunc WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)//創(chuàng)建帶有定時(shí)自動(dòng)取消功能的Contextfunc WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

對(duì)應(yīng)的函數(shù)創(chuàng)建的結(jié)構(gòu)體及該實(shí)例所實(shí)現(xiàn)的功能的主要特點(diǎn)如下圖所示:

在圖中我們看到結(jié)構(gòu)體依次是繼承關(guān)系。因?yàn)樵赾ancelCtx結(jié)構(gòu)體內(nèi)嵌套了Context(實(shí)際上是emptyCtx)、timerCtx結(jié)構(gòu)體內(nèi)嵌套了cancelCtx結(jié)構(gòu)體,可以認(rèn)為他們之間存在繼承關(guān)系。

通過WithTimeout和WithDealine函數(shù)創(chuàng)建的Context實(shí)際上都是timerCtx結(jié)構(gòu)體,唯一的區(qū)別就是WithDeadline函數(shù)的第二個(gè)參數(shù)指定的是最后的時(shí)間點(diǎn),而WithTimeout函數(shù)的第二個(gè)參數(shù)是一段時(shí)間。但WithDealine在內(nèi)部實(shí)現(xiàn)中本質(zhì)上也是將時(shí)間點(diǎn)轉(zhuǎn)換成距離當(dāng)前的時(shí)間段。

3.2 為什么Done函數(shù)返回值是通道

在Context接口的定義中我們看到Done函數(shù)的定義,其返回值是一個(gè)輸出通道:

Done() <-chan struct{}

在上面的示例中我們看到的子協(xié)程是通過監(jiān)聽Context的Done()函數(shù)返回的通道來判斷父協(xié)程是否發(fā)送了取消信號(hào)的。當(dāng)父協(xié)程調(diào)用取消函數(shù)時(shí),該取消函數(shù)將該通道關(guān)閉。關(guān)閉通道相當(dāng)于是一個(gè)廣播信息,當(dāng)監(jiān)聽該通道的接收者從通道到中接收完最后一個(gè)元素后,接收者都會(huì)解除阻塞,并從通道中接收到通道元素類型的零值。

既然父子協(xié)程是通過通道傳到信號(hào)的。下面我們介紹父協(xié)程是如何將信號(hào)通過通道傳遞給子協(xié)程的。

3.3 父協(xié)程是如何取消子協(xié)程的

我們發(fā)現(xiàn)在Context接口中并沒有定義Cancel方法。實(shí)際上通過WithCancel函數(shù)創(chuàng)建的一個(gè)具有可取消功能的Context實(shí)例來實(shí)現(xiàn)的:

// WithCancel returns a copy of parent whose Done channel is closed as soon as// parent.Done is closed or cancel is called.func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {  if parent == nil {    panic("cannot create context from nil parent")  }  c := newCancelCtx(parent)  propagateCancel(parent, &c)  return &c, func() { c.cancel(true, Canceled) }}

WithCancel函數(shù)的返回值有兩個(gè),一個(gè)是ctx,一個(gè)是取消函數(shù)cancel。當(dāng)父協(xié)程調(diào)用cancel函數(shù)時(shí),就相當(dāng)于觸發(fā)了關(guān)閉的動(dòng)作,在cancel的執(zhí)行邏輯中會(huì)將ctx的done通道關(guān)閉,然后所有監(jiān)聽該通道的子協(xié)程就會(huì)收到一個(gè)struct{}類型的零值,子協(xié)程根據(jù)此便執(zhí)行了返回操作。下面是cancel函數(shù)實(shí)現(xiàn):

// cancel closes c.done, cancels each of c"s children, and, if// removeFromParent is true, removes c from its parent"s children.func (c *cancelCtx) cancel(removeFromParent bool, err error) {  //...  d, _ := c.done.Load().(chan struct{})//獲取通道  if d == nil {    c.done.Store(closedchan)  } else {    close(d) //關(guān)閉通道done  }  //...}

由源碼可知,cancelCtx的cancel函數(shù)執(zhí)行時(shí)會(huì)關(guān)閉通道close(d)。

通過WithCancel函數(shù)構(gòu)造的Context,需要開發(fā)者自己設(shè)定調(diào)用取消函數(shù)的條件。而在某些場(chǎng)景下需要設(shè)定超時(shí)時(shí)間,比如調(diào)用grpc服務(wù)時(shí)設(shè)置超時(shí)時(shí)間,那么實(shí)際上就是在構(gòu)造Context的同時(shí),啟動(dòng)一個(gè)定時(shí)任務(wù),當(dāng)達(dá)到設(shè)定的定時(shí)時(shí)間時(shí),就自動(dòng)調(diào)用cancel函數(shù)即可。這就是context包中提供的WithDeadline和WithTimeout函數(shù)來構(gòu)造的上下文。如下是WithDeadline函數(shù)的關(guān)鍵實(shí)現(xiàn)部分:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {  //...  c := &timerCtx{    cancelCtx: newCancelCtx(parent),    deadline:  d,  }  propagateCancel(parent, c)  dur := time.Until(d)  //...  if c.err == nil {        //這里實(shí)現(xiàn)定時(shí)器,即dur時(shí)間后執(zhí)行cancel函數(shù)    c.timer = time.AfterFunc(dur, func() {      c.cancel(true, DeadlineExceeded)    })  }  return c, func() { c.cancel(true, Canceled) }}

WithTimeout函數(shù)也是將相對(duì)時(shí)間timeout轉(zhuǎn)換成絕對(duì)的時(shí)間點(diǎn)deadline之后,調(diào)用的WithDeadline函數(shù)。

3.4為什么要通過WithXXX函數(shù)構(gòu)造一個(gè)樹形結(jié)構(gòu)

很多文章都說,通過WithXXX函數(shù)基于Context會(huì)衍生出一個(gè)Context樹,樹的每個(gè)節(jié)點(diǎn)都可以有任意多個(gè)子節(jié)點(diǎn)Context。如下圖表示:

那為什么要構(gòu)造一個(gè)樹形結(jié)構(gòu)呢?我們從處理一個(gè)請(qǐng)求時(shí)經(jīng)過的多個(gè)協(xié)程來角度來理解會(huì)更容易一些。當(dāng)一個(gè)請(qǐng)求到來時(shí),該請(qǐng)求會(huì)經(jīng)過很多個(gè)協(xié)程的處理,而這些協(xié)程之間的關(guān)系實(shí)際上就組成了一個(gè)樹形結(jié)構(gòu)。如下圖:

Context的目的就是為了在關(guān)聯(lián)的協(xié)程間傳遞信號(hào)和共享數(shù)據(jù)的,而每個(gè)協(xié)程又只能管理自己的子節(jié)點(diǎn),而不能管理父節(jié)點(diǎn)。所以,在整個(gè)處理過程中,Context自然就衍生成了樹形結(jié)構(gòu)。

3.5為什么WithXXX函數(shù)返回的是一個(gè)新的Context對(duì)象

通過WithXXX的源碼可以看到,每個(gè)衍生函數(shù)返回來的都是一個(gè)新的Context對(duì)象,并且都是基于parent Context的。以WithDeadline為例,就是返回的一個(gè)timerCtx新的結(jié)構(gòu)體實(shí)例。這是因?yàn)?,在Context的傳遞過程中,每個(gè)協(xié)程都能根據(jù)自己的需要來定制Context(例如,在上圖中,main協(xié)程調(diào)用goroutine2時(shí)要求是600毫秒完成操作,但goroutine2調(diào)用goroutine2.1時(shí),要求是500毫秒內(nèi)完成操作),而這些修改又不能影響之前已經(jīng)調(diào)用的函數(shù),只能對(duì)向下傳遞。所以,通過一個(gè)新的Context值來進(jìn)行傳遞。

04 Context的作用二:協(xié)程間共享數(shù)據(jù)

Context的另外一個(gè)功能就是在協(xié)程間共享數(shù)據(jù)。該功能是通過WithValue函數(shù)構(gòu)造的Context來實(shí)現(xiàn)的。我們看下WithValue的實(shí)現(xiàn):

func WithValue(parent Context, key, val interface{}) Context {  if parent == nil {    panic("cannot create context from nil parent")  }  if key == nil {    panic("nil key")  }  if !reflectlite.TypeOf(key).Comparable() {    panic("key is not comparable")  }  return &valueCtx{parent, key, val}}

實(shí)現(xiàn)代碼很簡(jiǎn)短,我們看到最終返回的是一個(gè)valueCtx結(jié)構(gòu)體實(shí)例。其中有兩點(diǎn):一是key的類型必須是可比較的。二是value是不能修改的,即具有不可變性。如果需要添加新的值,只能通過WithValue基于原有的Context再生成一個(gè)新的valueCtx來攜帶新的key-value。這也是Context的值在傳遞過程中是并發(fā)安全的原因。從另外一個(gè)角度來說,在獲取一個(gè)key的值的時(shí)候,也是遞歸的一層一層的從下往上查找,如下:

func (c *valueCtx) Value(key interface{}) interface{} {  if c.key == key {    return c.val  }  return c.Context.Value(key)}

上面簡(jiǎn)單介紹了下在協(xié)程間調(diào)用的時(shí)候是如何通過Context共享數(shù)據(jù)的。

但這里討論的重點(diǎn)是什么樣的數(shù)據(jù)需要通過Context來共享,而不是通過傳參的方式。總結(jié)下來有以下兩點(diǎn):

攜帶的數(shù)據(jù)作用域必須是在請(qǐng)求范圍內(nèi)有效的。即該數(shù)據(jù)隨著請(qǐng)求的產(chǎn)生而產(chǎn)生,隨著請(qǐng)求的結(jié)束而結(jié)束,不會(huì)永久的保存。攜帶的數(shù)據(jù)不建議是關(guān)鍵參數(shù),關(guān)鍵參數(shù)應(yīng)顯式的通過參數(shù)來傳遞。例如像trace_id之類的,用于維護(hù)作用,就適合用在Context中傳遞。

4.1 什么是請(qǐng)求范圍(request-scoped)內(nèi)的數(shù)據(jù)

這個(gè)沒有一個(gè)明顯的劃定標(biāo)準(zhǔn)。一般的請(qǐng)求范圍的數(shù)據(jù)就是用來表示該請(qǐng)求的元數(shù)據(jù)。比如該請(qǐng)求是由誰發(fā)出(即user id),該請(qǐng)求是在哪兒發(fā)出的(即user ip,請(qǐng)求是從該用戶的ip位置發(fā)出的)。

例如,如果一個(gè)日志對(duì)象logger是一個(gè)單例那么它也不是一個(gè)請(qǐng)求范圍內(nèi)的數(shù)據(jù)。但如果該logger包含了發(fā)送請(qǐng)求的來源信息,以及該請(qǐng)求是否啟動(dòng)了調(diào)試功能的開關(guān)信息,那么該logger也可以被認(rèn)為是一個(gè)請(qǐng)求范圍內(nèi)的數(shù)據(jù)。

4.2 使用Context.Value的缺點(diǎn)

使用Context.Value會(huì)對(duì)降低函數(shù)的可讀性和表達(dá)性。例如,下面是使用Context.Value來攜帶token驗(yàn)證角色的示例:

func IsAdminUser(ctx context.Context) bool {  x := token.GetToken(ctx)  userObject := auth.AuthenticateToken(x)  return userObject.IsAdmin() || userObject.IsRoot()}

當(dāng)用戶調(diào)用該函數(shù)的時(shí)候,僅僅知道該函數(shù)帶有一個(gè)Context類型的參數(shù)。但如果要判斷一個(gè)用戶是否是Admin必須要兩部分要說明:一個(gè)是驗(yàn)證過的token,一個(gè)是認(rèn)證服務(wù)。

我們將該函數(shù)的Context移除,然后使用參數(shù)的方式來重構(gòu),如下:

func IsAdminUser(token string, authService AuthService) bool {  x := token.GetToken(ctx)  userObject := auth.AuthenticateToken(x)  return userObject.IsAdmin() || userObject.IsRoot()}

那么這個(gè)函數(shù)的可讀性和表達(dá)性就比重構(gòu)前提高了很多。調(diào)用者通過函數(shù)簽名就很容易知道要判斷一個(gè)用戶是否是AdminUser,只需要傳入token和認(rèn)證的服務(wù)authService即可。

4.3 context.Value的使用場(chǎng)景

一般復(fù)雜的項(xiàng)目都會(huì)有中間件層以及大量的抽象層。如果將類似token或userid這樣簡(jiǎn)單的參數(shù)以參數(shù)的方式從第一個(gè)函數(shù)層層傳遞,那對(duì)調(diào)用者來說將會(huì)是一種噩夢(mèng)。如果將這樣的元數(shù)據(jù)通過Context來攜帶進(jìn)行傳遞,將會(huì)是比較好的方式。在實(shí)際項(xiàng)目中,最常用的就是在中間件中。我們以iris為web框架,來看下在中間件中的應(yīng)用:

package mainimport (  "context"  "github.com/google/uuid"  "github.com/kataras/iris/v12")func main() {  app := iris.New()  app.Use(RequestIDMiddleware)  app.Get("/hello", mainHandler)  app.Listen("localhost:8080", iris.WithOptimizations)}func RequestIDMiddleware(c iris.Context) {  reqID := uuid.New()  ctx := context.WithValue(c.Request().Context(), "req_id", reqID)  req := c.Request().Clone(ctx)  c.ResetRequest(req)  c.Next()}func mainHandler(ctx iris.Context) {  req_id := ctx.Request().Context().Value("req_id")  ctx.Writef("Hello request id:%s", req_id)  return}

05 總結(jié)

context包是go語言中的一個(gè)重要的特性。要想正確的在項(xiàng)目中使用context,理解其背后的工作機(jī)制以及設(shè)計(jì)意圖是非常重要的。context包定義了一個(gè)API,它提供對(duì)截止日期、取消信號(hào)和請(qǐng)求范圍值的支持,這些值可以跨API以及在Goroutine之間傳遞。

關(guān)鍵詞:

上一篇:
下一篇:

凡本網(wǎng)注明“XXX(非中國微山網(wǎng))提供”的作品,均轉(zhuǎn)載自其它媒體,轉(zhuǎn)載目的在于傳遞更多信息,并不代表本網(wǎng)贊同其觀點(diǎn)和其真實(shí)性負(fù)責(zé)。

特別關(guān)注