原文:Context Package Semantics In Go
介绍
Golang 可以使用关键词 "go" 来创建 goroutine,但是却没有关键词来终止goroutine。在实际应用中,为了让服务应用稳健运行,让goroutine超时终止的能力也是至关重要的。任何请求或任务都不能永远的运行,因此识别并管理这些延迟的goroutine是每个程序应该具有的职责。
这对这个问题,Go团队提供的解决方案是Context包。它是由Sameer Ajmani于2014年在Gotham Go会议上撰写并介绍。他为此撰写了一篇博客文章。
谈话视频:https : //vimeo.com/115309491
幻灯片:https : //talks.golang.org/2014/gotham-context.slide#1
博客文章:https : //blog.golang.org/context
通过这些已发表的工作以及我多年来与Sameer的沟通,目前已经形成了一系列语义。在这篇文章中,我将提供这些语义,并尽力向您展示代码中的示例。
到服务端的request请求应创建一个上下文
在一次服务请求或任务创建的过程中,上下文的创建永远都是越早越好。这样在开发的早期使用Context将逼迫你设计API的时候将Context作为第一个参数。即使你并不能100%确保某个function需要使用Context,在后期删除一个Context也远比你要新添加一个Context要容易的多。
代码1
https://github.com/ardanlabs/service/blob/master/internal/platform/web/web.go#L75
75 // Handle is our mechanism for mounting Handlers for a given HTTP verb and path
76 // pair, this makes for really easy, convenient routing.
77 func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) {
...
85 // The function to execute for each request.
86 h := func(w http.ResponseWriter, r *http.Request, params map[string]string) {
87 ctx, span := trace.StartSpan(r.Context(), "internal.platform.web")
88 defer span.End()
...
106 // Add this handler for the specified verb and route.
107 a.TreeMux.Handle(verb, path, h)
108 }
在代码1中,你看见的代码来自我们在Ardan Labs上的服务。第86行定义了一个handler,用来绑定所有路由,就像107行的效果。这里是所有请求函数最开始的地方。第87行,为请求创建了一个span,它将作为Context的第一个参数。这是服务代码中第一次使用上下文。
一个很棒的前提是http.Request已经包含了一个上下文。这个功能在Go的1.7版本中添加。这表示我们的代码不需要手动去创建一个顶层的Context。如果你使用的是Go的1.8版本,那么就需要在调用StartSpan
之前,使用context.Background
创建一个空的Context 。
代码2
https://golang.org/pkg/context/#Background
87 ctx := context.Background()
88 ctx, span := trace.StartSpan(ctx, "internal.platform.web")
89 defer span.End()
代码2显示了Go 1.8版本中的代码。源代码的注释这样写道,
Background返回一个非空的Context。它不会被取消,没有值,也没有死亡日期。
它通常会被使用在main方法、初始化、单元测试以及request请求的顶级上线文中。
在Go中有一个习惯用法,就是所有的Context变量名称使用ctx
。因为上下文是一种接口,因此不应使用指针的语义。
代码3
https://golang.org/pkg/context/#Context
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
每个接受Context的函数都应实现接口中的方法。
对服务端的请求应携带上下文
这种语义背后的思想是,较高级别的请求需要告诉较低级别的请求他们愿意等待多长时间。一个很好的例子是http
包,版本1.7的更改,Do
方法实现请求的超时。
代码4
https://play.golang.org/p/9x4kBKO-Y6q
06 package main
07
08 import (
09 "context"
10 "io"
11 "log"
12 "net/http"
13 "os"
14 "time"
15 )
16
17 func main() {
18
19 // Create a new request.
20 req, err := http.NewRequest("GET", "https://www.ardanlabs.com/blog/post/index.xml", nil)
21 if err != nil {
22 log.Println("ERROR:", err)
23 return
24 }
25
26 // Create a context with a timeout of 50 milliseconds.
27 ctx, cancel := context.WithTimeout(req.Context(), 50*time.Millisecond)
28 defer cancel()
29
30 // Bind the new context into the request.
31 req = req.WithContext(ctx)
32
33 // Make the web call and return any error. Do will handle the
34 // context level timeout.
35 resp, err := http.DefaultClient.Do(req)
36 if err != nil {
37 log.Println("ERROR:", err)
38 return
39 }
40
41 // Close the response body on the return.
42 defer resp.Body.Close()
43
44 // Write the response to stdout.
45 io.Copy(os.Stdout, resp.Body)
46 }
在代码4中,该程序发出对Ardan rss订阅的请求,该请求的超时时间为50毫秒。在第20-24行,针对提供的URL 创建了一个GET
请求。第27-28行创建了一个50毫秒超时的上下文。在1.7版本中新增了一个WithContext
方法,此方法允许Request
更新上下文。在第31行,这正是代码的使用例子。
在第35行,创建了一个真正的request请求,他调用了http
包中DefaultClient
的Do
方法。该Do
方法将遵从Request
内部Context设置的50毫秒超时时间。
就像代码中展示的高级别的方法告诉Do
方法(低级别的方法),我愿意等待Do
操作多长时间。
不要将上下文存储在结构类型中;而是将上下文传递给每一个明确需要它的函数
本质上,任何执行I / O的功能都应接受Context作为其第一个参数,并遵守调用方配置的超时设置。比如Request
这个例子中,因为存在要考虑向后兼容性的问题,无需更改API,而是实现了上一节中提到的机制。
凡事都有意外。但是,在本文以及标准库中采用了Context的所有API的范围内,习惯用法都是使第一个参数接受Context值。
图1
图1显示了net
包中的示例,其中每个方法的第一个参数都将Context作为第一个参数,并使用ctx
作为变量名。
函数之间的链式调用必须传递Context
这是一条重要规则,上下文是基于请求或任务的。在请求或任务的执行期间,你可以基于上下文进行任意的修改。
代码5
https://github.com/ardanlabs/service/blob/master/cmd/sales-api/internal/handlers/user.go#L24
23 // List returns all the existing users in the system.
24 func (u *User) List(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
25 ctx, span := trace.StartSpan(ctx, "handlers.User.List")
26 defer span.End()
27
28 users, err := user.List(ctx, u.db)
29 if err != nil {
30 return err
31 }
32
33 return web.Respond(ctx, w, users, http.StatusOK)
34 }
在代码5中,你可以看见一个List
方法,这个函数在用户对此端发起HTTP请求时执行。此方法接受上下文作为一个参数,因为Context是请求的一部分,并随着这次I/O请求传递。你可以在第25、28和33行看见同一个Context沿着调用堆栈传递。
由于此函数不需要更改上下文,因此不会创建新的上下文值。如果此函数创建了一个新的顶级上下文值,则与此请求关联的更高级别调用中的所有现有上下文信息都将丢失。这肯定不是你想要的。
代码6
https://github.com/ardanlabs/service/blob/master/internal/user/user.go#L34
33 // List retrieves a list of existing users from the database.
34 func List(ctx context.Context, db *sqlx.DB) ([]User, error) {
35 ctx, span := trace.StartSpan(ctx, "internal.user.List")
36 defer span.End()
37
38 users := []User{}
39 const q = `SELECT * FROM users`
40
41 if err := db.SelectContext(ctx, &users, q); err != nil {
42 return nil, errors.Wrap(err, "selecting users")
43 }
44
45 return users, nil
46 }
在代码6中,你可以看见代码5中第28行调用的List
方法的声明。该方法依然接受Context作为其第一个参数。然后,此值在第35行和第41行上再次向下传播。由于第41行是数据库调用,因此该函数应遵守上下文中的来自任何上级调用者的任何超时信息的设置。
使用WithCancel,WithDeadline,WithTimeout或WithValue替换上下文
因为每个函数都可以根据其特定需求添加/修改Context,并且这些更改不应影响之前调用的任何函数,所以Context使用值传递。这意味着对Context值的任何更改都会创建一个新的Context值。
代码7
https://play.golang.org/p/8RdBXtfDv1w
18 func main() {
19
20 // Set a duration.
21 duration := 150 * time.Millisecond
22
23 // Create a context that is both manually cancellable and will signal
24 // cancel at the specified duration.
25 ctx, cancel := context.WithTimeout(context.Background(), duration)
26 defer cancel()
27
28 // Create a channel to receive a signal that work is done.
29 ch := make(chan data, 1)
30
31 // Ask the goroutine to do some work for us.
32 go func() {
33
34 // Simulate work.
35 time.Sleep(50 * time.Millisecond)
36
37 // Report the work is done.
38 ch <- data{"123"}
39 }()
40
41 // Wait for the work to finish. If it takes too long, move on.
42 select {
43 case d := <-ch:
44 fmt.Println("work complete", d)
45
46 case <-ctx.Done():
47 fmt.Println("work cancelled")
48 }
49 }
在代码7中,有一个小程序显示了WithTimeout
函数的值传递性质。在第25行,WithTimeout
返回一个新的上下文值和一个cancel
函数。由于该函数调用需要父Context,因此代码使用Background
函数来创建顶级空Context。这就是Background
的作用。
随着程序执行,WithTimeout
函数创建的上下文值将继续使用。如果调用链中将来的任何功能需要它们自己的特定超时或截止时间,它们还应该使用适当的With
功能并将此新的Context值用作父级。
重要的一点是,任何With
函数返回的cancel
都需要在方法返回前执行。这就是为什么习惯用法是在With
调用之后立即使用defer
关键字,就像第26行中一样,如果不这样做,会导致你的程序出现内存泄露。
当一个上下文被取消后,从该上下文派生的所有上下文也会被取消
每个新Context值都将获得父Context具有的所有内容以及任何更改。这意味着,如果取消父级上下文,则该父级上下文派生的所有子级也将被取消。
代码8
https://play.golang.org/p/PmhTXiCZUP1
20 func main() {
21
22 // Create a Context that can be cancelled.
23 ctx, cancel := context.WithCancel(context.Background())
24 defer cancel()
25
26 // Use the Waitgroup for orchestration.
27 var wg sync.WaitGroup
28 wg.Add(10)
29
30 // Create ten goroutines that will derive a Context from
31 // the one created above.
32 for i := 0; i < 10; i++ {
33 go func(id int) {
34 defer wg.Done()
35
36 // Derive a new Context for this goroutine from the Context
37 // owned by the main function.
38 ctx := context.WithValue(ctx, key, id)
39
40 // Wait until the Context is cancelled.
41 <-ctx.Done()
42 fmt.Println("Cancelled:", id)
43 }(i)
44 }
45
46 // Cancel the Context and any derived Context's as well.
47 cancel()
48 wg.Wait()
49 }
在代码8中,第23行创建了一个可以被取消的Context值。然后在第32-44行上,创建了十个goroutine。在第38行每个goroutine都在自己的Context中放入唯一ID。调用WithValue
传入的Context是来自main
传递的父级Context。然后在41行,每个goroutine阻塞直到他们的上下文被取消。
在第47行,主goroutine取消其Context值,然后在第48行上等待所有十个goroutine接收信号,然后再关闭程序。一旦cancel
函数调用,第41行上的所有十个goroutine将变为非阻塞状态并打印它们已被取消。一次cancel
的调用取消了所有。
这也表明可以将相同的上下文传递给在不同goroutine中运行的函数。上下文对于多个goroutine同时使用是安全的。
即使函数允许,也不要传递nil Context。如果不确定要使用哪个上下文,请传递TODO上下文
TODO
函数是Context包中我最喜欢的部分之一。我坚信程序员写代码就像起草法案。就像写文章时发布了新版本的作者一样。在编写代码时,你不可能了解一切,但希望你了解的足够多来保证编程的快速进展。最后,你将不断学习,重构和测试。
很多时候,我知道我需要一个上下文,但是不确定它的来源。我知道创建顶级Context是不可靠的,因此就不会考虑使用Background
。直到我弄清楚实际上下文从哪里来之前,我需要一个临时的顶级上下文,这时候应该使用TOOD
函数。
使用上下文传递数据,应仅用于请求流程和API范围的数据,不可以利用上下文传递普通函数的参数。
这可能是最重要的一点。当函数需要一项数据用于执行其代码时,请勿使用Context值将数据传递给该函数。换句话说,一个函数应该能够使用空的Context值执行其逻辑代码。如果某个函数要求将信息包含在上下文中,则如果该信息丢失,整个程序应失败并向应用程序发出关闭信号。
一个典型的错用案例,就是数据库连接使用Context将数据传递到函数中。通畅,在程序中传递数据时要遵循以下规则。
将数据作为函数参数传递,这是最清晰的方法,在整个程序中数据的传递没有隐藏。
通过接收器(receiver)传递数据,如果需要数据的方法要求不能更改其签名,那么就使用一种方法,将数据通过接收器传递。
使用接收器的简单示例
Request handler是第二条规则的经典示例。由于handler已绑定了特定的声明,因此无法更改签名。
代码9
https://github.com/ardanlabs/service/blob/master/cmd/sales-api/internal/handlers/user.go#L24
23 // List returns all the existing users in the system.
24 func (u *User) List(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
25 ctx, span := trace.StartSpan(ctx, "handlers.User.List")
26 defer span.End()
27
28 users, err := user.List(ctx, u.db)
29 if err != nil {
30 return err
31 }
32
33 return web.Respond(ctx, w, users, http.StatusOK)
34 }
在代码9中,您将看到List
方法。这些方法的签名与Web框架定义的内容绑定在一起,无法更改。但是,为了在第28行进行业务调用,需要进行数据库连接。在此代码中连接池并不是来自Context值,而是从而是来自接收器。
代码10
https://github.com/ardanlabs/service/blob/master/cmd/sales-api/internal/handlers/user.go#L15
15 // User represents the User API method handler set.
16 type User struct {
17 db *sqlx.DB
18 authenticator *auth.Authenticator
19
20 // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
21 }
在代码10中,我们可以看到接收器类型的声明。请求处理程序需要的任何内容都定义为字段。这允许传递的信息不被隐藏,并且业务层可以使用空的Context值运行。
代码11
https://github.com/ardanlabs/service/blob/master/cmd/sales-api/internal/handlers/routes.go#L14
14 // API constructs an http.Handler with all application routes defined.
15 func API(shutdown chan os.Signal, log *log.Logger, db *sqlx.DB, authenticator *auth.Authenticator) http.Handler {
16
...
26 // Register user management and authentication endpoints.
27 u := User{
28 db: db,
29 authenticator: authenticator,
30 }
31
32 app.Handle("GET", "/v1/users", u.List)
在代码11中,我们看到构造一个User
,然后将该List
方法绑定到路由。同样的,由于handle的验证信息不可更改,因此使用接收器是传递数据而不隐藏数据的第二种最佳方法。
调试或跟踪数据可以安全地传递到上下文中
我们可以对Context中存储以及接受到的值进行调试及追踪。
代码12
https://github.com/ardanlabs/service/blob/master/internal/platform/web/web.go#L23
23 // Values represent state for each request.
24 type Values struct {
25 TraceID string
26 Now time.Time
27 StatusCode int
28 }
在代码12中,我们看到Values的声明,每个新请求的Context中我们都创建了一个Values。这三个字段提供了请求的跟踪和调试信息。随着请求的进行,程序将收集到这些信息。
代码13
https://github.com/ardanlabs/service/blob/master/internal/platform/web/web.go#L75
75 // Handle is our mechanism for mounting Handlers for a given HTTP verb and path
76 // pair, this makes for really easy, convenient routing.
77 func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) {
78
...
79 // The function to execute for each request.
80 h := func(w http.ResponseWriter, r *http.Request, params map[string]string) {
…
84 // Set the context with the required values to
85 // process the request.
86 v := Values{
87 TraceID: span.SpanContext().TraceID.String(),
88 Now: time.Now(),
89 }
90 ctx = context.WithValue(ctx, KeyValues, &v)
在代码13中,我们可以看见Values
在86行创建,然后在第90行将其存储在Context中。这是日志记录中间件,它需要大部分此类信息。
代码14
https://github.com/ardanlabs/service/blob/master/internal/mid/logger.go#L20
20 // Create the handler that will be attached in the middleware chain.
21 h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
...
25 // If the context is missing this value, request the service
26 // to be shutdown gracefully.
27 v, ok := ctx.Value(web.KeyValues).(*web.Values)
28 if !ok {
29 return web.NewShutdownError("web value missing from context")
30 }
...
34 log.Printf("%s : (%d) : %s %s -> %s (%s)",
35 v.TraceID, v.StatusCode,
36 r.Method, r.URL.Path,
37 r.RemoteAddr, time.Since(v.Now),
38 )
代码14中,第27-30行的代码中显示了通过Context传递信息的结果。这段代码正尝试
从Context 检索Values
并查看数据是否还在。如果数据不存在了,说明存在完整性问题,整个服务应该被关闭。这是通过在服务代码中发送一个特殊的错误值(通过应用程序备份)来完成的。
如果你使用上下文传递数据库连接池,或者是业务层的数据,会有两个问题:
您需要检查数据完整性,并且需要一种机制来快速关闭服务。
测试和调试变得更加困难和复杂。你正在与代码的清晰度和可读性的路上渐行渐远。
结论
Context包定义了一个API,该API支持超时时间,取消信号以及可以在API边界之间以及goroutine之间传递的请求范围值。该API是我们在Go应用的编写过程里重要组成部分。如果我们目标是编写具有完整可靠软件,那么理解这个语义至关重要。
在这篇博客中,我尝试分解了Go团队定义的语义。希望你现在对如何更有效地使用上下文有了更好的了解。你可以使用所有代码示例。如有任何疑问,请随时给我发送电子邮件。
最后说明
- 传入服务器的请求应创建一个上下文。
- 对于接受服务端的传出的调用应接收上下文。
- 不要将上下文存储在结构类型中;而是将上下文明确传递给需要它的每个函数。
- 函数之间的调用链必须传递Context。
- 使用WithCancel,WithDeadline,WithTimeout或WithValue替换上下文。
- 取消上下文后,从该上下文派生的所有上下文也会被取消。
- 可以将相同的上下文传递给在不同goroutine中运行的函数。上下文对于由多个goroutine同时使用是安全的。
- 即使函数允许,也不要传递nil Context。如果不确定要使用哪个上下文,请传递TODO上下文。
- 上下文值仅用于传递请求流程和API范围的数据,而不用于将可选参数传递给函数。