云原生系列Go语言篇上下文

2023年 7月 19日 33.6k 0

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

服务端需要一种处理单个请求元数据的方式。这些元数据可以分为两大类别:一种是在正确处理请求时所需的元数据,另一种是关于何时停止处理请求的元数据。例如,HTTP服务器可能希望使用追踪ID来标识一系列通过一组微服务的请求。它还可能希望设置一个计时器,在对其他微服务的请求时间过长时,就结束这些请求。很多语言使用threadlocal变量来存储此类信息,将数据关联到特定的操作系统执行线程。但在Go语言中,这种方式不可行,因为goroutine没有可以用来查找值的唯一标识。更重要的是,线程本地变量不够清晰,值放在一个地方,却在另一个地方弹出。

Go语言通过一种称为context的结构来解决请求元数据的问题。我们来看如何正确使用它。

上下文是什么

与向语言添加新功能不同,上下文(context)只是实现了context包中的Context接口的一个实例。我们知道,地道的Go语言鼓励通过函数参数显式传递数据。上下文也是如此。它只是函数中的另一个参数。就像Go语言约定函数的最后一个返回值是error一样,还有另一个Go语言的约定,即上下文作为函数的第一个参数在程序中显式传递。上下文参数常命名为ctx

func logic(ctx context.Context, info string) (string, error) {
    // do some interesting stuff here
    return "", nil
}

除了定义Context接口之外,context包还包含了一些用于创建和封装上下文的工厂函数。在没有现成上下文时,比如在命令行程序的入口,可以使用函数context.Background创建一个空的初始上下文。它返回一个context.Context类型的变量。(是的,这是函数调用返回具体类型的常规模式的一个例外。)

空上下文是一个起点;每次向上下文中添加元数据时,你需要使用context包中的工厂函数来封装现有的上下文:

注:还有另一个函数context.TODO,也创建一个空的context.Context。它用于在开发时临时使用。如果你不确定上下文来自哪里或如何使用它,可以使用context.TODO在代码中放置一个占位符。生产代码不应包含context.TODO

在编写HTTP服务器时,需要使用稍微不同的模式来获取和传递上下文,通过多层中间件将其传递到顶层的http.Handler。只是,上下文是有了net/http很久之后才添加到Go API中的。由于兼容性承诺,无法更改http.Handler接口来添加context.Context参数。

兼容性承诺允许向现有类型添加新方法,这正是Go团队所做的。http.Request上有两个与上下文相关的方法:

  • Context返回与请求关联的context.Context
  • WithContext接收context.Context,并返回一个组合了旧请求状态和context.Context的新http.Request

通常模式如下:

func Middleware(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        // wrap the context with stuff -- you'll see how soon!
        req = req.WithContext(ctx)
        handler.ServeHTTP(rw, req)
    })
}

在我们的中间件中,首先使用Context方法从请求中提取现有的上下文。(如果你想跳过这部分,在值小节中会讲到如何将值放入上下文中。)在将值放入上下文后,我们使用WithContext方法基于旧请求和当前已填充的上下文创建新请求。最后,调用handler,并将新请求和现有的http.ResponseWriter传递给它。

在实现handler时,使用Context方法从请求中提取上下文,并以上下文作为第一个参数调用业务逻辑,就像我们之前学习的那样:

func handler(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    err := req.ParseForm()
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    data := req.FormValue("data")
    result, err := logic(ctx, data)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    rw.Write([]byte(result))
}

在应用程序中从一个HTTP服务向另一个服务发起HTTP调用时,可以使用net/http包中的NewRequestWithContext函数构建包含现有上下文信息的请求:

type ServiceCaller struct {
    client *http.Client
}

func (sc ServiceCaller) callAnotherService(ctx context.Context, data string)
                                          (string, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
                "http://example.com?data="+data, nil)
    if err != nil {
        return "", err
    }
    resp, err := sc.client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("Unexpected status code %d",
                              resp.StatusCode)
    }
    // do the rest of the stuff to process the response
    id, err := processResponse(resp.Body)
    return id, err
}

这段代码位于第十四章代码仓库中的sample_code/context_patterns目录中。

我们已经学习了如何获取和传递上下文,下面就开始使用它。先从传值开始。

