怎么使用context

简介

context库是go 1.7中加入的,本篇文章主要是讲解如何正确的使用它。

缘起

一切有为法,让我们先来看看golang里加入context的缘由。

在一个go实现的服务器程序中,通常我们对每个请求使用一个goroutine来进行处理。在请求处理里,我们还有可能启动新的goroutine来访问一些后台程序,如进行数据库操作或发起RPC请求。这些处理这个请求相关的goroutines,经常需要访问与当前请求相关的信息,如用户ID,认证token,请求的截止时间等等。当请求被取消或者超时的时候,所有处理这个请求的goroutines都应该尽快退出,这样才能够对相关的资源进行快速地回收。

因此,在Google内部,开发了一个context包,用于把请求相关的值,取消信号,API截止日期等信息在所有处理这个请求相关的goroutines里进行方便地传递。这个包最终也发布到了go 1.7里.

如露亦如电

我们再来详细地了解一下context的接口。

context包的核心就是context接口.下面是从源码包里摘录的其核心部分。

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

  • Done:这个方法返回一个只读的通道,这个通道可以被运行在当前context上的函数当作一个取消信号使用:当这个通道被关闭后,正在处理的函数应该终止操作并返回。
  • Err:Err方法返回一个错误信息用来说明context被取消的原因。

细心的你可能发现了Context没有Cancel方法,这是为什么呢?这和Done方法只返回了一个只读通道的原因是一样的:接收到取消信号的函数通常不会是发送取消信号的函数(isn’t it?).尤其是当父操作启动goroutines来处理子操作时,这些子操作不应该能够取消父操作.

  • WithCancel函数提供了取消一个新创建的Context的方法.
  • Context对于并发的多个goroutines是安全的。在代码里,我们可以将一个context传递给任意数量的goroutines然后取消Context并通知所有的gourintes.
  • Deadline:方法允许函数来判断它们是否应该启动;如果可用的时间不多了,可能就没必要干活了。在代码里,我们还可以利用deadline来对I/O操作设置超时。
  • Value方法允许Context设置请求范围内的值。这个数据应该能够被并发的多个goroutines安全地访问.

从Context继承

context包提供用来从已有的context继承产生出新的Context的函数。这些值构成了一棵树:当一个Context取消时,所有从其继承衍生出来的Contextx也被取消.

  • Background是Context树的根,它从来不会被取消.

下面是源码包里的描述:

// Background returns an empty Context. It is never canceled, has no deadline,
// and has no values. Background is typically used in main, init, and tests,
// and as the top-level Context for incoming requests.
func Background() Context
  • WithCancelWithTimeout返回衍生出的Context值,可以在父Context之前被取消.当请求处理函数返回时,与这个请求相关的Context就被取消了.WithCancel对于需要取消使用多个副本处理冗余请求的场景也十分有用.WithTimeout用于为访问后端服务的请求设置deadline.

下面是源码包里的相关描述:

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithTimeout returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed, cancel is called, or timeout elapses. The new
// Context's Deadline is the sooner of now+timeout and the parent's deadline, if
// any. If the timer is still running, the cancel function releases its
// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue用于将一个请求范围相关的值设置到Context.

下面是源码包里的描述:

// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

实战

说了这么多道理,还是来个实际的例子理解起来容易。

我们实现一个HTTP服务程序,这个程序处理  /search?q=golang&timeout=1s 这个的URL。这个URL查询”golang”关键字的相关信息,超时时间是1s.我们将这个请求转给Google查询API处理,并对查询结果进行一些渲染工作。

我们的代码由以下3部分组成:

  • server提供了main函数实现并处理/search URL.
  • userip 提供了解析用户IP并将其关联到Context的功能.
  • google 提供了Search函数用于将请求发送给Google处理.

server实现

server程序处理像/search?q=golang这样的url请求.我们注册handleSearch函数用于处理/search这个url.这个handler会创建一个初始的Context的名为ctx,并会在处理结束时调用cancel.如果请求URL里有timeout参数,Context会在超时时自动取消:.

