前言

介绍:Go是静态,编译类型语言,2009年11月开源。凭借语法简单,原生支持并发,标准库强大,工具链丰富的优点已成为主流云原生编程语言:很多云原生时代的杀手级平台、中间件、协议和应用都是采用Go语言开发的,Docker,Kubernetes,以太坊,Hyperledger Fabric超级账本、新一代互联网基础设施协议IPFS等。


入门简单精进难(如同说话和写文章),如何精进:

1)思维层面:写出高质量Go代码的前提是思维方式的进阶,即用Go语言的思维写Go代码。

2)实践技巧层面:Go标准库和优秀Go开源库是挖掘符合Go惯用法的高质量Go代码的宝库,对其进行阅读、整理和归纳,可以得到一些能够帮助我们快速进阶的有效实践。


第一部分 熟知Go语言的一切

第1条 go的发展史

新语言主要思路是:在C语言的基础上,修正一些明显的缺陷,删除一些被诟病较多的特性,增加一些缺失的功能。

具体功能和特性如下:

  1. 语句像C语言一样,但需要修正switch语句的缺陷。
  2. 表达式像C语言一样,但有一些注意事项(比如是否需要逗号表达式)。
  3. 基本上是强类型的,但可能需要支持运行时类型。
  4. 数组应该总是有边界检查。
  5. 具备垃圾回收的机制。
  6. 支持接口(interface)。
  7. 支持嵌套和匿名函数/闭包。
  8. 一个简单的编译器。
  9. 各种语言机制应该能产生可预测的代码。

很多Go语言初学者经常称这门语言为golang,其实这是不对的:golang仅应用于命名Go语言官方网站,当时之所以使用http://golang.org作为Go语言官方域名,是因为http://go.com已经被迪士尼公司占用了。


第3条 go的设计哲学

Go语言的魅力就来自Go语言的设计哲学。官方没有给出明确说法。

【哲学一:追求简单,少即是多】(把复杂性留给了语言自身的设计和实现,将简单、易用和清晰留给了广大Gopher)

  1. 简洁、常规的语法(不需要解析符号表),它仅有25个关键字;
  2. !内置垃圾收集,降低开发人员内存管理的心智负担;
  3. 没有头文件;
  4. 显式依赖(package);
  5. 没有循环依赖(package);
  6. 常量只是数字;
  7. !首字母大小写决定可见性;
  8. !任何类型都可以拥有方法(没有类);
  9. !没有子类型继承(没有子类);
  10. 没有算术转换;
  11. !接口是隐式的(无须implements声明);
  12. 方法就是函数;
  13. !接口只是方法集合(没有数据);
  14. !方法仅按名称匹配(不是按类型);
  15. 没有构造函数或析构函数;
  16. n++和n--是语句,而不是表达式;
  17. 没有++n和--n;
  18. 赋值不是表达式;
  19. 在赋值和函数调用中定义的求值顺序(无“序列点”概念);
  20. 没有指针算术;
  21. !内存总是初始化为零值;

有人向Go开发团队提出过这样一个问题:Go后续演化的最大难点是什么?Go开发团队的一名核心成员回答道:“最大的难点是如何继续保持Go语言的简单。

【哲学二:偏好组合,正交解耦】

C++、Java等主流面向对象(以下简称OO)语言通过庞大的自上而下的类型体系、继承、显式接口实现等机制将程序的各个部分耦合起来,但在Go语言中我们找不到经典OO的语法元素、类型体系和继承机制,或者说Go语言本质上就不属于经典OO语言范畴。

  1. Go语言无类型体系(type hierarchy),类型之间是独立的,没有子类型的概念;
  2. 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;
  3. 接口(interface)与其实现之间隐式关联;
  4. 包(package)之间是相对独立的,没有子包的概念。

Go语言提供的最为直观的组合的语法元素是类型嵌入(type embedding)。通过类型嵌入,我们可以将已经实现的功能嵌入新类型中,以快速满足新类型的功能需求。这种方式有些类似经典OO语言中的继承机制,但在原理上与其完全不同,这是一种Go设计者们精心设计的语法糖。

我们在poolLocal这个结构体类型中嵌入了类型Mutex,被嵌入的Mutex类型的方法集合会被提升到外面的类型(poolLocal)中。比如,这里的poolLocal将拥有Mutex类型的Lock和Unlock方法。但在实际调用时,方法调用会被传给poolLocal中的Mutex实例。

interface是Go语言中真正的“魔法”,是Go语言的一个创新设计,它只是方法集合,且与实现者之间的关系是隐式的,它让程序各个部分之间的耦合降至最低,同时是连接程序各个部分的“纽带”。隐式的interface实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,这在其他语言中是需要很刻意的设计谋划才能实现的,但在Go interface看来,一切却是自然而然的。

