go代码调用关系可视化工具

2,095 阅读7分钟

同步环境

  1. macOS
  2. go1.16

安装

# 安装完成后go-callvis将出现在你得GOPATH/bin目录下
go get -u github.com/ofabry/go-callvis

什么是可视化?
答:是利用计算机图形学和图像处理技术,将数据转换成图形或图像在屏幕上显示出来,并进行交互处理的理论、方法和技术。举例:Excel到图表的关系。 可视化后能咋样? 答:直白的说就相当于你不懂英文,你看英文的电影肯定有很多不懂得地方,但是加上中文字幕,聪明人就不说透了,可视化后就相当于中文字幕,让你看的明白

命令行参数解释:

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. 

使用示例

tips:

  1. cd project_dir
  2. 启动go-callvis . . 是main包,main函数所在的目录,如果使用了mod,或者想用mod name,执行 go-callvis modName ,如果声明的是github/xxx/project,写的时候一定写全。
go-callvis -skipbrowser  -nostd  -group pkg,type -focus=3day/cmd 3day
# 3day 是我在mod文件中声明的包名
# -focus=3day/cmd 可视化3day下的cmd这个包,


运行命令,默认会打开浏览器加载地址http://localhost:7878

图片格式为 svg,也可以添加 -format=png,指定以png形式展示

推荐使用svg,svg格式的内容是可交互的,比如这里想查看cmd包的内容就可以点击 对应的模块来看详情。

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 -skipbrowser yourpackage
 
 go-callvis -skipbrowser  -nostd  -group pkg,type -focus=3day/cmd 3day

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参数同理。

从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包负责解析输入的ssa中间代码,生成代码调用关系

CallGraph

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

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