Golang Context的好与坏及使用建议

https://juejin.im/post/5d16dc03e51d4550bf1ae8aa

context的设计在Golang中算是一个比较有争议的话题。context不是银弹,它解决了一些问题的同时,也有不少让人诟病的缺点。本文主要探讨一下context的优缺点以及一些使用建议。

缺点

由于主观上我也不是很喜欢context的设计,所以我们就从缺点先开始吧。

到处都是context

根据context使用的官方建议,context应当出现在函数的第一个参数上。这就直接导致了代码中到处都是context。作为函数的调用者,即使你不打算使用context的功能,你也必须传一个占位符——context.Background()context.TODO()。这无疑是一种code smell,特别是对于有代码洁癖程序员来说,传递这么多无意义的参数是简直是令人无法接受的。

Err() 其实很鸡肋

context.Context接口中有定义Err()方法:

  1. type Context interface {
  2. ...
  3. // If Done is not yet closed, Err returns nil.
  4. // If Done is closed, Err returns a non-nil error explaining why:
  5. // Canceled if the context was canceled
  6. // or DeadlineExceeded if the context's deadline passed.
  7. // After Err returns a non-nil error, successive calls to Err return the same error.
  8. Err() error
  9. ...
  10. }

当触发取消的时候(这通常意味着发生了一些错误或异常),可以通过Err()方法来查看错误的原因。这的确是一个常见的需求,但context包里面对Err()的实现却显得有点鸡肋,Err()反馈的错误信息仅限于如下两种:

  1. 因取消而取消 (excuse me???)
  2. 因超时而取消
  1. // Canceled is the error returned by Context.Err when the context is canceled.
  2. var Canceled = errors.New("context canceled")
  3. // DeadlineExceeded is the error returned by Context.Err when the context's
  4. // deadline passes.
  5. var DeadlineExceeded error = deadlineExceededError{}
  6. type deadlineExceededError struct{}
  7. func (deadlineExceededError) Error() string { return "context deadline exceeded" }
  8. func (deadlineExceededError) Timeout() bool { return true }
  9. func (deadlineExceededError) Temporary() bool { return true }

Err()方法中你几乎不能得到任何与业务相关的错误信息,也就是说,如果你想知道具体的取消原因,你不能指望context包,你得自己动手丰衣足食。如果cancel()方法能接收一个错误可能会好一些:

  1. ctx := context.Background()
  2. c, cancel := context.WithCancel(ctx)
  3. err := errors.New("some error")
  4. cancel(err) //cancel的时候能带上错误原因

context.Value——没有约束的自由是危险的

context.Value几乎就是一个 map[interface{}]interface{}

  1. type Context interface {
  2. ...
  3. Value(key interface{}) interface{}
  4. ...
  5. }

这给了程序员们极大的自由,几乎就是想放什么放什么。但这种几乎毫无约束的自由是很危险的,不仅容易引起滥用,误用,而且失去了编译时的类型检查,要求我们对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的定义和注释:

  1. // http.Request
  2. type Request struct {
  3. ....
  4. // ctx is either the client or server context. It should only
  5. // be modified via copying the whole Request using WithContext.
  6. // It is unexported to prevent people from using Context wrong
  7. // and mutating the contexts held by callers of the same request.
  8. ctx context.Context
  9. }

请问你能看出来这个context.Value里面会保存什么吗?

  1. ...
  2. func main () {
  3. http.Handle("/", http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
  4. fmt.Println(req.Context()) // 猜猜看这个context里面有什么?
  5. }))
  6. }

写到这里我不禁想起来了“奶糖哥”的灵魂拷问:桌上这几杯酒,哪一杯是茅台?

即使你将context打印了出来,你也无法得知context跟函数入参之间的关系,说不定下次传另一组参数,context里面的值就变了呢。通常遇到这种情况,如果文档不清晰(很遗憾的是我发现大部分代码都不会对context.Value有清晰的注释),只能全局搜索context.WithValue,一行行找了。

优点

虽然主观上我对context是有一定“偏见”的,但客观上,它还是具备一些优点和功劳的。

统一了cancelation的实现方法

