代码分析驱动代码质量

王国梁

目前任职于美团云计算部门,负责云平台资源调度系统和 Go 相关系统设计和性能优化;曾任职于奇虎360主要在从事高性能中间件产品的开发和性能优化。Go 语言重度使用者,开源代码质量评估工具 Goreporter 的作者,曾上过 Github 周最热开源项目,目前2000+star;知乎专栏——进击的 Golang,是关注人数最多的 Go 相关专栏;热爱开源和分享,目前是 Kubernetes member 及多个著名开源项目 contributor。

前言

     今天的演讲主要内容是就 GO 语言中怎么通过代码分析驱动代码质量与大家做一些分享交流。我目前在美团的云计算部门任职,主要负责云平台资源的调度和 go 语言的性能优化问题,曾经在360做过一些中间件的开发。写过一个开源项目 goreporter,通过分析代码生成代码质量的报告,可以对我们的代码有整体的了解。现在360的一些研发部门主要是在用它做一些代码的 review 工作,而在美团这边则是被信息安全部门用作代码安全审计的工具。现在我在开源方面主要在做 Kubernetes 调度开发以及性能优化工作,另外,我会经常在知乎专栏(进击的Golang)上也写一些 golang 相关的文章,大家有兴趣可以看一下。

     今天的演讲分为四个部分,第一是讨论下开发效率与代码质量他们之间的关系以及我们为什么放弃质量,第二是代码分析技术的一些简单介绍,其次是如何在 go 语言中实现代码分析工具,最后是一些总结和心得体会。

开发效率与代码质量

       我们先来讲讲开发效率与代码质量。首先我想问一下大家,在写代码的过程中有多少部门或者组内做过 review?很少。那么为什么我们不关注代码质量呢?我想,有很多原因,或许是我们自身开发习惯,也可能是团队开发氛围的问题,导致我们一味追求开发速度,不去考虑我们写的代码的质量到底是如何。我们自己也可能懒得重新再去看之前写过的代码或者重新思考我们之前写过的代码,甚至不知道怎么改我们的代码、不知道怎么写比较好。

       这些问题导致我们一直在放弃代码质量,但是我们为什么会选择放弃代码质量?因为那需要太多时间精力,也会拖慢研发的进度,主观感受是这样的。但是从另外一个方面考虑,就是我们怎么能够降低我们去追求代码质量的代价?我们可以借助一些工具,让机器帮我们做一些代码 review 或者有一些自动化的建议,我们通过代码工具做一些代码的审核,比如说我们可以统一代码风格,在 go 语言中我们大家都了解gofmt,也可以通过工具代码分析,分析代码中的缺陷,或者根据代码给出一些更好的优化建议或者是评估代码可维护性有多高,代码是不是对后期的维护更加的友好等等。我们都可以通过工具实现这些功能,这就减少了我们在人力时间上的花费。

代码分析技术

       那么我们又该如何去实现这些功能?通过代码分析技术。

代码分析中五种常见技术

       一、词法分析,在语言内部的标记序列,怎么把字符定义为是一个标记还是关键字或是操作等等。

       二、语法分析,我们既然得到了这些标记,还可以把这些标记进行类型上的检查。

       三、语义分析,我们的每一行代码,或者是每一个标识,它的一些上下文的关系,是否符合语义上的规则。

       四、控制流分析,通过指令得到控制流图。

       五、数据流分析,比如我们想查看一些热点数据,对一些数据的分析等等。

 静态和动态分析

       静态和动态分析有很大的区别,它们之间有很明显的特点可以区分,静态分析是分析原代码的结构,主要是做一些代码的检查验证分析,动态分析其实就是分析平时的一些数据,比如我们经常使用的追踪技术等等之类的,而静态分析就是我们做的 linter 之类的相关工具。

