Go 语言开发有很多技巧,网上也有很多文章介绍,不可能通过一篇文章就将这些技巧涵盖进去。有很多开发技巧,但有些不常用,即使一些看似常用的技巧,随着开发者、业务的不同,也可能不会被用到。所以,常用的开发技巧,没有一个固定的答案,但不妨碍我将我开发工作中认为常用的技巧分享给你。

本节课介绍代码类的开发技巧。这类开发技巧比较固定,而且技巧很多,为了方便你学习,我参考了网上的一些文章,并将一些重要的开发技巧罗列在本节课中。

编写优雅的 Go 应用

使用 Go 语言做项目开发,核心目的其实就是开发一个优雅的 Go 项目。那么如何开发一个优雅的 Go 项目呢?Go 项目包含三大内容,即 Go应用、项目管理、项目文档,因此开发一个优雅的 Go 项目,其实就是编写高质量的 Go 应用高效管理项目编写高质量的项目文档。针对每一项,我都给出了一些实现方式,这些方式详见下图:

26.开发技巧:Go 常用开发技巧分享 - 图1

在项目开发之前,要设计一个合理的 Go 目录结构,当前业界最流行的目录结构是 project-layout。在拆分功能的时候,要采用按功能拆分模块,这样可以有效避免循环引用。

在编写代码的时候,我们还要遵循代码规范。遵循这些现有的规范、技巧、最佳实践,可以让我们用最低的成本,来提高代码质量。

为了确保每次开发后的代码功能正确,在发布前,还需要执行完善的单元测试用例,来保证每一次的发布质量。

在编写代码时,也需要遵循一些更高维度的编程技巧,例如:采用设计模式、遵循 SOLID 原则。这些软件设计方法,可以从代码功能实现、代码逻辑等方面来提高我们的代码质量和稳定性。

在代码开发完成之后,还需要采用高效的手段来管理我们的项目,进而提高代码维护效率,解放双手。你有很多种途径可以提高项目管理效率,例如:

  • 采用高效的开发流程;

  • 使用 Makefile 来管理项目;

  • 采用一些工具、对接 CI/CD 平台等方式,来提高项目的自动化程度。

开发完项目之后,你还要编写高质量的文档,这些文档可以降低日后的代码维护成本,也能进行知识宣发。

遵循符合 Go 编程哲学的代码

如何遵循符合 Go 编程哲学的代码呢?这里你首先需要知道 Go 语言的哲学,概括如下:

  1. 面向接口编程;

  2. 使用组合的编程;

  3. 正交性:语言设计的正交性,保证语言的稳定性和简单性;

  4. 少即是多:有且仅有一种方法把事情做好做对;

  5. 并发语言层面支持:并发更好利用多核,有更强的表现力来模拟真实世界;

  6. 开放性:开源,语言的实现对程序员不是个黑盒子,任何想了解语言实现的人都可以参与进来。

在开发 Go 代码时,你可以根据 Go 语言的哲学开发、优化你的代码。

代码相关

有很多跟代码相关的开发技巧,这里推荐你优先学习 Go 官方提供的一些编程技巧: CodeReviewCommentsEffective Go

  1. 不要忘记使用 copy

我们尽量不要在复制时使用 append,例如,在合并两个或多个 slice 时。append 要小心自动分配内存,append 返回的可能是新分配的地址。

  1. 避免复制大的 Struct

小的结构体,一般是指不超过 4 个字段的结构体。如果是一个大的结构体,复制时,会带来性能开销。所以,大的结构体,可以选择使用指针来传递。

  1. 通过内存对齐来减小结构体的大小

我们可以根据字段的大小,以正确的顺序排列来对齐结构体,从而减小结构体本身的大小。

  1. 提前分配内存

尽量使用第三个参数:make([]T, 0, len)。如果你事先不知道确切的数量并且 slice 是临时的,你可以设置得大一些,只要 slice 在运行时不会增长。

初始化 Map 时,指定其大小,例如:varmap := make(map[string]string, 10)

  1. 使用空结构作为值

