本文为 QCon 2018 上海站主题演讲嘉宾、Heptio 资深工程师、著名 Go 语言专家 David Cheney 关于 Go 语言实践的英文分享。 本文主要基于译文cloud.tencent.com/developer/a… 整理发布
引言
接下来这两场我将给大家一些编写 Go 代码的最佳实践。
今天这是一个研讨会风格的演讲,我会摒弃那些绚丽的 PPT,而是使用您们可以直接带走的文档
您可以在这里找到这个演讲最新的在线版本: dave.cheney.net/practical-g…
1.指导原则
我们要谈论在一个编程语言中的最佳实践,那么我们首先应该明确什么是“最佳”。如果您们听了我昨天那场讲演的话,您一定看到了来自 Go 团队的 Russ Cox 讲的一句话:
软件工程,是您在编程过程中增加了工期或者开发人员之后发生的那些事。 — Russ Cox
Russ 是在阐述软件“编程”和软件“工程”之间的区别,前者是您写的程序,而后者是一个让更多的人长期使用的产品。软件工程师会来来去去地更换,团队也会成长或者萎缩,需求也会发生变化,新的特性也会增加,bug 也会被修复,这就是软件“工程”的本质。
我可能是现场最早的 Go 语言用户,但与其说我的主张来自我的资历,不如说我今天讲的是真实来自于 Go 语言本身的指导原则,那就是:
- 简单性
- 可读性
- 生产率
您可能已经注意到,我并没有提性能或者并发性。实际上有不少的语言执行效率比 Go 还要高,但它们一定没有 Go 这么简单。有些语言也以并发性为最高目标,但它们的可读性和生产率都不好。 性能和并发性都很重要,但它们不如简单性、可读性和生产率那么重要。
1.1 简单性
为什么我们要力求简单,为什么简单对 Go 语言编程如此重要?
我们有太多的时候感叹“这段代码我看不懂”,是吧?我们害怕修改一丁点代码,生怕这一点修改就导致其他您不懂的部分出问题,而您又没办法修复它。
这就是复杂性。复杂性把可读的程序变得不可读,复杂性终结了很多软件项目。
简单性是 Go 的最高目标。无论我们写什么程序,我们都应该能一致认为它应当简单。
1.2 可读性
Readability is essential for maintainability. — Mark Reinhold, JVM language summit 2018 可读性对于可维护性至关重要。
为什么 Go 代码的可读性如此重要?为什么我们应该力求可读性?
Programs must be written for people to read, and only incidentally for machines to execute. — Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs 程序应该是写来被人阅读的,而只是顺带可以被机器执行。
可阅读性对所有的程序——不仅仅是 Go 程序,都是如此之重要,是因为程序是人写的并且给其他人阅读的,事实上被机器所执行只是其次。
代码被阅读的次数,远远大于被编写的次数。一段小的代码,在它的整个生命周期,可能被阅读成百上千次。
The most important skill for a programmer is the ability to effectively communicate ideas. — Gastón Jorquera ^1 程序员最重要的技能是有效沟通想法的能力。
可读性是弄清楚一个程序是在做什么事的关键。如果您都不知道这个程序在做什么,您如何去维护这个程序?如果一个软件不可用被维护,那就可能被重写,并且这也可能是您公司最后一次在 GO 上面投入了。
如果您仅仅是为自己个人写一个程序,可能这个程序是一次性的,或者使用这个程序的人也只有您一个,那您想怎样写就怎样写。但如果是多人合作贡献的程序,或者因为它解决人们的需求、满足某些特性、运行它的环境会变化,而在一个很长的时间内被很多人使用,那么程序的可维护性则必须成为目标。
编写可维护的程序的第一步,那就是确保代码是可读的。
1.3 生产率
Design is the art of arranging code to work today, and be changeable forever. — Sandi Metz > 设计是一门艺术,要求编写的代码当前可用,并且以后仍能被改动。
我想重点阐述的最后一个基本原则是生产率。开发者的生产率是一个复杂的话题,但归结起来就是:为了有效的工作,您因为一些工具、外部代码库而浪费了多少时间。Go 程序员应该感受得到,他们在工作中可以从很多东西中受益了。(Austin Luo:言下之意是,Go 的工具集和基础库完备,很多东西触手可得。)
有一个笑话是说,Go 是在 C++ 程序编译过程中被设计出来的。快速的编译是 Go 语言用以吸引新开发者的关键特性。编译速度仍然是一个不变的战场,很公平地说,其他语言需要几分钟才能编译,而 Go 只需要几秒即可完成。这有助于 Go 开发者拥有动态语言开发者一样的高效,但却不会面临那些动态语言本身可靠性的问题。
Go 开发者意识到代码是写来被阅读的,并且把阅读放在编写之上。Go 致力于从工具集、习惯等方面强制要求代码必须编写为一种特定样式,这消除了学习项目特定术语的障碍,同时也可以仅仅从“看起来”不正确即可帮助开发者发现潜在的错误。
Go 开发者不会整日去调试那些莫名其妙的编译错误。他们也不会整日浪费时间在复杂的构建脚本或将代码部署到生产中这事上。更重要的是他们不会花时间在尝试搞懂同事们写的代码是什么意思这事上。
当 Go 语言团队在谈论一个语言必须扩展时,他们谈论的就是生产率。
2标识符
我们要讨论的第一个议题是标识符。标识符是一个名称的描述词,这个名称可以是一个变量的名称、一个函数的名称、一个方法的名称、一个类型的名称或者一个包的名称等等。
鉴于 Go 的语法限制,我们为程序中的事物选择的名称对我们程序的可读性产生了过大的影响。良好的可读性是评判代码质量的关键,因此选择好名称对于 Go 代码的可读性至关重要。
2.1选择清晰的名称,而不是简洁的名称
Go 不是专注于将代码精巧优化为一行的那种语言,Go 也不是致力于将代码精炼到最小行数的语言。我们并不追求源码在磁盘上占用的空间更少,也不关心录入代码需要多长时间。
这个清晰度的关键就是我们为 Go 程序选择的标识符。让我们来看看一个好的名称应当具备什么吧:
- 好的名称是简洁的。一个好的名称未必是尽可能短的,但它肯定不会浪费任何无关的东西在上面,好名字具有高信噪比。
- 好的名称是描述性的。一个好的名称应该描述一个变量或常量的使用,而非其内容。一个好的命名应该描述函数的结果或一个方法的行为,而不是这个函数或方法本身的操作。一个好的名称应该描述一个包的目的,而不是包的内容。名称描述的东西越准确,名称越好。
- 好的名称是可预测的。您应该能够从名称中推断出它的使用方式,这是选择描述性名称带来的作用,同时也遵循了传统。Go 开发者在谈论惯用语时,即是说的这个。
接下来让我们深入地讨论一下。
2.2 标识符长度
有时候人们批评 Go 风格推荐短变量名。正如 Rob Pike 所说,“Go 开发者想要的是合适长度的标识符”。^1 Andrew Gerrand 建议通过使用更长的标识符向读者暗示它们具有更高的重要性。
据此,我们可以归纳一些指导意见:
- 短变量名称在声明和上次使用之间的距离很短时效果很好。
- 长变量名需要证明其不同的合理性:越长的变量名,越需要更多的理由来证明其合理。冗长、繁琐的名称与他们在页面上的权重相比,携带的信息很低。
- 不要在变量名中包含其类型的名称。
- 常量需要描述其存储的值的含义,而不是怎么使用它。
- 单字母变量可用于循环或逻辑分支,单词变量可用于参数或返回值,多词短语可用于函数和包这一级的声明。
- 单词可用于方法、接口和包
- 请记住,包的命名将成为用户引用它时采用的名称,确保这个名称更有意义。 让我们来看一个示例:
type Person struct {
Name string
Age int
}
func AverageAge(people []Person) int {
if len(people) == 0 {
return 0
}
var count, sum int
for _, p := range people {
sum += p.Age
count += 1
}
return sum / count
}
复制代码
在这个示例中,范围变量p在定义之后只在接下来的一行使用。p在整页源码和函数执行过程中都只生存一小段时间。对p感兴趣的读者只需要查看两行代码即可。
与之形成对比的是,变量people在函数参数中定义,并且存在了 7 行,同理的还有sum和count,这他们使用了更长的名称,读者必须关注更广泛的代码行。
我也可以使用s而不是sum,用c(或n)而不是count,但这会将整个程序中的变量都聚集在相同的重要性上。我也可以使用p而不是people,但是这样又有一个问题,那就是for ... range循环中的变量又用什么?单数的 person 看起来也很奇怪,生存时间极短命名却比导出它的那个值更长。
Austin Luo:这里说的是,若数组people用变量名p,那么从数组中获取的每一个元素取名就成了问题,比如用person,即使使用person看起来也很奇怪,一方面是单数,一方面person的生存周期只有两行(很短),命名比生存周期更长的p(people)还长了。 小窍门:跟使用空行在文档中分段一样,使用空行将函数执行过程分段。在函数AverageAge中有按顺序的三个操作。第一个是先决条件,检查当people为空时我们不会除零,第二个是累加总和和计数,最后一个是计算平均数。
2.2.1 上下文是关键
绝大多数的命名建议都是根据上下文的,意识到这一点很重要。我喜欢称之为原则,而不是规则。 i和index 这两个标识符有什么不同?我们很难确切地说其中一个比另一个好,比如:
for index := 0; index < len(s); index++ {
}
复制代码
上述代码的可读性,基本上都会认为比下面这段要强:
for i := 0; i < len(s); i++ {
}
复制代码
但我表示不赞同。因为无论是i还是index,都是限定于for循环体的,更冗长的命名,并没有让我们更容易地理解这段代码。
话说回来,下面两段代码那一段可读性更强呢?
func (s *SNMP) Fetch(oid []int, index int) (int, error)
复制代码
或者
func (s *SNMP) Fetch(o []int, i int) (int, error)
复制代码
在这个示例中,oid是SNMP对象 ID 的缩写,因此将其略写为 o 意味着开发者必须将他们在文档中看到的常规符号转换理解为代码中更短的符号。同样地,将index简略为i,减少了其作为SNMP消息的索引的含义。
小窍门:在参数声明中不要混用长、短不同的命名风格。
2.3 命名中不要包含所属类型的名称
正如您给宠物取名一样,您会给狗取名“汪汪”,给猫取名为“咪咪”,但不会取名为“汪汪狗”、“咪咪猫”。出于同样的原因,您也不应在变量名称中包含其类型的名称。
变量命名应该体现它的内容,而不是类型。我们来看下面这个例子:
var usersMap map[string]*User
复制代码
这样的命名有什么好处呢?我们能知道它是个 map,并且它与*User类型有关,这可能还不错。但是 Go 作为一种静态类型语言,它并不会允许我们在需要标量变量的地方意外地使用到这个变量,因此Map后缀实际上是多余的。 现在我们来看像下面这样定义变量又是什么情况:
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
复制代码
现在这个范围内我们有了三个 map 类型的变量了:usersMap,companiesMap,以及 productsMap,所有这些都从字符串映射到了不同的类型。我们知道它们都是 map,我们也知道它们的 map 声明会阻止我们使用一个代替另一个——如果我们尝试在需要map[string]*User的地方使用companiesMap,编译器将抛出错误。在这种情况下,很明显Map后缀不会提高代码的清晰度,它只是编程时需要键入的冗余内容。(Austin Luo:陈旧的思维方式)
我的建议是,避免给变量加上与类型相关的任何后缀。
小窍门:如果users不能描述得足够清楚,那usersMap也一定不能。
这个建议也适用于函数参数,比如:
type Config struct {
}
func WriteConfig(w io.Writer, config *Config)
复制代码
将Config参数命名为config是多余的,我们知道它是个Config,函数签名上写得很清楚。
在这种情况建议考虑conf或者c——如果生命周期足够短的话。
如果在一个范围内有超过一个*Config,那命名为conf1、conf2的描述性就比original、updated更差,而且后者比前者更不容易出错。
NOTE:不要让包名占用了更适合变量的名称。 导入的标识符是会包含它所属包的名称的。 例如我们很清楚context.Context是包context中的类型Context。这就导致我们在我们自己的包里,再也无法使用context作为变量或类型名了。 func WriteLog(context context.Context, message string) 这无法编译。这也是为什么我们通常将context.Context类型的变量命名为ctx的原因,如: func WriteLog(ctx context.Context, message string)
2.4 使用一致的命名风格
一个好名字的另一个特点是它应该是可预测的。阅读者应该可以在第一次看到的时候就能够理解它如何使用。如果遇到一个约定俗称的名字,他们应该能够认为和上次看到这个名字一样,一直以来它都没有改变意义。
例如,如果您要传递一个数据库句柄,请确保每次的参数命名都是一样的。与其使用 d *sql.DB
,dbase *sql.DB
,DB *sql.DB
和database *sql.DB
,还不如都统一为:
db *sql.DB
复制代码
这样做可以增进熟悉度:如果您看到db,那么您就知道那是个*sql.DB,并且已经在本地定义或者由调用者提供了。 对于方法接收者也类似,在类型的每个方法中使用相同的接收者名称,这样可以让阅读者在跨方法阅读和理解时更容易主观推断。
Austin Luo:“接收者”是一种特殊类型的参数。^2 比
如func (b *Buffer) Read(p []byte) (n int, err error)
,它通常只用一到两个字母来表示,但在不同的方法中仍然应当保持一致。 注意:Go 中对接收者的短命名规则惯例与目前提供的建议不一致。这只是早期做出的选择之一,并且已经成为首选的风格,就像使用CamelCase而不是snake_case一样。
小窍门:Go 的命名风格规定接收器具有单个字母名称或其派生类型的首字母缩略词。有时您可能会发现接收器的名称有时会与方法中参数的名称冲突,在这种情况下,请考虑使参数名称稍长,并且仍然不要忘记一致地使用这个新名称。
最后,某些单字母变量传统上与循环和计数有关。例如,i,j,和k通常是简单的for循环变量。n通常与计数器或累加器有关。 v通常是某个值的简写,k通常用于映射的键,s通常用作string类型参数的简写。
与上面db的例子一样,程序员期望i是循环变量。如果您保证i始终是一个循环变量——而不是在for循环之外的情况下使用,那么当读者遇到一个名为i或者j的变量时,他们就知道当前还在循环中。
小窍门:如果您发现在嵌套循环中您都使用完i,j,k了,那么很显然这已经到了将函数拆得更小的时候了。 使用一致的声明风格
2.5 使用一致的声明风格
Go 中至少有 6 种声明变量的方法(Austin Luo:作者说了 6 种,但只列了 5 种)
- var x int = 1
- var x = 1
- var x int; x = 1
- var x = int(1)
- x := 1
我敢肯定还有更多我没想到的。这是 Go 的设计师认识到可能是一个错误的地方,但现在改变它为时已晚。有这么多不同的方式来声明变量,那么我们如何避免每个 Go 程序员选择自己个性独特的声明风格呢?
我想展示一些在我自己的程序里声明变量的建议。这是我尽可能使用的风格。
- 只声明,不初始化时,使用var。在声明之后,将会显式地初始化时,使用var关键字。
var players int // 0
var things []Thing // an empty slice of Things
var thing Thing // empty Thing struct
json.Unmarshall(reader, &thing)
复制代码
var关键字表明这个变量被有意地声明为该类型的零值。这也与在包级别声明变量时使用var而不是短声明语法(Austin Luo::=)的要求一致——尽管我稍后会说您根本不应该使用包级变量。
- 既声明,也初始化时,使用:=。当同时要声明和初始化变量时,换言之我们不让变量隐式地被初始化为零值时,我建议使用短声明语法的形式。这使得读者清楚地知道:=左侧的变量是有意被初始化的。 为解释原因,我们回头再看看上面的例子,但这一次每个变量都被有意初始化了:
var players int = 0
var things []Thing = nil
var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)
复制代码
第一个和第三个示例中,因为 Go 没有从一种类型到另一种类型的自动转换,赋值运算符左侧和右侧的类型必定是一致的。编译器可以从右侧的类型推断出左侧所声明变量的类型。对于这个示例可以更简洁地写成这样:
var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
复制代码
由于0是players的零值,因此为players显式地初始化为0就显得多余了。所以为了更清晰地表明我们使用了零值,应该写成这样:
var players int
复制代码
那第二条语句呢?我们不能忽视类型写成:
var things = nil
复制代码
因为nil根本就没有类型^2。相反,我们有一个选择,我们是否希望切片的零值?
var things []Thing
复制代码
或者我们是否希望创建一个没有元素的切片?
var things = make([]Thing, 0)
复制代码
如果我们想要的是后者,这不是个切片类型的零值,那么我们应该使用短声明语法让阅读者很清楚地明白我们的选择:
things := make([]Thing, 0)
复制代码
这告诉了读者我们显式地初始化了things。 再来看看第三个声明:
var thing = new(Thing)
复制代码
这既显式地初始化了变量,也引入了 Go 程序员不喜欢而且很不常用的new关键字。如果我们遵循短命名语法的建议,那么这句将变成:
thing := new(Thing)
复制代码
这很清楚地表明,thing被显式地初始化为new(Thing)的结果——一个指向Thing的指针——但仍然保留了我们不常用的new。我们可以通过使用紧凑结构初始化的形式来解决这个问题,
thing := &Thing{}
复制代码
这和new(Thing)做了同样的事——也因此很多 Go 程序员对这种重复感觉不安。不过,这一句仍然意味着我们为thing明确地初始化了一个Thing{}的指针——一个Thing的零值。
在这里,我们应该意识到,thing被初始化为了零值,并且将它的指针地址传递给了json.Unmarshall:
var thing Thing
json.Unmarshall(reader, &thing)
复制代码
注意:当然,对于任何经验法则都有例外。比如,有些变量之间很相关,那么与其写成这样:
var min int max := 1000
不如写成这样更具可读性:min, max := 0, 1000
综上所述:
- 只声明,不初始化时,使用var。
- 既声明,也显式地初始化时,使用:=。
小窍门:使得机巧的声明更加显而易见。 当某件事本身很复杂时,应当使它看起来就复杂。 var length uint32 = 0x80 这里的length可能和一个需要有特定数字类型的库一起使用,并且length被很明确地指定为uint32类型而不只是短声明形式: length := uint32(0x80) 在第一个例子中,我故意违反了使用var声明形式和显式初始化程序的规则。这个和我惯常形式不同的决定,可以让读者意识到这里需要注意。
2.6 成为团队合作者
我谈到了软件工程的目标,即生成可读,可维护的代码。而您的大部分职业生涯参与的项目可能您都不是唯一的作者。在这种情况下我的建议是遵守团队的风格。
在文件中间改变编码风格是不适合的。同样,即使您不喜欢,可维护性也比您的个人喜好有价值得多。我的原则是:如果满足gofmt,那么通常就不值得再进行代码风格审查了。
小窍门:如果您要横跨整个代码库进行重命名,那么不要在其中混入其他的修改。如果其他人正在使用 git bisect,他们一定不愿意从几千行代码的重命名中“跋山涉水”地去寻找您别的修改。
3 代码注释
在我们进行下一个更大的主题之前,我想先花几分钟说说注释的事。
Good code has lots of comments, bad code requires lots of comments. — Dave Thomas and Andrew Hunt, The Pragmatic Programmer 好的代码中附带有大量的注释,坏的代码缺少大量的注释。
代码注释对 Go 程序的可读性极为重要。一个注释应该做到如下三个方面的至少一个:
- 注释应该解释“做什么”。
- 注释应该解释“怎么做的”。
- 注释应该解释“为什么这么做”。
第一种形式适合公开的符号:
// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.
复制代码
第二种形式适合方法内的注释:
// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
results = append(results, execute(seen, dep))
}
复制代码
第三种形式,“为什么这么做”,这是独一无二的,无法被前两种取代,也无法取代前两种。第三种形式的注释用于解释更多的状况,而这些状况往往难以脱离上下文,否则将没有意义,这些注释就是用来阐述上下文的。
return &v2.Cluster_CommonLbConfig{
// Disable HealthyPanicThreshold
HealthyPanicThreshold: &envoy_type.Percent{
Value: 0,
},
}
复制代码
在这个示例中,很难立即弄清楚把HealthyPanicThreshold的百分比设置为零会产生什么影响。注释就用来明确将值设置为0实际上是禁用了panic阈值的这种行为。
3.1 变量和常量上的注释应当描述它的内容,而非目的
我之前谈过,变量或常量的名称应描述其目的。向变量或常量添加注释时,应该描述变量的内容,而不是定义它的目的。
const randomNumber = 6 // determined from an unbiased die
复制代码
这个示例的注释描述了“为什么”randomNumber
被赋值为 6,也说明了 6 这个值是从何而来的。但它没有描述randomNumber
会被用到什么地方。下面是更多的例子:
const (
StatusContinue = 100 // RFC 7231, 6.2.1
StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
StatusProcessing = 102 // RFC 2518, 10.1
StatusOK = 200 // RFC 7231, 6.3.1
复制代码
如在 RFC 7231 的第 6.2.1 节中定义的那样,在 HTTP 语境中 100 被当做StatusContinue
。
小窍门:对于那些没有初始值的变量,注释应当描述谁将负责初始化它们 // sizeCalculationDisabled indicates whether it is safe // to calculate Types' widths and alignments. See dowidth. var sizeCalculationDisabled bool 这里,通过注释让读者清楚函数dowidth在负责维护sizeCalculationDisabled的状态。 小窍门:隐藏一目了然的东西 Kate Gregory 提到一点^3,有时一个好的命名,可以省略不必要的注释。 // registry of SQL drivers var registry = make(mapstringsql.Driver) 注释是源码作者加的,因为registry没能解释清楚定义它的目的——它是个注册表,但是什么的注册表? 通过重命名变量名为sqlDrivers,现在我们很清楚这个变量的目的是存储 SQL 驱动。 var sqlDrivers = make(mapstringsql.Driver) 现在注释已经多余了,可以移除。
3.2 总是为公开符号写文档说明
因为 godoc 将作为您的包的文档,您应该总是为每个公开的符号写好注释说明——包括变量、常量、函数和方法——所有定义在您包内的公开符号。 这里是 Go 风格指南的两条规则:
- 任何既不明显也不简短的公共功能必须加以注释。
- 无论长度或复杂程度如何,都必须对库中的任何函数进行注释。
package ioutil
// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)
复制代码
对这个规则有一个例外:您不需要为实现接口的方法进行文档说明,特别是不要这样:
// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)
复制代码
这个注释等于说明都没说,它没有告诉您这个方法做了什么,实际上更糟的是,它让您去找别的地方的文档。在这种情况我建议将注释整个去掉。
这里有一个来自io这个包的示例:
// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }
// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
R Reader // underlying reader
N int64 // max bytes remaining
}
func (l *LimitedReader) Read(p []byte) (n int, err error) {
if l.N <= 0 {
return 0, EOF
}
if int64(len(p)) > l.N {
p = p[0:l.N]
}
n, err = l.R.Read(p)
l.N -= int64(n)
return
}
复制代码
请注意,LimitedReader的声明紧接在使用它的函数之后,并且LimitedReader.Read又紧接着定义在LimitedReader之后,即便LimitedReader.Read本身没有文档注释,那和很清楚它是io.Reader的一种实现。
小窍门:在您编写函数之前先写描述这个函数的注释,如果您发现注释很难写,那就表明您正准备写的这段代码一定难以理解。
3.2.1 不要为坏的代码写注释,重写它
Don’t comment bad code — rewrite it — Brian Kernighan 不要为坏的代码写注释——重写它
为粗制滥造的代码片段着重写注释是不够的,如果您遭遇到一段这样的注释,您应该发起一个问题(issue)从而记得后续重构它。技术债务只要不是过多就没有关系。 在标准库的惯例是,批注一个 TODO 风格的注释,说明是谁发现了坏代码。 注释中的姓名并不意味着承诺去修复问题,但在解决问题时,他可能是最合适的人选。其他批注内容一般还有日期或者问题编号。
3.2.2与其为一大段代码写注释,不如重构它
Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I > improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer. — Steve McConnell 好的代码即为最好的文档。在您准备添加一行注释时,问自己,“我要如何改进这段代码从而使它不需要注释?”优化代码,然后注释它使之更清晰。
函数应该只做一件事。如果您发现一段代码因为与函数的其他部分不相关因而需要注释时,考虑将这段代码拆分为独立的函数。
除了更容易理解之外,较小的函数更容易单独测试,现在您将不相关的代码隔离拆分到不同的函数中,估计只有函数名才是唯一需要的文档注释了
4 包的设计
Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations. — Dave Thomas 编写内敛的代码——模块不向外部透露任何不必要的信息,也不依赖外部模块的实现。
每个 Go Package 事实上自身都是一个小的 Go 程序。正如函数或方法的实现对其调用者不重要一样,构成公开 API 的函数、方法、类型的实现——其行为——对调用者也不重要。
一个好的 Go Package 应该致力于较低的源码级耦合,这样,随着项目的增长,对一个包的更改不会级联影响其他代码库。那些“世界末日”似的重构让代码的更新优化变得极其困难,也让工作在这样的代码库上的开发者的生产效率极度地受限。
在这一节中我会来谈一谈包的设计,包括包的命名、类型的命名,以及编写方法和函数的一些小技巧。 一个好的包从它的名称开始
4.1 一个好的包从它的名称开始
编写一个好的 Go 程序包从命名开始。好好思考您的软件包的名字,仅用一个词来描述它是什么。(Austin Luo:就如同“电梯游说”一样,您只能在极短的时间极少的话语的情况下描述您要表达的东西。) 正如我在上一节讲变量命名一样,包的名称也同样非常重要。以我的经验来看,我们应当思考的不是“我在这个包里应当放哪些类型”,而是“包提供的服务都应该做什么”。通常这个问题的答案不应该是“这个包提供了某某类型”,而是“这个包让您可以进行 HTTP 通信”。
小窍门:以包“提供”的东西来命名,而不是以“包含”的东西来命名。
4.1.1好的包名应该是唯一的
在您的项目里,每个包名都应该是唯一的。这个建议很容易理解,也很容易遵守。包的命名应该源于它的目的——如果您发现有两个包需要取相同的名字,那可能是下面两种情况:
- 包的名称太通用了。
- 和另外一个类似名称的包重复了。在这种情况下,您应该重新评审设计或者直接将这两个包合并。
4.2 避免将包命名为base、common、util
一个低劣的名称通常是“utility”。这些通常是随着时间推移沉淀下来的通用帮助类或者工具代码。这种包里通常混合有各种不相关的功能,并且因为其通用性,以至于难以准确地描述这个包都提供了些什么。这通常导致包名来源于这个包“包含”的东西——一堆工具。
像utils或helpers这样的名称,通常在一些大型项目中找到,这些项目中已经开发了较深的层次结构,并且希望在共享这些帮助类函数时,避免循环导入。虽然打散这些工具函数到新的包也能打破循环导入,但是因为其本身是源于项目的设计问题,包名称并未反映其目的,因此打散它也仅仅只起到了打破导入循环的作用而已。
针对优化utils或helpers这种包名,我的建议是分析它们是在哪里被使用,并且是否有可能把相关函数挪到调用者所在的包。即便这可能导致一些重复的帮助类代码,但这也比在两个包之间引入一个导入依赖来的更好。
A little duplication is far cheaper than the wrong abstraction. — Sandy Metz (一点点的)重复远比错误的抽象更值得。
在多个地方使用工具类方法的情况下,优先选择多个包(的设计),每个包专注于一个单独的方面,而不是整个包。(Austin Luo:Separation Of Concerns。)
小窍门:使用复数形式命名工具包。比如strings是字符串的处理工具。
像base或common这样的名称,常用于一个通用的功能被分为两个或多个实现的情况,或者一些用于客户端、服务端程序,并且被重构为单独通用类型的包。我认为解决这个问题的方法是减少包的数量,把客户端、服务端的通用代码合并到一个统一包里。
具体例子,net/http包总并没有client和server这两个子包,取而代之的是只有两个名为client.go和server.go的文件,每个文件处理各自的类型,以及一个transport.go文件用于公共消息传输的代码。
小窍门:标识符的名称包括其包的名称 牢记标识符的名称包含其所在包的名称,这一点很重要 net/http包中的Get函数,在其他包引用时变成了http.Get。 strings包中的Reader类型,在其他包导入后变成了strings.Reader。 net包中的Error接口很明确地与网络错误相关。
4.3 快速返回,而不是深层嵌套
正如 Go 并不使用异常来控制执行流程,也不需要深度缩进代码只为了在顶层结构添加一个try...catch...块。与把成功执行的路径向右侧一层一层深度嵌套相比,Go 风格的代码是随着函数的执行,成功路径往屏幕下方移动。我的朋友 Mat Ryer 称这种方式为“视线”编码。^4
这是通过“保护条款”来实现的(Austin Luo: 类似我们常说的防御式编程):条件代码块在进入函数时立即断言前置条件。这里是bytes包里的一个示例:
func (b *Buffer) UnreadRune() error {
if b.lastRead <= opInvalid {
return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}
if b.off >= int(b.lastRead) {
b.off -= int(b.lastRead)
}
b.lastRead = opInvalid
return nil
}
复制代码
一旦进入UnreadRune,就会检查b.lastRead,如果之前的操作不是ReadRune就会立即返回错误。从这里开始,函数执行下去的其余部分,我们就能明确肯定b.lastRead比opInvalid大了。
与没有使用“保护条款”的相同功能代码对比看看:
func (b *Buffer) UnreadRune() error {
if b.lastRead > opInvalid {
if b.off >= int(b.lastRead) {
b.off -= int(b.lastRead)
}
b.lastRead = opInvalid
return nil
}
return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}
复制代码
最通常的、成功的情况,被缩进到了第一个if条件中了。并且成功的退出条件 return nil,需要非常小心地与闭口括号(})对应。接下来,最后一行代码返回了一个错误,并且我们需要回退跟踪到函数的开口括号({)才知道执行控制流什么时候到达了这里。 对于读者和维护程序员来说,这更容易出错,因此 Go 更喜欢使用“保护条款”并尽早返回错误。
4.4 让零值变得有意义
假设没有明确提供显示初始化器,每个变量声明之后都会被自动初始化为零内存对应的值,这就是零值。零值与其类型有关:数值类型为0,指针为nil,切片、映射、管道等也同样(为nil)。
始终将值设置为已知默认值,对于程序的安全性和正确性非常重要,并且可以使 Go 程序更简单,更紧凑。这就是 Go 程序员在说“给您的结构一个有用的零值”时所表达的意思。
我们来看sync.Mutex
这类型。它有两个未导出的整数型字段,表示互斥锁的内部状态。由于零值,无论何时一个sync.Mutex类型变量被声明后,这些字段都将被设置为0。sync.Mutex类被故意地编码为这样,使得它无需被显式初始化即可使得零值有意义。
type MyInt struct {
mu sync.Mutex
val int
}
func main() {
var i MyInt
// i.mu is usable without explicit initialisation.
i.mu.Lock()
i.val++
i.mu.Unlock()
}
复制代码
Austin Luo:原文为“useful”,我在此译为“有意义”而不是“有用”,意在强调其零值是符合业务的、符合逻辑的,并且也是初始的、默认的,而不是“不用管它,让它为零好了”。 这与变量的命名也息息相关,比如: isCacheEnabled bool // 缓存是否被启用 isCacheDisabled bool // 缓存是否被禁用 对于上述两个变量,看起来都差不多,随意定义其中一个即可,唯一的差别只是一个表示启用一个表示禁用而已。但是结合考虑“业务要求默认启用缓存”和“bool 的零值为 false”,那么显然我们应该定义isCacheDisabled bool而不是前者。一方面,调用者不显式赋值时默认零值为false,另一方面值为false时表达的含义与业务要求默认启用缓存一致。 这才使得零值真正地有意义,正如示例中注释的那行i.mu一样,不显示初始化其代表的是默认锁是可用的。
另一个有意义零值的类型示例是bytes.Buffer。您可以无需显式初始化地声明bytes.Buffer然后立即开始向它写入数据。
func main() {
var b bytes.Buffer
b.WriteString("Hello, world!\n")
io.Copy(os.Stdout, &b)
}
复制代码
切片的一个有用性质是它的零值为nil,我们只需要去看看切片的运行时定义即可理解它的合理性:
type slice struct {
array *[...]T // pointer to the underlying array
len int
cap int
}
复制代码
此结构的零值将暗示len和cap的值为0,并且指向内存的指针array,保存切片背后数组的内容,其值也为nil。这意味着您不需要显式make切片,您只需声明它即可。
func main() {
// s := make([]string, 0)
// s := []string{}
var s []string
s = append(s, "Hello")
s = append(s, "world")
fmt.Println(strings.Join(s, " "))
}
复制代码
NOTE:var s []string看起来和上面被注释掉的两行很像,但又不完全相同。要判断值为nil的切片和长度为零的切片的区别是可以办到的,下面的代码将输出false:
func main() {
var s1 = []string{}
var s2 []string
fmt.Println(reflect.DeepEqual(s1, s2))
}
复制代码
一个意外但是有用的惊喜是未初始化的指针——nil
指针,您可以在nil
值的类型上调用方法,这可以简单地用于提供默认值。
type Config struct {
path string
}
func (c *Config) Path() string {
if c == nil {
return "/usr/home"
}
return c.path
}
func main() {
var c1 *Config
var c2 = &Config{
path: "/export",
}
fmt.Println(c1.Path(), c2.Path())
}
复制代码
4.5避免包级别的状态
编写可维护的程序的一个关键方面是松耦合——更改一个包,应该把对没有直接依赖它的包的影响降到最低。
在 Go 中有两种很好的方法可以实现松散耦合:
- 使用接口来描述函数或方法所需的行为。
- 避免使用全局状态。
在 Go 中,我们可以在函数或方法范围内声明变量,也可以在包的范围内声明变量。当变量是公开的,标识符首字母为大写,那么其范围实际上是整个程序——任何包都可以在任何时候观察到它的类型和存储的内容。
可变的全局状态在程序的独立部分之间引入了紧耦合,因为全局变量对于程序中的每个函数都是隐匿的参数!如果全局变量的类型变化了,那么任何依赖该变量的函数将会被打破。程序其他任何部分对变量值的修改,都将导致依赖该变量状态的函数被打破。
Austin Luo:全局变量对每个函数都是可见的,但开发者可能意识不到全局变量的存在(即隐匿的参数),即使意识到并使用了全局变量,也可能意识不到该变量可能在别处被修改,导致全局变量的使用不可靠,依赖该变量状态(值)的函数被打破。
如果您想减少全局变量带来的耦合,那么:
- 将相关变量作为字段移动到需要它们的结构上。
- 使用接口来减少行为与该行为的实现之间的耦合。
5 项目结构
让我们来看看多个包合并在一起组成项目的情况。通常这应该是一个单独的 git 仓库,但在将来, Go 开发者将交替使用 module
和 project
。
和包一样,每个项目也应该有一个清晰的目的。如果您的项目是个库,那么它应该只提供一个东西,比如 XML 解析,或者日志记录。您应该避免将多个不同的目的混杂在同一个项目中,这有助于避免common
库的出现。
小窍门:根据我的经验,
common
库与其最大的消费者(使用者)紧密相连,这使得在不锁定步骤的情况下单独升级common
或者消费者以进行升级或者修复变得很困难,从而带来很多不相关的更改和 API 破坏。
如果您的项目是一个应用程序,比如您的 Web 应用,Kubernetes 控制器等等,那么在您的项目中可能有一个或多个 main
包。比如,我维护的那个 Kubernetes 控制器里有一个单独的 cmd/contour
包,用来提供到 Kubernetes 集群的服务部署,以及用于调试的客户端。
5.1 考虑更少、更大的包
对于从其他语言过渡到 Go 的程序员来说,我倾向于在代码审查中提到的一件事是,他们倾向于过度使用包。
Go 没有提供建立可见性的详细方法:比如 Java 的 public
、protected
、private
和隐式 default
访问修饰符,也没有相当于 C++ 的friend
类的概念。
在 Go 中我们只有两种访问修饰符,公开和私有,这由标识符首字母的大小写决定。如果标识符是公开的,命名首字母就是大写的,则这个标识符可以被其他任何 Go 包引用。
注意:您可能听到有人说导出和非导出,那是公开和私有的同义词。
鉴于对包里的符号可见性控制手段的有限,Go 程序员要怎么做才能避免创建过于复杂的包层次结构呢?
小窍门:除
cmd/
和internal/
之外,每个包都应该包含一些源代码。
我反复建议的是偏向更少、更大的包。您的默认选项并不是创建新的包,那将导致为了创建宽而浅的 API 平面时您不得不公开太多的类型。
接下来的几节让我们更详细地探讨这些建议。
小窍门:来自 Java? 如果您有开发 Java 或 C# 的背景,考虑这样的经验规则:一个 Java 包等效于一个独立的
.go
源文件;一个 Go 包等效于整个 Maven 模块或 .NET 程序集。
5.1.1 通过 import 语句将代码整理到多个文件中
如果您根据包提供给调用者的功能来整理包,那么在 Go 包里整理源文件是不是也应该按相同的方式?您如何知道什么时候您应该将一个 .go
文件拆分成多个文件?您如何知道是不是过分拆分,而应当考虑整合多个 .go
文件?
这里是我用到的一些经验规则:
- 从单一的
.go
文件开始,并且使用与包相同的名字。比如包http
的第一个文件应该是http.go
,并且放到名为http
的文件夹中。 - 随着包的逐渐增长,您可以根据职责拆分不同的部分到不同的文件。例如,将
Request
和Response
类型拆分到message.go
中,将Client
类型拆分到client.go
中,将Server
类型拆分到server.go
中。 - 如果您发现您的文件具有很相似的 import 声明时,考虑合并它们,否则确定两者的具体差异并优化重构它们。
- 不同的文件应该负责包的不同区域。
messages.go
可能负责网络相关的 HTTP 请求和响应编组,http.go
可能包含低级网络处理逻辑,client.go
和server.go
实现 HTTP 请求创建或路由的业务逻辑,等等。
小窍门:源文件名应当考虑名词。 注意:Go 编译器并行编译各个包。在包中,Go 编译器并行地编译各个函数(方法在 Go 中只是花哨的函数)。修改包源码中代码的排列分布不影响编译时间。
5.1.2 内部测试优于外部测试
Go 工具集允许您在两处编写包的测试。假设您的包名是 http2
,您可以使用 package http2
声明并编写一个 http2_test.go
文件,这样做将会把 http2_test.go
中的代码当成 http2 包的一部分编译进去。这通常称为内部测试。
Go 工具集同样支持一个以 test 结尾的特定声明的包,例如 package http_test
,即使这些测试代码不会被视为正式代码一样编译到正式的包里,并且他们有自己独立的包名,也允许您的测试文件和源码文件一样放置在一起。这允许让您像在外部另外一个包里调用一样编写测试用例,这我们称之为外部测试。
在编写单元测试时我推荐使用内部测试。这让您可以直接测试每个函数或方法,避免外部测试的繁文缛节。
但是,您应该把 Example
测试函数放到外部测试中。这确保了在 godoc 中查看时,示例具有适当的包前缀,并且可以轻松地进行复制粘贴。
小窍门:避免复杂的包层次结构,克制分类的渴望 只有一个例外,这我们将在后面详述。对于 Go 工具集来讲,Go 包的层次结构是没有意义的。例如,
net/http
并不是net
的子或子包。 如果您创建了不包含任何.go
文件的中间目录,则不适用此建议。
5.1.3 使用 internal
包收敛公开的 API 表面
如果您的项目包含多个包,则可能有一些导出的函数——这些函数旨在供项目中的其他包使用,却又不打算成为项目的公共 API 的一部分。如果有这样的情况,则 go 工具集会识别一个特殊的文件夹名——非包名—— internal/
,这用于放置那些对当前项目公开,但对其他项目私有的代码。
要创建这样的包,把代码放置于名为 internal/
的目录或子目录即可。 go 命令发现导入的包中包含 internal
路径,它就会校验执行导入的包是否位于以 internal
的父目录为根的目录树中。
例如,包 .../a/b/c/internal/d/e/f
只能被根目录树 .../a/b/c
中的代码导入,不能被 .../a/b/g
或者其他任何库中的代码导入。^5
5.2确保 main 包越小越好
main
函数和 main
包应当只做尽可能少的事情,因为 main.main
实际上是一个单例,整个应用程序都只允许一个 main
函数存在,包括单元测试。
由于 main.main
是一个单例,因此 main.main
的调用中有很多假定,而这些假定又只在 main.main
或 main.init
期间调用,并且只调用一次。这导致很难为 main.main
中的代码编写单元测试,因此您的目标应该是将您的业务逻辑从主函数中移出,最好是压根从主程序包中移出。
Austin Luo:这里主要是讲,由于整个程序(包括单元测试在内)只允许存在一个
main.main
,因此在main.main
中编写过多的代码将导致这些代码很难被测试覆盖,因此应当将这些代码从main.main
中——甚至从main
包中——独立出来,以便能够写单元测试进行测试。(文中的“假定”是针对测试而言,“假定” main 中的代码可以正常运行。) 小窍门:main
应当解析标识,打开数据库连接,初始化日志模块等等,然后将具体的执行交给其他高级对象。
6 API设计
今天给出的最后一个设计建议是我认为最重要的一个。
到此为止我给出的所有建议,也仅仅是建议。这是我写 Go 程序时遵守的方式,但也并没有强制推行到代码评审中。 但是,在审查 API 时,我就不太宽容了。因为之前我所说的一切都可以在不破坏向后兼容性的情况下得到修正,他们大多只是实施细节而已。
但说到包的开放 API,在初始设计中投入大量精力是值得的,因为后续的更改将是破坏性的,特别是对于已经使用 API 的人来说。
6.1 设计难以被误用的 API
APIs should be easy to use and hard to misuse. — Josh Bloch ^3 API 应当易用并且难以被误用 如果您从这个演讲中获得任何收益,那就应该是 Josh Bloch 的这个建议。如果 API 很难用于简单的事情,那么 API 的每次调用都会很复杂。当 API 的实际调用很复杂时,它将不那么明显,更容易被忽视。
6.1.1 警惕具有多个相同类型参数的函数
一个看起来很简单,但实际很难正确使用的 API 的例子,就是具有两个及以上的相同类型参数的情况。让我们来对比如下两个函数签名:
func Max(a, b int) int
func CopyFile(to, from string) error
复制代码
这两个函数有什么不同?很显然一个是返回两个数的最大数,另一个是复制文件,但这都不是重点。
Max(8, 10) // 10
Max(10, 8) // 10
复制代码
Max
是可交换的,参数的顺序无关紧要,8 和 10 比较无论如何都是 10 更大,不论是 8 与 10 比较,还是 10 与 8 比较。
但是,对于 CopyFile 就不具有这样的特性了:
CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
复制代码
哪条语句将 presentation.md 复制了一份,哪条语句又是用上周的版本覆盖了 presentation.md ?没有文档说明,您很难分辨。代码评审者在没有文档时也对您参数传入的顺序是否正确不得而知。
一个可行的解决方案是,引入一个帮助类,用来正确地调用 CopyFile
:
type Source string
func (src Source) CopyTo(dest string) error {
return CopyFile(dest, string(src))
}
func main() {
var from Source = "presentation.md"
from.CopyTo("/tmp/backup")
}
复制代码
这样 CopyFile
就总是可以被正确地调用——这也可以通过单元测试确定,也可以被设置为私有,进一步降低了误用的可能性。
小窍门:具有多个相同类型参数的 API 很难被正确使用。
6.2 针对默认用例设计 API
几年前我做过一次关于使用功能选项^7使 API 在默认用例时更易用的报告^6。 本演讲的主旨是您应该为常见用例设计 API。另一方面,您的 API 不应要求调用者提供那些他们不关心的参数。
#####6.2.1 不鼓励使用 nil
作为参数
我讲述本章开宗明义时建议您不要强迫 API 的调用者在他们不关心这些参数意味着什么的情况下为您提供那些参数。当我说针对默认用例的设计 API 时,这就是我的意思。
这里有个来自 net/http
包的示例:
package http
// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
复制代码
ListenAndServe
有两个参数,一个 TCP 地址用来监听传入连接,一个 http.Handler
用来处理传入的 HTTP 请求。Serve
允许第二个参数为 nil
,并且注意,调用者通常都会传入 nil
用来表示他们希望使用 http.DefaultServeMux
作为隐式参数。
现在Serve
的调用者就有两个方式来做同样的事情:
http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
复制代码
两个方式都做完全一样的事情。
这种 nil
的行为是病毒式的。在 http
包中同样有个 http.Serve
帮助类,您可以合理地想象 ListenAndServe
是这样建立的:
func ListenAndServe(addr string, handler Handler) error {
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer l.Close()
return Serve(l, handler)
}
复制代码
因为ListenAndServe
允许调用者为第二个参数传递nil
,所以http.Serve
也支持这种行为。事实上,http.Serve
是“当 handler
为 nil
,则使用 DefaultServeMux
”这个逻辑的一个实现。允许其中一个参数传入 nil
可能导致调用者以为他们可以给两个参数都传入 nil
(Austin Luo:调用者可能想,既然第二个参数有默认实现,那第一个参数可能也有),但像这样调用:
http.Serve(nil, nil)
复制代码
将导致一个丑陋的 panic 。
小窍门:在函数签名中不要混用可为
nil
和不可为nil
的参数。
http.ListenAndServe
的作者尝试在常规状况时让 API 的使用者更轻松,但可能反而导致这个包难于被安全地使用。
显示地指定 DefaultServeMux
或隐式地指定nil
,并没有在代码行数上带来不同。
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", nil)
复制代码
相较于
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
复制代码
并且,仅仅为了节省一行代码,这样的混乱是值得的吗?
const root = http.Dir("/htdocs")
mux := http.NewServeMux()
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)
复制代码
小窍门:认真考虑帮助类将节省程序员的时间。清晰比多个选择好。 小窍门:避免公开只用于测试的参数 避免公开导出仅在测试作用域上具有不同值的 API。相反,使用 Public 包装隐藏这些参数,使用在测试作用域的帮助类来设置测试范围中的属性。
6.2.2 首选可变参数(var args)而非切片参数
编写一个处理切片的函数或方法是很常见的:
func ShutdownVMs(ids []string) error
复制代码
这仅仅是我举的一个例子,但在我工作中更加常见。像这样的签名的问题是,他们假设被调用时会有多个实体。但是,我发现很多时候这些类型的函数却只有一个参数,为了满足函数签名的要求,它必须在一个切片内“装箱”。(Austin Luo:如示例,函数定义时预期会有多个 id,但实际调用时往往只有一个 id,为了满足前面,必须构造一个切片,并把 id 装进去。)
此外,由于 ids
是个切片,您可以向函数传入一个空的切片甚至 nil
,编译器也会允许。这就增加了更多的测试用例,因为您应当覆盖这些场景。
为构造一个这类型的 API 的例子,最近我重构了一条逻辑,如果一组参数中至少有一个非零则要求我设置一些额外的字段。这段逻辑看起来像这样:
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
// apply the non zero parameters
}
复制代码
鉴于 if 语句变得非常长,我想将这个校验放到单独的函数中,这是优化的结果:
// anyPostive indicates if any value is greater than zero.
func anyPositive(values ...int) bool {
for _, v := range values {
if v > 0 {
return true
}
}
return false
}
复制代码
这使我能够向读者明确执行内部块的条件:
if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
// apply the non zero parameters
}
复制代码
但对于 anyPositive
还是有一个问题,有人可能会意外地像这样调用它:
if anyPositive(){...}
复制代码
在这种情况下anyPositive
会返回false
,因为它将不会执行迭代并立即返回false
。这还不是世界上最糟糕的事情——(更糟糕的是)没有传入参数时这段代码的逻辑将会变成“anyPositive
是否返回true
?”。
然而,假如可以这样那就更好了:更改 anyPositive
的签名,使得强制调用者应该传递至少一个参数。我们可以像这样组合常规参数和可变参数:
func anyPositive(first int, rest ...int) bool {
if first > 0 {
return true
}
for _, v := range rest {
if v > 0 {
return true
}
}
return false
}
复制代码
现在anyPositive
的调用就不能少于一个参数了。
6.3 让函数自身定义它所需的行为
假设我们有个将文档保存写入磁盘的工作任务。
// Save将doc的内容写入文件f。
func Save(f * os.File,doc * Document)错误
复制代码
我可以这样描述这个函数,Save
,它以一个 *os.File
作为目标来保存写入 Document
。但这有一些问题。
签名 Save
排除了将数据写入网络位置的可能。假设网络存储成为后续的需求,可能不得不更改函数签名,从而影响其所有调用者。
Save
也对测试不友好,因为这是直接对磁盘的文件进行操作。因此,为了验证其操作,测试用例不得不在文件被写入之后重新去读取写入的内容。而且我还必须确保 f
最终从临时位置被删除。
同时 *os.File
也定义了很多与 Save
无关的方法,比如读取目录,检查一个路径是否为符号链接等。如果 Save
函数的签名只描述 *os.File
的一部分就更好了。
我们可以怎么做呢?
// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
复制代码
使用 io.ReadWriteCloser
我们可以遵循接口隔离原则重新定义 Save
,从而获得一个更常规的文件操作接口。
有了这个改变,io.ReadWriteCloser
接口的任何实现都可以替代前文的 *os.File
。
这使得 Save
的应用更加广泛,并且向 Save
的调用者澄清了哪些 *os.File
类型的方法与其操作相关。
并且,作为 Save
函数作者,我不再能调用 *os.File
其他那些不相关方法,它们都被 io.ReadWriteCloser
接口隐藏到了背后。
我们可以针对接口隔离原则谈得更深入些。
首先,如果 Save
遵循单一职责原则,它不太可能读取它刚刚编写的文件以校验其内容——这应该是另一段代码的责任。
// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
复制代码
因此,我们可以将传递给 Save
的接口缩小到只是写和关闭两个方面。
其次,通过 Save
附带提供一种关闭其流的机制(Austin Luo:由于 io.WriteCloser
的存在,Save
隐含了关闭流的含义)。我们继承了这种机制,使其仍然看起来像一个文件,这就提出了在什么情况下 wc
会被关闭的问题。
可能 Save
会无条件地调用 Close
,或者在成功的情况下才调用 Close
。
这给 Save
的调用者带来一个问题,那就是希望在写入文档之后再向数据流写入其他数据时怎么办?
// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
复制代码
一个更好的解决方案是,重新定义 Save
,只持有一个 io.Writer
,将除了向数据流写入数据之外的其他所有职责都完全剥离出来。
通过在 Save
函数上遵循接口隔离原则,其结果是实际需求的最核心描述同时作为一个函数——它只需要一个可写的对象——并且是最通常的情况,我们现在可以使用 Save
来向任何 io.Writer
的实现保存数据。
7 错误处理
我已经做了好几场关于错误处理的演讲,在我的博客里也写了很多相关的内容,昨天的那一节我也讲了很多了,因此我不打算再赘述了。
- dave.cheney.net/2014/12/24/…
- dave.cheney.net/2016/04/07/… 相反,我想谈关于错误处理的其它两个方面。
7.1 通过消除错误来消除错误处理
您昨天可能听了我的讲演,我谈到了关于改进错误处理的建议草案。但是您知道有什么是比改进错误处理语法更好的吗?那就是根本不用处理错误。
注意:我并不是说“移除您的错误处理”。我建议的是,修改您的代码,从而无需处理错误。 本节是从 John Ousterhout 的新书《A philosophy of Software Design》^9中得到的启示。其中一章是“Define Errors Out of Existence”,我们来把这个建议放到 Go 中来看看。
7.1.1 统计行数
让我们来写一个统计文件行数的函数。
func CountLines(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)
for {
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
}
if err != io.EOF {
return 0, err
}
return lines, nil
}
复制代码
由于我们要遵循上一节的建议,CountLines
持有了一个 io.Reader
,而非 *File
——提供要计数内容的 io.Reader
是调用者的职责。
我们构造了一个bufio.Reader
,并将它放到循环中调用ReadString
方法,累加一个计数器,直到文件末尾,然后我们返回读取到的行数。
至少这是我们期望的的代码,但这个函数因为错误处理变得更加复杂。例如,这里有个奇怪的结构:
_, err = br.ReadString('n')
lines++
if err != nil {
break
}
复制代码
我们在判断错误之前累加了计数——这看起来很怪异。
我之所以写成这样,是因为ReadString
在遇到换行符之前如果遇到文件结尾则会返回一个错误,如果文件中没有最终换行符,则会发生这种情况。
为了修复这个问题,我们重新排列逻辑以累加行数,然后查看是否需要退出循环。
注意:这个逻辑依然不够完美,您能发现 bug 吗?
错误还没有检查完毕。ReadString
在遇到文件末尾时会返回io.EOF
。这是符合预期的,ReadString
需要某种方式“叫停,后面没有更多的东西可读取了”。因此在我们向CountLine
的调用者返回错误之前,我们需要检查错误不是io.EOF
,并且在这种情况下才将其进行传播,否则我们返回 nil
说一切正常。
Russ Cox 觉察到错误处理可能会模 糊函数操作,我想这就是个很好的例子。让我们来看一个优化的版本:
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
复制代码
这个优化的版本选择使用 bufio.Scanner
而不是 bufio.Reader
。
在 bufio.Scanner
的封装下使用 bufio.Reader
,但它提供了一个很好的抽象层,帮助我们移除了 CountLines
操作模糊不清的错误。
注意:
bufio.Scanner
可以根据任何模式扫描,但默认只查找换行。sc.Scan()
这个方法,在匹配到一行文本并且没有遇到错误时会返回true
,因此,for
循环会在遇到文件结尾或者遇到错误时退出。类型bufio.Scanner
会记录它遇到的第一个错误,一旦退出,我们可以使用sc.Err()
方法获取到这个错误。 最后,sc.Err()
会合理处理io.EOF
,并且在遇到文件结尾但没有其他错误时,将错误转化为nil
。 小窍门:当您发现自己遇到难以消除的错误时,请尝试将某些操作提取到帮助类中。
7.1.2 写入响应
我的第二个例子受到了博客文章“Errors are values”^10的启发。
之前的讲演中我们已经看过如何打开、写入和关闭文件。错误处理还存在,但不是那么难以消除,我们可以使用 ioutil.ReadFile
和 ioutil.WriteFile
来封装。但是当我们处理低级别的网络协议时,有必要通过 I/O 来构建响应,这就让错误处理可能变得重复。考虑构建 HTTP 响应的 HTTP 服务器的这个片段:
type Header struct {
Key, Value string
}
type Status struct {
Code int
Reason string
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
if err != nil {
return err
}
for _, h := range headers {
_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}
if _, err := fmt.Fprint(w, "\r\n"); err != nil {
return err
}
_, err = io.Copy(w, body)
return err
}
复制代码
首先我们使用 fmt.Fprintf
构造了状态行并且检查了错误。然后为每个请求头写入键和值,同样检查了错误。最后我们使用 rn
终结了请求头这一段,仍然检查了错误。接下来复制响应体到客户端。最后,尽管我们不用检查 io.Copy
的错误,但我们也需要将 io.Copy
的双返回值转换为 WriteResponse
所需的单返回值。
这有太多的重复工作了。我们可以通过引入一个小的封装类 errWriter
来让这件事变得更容易。
errWriter
满足 io.Writer
的契约,因此它可以用来包装现有的 io.Writer
。errWriter
将写入传递给底层的 Writer,直到检测到错误,从这开始,它会丢弃任何写入并返回先前的错误。
type errWriter struct {
io.Writer
err error
}
func (e *errWriter) Write(buf []byte) (int, error) {
if e.err != nil {
return 0, e.err
}
var n int
n, e.err = e.Writer.Write(buf)
return n, nil
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
ew := &errWriter{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
for _, h := range headers {
fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
}
fmt.Fprint(ew, "\r\n")
io.Copy(ew, body)
return ew.err
}
复制代码
使用 errWriter
替换 WriteResponse
可以显着提高代码的清晰度。每个操作不再需要用错误检查来自我修复。通过检查 ew.err
字段来将报告错误移动到函数的末尾,同时也避免因为 io.Copy 的多返回值而引起恼人的转换。
7.2 错误只处理一次
最后,我想提一下您应该只处理一次错误。处理错误意味着检查错误值并做出单一决定。
// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
w.Write(buf)
}
复制代码
如果错误您一次都不处理,那您就忽略了它。就像我们看到的这样,w.WriteAll
的错误完全被丢弃了。
但是对单一错误做出多次处理决定,也是有问题的。以下是我经常遇到的代码。
func WriteAll(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
log.Println("unable to write:", err) // annotated error goes to log file
return err // unannotated error returned to caller
}
return nil
}
复制代码
在这个示例中,如果 w.Write
产生了一个错误,则会在日志文件中写一行日志,记录错误发生的文件和代码行,并且错误又同时被返回给了调用者,调用者又可能去记录日志,继续返回,直至回溯到程序的顶部。
调用者可能也会做同样的事,
func WriteConfig(w io.Writer, conf \*Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("could not marshal config: %v", err)
return err
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}
复制代码
到头来在您的日志中会出现重复的行,
unable to write: io.EOF
could not write config: io.EOF
复制代码
但在程序的顶部,您得到了一个没有上下文的原始错误,
err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
复制代码
我想进一步深入研究这一点,因为我不认为记录并且返回错误仅仅是个人偏好的问题。
func WriteConfig(w io.Writer, conf \*Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("could not marshal config: %v", err)
// oops, forgot to return
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}
复制代码
我看到的很多问题是程序员忘记在错误处返回。正如我们之前谈到的那样,Go 风格应当使用保护条款,检查函数进行下去的前提条件,并提前返回。
在这个示例中,作者处理了错误,记录了日志,但忘记返回,这将导致一个难以觉察的 bug。
在 Go 的错误处理契约中,如果出现错误,您不能对其他返回值的内容做出任何假设。就像上例中如果 JSON 反序列化失败,buf
的内容未知,可能什么都不包含,但包含了 1/2 的 JSON 片段会更糟糕。
因为程序员在检查和日志记录了错误之后忘记返回,一个混乱的缓冲区被传递给了 WriteAll
,它又可能执行成功,这样配置文件就会被错误地覆盖了。但此时函数会正常返回,并且发生问题的唯一迹象只是单个日志行记录了 JSON 编码失败,而不是编写配置文件失败。
7.2.1 向错误添加上下文
这个 bug 的发生是因为作者尝试向错误消息添加上下文信息。他们试图给自己留下一个线索,指引他们回到错误的源头。
让我们看看使用 fmt.Errorf
来做同样的事的另一种方法。
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
return fmt.Errorf("could not marshal config: %v", err)
}
if err := WriteAll(w, buf); err != nil {
return fmt.Errorf("could not write config: %v", err)
}
return nil
}
func WriteAll(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
return fmt.Errorf("write failed: %v", err)
}
return nil
}
复制代码
通过将错误的注释与返回组合到一行,则以就更难以忘记返回错误,从而避免意外继续。
如果写文件时发生一个 I/O 错误,错误对象的 Error()
方法将会报告如下信息:
could not write config: write failed: input/output error
7.2.2 使用 github.com/pkg/errors
包装错误
fmt.Errorf
模式适用于提示错误信息,但其代价是原始的错误类型被掩盖了。我认为,将错误视为不透明的值对于生成松散耦合的软件很重要,所以如果对错误值所做的唯一事情是如下两个方面的话,则原始错误是什么类型就无关紧要了。
- 检查是否为
nil
- 打印或记录日志
但是,在某些场景,可能并不常见,您确实需要恢复原始错误。在这种情况下,您可以使用类似我的 errors
包来备注这样的错误。
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}
func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.WithMessage(err, "could not read config")
}
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
复制代码
现在报告的错误将会是很好的 K&D ^11 风格的错误: could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
并且错误值保留了对原始原因的引用。
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
fmt.Printf("stack trace:\n%+v\n", err)
os.Exit(1)
}
}
复制代码
从而您可以恢复原始的错误,并且打印其堆栈:
original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
/Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
/Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
/Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
/Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config
复制代码
使用 errors
包让您得可以以人和机器都能检测到的方式向错误添加上下文。如果您昨天来看了我的讲演,就会知道 error
的包装正在进入即将发布的 Go 版本的标准库。
8 并发
我们选择 Go 开发项目通常是因为其并发的特性。Go 团队已经竭尽全力使 Go 中的并发性廉价(在硬件资源方面)并具有高性能,但是使用 Go 的并发性写出既不高性能也不可靠的代码仍然是可能的。在我即将离开的时候,我想留下一些关于避免并发特性带来的陷阱的建议。
Go 特性支持的第一类并发是针对通道、select
语句和 go
语句的。如果你从书籍或者培训课程中正式地学习过,你可能注意到并发这一节总是在最后才会讲到。这里也不例外,我选择最后才讲并发,好像它是对于 Go 程序员来说应该掌握的常规技能之外的附加部分。
这有两个方面。一方面 Go 的主旨是简单、轻量的并发模型。作为一个产品,我们的语言几乎只靠这方面进行兜售。另一方面,有一种说法认为并发实际上并不容易使用,否则作者并不会放到一本书的最后一章,我们回首我们之前的努力时也不会带有遗憾。
本节讨论使用 Go 原生的并发特性时的一些陷阱。
8.1保持自己忙碌,否则自己做
这段程序有什么问题?
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
go func() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}()
for {
}
}
复制代码
这段代码按我们预期在执行,他开启了一个简单的 Web 服务。但它同时又干了些别的事情,那就是在一个无限循环中浪费 CPU。这是因为main
的最后一行for {}
循环阻塞了主的协程,因为它不做任何输入输出,也不等待锁,也不在通道上做发送或接收,或以其他方式与调度程序通信。
由于 Go 运行时主要是协同安排的,因此该程序将在单个 CPU 上无效地循环,并且可能最终被实时锁定。
我们要怎么修复它呢?这里有个建议:
package main
import (
"fmt"
"log"
"net/http"
"runtime"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
go func() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}()
for {
runtime.Gosched()
}
}
复制代码
这可能看起来很愚蠢,但这是我看到的最通常的解决方案。这是不了解根本问题的症结所在。
现在,如果你对 Go 稍有经验,你可能写成这样:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
go func() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}()
select {}
}
复制代码
任何一个空的 select
语句都会永远阻塞在那。这是个很有用的性质,因为现在我们不想仅仅因为调用runtime.GoSched()
就让整个 CPU 都“旋转”起来。但这样做,我们只治了标,没有治本。
我想向你提出另一种解决方案,希望这一方案已经被采用了。与其让http.ListenAndServe
在一个协程中执行并带来一个“主协程中应该做什么”的问题,不如简单地由主协程自己来执行http.ListenAndServe
。
小窍门:Go 程序的
main.mian
函数退出,则 Go 程序都会无条件退出,不论其他协程正在做什么。
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r \*http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
复制代码
总之,这是我的第一个简易:如果你的协程在其他协程返回结果之前什么事都不能干,通常就应该直接了当地自己做这件事,而不是委托其他协程去做。 这通常也消除了将结果从协程引导回其发起者所需的大量状态跟踪和通道操作。
小窍门:许多 Go 程序员滥用协程,特别是初学者。与生活中的所有事情一样,适度是成功的关键。
8.2 将并发留给调用者
下面两个 API 的区别是什么?
// ListDirectory returns the contents of dir.
func ListDirectory(dir string) ([]string, error)
// ListDirectory returns a channel over which
// directory entries will be published. When the list
// of entries is exhausted, the channel will be closed.
func ListDirectory(dir string) chan string
复制代码
首先,显著的区别是,第一个例子读取目录到切片,然后将整个切片返回,否则如果有问题则返回一个错误。这是同步发生的,ListDirectory
的调用者将被阻塞直到整个目录被读完。依赖于目录有多大,这个过程可能持续很长时间,也可能因为构建一个目录条目名称的切片,而分配大量的内存。
让我们来看看第二个例子。这更一个更像 Go,ListDirectory
返回了一个传输目录条目的通道,当通道关闭时,表明没有更多目录条目了。由于通道信息发生在ListDirectory
返回之后,ListDirectory
内部可能开启了一个协程。
注意:第二个版本实际上没有必要真的使用一个协程。它可以分配一个足以保存所有目录条目而不阻塞的通道,填充通道,关闭它,然后将通道返回给调用者。但这不太可能,因为这会消耗大量内存来缓冲通道中的所有结果。
通道版本的ListDirectory
还有两个进一步的问题:
- 通过使用通道的关闭作为没有更多项目要处理的信号,
ListDirectory
在中途遇到错误就无法告知调用者返回的集合是不完整的。调用者也无法区分空目录和一读取就产生错误的情况,这两种结果对于ListDirectory
返回的通道来说都是立即关闭。 - 调用者必须持续读取通道的内容直到通道关闭,因为这是让调用者知道协程已经结束的唯一办法。这是对
ListDirectory
的使用的一个严重限制。调用者必须花时间从通道读取数据,哪怕调用者已经接收到它想要的信息。就需要使用大量内存的中型到大型目录而言,它可能更有效,但这种方法并不比原始的基于切片的方法快。
以上两个实现中的问题,其解决方案是使用回调。一个在每个目录条目上执行的函数。
func ListDirectory(dir string, fn func(string))
复制代码
毫不奇怪,filepath.WalkDir
就是这么做的。
小窍门:如果你的函数开启了一个协程,那么你必须给调用者提供一个停止协程的途径。将异步执行函数的决策留给该函数的调用者通常更容易。
8.3 不要启动一个永不停止的协程
上一个例子演示了没有必要的情况下使用协程。但使用 Go 的驱动原因之一是该语言提供的第一类并发功能。实际上,在许多情况下,您希望利用硬件中可用的并行性。为此,你必须使用协程。
这个简单的应用,在两个不同的端口上提供 http 服务,端口 8080 用于应用本身的流量,8081 用于访问 /debug/pprof
终结点。
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug
http.ListenAndServe("0.0.0.0:8080", mux) // app traffic
}
复制代码
尽管这个程序不是很复杂,但它代表了一个基本的真实应用程序。 随着应用程序的增长,有一些问题也显现出来,让我们现在来解决其中的一些。
func serveApp() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req \*http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
http.ListenAndServe("0.0.0.0:8080", mux)
}
func serveDebug() {
http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}
func main() {
go serveDebug()
serveApp()
}
复制代码
通过将 serveApp
和 serveDebug
的功能放到到各自的函数中,我们把他们从 main.main 分离出来。我们照样遵循了上面的建议,把 serveApp
和 serveDebug
的并发性留给了调用者。
但是这个程序有一些可操作性上的问题。如果serveApp
返回则main.main
会返回并导致程序关闭,最终由您正在使用的任何进程管理器重新启动。
小窍门:正如函数的并发性留给调用者一样,应用应该将状态监视、重启留给程序的唤起者。不要让你的应用程序担负重启自身的责任,这是一个最好从应用程序外部处理的过程。 但是,
serveDebug
是在另一个协程中执行的,如果它退出,也仅仅是这个协程自身退出,程序的其他部分将继续运行。由于/debug
处理程序停止工作,您的操作人员会很不高兴地发现他们无法在应用程序中获取统计信息。 我们要确保的是,负责服务此应用程序的任何协程停止,都关闭应用程序。
func serveApp() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil {
log.Fatal(err)
}
}
func serveDebug() {
if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil {
log.Fatal(err)
}
}
func main() {
go serveDebug()
go serveApp()
select {}
}
复制代码
现在我们通过必要时调用 log.Fatal
来检查 serverApp
和 serveDebug
从 ListenAndServe
返回的错误。由于两个处理器都是在协程中运行,我们使用 select{}
来阻塞主协程。
这种方法存在许多问题:
- 如果
ListenAndServe
返回一个nil
,log.Fatal
不会被调用,则对应的 HTTP 服务会停止,并且应用程序不会退出。 log.Fatal
会调用os.Exit
无条件终止进程,defer 不会被调用,其他协程不会被通知关闭,应用程序会停止。这会使得为这些函数编写测试用例变得很困难。
小窍门:只在
main.main
或init
函数里使用log.Fatal
。
我们需要的是,把任何错误都传回协程的发起者,以便于我们弄清楚为什么协程会停止,并且可以干净地关闭进程。
func serveApp() error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
return http.ListenAndServe("0.0.0.0:8080", mux)
}
func serveDebug() error {
return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}
func main() {
done := make(chan error, 2)
go func() {
done <- serveDebug()
}()
go func() {
done <- serveApp()
}()
for i := 0; i < cap(done); i++ {
if err := <-done; err != nil {
fmt.Println("error: %v", err)
}
}
}
复制代码
我们可以使用一个通道来收集协程返回的状态。通道的大小与我们要管理的协程数一致,从而使得向 done
通道发送状态时不会被阻塞,否则这将阻塞协程的关闭,导致泄漏。
由于没有办法安全地关闭 done
通道,我们不能使用 for range
循环通道知道所有协程都上报了信息,因此我们循环开启协程的次数,这也等于通道的容量。
现在我们有办法等待协程干净地退出,并且记录发生的日志。我们所需的仅仅是将一个协程的关闭信号,通知到其他协程而已。
其结果是,通知一个 http.Server
关闭这事被引入进来。所以我将这个逻辑转换为辅助函数。serve
帮助我们持有一个地址和一个 http.Handler
,类似 http.ListenAndServe
以及一个用于触发 Shutdown
方法的 stop
通道。
func serve(addr string, handler http.Handler, stop <-chan struct{}) error {
s := http.Server{
Addr: addr,
Handler: handler,
}
go func() {
<-stop // wait for stop signal
s.Shutdown(context.Background())
}()
return s.ListenAndServe()
}
func serveApp(stop <-chan struct{}) error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
return serve("0.0.0.0:8080", mux, stop)
}
func serveDebug(stop <-chan struct{}) error {
return serve("127.0.0.1:8001", http.DefaultServeMux, stop)
}
func main() {
done := make(chan error, 2)
stop := make(chan struct{})
go func() {
done <- serveDebug(stop)
}()
go func() {
done <- serveApp(stop)
}()
var stopped bool
for i := 0; i < cap(done); i++ {
if err := <-done; err != nil {
fmt.Println("error: %v", err)
}
if !stopped {
stopped = true
close(stop)
}
}
}
复制代码
现在,每当我们从 done
通道接收到一个值,就关闭 stop
通道,从而导致所有等待在这个通道上的协程关闭 http.Server
。这将导致所有剩余的 ListenAndServe
协程返回。一旦我们启动的协程停止,main.main
便返回继而进程干净地停止了。
小窍门:自己写这个逻辑是重复和微妙的。考虑类似这个包的东西,github.com/heptio/work… 它将为你完成大部分工作。
【完】
参考链接
- gaston.life/books/effec…
- talks.golang.org/2014/names.…
- www.infoq.com/articles/AP…
- www.lysator.liu.se/c/pikestyle…
- speakerdeck.com/campoy/unde…
- www.youtube.com/watch?v=Ic2…
- medium.com/@matryer/li…
- golang.org/doc/go1.4#i…
- dave.cheney.net/2014/10/17/…
- commandcenter.blogspot.com/2014/01/sel…
- dave.cheney.net/2016/04/27/…
- www.amazon.com/Philosophy-…
- blog.golang.org/errors-are-…
- www.gopl.io/
有疑问加站长微信联系(非本文作者)