go context解析

语言: CN / TW / HK

上下文context

描述

上下文 context 是1.7引进到标准库的包。官方的解释为:

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

大致的意思是:

context包定义了Context类型,这个类型含有截止时间,取消信号和在进程中和跨api边界的其他跨域请求

context.Context 类型接口需要实现4个方法:

  1. Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  2. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消之后关闭,多次调用 Done 方法会返回同一个 Channel;
  3. Err — 返回 context.Context 结束的原因,它只会在 Done 返回的 Channel 被关闭时才会返回非空的值;

如果 context.Context 被取消,会返回 Canceled 错误;

如果 context.Context 超时,会返回 DeadlineExceeded 错误;

  1. Value — 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

context的设计的初衷是为了通过同步信号实现对goroutines的树结构进行信号同步,这样可以最大程度的减少对计算资源的浪费。

Go 服务的每一个请求的都是通过单独的 Goroutine 处理的,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。

代码讲解

代码入门

这里我们先走一个简单的事例:

首先我们创建一个方法 sleepAndTalk ,这个方法会在5秒钟后输出字符串,或者如果context关闭,则打印错误信息

func sleepAndTalk(ctx context.Context, duration time.Duration, s string) {

    select {
    case <-ctx.Done():
        fmt.Println("handle", ctx.Err())

    case <-time.After(duration):
        fmt.Println(s)
    }
}

接下来我们在main方法里面调用这个方法,创建一个 context.Background 同时使用 context.WithCancel(ctx) 方法

,同时开启一个协程用来扫描输入信号,如果在5秒中内有键盘输入,则出发 cancel 方法

func main() {

    ctx := context.Background()

    ctx , cancel := context.WithCancel(ctx)


    go func() {
        s := bufio.NewScanner(os.Stdin)
        s.Scan()
        cancel()
    }()

    sleepAndTalk(ctx, time.Second*5, "hello")

}

貌似这里我们感觉不太爽,我们应该修改为1秒钟之后让其触发 cancel 方法,修改代码为:

func main() {

    ctx := context.Background()

    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel()

    sleepAndTalk(ctx, time.Second*5, "hello")

}

我们这一次把代码修改为在main中等待一秒中之后触发 cancel 方法。

http中使用context

首先我们创建两个文件夹:一个server和一个client,分别处理http的服务端和客户端的请求

在server文件夹下创建一个 server.go ,这里我们模拟一下请求很慢的操作,让其等待5秒钟后返回值

package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {

    log.Println("handler started")
    defer log.Println("handler stopped")

    time.Sleep(time.Second * 5)
    fmt.Fprintln(w , "hello world")
}

在client文件夹下我们创建一个 client.go 文件来处理服务端返回的请求

package main

import (
    "io"
    "log"
    "net/http"
    "os"
)

func main() {

    r ,err := http.Get("http://localhost:8080")
    if err != nil {
        log.Fatal(err.Error())
    }
    defer r.Body.Close()
    if r.StatusCode != http.StatusOK {
        log.Fatal(r.Status)
    }

    io.Copy(os.Stdout , r.Body)
}

当我们运行代码时,出现的效果为5秒钟后返回hello world

➜  server git:(master) ✗ go run server.go
2020/04/07 00:30:30 handler started
2020/04/07 00:30:35 handler stopped
➜  ~ curl 127.0.0.1:8080
hello world

现在我们需要把context加入到服务端中,在 server.go 文件中context隐藏在 r *http.Request 中,我们可以调用 r.Context() 返回context。之后使用select语句处理请求

修改的 server.go 文件:

package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {

    ctx := r.Context()

    log.Println("handler started")
    defer log.Println("handler stopped")
    select {
    case <-time.After(5 *time.Second):
        fmt.Fprintln(w , "hello world")
    case <-ctx.Done():
        err := ctx.Err()
        log.Print(err)
        http.Error(w , err.Error(),http.StatusInternalServerError)
    }

}

当我们运行这段代码时,不需要等待5秒钟强行把请求关闭,则在终端会输出取消

➜  server git:(master) ✗ go run server.go
2020/04/07 00:58:30 handler started
2020/04/07 00:58:31 context canceled
2020/04/07 00:58:31 handler stopped

那么我们如何在client中使用 context 呢?

这里我们需要创建一个新的 http.NewRequest() 请求,之后创建一个 context ,把 context 包含到请求中

package main

import (
    "context"
    "io"
    "log"
    "net/http"
    "os"
)

func main() {

    ctx := context.Background()
    req , err := http.NewRequest(http.MethodGet , "http://localhost:8080" , nil)
    if err != nil {
        log.Fatal(err)
    }

    req = req.WithContext(ctx)

    res ,err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err.Error())
    }
    defer res.Body.Close()
    if res.StatusCode != http.StatusOK {
        log.Fatal(res.Status)
    }

    io.Copy(os.Stdout , res.Body)
}

这时候我们在运行代码,在一秒钟之后强制退出

终端会打印:

➜  server git:(master) ✗ go run server.go
2020/04/07 01:09:53 handler started
2020/04/07 01:09:58 handler stopped
2020/04/07 01:10:10 handler started
2020/04/07 01:10:10 context canceled
2020/04/07 01:10:10 handler stopped