【哲学三:原生并发,轻量高效】

CPU仅靠提高主频来改进性能的做法遇到了瓶颈。主频提高导致CPU的功耗和发热量剧增,反过来制约了CPU性能的进一步提高。焦点组件转向了多核。

Go语言采用轻量级协程并发模型,使得Go应用在面向多核硬件时更具可扩展性

传统语言的实现是基于操作系统的,存在两大不足,复杂和难于扩展,复杂性如下:

  • 创建容易,退出难。
  • 并发单元间通信困难,易错。
  • 线程栈大小(thread stack size)的设定。

Go果断放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程或者说是类协程(coroutine),Go将之称为goroutine。goroutine占用的资源非常少,Go运行时默认为每个goroutine分配的栈空间仅2KB。goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,在一个Go程序中可以创建成千上万个并发的goroutine。

一个Go程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine的存在。goroutine的调度全靠Go自己完成,实现Go程序内goroutine之间公平地竞争CPU资源的任务就落到了Go运行时头上。而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)。

【哲学四:面向工程,“自带电池”】

设计Go语言时的初衷:面向真实世界中Google内部大规模软件开发存在的各种问题,为这些问题提供答案。主要的问题包括:

  1. 程序构建慢;
  2. 失控的依赖管理;
  3. 开发人员使用编程语言的不同子集(比如C++支持多范式,这样有些人用OO,有些人用泛型);
  4. 代码可理解性差(代码可读性差、文档差等);
  5. 功能重复实现;
  6. 升级更新消耗大;
  7. 实现自动化工具难度高;
  8. 版本问题;
  9. 跨语言构建问题。

Go设计者以更高、更广阔的视角审视软件开发领域尤其是大规模软件开发过程中遇到的各种问题,并在Go语言最初设计阶段就将解决工程问题作为Go的设计原则之一去考虑Go语法、工具链与标准库的设计

三个角度看问题:

1)代码

  • 未使用的引入无法编译,节省编译时间
  • 去除循环依赖,节省编译时间
  • 首字母大小写提升阅读速度
  • 内置垃圾回收,内置并发等

2)标准库

Go在标准库中提供了各类高质量且性能优良的功能包,其中的net/http、crypto/xx加密、encoding/xx等包充分迎合了云原生时代关于API/RPC Web服务的构建需求。减少三方依赖。

3)工具链

Go语言提供了十分全面、贴心的编程语言官方工具链,涵盖了编译、编辑、依赖获取、调试、测试、文档、性能剖析等的方方面面。

第4条 go思维写go代码

萨丕尔-沃夫假说:语言影响或决定人类的思维方式。

同样是筛选素数,不同的语言实现方式差距很大:C的命令式思维、Haskell的函数式思维和Go的并发思维。

一门编程语言的编程思维是由语言设计者、语言实现团队、语言社区、语言使用者在长期的演进和实践中形成的一种统一的思维习惯、行为方式、代码惯用法和风格。

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

第5条目录结构

【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等。

第6条 gofmt

1)手动格式化代码:GoLand主菜单中依次选择Tools→Go Tools→Go fmt file/Go fmt project/Goimports file

2)保存文件自动格式化:Pereferences中依次选择Tools→File Watchers,然后添加一个File Watcher,选择go fmt模板或goimports模板即可

第7条 go命名

一个好笑话,如果你必须解释它,那就不好笑了。好的命名也类似。
  • 要想做好Go标识符的命名(包括对包的命名),至少要遵循两个原则:简单且一致;利用上下文辅助命名。
  • 短小意味着能用一个单词命名的,就不要使用单词组合;能用单个字母(在特定上下文中)表达标识符用途的,就不用完整单词。甚至在某种情况下,Go命名惯例选择了简洁命名+注释辅助解释的方式,而不是一个长长的名字。
  • 对于Go中的包(package),一般建议以小写形式的单个单词命名
  • 我们在给包命名时不要有是否与其他包重名的顾虑,因为在Go中,包名可以不唯一。可以在导入包时使用一个显式包名来指代导入的包,并且在这个源文件中使用这个显式包名来引用包中的元素。
  • Go语言建议,包名应尽量与包导入路径(import path)的最后一个路径分段保持一致。
  • 由于对这些包导出标识符的引用必须以包名为前缀,因此对包导出标识符命名时,在名字中不要再包含包名。
  • 在Go中变量分为包级别的变量和局部变量(函数或方法内的变量)。函数或方法的参数、返回值都可以被视为局部变量。
  • Go语言官方要求标识符命名采用驼峰命名法(CamelCase),有两种形式:一种是第一个词的首字母小写,后面每个词的首字母大写,叫作“小骆峰拼写法”(lowerCamelCase),这也是在Go中最常见的标识符命名法;而第一个词的首字母以及后面每个词的首字母都大写,叫作“大驼峰拼写法”(UpperCamelCase),又称“帕斯卡拼写法”(PascalCase)。由于首字母大写的标识符在Go语言中被视作包导出标识符,因此只有在涉及包导出的情况下才会用到大驼峰拼写法。不过如果缩略词的首字母是大写的,那么其他字母也要保持全部大写,比如HTTP(Hypertext Transfer Protocol)、CBC(Cipher Block Chaining)等。
  • Go语言建议通过保持一致性来维持可读性。一致意味着代码中相同或相似的命名所传达的含义是相同或相似的,这样便于代码阅读者或维护者猜测出变量的用途。
  • Go语言中,常量在命名方式上与变量并无较大差别,并不要求全部大写。
  • Go语言中,对于接口类型优先以单个单词命名。对于拥有唯一方法(method)或通过多个拥有唯一方法的接口组合而成的接口,Go语言的惯例是用“方法名+er”命名。
  • Go在给标识符命名时还有着考虑上下文环境的惯例,即在不影响可读性的前提下,兼顾一致性原则,尽可能地用短小的名字命名标识符。这与其他一些主流语言在命名上的建议有所不同,比如Java建议遵循“见名知义”的命名原则。

