一、背景介绍
随着微服务架构在应用开发中的应用越来越广泛,应用程序的分布式特性也越来越明显。同时,在微服务架构中,每个服务都是相对独立的,他们之间的通信是基于网络协议的远程过程调用,分布式服务调用链的管理由于服务之间的异构性和部署的复杂性,加大了分布式系统的调试和问题排查难度。
Go作为一种快速、高效、轻量级的语言被越来越多的公司所采用。在Go开发中,常常会使用goroutine来实现并发和异步,然而,goroutine之间同样会出现分布式调用,因此,为了更好地排查分布式系统中出现的问题,Go社区也实现了一套方便的追踪工具——context。
二、context介绍
context是Go标准库中的一个包,主要用于在多个goroutine之间平衡请求的数据,并控制多个goroutine的处理过程。
1. context的结构体
context在标准库中被定义为:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
其中:
Deadline()
:返回当前Context被取消的截止时间和一个bool值,当返回值为true时说明Context还没被取消,否则说明已被取消。
Done()
:返回一个channel,当Context被取消时,该channel会立即被关闭。
Err()
:返回Context被取消的原因。
Value(key)
:返回Context中与key关联的value值,对于同一个key可以多次调用Value(key)来获取多个value值。
2. context使用方法
context作为一个管理分布式系统的工具,在使用中不可避免地会被嵌入到各个系统之中。在对外提供服务时,我们可以通过构造一个根Context,并不断携带上下文Context信息,使每次请求都关联到指定的Context。这种链式关系,就是分布式调用的追踪信息。
在构造Context时可以使用两种方法:
使用Context的构造函数(WithCancel, WithDeadline, WithTimeout, WithValue)构造一个根Context。
使用WithCancel, WithDeadline, WithTimeout等方法从已有的Context派生一个新的Context。
值得注意的是,每个Context都有一个父Context,派生的Context也会有一个父Context。因此,整个Context可以由一个根Context和派生的Context链条构成。
三、请求追踪实现
在实际开发中,我们可能有一个根Context,然后从根Context中派生出多个子Context,用于在多个goroutine之间共享请求信息和设置请求超时。随着业务逻辑的复杂,会出现多个goroutine之间的嵌套调用,此时,我们需要确保这些处理过程都是一个请求的过程。
1. 实现思路
对于这种嵌套处理的方式,我们可以通过context中的Value特性来实现请求追踪,使用唯一标识符来标识每个请求,然后在动态将该标识符传递到不同的goroutine中,使得请求得以追踪。当一个goroutine启动子goroutine时,需要通过withValue方法将其唯一标识符传递到子goroutine的上下文环境中。
2. 实现示例
下面是一个使用context实现请求追踪的示例:
package main
import (
"fmt"
"context"
"math/rand"
"time"
)
type ctxKey string
const traceIDKey ctxKey = "trace_id"
// 生成一个6位随机数作为请求追踪的唯一标识符
func genTraceID() string {
rand.Seed(time.Now().UnixNano())
return fmt.Sprintf("%06d", rand.Int31n(1000000))
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 将请求追踪标识符放入context
ctx = context.WithValue(ctx, traceIDKey, genTraceID())
process(ctx)
}
func process(ctx context.Context) {
// 获取请求追踪标识符
traceID := ctx.Value(traceIDKey).(string)
// 打印当前请求追踪标识符
fmt.Printf("process trace_id=%s\n", traceID)
// 启动子运行时,并将请求追踪标识符放入子运行时中
ctx = context.WithValue(ctx, traceIDKey, traceID+"->foo")
go foo(ctx)
time.Sleep(time.Second)
}
func foo(ctx context.Context) {
// 获取请求追踪标识符
traceID := ctx.Value(traceIDKey).(string)
// 打印当前请求追踪标识符
fmt.Printf("foo trace_id=%s\n", traceID)
// 启动子运行时,并将请求追踪标识符放入子运行时中
ctx = context.WithValue(ctx, traceIDKey, traceID+"->bar")
go bar(ctx)
time.Sleep(time.Second)
}
func bar(ctx context.Context) {
// 获取请求追踪标识符
traceID := ctx.Value(traceIDKey).(string)
// 打印当前请求追踪标识符
fmt.Printf("bar trace_id=%s\n", traceID)
time.Sleep(time.Second)
}
在该示例中,我们启动了一个根Context,并初始化了一个请求追踪标识符。接下来,我们通过process方法的参数中传递context,并通过WithValues方法将该标识符放入到context中。
在process方法中,我们将context信息传递给一个子goroutine中,并在子goroutine上下文环境中添加了traceID+"->foo"。同理,在foo和bar中也做了同样的处理,不断将traceID传递到更深的goroutine之中。运行上述代码,输出内容如下:
process trace_id=724301
foo trace_id=724301->foo
bar trace_id=724301->foo->bar
可以看到,每个goroutine中都打印出了请求追踪标识符和其父级的标识符,在使用中可以清晰地识别出一个请求的过程。
四、结论
通过本文的介绍,我们可以了解到在分布式系统的开发过程中,使用context可以实现请求的追踪,使得在分布式系统中我们可以很好地排查各种问题。同时,通过Value在goroutine之间传递数据也是很方便的。
当然,使用context需要特别注意,因为一个context被取消或截止时间过期后,所有从该Context中派生出来的Context都会被取消。
最后,我们看到context可以为我们的系统提供很好的请求追踪功能,帮助我们很好地处理分布式中各种问题。此外,context还是Golang框架中的重要组成部分,学习使用context是Golang架构设计的基础之一。