可以在真是的场景里,可能用户会因为等待时间过长,而取消请求,这时候我们需要在 client.go 使用 withTimeout 方法来模拟服务器处理时间太长导致用户失去耐心,取消请求

package main

import (
    "context"
    "io"
    "log"
    "net/http"
    "os"
    "time"
)

func main() {

    ctx := context.Background()

    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel()
    req, err := http.NewRequest(http.MethodGet, "http://localhost:8080", nil)
    if err != nil {
        log.Fatal(err)
    }

    req = req.WithContext(ctx)

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err.Error())
    }
    defer res.Body.Close()
    if res.StatusCode != http.StatusOK {
        log.Fatal(res.Status)
    }

    io.Copy(os.Stdout, res.Body)
}

一秒钟之后,客户端终端会打印context取消的错误信息

2020/04/07 01:15:30 Get "http://localhost:8080": context deadline exceeded

context传递value

理论上不推荐在context中传递value,因为可能会导致系统不可控。在真正使用传值的功能时我们也应该非常谨慎,使用 context.Context 进行传递参数请求的所有参数一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

下面我们创建一个log文件夹,创建一个 log.go 文件,这个文件的主要作用是打印日志,把context引用到print方法中。同时创建一个 Decorate 方法,重写handlerfunc方法,在请求的request中添加 context.withValue 产生一个随机数,让 Println 方法中打印这个随机数

log.go代码:

package log

import (
    "context"
    "log"
    "math/rand"
    "net/http"
)

const RequestIDKey = 42

func Println(ctx context.Context , msg string) {

    id, ok := ctx.Value(RequestIDKey).(int64)
    if !ok {
        log.Println("could not find request ID in context")
    }

    log.Printf("[%d] %s" , id , msg)
}

func Decorate(f http.HandlerFunc) http.HandlerFunc {

    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        id := rand.Int63()
        ctx = context.WithValue(ctx , RequestIDKey , id)
        f(w , r.WithContext(ctx))
    }
}

下面修改server文件夹下的 main.go 文件,修改handlefunc。

package main

import (
    "fmt"
    "github.com/my/repo/concurrency/context/log"
    "net/http"
    "time"
)

func main() {
    http.HandleFunc("/", log.Decorate(handler))
    panic(http.ListenAndServe("127.0.0.1:8080", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {

    ctx := r.Context()

    log.Println(ctx, "handler started")
    defer log.Println(ctx, "handler stopped")
    select {
    case <-time.After(5 * time.Second):
        fmt.Fprintln(w, "hello world")
    case <-ctx.Done():
        err := ctx.Err()
        log.Println(ctx, err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }

}

重新启动服务端,会发现后台输出随机数字和传递的字符串

➜  server git:(master) ✗ go run server.go
2020/04/07 22:32:22 [5577006791947779410] handler started
2020/04/07 22:32:23 [5577006791947779410] context canceled
2020/04/07 22:32:23 [5577006791947779410] handler stopped

我们现在需要考虑一下这个问题,如果 RequestIDKey 值被重新设置了,这样的话我们可能不会生成一个随机数

package main

import (
    "context"
    "fmt"
    "github.com/my/repo/concurrency/context/log"
    "net/http"
    "time"
)

func main() {
    http.HandleFunc("/", log.Decorate(handler))
    panic(http.ListenAndServe("127.0.0.1:8080", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {

    ctx := r.Context()
    ctx = context.WithValue(ctx , int(42) , int64(100))
    log.Println(ctx, "handler started")
    defer log.Println(ctx, "handler stopped")
    select {
    case <-time.After(5 * time.Second):
        fmt.Fprintln(w, "hello world")
    case <-ctx.Done():
        err := ctx.Err()
        log.Println(ctx, err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }

}

这里我们设置了

ctx = context.WithValue(ctx , int(42) , int64(100))

这时输出的结果为

➜  server git:(master) ✗ go run server.go
2020/04/07 23:18:20 [100] handler started
2020/04/07 23:18:21 [100] context canceled
2020/04/07 23:18:21 [100] handler stopped

我们如果防止 RequestIDKey 被篡改呢?其实我们可以对这个常量设置类型,让其阻断修改

package log

import (
    "context"
    "log"
    "math/rand"
    "net/http"
)

type key int

const RequestIDKey = key(42)

func Println(ctx context.Context , msg string) {

    id, ok := ctx.Value(RequestIDKey).(int64)
    if !ok {
        log.Println("could not find request ID in context")
    }

    log.Printf("[%d] %s" , id , msg)
}

func Decorate(f http.HandlerFunc) http.HandlerFunc {

    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        id := rand.Int63()
        ctx = context.WithValue(ctx , RequestIDKey , id)
        f(w , r.WithContext(ctx))
    }
}

这里我们设置了一个类型 key 为int类型,这样这个key类型只能在log.go文件里面使用,这样几可以阻止外部对其的修改

这回我们的输出结果为:

➜  server git:(master) ✗ go run server.go
2020/04/07 23:52:45 [5577006791947779410] handler started
2020/04/07 23:52:46 [5577006791947779410] context canceled
2020/04/07 23:52:46 [5577006791947779410] handler stopped

参考文献:

  1. Go 语言并发编程与 Context
  2. justforfunc #9: The Context Package
分享到: