0%

《Go 语言精进之路》读书笔记(10):工具链与工程实践

这篇文章学习使用 Go 语言做软件项目过程中很可能会遇到的一些工程问题的解决方法,包括使用 go module 进行 Go 包依赖管理、Go 应用容器镜像、Go 相关工具使用等。

使用 Go module 管理包依赖

使用 go module 管理包依赖已经成为 Go 项目包依赖管理的唯一标准,并成为高质量 Go 代码的必要条件。

Go 语言包管理演进回顾

为了更好理解 go module 包依赖管理机制,首先需要了解 Go 语言包管理的演进历史。

Go 在构建设计方面深受 Google 内部开发实践的影响。Google 内部采用基于主干的开发模型:

  • 所有开发人员基于主干(trunk/mainline)开发,将代码提交到主干或从主干获取最新的代码(同步到本地仓库)​
  • 版本发布时,建立发布分支(Release branch)​,发布分支实质上就是某一个时刻主干代码的快照
  • 必须同步到发布分支上的补丁代码和改进代码通常先在主干上提交(commit)​,再挑拣(cherry-pick)到发布分支上

基于这个模型,google 内部各个 project/repositories 的 master 分支上的代码都被认为是稳定的。go get 早期的行为模式与该模型类似:go get 仅支持获取主分支上的 latest 代码,没有指定 version、branch 或 revision 的能力。

go get 本质上是 git、hg 等版本管理工具的高级包装。对于使用 git 的 Go 包来说,go get 的实质就是将这些包克隆到本地的特定目录下(例如在 gopath 模式下为 $GOPATH/src/github.com/user/repo),同时 go get 可以自动解析包的依赖,自动下载相关依赖包并调用本地 Go 工具链完成的包的本地构建。

这种机制有如下缺点:

  • 不能保证可重现的构建(不同 gopher 在不同时间获取和编译包得到的结果可能是不同的)
  • 如果依赖包引入了不兼容代码,你的程序/包无法通过编译
  • 如果依赖包引入了编译问题,这种错误也会传导到你的包中,导致你的包无法通过编译

Gopher 希望自己项目所依赖的第三方包都能受自己控制,不是随意变化,于是 godep、gb、glide 等一批第三方包管理工具出现。

Go 核心团队自己也一直在关注 Go 的包依赖问题,并在 1.5 版本中引入了 vendor 机制。vendor 标准化了项目依赖的第三方库的存放位置,Go 编译器能够原生优先感知和使用 vendor 目录下缓存的第三方包版本。

虽然有了 vendor 的支持,vendor 内的第三方依赖包代码的管理也依旧是不规范的:要么是手动、要么借助 godep 这样的第三方包管理工具(依赖包的分析、记录和获取等)。

2018 年 5 月,Russ Cox 将 go module 机制合入 Go 项目项目主干,它的诞生意味着另一个尝试用来解决 Go 包管理的 dep 项目结束。

Go module:Go 包管理的生产标准

GO111MODULE="off" 的情况下,项目的构建采用传统的 GOPATH 模式。在该模式下,我们需要通过 go get 将包下载到本地(它也会自动下载该包的所有依赖包)$GOPATH 目录下,GO 编译器会从 $GOPATH(及 vendor 目录下)搜索目标程序依赖包。这种模式称为 gopath mode

在引入了 vendor 机制以及诸多包管理工具后,Go 核心团队一致在尝试 去 GOPATH。从 Go 1.18 开始,GOPATH 会有一个默认值,Linux 下是 $HOME/go。虽然不用设置 GOPATH,但是 GOPATH 在 Go 工具链中依然很重要。

Go module 引入了一种新的依赖管理模式:module-aware 模式。在该模式下,通常一个仓库的顶层目录下会放置一个 go.mod 文件,每个 go.mod 文件唯一定义一个 module。一个 module 就是由一组相关的包组成的一个独立的版本单元。存放 go.mod 文件的目录被称为 module 根目录。module root 目录及其子目录下的所有 Go 包均属于该 module(除了那些自身包含 go.mod 文件的子目录)。虽然 Go 支持在一个仓库中定义多个 module,但是 Go 惯用法是一个仓库只定义一个 module

在 module-aware 模式下,Go 编译器将不会在 GOPATHvendor 下搜索目标程序依赖的第三方 Go 包。在该模式下,Go 编译器会将下载的依赖包缓存在 $GOPATH/pkg/mod 目录下。

在 Go1.11 版本中,go module 通过 GO111MODULE 变量来控制是否开启。该变量有 3 个值:auto、on 和 off。默认值为 auto。GO111MODULE 的值会直接影响 Go 编译器的包依赖管理工作模式的选择:是 gopath 模式还是module-aware 模式。

  • 如果为 off,则始终使用 gopath 模式,即 Go 编译器在 $GOPATH 及 vendor 下搜索目标程序的依赖包
  • 如果为 on,则始终使用 module-aware 模式,即总是在 go module 的缓存目录(默认是 $GOPATH/pkg/mod)下搜索对应版本的依赖包
  • 如果为 auto,此时取决于要构建的源代码所在位置以及是否包含 go.mod 文件。如果要构建的源代码不在以 GOPATH/src 为根目录的子树中且包含 go.mod 文件(两个条件同时满足),那么会使用 module-aware 模式,否则使用 gopath 模式

