Go语言的工程设计

2022-11-09 ⏳4.8分钟(1.9千字) g

Go语言是一门很实用的语言。它并不完美,甚至也难说优雅,但它很实用。设计者在总结前人多年工程实践的基础上设计了Go语言。这些设计导致了不少的争论,却也实实在在的解决了不少问题。工程设计的范围很广,今天只能面向初学者选几个方面加以简要介绍。

模块体系

前几节的学习里,我们只需要一个main.go文件就能完成所有实验。但在现实的项目中,代码量很大,不可能都写到一个文件。另一方面,有很多功能是通用的,最好是能够复用别人已经写好的代码。为了解决这两个问题,Go语言自带了一套包管理工具。

每个项目是一个包,包分包路径径和包名(也叫模块名)两个概念。假设我们有一个项目保存在文件夹foo下面,自然我们希望包名也是foo。为此,我们可以运行go mod init foo来初始化这个项目,Go会在foo目录下生成go.mod文件:

module foo

go 1.19 # 限制最小版本号

我们可以在foo目录创建多个.go文件,所有的文件都应该声明包名:

package foo

这时候我们还能否创建main.go文件呢?答案是不能。因为main()函数对应的包名一定是main。但是Go要求相同文件夹下的源文件件必须使用相同的package声明。为了解决这个问题,我们可以新建一个目录,比如foo/bin/foo,然后在里面创建main.go。这样就不会出现包名冲突。

假设我们在foo目录创建foo.go,内容如下:

package foo

var A = 1

然后我们可以在foo/cmd/foo/main.go中引用它:

package main

import "fmt"
import "foo"

func main() {
  fmt.Println(foo.A)
}

因为我们在go.mod声明foo文件夹的包名是foo,所以当编译器遇到import "foo"的时候自然会知道要从foo目录查找对应的代码。

我们还可以在foo目录创建子目录,比如foo/bar/bar.go,内容如下:

package bar

var B = 2

引用子目录的时候需要同时指定模块名和子目录路径:

import "foo/bar"

包名也不一定跟路径一一对应。比如假设我们给bar搞了个新版本,对应的路径为foo/bar/v2/,但我们依然希望保留bar这个包名,所以我们可以让foo/bar/v2/bar.go的package也用bar。但在导入的时候会产生一点混乱:

import "foo/bar/v2"

导入后我们不能使用v2.B这样的写法,而是要继续使用bar.B这种写法。如果我们同时导入foo/bar和foo/bar/v2则会产生导入冲突,需要给其中的一个设置别名:

import "foo/bar"
import bar2 "foo/bar/v2"

一旦设置了别名,就不能使用源码中定义的包名了。比如这个时候引用foo/bar/v2需要写成bar2.A。

总结一下就是:

以上是基础的包结构。如果我们想把自己写的包发布到网上,就需要修改go.mod中的模块名,使其包含源码的完整路径。假设我们要把foo发布到github.com/taoso这个空间,则对应的包名也需要改成github.com/taoso/foo。

原来导入foo包的所有地方都要改成github/taoso/foo。

从这个角度看,go.mod里的包名是一个快捷方式,它指向了模块的根目录。这个目录可以本地目录,也可以是网络上的某个文件夹。模块内部的引用只跟模块名相关,与项目所在的路径无关。比如我们可以把foo目录改成bar,里面的源源码不受影响。

代码一旦发布到网上,就可以通过go get下载。现在大多数模块都是通过 GitHub 发布,go get会自动识别 git tag 版本,并默认自动下载最新版。go get在首次下载远程模块时会根据所有源文件的内容以及对应的go.mod的内容计算两个哈希值,并把它们保存到go.sum文件。如果有在在其他设备上重新编译,go get会重新下载对应的依赖。如果此时有偷偷修改了某版本的源代码,计算出来的哈希值就会有变化,就可以立即发现这类的问题。所以我们应该把go.sum文件也提交到版本控制系统。

也正因为go.sum很重要,所以Go官方搞了一个 sumdb 来保存各版本包对应的内容哈希,方便go get校验。然而这个库在国内无法访问。Go官方有很多包都托管在golang.org域名,国内也是无法访问。解决这个问题需要设置两个环境变量:

GOSUMDB 指定新的 sumdb 域名。sum.golang.google.cn 是 Go 官方在国内设置的镜像,值得信赖。 GOPROXY 是指定go get代理。国内类似的代理有很多。使用公共服务最大的隐患是可能有人篡改代码。但是 Go 官方有 sumdb,所以不怕改😄

设置了上面两个环境变量还不够。因为我们自己业务代码不可能在网上发布。Go 官方的 sumdb 就没有对应的哈希值。这样我们在导入一些内部包的时候就会报错。为此,我们需要额外设置环境变量:

这里的意思是凡是包名里有bilibili.co域名的包都是内部包,加载的时候不走 GOPROXY 代理,也不需要通过 GOSUMDB 校验。

如果我们导入一个包,比如:

go get github.com/pion/dtls/v2@v2.1.3

go get会给go.mod文件的require列表添加一行:

module github.com/taoso/foo

require (
  github.com/pion/dtls/v2 v2.1.3
)

新版的go get还会把依赖模块所依赖的模块列表写入一个单独的require块中,并在最后追加// indirect。

此外,go get还会把对应的哈希值写入go.sum文件:

github.com/pion/dtls/v2 v2.1.3 h1:3UF7udADqous+M2R5Uo2q/YaP4EzUoWKdfX2oscCUio=
github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus=

最后说一下符号的可见性问题。这个问题不同的语言有不同的处理方式,但Go语言的方式比较奇葩。它规定只有大家字母开头的常量、变量、类型、函数才能被外部引用。

假如我们在foo/bar/bar.go声明如下对象:

package bar

const A = 1
const b = 2

type C struct {
  A int
  b int
}

那么我们导入foo/bar后只能引用A,不能引用b:

import "foo/bar"

bar.A // 值为 1
bar.b // 不存在,报错

c := bar.C{}
c.A = 1 // 成功
c.b = 2 // 不存在,报错

这种设计最大的问题是各类对象不对取中文名,因为所有中文都被当成小写字母处理,外界无法引用😂

常用标准库

Go语言的标准库内容也是很多。如果有时间可以慢慢学。但新手可能觉得自己没有时间😂我统计了一个运行两年的业务项目,列出了导数次数超过十次的模块供大家参考:

频次 包名 用途
876 context 协程间同步和传递信息
487 time 时间和日期工具
486 fmt 格式化输出工具
305 strings 字符串处理
233 encoding/json JSON编解码
203 testing 单元测试
166 strconv 字符串与数字相互转换
113 net/http HTTP协议相关工具
101 io 文件IO操作
94 sync 并发控制库
89 regexp 正则表达式
76 net/url 解析URL相关工具
72 reflect 反射库,高级内容
65 net 网络基础库
61 unicode/utf8 UTF8编码相关工具
61 errors 标准错误处理工具
57 bytes 字节缓冲区处理工具
40 sort 排序库
31 os 部分操作系统相关工具
30 math/rand 生成随机数
15 math 常用数学运算工具包
11 crypto/md5 计算MD5工具
11 bufio 数据解析工具库,多用于网络协议解析

单元测试

未完待续……

常用工具

未完待续……