第二部分 项目结构、代码风格与标识符命名


第5条 使用得到公认且广泛使用的项目结构

5.1 Go项目的项目结构

我们先来看看第一个Go项目——Go语言自身——的项目结构是什么样的。
Go项目的项目结构自1.0版本发布以来一直十分稳定,直到现在Go项目的顶层结构基本没有大的改变。截至Go项目commit1e3ffb0c(2019.5.14),Go项目结构如下:

$ tree -LF 1 ~/go/src/github.com/golang/go
./go
├── api/
├── AUTHORS
├── CONTRIBUTING.md
├── CONTRIBUTORS
├── doc/
├── favicon.ico
├── lib/
├── LICENSE
├── misc/
├── PATENTS
├── README.md
├── robots.txt
├── src/
└── test/

作为Go语言的创世项目,Go的项目结构的布局对后续的Go语言项目具有重要的参考意义,尤其是早期Go项目中src目录下面的结构,更是在后续被Go社区作为Go应用项目结构的模板广泛使用。以早期的Go 1.3版本的src目录下的结构为例:

$ tree -LF 1 ./src
./src
├── all.bash*
├── all.bat
├── all.rc*
├── clean.bash*
├── clean.bat
├── clean.rc*
├── cmd/
├── lib9/
├── libbio/
├── liblink/
├── make.bash*
├── make.bat
├── Make.dist
├── make.rc*
├── nacltest.bash*
├── pkg/
├── race.bash*
├── race.bat
├── run.bash*
├── run.bat
├── run.rc*
└── sudo.bash*

关于上面src目录下的结构,笔者总结了以下三个特点。

1)代码构建的脚本源文件放在src下面的顶层目录下。
2)src下的二级目录cmd下面存放着Go工具链相关的可执行文件(比如go、gofmt等)的主目录以及它们的main包源文件。

$ tree -LF 1 ./cmd
./cmd
...
├── 6a/
├── 6c/
├── 6g/
...
├── cc/
├── cgo/
├── dist/
├── fix/
├── gc/
├── go/
├── gofmt/
├── ld/
├── nm/
├── objdump/
├── pack/
└── yacc/

3)src下的二级目录pkg下面存放着上面cmd下各工具链程序依赖的包、Go运行时以及Go标准库的源文件。

$ tree -LF 1 ./pkg
./pkg
...
├── flag/
├── fmt/
├── go/
├── io/
├── log/
├── math/
...
├── syscall/
├── testing/
├── text/
├── time/
├── unicode/
└── unsafe/

在Go 1.3版本以后至今,Go项目下的src目录发生了几次结构上的变动。

  • Go 1.4版本删除了Go源码树中src/pkg/xxx中的pkg这一层级目录,改为直接使用src/xxx。
  • Go 1.4版本在src下面增加internal目录,用于存放无法被外部导入、仅Go项目自用的包。
  • Go 1.6版本在src下面增加vendor目录,但Go项目自身真正启用vendor机制是在Go 1.7版本中。vendor目录中存放了Go项目自身对外部项目的依赖,主要是golang.org/x下的各个包,包括net、text、crypto等。该目录下的包会在每次Go版本发布时更新。
  • Go 1.13版本在src下面增加了go.mod和go.num,实现了Go项目自身的go module迁移。Go项目内所有包被放到名为std的module下面,其依赖的包依然是golang.org/x下的各个包。
// Go 1.13版本Go项目src下面的go.mod
module std
go 1.12
require (
  golang.org/x/crypto v0.0.0-20200124225646-8b5121be2f68
  golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
  golang.org/x/sys v0.0.0-20190529130038-5219a1e1c5f8 // indirect
  golang.org/x/text v0.3.2 // indirect
)

下面是Go 1.16版本src目录下的完整布局:

├── Make.dist
├── README.vendor
├── all.bash*
├── all.bat
├── all.rc*
├── bootstrap.bash*
├── buildall.bash*
├── clean.bash*
├── clean.bat
├── clean.rc*
├── cmd/
├── cmp.bash
├── go.mod
├── go.sum
├── internal/
├── make.bash*
├── make.bat
├── make.rc*
├── race.bash*
├── race.bat
├── run.bash*
├── run.bat
├── run.rc*
├── testdata/
...
└── vendor/


5.2 Go语言典型项目结构

1. Go项目结构的最小标准布局

关于Go应用项目结构的标准布局是什么样子的,Go官方团队始终没有给出参考标准。不过作为Go语言项目的技术负责人,Russ Cox在一个开源项目的issue中给出了他关于Go项目结构的最小标准布局的想法。他认为Go项目的最小标准布局应该是这样的:

// 在Go项目仓库根路径下
- go.mod
- LICENSE
- xx.go
- yy.go
...

- go.mod
- LICENSE
- package1
- package1.go
- package2
- package2.go
...

pkg、cmd、docs这些目录不应该成为Go项目标准结构的一部分,至少不是必需的。笔者认为Russ Cox给出的最小标准布局与Go一贯崇尚的“简单”哲学是一脉相承的,这个布局很灵活,可以满足各种Go项目的需求。但是在Russ Cox阐述上述最小标准之前,Go社区其实是处于“无标准”状态的,早期Go语言自身项目的结构布局对现存的大量Go开源项目的影响依然存在,对于一些规模稍大些的Go应用项目,我们势必会在上述“最小标准布局”的基础上进行扩展。而这种扩展显然不会是盲目的,还是会参考Go语言项目自身的结构布局,于是就有了下面的非官方标准的建议结构布局



2. 以构建二进制可执行文件为目的的Go项目结构

基于Go语言项目自身的早期结构以及后续演进,Go社区在多年的Go语言实践积累后逐渐形成了一种典型项目结构,这种结构与Russ Cox的最小标准布局是兼容的,如图5-1所示。

图5-1所示就是一个支持(在cmd下)构建二进制可执行文件的典型Go项目的结构,我们分别来看一下各个重要目录的用途。

  • cmd目录:存放项目要构建的可执行文件对应的main包的源文件
    • 如果有多个可执行文件需要构建,则将每个可执行文件的main包单独放在一个子目录中,比如图中的app1、app2
    • cmd目录下的各app的main包将整个项目的依赖连接在一起,并且通常来说,main包应该很简洁。我们会在main包中做一些命令行参数解析、资源初始化、日志设施初始化、数据库连接初始化等工作,之后就会将程序的执行权限交给更高级的执行控制对象
    • 有一些Go项目将cmd这个名字改为app,但其功用并没有变。
  • pkg目录:存放项目自身要使用并且同样也是可执行文件对应main包要依赖的库文件
    • 该目录下的包可以被外部项目引用,算是项目导出包的一个聚合
    • 有些项目将pkg这个名字改为lib,但该目录的用途不变
    • 由于Go语言项目自身在1.4版本中去掉了pkg这一层目录,因此有一些项目直接将包平铺到项目根路径下,但笔者认为对于一些规模稍大的项目,过多的包会让项目顶层目录不再简洁,显得很拥挤,因此个人建议对于复杂的Go项目保留pkg目录。
  • Makefile:这里的Makefile是项目构建工具所用脚本的“代表”,它可以代表任何第三方构建工具所用的脚本
    • Go并没有内置如make、bazel等级别的项目构建工具,对于一些规模稍大的项目而言,项目构建工具似乎不可缺少
    • 在Go典型项目中,项目构建工具的脚本一般放在项目顶层目录下,比如这里的Makefile;对于构建脚本较多的项目,也可以建立build目录,并将构建脚本的规则属性文件、子构建脚本放入其中。
  • go.mod和go.sum:Go语言包依赖管理使用的配置文件
    • Go 1.11版本引入Go module机制
    • Go 1.16版本中,Go module成为默认的依赖包管理和构建机制
    • 因此对于新的Go项目,建议基于Go module进行包依赖管理
    • 对于没有使用Go module进行包管理的项目(可能主要是一些使用Go 1.11以前版本的Go项目),这里可以换为dep的Gopkg.toml和Gopkg.lock,或者glide的glide.yaml和glide.lock等。
  • vendor目录(可选):vendor是Go 1.5版本引入的用于在项目本地缓存特定版本依赖包的机制
    • 在引入Go module机制之前,基于vendor可以实现可重现的构建(reproducible build),保证基于同一源码构建出的可执行程序是等价的
    • Go module本身就可以实现可重现的构建而不需要vendor,当然Go module机制也保留了vendor目录(通过go mod vendor可以生成vendor下的依赖包;通过gobuild -mod=vendor可以实现基于vendor的构建),因此这里将vendor目录视为一个可选目录。一般我们仅保留项目根目录下的vendor目录,否则会造成不必要的依赖选择的复杂性
    • Go 1.11引入的module是一组同属于一个版本管理单元的包的集合。Go支持在一个项目/仓库中存在多个module,但这种管理方式可能要比一定比例的代码重复引入更多的复杂性。因此,如果项目结构中存在版本管理的“分歧”,比如app1和app2的发布版本并不总是同步的,那么笔者建议将项目拆分为多个项目(仓库),每个项目单独作为一个module进行版本管理和演进。