在 Go1.13 中,虽然 GO111MODULE 的值还是 auto,但是只要目录下有 go.mod 文件,那么 Go 编译器就会使用 module-aware 模式来管理包依赖。

在 Go1.14, go module 机制趋于稳定,GO111MODULE 的值对包依赖管理工作模式的选择及行为模式变动如下:

  • module-aware 模式下,如果 go.mod 中 go version 是 Go 1.14 及以上,且当前仓库顶层目录下有 vendor 目录,那么 Go 工具链将默认使用 vendor(-mod=vendor)中的包,而不是 module cache 中的($GOPATH/pkg/mod下)​。同时,Go 工具会校验 vendor/modules.txt 与 go.mod 文件以确保它们保持同步。如果显示传入 -mod=mod,那么也可以强制使用 module cache 中的包进行构建
  • module-aware 模式下,如果无法找到 go.mod,那么你必须显式传入要处理的 Go 源文件列表,否则 Go 工具链将需要你明确建立 go.mod。
1
2
3
4
5
6
# go build
go: go.mod file not found in current directory or any parent directory; see 'go help modules'

# go build main.go
# ls
main main.go

在 Go 1.16 中,Go module-aware 模式成为默认模式,即 GO111MODULE 的值默认为 on。

go.mod 文件一旦被创建,它的内容就会被 Go 工具链全面掌控。Go 工具链的各类命令执行时都会维护 go.mod 文件。

  • 可以通过 go list -m 来查看构建当前 module 所需的所有相关包信息的列表。其中 Main: true 的那个 module 为 main modulemain modulego build 命令执行时所在当前目录所归属的那个 module。go 命令会在当前目录、当前目录的父目录、父目录的父目录等下面寻找 go.mod 文件,所找到的第一个 go.mod 文件对应的 module 即为 main module
  • 使用 go mod -require 可以显示更新 go.mod 文件中的 require 段的信息(支持 query 表达式)。如果没有显式指定版本,Go 编译器总是使用依赖包的最新版本信息(如果依赖包没有版本,Go 会为其会产生伪版本号)
  • 每个依赖管理方案都必须解决选择依赖项版本的问题。Go 则采用了最小版本选择(Minimal Version Selection, MVS)算法
  • 按照语义化版本规范,当代码出现与之前版本不兼容性变化时,需要升级版本中的 major 版本号。go module 允许在包导入路径中带有 major 版本号,甚至可以在一个项目中同时依赖同一个包的不同版本
  • go module 机制依然兼容 vendor 机制。go module 支持通过如下命令将某个 module 的所有依赖复制一份到 module 的根路径下。这样即使在 module-aware 模式下,依然可以只用 vendor 下的包来构建。而且生成的 vendor 目录还可以兼容 Go1.11 之前的 Go 编译器
1
2
3
# go mod -vendor

# go build -mod=vendor
  • 执行 go build 之后,当前构建的模块目录下多了一个 go.sum 文件,该文件记录了每个依赖库的版本和对应内容的校验和。go 命令会使用这些校验和与缓存在本地的依赖包副本元信息进行对比校验。可以通过 go mod verify 命令来检查当前依赖包是否被修改过
  • 在将代码提交/推回存储库之前,运行 go mod tidy 以确保 module 文件(go.mod)是最新且准确的,该命令可以确保项目具有所需内容准确且完整的快照
  • 由于 go.mod 和 go.sum 都是由 Go 工具链维护和管理的,不建议手动修改 go.mod 中 require 中的包版本号。我们可以通过 go get 命令来修改依赖关系(该命令会自动计算并更新其间接依赖的包的版本)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# go list -m -versions golang.org/x/text
golang.org/x/text v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.3.2 v0.3.3 v0.3.4 v0.3.5 v0.3.6 v0.3.7 v0.3.8 v0.4.0 v0.5.0 v0.6.0 v0.7.0 v0.8.0 v0.9.0 v0.10.0 v0.11.0 v0.12.0 v0.13.0 v0.14.0 v0.15.0 v0.16.0 v0.17.0 v0.18.0

# go get golang.org/x/text@v0.3.2
go: downloading golang.org/x/text v0.3.2
go: added golang.org/x/text v0.3.2

# cat go.mod
module test

go 1.23.1

require golang.org/x/text v0.3.2 // indirect

# cat go.sum
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
  • 在 module-aware 模式下,使用 go get -u 将当前 module 的所有依赖的包版本都升级到最新的兼容版本。如果仅升级 patch 号,而不升级 minor 号,可以使用 go get -u=patch

Go module 代理

可以通过设置 GOPROXY 环境变量让 Go 命令从代理服务器下载 module。

