golang源码分析:goc集成测试覆盖率实现原理(2)

2023-03-01 16:20:56 浏览数 (1)

下面我们进入源代码来分析goc的具体实现,它的入口在goc.go文件里,是用来cobra的命令解析方式。

代码语言:javascript复制
    cmd.Execute()

然后会启动一个http代理服务cmd/server.go

代码语言:javascript复制
server, err := cover.NewFileBasedServer(localPersistence)
server.Run(port)

然后启用一个多路writer,分别写入文件和标准输出:

代码语言:javascript复制
func (s *server) Run(port string) {
        f, err := os.Create(LogFile)
        mw := io.MultiWriter(f, os.Stdout)
        r := s.Route(mw)
        log.Fatal(r.Run(port))

然后基于gin框架,启动一个http服务

代码语言:javascript复制
  func (s *server) Route(w io.Writer) *gin.Engine {
      r := gin.Default()
      r.StaticFile("static", "./" s.PersistenceFile)
      v1 := r.Group("/v1")
      {
        v1.POST("/cover/register", s.registerService)
        v1.GET("/cover/profile", s.profile)
        v1.POST("/cover/profile", s.profile)
        v1.POST("/cover/clear", s.clear)
        v1.POST("/cover/init", s.initSystem)
        v1.GET("/cover/list", s.listServices)
        v1.POST("/cover/remove", s.removeServices)
      }

以服务注册为例,它类似于服务发现,用来把带桩的server注册到到goc服务上

代码语言:javascript复制
func (s *server) registerService(c *gin.Context) {
        var service ServiceUnderTest
  if err := c.ShouldBind(&service); err != nil {
      u, err := url.Parse(service.Address)
      service.Address = fmt.Sprintf("%s://%s", u.Scheme, host)
        address := s.Store.Get(service.Name)
  if !contains(address, service.Address) {
    if err := s.Store.Add(service);

然后就可以看到进行性能分析的具体实现

代码语言:javascript复制
func (s *server) profile(c *gin.Context) {
      if err := c.ShouldBind(&body); err != nil {
      allInfos := s.Store.GetAll()
      filterAddrInfoList, err := filterAddrInfo(body.Service, body.Address, body.Force, allInfos)
      for _, addrInfo := range filterAddrInfoList {
        pp, err := NewWorker(addrInfo.Address).Profile(ProfileParam{})
        profile, err := convertProfile(pp)
        mergedProfiles = append(mergedProfiles, profile)
      merged, err := cov.MergeMultipleProfiles(mergedProfiles)
      if err := cov.DumpProfile(merged, c.Writer); err != nil {

先拿到全量地址,然后过滤出我们需要的的地址,然后向对应地址发送请求,获取该服务的覆盖率信息。本质上是一个代理,解耦了被检测的服务和goc server,发起代理请求的代码实现位于:pkg/cover/client.go

代码语言:javascript复制
func NewWorker(host string) Action {
      _, err := url.ParseRequestURI(host)
      client: http.DefaultClient,
代码语言:javascript复制
func (c *client) Profile(param ProfileParam) ([]byte, error) {
      u := fmt.Sprintf("%s%s", c.Host, CoverProfileAPI)
      res, profile, err := c.do("POST", u, "application/json", bytes.NewReader(body))

分析完提供覆盖率服务的过程,我们分析下编译出带桩二进制代码的过程cmd/build.go

代码语言:javascript复制
    wd, err := os.Getwd()
    runBuild(args, wd)

先构建出编译需要的环境,然后进行打桩代码的插入,最后进行编译:

代码语言:javascript复制
func runBuild(args []string, wd string) {
      gocBuild, err := build.NewBuild(buildFlags, args, wd, buildOutput)
      ci := &cover.CoverInfo{
      err = cover.Execute(ci)
      err = gocBuild.Build()

pkg/build/build.go

代码语言:javascript复制
func NewBuild(buildflags string, args []string, workingDir string, outputDir string) (*Build, error) {
      b := &Build{
      if false == b.validatePackageForBuild() {
      b.MvProjectsToTmp()
      dir, err := b.determineOutputDir(outputDir)

编译的过程并不会在原来的目录,而是会在一个临时目录里进行,否则和以前的源码是会有冲突的。

代码语言:javascript复制
b.Pkgs, err = cover.ListPackages(b.WorkingDir, strings.Join(listArgs, " "), "")
err = b.mvProjectsToTmp()
b.OriGOPATH = os.Getenv("GOPATH")
b.NewGOPATH = fmt.Sprintf("%v:%v", b.TmpDir, b.OriGOPATH)

首先是获取项目里所有的包名,其实是调用了go list命令

代码语言:javascript复制
cmd := exec.Command("/bin/bash", "-c", "go list " args)

然后进行路径的转换操作

代码语言:javascript复制
b.TmpDir = filepath.Join(os.TempDir(), tmpFolderName(b.WorkingDir))
os.RemoveAll(b.TmpDir)
b.GlobalCoverVarImportPath = filepath.Join("src", tmpPackageName(b.WorkingDir))
err := os.MkdirAll(filepath.Join(b.TmpDir, b.GlobalCoverVarImportPath), os.ModePerm)
b.IsMod, b.Root, err = b.traversePkgsList()
b.TmpWorkingDir, err = b.getTmpwd()
b.cpGoModulesProject()
updated, newGoModContent, err := b.updateGoModFile()   

最后更新go mod文件

代码语言:javascript复制
tempModfile := filepath.Join(b.TmpDir, "go.mod")
buf, err := ioutil.ReadFile(tempModfile)

紧接着就会进行编译操作,就是调用go build

代码语言:javascript复制
func (b *Build) Build() error {
      cmd := exec.Command("/bin/bash", "-c", "go build " b.BuildFlags " " b.Packages)

比较核心的是pkg/cover/cover.go的Execute方法

代码语言:javascript复制
func Execute(coverInfo *CoverInfo) error {
      globalCoverVarImportPath = filepath.Join(coverInfo.ModRootPath, globalCoverVarImportPath)
      pkgs, err := ListPackages(target, strings.Join(listArgs, " "), newGopath)
        for _, pkg := range pkgs {
    if pkg.Name == "main" {
        mainCover, mainDecl := AddCounters(pkg, mode, globalCoverVarImportPath)
    for _, dep := range pkg.Deps {
        packageCover, depDecl := AddCounters(depPkg, mode, globalCoverVarImportPath)
      var httpCoverApis = fmt.Sprintf("%s/http_cover_apis_auto_generated.go", pkg.Dir)
      if err := InjectCountersHandlers(tc, httpCoverApis); err != nil {
      return injectGlobalCoverVarFile(coverInfo, allDecl)

获取所有包名

代码语言:javascript复制
 cmd := exec.Command("/bin/bash", "-c", "go list " args)

对main包,和它依赖的包分别加上counter

代码语言:javascript复制
func AddCounters(pkg *Package, mode string, globalCoverVarImportPath string) (*PackageCover, string) {
   coverVarMap := declareCoverVars(pkg)
   Var:  fmt.Sprintf("GoCover_%d_%x", coverIndex, h),
    for file, coverVar := range coverVarMap {
       decl  = "n"   tool.Annotate(path.Join(pkg.Dir, file), mode, coverVar.Var, globalCoverVarImportPath)   "n"
              switch mode {
                case "set":
                  counterStmt = setCounterStmt
                case "count":
                  counterStmt = incCounterStmt
                case "atomic":
                  counterStmt = atomicCounterStmt
                default:
                  counterStmt = incCounterStmt
                }
  parsedFile, err := parser.ParseFile(fset, name, content, parser.ParseComments)
  ast.Walk(file, file.astFile)

核心原理就是解析文件源码得到抽象语法书,然后遍历语法树,加上对应的打桩代码。最后一步是加下import文件

代码语言:javascript复制
packageName := "package "   filepath.Base(ci.GlobalCoverVarImportPath)   "nn"
packageName := "package "   filepath.Base(ci.GlobalCoverVarImportPath)   "nn"

为了和官方实现做对比,有相关的单测:

代码语言:javascript复制
func buildCoverCmd(file string, coverVar *FileVar, pkg *Package, mode, newgopath string) *exec.Cmd {
      go tool cover -mode=atomic -o dest src (note: dest==src)
      cmd := exec.Command("go", newArgs...)

pkg/cover/cover_test.go

代码语言:javascript复制
func TestBuildCoverCmd(t *testing.T) {
     cmd := buildCoverCmd(testCase.file, testCase.coverVar, testCase.pkg, testCase.mode, testCase.newgopath)
      if !reflect.DeepEqual(cmd, testCase.expectCmd) {

代码插桩的逻辑,核心实现是访问者模式pkg/cover/internal/tool/cover.go

代码语言:javascript复制
func (f *File) Visit(node ast.Node) ast.Visitor {
        switch n := node.(type) {
  case *ast.BlockStmt:
        case *ast.CaseClause: // switch
          for _, n := range n.List {
          clause := n.(*ast.CaseClause)
          f.addCounters(clause.Colon 1, clause.Colon 1, clause.End(), clause.Body, false)
      case *ast.CommClause: // select
      f.addCounters(n.Lbrace, n.Lbrace 1, n.Rbrace 1, n.List, true)
      case *ast.IfStmt:
        ast.Walk(f, n.Init)
        ast.Walk(f, n.Cond)
        ast.Walk(f, n.Body)

对于每一个逻辑分支,添加打桩代码

代码语言:javascript复制
func (f *File) addCounters(pos, insertPos, blockEnd token.Pos, list []ast.Stmt, extendToClosingBrace bool) {
代码语言:javascript复制
func Annotate(name string, mode string, varVar string, globalCoverVarImportPath string) string {

对于查询覆盖率,逻辑是一样的cmd/cover.go

代码语言:javascript复制
    runCover(target)
      _ = cover.Execute(ci)

源码实现的同时也实现了对应的的vscode插件,首先可以看下它的配置

tools/vscode-ext/package.json

代码语言:javascript复制
"configuration": {
      "title": "Goc",
      "properties": {
        "goc.serverUrl": {
          "type": "string",
          "default": "http://127.0.0.1:7777",
          "description": "Specify the goc server url."
        },
        "goc.debug": {
          "type": "boolean",
          "default": false,
          "description": "Turn on debug mode to log more details."
        }
      }
    }

对应入口文件是:tools/vscode-ext/src/extension.ts,会检查环境然后执行覆盖率收集和展示:

代码语言:javascript复制
let err = gocserver.checkGoEnv()
let packages = gocserver.getGoList();
await gocserver.startQueryLoop(packages);

核心逻辑位于tools/vscode-ext/src/gocserver.ts,首先是编辑器上覆盖率展示的代码:

代码语言:javascript复制
private highlightDecorationType = vscode.window.createTextEditorDecorationType({
        backgroundColor: 'green',
        border:  '2px solid white',
        color:  'white'
    });;
代码语言:javascript复制
async startQueryLoop(packages: any[]) {
   this.getConfigurations();
    this.setDebugLogger();
    let profile = await this.getLatestProfile();
    this.renderFile(packages, profile);
代码语言:javascript复制
clearHightlight() {
    vscode.window.visibleTextEditors.forEach(visibleEditor => {
     visibleEditor.setDecorations(this.highlightDecorationType, []);
  });

获取最新覆盖率,其实是发起了一个http请求去查询最新的覆盖率信息:

代码语言:javascript复制
 async getLatestProfile(): Promise<string> {
        let profileApi = `${this._serverUrl}/v1/cover/profile?force=true`;
      let res = await axios.get(profileApi, );
      let body: string = res.data.toString();
      this._logger.debug(body);
代码语言:javascript复制
checkGoEnv() : Boolean {
  let output = spawnSync('go', ['version']);

根据覆盖率信息,更新代码的展示样式:

代码语言:javascript复制
getGoList(): Array<any> {
       let output = spawnSync('go', ['list', '-json', './...'], opts);
        let packages = JSON.parse('['   output.stdout.toString().replace(/}n{/g, '},n{')   ']');
    renderFile(packages: Array<any>, profile: string) {
        for (let i=0; i<packages.length; i  ) {
              this.triggerUpdateDecoration(ranges);
        triggerUpdateDecoration(ranges: vscode.Range[]) {
                  vscode.window.activeTextEditor.setDecorations(
                this.highlightDecorationType,
                ranges
)

0 人点赞