如果你想实现一个无序的 Map,并利用这个 Map 来判断值是否存在,你可以使用 struct{} 来作为 Map 的 Value。因为 struct{} 不占用任何空间,从而可以减小内存开销,例如:

  1. varmap := make(map[string]struct{}, 10)
  2. if _, ok := varmap["a"]; ok {
  3. fmt.Println("a is exist")
  4. }
  1. 在初始化结构体时使用带有标签的语法

这是一个无标签语法的例子:

  1. type T struct {
  2. Foo string
  3. Bar int
  4. }
  5. func main() {
  6. t := T{"example", 123} // 无标签语法
  7. fmt.Printf"t %+v\n", t)
  8. }

那么,如果你添加一个新的字段到 T 结构体,代码会编译失败:

  1. type T struct {
  2. Foo string
  3. Bar int
  4. Qux string
  5. }
  6. func main() {
  7. t := T{"example", 123} // 无法编译
  8. fmt.Printf("t %+v\n", t)
  9. }

最好的方法是,在初始化结构体时使用带有标签的语法:

  1. type T struct {
  2. Foo string
  3. Bar int
  4. Qux string
  5. }
  6. func main() {
  7. t := T{Foo: "example", Qux: 123}
  8. fmt.Printf("t %+v\n", t)
  9. }

这个编译起来没问题,而且弹性也好。不论你如何添加其他字段到 T 结构体。你的代码总是能编译,并且在以后的 Go 的版本也可以保证这一点。你可以通过静态代码检查来检查这一点。

  1. 将结构体的初始化拆分到多行

如果有两个以上的字段,那么就用多行。它会让你的代码更加容易阅读,也就是说不要:

  1. T{Foo: "example", Bar:someLongVariable,
  2. Qux:anotherLongVariable, B: forgetToAddThisToo}

而是:

  1. T{
  2. Foo: "example",
  3. Bar: someLongVariable,
  4. Qux: anotherLongVariable,
  5. B: forgetToAddThisToo,
  6. }

这有许多好处,首先它容易阅读,其次它使得允许或屏蔽字段初始化变得容易(只要注释或删除它们),最后添加其他字段也更容易(只要添加一行)。

  1. 为整数常量添加 String() 方法

如果你利用 iota 来使用自定义的整数枚举类型,务必要为其添加 String() 方法。例如:

  1. type State intconst (
  2. Running State = iota
  3. Stopped
  4. Rebooting
  5. Terminated
  6. )

如果你创建了这个类型的一个变量,然后输出,会得到一个整数:

  1. type State intconst (
  2. Running State = iota
  3. Stopped
  4. Rebooting
  5. Terminated
  6. )

除非你回顾常量定义,否则这里的 0 看起来毫无意义。只需要为 State 类型添加 String() 方法就可以修复这个问题:

  1. func (s State) String() string {switch s {
  2. case Running:
  3. return "Running"case Stopped:
  4. return "Stopped"case Rebooting:
  5. return "Rebooting"case Terminated:
  6. return "Terminated"default:
  7. return "Unknown"
  8. }
  9. }

新的输出是:state: Running。显然现在看起来可读性好了很多。在你调试程序的时候,这会带来更多的便利。同时还可以在实现 MarshalJSON()UnmarshalJSON() 这类方法的时候使用同样的手段。

  1. iotaa +1 开始增量

在前面的例子中同时也产生了一个我已经遇到过许多次的 bug。假设你有一个新的结构体,有一个 State 字段:

  1. type T struct {
  2. Name string
  3. Port int
  4. State State
  5. }

现在如果基于 T 创建一个新的变量,然后输出,你会得到奇怪的结果:

  1. func main() {
  2. t := T{Name: "example", Port: 6666}
  3. // prints: "t {Name:example Port:6666 State:Running}"
  4. fmt.Printf("t %+v\n", t)
  5. }

上面这段代码,State 字段没有初始化,Go 默认使用对应类型的零值进行填充。由于 State 是一个整数,零值也就是 0,但在我们的例子中它表示 Running