默认情况下,更应通过显式参数传递数据。之前提到过,Go 语言鼓励使用显式而非隐式,其中包括显式数据传递。如果一个函数依赖于某些数据,应该清楚数据来自哪里。

然而,有些情况下我们无法显式传递数据。最常见的情况是 HTTP 请求handler及相关中间件。正如我们所见,所有的 HTTP 请求处理程序都有两个参数,一个是请求,另一个是响应。如果你想在中间件中使某个值对处理程序可用,需要将它存储在上下文中。可能包括从 JWT(JSON Web Token)中提取用户信息,或者创建一个在多层中间件和处理程序以及业务逻辑间传递的各请求的 GUID。

有一个用于将值放入上下文的工厂方法,context.WithValue。它接受三个值:上下文、用于查找值的键,以及值本身。键和值参数的类型声明为类型anycontext.WithValue函数返回一个上下文,但并不是传入函数的那个上下文。相反,它是一个包含键值对并封装传入的父上下文(context.Context)的子上下文。

注: 我们将多次看到这种封装模式。上下文被视为不可变实例。每当我们向上下文添加信息时,都是通过将现有的父上下文封装子上下来实现的。这使我们可以使用上下文将信息传递到更深层的代码中。上下文从不用于将信息从深层传递到更高层。

context.Context上中的 Value 方法会检查上下文或其父上下文中是否存在某个值。该方法接受一个键(key)并返回与该键关联的值。同样,键参数和值结果的声明类型都是any。如果找不到所提供键的值,则返回nil。使用逗号ok语法将返回的值断言为正确的类型:

    ctx := context.Background()
    if myVal, ok := ctx.Value(myKey).(int); !ok {
        fmt.Println("no value")
    } else {
        fmt.Println("value:", myVal)
    }

注: 读者如果熟悉数据结构,你可能会认识到在上下文链中搜索存储的值是一种线性搜索。在只有少量值时,这不会对性能产生严重影响,但如果在请求期间将几十个值存储在上下文中,性能将会很差。也就是说,如果你的程序在创建包含几十个值的上下文链,那么可能需要进行一些重构。

上下文中存储的值可以是任意类型,但选择正确的键非常重要。就像map的键一样,上下文值的键必须是可比较的。不要只使用像"id"这样的字符串作为键。如果使用字符串或其他预定义或导出类型作为键的类型,不同的包可能会创建相同的键,导致冲突。这会很难调试,比如一个包向上下文中写入数据,覆盖了另一个包写入的数据,或者从上下文中读取由另一个包写入的数据。

有一种地道的模式可以确保键是唯一且可比较的。基于int创建一个新的、未导出的键类型:

type userKey int

在声明你的未导出键类型之后,可以声明一个未导出的该类型的常量:

const (
    _ userKey = iota
    key
)

由于类型和该类型化常量均未导出的,来自包外的代码无法向上下文中放入可能引发冲突的数据。如果你的包需要将多个值放入上下文中,使用我们在错误处理中介绍的 iota 模式为每个值定义一个相同类型的不同键。由于我们只关心用常量的值区分多个键的方法,这是一种iota的完美用法。

接下来,构建一个 API 来将值放入上下文并从上下文中读取值。仅在包外的代码需要读取和写入上下文值时,才将这些函数设为公开。创建带有值的上下文的函数名称应该以 ContextWith 开头。从上下文中返回值的函数名称应该以 FromContext 结尾。以下是从上下文中获取和读取用户的函数实现示例:

func ContextWithUser(ctx context.Context, user string) context.Context {
    return context.WithValue(ctx, key, user)
}

func UserFromContext(ctx context.Context) (string, bool) {
    user, ok := ctx.Value(key).(string)
    return user, ok
}

现在我们已经编写了用户管理代码,来看如何使用它。我们将编写一个中间件,从 cookie 中提取用户ID:

// a real implementation would be signed to make sure
// the user didn't spoof their identity
func extractUser(req *http.Request) (string, error) {
    userCookie, err := req.Cookie("identity")
    if err != nil {
        return "", err
    }
    return userCookie.Value, nil
}

func Middleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        user, err := extractUser(req)
        if err != nil {
            rw.WriteHeader(http.StatusUnauthorized)
            rw.Write([]byte("unauthorized"))
            return
        }
        ctx := req.Context()
        ctx = ContextWithUser(ctx, user)
        req = req.WithContext(ctx)
        h.ServeHTTP(rw, req)
    })
}