许多文章都说context解决了goroutine的cancelation问题,但实际上,我觉得cancelation的实现本身不算是一个问题,利用关闭channel的广播特性,实现cancelation是一件比较简单的事情,举个栗子:

  1. // Cancel触发一个取消
  2. func Cancel(c chan struct{}) {
  3. select {
  4. case <-c: //已经取消过了, 防止重复close
  5. default:
  6. close(c)
  7. }
  8. }
  9. // DoSomething做一些耗时操作,可以被cancel取消。
  10. func DoSomething(cancel chan struct{}, arg Arg) {
  11. rs := make(chan Result)
  12. go func() {
  13. // do something
  14. rs <- xxx //返回处理结果
  15. }()
  16. select {
  17. case <-cancel:
  18. log.Println("取消了")
  19. case result := <-rs:
  20. log.Println("处理完成")
  21. }
  22. }

或者你也可以把用于取消的channel放到结构体里:

  1. type Task struct{
  2. Arg Arg
  3. cancel chan struct{} //取消channel
  4. }
  5. // NewTask 根据参数新建一个Task
  6. func NewTask(arg Arg) *Task{
  7. return &Task{
  8. Arg:arg ,
  9. cancel:make(chan struct{}),
  10. }
  11. }
  12. // Cancel触发一个取消
  13. func (t *Task) Cancel() {
  14. select {
  15. case <-t.c: //已经取消过了, 防止重复close
  16. default:
  17. close(t.c)
  18. }
  19. }
  20. // DoSomething做一些耗时操作,可以被cancel取消。
  21. func (t *Task) DoSomething() {
  22. rs := make(chan Result)
  23. go func() {
  24. // do something
  25. rs <- xxx
  26. }()
  27. select {
  28. case <-t.cancel:
  29. log.Println("取消了")
  30. case result := <-rs:
  31. log.Println("处理完成")
  32. }
  33. }
  34. // t := NewTask(arg)
  35. // 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作为参数传递更清晰。

  1. // 用context
  2. func IsAdminUser(ctx context.Context) bool {
  3. x := token.GetToken(ctx)
  4. userObject := auth.AuthenticateToken(x)
  5. return userObject.IsAdmin() || userObject.IsRoot()
  6. }
  7. // 不用context
  8. func IsAdminUser(token string, authService AuthService) int {
  9. userObject := authService.AuthenticateToken(token)
  10. return userObject.IsAdmin() || userObject.IsRoot()
  11. }

示例代码来源:@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39">How to correctly use context.Context in Go 1.7

所以,请忘了“request-scoped”吧,把context.Value想象成是“user-scoped”——让用户,也就是库的调用者来决定在context.Value里面放什么。

使用NewContextFromContext对来存取context

不要直接使用context.WithValue()context.Value("key")来存取数据,将context.Value的存取做一层封装能有效降低代码冗余,增强代码可读性同时最大限度的防止一些粗心的错误。context.Context接口中注释为我们提供了一个很好的示例:

  1. package user
  2. import "context"
  3. // User is the type of value stored in the Contexts.
  4. type User struct {...}
  5. // key is an unexported type for keys defined in this package.
  6. // This prevents collisions with keys defined in other packages.
  7. type key int
  8. // userKey is the key for user.User values in Contexts. It is
  9. // unexported; clients use user.NewContext and user.FromContext
  10. // instead of using this key directly.
  11. var userKey key
  12. // NewContext returns a new Context that carries value u.
  13. func NewContext(ctx context.Context, u *User) context.Context {
  14. return context.WithValue(ctx, userKey, u)
  15. }
  16. // FromContext returns the User value stored in ctx, if any.
  17. func FromContext(ctx context.Context) (*User, bool) {
  18. u, ok := ctx.Value(userKey).(*User)
  19. return u, ok
  20. }

如果使用context.Value,请注释清楚

上面提到,context.Value可读性是十分差的,所以我们不得不用文档和注释的方式来进行弥补。至少列举所有可能的context.Value以及它们的get set方法(NewContext(),FromContext()),尽可能的列举函数入参与context.Value之间的关系,给阅读或维护你代码的人多一份关爱。

封装以减少context.TODO()context.Background()

对于那些提供了context的方法,但作为调用方我们并不使用的,还是不得不传context.TODO()context.Background()。如果你不能忍受大量无用的context在代码中扩散,可以对这些方法做一层封装:

  1. // 假设有如下查询方法,但我们几乎不使用其提供的context
  2. func QueryContext(ctx context.Context, query string, args []NamedValue) (Rows, error) {
  3. ...
  4. }
  5. // 封装一下
  6. func Query(query string, args []NamedValue) (Rows, error) {
  7. return QueryContext(context.Background(), query, args)
  8. }

其他参考

ft_authoradmin  ft_create_time2019-11-19 13:58
 ft_update_time2019-11-19 14:00