在go语言中进行代码分析

       它的主要优势一个是静态类型,还有就是语法更加简单,比动态的特性更简单,同时,它的标准库直接支持了代码分析的一些包。

       在 源码中go 目录下的一些包,第一个 ast,一些数据结构和类型都在这里定义。 build 是构建收集有关30包的信息,constant 是一些常用的常量表达方法,doc 是 go 语言提取原代码的文档,goastformat 它是实现原代码的标准格式,importer 是程序提供了对导入数据程序的一些访问,parser 是提供了一个递归下降的构造 ast,相当于遍历。printer 可以直接支持 ast 节点,scanner 主要是测码分析器的一些数据和方法,token 定义测码分析的数据结构,还有types主要定义数据类型和实现类型检查的算法。这些都是代码分析的基础工具,在 GO的标准库下已经给我们提供。

       从上层角度来看,代码分析可以分为几个比较抽象的概念,比如代码解析(怎么去从我们的原代码中解析出来,再去分析一些基本的数据),类型检查,语法分析以及指针分析。

      1、代码的解析

       第一步是我们要从原代码中去解析出所有的 tokens,也就是我们说的标识符,可以通过 go/scanner 和 go/token来实现;第二步是通过tokens来构建ast(抽象语法树),可以通过go/parser和go/ast实现;然后,既然我们得到了ast我们就可以验证下token之间的关系,通过go/types和go/constant来对token进行检查;后面我们就可以根据自己的需要对ast进行分析,以及分析我们关注的所有与ast和节点相关的代码数据。

       我们来看一块简单的代码,把它标识为不同类型token后会发现有五种类型:(只有四个),蓝色的是关键字,红色字(例如我们自己定义的变量)和紫色字是基本类型,以及特殊字符(比如文件的节数还有注释,都是属于特殊字符),还有操作符(例如加减乘除的一些标记是操作符)

       2、类型的检查

       可以看到一个完整程序有哪些部分和类型组成,所有生成的token都有它的位置,类型以及它的值到底是什么,这是在后面代码分析中一个很重要的步骤,就是把它的token信息通过我们的基础工具来获取,这样就可以在类型上做一些代码分析,比如某些类型属于有函数变量都可以检查。

      3、语法的检查

       下面是通过语法检查,通过输入一些词素组成的数据结构,比如说 i+1 可以把它转换成树状结构。在一个文件中包含哪些部分,在 ast 有一个文件,会有一些声明,声明又分为函数声明和其他的一些全局的声明,主要关注的在函数声明中,下面有一些注释文档,它的类型是什么,name 是什么以及这个函数体是什么,可以看一下右侧,把我们刚才看到的 ast 真的去做一个可视化,这里面我们看一下,我们这一个文件,左边的代码,这里面就会有这个代码中它有一些在 ast 上是一个什么样的结构,然后我们的文件中包括一些 name 一些声明还有一些范围,importer 这里面一些注释还有其他的,我们看到在它的声明中就分为一些全局的一些声明还有我们的函数,在函数里面,有一些函数的 name 和类型,在这个 ast 中有哪些信息,我们可以从这里面拿到一些信息。

      4、token.Fileset

       在 fileset 中,我们可以做代码中的基本单元,记录下这个项目有多少个文件,位置是什么,文件资源的偏移,在 fileset 里有多个文件,还有它缓存之后的文件,文件中还有每一行的信息,包含哪些字符,以及我们刚才看到的那些 token 的数据。

      5、如何遍历ast?

       下面看一下我们的重点,怎么遍历我们的 ast。有两种方式,一种简单的方式是 inspect 遍历 fileset,在这里面看是属于一些变量,还是属于你的一些基本的标识,这是我们去简单的去做一个遍历,然后另外一种就是我们要去实现每次遍历 ast 调用 walk 的方法,右边这个,我们调用 walk 方法的时候,需要在 visitor 有一个参数,需要自己去实现 visitor,这个 visitor 里面就是我们要想要去分析里面写了一些代码分析的算法,我们的外部实现一个 visitor 把它传入我们的 walk 去调用,这样它会根据我们的分析的一些规则去提取出 ast 中我们去关注的一些信息。

     我们看第一个例子,就是关于字节对齐,这些结构体,是我们经常去声明一个结构体,不知道大家有没有关注过这里面的字节对齐的问题,我们可以算一下这个结构体占了多少空间。它的大小是72,占了72空间,我们知道它需要以8为字节对齐,当它是连续的内存空间的时候,第一个必须去对齐它最低的8的单元,因为我们第二个占用了24个size,它们一共是72个空间,我们调整顺序的时候发现,它的大小可以达到最小64个size,即使再加进去一个也是64个,我们相当于把 A 和 D 合并在一个8的单元里面,这个大家应该都比较清楚,我们想做的这件事就是我们怎么去发现,我们怎么去计算这个结构体确实是做了一些字节对齐的优化,是没有占用额外的空间,我们看一些对内存比较敏感的应用的时候,可能会注意这些,那么我们又该怎么去选择分析它这个结构?

       我们首先在包装提供了 side off 函数,通过分析程序中所有的 struct,可以把实际大小计算出来,再去遍历一下这个 struct 中所有元素,把每一个元素取一下它自己本身应该占用的空间的大小,abcde 占用空间的和就是我们这个结构体能达到的最小的占用的内存空间。

       首先我们加载程序,因为它有 load 的工具包,可以通过这个工具包把程序加载下来,做一些类型的检查,这些不是我们所要的,左边我们分析一下结构体的过程,首先是我们拿到了某一个声明的类型信息,然后把它转换成对应的结构体的信息,如果不是结构体或者是其他的一些函数,那就直接跳出了,不需要继续检查,因为我们关注的是所有的结构体是不是做了内存优化,有一个 side off 我们可以拿到 struct 它实际占用的空间大小,还有它对齐的单位是什么,这里面就是8,我们这里面可以看到,我们要去遍历一下一下 struct 当中所有的属性,我们拿到每个属性所有的占用空间大小,这时候我们可以看到,我们既然拿到了属性的空间大小以及结构体实际占用空间的大小,可以比较一下这两个值大小是不是一致的,如果一致,其实在这里面已经做到了占用了结构体最小的字节,如果不是就说明我们的结构体需要调整我们一些属性的顺序,从而达到占用最小的空间。

       类似的应用比如说我们可以检查一些重复的字面值,比如在代码中写了很多相等的字符串,按照我们正常写的方法可能需要把它设一个全局变量,再强制做一些类型转换,但这是没有必要的,甚至可能带来一些风险。我们也可以通过这种方式然后去分析,还有一些冗余的结构体,也是可以通过这样的方式来发现。 

       第二个例子,遍历 ast 的代码分析,我们刚才说的我们实现了 visitor 方法,如果在一个函数内部,我们声明一个变量但没有使用的话,会发生报错声明,我们去发现有哪些全局的变量没有使用,从而把一些没有用的变量删除。通过左侧的代码,我们能够发现哪些全局的变量没有被使用。我们看一下右边,如果发现一个声明的变量,把它标记为到底是使用还是没有使用,如果这个变量使用了就把它设为1,第二个是在声明的时候,如果是执行了第二个case,但是没有执行第一个,到最后打印出来说这个是没有去使用的,最后一个是重新调用了 walk 方法,要查一下这个函数中是不是也有这样的一些情况。

       其实也实现了我们遍历的时候要做的工作。构造一个遍历器,扫描包中的文件,每次调用一个 walk。最后可以发现在这个里面我们所有的声明的变量到底使用没有使用,如果说声明了没有使用就可以打印出来,某个变量在某一行某一个文件没有使用,但是声明了。类似的工具可以通过我刚才说的可以去实现全局变量一些结构体,或者是没有用到的参数也是可以发现的,还有一些函数声明了但是没有使用。   

       第三个,就是提出改进意见,就是这块代码怎么去写,怎么更简练一点直观一点,比如我们合并两个 slice,我们可以通过一行代码,直接把一个 slice  append 到另外一个。但如果我们的slice数据经过了转变,即slice1 和 slice2 的元素类型是不一样的,这样就不符合代码优化的提示规则,或者说在合并slice时还要做一些其他的工作,这时候也是不符合的。我们在分析的时候,给出一个优化提示的前提是必须是完全符合我们左边的规则的时候,我们才能给出它这样的优化的建议。这里,首先是在一个循环,必须有 Range,还需要这个循环中只有一行代码,而且 slice1 和 slice2 类型必须一样,每个语义必须完全符合这些规则,才能去命中这一行代码。

       我们看下具体的分析逻辑,首先我们要匹配 Range,把 slice 合并必须要有 append 操作,相当于内置一个函数,我们做类型检查,看下 slice 类型是不是一致的,如果命中可以直接有一个提示,就是你应该去使用我们刚才看到的后面这个,去代替左边这个,相当于给出了代码建议,在 CI 中,或者试运行我们的工具的时候,可以直接给你有这样的提示,你就可以根据这个提示做相应代码上的优化,这是我们简单的例子。比如我们还可以对它的返回值进行检查,类型的转换的时候进行检查,还有错误处理,比如有一行代码,有错误但是测试的时候忽略了,这会导致我们的程序崩溃,可以做一些安全性检查。

       统计类的代码分析,需要逐个分析文件,对文件中函数的圈复杂度的一些计算,例如你的函数有很多if的嵌套,这样非常不利于我们后期的维护。还有逐个分析你的函数深度,例如你有很多代码块,每一个代码块嵌套,代码深度就+1,最后统计出来代码到底是深度是不是特别大,比如我们可以设一个分界值是15,如果大于这个值就会给出这样的提示。类似的比如我们还可以做一些代码行长度的限制,或者是代码函数数量的统计,一些代码的注释的统计。

