这节课让我们继续优化代码,让程序可配置化。然后通过静态与动态的代码扫描发现程序中存在的问题,让代码变得更加优雅。
首先,让我们紧接上节课的 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 中,这样就大功告成了。
service := micro.NewService(
...
micro.WrapHandler(logWrapper(logger)),
)
当 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,可以使用下面的命令:
> golangci-lint help linters
Go 语言定义了实现 Linter 的 API,它还提供了 golint 工具,golint 集成了几种常见的 Linter。在源码中,我们可以查看在标准库中如何实现典型的 Linter。
Linter 的实现原理是静态扫描代码的 AST(抽象语法树),Linter 的标准化意味着我们可以灵活实现自己的 Linters。不过,golangci-lint 里面其实已经集成了包括 golint 在内的众多 Linter,并且具有灵活的配置能力。所以如果你想自己写 Linter,我也建议你先了解一下 golangci-lint 现有的能力。
使用 golangci-lint 的第一步就是安装,不同环境下的安装方式你可以查看官方文档。下面我来演示一下如何在本地使用 golangci-lint。最简单的方式就是执行下面的命令:
golangci-lint run
它等价于:
golangci-lint run ./...
我们也可以指定要分析的目录和文件:
golangci-lint run dir1 dir2/... dir3/file1.go
就像前面所说,golangci-lint 是众多 lint 的集合,要查看 golangci-lint 默认启动的 lint,可以运行下面的命令:
golangci-lint help linters
可以看到,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。
sqldb/option.go:9:2: struct field `sqlUrl` should be `sqlURL` (golint)
sqlUrl string
再举个例子,这里,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 的结果也是不明确的。
// race.go
var count = 0
func add() {
count++
}
func main() {
go add()
go add()
}
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 冲突的协程栈,以及此时正在运行的协程栈。
$ go test -race mypkg
$ go run -race mysrc.go
$ go build -race mycmd
$ go install -race mypkg
如果对上面这个例子的 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 行,这样我们就可以非常快速地发现并定位数据争用问题了。不过,竞争检测也有一定成本,它因程序的不同而有所差异。对于典型的程序来说,内存使用量可能增加 510 倍,执行时间会增加 220 倍。同时,竞争检测器还会为当前每个 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
此文章来源于极客时间《Go 进阶 · 分布式爬虫实战》。