那么如何知道 State 被初始化了?还是它真的是在 Running 模式?没有办法区分它们,那么这就会产生未知的、不可预测的 bug。不过,修复这个很容易,只要让 iota+1 开始:

  1. const (
  2. Running State = iota + 1
  3. Stopped
  4. Rebooting
  5. Terminated
  6. )

现在 t 变量将默认输出 Unknown

  1. func main() {
  2. t := T{Name: "example", Port: 6666}
  3. // 输出: "t {Name:example Port:6666 State:Unknown}"
  4. fmt.Printf("t %+v\n", t)
  5. }

不过让 iota 从零值开始也是一种解决办法。例如,你可以引入一个新的状态叫做 Unknown,将其修改为:

  1. const (
  2. Unknown State = iota
  3. Running
  4. Stopped
  5. Rebooting
  6. Terminated
  7. )
  1. 返回函数调用

我已经看过很多错误返回的代码,例如:

  1. func bar() (string, error) {
  2. v, err := foo()
  3. if err != nil {
  4. return "", err
  5. }
  6. return v, nil
  7. }

然而,你只需要:

  1. func bar() (string, error) {
  2. return foo()
  3. }

更简单也更容易阅读(当然,除非你要对某些内部的值做一些记录)。

  1. 大量字符串拼接

Go 语言的字符串为不可变类型,使用 + 拼接字符串会创建一个新的对象,降低程序性能。fmt.Sprintf 底层大量使用了反射,性能上也会有所损耗。

所以,这里建议在大量字符串拼接的场景下,使用 strings.Builder, 可以显著提升性能。

优化前:

  1. func Before(str string, n int) string {
  2. var s string
  3. for i := 0; i < n; i++ {
  4. s += str // 直接使用 + 来拼接
  5. }
  6. return s
  7. }

优化后:

  1. func After(str string, n int) string {
  2. var buf strings.Builder
  3. for i := 0; i < n; i++ {
  4. buf.WriteString(str) // 使用 strings.Builder 优化拼接
  5. }
  6. return buf.String()
  7. }

这里也有一个建议:从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(<=5),行内拼接字符串推荐使用运算符 +,反之使用 fmt.Sprintf()

  1. 正则表达式预编译

如果正则表达式是确定不变的,则可以将其定义为全局变量并预先编译,避免每次使用时现编译。

优化前:

  1. func ParseIPv4Before(ip string) bool {
  2. re := regexp.MustCompile((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))
  3. return re.MatchString(ip)
  4. }

优化后:

ruby

  1. var re = regexp.MustCompile((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))
  2. func ParseIPv4Before(ip string) bool {
  3. return re.MatchString(ip)
  4. }

其他技巧

  1. 不懂就问 Kubernetes

Kubernetes 是一个非常优秀的开源项目。其代码结构设计、功能、规范等方面都堪称模范。在开发中,如果我们遇到不知道怎么命名变量、返回错误信息是否大写开头、如何获取机器 IP 等问题,你都可以参考 Kubernetes 的现有实现。

类似的,你可以将你觉得比较优质的项目都保存在一个文件夹下。日后,如果你想知道某类功能的实现,或者想参考其他项目的实现,你可以直接通过 grep 的方式在这些优质的项目中进行搜索,例如:

  1. $ ls
  2. beer-shop gin-admin gin-vue-admin gin-web go-admin naas nirvana
  3. [colin@dev learnproj]$ grep -R Login *
  1. 积累模板

在你的 Go 职业生涯中,你可能会开发很多个 Go 项目,这些项目在项目目录结构、代码架构、应用构建方式等方面可能都具有类似的实现。我们可以将这些功能实现抽象到一个模板 Go 项目中。这样,今后我们开发一个新项目,只需要拷贝模板项目,并稍作修改,就能很快搭建起一个新的项目。

另外,在实际开发中,也有很多功能需要重复用到,针对这部分功能,你可以整理,并存放在 Go 模板项目中,或者一个单独的 Go 包中,供日后参考使用,例如:user.go 文件中,List 函数中的并发逻辑,就可以抽象成一个功能模板,供日后使用。

小结

本节课分享了我工作中很有用的开发技巧,希望对你有所帮助。