65.9K
CodeProject 正在变化。 阅读更多。
Home

Go 项目的 OpenTracing

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2020年9月22日

CPOL

7分钟阅读

viewsIcon

3919

在不让您的代码充斥着重复、样板化的追踪代码的情况下,在 Go 应用程序中实现追踪系统的所有细节。

什么是分布式追踪?

大规模云应用程序通常使用相互连接的服务构建,这些服务可能相当难以故障排除。当服务扩展时,简单的日志记录已不够用,需要更深入地了解系统的流程。这就是 分布式追踪 发挥作用的地方;它使开发人员和 SRE 能够详细了解请求在服务系统中传输的过程。

通过分布式追踪,您可以

  1. 追踪单个请求在分布式系统中复杂路径上的执行路径
  2. 精确定位瓶颈并测量执行路径特定部分的延迟
  3. 记录和分析系统行为

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 中,没有办法以高性能的方式做到这一点,常规方法将涉及使用反射,这会严重影响底层代码的性能。

我们的解决方案

我们选择的装饰器模式的实现包括三个部分

  1. 结构体嵌入
  2. 使用 AST 进行代码解析
  3. 使用模板进行代码生成

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 日:初始版本
© . All rights reserved.