1
GOPROXY='https://goproxy.cn'

Go1.13 开始,https://proxy.golang.org 被设置为 GOPROXY 环境变量的默认值之一,这也是 Go 提供的官方代理服务。而且该变量支持设置多个值(多个代理之间采用逗号隔开),Go 工具链会按照顺序从列表中的代理服务获取依赖包数据。

Go 1.13 提供了 GOSUMDB 环境变量来配置 Go 校验和数据库的服务地址(和公钥)​,其默认值为 sum.golang.org,这也是 Go 官方提供的校验和数据库服务(也可以使用 sum.golang.google.cn)​。

如果依赖的是企业内部代码服务器或公共代码托管站点上的私有 module,那么就不能使用公共 module 代理服务来获取数据。Go 1.13 提供了 GOPRIVATE 环境变量用于指示哪些仓库下的 module 是私有的,不需要通过 GOPROXY 下载,也不需要通过 GOSUMDB 验证其校验和。但是也需要注意,GONOPROXYGONOSUMDB 可以覆盖 GOPRIVATE 变量中的设置。

升级 module 的主版本号

Go import包兼容性的总原则是:如果新旧版本的包使用相同的导入路径,那么新包与旧包是兼容的。也就是说,如果新旧两个包不兼容,那么应该采用不同的导入路径。因此主版本是导入路径的一部分。

1
2
3
4
import (
"github.com/bigwhite/foo/bar"
barV2 "github.com/bigwhite/foo/v2/bar"
)

