35 未雨绸缪:怎样通过静态与动态代码扫描保证代码质量?
你好,我是郑建勋。
这节课让我们继续优化代码,让程序可配置化。然后通过静态与动态的代码扫描发现程序中存在的问题,让代码变得更加优雅。
micro中间件
首先,让我们紧接上节课的go-micro框架,对代码进行优化,设置go-micro的中间件。如下,我们使用了Go函数闭包的特性,对请求进行了一层包装。中间件函数在接收到GRPC请求时,可以打印出请求的具体参数,方便我们排查问题。
func logWrapper(log *zap.Logger) server.HandlerWrapper {
return func(fn server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
log.Info("recieve request",
zap.String("method", req.Method()),
zap.String("Service", req.Service()),
zap.Reflect("request param:", req.Body()),
)
err := fn(ctx, req, rsp)
return err
}
}
}
接下来,使用micro.WrapHandler将中间件注入到micro.NewService中,这样就大功告成了。
当GRPC服务器收到请求之后,会打印出下面这样的请求信息。
{"level":"INFO","ts":"2022-11-28T00:29:28.287+0800","caller":"crawler/main.go:148","msg":"recieve request","method":"Greeter.Hello","Service":"go.micro.server.worker","request param:":{}}
静态扫描
接下来,让我们用静态扫描把代码变得更优雅一些。
当前大多数公司采用的静态代码分析工具都是golangci-lint 。Linter 本来指的是一种分析源代码以此标记编程错误、代码缺陷和风格错误的工具,而golangci-lint就是集合多种Linter的工具。要查看golangci-lint支持的 Linter 列表,以及它启用/禁用了哪些Linter,可以使用下面的命令:
Go语言定义了实现Linter的API,它还提供了golint工具,golint集成了几种常见的Linter。在源码中,我们可以查看在标准库中如何实现典型的Linter。
Linter的实现原理是静态扫描代码的AST(抽象语法树),Linter的标准化意味着我们可以灵活实现自己的Linters。不过,golangci-lint里面其实已经集成了包括golint在内的众多Linter,并且具有灵活的配置能力。所以如果你想自己写Linter,我也建议你先了解一下golangci-lint现有的能力。
使用golangci-lint的第一步就是安装,不同环境下的安装方式你可以查看官方文档。下面我来演示一下如何在本地使用golangci-lint。最简单的方式就是执行下面的命令:
它等价于:
我们也可以指定要分析的目录和文件:
就像前面所说,golangci-lint是众多lint的集合,要查看golangci-lint默认启动的lint,可以运行下面的命令:
可以看到,golangci-lint内置了数十个lint:
为了能够灵活地配置golangci-lint的功能,我们需要新建对应的配置文件。golangci-lint会依次查找当前目录下的文件,实现启用或禁用指定的Linter,并指定不同Linter的行为。具体的配置说明你也可以查看官方文档。
- .golangci.yml
- .golangci.yaml
- .golangci.toml
- .golangci.json
现在让我们在项目中创建.golangci.yml文件,具体的配置如下:
run:
tests: false
skip-dirs:
- vendor
linters-settings:
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 120
# Checks the number of statements in a function.
# If lower than 0, disable the check.
# Default: 40
statements: -1
# list all linters by run `golangci-lint help linters`
linters:
enable-all: true
disable:
# gochecknoglobals: Checks that no globals are present in Go code
- gochecknoglobals
# gochecknoinits: Checks that no init functions are present in Go code
- gochecknoinits
# Checks that errors returned from external packages are wrapped
- wrapcheck
# checks that the length of a variable's name matches its scope
- varnamelen
# Checks the struct tags.
- tagliatelle
# An analyzer to detect magic numbers.
- gomnd
...
其中,run.tests选项表明我们不扫描测试文件,run.skip-dirs表示扫描特定的文件夹,linters-settings选项用于设置特定Linter的具体行为。funlen linter 用于限制函数的行数,默认的限制是60行,在这里我们根据项目的规范,将其配置为了120行。Linter的特性你可以根据自己项目和团队的要求动态配置。
另外,linters.enable-all表示默认开启所有的Linter,linters.disable表示禁用指定的Linter。存在这个设定是因为在golangci-lint中有众多的Linter,但是有些Linter相互冲突,有些已经过时,还有些并不适合你当前的项目。例如,gochecknoglobals禁止使用全局变量,但是有时候我们在项目中确实需要全局变量,这时候就要根据实际需求来调整了。
添加完配置文件之后,执行 golangci-lint run 可以看到静态扫描之后的众多警告,如下图所示:
有很多Linter对提高代码的质量是非常有帮助的。例如在下面这个例子中,golangci-lint会打印出文件、行号、不符合规范的位置以及原因。其中,第一行最后的(golint)表明问题是由golint这个lint静态扫描出来的。这里它提示我们应该将sqlUrl的命名修改为sqlURL。
再举个例子,这里,wsl linter要求我们在特定的场景下在continue前方空一行,这样可以方便阅读。
engine/schedule.go:242:4: branch statements should not be cuddled if block has more than two lines (wsl)
continue
我将项目中所有的代码都根据Linter的提示进行了修改,完整的代码见v0.3.1。
动态扫描
不过,有一些问题是很难通过静态扫描发现的,例如数据争用问题。数据争用是并发系统中最常见且最难调试的错误类型之一。在下面这个例子中,两个协程共同访问了全局变量count,乍看之下可能没有问题,但是这个程序其实是存在数据争用的,count 的结果也是不明确的。
count++操作看起来是一条指令,但是对 CPU 来说,需要先读取 count 的值,执行+1 操作, 再将count 的值写回内存。大部分人期望的操作可能是下面这样: R←0 代表读取到0,w→1 代表写入count 为1;协程1写入数据1后,协程2再写入,count 最后的值为2。
但是由于count++并不是一条原子指令,情况开始变得复杂。如果执行的流程如下所示,那么count 最后的值为1。
这两种情况告诉我们,当两个协程发生数据争用时,结果是不可预测的,这会导致很多奇怪的错误。
再举一个 Go 语言中经典的数据争用错误。如下伪代码所示,在Hash 表中,存储了我们希望存储到 Redis 数据库中的data数据。但是在 Go 语言中使用Range时,变量k是一个堆上地址不变的对象,该地址存储的值会随着 Range 遍历而发生变化。
如果此时我们将变量 k 的地址放入协程save,以此提供并发存储而不堵塞程序,那么最后的结果可能是,后面的数据会覆盖前面的数据,同时导致一些数据没有被存储,并且每一次完成存储的数据也是不明确的。
func save(g *data){
saveToRedis(g)
}
func main() {
var a map[int]data
for _, k := range a{
go save(&k)
}
}
数据争用可以说是高并发程序中最难排查的问题,原因在于它的结果是不明确的,而且可能只在在特定的条件下出错,这导致很难复现相同的错误,在测试阶段也不一定能测试出问题。
Go 1.1 后提供了强大的检查工具 race 来排查数据争用问题。如下所示,race 可以用在多个Go 指令中。当检测器在程序中找到数据争用时,将打印报告。这个报告包含发生 race 冲突的协程栈,以及此时正在运行的协程栈。
如果对上面这个例子的 race.go 文件执行go run -race ,程序在运行时会直接报错,如下所示。从报错后输出的栈帧信息中可以看出发生冲突的具体位置。
» go run -race race.go
==================
WARNING: DATA RACE
Read at 0x00000115c1f8 by goroutine 7:
main.add()
bookcode/concurrence_control/race.go:5 +0x3a
Previous write at 0x00000115c1f8 by goroutine 6:
main.add()
bookcode/concurrence_control/race.go:5 +0x56
Read at 表明读取发生在race.go 文件的第 5 行,而Previous write 表明前一个写入也发生在race.go 文件的第 5 行,这样我们就可以非常快速地发现并定位数据争用问题了。
不过,竞争检测也有一定成本,它因程序的不同而有所差异。对于典型的程序来说,内存使用量可能增加5~10 倍,执行时间会增加 2~20 倍。同时,竞争检测器还会为当前每个 defer 和 recover 语句额外分配 8 字节,在Goroutine退出前,这些额外分配的字节不会被回收。这意味着,如果有一个长期运行的 Goroutine,而且定期有 defer 和 recover 调用,那么程序的内存使用量可能无限增长(有关race工具的原理你可以参考《Go底层原理剖析》)。
配置文件
看完静态和动态的代码扫描,我们接着来让代码可配置化,这是我们项目一直没有实现的功能。很多人可能直接会书写JSON、TOML等配置文件并在程序启动时读取配置文件。不过一个优秀的处理配置的库要考虑更多内容。go-micro的配置库提供了下面这几种能力。
- 动态配置
大多数程序在初始化时会读取应用程序配置,之后就一直保持静态状态。 如果需要更改配置,则需要重新启动应用程序,这有时候会显得比较繁琐。而动态配置通过监听配置的变化,实现了动态化的配置。 - 支持多种后端数据源
它可以支持文件、flags、环境变量、甚至etcd等数据源获取源数据。 - 支持多种数据格式的解析
它可以解析包括JSON、TOML、YML在内的多种数据源格式。 - 可合并
它支持将多个后端数据源读取到的数据合并到一起进行处理。 - 安全性
当配置文件不存在时,go-micro的配置库支持返回默认的数据。
关于go-micro代码的设计你可以参考这篇文章。
我们举一个简单的例子来说明go-micro配置库的使用方式。假设我们有配置文件config.json:
{
"hosts": {
"database": {
"address": "10.0.0.2",
"port": 3306
},
"cache": {
"address": "10.0.0.2",
"port": 6379
}
}
}
获取配置文件的实例代码如下:
package main
import (
...
"go-micro.dev/v4/config"
"go-micro.dev/v4/config/source/file"
)
func main() {
// 导入数据
err := config.Load(file.NewSource(
file.WithPath("config.json"),
))
if err != nil {
fmt.Println(err)
}
type Host struct {
Address string `json:"address"`
Port int `json:"port"`
}
var host Host
// 获取hosts.database下的数据,并解析为host结构
config.Get("hosts", "database").Scan(&host)
fmt.Println(host)
w, err := config.Watch("hosts", "database")
if err != nil {
fmt.Println(err)
}
// 等待配置文件更新
v, err := w.Next()
if err != nil {
fmt.Println(err)
}
v.Scan(&host)
fmt.Println(host)
}
在这里,config.Load用于导入某一个数据源中的config.json文件,config.Get用于获得某一个层级下的数据,Scan函数用于将数据解析到结构体中。config.Watch函数用于监听指定的配置文件更新。
在项目中,我们使用TOML来作为配置文件。TOML相比JSON文件的优势在于,能够书写注释,阅读起来相对清晰,但是它不适合表示一些复杂的层次结构。要想在项目中读取TOML数据并将其转化为类似JSON的层次结构,需要导入TOML插件库并做额外的处理:
enc := toml.NewEncoder()
cfg, err := config.NewConfig(config.WithReader(json.NewReader(reader.WithEncoder(enc))))
err = cfg.Load(file.NewSource(
file.WithPath("config.toml"),
source.WithEncoder(enc),
))
之前我们有许多项目的配置是写死在代码中的,例如数据库的地址、etcd的地址、GRPC服务器的监听地址,以及超时时间、日志级别等等。现在我们需要将这些配置迁移到配置文件中,实现可配置化。
项目中配置文件的处理方法我这里就不再赘述了,具体你可以查看v0.3.2 分支。
logLevel = "debug"
Tasks = [
{Name = "douban_book_list",WaitTime = 2,Reload = true,MaxDepth = 5,Fetcher = "browser",Limits=[{EventCount = 1,EventDur=2,Bucket=1},{EventCount = 20,EventDur=60,Bucket=20}],Cookie = "xxx"},
{Name = "xxx"},
]
[fetcher]
timeout = 3000
proxy = ["<http://127.0.0.1:8888>", "<http://127.0.0.1:8888>"]
[storage]
sqlURL = "root:123456@tcp(127.0.0.1:3326)/crawler?charset=utf8"
[GRPCServer]
HTTPListenAddress = ":8080"
GRPCListenAddress = ":9090"
ID = "1"
RegistryAddress = ":2379"
RegisterTTL = 60
RegisterInterval = 15
ClientTimeOut = 10
Name = "go.micro.server.worker"
Makefile
将配置文件准备好之后,我们就可以构建并运行程序了。在构建程序时,输入一长串的构建命令比较繁琐。为了解决这样的问题,我们可以把一些构建的脚本写入Makefile文件中。如下所示:
VERSION := v1.0.0
LDFLAGS = -X "main.BuildTS=$(shell date -u '+%Y-%m-%d %I:%M:%S')"
LDFLAGS += -X "main.GitHash=$(shell git rev-parse HEAD)"
LDFLAGS += -X "main.GitBranch=$(shell git rev-parse --abbrev-ref HEAD)"
LDFLAGS += -X "main.Version=${VERSION}"
ifeq ($(gorace), 1)
BUILD_FLAGS=-race
endif
build:
go build -ldflags '$(LDFLAGS)' $(BUILD_FLAGS) main.go
lint:
golangci-lint run ./...
其中,build下的命令就是构建程序的命令。在这段命令中,LDFLAGS为编译时的一些选项,我们在编译时注入了程序的版本号、分支、构建时间、git commit号等信息。这些信息会注入到main.go中的全局变量中。在main.go中,我们还要进行一些配套的处理,用来打印一些程序的版本信息。
// Version information.
var (
BuildTS = "None"
GitHash = "None"
GitBranch = "None"
Version = "None"
)
func GetVersion() string {
if GitHash != "" {
h := GitHash
if len(h) > 7 {
h = h[:7]
}
return fmt.Sprintf("%s-%s", Version, h)
}
return Version
}
// Printer print build version
func Printer() {
fmt.Println("Version: ", GetVersion())
fmt.Println("Git Branch: ", GitBranch)
fmt.Println("Git Commit: ", GitHash)
fmt.Println("Build Time (UTC): ", BuildTS)
}
var (
PrintVersion = flag.Bool("version", false, "print the version of this build")
)
func main(){
flag.Parse()
if *PrintVersion {
Printer()
os.Exit(0)
}
}
如下所示。当我们执行make build 构建可运行程序,并传递 -version运行参数时,就会打印出程序的版本信息了:
> make build
> ./main -version
Version: v1.0.0-ed89d91
Git Branch: master
Git Commit: ed89d91d03834fe85b1ca7f74f0cca305b8e516a
Build Time (UTC): 2022-11-30 04:52:45
同时在Makefile中,BUILD_FLAGS表示构建可执行文件的参数。当我们设置环境变量gorace=1时,go build会将 race 工具编译到程序中。最后我们会看到完整的构建命令:
» export gorace=1
» make build
go build -ldflags '-X "main.BuildTS=2022-12-03 05:48:59" -X "main.GitHash=e73f1126031f56178ca86deda7fceb0a71b5314e" -X "main.GitBranch=master" -X "main.Version=v1.0.0"' main.go
总结
这节课,我们使用了静态与动态的代码扫描来发现代码的Bug和不太规范的代码,这可以帮助开发者遵循团队的编码规范,书写出更优雅的程序。
同时我们还看到了如何用go-micro的config库来更灵活地对配置文件进行管理。它不仅提供了配置化的能力,还实现了动态配置、配置合并的能力,在这个过程中,我们看到了功能全面的配置管理需要考虑的因素。
最后,通过书写Makefile文件,我们可以执行预先定义好的脚本,更快、更优雅地书写项目代码。
课后题
- golangci-lint中包含了众多的lint,其中有些lint的功能是过时的,重复的。那么我们在项目中应该让哪些lint生效呢?
- 配置文件、JSON格式与TOML格式分别适用于哪一种场景?
欢迎你在留言区与我交流讨论,我们下节课见!
- konyo 👍(0) 💬(1)
跨度好大啊
2023-02-03