WHAT(Go代码调用链路可视化工具是什么?)

链路追踪(Tracing)不同,Tracing关注复杂的分布式环境中各个服务节点间的调用关系,主要用于服务治理。而我们本次探索的代码调用链路则是代码方法级别的调用关系,主要用于代码设计

可视化工具可以将代码间的调用关系通过图表的方式展示出来,如下图(来自go-callvis工具)

8

WHY

将代码间调用关系可视化后,我们可以更快速的了解代码逻辑。

尤其是在进行代码重构时,使用工具能让我们对代码有一个整体的了解,而不是人工一行行代码去梳理。

HOW(如何生成调用关系图)

go-callvis是github上开源的一个工具

1
2
此工具的目的是使用调用图中的数据及其与包和类型的关系,为开发人员提供Go程序的可视化概述。
这在代码复杂度更高的大型项目中尤其有用,或者您只是试图理解其他人的代码。 

下图是go-callvis自己的调用关系: 9

go-callvis除了可以生成图片文件,还可以生成svg图,它默认会启动一个Web Server,我们可以在浏览器访问它的地址,在页面上实现交互式的浏览调用关系。

SVG:

1
2
SVG是一种用XML定义的语言,用来描述二维矢量及矢量/栅格图形。
SVG图形是可交互的和动态的,可以在SVG文件中嵌入动画元素或通过脚本来定义动画。

下面是一段SVG代码:

1
2
3
<g id="a_clust3"><a xlink:href="/?f=github.com/goccy/go-graphviz/cgraph" xlink:title="package: github.com/goccy/go&#45;graphviz/cgraph">
<polygon fill="#ffffe0" stroke="#000000" stroke-width=".8" points="861.8909,-442.8 861.8909,-521.8 972.5803,-521.8 972.5803,-442.8 861.8909,-442.8"/>
<text text-anchor="middle" x="917.2356" y="-503.4" font-family="Tahoma bold" font-size="16.00" fill="#000000">cgraph</text> 

可以看到和HTML类似,同样是一种标记语言。

go-callvis使用介绍

首先使用go get -u github.com/ofabry/go-callvis 命令进行安装,安装完成后go-callvis将出现在你得GOPATH/bin目录下。

命令行参数解释:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
go-callvis: visualize call graph of a Go program.

Usage:
  go-callvis [flags] package //package即想要进行分析的包名,注意:package必须是main包或者包含单元测试的包,原因稍候介绍


