Go 项目的 OpenTracing





5.00/5 (1投票)
在不让您的代码充斥着重复、样板化的追踪代码的情况下,在 Go 应用程序中实现追踪系统的所有细节。
什么是分布式追踪?
大规模云应用程序通常使用相互连接的服务构建,这些服务可能相当难以故障排除。当服务扩展时,简单的日志记录已不够用,需要更深入地了解系统的流程。这就是 分布式追踪 发挥作用的地方;它使开发人员和 SRE 能够详细了解请求在服务系统中传输的过程。
通过分布式追踪,您可以
- 追踪单个请求在分布式系统中复杂路径上的执行路径
- 精确定位瓶颈并测量执行路径特定部分的延迟
- 记录和分析系统行为
OpenTracing 是描述分布式追踪工作方式的开放标准。
追踪中有几个关键术语
- Trace: 请求执行路径的记录
- Span: 一个命名的、计时的操作,代表追踪中的一个连续段
- Root Span: 追踪中的第一个 span – 追踪中所有 span 的共同祖先
- Context: 标识请求的信息,用于连接分布式追踪中的 span
追踪记录通常看起来像这样
我们之前在本系列第一篇文章中探讨了 OpenTracing 的实现。接下来,我们想为 mattermost-server 添加分布式追踪功能。为此,我们选择了 OpenTracing Go。
在本文中,我们将讨论在 Go 应用程序中实现追踪系统的所有细节,而不会让您的代码充斥着重复、样板化的追踪代码。
目标
那么我们实际在做什么呢?我们希望确保服务器处理的每个 API 请求都将被记录到追踪中,并附带上下文信息。这使我们能够深入了解执行情况,并轻松进行问题分析。
生成的系统追踪将如下所示(使用 Jaeger Web UI 可视化)
直接的追踪实现
为了给任何 API 调用添加追踪,我们可以在 `ServeHTTP` 函数中执行以下操作
package web
import (
// ...
"github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
spanlog "github.com/opentracing/opentracing-go/log"
)
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &Context{}
// Start root span
span, ctx := tracing.StartRootSpanByContext(context.Background(), "apiHandler")
// Populate different span fields based on request headers
carrier := opentracing.HTTPHeadersCarrier(r.Header)
_ = opentracing.GlobalTracer().Inject(span.Context(), opentracing.HTTPHeaders, carrier)
ext.HTTPMethod.Set(span, r.Method)
ext.HTTPUrl.Set(span, c.App.Path())
ext.PeerAddress.Set(span, c.App.IpAddress())
span.SetTag("request_id", c.App.RequestId())
span.SetTag("user_agent", c.App.UserAgent())
// On handler exit, do the following:
defer func() {
// In case of an error, add it to the trace
if c.Err != nil {
span.LogFields(spanlog.Error(c.Err))
ext.HTTPStatusCode.Set(span, uint16(c.Err.StatusCode))
ext.Error.Set(span, true)
}
// Finish the span
span.Finish()
}()
// Set current context to the one we got from root span -
// it will be passed down to actual API handlers
c.App.SetContext(ctx)
// ...
// Execute the actual API handler
h.HandleFunc(c, w, r)
}
接下来,我们将修改由 API 处理程序调用的实际业务逻辑函数,将其嵌套在父 span 中(我们将以 `SearchUsers` 为例)
func (a *App) SearchUsers(props *model.UserSearch, options *model.UserSearchOptions)
([]*model.User, *model.AppError) {
// Save previous context
origCtx := a.ctx
// Generate new span, nested inside the parent span
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SearchUsers")
// Set new context
a.ctx = newCtx
// Log some parameters
span.SetTag("searchProps", props)
// On function exit, restore context and finish the span
defer func() {
a.ctx = origCtx
span.Finish()
}()
// ...
// Perform actual work
// ...
// In case of an error, add it to the span
if err != nil {
span.LogFields(spanlog.Error(err))
ext.Error.Set(span, true)
}
// Return results
}
相当直接,对吧?我们通过创建一个根 span 来标记我们的“入口点”,用有用的上下文信息填充它,将上下文向下传递,并在其下方创建一个新的 span。
我们可以在这里停止,因为这就是您拥有有效追踪所需的一切!**但是**,对于像 `mattermost-server` 这样的大型应用程序,用追踪代码包装所有 900 多个 API 处理程序将极其耗费人力,并在源代码中产生大量噪音。
那么,我们能做得更好吗?
装饰器模式
在深入探讨我们的解决方案之前,我想先介绍装饰器模式。
引用 Wikipedia
在面向对象编程中,装饰器模式是一种设计模式,它允许在不影响同一类的其他对象行为的情况下,动态地向单个对象添加行为。装饰器模式通常有助于遵循单一职责原则,因为它允许将功能划分给具有独特关注点的类。装饰器模式在结构上与责任链模式几乎相同,区别在于在责任链中,只有一个类处理请求,而对于装饰器,所有类都处理请求。
简单来说,假设我们有一个名为 `Cow` 的对象,它有一些方法
我们想在 `Cow` 已有的功能之上引入额外的功能,而不修改 `Cow` 对象的实际代码。例如,我们想测量每个方法的性能,并记录传递给每个方法的参数。如果应用 装饰器模式,看起来会是这样
我们将 `Cow` 的每个方法都包装在一系列额外的函数中:`f(x) = y` 变成了 `f(x) = a(b(y))`,每个函数都有其自身的职责。
如果我们将相同的模式应用于我们的问题,我们可以用 OpenTracing 装饰 `mattermost-server` 的所有 API 调用,而不实际修改函数本身!
在其他动态语言中实现这种功能是相当简单的。例如,以下是 JavaScript 在给定一个简单的 `Cow` 对象时如何处理的
const cow = {
feed: function(x) {
return `Ate for ${x} seconds!`
},
speak: function(x) {
return `${"Moo ".repeat(x)}!`
}
}
console.log(cow.feed(20))
console.log(cow.speak(3))
我们可以用 代理 来包装它
const tracerHandler = {
get: function(target, prop, receiver) {
if (typeof target[prop] === "function") {
return function(...args) {
console.log(`'${prop} 'called with arguments: `, ...arguments);
return target[prop](...arguments);
};
}
}
};
const timerHandler = {
get: function(target, prop, receiver) {
if (typeof target[prop] === "function") {
return function(...args) {
console.log(`starting '${prop}'`);
const t1 = window.performance.now();
const res = target[prop](...arguments);
const t2 = window.performance.now();
console.log(`'${prop}' took ${t2 - t1}ns`);
return res;
};
}
}
};
const proxy = new Proxy(cow, tracerHandler);
const proxy2 = new Proxy(proxy, timerHandler);
console.log(proxy2.feed(20));
console.log(proxy2.speak(3));
不幸的是,在 Go 中,没有办法以高性能的方式做到这一点,常规方法将涉及使用反射,这会严重影响底层代码的性能。
我们的解决方案
我们选择的装饰器模式的实现包括三个部分
- 结构体嵌入
- 使用 AST 进行代码解析
- 使用模板进行代码生成
1. 结构体嵌入
引用 Go FAQ
虽然 Go 有类型和方法,并且允许面向对象的编程风格,但没有类型层次结构。Go 中的“接口”概念提供了一种不同的方法,我们认为这种方法易于使用,并且在某些方面更通用。还有方法可以将类型嵌入到其他类型中,以提供与子类化类似但**不完全相同**的东西。
type Animal struct{
Name string
}
type Cow struct{
Animal
}
func (c Cow) Speak() {
fmt.Printf("Moo, I am a %s", c.Animal.Name)
}
func main() {
a := Animal{Name:"Cow"}
c := Cow{Animal:a}
c.Speak()
}
结构体嵌入如何帮助我们实现装饰器模式?
type Speaker interface {
Speak(x int)
}
type Animal struct {
Name string
}
type TraceAnimal struct {
Speaker
}
type MeasureAnimal struct {
Speaker
}
func (c Animal ) Speak(x int) {
fmt.Println(strings.Repeat("I am a " + c.Name + " ",x))
}
func (c TraceAnimal) Speak(x int) {
fmt.Printf("Running Speak(x) function with x=%d!\n",x)
c.Speaker.Speak(x)
}
func (c MeasureAnimal) Speak(x int) {
fmt.Println("Timing Speak() function...")
t := time.Now()
c.Speaker.Speak(x)
fmt.Printf("Speak(%d) took %s\n", x, time.Since(t))
}
func main() {
a := Animal{Name: "Cow"}
c := TraceAnimal {Speaker: a}
d := MeasureAnimal{Speaker: c}
d.Speak(2)
}
运行以下代码将产生
Timing Speak() function...
Running Speak(x) function with x=2!
I am a Cow I am a Cow
Speak(2) took 0s
因此,我们基本上对原始的 `Speak()` 方法实现了两个装饰器。首先,我们在 `MeasureAnimal` 中开始计时执行,然后将其传递给 `TraceAnimal`,后者又调用实际的 `Speak()` 实现。“
这效果很好,并且保持了高性能,因为我们不使用任何动态技术,如反射。然而,这非常冗长,并且需要我们编写大量的包装代码——这一点也不好玩。
我们可以做得更好!
2. 使用 AST 进行代码解析
使用本系列第 1 和第 2 部分讨论的方法,我们可以扫描我们想要包装的结构体的接口,并收集生成装饰器/包装器所需的所有信息。让我们深入了解一下。
首先,我们在包含接口的输入文件上启动 AST 解析器,并开始遍历找到的节点
package main
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/ioutil"
"log"
"os"
"path"
"strings"
"text/template"
"golang.org/x/tools/imports"
)
func main() {
fset := token.NewFileSet() // Positions are relative to fset
file, err := os.Open("animal.go")
if err != nil {
return nil, fmt.Errorf("Unable to open %s file: %w", inputFile, err)
}
defer file.Close()
src, err := ioutil.ReadAll(file)
if err != nil {
return nil, err
}
f, err := parser.ParseFile(fset, "animal.go", src, parser.AllErrors|parser.ParseComments)
if err != nil {
return nil, err
}
ast.Inspect(f, func(n ast.Node) bool {
// ... Handle the found nodes
})
}
为了区分接口方法和其他 AST 节点,我们可以这样做
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.TypeSpec:
if x.Name.Name == "Speaker" {
for _, method := range x.Type.(*ast.InterfaceType).Methods.List {
methodName := method.Names[0].Name
// Here we can parse all the required information about the method
methods[methodName] = extractMethodMetadata(method, src)
}
}
}
return true
})
让我们定义几个结构体来帮助我们收集有关方法的信息
type methodParam struct {
Name string
Type string
}
type methodData struct {
Params []methodParam
Results []string
}
// For each found method we'll store its name, params with their types,
// and return types in methods := map[string]methodData {}
现在让我们编写一个简短的函数来用方法的元数据填充这些结构体
func formatNode(src []byte, node ast.Expr) string {
return string(src[node.Pos()-1 : node.End()-1])
}
func extractMethodMetadata(method *ast.Field, src []byte) methodData {
params := []methodParam{}
results := []string{}
e := method.Type.(*ast.FuncType)
if e.Params != nil {
for _, param := range e.Params.List {
for _, paramName := range param.Names {
paramType := formatNode(src, param.Type)
params = append(params, methodParam{Name: paramName.Name, Type: paramType})
}
}
}
if e.Results != nil {
for _, r := range e.Results.List {
typeStr := formatNode(src, r.Type)
if len(r.Names) > 0 {
for _, k := range r.Names {
results = append(results, fmt.Sprintf("%s %s", k.Name, typeStr))
}
} else {
results = append(results, typeStr)
}
}
}
return methodData{Params: params, Results: results}
}
现在我们可以解析我们的接口,并将获得类似:`map[Speak:{Params:[{Name:x Type:int}] Results:[]}]` 的结果。正如您所见,我们收集了接口方法所需的所有信息,现在可以继续使用这些数据生成装饰器了。
3. 使用模板进行代码生成
我们开始吧!我们将首先定义几个在代码生成过程中将有用的辅助函数。它们将操作我们之前收集的元数据。
helperFuncs := template.FuncMap{
"joinResults": func(results []string) string {
return strings.Join(results, ", ")
},
"joinResultsForSignature": func(results []string) string {
return fmt.Sprintf("(%s)", strings.Join(results, ", "))
},
"joinParams": func(params []methodParam) string {
paramsNames := []string{}
for _, param := range params {
s := param.Name
if strings.HasPrefix(param.Type, "...") {
s += "..."
}
paramsNames = append(paramsNames, s)
}
return strings.Join(paramsNames, ", ")
},
"joinParamsWithType": func(params []methodParam) string {
paramsWithType := []string{}
for _, param := range params {
paramsWithType = append(paramsWithType,
fmt.Sprintf("%s %s", param.Name, param.Type))
}
return strings.Join(paramsWithType, ", ")
},
}
接下来,我们将为我们的两个装饰器创建一个 Go 模板
1 tracerTemplate := `
2 // Generated code; DO NOT EDIT.
3 package animals
4
5 type AnimalTracer struct {
6 Speaker
7 }
8 {{range $index, $element := .}}
9 func (a *AnimalTracer) {{$index}}({{$element.Params |
joinParamsWithType}}) {{$element.Results | joinResultsForSignature}} {
10 fmt.Printf("Running {{$index}}({{$element.Params | joinParams}})
with {{range $paramIdx, $param := $element.Params}}'{{$param.Name}}'=%v {{end}}",
{{$element.Params | joinParams}})
11 {{- if $element.Results | len | eq 0}}
12 a.Speaker.{{$index}}({{$element.Params | joinParams}})
13 {{else}}
14 return a.Speaker.{{$index}}({{$element.Params | joinParams}})
15 {{end}}
16 }
17 {{end}}
18 `
我知道这看起来有点吓人,但原理却很简单。给定以下元数据:`map[Speak:{Params:[{Name:x Type:int}] Results:[]}]`,我们想生成一个新的结构体,它嵌入我们的 `Animal` 并将其调用包装在额外的功能中。
我将逐行解释模板
- 2 – 6: 定义新的结构体
- 7: 迭代我们元数据中的方法
- 8: 在新结构体上定义一个函数,其签名与原始函数完全相同
- 9: 使用上面定义的辅助函数,通过迭代 `$element.Params` 来打印所有函数参数
- 10 – 14: 运行实际代码,并根据函数签名退出函数或返回结果
对于 `Timer` 装饰器,我们将编写以下模板
1 timerTemplate := `
2 // Generated code; DO NOT EDIT.
3 package animals
4
5 type AnimalTimer struct {
6 Speaker
7 }
8 {{range $index, $element := .}}
9 func (a *AnimalTimer) {{$index}}({{$element.Params | joinParamsWithType}})
{{$element.Results | joinResultsForSignature}} {
10 fmt.Println("Timing {{$index}} function...")
11 __t := time.Now()
12 {{- if $element.Results | len | eq 0}}
13 a.Speaker.{{$index}}({{$element.Params | joinParams}})
14 {{else}}
15 ret := a.Speaker.{{$index}}({{$element.Params | joinParams}})
16 {{end}}
17 fmt.Printf("{{$index}} took %s\n", x, time.Since(__t))
18 {{- if not ($element.Results | len | eq 0)}}
19 return ret
20 {{end}}
21
22 }
23 {{end}}
24 `
这与上面的模板非常相似,只是这次我们记录函数的开始时间,并在退出时打印经过的时间。
有了这些模板,我们现在就可以生成装饰器了!
// Create output buffer
out := bytes.NewBufferString("")
// Parse the template and pass it the helper functions
t := template.Must(template.New("my.go.tmpl").Funcs(helperFuncs).Parse(tracerTemplate))
// Execute the template and pass it the metadata we collected before
t.Execute(out, metadata)
// Add needed imports and format the code before printing
formattedCode, err := imports.Process("animal_tracer.go", out.Bytes(),
&imports.Options{Comments: true})
if err != nil {
fmt.Printf("cannot format source code, might be an error in template: %s\n", err)
return err
}
// print it out!
fmt.Println(string(formattedCode))
结果将是。
package animals
import "fmt"
type AnimalTracer struct {
Speaker
}
func (a *AnimalTracer) Speak(x int) {
fmt.Printf("Running Speak(x) with 'x'=%v ", x)
a.Speaker.Speak(x)
}
太棒了!
同样,运行 `timerTemplate` 生成器将产生
package animals
import (
"fmt"
"time"
)
type AnimalTimer struct {
Speaker
}
func (a *AnimalTimer) Speak(x int) {
fmt.Println("Timing Speak function...")
__t := time.Now()
a.Speaker.Speak(x)
fmt.Printf("Speak took %s\n", x, time.Since(__t))
}
收尾
使用上一节中的技术,我们现在可以使用以下模板生成我们想要的 `OpenTracing` 装饰器
{{range $index, $element := .Methods}}
func (a *{{$.Name}}) {{$index}}({{$element.Params | joinParamsWithType}})
{{$element.Results | joinResultsForSignature}} {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.{{$index}}")
a.ctx = newCtx
a.app.Srv().Store.SetContext(newCtx)
defer func() {
a.app.Srv().Store.SetContext(origCtx)
a.ctx = origCtx
}()
{{range $paramIdx, $param := $element.Params}}
{{ shouldTrace $element.ParamsToTrace $param.Name }}
{{end}}
defer span.Finish()
{{- if $element.Results | len | eq 0}}
a.app.{{$index}}({{$element.Params | joinParams}})
{{else}}
{{$element.Results | genResultsVars}} := a.app.{{$index}}
({{$element.Params | joinParams}})
{{if $element.Results | errorPresent}}
if {{$element.Results | errorVar}} != nil {
span.LogFields(spanlog.Error({{$element.Results | errorVar}}))
ext.Error.Set(span, true)
}
{{end}}
return {{$element.Results | genResultsVars -}}
{{end}}}
{{end}}
呼,这真是一段漫长的旅程,不是吗?希望您觉得它很有趣。您可以在 `mattermost-server` 的 /app/layer_generators/main.go 中找到实际的生成器实现。
题外话:这只是处理此问题的一种方法。并非每个人都想过度依赖代码生成,因为它隐藏了大量的实现细节,并使构建过程复杂化(每次接口更改时都必须重新运行生成器)。我们之所以选择这种方法,是因为它的灵活性和性能。
如果您对如何以更简洁的方式实现这一点有任何意见或想法,请随时到 Mattermost Community 服务器。我将**非常**乐意进一步讨论。
历史
- 2020 年 9 月 22 日:初始版本