在该中间件中,我们首先获取用户值。接下来,使用 Context 方法从请求中提取上下文,并使用 ContextWithUser 函数创建一个包含用户的新上下文。在包装上下文时,复用 ctx 变量名是惯用方式。然后,我们使用 WithContext 方法从旧请求和新上下文创建一个新请求。最后,我们使用新请求和传入的 http.ResponseWriter调用处理程序链中的下一个函数。

在大多数情况下,我们希望在请求处理程序中从上下文中提取值,并显式地传递给业务逻辑。Go 函数具有显式参数,不应使用上下文作为绕过 API 传递值的方式:

func (c Controller) DoLogic(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    user, ok := identity.UserFromContext(ctx)
    if !ok {
        rw.WriteHeader(http.StatusInternalServerError)
        return
    }
    data := req.URL.Query().Get("data")
    result, err := c.Logic.BusinessLogic(ctx, user, data)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    rw.Write([]byte(result))
}

我们的处理程序使用请求的 Context 方法获取上下文,使用 UserFromContext 函数从上下文中提取用户,然后调用业务逻辑。

完整的示例代码位于第十四章代码仓库的 sample_code/context_user 目录中。

在某些情况下,将值保留在上下文中更好。之前提到的追踪 GUID 就是其中一种。这种信息用于应用程序的管理,而不是业务状态的一部分。通过在代码中显式传递它,会增加额外的参数,并阻止与不了解你的元信息的第三方库集成。通过在上下文中保留追踪 GUID,它在不需要了解跟踪信息的业务逻辑中以不可见的方式传递,并且在程序写入日志消息或连接到另一个服务器时可用。

下面是一个简单的上下文感知 GUID 实现,用于在服务之间进行跟踪,并创建包含 GUID 的日志:

package tracker

import (
    "context"
    "fmt"
    "net/http"

    "github.com/google/uuid"
)

type guidKey int

const key guidKey = 1

func contextWithGUID(ctx context.Context, guid string) context.Context {
    return context.WithValue(ctx, key, guid)
}

func guidFromContext(ctx context.Context) (string, bool) {
    g, ok := ctx.Value(key).(string)
    return g, ok
}

func Middleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        if guid := req.Header.Get("X-GUID"); guid != "" {
            ctx = contextWithGUID(ctx, guid)
        } else {
            ctx = contextWithGUID(ctx, uuid.New().String())
        }
        req = req.WithContext(ctx)
        h.ServeHTTP(rw, req)
    })
}

type Logger struct{}

func (Logger) Log(ctx context.Context, message string) {
    if guid, ok := guidFromContext(ctx); ok {
        message = fmt.Sprintf("GUID: %s - %s", guid, message)
    }
    // do logging
    fmt.Println(message)
}

func Request(req *http.Request) *http.Request {
    ctx := req.Context()
    if guid, ok := guidFromContext(ctx); ok {
        req.Header.Add("X-GUID", guid)
    }
    return req
}

Middleware 函数从传入的请求中提取 GUID,或者生成一个新的 GUID。无论哪种情况,都会将 GUID 放到上下文中,创建一个带有更新上下文的新请求,并继续调用链。

接下来,我们看看如何使用这个 GUID。Logger 结构提供了一个通用的日志记录方法,它接收一个上下文和字符串。如果上下文中存在 GUID,它会将 GUID 追加到日志消息的开头并输出。Request 函数用于在该服务调用另一个服务时。它接收一个*http.Request,如果上下文中存在 GUID,则添加一个带有 GUID 的header,并返回 *http.Request

有了这个包之后,我们就可以使用错误处理中讨论的依赖注入技术,创建完全不知道任何跟踪信息的业务逻辑。首先,我们声明一个表示日志记录器的接口,一个表示请求修饰器的函数类型,以及依赖于它们的业务逻辑结构体:

type Logger interface {
    Log(context.Context, string)
}

type RequestDecorator func(*http.Request) *http.Request

type LogicImpl struct {
    RequestDecorator RequestDecorator
    Logger           Logger
    Remote           string
}

接下来实现业务逻辑:

