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

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

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

WHY

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

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

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

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

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

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

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

SVG:

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

下面是一段SVG代码:

<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: 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
        定位到指定的package,package可以是包名也可以是包的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. 最简单的命令如下:

go-callvis .

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

2. 指定package

go-callvis github.com/ofabry/go-callvis

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

3. 指定包含单元测试方法的package

go-callvis -tests yourpackage

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

4. 输出结果到文件

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

go-callvis -file yourfilename -format png  yourpackage

5. include、limit、ignore参数

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

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

6. 过滤标准库

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

 go-callvis -nostd yourpackage

7. build tags

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

go build -tags release 

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

dev_config.go

 // +build dev

package main

var version = "DEV"

release_config.go

// +build release

package main

const version = "RELEASE"


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

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

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

    "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

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

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

SSA

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

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

Pointer

golang.org/x/tools/go/pointer

CallGraph

golang.org/x/tools/go/callgraph
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解析方法调用关系示例:

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
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