在该方案中,对于包作者而言,升级主版本号需要:

  • 在 go.mod 中升级 module 的根路径,增加 vN
  • 建立 vN.x.x 形式的标签(可选,如果不打标签,Go 会在消费者的 go.mod 中使用伪版本号,比如 (bitbucket.org/bigwhite/modules-major-branch/v2 v2.0.0-20190603050009-28a5b8da279e
  • 如果包内部有相互的包引用,那么在升级主版本号的时候,这些包的导入路径也要增加 vN,否则就会出现在高版本号的代码中引用低版本号包代码的情况,这也是包作者极容易忽略的事情

对于包的消费者而言,升级依赖包的主版本号,只需要在导入包时在导入路径中增加 vN 即可,当然代码中也要针对不兼容的部分进行修改,然后 go 工具就会自动下载相关包了。

Go module 还提供了一种用起来不那么自然的方案,那就是利用子目录分割不同主版本。这里直接用 vN 作为子目录名字,在代码仓库中将不同版本 module 放置在不同的子目录中,这样即便不建分支、不打标签,Go编译器通过子目录名也能找到对应的版本。这种方式使得代码的分支管理更为复杂,一般很少使用。

构建最小 Go 程序容器镜像

Go 语言已经成为云原生时代的头部语言,在本条中我们就来看看如何一步步地构建出最小 Go 程序容器镜像。

镜像:继承中的创新

2008 年,LXC(Linux Container)功能被合并到 Linux 内核中。LXC 是一种内核级虚拟化技术,主要基于 Namespace 和 Cgroups 技术,实现共享一个操作系统内核前提下的进程资源隔离,为进程提供独立的虚拟执行环境,这样的一个虚拟执行环境就是一个容器。

Docker 也是基于 Namespace 和 Cgroups 技术实现的,其创新之处在于其基于 Union File System 技术定义了一套容器打包规范,真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中,这种文件就称为 镜像。Docker 为开发者提供了开发者体验良好的工具集,包括用于构建镜像的 Dockerfile 以及一种用于编写 Dockerfile 的领域特定语言。

镜像是容器的序列化标准,它为容器的存储、重用和传输打下了基础。

builder 模式的崛起

Dockerfile 由若干条命令组成,每条命令的执行结果都会单独形成一个层。在最终镜像中只要包含能够让应用正常运行的运行环境即可。为了解决应用的构建问题,至少有两种方法:

  • 在本地构建并复制到镜像中
  • 借助构建者镜像(builder image)构建

本地构建有很多局限性,例如本地环境无法复用、无法融入 CI/CD 流水线等。而借助 builder image 进行构建成为 Docker 社区的最佳实践。在该模式下,整个目标镜像的构建被分为两个阶段:

  • 第一阶段,构建负责编译源码的构建者镜像
  • 第二阶段,将第一阶段的输出作为输入,构建出最终的目标镜像

在第一阶段中,可以使用 Docker 官方推出的各种主流编程语言的官方基础镜像,例如:

1
2
3
4
5
6
7
FROM golang:1.23.1

WORKDIR /go/src

COPY ./main.go .

RUN go build -o main ./main.go

接下来通过一些命令将两个阶段进行连接,这些命令将上阶段的构建输出取出并作为下一阶段构建的输入:

1
2
3
4
5
6
# docker build -t go-docker -f Dockerfile .

# docker create --name build-go-docker go-docker
# docker cp build-go-docker:/go/src/main ./main
# docker rm -f build-go-docker
# docker rmi go-docker

接下来就是目标镜像的 Dockerfile:

1
2
3
4
5
6
7
From ubuntu:20.04

COPY ./main /root/main
RUN chmod +x /root/main

WORKDIR /root
ENTRYPOINT ["/root/main"]

这样构建的目标镜像就不需要 go 的编译环境,只包含应用程序的运行环境即可。

追求最小镜像

我们还可以对目标镜像继续优化,仅保留能支撑我们应用运行所必要的库、命令,其余的一律不纳入目标镜像。我们可以选择一些更小的镜像作为 base image,例如:

  • busybox:它的默认 C 运行时库是 uClibc,而通常使用的 libc 实现都是 glibc
  • alpine:它使用比 glibc 更小、更安全的 musl libc 库。而且其支持自己的包管理体系 apk

一般开发者喜欢使用 alpine 作为基础镜像。但是由于其也不是使用 glibc 作为 C 运行时库,这会使得基于 glibc 上编译出来的应用程序不兼容。对于 Go 程序来说,可以静态编译程序(也会失去一些 libc 提供的原生能力),或者可以采用基于 alpine 的 builder image。

对多阶段构建的支持

Docker 引擎本身也支持多阶段构建(multi-stage build),如下是一个多阶段构建的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM golang:alpine as builder

WORKDIR /go/src

COPY ./main.go .

RUN go build -o main ./main.go

FROM alpine:latest

WORKDIR /root
COPY --from=builder /go/src/main .
RUN chmod +x /root/main

ENTRYPOINT ["/root/main"]
1
2
3
# docker build -f Dockerfile -t multi-go-docker .
# docker run multi-go-docker
hello docker

自定义 Go 包的导入路径

我们使用最多的 Go 包 go get 导入路径主要是基于一些代码托管站点域名,例如 github.com。还有一些包的导入路径很特殊,例如 golang.org/x/net,这些包使用了自定义的包导入路径,这种自定义包导入路径的方式有很多好处:

  • 可以作为 Go 包的权威导入路径:Go 包的用户只需要使用包的权威导入路径,无论 Go 包的实际托管点在哪、Go 包迁移到哪个托管站点,对 Go 包的用户都不会带来实质性影响
  • 便于组织和个人对 Go 包的管理:可以将分散托管在不同代码站点上的 Go 包统一聚合到组织的官方域名下
  • go 包导入路径可以更短、更简洁

govanityurls

govanityurls 工具可以帮助 gopher 快速实现自定义 Go 包的 go get 导入路径。它其实类似于导航服务器,go get 向自定义包地址发起请求时,实则是将请求发送给了 govanityurls 服务,之后 govanityurls 将请求中包所在仓库的真实地址返回给 go getgo get 再从真实的仓库地址获取数据。

使用 govanityurls

要使用 govanityurls,需要遵循如下步骤:

  • 安装 govanityurls 工具
  • 配置 govanityurls 的配置文件 vanity.yaml,该文件中配置了 host 下的自定义包路径以及其真实的仓库地址
  • 配置反向代理:使用一个反向代理软件(例如 Nginx)将访问自定义包导入路径的请求转发给 govanityurls 服务的主机地址
  • 启动 govanityurls 服务

熟练掌握 Go 常用工具

接下来将全面介绍 Go 包开发周期中的常用工具,涵盖主要原生工具以及使用较广的第三方工具。

获取与安装

Go 原生提供了获取和安装 Go 包的工具 go getgo install

go get

go get 用于获取 Go 包及其依赖包。

  • go get -d 可以获取 Go 项目的源码,它仅将源码下载到本地,不会对目标包进行编译和安装

    • module-aware 模式下,go get -d 命令会将源码包及其依赖下载到 $GOPATH/pkg/mod
    • gopath 模式下,则将源码包及其依赖下载到 $GOPATH/src
    • 虽然都会下载依赖包,但是 module-aware 模式还会分析依赖包的版本。
  • 标准的 go get 会下载项目及其依赖包的源码,并对下载的源码进行编译和安装。

    • 在 gopath 模式下,如果目标源码最终被编译成一个可执行的二进制文件,则被安装到 $GOBIN 或者 $GOPATH/bin 目录下。如果是目标源码是库,则编译后的库目标文件以 .a 形式被安装到 $GOPATH/pkg/$GOOS_$GOARCH
    • 在 module-aware 模式下,编译出的二进制目标文件也会被安装到 $GOBIN 或者 $GOPATH/bin;如果目标源码是库,则只编译并将编译结果缓存下来,不会安装
  • go get -u 可以更新目标包及其依赖包的版本。

    • 在 gopath 模式下,该命令获取最新版本的源码到本地后会再次编译安装
    • 在 module-aware 模式下,该命令则会根据目标 module 的 go.mod 中依赖 module 版本获取满足要求的、依赖 module 的 minor 版本或 patch 版本更新
  • go get -t 可以获取测试代码依赖的包

go install

go install 可以将本地构建出的可执行文件安装到 $GOBIN(默认值为 $GOPATH/bin)、将包目标文件 .a 安装到 $GOPATH/pkg/$GOOS_$GOARCH 下。

go install 命令在 gopath 模式和 module-aware 模式下的行为也略有差异。

  • gopath 模式:
    • 引入 go module 之前(即 Go1.11 之前):将可执行文件安装到 $GOBIN,将依赖包安装到 $GOPATH/pkg/$GOOS_$GOARCH
    • 引入 go module 之后:仅将可执行文件安装到 $GOBIN,将依赖包编译后放入 $GOCACHE
  • module-aware 模式:仅将可执行文件安装到 $GOBIN 下,将依赖包编译后放入 $GOCACHE

新的变化

其实从 Go1.17 开始,使用 go get 来安装可执行文件已经被废弃了,取而代之的是 go install。在 Go1.18 中,go get 也不再用于构建软件包,它只是用来增加、更新或移除 go.mod 中的依赖项。具体可以参考 Deprecation of ‘go get’ for installing executables

包或者 module 的检视

Go 提供了一个原生工具 go list,用于列出关于包/module 的各类信息。该命令默认列出当前路径下的包的导入路径

  • 如果是 module-aware 模式,它会在当前路径下寻找 go.mod 文件。如果当前路径下没有 go.mod 文件,则报错
  • 在 gopath 模式下,如果当前路径没有包,go list 会报错。如果后面直接接包的导入路径,go list 会在 $GOPATH/src 下寻找该包,如果存在,则输出包的导入路径
  • 如果要列出当前路径及其子路径(递归)下的所有包,可以用 go list {当前路径}/...。也可以使用 包导入路径 + ... 的方式,表示列出该路径下所有子路径下的包导入路径

Go 原生保留了几个代表特定包或包集合的路径关键字:main、all、cmd 和 std。这些保留的路径关键字不要用于 Go 包的构建中:

  • main:表示独立可执行程序的顶层包
  • all:在 gopath 模式下,可以展开为标准库和 GOPATH 路径下的所有包;在 module-aware 模式下,可以展开为主 module(当前路径下的 module)下的所有包及其所有依赖包
  • std:代表标准库所有包的集合
  • cmd:代表 Go 语言自身项目仓库下 src/cmd 下的所有包及 internal 包

如下是 go list 的其他用法:

  • 默认 go list 输出的都是包的导入路径信息,如果要列出 module 信息,可以使用 go list -m
  • go list -f 可以定制其输出内容的格式
  • go list -json 可以以 JSON 格式输出包的全部信息
  • go list -m -u 可以列出 module 及其依赖 module 是否有新的版本可以升级

构建

Go 原生的 go build 命令可以用于 Go 源码的构建。大多数情况下,只需要标准 go build 即可满足构建需求。如下介绍 go build 的一些常见用法:

  • -x -v:可以让构建过程一目了然。其中 -v 用于输出当前正在编译的包,而 -x 则用于输出 go build 执行的每一个命令
  • -a:该选项让 go build 忽略掉所有缓存机制,忽略掉已经安装到 $GOPATH/pkg 下的依赖包库文件,并从目标包/module 依赖的标准包的每个 Go 源文件开始重新构建
  • -race:可以在构建的结果中加入竞态检测的代码
  • go build 可以经由 -gcflags 向 compile 工具传递所需的命令行标志选项集合。具体的使用方法如下所示。如果没有指定 标志应用的包范围,则指定的编译选项仅应用于当前包;如果显式指定了包范围,则编译选项不仅会应用到当前包的编译上,还会应用于包范围指定的包上
1
go build -gcflags[=标志应用的包范围]='空格分隔的标志选项列表'
  • 可以使用 go tool compile -help 列出编译器所支持的选项集合。例如通常使用 -N -l 两个选项关闭对代码的优化和内联,这样方便调试
  • go build 支持经由 -ldflags 为链接器传递链接选项的集合。可以通过 go tool link -help 列出链接器支持的所有链接选项。例如,通过 -X 选项设置包中 string 类型变量的值(经常用于设置程序的版本信息)、-s 选项不生成符号表、-w 不生成 DWARF 调试信息
  • go build 支持通过 -tags 指定构建的约束条件,以决定哪些源文件被包含在包内进行构建。tags 的值是一组逗号分隔的值。例如:
1
$ go build -tags="tag1,tag2..." ...
  • 此时 Go 源文件中也会包含 build tagbuild tag 通常放在 Go 源文件的顶部区域,以一行注释或者连续的多行注释形式存在。build tag 与前后的包注释或包声明语句的中间要有一行空行。build tag 行也是注释行,它以 +build 作为起始标记,与前面的注释符号 // 中间有一个空格,之后则是约束标记字符串。当一个 Go 源文件带有 build tag 时,只有该组 tag 被求值为 true 时,该源文件才会被包含入对应包的构建中
1
2
3
// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

package os
  • Go 源文件中的 build tag 按照如下规则进行布尔表达式求值:
    • // +build tag1 tag2:tag1 OR tag2
    • // +build tag1,tag2:tag1 AND tag2
    • // +build !tag1:NOT tag1

运行与诊断

由于 Go 生成的程序对环境的依赖很少,甚至不需要任何依赖(比如采用静态链接),在如今的云原生微服务时代,这让 Go 程序在部署和运行方面有着很大的优势。Go 原生提供了一些环境变量,这些环境变量可以影响 Go 程序运行时的行为,并输出 Go 运行时的一些信息以辅助在线诊断 Go 程序的问题。

  • GOMAXPROCS:可用于设置 Go 程序启动后的逻辑处理器 P 的数量。从 Go1.5 开始,该值默认为 CPU 核数。
  • GOGC:它是一个整数值,表示一个百分比,用于控制垃圾回收的时机。它的分子是上一次 GC 结束后到当前时刻新分配的堆内存大小(设为 M)​,分母则是上一次 GC 结束后堆内存上的活动对象内存数据的大小(设为 N)​
  • GODEBUG:提供强大的运行时诊断能力。例如通过 gctrace=1 可以在 GC 启动时输出 GC 相关信息

格式化与静态代码检查

  • 在提交代码前,可以使用 gofmt 对代码进行格式化。goimportsgofmt 功能的基础上可以自动更新源文件中的 import 区域
  • 静态代码检查工具可以按照设定好的规则对代码进行扫描,才语义层面尝试发现潜在问题。go vet 是官方 Go 工具链提供的静态代码检查工具。也有一些第三方 lint 工具,例如 golangci-lint

重构

接下来介绍一些可以实施 Go 代码重构的工具。

  • gofmt -r 支持对当前路径及其子路径下的 Go 包源文件进行纯字符串模式的替换
  • gorename 工具可以进行语法层面安全的标识符替换
  • gomvpkg 是专门用来实现包移动/改名并同步更新项目中所有导入该包的源文件中的包导入路径和包引用名的工具

查看文档

Go 已经将项目文档加入到 Go 发行版中,开发人员在本地安装 Go 的同时也拥有了一份完整的 Go 项目文档。而且 Go 还将文档查看工具集成到其工具链当中(go doc),使之成为 Go 工具链不可分割的一部分。

  • 通过 go doc <pkg> 可以查看标准库某个包的文档
1
# go doc http
  • 通过 go doc [<pkg>.][<sym>.]<methodOrField> 可以查看某个函数/方法/类型/字段的文档
1
2
3
4
5
6
7
8
9
10
# go doc http.Request.Form
package http // import "net/http"

type Request struct {
// Form contains the parsed form data, including both the URL field's query
// parameters and the PATCH, POST, or PUT form data. This field is only available
// after ParseForm is called. The HTTP client ignores Form and uses Body instead.
Form url.Values

// ... other fields elided ...
  • 直接输入 go doc 可以查看当前路径下的包的文档,可以查看某个导出元素、某个子路径下的包的文档
  • go doc -src 可以查看某个元素的源代码

从 Go1.13 开始,godoc 被挪到 Go 扩展工具链中。可以通过如下命令安装 godoc

1
go install golang.org/x/tools/cmd/godoc@latest

godoc 实际上是一个 Web 服务,它会在本地建立起一个 Web 形式的 Go 文档中心,执行如下命令就会启动该文档中心:

1
godoc -http=0.0.0.0:6060

另外,Go 团队提供了一个博客内容服务程序,可以本地安装该 blog 内容服务程序。另外,Go 团队还提供了一种 present 文件,它是使用自定义的轻量级标记语言编写的文件,可以通过特点的站点渲染该文件,或者本地安装 present 工具,就可以在浏览器中查看 present 文件。

代码导航与洞察

2019 年,Go 官方启动了 Go 语言服务器的实现项目 gopls。语言服务器协议(LSP)是由微软创建的,目的是让语言服务器和开发工具之间的通信协议标准化。这样单个语言服务器就可以在多个开发工具中重复使用,从而避免以往必须为每个开发工具都单独开发代码补全、代码跳转等功能。

VsCode、Vim 等主流 IDE/编辑器都已经支持 gopls。

使用 go generate 驱动代码生成

这里单独介绍如何使用 go generate 工具驱动代码生成。

go generate:Go 原生的代码生成驱动器

有时候目标的构建需要依赖一些额外的前置动作,我们可以借助外部构建管理工具(例如 make)实现这个需求。Go 核心团队在 Go1.4 版本的 Go 工具链中增加了这种在构建之前驱动执行前置动作的能力,即 go generate 命令。

通过预先放置在代码中的、可以被 go generate 命令识别的指示符(directive),当我们执行 go generate 命令时,这些指示符命令就会被 go generate 识别并执行,从而完成前置动作的执行。

例如:

1
//go:generate protoc -I ./IDL msg.proto --gofast_out=./msg

go generate 的工作原理

go generate 命令需要在 go build 这类命令之前单独执行以生成后续命令需要的 Go 源文件等。go generate 命令将 Go 源文件当做普通文本文件并识别出可以与如下字符串模式匹配的内容。注意,注释符号 // 后面没有空格。

1
//go:generate command arg...

注意,go generate 指示符可以放在 Go 源文件中的任意位置,并且一个 Go 源文件中可以有多个 go generate 指示符,go generate 命令会按其出现的顺序逐个识别和执行。可以将 Go 源文件作为参数传递给 go generate 命令,也可以使用包作为 go generate 参数。

1
2
3
4
5
6
7
8
9
10
package main

//go:generate echo "start"
func main() {
println("hello")

//go:generate echo "middle"
}

//go:generate echo "end"
1
2
3
4
# go generate
start
middle
end

go generate 还可以通过 -run 使用正则式去匹配各源文件中 go generate 指示符中的命令,并仅执行匹配成功的命令。

1
$go generate -x -v -run "protoc" ./...

go generate 的应用场景

go generate 目前主要用在目标构建之前驱动代码生成动作的执行,例如基于 protobuf 定义文件生成 Go 源文件就是 go generate 一个极为典型的应用。

还有比较广泛的应用包括利用 stringer 工具自动生成枚举类型的 String 方法以及利用 go-bindata 工具将数据文件/静态文件嵌入 Go 源码中。当然从 Go1.16 开始,Go 内置了静态文件嵌入功能,我们可以直接在 Go 源码中通过 go:embed 指示符将静态资源文件嵌入。

go generate 工具通常是由 Go 包的作者使用和执行的,其生成的 Go 源码一般会提交到代码仓库中,这个过程对生成的包的使用者来说是透明的

牢记 Go 常见的陷阱

牢记 Go 语言中的一些场景 陷阱,有助于 Gopher 在工程实践中少走弯路。当然这里的 陷阱 并不是真正的语言缺陷,而是一些因为对 Go 语言规范、运行时、标准库以及工具链等了解不够全面、深入和透彻而容易犯的错误,或者是因为语言间的使用差异而导致的误用问题。

语法规范类

  • 短变量声明不总是会声明一个新的变量:在同一个代码块中,使用多短变量声明语句重新声明已经声明过的变量时,短变量声明语句
  • 短变量声明会导致难于发现的变量遮蔽:由于不在同一个代码块中,短变量声明可能会会创建一个新的同名变量,造成对外层代码块中同名变量的遮蔽
  • 不是所有以 nil 作为零值的类型都是零值可用的:切片、map、接口类型和指针类型的零值都是 nil,但是不是所有这些类型都是零值可用的
    • 对于切片类型,在 append 操作时是零值可用的
    • 对于指针类型,可以调用没有对自身进行指针解引用的方法
    • 对于接口类型,为接口类型赋予显式类型转换后的 nil(并非真正的零值),可以通过该接口调用 没有解引用 操作的方法
  • 值为 nil 的接口类型变量并不总是等于 nil:接口类型在运行时的表示分为两部分,一部分是类型信息,一部分是值信息。只有当接口类型变量的这两部分的值都为 nil 时,该变量才与 nil 相等。
  • for range 针对切片、数组或字符串进行迭代操作时,迭代变量由两个,第一个是元素在迭代集合中的序号值,第二个值才是元素值
  • 针对 string 类型进行 for range 迭代时,每次返回的是一个码点,而不是一个字节。如果想要逐字节迭代,可以通过 []byte(s) 将字符串类型转换为字节切片再进行迭代。Go 编译器会对这个转换进行优化,它不会为 []byte 进行额外的内存分配,而是直接使用 string 的底层数据
  • 对 map 类型内元素的迭代顺序是随机的,如果想要有序迭代 map 内的元素,需要额外数据结构的支持,例如使用切片来有序保证 map 内元素的 key 值
  • for range 迭代中,我们需要注意到,我们是在 复制品 上进行迭代。而且迭代变量也是重用的
  • 基于已有切片创建新的切片与原切片共享底层存储,这样如果原切片占用较大的内存,新切片的存在又使原切片内存无法得到释放,这样会占用过多内存。可以通过内建函数 copy 为新切片建立独立的存储空间以避免与原切片共享底层存储,从而避免空间浪费
  • 除了占用的内存过多,新切片与旧切片共享底层存储也可能导致隐匿数据的暴露、切片数据被篡改。因为切片的容量特性,对新分配的切片进行扩张式 reslicing 操作有可能导致隐匿数据被暴露。依然可以通过为新切片分配独立的存储空间的方法来解决这个缺陷
  • 新切片与原切片底层存储可能 分家,这是因为 Go 的切片支持自动扩。一旦发生 分家,后续对新切片的任何操作都不会影响到原切片
  • string 类型的长度并不等于该字符串的字符个数,而是等于 string 类型底层数组中的字节数量。string 类型的下标操作也是以字节为单位
  • string 类型是不可变的,无法改变其中的数据内容
  • string 类型的零值是 “”,而不是 nil
  • 在 Go 中,switch 的执行流并不会从匹配到的 case 语句开始一直向下执行,而是执行完 case 语句块代码后跳出 switch 语句,除非你显式使用 fallthrough 强制向下一个case语句执行
  • 在没有外部结构支撑的情况下,Go 原生并不支持获取某个 goroutine 的退出状态。而利用 channel 等数据结构,可以轻松获取 goroutine 的退出状态
  • 程序的退出与否全看 main goroutine 是否退出。一旦 main goroutine 退出,这时即便有其他 goroutine 仍然在运行,程序进程也会退出,其他 goroutine 也就终止了。通常我们可以使用 sync.WaitGroup 来协调多个 goroutine
  • 任何一个 goroutine 出现 panic,如果没有及时捕获,那么整个程序都将退出。为了避免这个问题,可以采用防御性代码,即在每个 goroutine 的启动函数中加上对 panic 的捕获逻辑。当然有时候让程序及时退出也可能是更好的处理方案
1
2
3
4
5
6
7
8
9
func safeRun(g func()) {
defer func() {
if e := recover(); e != nil {
fmt.Println("caught a panic:", e)
}
}()

g()
}
  • channel 有可能处于两种特殊状态,即零值 channel(nil channel)和已关闭的 channel(closed channel)。

    • 对 nil channel,发送和接收操作都会被阻塞
    • 对于 closed channel,接收操作将返回 channel 中元素类型的零值,发送操作将引发 panic
  • 使用值类型 receiver 的方法无法改变类型实例的状态。因为方法本质上是一个以 receiver 为第一个参数的函数,而 Go 都是通过 值复制 的形式进行参数传递,因此函数内部对值类型的 receiver 进行修改,无法改变外部实参的状态

  • 值类型实例可以调用采用指针类型 receiver 的方法,指针类型实例也可以调用采用值类型 receiver 的方法。注意这个语法糖的范围仅限于类型实例调用方法这个范畴。当我们将类型实例赋值给某个接口时,只有真正实现了该接口类型的类型实例才能赋值成功

  • 在 Go 中,如果 for 循环与 switch 或 select 联合使用时,可能会调用 break 的陷阱中。在 Go 中,不接标签的 break 语句会跳出最内层的 switch、select 或 for 代码块。如果要跳出最外层的循环,需要为循环定义一个标签,并让 break 跳到这个标签

标准库类

接下来介绍一些标准库使用时经常会犯的一些错误:

  • time 包中采用 参考时间 来实现日期时间的格式化
  • json 包在将结构体类型编码为 JSON 文本时,是通过为结构体字段添加 tag 的方式来指示其在 JSON 文本中的名字。而且 json 包默认仅对结构体中的导出字段(字段名首字母大写)进行编码,非导出字段并不会被编码。解码时也遵循该规则。除了 json 包,标准库 encoding 目录下的各类编解码包都遵循相同的规则
  • nil 切片是指尚未初始化的切片,Go 运行时尚未为其分配存储空间,而空切片则是已经初始化了的切片,Go 运行时为其分配了存储空间,但该切片的长度为 0。json 包在为这两种切片编码时会区别对待,空切片编码为 [],nil 切片则编码为 null
  • 由于字节切片可以存储任意的字节序列,可能包含控制符、\0 以及不合法的 Unicode 字符等无法显示或导致乱码的内容。json 包在编码字节序列时,有可能将其编码为 base64 编码的文本。如果能确保切片中存储的是合法 Unicode 字符的 utf-8 编码字节,又不想将其编码为 base64 输出,那么可以将其转换为 string 类型后在用 json 包进行处理
  • 当 JSON 文本中的整型数值被解码为 interface{} 类型时,其底层真实类型为 float64。因为很多时候 JSON 文本中字段不确定,因此常用 map[string]interface{} 类型来接收 json 包解码后的数据,这样 JSON 字段值就会被存储在一个 interface{} 变量中,之后则通过类型断言来获取其中存储的整型值,而此时 interface{} 的底层类型是 float64,而不是 int。json 包也提供了 Number 类型来存储 JSON 文本中的各类数值类型,并可以转换为整型、浮点型、字符串等
  • 在使用 http 包实现 http 客户端时,需要及时关闭 resp.Body,因为 http 包的实现逻辑是只有应答的 Body 中的内容被全部读取完毕且调用了 Body.Close(),默认的 HTTP 客户端才会重用带有 keep-alive 标志的 HTTP 连接。如果是服务端,http 包会自动处理 Request.Body
  • 对于 Go 标准库 HTTP 客户端,想要及时关闭 HTTP 连接有两种方法:
    • 第一种是将 http.Request 中的字段 Close 设置为 true
    • 第二种是通过创建一个 http.Client 新实例来实现(不使用 DefaultClient),并将心创建的 Client 实例的 Transport 字段中的 DisableKeepAlives 设置为 true,这样就设置了与服务端不保持长连接