Visualizing Your Go Code

Posted October 6, 2020 by  ‐ 1 min read

分享:  

代码可读性

作为一名开发人员,代码可读性是我们常常挂在嘴边的。代码写出来除了让计算机能够正常执行以外,终究还是要让人能够理解它,后续才能做进一步的维护工作。如果代码写出来,只有它的作者能够看得懂,那只能说明这个作者逻辑表达能力有问题,透过其代码难以看出解决问题的思路。这是软件工程中要尽力避免的。

在软件工程方法论指导下,为了尽可能让代码可读性达标,我们往往会根据一些最佳实践拟定一些大多数人认可的标准,让所有开发人员遵守,然后通过代码评审、代码规范检查、持续集成交付流水线等综合起来,以尽可能逼近这一目标。当绝大多数人能够在约定的框架下,保质保量提交代码时,我们已经在代码可读性、可维护性方面前进了一大步。

然而,这样足够了吗?我认为还不够。

代码是思维的表达

代码,不过是通过一种大家都理解的语言书写出来的篇章。就好比写文章一样,要有中心思想,然后围绕中心思想要展开层层描述。写代码一样,中心思想就是我们要解决的问题,围绕中心思想的层层描述就是我们解决问题的思路。所以,代码没有什么神秘的,它是人类思维的表达。

我们是如何快速理解一篇文章的呢?

  • 先看标题,掌握其核心关键词;
  • 看下第一段落的内容,往往第一段会引出问题;
  • 看下其余段落的首句、末句,往往会给出该段落的中心思想;
  • 看下最后一段的内容,一般会给出一个结论;
  • 通篇串下,了解文章整体含义;

为什么我们会通过这种方式?因为一篇好的文章一定有承上启下、过渡。这种循序渐进的方式,步步逼近中心思想。

那代码呢?某种程度上,代码也是类似的。

  • 以go语言为例,通常对于一个package,我们会提供package注释来表示当前package要解决的问题;
  • 每个package内部又包含了不同的types、variables、functions,它们结合起来来解决一个问题;
  • 每一个function内部又分为多个步骤,每一步完成一个小功能,为下一步做好准备;
  • 每一个小功能、步骤可能是if-else, switch-case, for-loop……之类的语言结构;
  • 同时,我们还会提供测试用例,来验证上述方案的正确性。

有没有觉得很相似,或许我们应该采用已有的读书的经验来辅助更好地理解程序?

OOP思想认识世界

代码,和文章不同的是,它虽然有明显的程序构造,但是却没有明显的段落之分。

那我如何才能借鉴多年来养成的还不错的阅读习惯,来帮助我理解代码呢?当然不能盲目套用,不过俗话说,能工摹形,巧匠窃意,思想很多地方还是可以相通的。

如何更好地理解这个世界,对各种各样的问题进行抽象呢?比如一辆摩托车,它有离合器、发动机、链条、轮毂、轮胎、减震、油箱、排气等很多部件构成,我听说宝马水鸟电子控制很厉害,可以实现无人驾驶,那可是两轮的400多斤的大机器。那它的电子控制系统怎么做到的?至少要能理解一个摩托车有核心部件,整体运转起来如何理解其状态,如何控制个别部件以影响其他部件进而控制整体状态。那它如何控制部件呢?电子操作或机械操作。

扯远了,我只是有点喜欢水鸟而已。整个世界可以看做是一个个对象及其之间的联系所构成,代码也不例外。

道法自然,OOP的思想不过是借鉴了人类认识世界的方式,将其运用到了软件工程领域,以更好地对问题进行抽象,从而构建出设计更合理的软件。那代码里面有哪些语言构造体现了OOP的思想呢。

  • 类型与对象,生物学里区分物种、种群、个体,那是因为它们既有共性,也有个性;
  • 通信的方式,自然界个体之间的交互也有多种方式,比如雄狮撒泡尿标记领地也不管入侵者认不认同,或者低吼驱赶入侵者离开,人和人用听得懂的语言沟通;
  • 隐私与距离,每个人都有自己的隐私,如果你的朋友跟你借100块钱你可能给了,但是他如果问是你老婆给的还是你自己的,你可能就不想借给他了,给你就行了你管那么多干嘛呢,我还不想拿自家的借你呢,说不定借你老婆的给你的呢。每个人在一副外表下总有些不愿意被人触碰、靠近的地方。