下面是具体的代码:

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx是这个handler使用的Context.调用cancel来关闭ctx.Done channel,这个handler发起的所有操作都会收到取消信号.
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // 请求里有timeout,因此创建一个有超时时间的context.
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // 函数返回里调用cancel().

handler会使用userip包里提供的函数从请求里取出用户IP并设置到context里,后面的请求会使用这个ip.

// 检查是否携带查询参数.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // 使用userip包里的函数取出userIP,并存储到ctx里.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

然后调用Google api进行查询.

// 调用google api进行查询.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

查询成功,处理结果.

 if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

userip实现

userip包提供了解析用户IP的功能.Context提供了一个key-value map.key和value都是interface{}类型.key必须支持相等判断,values必须能够安全地并多个goroutines并发访问.userip包隐藏了这个map的细节,并提供了对Context值的强类型访问.

为了避免冲突,userip定义了未导出的key类型.

// key类型没有导出,为了防止和其它包里定义的context keys冲突.
type key int

// userIPkey是用户IP的key,它的值是0.如果要定义其它的context keys,需要使用不同的值.
const userIPKey key = 0
  • FromRequest从http.Request里解析userIP.
func FromRequest(req *http.Request) (net.IP, error) {
    ip, _, err := net.SplitHostPort(req.RemoteAddr)
    if err != nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }
  • NewContext返回一个设置了userIP的新Context:
func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}
  • FromContext从Context里获取userIP:
func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value对于不存在的key返回nil.
net.IP断言对于nil返回的ok=false.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

google包

gooe.Search函数创建一个使用Google Web Search API的HTTP请求,并解析返回的JSON结果.它接受一个Context参数并在ctx.Done关闭时直接返回。

Google Web Search API需要使用user IP作为查询参数.

func Search(ctx context.Context, query string) (Results, error) {
    // 准备Google API请求.
    req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
    if err != nil {
        return nil, err
    }
    q := req.URL.Query()
    q.Set("q", query)

    // 从ctx里解析user IP并使用.
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()
  • Search使用了httpDo这个帮助函数用于提交http请求,并在ctx.Done时取消正在进行的处理.Search向httpDo传递了一个闭包函数用于处理HTTP响应:
 var results Results
    err = httpDo(ctx, req, func(resp *http.Response, err error) error {
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        // Parse the JSON search result.
        // https://developers.google.com/web-search/docs/#fonje
        var data struct {
            ResponseData struct {
                Results []struct {
                    TitleNoFormatting string
                    URL               string
                }
            }
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return err
        }
        for _, res := range data.ResponseData.Results {
            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
        }
        return nil
    })
    
    return results, err
  • httpDo函数在一个新的goroutine里发起HTTP请求并处理响应.如果在goroutine退出之前ctx.Done则取消请求。
func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // 在一个goroutine里处理HTTP请求并将响应交给f处理.
    c := make(chan error, 1)
    req = req.WithContext(ctx)
    go func() { c <- f(http.DefaultClient.Do(req)) }()
    select {
    case <-ctx.Done():
        <-c // 等待f返回.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

希望通过上面的例子,你能够理解并正确地使用Context包.

总结

在Google内部,要求Go程序员将Context作为请求和响应路径上所有函数的第一个参数.这样不同小组的程序员能够良好的合作在一起.这提供了一种简单的控制超时和取消的机制,并能够保证证书这种关键信息能够在程序里正确地传递.

希望使用Context这种机制的服务框架应该提供Context的实现,

用于在它们的包和那些使用Context参数的包之间建立一座桥梁.它们提供的库能够从调用代码里接受Context,这样就能够在请求相关的数据和取消上建立一种通用的接口。Context使包开发者能够更容易的共享代码来创建可伸缩的服务.

43.6K

发表评论

电子邮件地址不会被公开。