3. 以只构建库为目的的Go项目结构
Go 1.4发布时,Go语言项目自身去掉了src下的pkg这一层目录,这个结构上的改变对那些以只构建库为目的的Go库类型项目结构有一定的影响。我们来看一个典型的Go语言库类型项目的结构布局,见图5-2。

我们看到库类型项目结构与Go项目的最小标准布局也是兼容的,但比以构建二进制可执行文件为目的的Go项目要简单一些。

  • 去除了cmd和pkg两个子目录:由于仅构建库,没必要保留存放二进制文件main包源文件的cmd目录;由于Go库项目的初衷一般都是对外部(开源或组织内部公开)暴露API,因此也没有必要将其单独聚合到pkg目录下面了。
  • vendor不再是可选目录:对于库类型项目而言,不推荐在项目中放置vendor目录去缓存库自身的第三方依赖,库项目仅通过go.mod(或其他包依赖管理工具的manifest文件)明确表述出该项目依赖的模块或包以及版本要求即可。


4. 关于internal目录
无论是上面哪种类型的Go项目,对于不想暴露给外部引用,仅限项目内部使用的包,在项目结构上可以通过Go 1.4版本中引入的internal包机制来实现。以库项目为例,最简单的方式就是在顶层加入一个internal目录,将不想暴露到外部的包都放在该目录下,比如下面项目结构中的

ilib1、ilib2:
// 带internal的Go库项目结构
$tree -F ./chapter2/sources/GoLibProj
GoLibProj
├── LICENSE
├── Makefile
├── README.md
├── go.mod
├── internal/
│ ├── ilib1/
│ └── ilib2/
├── lib.go
├── lib1/
│ └── lib1.go
└── lib2/
└── lib2.go

这样,根据Go internal机制的作用原理,internal目录下的ilib1、ilib2可以被以GoLibProj目录为根目录的其他目录下的代码(比如lib.go、lib1/lib1.go等)所导入和使用,但是却不可以为GoLibProj目录以外的代码所使用,从而实现选择性地暴露API包。当然internal也可以放在项目结构中的任一目录层级中,关键是项目结构设计人员明确哪些要暴露到外层代码,哪些仅用于同级目录或子目录中。

对于以构建二进制可执行文件类型为目的的项目,我们同样可以将不想暴露给外面的包聚合到项目顶层路径下的internal下,与暴露给外部的包的聚合目录pkg遥相呼应。



小结

以上两个针对构建二进制可执行文件类型以及库类型的项目参考结构是Go社区在多年实践后得到公认且使用较为广泛的项目结构,并且它们与Russ Cox提出的Go项目最小标准布局是兼容的,对于稍大型的Go项目来说很有参考价值。但它们并不是必需的,在Go语言早期,很多项目将所有源文件都放在位于项目根目录下的根包中,这个方法也适合一些小规模项目。

对于以构建二进制可执行文件类型为目的的项目来说,受Go 1.4项目结构影响,将pkg这一层次目录去掉也是很多项目选择的结构布局方式。

上述参考项目结构产品设计开发领域的最小可行产品(MinimumViable Product,MVP)的思路异曲同工,开发者可以在这样一个最小的项目结构核心的基础上根据实际需要进行扩展。



第6条 提交前使用gofmt格式化源码

自从现代编程语言出现以来,针对每种编程语言的代码风格的争论就不曾停止过,直到Go语言的出现,人们才惊奇地发现Go社区似乎很少有针对Go语言代码风格的争论。



6.1 gofmt:Go语言在解决规模化问题上的最佳实践

gofmt的代码风格不是某个人的最爱,而是所有人的最爱。——Rob Pike



第7条 使用Go命名惯例对标识符进行命名

计算机科学中只有两件难事:缓存失效和命名。——Phil Karlton,Netscape架构师

从编程语言诞生那天起,给标识符命名这件事就一直伴随着程序员。命名看似简单,但在如今大规模软件工程需要程序员个体间紧密协作的背景下,就像上面Phil Karlton所说的那样,给出好的命名并非易事