第三部分 声明、类型、语句与控制结构

第8条 变量声明

如果让Go语言的设计者重新设计一次变量声明语法,相信他们很大可能不会再给予Gopher这么大的变量声明灵活性,但目前这一切都无法改变。

Go语言有两类变量:

1)包级变量(package variable):在package级别可见的变量。如果是导出变量,则该包级变量也可以被视为全局变量。

2)局部变量(local variable):函数或方法体内声明的变量,仅在函数或方法体内可见。

Go语言提供var块用于将多个变量声明语句放在一起,并且在语法上不会限制放置在var块中的声明类型。

第9条 使用无类型常量简化代码

无类型常量是Go语言在语法设计方面的一个“微创新”,也是“追求简单”设计哲学的又一体现,它可以让你的Go代码更加简洁。

Go是对类型安全要求十分严格的编程语言。Go要求,两个类型即便拥有相同的底层类型(underlying type),也仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算

我们看到,Go在处理不同类型的变量间的运算时不支持隐式的类型转换。Go的设计者认为,隐式转换带来的便利性不足以抵消其带来的诸多问题[1]。要解决上面的编译错误,必须进行显式类型转换。

第10条 iota实现枚举常量

第11条 定义零值可用

Go语言中的每个原生类型都有其默认值,这个默认值就是这个类型的零值。下面是Go规范定义的内置原生类型的默认值(零值)。

  1. 所有整型类型:0
  2. 浮点类型:0.0
  3. 布尔类型:false
  4. 字符串类型:""
  5. 指针、interface、切片(slice)、channel、map、function:nil

另外,Go的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。

第一个例子 是关于切片的:

我们声明了一个[]int类型的切片zeroSlice,但并没有对其进行显式初始化,这样zeroSlice这个变量就被Go编译器置为零值nil。按传统的思维,对于值为nil的变量,我们要先为其赋上合理的值后才能使用。但由于Go中的切片类型具备零值可用的特性,我们可以直接对其进行append操作,而不会出现引用nil的错误。

第二个例子 是通过nil指针调用方法

在标准输出上输出该变量,fmt.Println会调用p.String()。有些string方法内会考虑值为nil的情况直接输出“nil”

第三个例子 声明锁直接用,无需初始化

第12条 使用复合字面值作为初值构造器

Go语言中的复合类型包括结构体、数组、切片和map。对于复合类型变量,最常见的值构造方式就是对其内部元素进行逐个赋值。

但这样的值构造方式让代码显得有些烦琐,尤其是在构造组成较为复杂的复合类型变量的初值时。Go提供的复合字面值(composite literal)语法可以作为复合类型变量的初值构造器。

复合字面值由两部分组成:一部分是类型,比如上述示例代码中赋值操作符右侧的myStruct、[5]int、[]int和map[int]string;另一部分是由大括号{}包裹的字面值

1)结构体复合字面值

这种field:value形式的复合字面值初值构造器颇为强大。与之前普通复合字面值形式不同,field:value形式字面值中的字段可以以任意次序出现,未显式出现在字面值的结构体中的字段将采用其对应类型的零值。

值得注意的是,不允许将从其他包导入的结构体中的未导出字段作为复合字面值中的field,这会导致编译错误。

2)数组/切片复合字面值

与结构体类型不同,数组/切片使用下标(index)作为field:value形式中的field,从而实现数组/切片初始元素值的高级构造形式

3)map复合字面值

和结构体、数组/切片相比,map类型变量使用复合字面值作为初值构造器就显得自然许多,因为map类型具有原生的key:value构造形式