(吐槽向) 我终于学会如何组织 Go 代码了

患者病史

患者本人有多年的强迫症病史,从事客户端编程两年有余,深受剥离代码和环境配置之苦;转后端开发约半年,脑子里仍然存在着 “💡哇哈哈哈哈,老子终于能任意使用自己喜欢的语言、时刻使用最新的版本了” 这样的幻想。

为了实现最初的构想,花了 30 分钟入门 Go 语言,写了一段“只是能用”的代码。现在 idea 已经证明可行,要正正经经的重构之前的 demo,更好地组织代码、并且在上面添加更多功能了。

因为要正正经经的编写 Go 的项目,所以该患者学习了 Go 包管理的历史,一度心脏骤停、无法呼吸。直到看到 Go Modules 一章,才感觉身体有所缓解。服用 VSCode 💊药丸、 Go Mod 💊药丸、十味 docker 多阶镜像💊大补丸之后,方彻底治愈。

本文为吐槽向,请大家以搞笑的态度看待,不要太认真啦 ^_^

患者病历

姓名:曹二葱

病史:强迫症、健忘症、任天堂游戏依赖症、懒癌晚期

籍贯:海拉尔人、乌贼星人(Booyah~

症状:不擅长记忆过长的命令参数、无法接受超过 100MB 的 docker 镜像、无法接受每次构建都拉取依赖项、无法接受把依赖项的代码放到本地路径、无法接受把本地代码路径与 $某PATH 关联、对 python 2.x 的代码过敏

经历:打架没赢过,吵架没输过

偏好:

  1. 喜欢 docker + k8s 带来的开发、构建、部署一条龙,不喜欢配置环境、裸机编译、手动部署
  2. 喜欢 anacondaminiconda 一站式环境解决方案,不喜欢在安装依赖项报错的海洋中泡浮
  3. 喜欢现代化语言 + 现代化包管理工具,不喜欢手动下载源码编译 SDK
  4. 喜欢 markdown 并用 mermaid 包办一切图表绘制,不喜欢二进制格式的文档、图表文件

康复疗程

1. 抛弃 $GOPATH

作为 Python 用户,我们都知道,如果你使用了外部库,就最好编写一份 requirements.txt,就算懒得自己写,也可以自动生成一个:

1
2
# 先装个 pipreqs
pipreqs . --force

大家拿到代码后,先 pip install -r requirements.txt 一下,只要别碰 python 2.x 的老代码,基本不怎么需要关心运行环境和依赖项的问题。就算需要对付 python 2.x 的代码,也可以用 pyenv 来管理机器上安装的不同版本的运行时,你说对不?

另外,在 Python 里用相对路径引用代码也很方便、不需要集中管理代码。但 Go 的话,据说以前 Google 内部都基于同一份代码库进行开发,也就没有包依赖的问题……

所以也就有了 \$GOPATH 的概念,你得把自己存代码的路径纳入 \$GOPATH 里 ,而且 Go 里的包名都很奇特,不仅带有包名本身、还长得很像下载链接,比如:github.com/gomodule/redigo。除了数不清的民间包管理工具,Go 里还有个 vendor 的概念,可以把你所依赖的包的源码放到 vendor 里、就不用往 \$GOPATH 里扔了。但我想没人愿意这样做吧?只有古老的 C++,因为没有官方包管理工具的缘故,才需要把依赖包的代码 down 下来、跟自己写的代码一并存放。有谁不想写一个 requirements.txt,然后就忘掉依赖项的事情呢?

但是自从 Go 1.11 版本里 Go Modules 的横空出事(误),已经可以做到彻底摒弃 \$GOPATH 了。

只需要 go mod init packagename 一下,就能生成一个类似 requirements.txt 这样的依赖包摘要列表了。

1
2
3
4
go mod init hello
...
# 一顿骚操作之后
go mod download

依赖并不是下载到$GOPATH中,而是$GOPATH/pkg/mod中,多个项目可以共享缓存的module。

go.mod 文件大概是这样子的:

1
2
3
4
5
6
module adapter

require (
github.com/gomodule/redigo v1.7.0 // indirect
github.com/gorilla/mux v1.7.1
)

你可以通过 go get ./...让它查找依赖,并记录在go.mod文件中(你还可以指定 -tags,这样可以把tags的依赖都查找到)。

通过go mod tidy也可以用来为go.mod增加丢失的依赖,删除不需要的依赖。

——《跳出Go module的泥潭

于是,代码就可以这样组织了(总算搞清楚了写代码的第一件事!):

1
2
3
4
5
6
7
8
9
10
11
12
13
tree .

├── Dockerfile
├── hello.go
├── go.mod
└── utils
├── digestgen.go
├── httphelper.go
├── kvhelper.go
├── loghelper.go
└── protocol.go

1 directory, 8 files

至于 go.sum 如何保存校验信息、如何使用 go mod vendor 来 fallback 到以前的处理方式等,可以参考其他治疗手册。

2. 构建极小的 docker 镜像

众所周知,带有 go 完整编译环境的 golang:latest 镜像足足有 772MB 之大,放到生产环境可谓宰相肚里能撑船(误),实在让人无法接受。但就算是十分精简的 golang:alpine 也有 350MB,那该怎么办呢?

我们知道,go 编译出来的文件,是可以做到直接执行、不带依赖的。所以我们可以使用一阶镜像来完成 go 项目的构建、然后装载到二阶镜像中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
##### 一阶镜像 #####
FROM golang:alpine as builder

RUN adduser -D -g '' appuser
WORKDIR /app
COPY . .

# bla bla bla
...

RUN go build xxx -o /go/bin/hello .
##### 二阶镜像 #####
FROM scratch
WORKDIR /app
COPY --from=builder /go/bin/hello /go/bin/hello
# 就算在容器中,也不要使用 root 用户
USER appuser
ENV PORT=3001
ENTRYPOINT ["/go/bin/hello"]

如果去掉调试信息、只 target 我们的目标环境,可以这样:

1
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -a -installsuffix cgo -o /go/bin/hello .

如果还能满足所有下列情况,那么强迫症的症状,会缓解的更快:

  • 每个容器只运行一个进程
  • 及时更新基础镜像的版本
  • 不要在容器中存储业务数据
  • 不要在容器中存储密钥
  • 容器中不要使用 root 用户来运行
  • 考虑 SSL 服务
  • 不要每次构建、都重新下载依赖包(那样好慢……

最后,写成的 dockerfile 如下,稍作改动就能用于新的项目了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
###########################
# STEP 1 编译源代码
############################
FROM golang:alpine as builder

RUN apk update && apk add --no-cache git ca-certificates tzdata && update-ca-certificates && apk add tree

RUN adduser -D -g '' appuser

WORKDIR /app

# 先拷贝 go.mod 安装依赖项
# 这样改变源代码时,重新构建镜像,就不用重复拉取依赖项了(use cache)
COPY ./go.mod ./go.mod
RUN go mod download

# 再拷贝源代码
COPY . .
RUN pwd && tree .

# 编译(尽量干掉 GOPATH)
# ENV GOPATH="$(pwd):$GOPATH"
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -a -installsuffix cgo -o /go/bin/adapter adapter.go

############################
# STEP 2 构建执行镜像
############################
FROM scratch

# 导入 HTTPS 证书等
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd

# 拷贝二进制
WORKDIR /app
COPY --from=builder /go/bin/adapter /go/bin/adapter

# 使用普通账户权限,不使用 root
USER appuser

# 服务端口
ENV PORT=3001

# 默认运行 adapter
ENTRYPOINT ["/go/bin/adapter"]

差点忘了截图,这样构建出来的 Go 项目、可以直接拉起容器运行的、才巴掌丁点大—— 7MB

1
2
docker image ls | grep hello
hello v1 7c6db3e85f99 About an hour ago 7.01MB

3. 使用 Makefile 忘记命令行

本机构建镜像、拉起调试、导出镜像时,会用到 docker 命令行。鉴于患者有严重的健忘症,记不住那么多参数,所以使用 Makefile 来简化记忆过程。

1
2
3
4
5
6
7
8
9
10
11
12
img_ver=v1
img_name=my_docker_image_name

build:
docker build -t $(img_name):$(img_ver) .
docker image ls | grep $(img_name)

export:
docker save -o ../$(img_name)$(img_ver).tar $(img_name):$(img_ver)

debug:
docker run -it --rm --name "hello_instance" -p 3000:3001 --entrypoint "/go/bin/hello" $(img_name):$(img_ver)

这样的话,就可以放心地忘掉那些麻烦的参数:

  • make build 一下,就开始构建镜像了,只需要偶尔改一下 img_ver 字段来使用新的版本号(或者一直填 latest,额,等等,又要犯病了…)
  • make debug 一下,就像本机调试一样,在当前命令行启动服务了,而且 ctrl+c 杀掉进程后,自动销毁容器,干干净净
  • make export 一下,保存镜像到本地,上传到 k8s 容器平台上(比如蓝盾),补一个 deployment.yaml (或者使用图形化界面创建)就可以用了

4. 用上 CI/CD 流水线

这是未完成的最后一步,日后采用流水线构建,代码更新触发镜像构建、部署更新一条龙、放到腾讯云 TKE 上一劳永逸。

出院报告

最后,该患者终于学会了如何组织 Go 的代码、以及本地/联机运行的姿势。最终代码结构的组织如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tree .                                                       

.
├── Dockerfile # 构建镜像
├── Makefile # 简化命令行
├── adapter.go # Go 主要代码
├── go.mod # “requirements.txt”
├── go.sum # 校验信息,防止 XcodeGhost 事件(误
└── utils # Go 基础代码
├── digestgen.go
├── httphelper.go
├── kvhelper.go
├── loghelper.go
└── protocol.go

1 directory, 10 files

更新

患者后来留意到,自己把 adapter.go 放到了根目录下,没有放到包里。在继续整理代码的时候,遇到了两个本地包之间引用的问题。

上文中只提到了如何使用 go mod 引入外部包,没提到如何引入自己开发的本地的包。所以补充一下,懒癌发作,贴上原文好了。

So if you have two local modules host/remote and host/local and you want to import host/remote in the module host/local:

Open the host/local go.mod file, should look something like this:

1
2
3
4
5
6
> module host/local
>
> require (
> ...
> )
>

Edit it to look like this:

1
2
3
4
5
6
7
8
9
> module host/local
>
> require (
> ...
> host/remote v0.0.0
> )
>
> replace host/remote => ../remote
>

—— Reddit

更新 v2

常用 go mod 命令:

1
2
3
4
5
6
7
go mod tidy //拉取缺少的模块,移除不用的模块。
go mod download //下载依赖包
go mod graph //打印模块依赖图
go mod vendor //将依赖复制到vendor下
go mod verify //校验依赖
go mod why //解释为什么需要依赖
go list -m -json all //依赖详情

愿原力与你同在。