Golang Context的好与坏及使用建议

4,642 阅读8分钟

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()反馈的错误信息仅限于如下两种:

  1. 因取消而取消 (excuse me???)
  2. 因超时而取消
// 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()
}

示例代码来源: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接口中注释为我们提供了一个很好的示例:

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)
}

其他参考