Go语言的工程设计
涛叔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() {
.Println(foo.A)
fmt}
因为我们在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
中的module
保持一致
以上是基础的包结构。如果我们想把自己写的包发布到网上,就需要修改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=sum.golang.google.cn
GOPROXY=https://goproxy.cn
GOSUMDB 指定新的 sumdb 域名。sum.golang.google.cn 是 Go 官方在国内设置的镜像,值得信赖。 GOPROXY 是指定go get
代理。国内类似的代理有很多。使用公共服务最大的隐患是可能有人篡改代码。但是 Go 官方有 sumdb,所以不怕改😄
设置了上面两个环境变量还不够。因为我们自己业务代码不可能在网上发布。Go 官方的 sumdb 就没有对应的哈希值。这样我们在导入一些内部包的时候就会报错。为此,我们需要额外设置环境变量:
- `GOPRIVATE=’*.bilibili.co’
这里的意思是凡是包名里有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 {
int
A int
b }
那么我们导入foo/bar
后只能引用A
,不能引用b
:
import "foo/bar"
.A // 值为 1
bar.b // 不存在,报错
bar
:= bar.C{}
c .A = 1 // 成功
c.b = 2 // 不存在,报错 c
这种设计最大的问题是各类对象不对取中文名,因为所有中文都被当成小写字母处理,外界无法引用😂
常用标准库
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 | 数据解析工具库,多用于网络协议解析 |
单元测试
未完待续……
常用工具
未完待续……