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 日:初始版本

