context
的设计在Golang中算是一个比较有争议的话题。context
不是银弹,它解决了一些问题的同时,也有不少让人诟病的缺点。本文主要探讨一下context
的优缺点以及一些使用建议。
缺点
由于主观上我也不是很喜欢context
的设计,所以我们就从缺点先开始吧。
到处都是context
根据context
使用的官方建议,context
应当出现在函数的第一个参数上。这就直接导致了代码中到处都是context
。作为函数的调用者,即使你不打算使用context
的功能,你也必须传一个占位符——context.Background()
或context.TODO()
。这无疑是一种code smell,特别是对于有代码洁癖程序员来说,传递这么多无意义的参数是简直是令人无法接受的。
Err()
其实很鸡肋
context.Context
接口中有定义Err()
方法:
type Context interface {
...
// If Done is not yet closed, Err returns nil.
// If Done is closed, Err returns a non-nil error explaining why:
// Canceled if the context was canceled
// or DeadlineExceeded if the context's deadline passed.
// After Err returns a non-nil error, successive calls to Err return the same error.
Err() error
...
}
复制代码
当触发取消的时候(这通常意味着发生了一些错误或异常),可以通过Err()
方法来查看错误的原因。这的确是一个常见的需求,但context
包里面对Err()
的实现却显得有点鸡肋,Err()
反馈的错误信息仅限于如下两种:
- 因取消而取消 (excuse me???)
- 因超时而取消
// Canceled is the error returned by Context.Err when the context is canceled.
var Canceled = errors.New("context canceled")
// DeadlineExceeded is the error returned by Context.Err when the context's
// deadline passes.
var DeadlineExceeded error = deadlineExceededError{}
type deadlineExceededError struct{}
func (deadlineExceededError) Error() string { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool { return true }
func (deadlineExceededError) Temporary() bool { return true }
复制代码
从Err()
方法中你几乎不能得到任何与业务相关的错误信息,也就是说,如果你想知道具体的取消原因,你不能指望context
包,你得自己动手丰衣足食。如果cancel()
方法能接收一个错误可能会好一些:
ctx := context.Background()
c, cancel := context.WithCancel(ctx)
err := errors.New("some error")
cancel(err) //cancel的时候能带上错误原因
复制代码
context.Value
——没有约束的自由是危险的
context.Value几乎就是一个 map[interface{}]interface{}
:
type Context interface {
...
Value(key interface{}) interface{}
...
}
复制代码
这给了程序员们极大的自由,几乎就是想放什么放什么。但这种几乎毫无约束的自由是很危险的,不仅容易引起滥用,误用,而且失去了编译时的类型检查,要求我们对context.Value
中的每一个值都要做类型断言,以防panic
。尽管文档中说明了context.Value
中应当用于保存“request-scoped”类型的数据,可对于什么是“request-scoped”,一千个人的眼中有一千种定义。像request-id,access_token,user_id
这些数据,可以当做是“request-scoped”放在context.Value
里,也完全可以以更清晰的定义方式定义在结构体里。
可读性很差
可读性差也是自由带来的代价,在学习阅读Go代码的时候,看到context
是令人头疼的一件事。如果文档注释的不够清晰,你几乎无法得知context.Value
里究竟包含什么内容,更不谈如何正确的使用这些内容了。下面的代码是http.Request
结构体中context
的定义和注释:
// http.Request
type Request struct {
....
// ctx is either the client or server context. It should only
// be modified via copying the whole Request using WithContext.
// It is unexported to prevent people from using Context wrong
// and mutating the contexts held by callers of the same request.
ctx context.Context
}
复制代码
请问你能看出来这个context.Value
里面会保存什么吗?
...
func main () {
http.Handle("/", http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
fmt.Println(req.Context()) // 猜猜看这个context里面有什么?
}))
}
复制代码
写到这里我不禁想起来了“奶糖哥”的灵魂拷问:桌上这几杯酒,哪一杯是茅台?
即使你将context
打印了出来,你也无法得知context
跟函数入参之间的关系,说不定下次传另一组参数,context
里面的值就变了呢。通常遇到这种情况,如果文档不清晰(很遗憾的是我发现大部分代码都不会对context.Value
有清晰的注释),只能全局搜索context.WithValue
,一行行找了。
优点
虽然主观上我对context
是有一定“偏见”的,但客观上,它还是具备一些优点和功劳的。
统一了cancelation的实现方法
许多文章都说context
解决了goroutine的cancelation问题,但实际上,我觉得cancelation的实现本身不算是一个问题,利用关闭channel
的广播特性,实现cancelation是一件比较简单的事情,举个栗子:
// Cancel触发一个取消
func Cancel(c chan struct{}) {
select {
case <-c: //已经取消过了, 防止重复close
default:
close(c)
}
}
// DoSomething做一些耗时操作,可以被cancel取消。
func DoSomething(cancel chan struct{}, arg Arg) {
rs := make(chan Result)
go func() {
// do something
rs <- xxx //返回处理结果
}()
select {
case <-cancel:
log.Println("取消了")
case result := <-rs:
log.Println("处理完成")
}
}
复制代码
或者你也可以把用于取消的channel
放到结构体里:
type Task struct{
Arg Arg
cancel chan struct{} //取消channel
}
// NewTask 根据参数新建一个Task
func NewTask(arg Arg) *Task{
return &Task{
Arg:arg ,
cancel:make(chan struct{}),
}
}
// Cancel触发一个取消
func (t *Task) Cancel() {
select {
case <-t.c: //已经取消过了, 防止重复close
default:
close(t.c)
}
}
// DoSomething做一些耗时操作,可以被cancel取消。
func (t *Task) DoSomething() {
rs := make(chan Result)
go func() {
// do something
rs <- xxx
}()
select {
case <-t.cancel:
log.Println("取消了")
case result := <-rs:
log.Println("处理完成")
}
}
// t := NewTask(arg)
// t.DoSomething()
复制代码
可见,对cancelation的实现也是多种多样的。一千个程序员由可能写出一千种实现方式。不过幸亏有context
统一了cancelation的实现,不然怕是每引用一个库,你都得额外学习一下它的cancelation机制了。我认为这是context
最大的优点,也是最大的功劳。gopher们只要看到函数中有context
,就知道如何取消该函数的执行。如果想要实现cancelation,就会优先考虑context
。
提供了一种不那么优雅,但是有效的传值方式
context.Value
是一把双刃剑,上文中提到了它的缺点,但只要运用得当,缺点也可以变优点。map[interface{}]interface{}
的属性决定了它几乎能存任何内容,如果某方法需要cancelation的同时,还需要能接收调用方传递的任何数据,那context.Value
还是十分有效的方式。如何“运用得当”请参考下面的使用建议。
context
使用建议
需要cancelation的时候才考虑context
context
主要就是两大功能,cancelation和context.Value
。如果你仅仅是需要在goroutine之间传值,请不要使用context
。因为在Go的世界里,context
一般默认都是能取消的,一个不能取消的context
很容易被调用方误解。
一个不能取消的
context
是没有灵魂的。
context.Value
能不用就不用
context.Value
内容的存取应当由库的使用者来负责。如果是库内部自身的数据流转,那么请不要使用context.Value
,因为这部分数据通常是固定的,可控的。假设某系统中的鉴权模块,需要一个字符串token
来鉴权,对比下面两种实现方式,显然是显示将token
作为参数传递更清晰。
// 用context
func IsAdminUser(ctx context.Context) bool {
x := token.GetToken(ctx)
userObject := auth.AuthenticateToken(x)
return userObject.IsAdmin() || userObject.IsRoot()
}
// 不用context
func IsAdminUser(token string, authService AuthService) int {
userObject := authService.AuthenticateToken(token)
return userObject.IsAdmin() || userObject.IsRoot()
}
复制代码
所以,请忘了“request-scoped”吧,把context.Value
想象成是“user-scoped”——让用户,也就是库的调用者来决定在context.Value
里面放什么。
使用NewContext
和FromContext
对来存取context
不要直接使用context.WithValue()
和context.Value("key")
来存取数据,将context.Value
的存取做一层封装能有效降低代码冗余,增强代码可读性同时最大限度的防止一些粗心的错误。context.Context
接口中注释为我们提供了一个很好的示例:
package user
import "context"
// User is the type of value stored in the Contexts.
type User struct {...}
// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int
// userKey is the key for user.User values in Contexts. It is
// unexported; clients use user.NewContext and user.FromContext
// instead of using this key directly.
var userKey key
// NewContext returns a new Context that carries value u.
func NewContext(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}
// FromContext returns the User value stored in ctx, if any.
func FromContext(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}
复制代码
如果使用context.Value
,请注释清楚
上面提到,context.Value
可读性是十分差的,所以我们不得不用文档和注释的方式来进行弥补。至少列举所有可能的context.Value
以及它们的get set方法(NewContext(),FromContext()
),尽可能的列举函数入参与context.Value
之间的关系,给阅读或维护你代码的人多一份关爱。
封装以减少context.TODO()
或context.Background()
对于那些提供了context
的方法,但作为调用方我们并不使用的,还是不得不传context.TODO()
或context.Background()
。如果你不能忍受大量无用的context
在代码中扩散,可以对这些方法做一层封装:
// 假设有如下查询方法,但我们几乎不使用其提供的context
func QueryContext(ctx context.Context, query string, args []NamedValue) (Rows, error) {
...
}
// 封装一下
func Query(query string, args []NamedValue) (Rows, error) {
return QueryContext(context.Background(), query, args)
}
复制代码
其他参考
- How to correctly use context.Context in Go 1.7
- Understanding the context package in golang
- Context should go away for Go 2
- Go Concurrency Patterns: Context
有疑问加站长微信联系(非本文作者)