代码分析工具介绍

 常见工具

       下面是我们比较常见的工具的介绍,大概分这些类别。根据我们平时使用到的一些特点,做的一个分类,比如代码规范的检查,代码检查,代码检查中是不是符合代码友好型的检查,还有安全检测,代码本身的安全漏洞,Go语言本身也是有安全漏洞的,也被报出来,然后修复。有代码优化,内存对齐代码的优化,还有命名检测,可能这个名字起的不是特别好,可以重新命名变量,或者单词拼写是不是有错误,还有一些代码统计,可以用这个工具分析出你的依赖关系,一个项目中依赖是不是太复杂或者冗长,可以调整一些代码结构。还有无效代码的检查,以及冗余代码的检查,或者可以根据自己的需求去自定义,根据我们上面所讲的代码分析的方式,根据它提供的工具可以自己去定制你想要分析的代码的特性。

     针对不同阶段

     开发阶段可以使用代码格式化,代码风格检测,以及自动填充 import,还有自动化表现测试模板,还有简化工作。测试与迭代阶段,代码格式化,单元测试和覆盖率,不是有无效的代码,或者是代码风格上的错误。Codereview 的时候,我们可能有自己的平台,可以加入一些圈复杂度,还有命名拼写,还有重复代码。     

总结&心得

       下面对一些代码分析的总结。首先我们说下什么才叫团队的代码风格统一,就是我们不仔细看代码,不看它的记录和署名,我们不知道这个代码谁写的,这就叫代码风格统一。风格统一对后期维护有很大的好处,即使有人员离职或者新人进入。还有自动化的 Codereview,我们一边在追求我们的代码质量,一方面又不需要我们花费那么多精力做 review,提高效率,还有消除开发人员对他人 Codereview 的抵触心理,我们是同一个小组时,组长给我们做 Codereview 这种情况还好,如果是同组之间我们成员之间 Codereview 会有这种抵触心理,通过工具可以减少这样的一些事情的发生。还有 go 语言中代码分析的技术和方法,以及常见的一些代码分析是怎么实现的,最后是给出不同类别以及不同的开发阶段可以使用的代码分析的工具。