了解一个人,其实你不需要深入他的家庭本身去了解,看看他天天接触什么人,说些什么话,你也就大致清楚了。感兴趣就继续了解,不感兴趣也就拉倒了。我想绝大多数人都不是窥视狂,在拥有一定判断力的基础上,通过一些局部的信息是可以了解大致的整体信息的。

理解代码有相同之处?

流程控制 + 组件交互

某种程度上,我认为理解代码也有相似之处。

如果能够拎出那些比较重要的对象(objects),以及他们之间的通信(function call, chan send/recv),或者他们的私密信息(注释),是不是也能够大致有个了解呢?

如果想更深入了解下,加上事情的脉络(控制结构,if-else, switch-case, for-loop)呢?

其他信息? 我相信还有其他有用的有用信息,能够通过一些更加有效率的方式呈现出来。

认识 go/ast

计算机编程语言,有多少种?我认为只有一种,就是人类可以理解的语言。有趣的是,编程语言之多可以覆盖元素周期表,不信来瞧瞧。

languagesgoast example
  • 语言是什么?语言有精确的数学定义,它不是胡编乱造,尤其是编程语言;
  • 编程语言更精确?那倒未必,人类社会多姿多彩之处,就在于会演绎出更加丰富多彩的内容,包括对语言的破坏性“创造”,人脑纠错能力太强了,我们甚至没有察觉到自己犯了错误,如网上津津乐道的山东人倒装玩法;
  • 我能发明一门语言吗?当然,只要你能给出严谨的数学定义,没有歧义,找到一群人学会并开始用它交流,姑且可以称为语言了,比如生活大爆炸谢耳朵他老婆;
  • 语言不是主谓宾之类的吗?主谓宾也可以进一步形式化,数学之美也让我感到惊叹;

So…假如我用编程语言写了一段代码,如何知道我有没有犯错误呢?那就是编译器的工作,词法分析、语法分析、语义分析,一切OK之后会进入中间代码生成、代码优化、生成最终代码。通常一般在语法分析会构建语法分析树AST(Abstract Syntax Tree),如果能够正常构建出AST,表示代码是按照语言对应生成规则来写的,就没什么大问题,反之则可能又自我“发挥”犯错了。

以下面的go程序为例:

package main

import (
    "fmt"
)

func add(a, b int) int {
    return a + b
}

func main() {
    c := add(1, 2)
    fmt.Printf("1 + 2 = %d\n", c)
}

以下是两个不错的ast可视化工具,可以将上述代码拷贝以下以查看对应的AST。

ps: 推荐前者,实现了类似chrome inspect element时选中区域查看对应代码的操作,光标移到对应代码区域,即可高亮显示对应的AST部分区域。

比如现在我们选中了import相关的部分,对应右边展示出了import声明对应的AST中的部分子树,对应的就是一个GenDecl结构。函数声明也有对应的FuncDecl,类型也有对应的…

goast example

ps: AST展示形式竟然不是一棵树?它确实是一棵树,只不过,AST是非常庞大的,如果通过树的形式来展示,篇幅太大,反而不方便查看。

go标准库提供了一个package go/ast,用它来对源码进行分析并构建出AST,然后基于AST可以对源码结构进行理解加工,举几个常见的用途:

  • go标准库有频率不高的package迁移、方法签名变化、其他情况,go fix实现了旧代码像新代码的快速迁移,其实就是通过对AST操作实现的;
  • 代码中检测error处理、是否有合理注释等,也可以基于AST进行分析,开发可能一不小心忽略对error处理、goroutine panic处理,有些三方库就可以基于AST分析有没有上述情况,以对源码中的问题进行自动修复;
  • 提取关键操作信息进行可视化,如apitest.dev将HTTP操作、DB操作作为重点关注对象对代码中的相关交互进行提取、可视化展示,Ballerina框架中也有类似实现。
  • 提取关键的网络调用操作,自动化opentracing埋点,Instrumenting Go code via AST
  • 其他;

