0%

《Go 语言精进之路》读书笔记(02):项目结构、代码风格与标识符命名

当我们需要解决实际工程问题的时候,我们需要在项目结构、代码风格、标识符命名等方面进行全面考虑。这些问题并非 Go 编程语言独有,任何编程语言在被用于实际项目时都会遇到这些问题。这篇文章将介绍高质量 Go 项目是如何处理这些问题的。

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

在 Go 语言中,项目结构十分重要,因为它决定了项目内部包的布局以及包依赖关系是否合理,同时还会影响到外部项目对该项目中包的依赖和引用。

Go 语言典型项目结构

Russ Cox 认为一个 Go 项目的最小布局应该是这个样子:

1
2
3
4
5
// 在 go 项目仓库跟路径下
- go.mod
- LICENCE
- xx.go
- yy.go

或者

1
2
3
4
5
6
- go.mod
- LICENCE
- package1
- package1.go
- package2
- package2.go

对于一些规模稍大一些的 Go 应用项目,势必会在最小标准布局的基础上进行扩展。这种扩展显然不会是盲目的,很多时候会参考 go 语言本身 的项目结构。于是就有了下面的非官方标准的建议布局结构。

如下展示了一个以构建二进制可执行文件为目的的 Go 项目结构:

  • cmd 目录:存放要构建的可执行文件对应的 main 包的源文件。如果有多个可执行文件需要构建,则将每个可执行文件的 main 包单独放在一个子目录中。通常 main 包只做一些命令行参数解析、资源初始化、日志设施初始化等,之后就将程序的执行权限交给更高级的执行控制对象。有些项目也会把 cmd 这个名字改为 app

  • pkg 目录:存放项目自身要使用并且同样也是可执行文件对应 main 包要依赖的库文件。该目录下的包可以被外部项目引用,算是项目导出包的一个聚合。有些项目会将 pkg 目录改名为 lib

  • Makefile:这里的 Makefile 可以认为是项目构建工具所使用脚本的代表,它可以代表任何第三方构建工具所使用的脚本。通常项目构建工具的脚本一般放在项目顶层目录。如果构建特别复杂,也可以建立 build 目录,将各个构建脚本放在 build 目录中

  • Go 语言包依赖管理使用的配置文件,Go 1.11 开始引入该机制,Go 1.16 称为默认的依赖包管理和构建机制。

  • vendor 目录(可选):vendor 是 Go 1.5 引入的用于在项目本地缓存特定版本依赖包的机制。在引入 Go module 机制之前,基于 vendor 可以实现可重现的构建。Go module 本身就可以实现可重现的构建而不需要 vendor,当然 Go module 机制也保留了 vendor 目录(通过 go build -mod=vendor 命令可以实现基于 vendor 的构建),因此 vendor 目录视为一个可选目录。一般仅保留项目根目录下的 vendor 目录,避免不必要的依赖选择复杂性

Go module 是一组同属于一个版本管理单元的包集合。Go 支持在一个项目或仓库中存在多个 module,但是这种管理方式可能要比一定比例的代码重复引入更多的复杂性。因此如果项目结构中存在版本管理的分歧,那么建议将项目拆分成多个独立的项目/仓库,每个 module 单独作为一个 module 版本管理和演进

下图展示了一个典型的 Go 语言库类型项目的结构布局:

由于 Go 库项目的初衷一般都是对外暴露 API,因此没有必要将其单独聚合到 pkg 目录下面了。vendor 目录则不再需要,因为对于库类型的项目,不推荐在项目中放置 vendor 目录去缓存自身的第三方依赖。库项目仅仅通过 go.mod 明确表述出该项目依赖的模块或包以及版本要求即可。

无论是那种 Go 项目,对于不想暴露给外部使用,仅限于项目内部使用的包,在项目结构中可以通过 internal 包机制来实现。如下是一个例子(库项目):

这样 internal 目录下的包可以被以 GoLibProj 目录为根目录的其他目录下的代码所导入和使用,但是却不不可以为 GoLibProj 目录以外的代码所使用,从而实现选择性地暴露 API 包。

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

提交前使用 gofmt 格式化源代码

gofmt 将一种统一的代码风格内置到 go 语言之中,并将其与 Go 语言一起以一种标准的形式推广给所有 gopher。gofmt 也是 Go 语言在解决规模化问题上的一个最佳实践,并称为 Go 语言吸引其他语言开发者的一大亮点。因此在提交代码前,我们需要使用 gofmt 对代码进行格式化。