Flags:

  -cacheDir string
        如果指定了缓存目录生成过的图片将被保存下来后续使用时不需要再渲染
  -debug
        开启调试日志.
  -file string
        指定输出文件名使用后将不在启动Web Server
  -focus string
        定位到指定的packagepackage可以是包名也可以是包的import路径
  -format string
        指定输出文件格式[svg | png | jpg | ...] (default "svg")
  -graphviz
        使用本地安装的graphviz的dot命令否则使用graphviz的go库
  -group string
        分组方式 packages and/or types [pkg, type] (separated by comma) (default "pkg")
  -http string
        Web Server地址. (default ":7878")
  -ignore string
        忽略的packages多个使用逗号分隔。(使用前缀匹配
  -include string
        必须包含的packages多个使用逗号分隔优先级比ignore和limit高使用前缀匹配
  -limit string
        限制的packages多个使用逗号分隔使用前缀匹配
  -minlen uint
        两个节点直接最小连线长度用于更宽的输出. (default 2)
  -nodesep float
        同一列中两个相邻节点之间的最小空间用于更高的输出. (default 0.35)
  -nodeshape string
        节点形状 (查看graphvis文档获取更多可用值) (default "box")
  -nodestyle string
        节点style(查看graphvis文档获取更多可用值) (default "filled,rounded")
  -nointer
        忽略未导出的方法
  -nostd
        忽略标准库的方法
  -rankdir string
        对齐方式 [LR 调用关系从左到右| RL 从右到左| TB 从上到下| BT 从下到上] (default "LR")
  -skipbrowser
        不打开浏览器
  -tags build tags
        支持传入build tags
  -tests
        包含测试代码
  -version
        Show version and exit. 

使用示例

1. 最简单的命令如下:
1
go-callvis .

此命令会在当前目录进行分析,如果没有错误,会自动打开浏览器,在浏览器中展示图

2. 指定package
1
go-callvis github.com/ofabry/go-callvis

指定的package是main,工具将以main方法作为起始点进行链路生成

3. 指定包含单元测试方法的package
1
go-callvis -tests yourpackage

如果不想从main方法开始,可以使用-tests参数,在想要进行链路生成的package下面创建一个单元测试方法,测试方法中调用你想要作为起始点的方法。

4. 输出结果到文件

以上都是打开浏览器进行交互式浏览和操作,如果只要输出文件,可以使用-file参数

1
go-callvis -file yourfilename -format png  yourpackage
5. include、limit、ignore参数

这三个参数用来控制过滤哪些调用关系(pkg1.FuncA -> pkg2.FuncB,形成一条调用关系,pkg1.FuncA为caller,pkg2.FuncB为callee)。例如代码中频繁出现的log包方法调用,没必要输出到链路中。可以使用ignore参数进行过滤

1
 go-callvis -ignore yourlogpkg yourpackage
  1. 当调用关系中caller的pkg或者callee的pkg有任意一个在include中,则这条关系被保留。
  1. 不满足1时,当调用关系中caller的pkg或者callee的pkg有任意一个不在limit中,则这条关系被过滤。
  1. 不满足1时,当调用关系中caller的pkg或者callee的pkg有任意一个在ignore中,则这条关系被过滤。
6. 过滤标准库

过滤掉代码中频繁使用的标准库方法调用,例如:fmt、math、strings等

1
 go-callvis -nostd yourpackage
7. build tags

go build命令可以允许我们传入-tags参数,来控制编译的版本

1
go build -tags release 

例如有两个配置文件dev_config.go和release_config.go,内容分别为

dev_config.go

1
2
3
4
5
 // +build dev

package main

var version = "DEV"

release_config.go

1
2
3
4
5
6
// +build release

package main

const version = "RELEASE"

每个文件都有一个编译选项(+build),编译器会根据-tags传入的参数识别应该编译哪一个文件。从而达到区分环境的效果。 go-callvis的tags参数同理。

HOW IT WORKS(go-callvis是怎么工作的)

从go-callvis的调用关系中看到,它引用了如下几个package:

1
2
3
4
5
6
7
8
	"golang.org/x/tools/go/packages"
	"golang.org/x/tools/go/pointer"
	"golang.org/x/tools/go/ssa"
	"golang.org/x/tools/go/ssa/ssautil"
    "golang.org/x/tools/go/callgraph"
    "golang.org/x/tools/go/buildutil"
    "github.com/goccy/go-graphviz"
    "github.com/pkg/browser"

其中buildutil用来接收-tags参数,browser用来打开浏览器,go-graphviz是graphviz的Go bindings实现版本。

Graphviz

1
2
3
Graphviz是开源的图形可视化软件。
图形可视化是一种将结构信息表示为抽象图形和网络图的方法。
它在网络、生物信息学、软件工程、数据库和网页设计、机器学习以及其他技术领域的可视化界面中有着重要的应用。 

Go的PProf使用的可视化工具就是Graphviz,在浏览器上打开的PProf图形页面需要本地安装Graphviz。

SSA

packages、ssautil用来读取go源码并解析成相应的SSA中间代码

1
2
包ssa定义了Go程序元素(包、类型、函数、变量和常量)的表示
使用函数体的静态单赋值(ssa)形式中间代码(IR) 

Pointer

golang.org/x/tools/go/pointer包负责解析输入的ssa中间代码,生成代码调用关系

CallGraph

golang.org/x/tools/go/callgraph 包的Graph是Pointer生成结果后,生成的调用图数据,Graph结构如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Graph struct {
	Root  *Node                   // the distinguished root node
	Nodes map[*ssa.Function]*Node // all nodes by function
}
type Node struct {
	Func *ssa.Function // the function this node represents
	ID   int           // 0-based sequence number
	In   []*Edge       // unordered set of incoming call edges (n.In[*].Callee == n)
	Out  []*Edge       // unordered set of outgoing call edges (n.Out[*].Caller == n)
}

示例代码

使用pointer解析方法调用关系示例:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package main

import (
	"flag"
	"fmt"
	"go/build"
	"os"
	"path/filepath"
	"strings"

	"golang.org/x/tools/go/callgraph"
	"golang.org/x/tools/go/packages"
	"golang.org/x/tools/go/pointer"
	"golang.org/x/tools/go/ssa"
	"golang.org/x/tools/go/ssa/ssautil"
)

func main() {
	flag.Parse()

	//生成Go Packages
	cfg := &packages.Config{Mode: packages.LoadAllSyntax}
	pkgs, err := packages.Load(cfg, flag.Args()...)
	if err != nil {
		fmt.Fprintf(os.Stderr, "load: %v\n", err)
		os.Exit(1)
	}
	if packages.PrintErrors(pkgs) > 0 {
		os.Exit(1)
	}

	//生成ssa
	prog, pkgs1 := ssautil.AllPackages(pkgs, 0)
	prog.Build()

	//找出main package
	mains, err := mainPackages(pkgs1)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	//使用pointer生成调用链路
	config := &pointer.Config{
		Mains:          mains,
		BuildCallGraph: true,
	}
	result, err := pointer.Analyze(config)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	//遍历调用链路
	callgraph.GraphVisitEdges(result.CallGraph, func(edge *callgraph.Edge) error {
		//过滤非源代码
		if isSynthetic(edge) {
			return nil
		}

		caller := edge.Caller
		callee := edge.Callee

		//过滤标准库代码
		if inStd(caller) || inStd(callee) {
			return nil
		}
		//过滤其他package
		limits := []string{"github/erberry/test"}
		if !inLimits(caller, limits) || !inLimits(callee, limits) {
			return nil
		}

		posCaller := prog.Fset.Position(caller.Func.Pos())
		filenameCaller := filepath.Base(posCaller.Filename)

		//输出调用信息
		fmt.Fprintf(os.Stdout, "call node: %s -> %s (%s -> %s) %v\n", caller.Func.Pkg, callee.Func.Pkg, caller, callee, filenameCaller)
		return nil
	})
}

func mainPackages(pkgs []*ssa.Package) ([]*ssa.Package, error) {
	var mains []*ssa.Package
	for _, p := range pkgs {
		if p != nil && p.Pkg.Name() == "main" && p.Func("main") != nil {
			mains = append(mains, p)
		}
	}
	if len(mains) == 0 {
		return nil, fmt.Errorf("no main packages")
	}
	return mains, nil
}

func isSynthetic(edge *callgraph.Edge) bool {
	return edge.Caller.Func.Pkg == nil || edge.Callee.Func.Synthetic != ""
}

func inStd(node *callgraph.Node) bool {
	pkg, _ := build.Import(node.Func.Pkg.Pkg.Path(), "", 0)
	return pkg.Goroot
}

func inLimits(node *callgraph.Node, limitPaths []string) bool {
	pkgPath := node.Func.Pkg.Pkg.Path()
	for _, p := range limitPaths {
		if strings.HasPrefix(pkgPath, p) {
			return true
		}
	}
	return false
}
 

运行go run main.go github/erberry/test,输出:

1
2
3
4
5
6
call node: package github/erberry/test -> package github/erberry/test (n13:github/erberry/test.main -> n22:github/erberry/test.mainPackages) main.go
call node: package github/erberry/test -> package github/erberry/test (n4571:github/erberry/test.main$1 -> n4572:github/erberry/test.isSynthetic) main.go
call node: package github/erberry/test -> package github/erberry/test (n4571:github/erberry/test.main$1 -> n4573:github/erberry/test.inStd) main.go
call node: package github/erberry/test -> package github/erberry/test (n4571:github/erberry/test.main$1 -> n4574:github/erberry/test.inLimits) main.go
call node: package github/erberry/test -> package github/erberry/test (n4571:github/erberry/test.main$1 -> n4573:github/erberry/test.inStd) main.go
call node: package github/erberry/test -> package github/erberry/test (n4571:github/erberry/test.main$1 -> n4574:github/erberry/test.inLimits) main.go