可见了解 go/ast,将有助于我们更好地理解代码,并作出一些更有创意的工具。

微服务可视化

我正在调研一些业界流行的微服务框架,吸收一些比较好的创意,在我从事的团队,现在基本都是采用微服务架构设计、开发、部署了,某种程度上,微服务一点都不微,有些逻辑也很复杂,而且业务代码真的没什么好看的。绝大多数时候,我希望1min了解其逻辑,超过10min我还看不懂的,心里已经开始在犯嘀咕了,这写的啥?

是我“没耐心”?我倒是不这么认为,如果一个操作我每天要人肉重复几十遍,我就得想办法“偷偷懒”提高下效率了。身为工程师,卖肉是耻辱。

再回想一下哪些语言构造比较重要,rpc、对象之间的通信(方法调用)、包方法调用、goroutine之间通信(chan send/recv),还有控制逻辑if-else、switch-case、for-loop。

我们可以先从实用又简单点的开始,比如rpc、对象方法调用、包方法调用,其他的后续再完善。OK!

找到关心的入口点

按照一些微服务框架的编码风格,通常main.go里面会注册service接口级实现,这里就可以作为一个入口,我们可以找出AST中main.go中注册的所有service及其实现,并分析service接口中定义的方法,然后再将service实现中的对应方法作为入口点。

找到这些入口点之后,我们将可以从AST中遍历所有的方法定义,直到匹配到receiver type、method signature匹配的定义。

file: main.go

func main() {

	s := gorpc.NewServer()

	pb.RegisterHelloService(s, &helloServiceImpl{})

	if err := s.Serve(); err != nil {
		log.Fatal(err)
	}
}

从入口点处开始层层展开

每个函数在AST中都有对应的结构,函数体内包括的每一条语句也是这样,那还有什么不能干的?

我们可以递归地将一条语句层层展开,抽丝剥茧,直到看到关心的脉络。刚才我们说先只考虑rpc、对象方法调用、包导出函数调用就可以了,这类基本可以形式化成xxx.Func(args...)的形式,就覆盖了上面这几种情况。

只要碰到这样的语句,我们就记录一下,并将其递归地展开,展开过程中依然记录所有xxx.Func(args...)形式的函数调用……直到没什么可继续展开为止。

这样,我们就可以大致实现最初的设想:看到对象之间的所有通信过程。

增加通信过程的说明信息

在go代码规范里面,对于导出方法、导出函数、导出类型通常都是需要添加godoc注释的,这些注释本身就具有一定的说明性。那当我们通过对象间的调用关系进行可视化时,是否可以将这些注释信息添加上,以提供更好的说明呢?当然。

何时何地以及如何触发

何时何地以及如何触发了特定的函数调用?

  • 何时何地?filename:lineno:columnno,每一个ast对象都有一个Pos()方法,配合token.Position(astobj.Pos())就可以计算出源码位置;
  • 如何触发?函数调用时的参数信息,函数调用发生的作用域(function scope);

Put It Together

当我们将上述提及的操作全部组织在一起,就可以实现大致如下效果,篇幅以及时间原因,这里就不再一步步详细描述代码逻辑了。

如果您对这里的实现确实感兴趣,您可以查看这里的代码来进一步了解MR: gorpc support visualize subcmd

ps: 我也在做一些gorpc101教程之类的材料准备,包括书籍、框架、工具、插件之类的小玩意,一方面是为了沉淀自己,一方面是为了验证想法,如果能有些许建议或者愿意参与进来,那我先表示欢迎。

Edit this page on GitHub