gofmt 没有提供任何关于代码风格设置的命令行选项和参数,但是却提供了足够在工程上对代码进行按格式查找、代码重构的命令行选项:

  • gofmt -s 可以对代码进行简化,将部分代码自动转换为更简单的写法,并且没有副作用
  • gofmt -r 可以对代码进行表达式级别的替换,以达到 微重构 的目的。它的用法如下,其中 pattern 和 replacement 都必须是合法的 go 表达式,并且其中的小写字母都会被视为通配符
1
gofmt -r 'pattern -> replacement' [other flags] [path ...]
  • gofmt -l 可以输出不满足 gofmt 格式的文件列表

使用 goimports

go 编译器在编译源代码时会对源文件导入的包进行检查,对于源文件中没有使用但是却导入的包或者使用了但没有导入的包,go 编译器都会报错。goimports 在 gofmt 功能的基础上,添加了包导入维护列表的功能。可以认为goimports 在 gofmt 之上又封装了一层。

使用如下方式安装 goimports:

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

可以将 gofmt/goimports 集成到 IDE 或编辑器中,这样在保存文件时,IDE 或编辑器会自动对代码进行格式化。例如 vim 中,就可以通过 vim-go 插件实现这个功能。

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

关于命名,Go 语言也有自己期望大家共同遵循的惯例。要想做好 Go 标识符的命名,至少要遵循两个原则:简单且一致;利用上下文辅助命名。

简答且一致

简单是在清晰明确这一前提下,尽量保持命名简短,甚至某些情况下,Go 命名惯例选择了 简洁命名 + 注释辅助解释 的方式,而不是一个长长的名字。

关于包命名:

  • 对于 go 包(package),一般建议以小写形式的单个单词命名。在给包命名时,不需要担心是否与其他包重名,因为 Go 中,包名可以不唯一。因为每个包的导入路径是唯一的。对于包名冲突的情况下,可以在导入包时使用一个显式包名来指代所导入的包
  • 包名尽量与包导入路径(import path)的最后一个路径分段保持一致
  • 类似于 github.com/nsqio/go-nsq/nsq 这种导入路径中出现两次 nsq 的现象也不是 Go 官方所推荐的
  • 在给包命名时,不仅需要考虑包自身的名字,还要兼顾该包导出标识符(如变量、常量、函数、类型等)的命名。由于对这些包导出标识符的引用必须以包名为前缀,因此对包导出标识符命名时,在名字中不要在包含命名**

关于变量、类型、函数、方法命名:

  • Go 要求标识符命名采用驼峰命名法(CamelCase)
  • 变量可以分为包级别的变量和局部变量(函数参数、返回值视为局部变量)。对于导出标识符的情况,会使用大驼峰(UpperCamelCase)命名法,而非导出标识符,则使用小驼峰(lowerCamelCase)命名法
  • 如果缩略词的首字母大写,那么其他字母也要全部大写,例如 HTTP、CBC 等
  • 对变量、类型、函数和方法命名时,依然要以简单、短小为原则
    • 循环和条件变量多采用单个字母命名
    • 函数/方法的参数和返回值变量以单个单词或单个字母为主
    • 由于方法在调用时会绑定类型信息,因此方法的命名以单个单词为主
    • 函数多以多单词的复合词进行命名;类型多以多单词的复合词进行命名
  • 除此之外,还有一些命名惯例:
    • 变量名字中不要携带类型信息
    • 保持变量声明与使用之间的距离越近越好,或者说应该在第一次使用变量之前声明该变量
    • 保持简短命名变量含义上的一致性,例如 i、k、v 分表用来表示下标、key、value;t 用来表示时间;b 用来表示 buffer 或者 byte

关于常量

  • Go 中常量在命名方式上与变量并无较大差别,并不要求全部大写,只是考虑其含义的准确传递,常量多使用多单词组合的方式命名
  • 当然,可以对名称本身就是全大写的特定常量使用全大写的名字,或者为了与系统错误码、系统信号名称保持一致而用全大写方式命名
  • 常量的名字中也不要包含类型信息

对于接口命名:

  • 在 Go 语言中,对于接口类型优先以单个单词命名
  • 对于拥有唯一方法或者通过多个拥有唯一方法的接口而组成的接口,Go 语言的管理是用 方法名 + er 命名
  • Go 语言推荐尽量定义小接口,并通过接口组合的方式构建程序

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

Go 在标识符命名时,还有考虑上下文环境的惯例。即在不影响可读性的前提下,兼顾一致性原则,尽可能地用短小的名字命名标识符。Go 语言追求简单一致且利用上下文辅助名字信息传达的命名惯例。