func (l LogicImpl) Process(ctx context.Context, data string) (string, error) {
    l.Logger.Log(ctx, "starting Process with "+data)
    req, err := http.NewRequestWithContext(ctx,
        http.MethodGet, l.Remote+"/second?query="+data, nil)
    if err != nil {
        l.Logger.Log(ctx, "error building remote request:"+err.Error())
        return "", err
    }
    req = l.RequestDecorator(req)
    resp, err := http.DefaultClient.Do(req)
    // process the response...
}

GUID 传递给日志记录器和请求修饰器,业务逻辑本身并不知道它,将程序逻辑所需的数据与程序管理所需的数据分离开来。唯一知道这种关联的地方是 main 中连接依赖项的代码:

controller := Controller{
    Logic: LogicImpl{
        RequestDecorator: tracker.Request,
        Logger:           tracker.Logger{},
        Remote:           "http://localhost:4000",
    },
}

可以在第十四章代码仓库的 sample_code/context_guid  目录中找到完整的跟踪 GUID 的代码。

小贴士: 在标准的 API 中使用上下文来传递值。在需要处理业务逻辑时,将上下文中的值复制到显式参数中。系统维护信息可以直接从上下文中获取。

取消

上下文对传递元数据及解决Go的HTTP API限制方面很有用,但它还有第二个用途。上下文还允许我们控制应用程序的响应及协调并发的协程。我们来看具体是如何做到的。

假设有一个请求,启动了多个goroutine,每个协程调用不同的HTTP服务。如果一个服务返回错误,导致无法返回有效的结果,那么继续处理其他协程就没有意义。在Go中,这被称为取消操作,而上下文提供了其实现机制。

要创建可取消的上下文,可以使用context.WithCancel函数。它接受一个context.Context作为参数,并返回context.Contextcontext.CancelFunc。与context.WithValue类似,返回的context.Context是传递给函数的上下文的子上下文。context.CancelFunc是一个不含参的函数,用于取消上下文,告诉所有正在监听取消操作的代码停止处理。

在创建具有关联取消函数的上下文时,无论处理是否以错误结束,都必须调用该取消函数。如果不这样做,程序将泄漏资源(内存和协程),最终会变慢或崩溃。多次调用取消函数不会引发错误;第一次之后的调用都不会产生任何效果。

确保调用取消函数的最简单方法是使用defer,在取消函数返回后立即调用它:

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()

这就引出了一个问题:如何检测取消操作?可以使用在并发中讨论过的done通道模式。context.Context接口有一个名为Done的方法。它返回一个类型为struct{}的通道。(选择这种返回类型的原因是空结构体不占用内存。)在调用取消函数时,该通道会被关闭。请记住,关闭的通道在尝试读取时会立即返回其零值。

警告: 如果对不可取消的上下文调用Done,会返回nil。正如在并发一章中介绍的,从nil通道读取永远不会返回。如果不是在select语句的case内,程序将会挂起。

我们来看看其运行方式。假设有一个从多个HTTP端点收集数据的程序。如果其中有一个失败,而你希望终止所有端点的处理。上下文取消可协助实现。

注意: 本例中,我们将使用httpbin.org的优秀服务。可以向它发送HTTP或HTTPS请求,以测试应用程序响应各种情况。我们将使用其中的两个端点:一个会在指定秒数后返回响应,另一个会返回所发送的状态码。

首先,我们创建可取消的上下文、一个用于从协程获取数据的通道,以及一个sync.WaitGroup,以等待所有goroutine完成:

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)

接下来,我们启动两个goroutine,一个调用随机返回错误状态的URL,另一个在延时后发送一个预定义的JSON响应。首先是随机状态的goroutine:

    go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx,
"http://httpbin.org/status/200,200,200,500")
if err != nil {
fmt.Println("error in status goroutine:", err)
cancelFunc()
return
}
if resp.StatusCode == http.StatusInternalServerError {
fmt.Println("bad status, exiting")
cancelFunc()
return
}
ch

相关文章

KubeSphere 部署向量数据库 Milvus 实战指南
探索 Kubernetes 持久化存储之 Longhorn 初窥门径
征服 Docker 镜像访问限制!KubeSphere v3.4.1 成功部署全攻略
那些年在 Terraform 上吃到的糖和踩过的坑
无需 Kubernetes 测试 Kubernetes 网络实现
Kubernetes v1.31 中的移除和主要变更

发布评论