Q&A

   提问:我有两个问题,一个是我做代码Review,遇到一个问题,就是项目有很多历史遗留问题,这个落地起来难度很大,有没有什么比较好的方法?第二个是扩展这块,go 这个扩展包这块的代码。历史的项目会有很多,这样会出来很多问题,过多的问题,一下子可能解决不了,有没有什么好的方案?

   王国梁:我们对于历史代码,怎么去做?一方面就是这个项目需要继续开发,如果需要,如果你们想去做这个风格代码这样一些工作的话,是不是需要花费你们的精力把旧代码重新的整理一下,这是有必要的。

   提问:有人提出,新的代码质量检查,新代码覆盖老的代码,这样一步步的去做。

   王国梁:这属于你们开发的时候怎么选择,如果有足够的时间把这个代码做一下,可能越做越好,后期去改的话会发现之前的代码本身写的就不好,再改的话,一方面怕改动太大,还会引入新的问题。

  提问:扩展包呢?新引入他人的项目包,需要考虑它的代码检查吗?

王国梁:也是有的,但是这个代码检查,比如之前有专门这个工具,会把标准库的包忽略。通常情况下这部分没必要,有的话可以提一些 pr 是可以的。

提问:两个问题,一个是代码检查,我们的 cover owasp top10 的问题有多少,另外就是刚才讲的是静态代码检查,动态代码检查大概是怎么实现的?

王国梁:我今天讲的大部分几乎都是静态的代码检查,动态代码检查目前我没有特别研究这方面的工作。

提问:我们讲那么多问题,我们对 top10 owasp 的问题,可以覆盖多少问题,比如代码注入等问题?

王国梁:刚才我们看有一些代码漏洞,防止代码注入的分析,一些安全性分析都是有的,这些东西其实都是有很多人自发的开发的,目前来说,没有非常成型的专门去分析 GO 的,其实有很多人写工具的,我们内部有需求,自己回去写一些,这个代码对我们重要一些,我们可以专门针对这些代码做一些代码分析。

提问:刚才看到有360的检查工具是您当时做的开发吗?挺好用的,里面还能检查一些前期的。你自己后面维护了吗?因为很长时间没更新了。

王国梁:我前端技能不够,界面好看度还是要改善,毕竟是360的东西,我在美团一直不好做一直更新维护,但是很多问题还是会解决的。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180919B0RW8800?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券