命名是编程语言的要求,但是好的命名却是为了提高程序的可读性和可维护性。好的命名是什么样子的呢?Go语言的贡献者和布道师Dave Cheney给出了一个说法:“一个好笑话,如果你必须解释它,那就不好笑了。好的命名也类似。”. 无论哪门编程语言,良好的命名都应该遵循一些通用的原则,但就像之前提到的“语言影响思维”的假说那样,不同的编程语言会有一些个性化的命名惯例。

要想做好Go标识符的命名(包括对包的命名),至少要遵循两个原则:

  • 简单且一致
  • 利用上下文辅助命名


7.1 简单且一致

对于简单,我们最直观的理解就是“短小”,但这里的简单还包含着清晰明确这一前提。短小意味着能用一个单词命名的,就不要使用单词组合;能用单个字母(在特定上下文中)表达标识符用途的,就不用完整单词。甚至在某种情况下,Go命名惯例选择了简洁命名+注释辅助解释的方式,而不是一个长长的名字。

1 包
对于Go中的包(package),一般建议以小写形式的单个单词命名。

我们在给包命名时不要有是否与其他包重名的顾虑,因为在Go中,包名可以不唯一。比如:foo项目有名为log的包,bar项目也可以有自己的名为log的包。每个包的导入路径是唯一的,对于包名冲突的情况,可以在导入包时使用一个显式包名来指代导入的包,并且在这个源文件中使用这个显式包名来引用包中的元素,示例如下。

import "github.com/bigwhite/foo/log"             // log.XX中的log指代github.com/ bigwhite/foo/log下的包
import barlog "github.com/bigwhite/bar/log"      // barlog这个显式包名指代github.com/ bigwhite/bar/log下的包

Go语言建议,包名应尽量与包导入路径(import path)的最后一个路径分段保持一致。比如:

  • 包导入路径golang.org/x/text/encoding的最后路径分段是encoding,该路径下包名就应该为encoding。

但在实际情况中,包名与导入路径最后分段不同的也有很多。比如:

  • 实时分布式消息队列NSQ的官方客户端包的导入路径为github.com/nsqio/go-nsq,但是该路径下面的包名却是nsq。

笔者分析这主要是为了用仓库名称强调该实现是针对Go语言的,比如go-nsq的意义是这是一份Go语言实现的NSQ客户端API库,为的是与nsq-java、pynsq、rust-nsq等其他语言的客户端API进行显式区分。这种情况在笔者的gocmpp项目中也存在。gocmpp项目的导入路径是github.com/bigwhite/gocmpp,gocmpp这个仓库名强调的是这是一份CMPP协议(中国移动通信互联短信网关接口协议)的Go实现,但该路径下包的名字却是cmpp。

此外,我们在给包命名的时候,不仅要考虑包自身的名字,还要兼顾该包导出的标识符(如变量、常量、类型、函数等)的命名。由于对这些包导出标识符的引用必须以包名为前缀,因此对包导出标识符命名时,在名字中不要再包含包名,比如:

strings.Reader              [good]
strings.StringReader        [bad]
strings.NewReader           [good]
strings.NewStringReader     [bad]

bytes.Buffer                [good]
bytes.ByteBuffer            [bad]
bytes.NewBuffer             [good]
bytes.NewByteBuffer         [bad]

2 变量、类型、函数和方法

在Go中变量分为包级别的变量和局部变量(函数或方法内的变量)。函数或方法的参数、返回值都可以被视为局部变量。

Go语言官方要求标识符命名采用驼峰命名法(CamelCase),以变量名为例,如果变量名由一个以上的词组合构成,那么这些词之间紧密相连,不使用任何连接符(如下划线)。

驼峰命名法有两种形式:

  • 一种是第一个词的首字母小写,后面每个词的首字母大写,叫作“小骆峰拼写法”(lowerCamelCase),这也是在Go中最常见的标识符命名法
  • 而第一个词的首字母以及后面每个词的首字母都大写,叫作“大驼峰拼写法”(UpperCamelCase),又称“帕斯卡拼写法”(PascalCase)。

由于首字母大写的标识符在Go语言中被视作包导出标识符,因此只有在涉及包导出的情况下才会用到大驼峰拼写法。不过如果缩略词的首字母是大写的,那么其他字母也要保持全部大写,比如HTTP(Hypertext Transfer Protocol)、CBC(Cipher Block Chaining)等。

一般来说,Go标识符仍以单个单词作为命名首选。从Go标准库代码的不完全统计结果来看,不同类别标识符的命名呈现出以下特征:

  • 循环和条件变量多采用单个字母命名(具体见上面的统计数据);
  • 函数/方法的参数和返回值变量以单个单词或单个字母为主;
  • 由于方法在调用时会绑定类型信息,因此方法的命名以单个单词为主;
  • 函数多以多单词的复合词进行命名;
  • 类型多以多单词的复合词进行命名。

除了上述特征,还有一些在命名时常用的惯例。
(1) 变量名字中不要带有类型信息
比如以下命名:

userSlice []*User         [bad]
users     []*User         [good]

带有类型信息的命名只是让变量看起来更长,并没有给开发者阅读代码带来任何好处。
不过有些开发者会认为:userSlice中的类型信息可以告诉我们变量所代表的底层存储是一个切片,这样便可以在userSlice上应用切片的各种操作了。提出这样质疑的开发者显然忘记了一条编程语言命名的惯例:保持变量声明与使用之间的距离越近越好,或者在第一次使用变量之前声明该变量。这个惯例与Go核心团队的Andrew Gerrard曾说的“一个名字的声明和使用之间的距离越大,这个名字的长度就越长”异曲同工。如果在一屏之内能看到users的声明,那么-Slice这个类型信息显然不必放在变量的名称中了。

(2) 保持简短命名变量含义上的一致性


3. 常量

在C语言家族中,常量通常用全大写的单词命名,比如下面的C语言和Java定义的常量:

// C语言
#define MAX_VALUE 1000
#define DEFAULT_START_DATA  "2019-07-08"
// Java语言
public static final int MAX_VALUE = 1000;
public static final String DEFAULT_START_DATA = "2019-07-08";

但在Go语言中,常量在命名方式上与变量并无较大差别,并不要求全部大写。只是考虑其含义的准确传递,常量多使用多单词组合的方式命名。下面是标准库中的例子:

// $GOROOT/src/net/http/request.go

const (
    defaultMaxMemory = 32 << 20 // 32 MB
)

const (
    deleteHostHeader = true
    keepHostHeader   = false
)

4. 接口

Go语言中的接口是Go在编程语言层面的一个创新,它为Go代码提供了强大的解耦合能力,因此良好的接口类型设计和接口组合是Go程序设计的静态骨架和基础。良好的接口设计自然离不开良好的接口命名。在Go语言中,对于接口类型优先以单个单词命名。对于拥有唯一方法(method)或通过多个拥有唯一方法的接口组合而成的接口,Go语言的惯例是用“方法名+er”命名。比如:

// $GOROOT/src/io/io.go

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

Go语言推荐尽量定义小接口,并通过接口组合的方式构建程序,后文会详细讲述。



7.2 利用上下文环境,让最短的名字携带足够多的信息

Go在给标识符命名时还有着考虑上下文环境的惯例,即在不影响可读性的前提下,兼顾一致性原则,尽可能地用短小的名字命名标识符。这与其他一些主流语言在命名上的建议有所不同,比如Java建议遵循“见名知义”的命名原则。我们可以对比一下Java和Go在循环变量命名上的差异,见表7-1。

表7-1 Java与Go的变量命名对比

我们在Go代码中来分别运用这两个命名方案并做比对:

for index := 0; index < len(s); index++ {
    value := s[index]
    ...
}

// vs

for i := 0; i < len(s); i++ {
    v := s[i]
    ...
}

我们看到,至少在for循环这个上下文中,index、value携带的信息并不比i、v多。

这里引用一下2014年Andrew Gerrard在一次关于Go命名演讲[1]中用的代码,我们再来感受一下Go命名惯例带来的效果:

// 不好的命名
func RuneCount(buffer []byte) int {
    runeCount := 0
    for index := 0; index < len(buffer); {
        if buffer[index] < RuneSelf {
            index++
        } else {
            _, size := DecodeRune(buffer[index:])
            index += size
        }
        runeCount++
    }
    return runeCount
}

// 好的命名
func RuneCount(b []byte) int {
    count := 0
    for i := 0; i < len(b); {
        if b[i] < RuneSelf {
            i++
        } else {
            _, n := DecodeRune(b[i:])
            i += n
        }
        count++
    }
    return count
}


小结

Go语言命名惯例深受C语言的影响,这与Go语言之父有着深厚的C语言背景不无关系。Go语言追求简单一致且利用上下文辅助名字信息传达的命名惯例,如果你刚从其他语言转向Go,这可能会让你感到不适应,但这就是Go语言文化的一部分,也许等你编写的Go代码达到一定的量,你就能理解这种命名惯例的好处了。

posted @ 2024-09-27 15:06  guanyubo  阅读(15)  评论(0编辑  收藏  举报