0. 引子
阅读了Dave Cheney 关于go编码的博客:Practical Go: Real world advice for writing maintainable Go programs
实际应用下来,对我这个go入门者,提升效果显著。
我对作者的文章进行整理翻译,提取精炼,加上自己的理解,分享出来。希望也能给大家带来帮助。
希望大家支持原作者,原汁原味的内容可以点击 链接 阅读。文中部分例子为个人添加,如有不足敬请包容指出^ _ ^
(PS:如涉及侵权,请与我联系,我会及时删除文章,知识传播无界,望大家支持)
1. 指导原则
个人认为,编码的最佳实践本质是为了提高代码的迭代产能,减少bug的几率。(成本、效率、稳定)
作者Dave Cheney提到,go语言的最佳实践的指导原则,需要考虑3点:
- 简洁
- 可读性
- 开发效率
1.1 简洁
简洁是对于人而言的,如果代码很复杂,甚至违法人的惯性理解,那么修改和维护是牵一发而动全身的。
1.2 可读性
因为代码被阅读的次数远远多于被修改的次数。在作者看来,代码被人的阅读和修改的需求,比被机器执行的需求更强烈。go编码最佳实践第一步就应该确定代码的可读性。
在我个人看来,类似于一致性算法中, raft为什么比paxos传播和应用更广,一个很重要的原因就是raft更加易于理解,raft作者在论文中也提到,raft设计的最重要的初衷就是,paxos太难懂了。可读性的重要性应该排在首位的。
1.3 开发效率
良好的编码习惯,可以提高代码的交流效率。使得同事们看到代码就知道实现了什么,而不必去逐行阅读,大大节约了时间,提高开发效率。
此外,对于go语言本身而言,无论在编译速度还是debug时间花费上,go相对C++也是开发效率大大提高的。
2. 命名
命名对编写可读性好的go程序至关重要!
曾经听到这样的一个言论:对变量的命名要像给自己孩子起名一样慎重。
其实,不光是变量命名,还包括function、method、type、package等,命名都很重要。
2.1 选择辨识度高的名字,而不是选择简短的名字
就像编码不是为了在尽量短的行数内,写完程序。而是为了写出可读性高的程序。
同样的,我们的命名标识也不是越短越好,而是容易被他人理解。
一个好名字应该具备的特点:
简短:一个好名字应该在具备高辨识度的情况下,尽量简短。
- 比如一个判断用户登录权限的方法:坏名字是
judgeAuth
(容易歧义),judgeUserLoginAuthority
(冗长) - 好的例子
judgeLoginAuth
- 比如一个判断用户登录权限的方法:坏名字是
描述性的:一个好的名字应该是描述变量和常量的用途,而非他们的内容;描述function的结果,或者method的行为,而不是他们的操作;描述package的目的,而非包含的内容。描述的准确性衡量了名字的好坏。
- 比如设计一个用来主从选举的包。坏的package名字
leader_operation
,好的名字election
- 坏的function或者method名字
ReturnElection
,好的名字NewElection
- 坏的变量或者常量名字
ElectionState
,好的名字Role
- 比如设计一个用来主从选举的包。坏的package名字
可预测的:一个好的名字,仅通过名字,大家就可以推断他们的用途。应该遵循大家的惯用理解。下面会详细阐述。比如
-
i,j,k
常用来在迭代中描述引用计数值 -
n
通常用来表示计数累加值 -
v
通常表示一个编码函数的值 -
k
通常用在map中的key -
s
通常用来表示字符串
-
2.2 命名的长度
关于名字的长度,我们有这些建议:
- 如果变量的声明和它被最后一次使用的距离很短,可以使用短的变量名
- 如果一个变量很重要,那么可以避免歧义,允许变量名称长一些,消除歧义
- 变量的名字中请不要包含变量的类型名
- 常量的名字应该描述他们保存的值,而不是如何使用该值
- 单个字母的名字可以用作迭代、逻辑分支判断、参数和返回值。包和函数的名字请使用多个字母的组合。
- method、interface、package 请使用单个单词
- pakcage名字也是调用方引用时需要注明的,所以请利用package的名字
举一个作者文中的例子说明:
type Person struct {
Name string
Age int
}
// AverageAge returns the average age of people.
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
}
在这个例子中,people 距离最后一次使用间隔7行,而变量p是用来迭代perple的,p距离最后一次使用间隔1行。所以p可以使用1个字母命名,而people则使用单词来命名。
其实这里是防止人们阅读代码时,阅读过多行数后,突然发现一个上下文不理解的词,再去找定义,导致可读性差。
同时,注意例子中的空行的使用。一个是函数之间的空行,另一个是函数内的空行:在函数里干了3件事:异常判断;累加age;返回。在这3者之间添加空行,可以增加可读性。
2.2.1 上下文是关键
以上强调的原则需要在上下文中去实际判断才行,万事无绝对。
func (s *SNMP) Fetch(oid []int, index int) (int, error)
与
func (s *SNMP) Fetch(o []int, i int) (int, error)
相比,显然使用oid命名更具备可读性,而使用短变量o则不容易理解。
2.3 变量的命名不要携带变量的类型
因为golang 是一个强类型的语言,在变量的命名中包含类型是信息冗余的,而且容易导致误解错误。举个作者的例子:
var usersMap map[string]*User
我们将一个从string 到 User 的map结构,命名为UsersMap,看起来合情合理,但是变量的类型中已经包含了map,没有必要再在变量中注明了。
作者的话来讲:如果Users 描述不清楚,nameUsersMap也不见得多清楚。
对于函数的名称同样适用,比如:
type Config struct {
//
}
func WriteConfig(w io.Writer, config *Config)
config 的名称有冗余了,类型中已经说明它是一个*Config了,如果变量在函数中最后一次引用的距离足够短,那么适用简称c或者conf 会更简洁。
提示:不要让包名抢占了好的变量名。比如context这个包,如果使用
func WriteLog(context context.Context, message string)
,那么编译的时候会报错,因为包名和变量名冲突了。所以一般使用的时候,会使用
func WriteLog(ctx context.Context, message string)
2.4 使用一致的命名
尽量不要将常见的变量名,换成其他的意思,这样会造成读者的歧义。
而且对于代码中一个类型的变量,不要多次改换它的名字,尽量使用一个名字。比如对于数据库处理的变量,不要每次出现不同的名字,比如d *sql.DB
,dbase *sql.DB
,DB *sql.DB
,最好使用惯用的,一致的名字db *sql.DB
。这样你在其他的代码中,看到变量db时,也能推测到它是*sql.DB
还有一些惯用的短变量名字,这里提一下:
-
i, j, k
用作循环中的索引 -
n
用在计数和累加 -
v
表示值 -
k
表示一个map或者slice 的key -
s
表示字符串
2.5 使用一致的声明类型
对于一个变量的声明有多重声明类型:
var x int = 1
var x = 1
var x int;x=1
var x = int(1)
x:=1
在作者看来,这是go的设计者犯的错误,但是来不及改正了,新的版本要保持向前兼容。有这么多种声明的方式,我们怎么选择自己的类型呢。
作者给出了这些建议:
- 当声明一个变量,但是不去初始化时,使用
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 things []Ting = make([]Thing, 0)
vs
var things = make([]Thing, 0)
vs
things := make([]Thing, 0)
对于go来说,= 右侧的类型,就是=左侧的类型,上面三个例子中,最后一个使用:=
的例子,既能充分标识类型,又足够简洁。
22.6 作为团队的一员
编程生涯大部分时间都是和作为团队的一员,参与其中。作者建议大家最好保持团队原来的编码风格,即使那不是你偏爱的风格。要不人会导致整个工程风格不一致,这会更糟糕。
3. 注释
注释很重要,注释应该做到以下3点之一:
- 解释做了什么
- 解释怎么做
- 解释为什么这么做
举个例子
这是适合对外方法的注释,解释了做了什么,怎么做的
/ Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.
The second form is ideal for commentary inside a method:
这是适合方法内的注释,解释了做了什么
// 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,
},
}
将value 设置成0的作用并不好理解,增加注释大大增加可理解性。
3.1 变量和常量的注释应该描述他们的内容,而不是他们的作用
在上文中提到,变量和常量的名字又应该描述他们的目的。然而他们的注释最好描述他们的内容。
const randomNumber = 6 // determined from an unbiased die
在这个例子中,注释描述了为什么randomNumber
被赋值为6,注释没有描述在哪里randomNumer
会被使用。再看一些例子:
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
这里区分一下,内容表示100代表什么,代表RFC 7231,但是100的目的是表示StatusContinue。
提示,对于没有初始值的变量,注释应该描述谁来初始化这些变量
// sizeCalculationDisabled indicates whether it is safe // to calculate Types' widths and alignments. See dowidth. var sizeCalculationDisabled bool
3.2 要对公共的名称添加文档
因为dodoc 是你的项目package的文档,所以你应该在每个公共的名称上添加注释,包括变量,常量,函数,方法。
这里给出两个谷歌风格指南的准则:
- 任何不是简练清晰的公共的函数,都应该添加注释
- 库中的任何函数,不管名称多长或者多么负责,都必须增加注释
举个例子:
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
}
提示:在写函数的内容前,最好先把函数的注释写出
3.2.1 不要在不完善的代码上写注释,而是重新它
如果遇到了不完善的代码,应该记录一个issue,以便后续去修复。
传统的方法是在代码上记录一个todo,以便提醒。比如
// TODO(dfc) this is O(N^2), find a faster way to do this.
3.2.2 如果要在一段代码上添加注释,要想想能否重构它
好的代码本身就是注释。如果要在一段代码上添加注释,要问问自己,能否优化这段代码,而不用添加注释。
函数应该只做一件事,如果你发现要在这个函数的注释里,提到其他函数,那么该想想拆解这个冗余的函数。
此外,函数越精简,越便于测试。而且函数名本身就是最好的注释。
4. package设计
每个go 的package 实际上都是自己的小型go程序。就好比一个function或者method的实现对调用者无关一样,包内的对外暴露的function,method和类型的实现,和调用者无关。
一个好的go长须应该努力降低耦合度,这样随着项目的演化,一个package的变化不会影响到整个程序的其他package。
接下来会讨论如何设计一个package,包括名字,类型,和编写method和funciton的一些技巧。
4.1 一个好的packag首先有一个好名字
package 的名字应该尽量简短,最好用一个单词表示。考虑package名字的时候,不要想着我要在package内写哪些类型,而是想着这个package要提供哪些服务。要以package提供哪些服务命名。
4.1.1 一个好的package名字应该是唯一的
一个项目那的package名字应该都是不同的。如果你发现可能要取相同的pcakge名字,那么可能是以下原因:
- package的名字太通用了
- 这个package提供的服务与另一个package重合了。如果是这种情况,要考虑你的package设计了
4.2 package名字避免使用base
,common
,util
如果package内包含了一些列不相关的function,那么很难说明这个package提供了哪些服务。这常常会导致package名字取一些通用的名字,类似utilities
。
大的项目中,经常会出现像utils
或者helpers
这样的package名字。它们往往在依赖的最底层,以避免循环导入问题。但是这样也导致出现一些通用的包名称,并且体现不出包的用意。
作者的建议是将utils
和helpers
这样的package名字取取消掉:分析函数被调用的场景,如果可能的话,将函数转移到调用者的package内,即使这涉及一些代码的拷贝。
提示:代码重复,比错误的抽象,代价更低
提示:使用单词的复数命名通用的包。比如
strings
包含了string处理的通用函数。
我们应该尽可能的减少package的数量,比如现在有三个包common
、client
,server
,我们可以将其组合为一个包het/http
,用client.go和server.go来区分client和server,避免引入过多的冗余包。
提示,标识符的名字包含了包名,比如
net/http
的
GET
function,调用的使用写作
http.Get
,在标识符起名和package起名时要考虑这一点
4.3 尽早Return
go语言没有try
和catch
来做exception处理。往往通过return一个错误来进行错误处理。如果错误返回在程序底部,阅读代码的人往往要在大脑里记住很多逻辑情形判断,不清晰明了。
来看一个例子
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")
}
对比
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
}
前者要阅读一些逻辑处理,最后return 错误。后者首先将错误场景明确,并return。显然后者更加易读。
4.4 充分利用空值
如果一个变量声明,但是不给定初始值,则会被自动赋值为空值。如果充分利用这些默认的空值,可以让代码更加精简。
- int 默认值是0
- 指针默认值是nil
- slice,map,channel默认值是nil
比如对于sync.Mutex,默认值是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()
}
同样的,因为slice的append是返回一个新的slice,所以我们可以向一个nil slice直接append:
func main() {
// s := make([]string, 0)
// s := []string{}
var s []string
s = append(s, "Hello")
s = append(s, "world")
fmt.Println(strings.Join(s, " "))
}
4.5 避免package级别的状态
书写可维护的程序关键是保持松耦合–对一个package的更改,不应该影响到其他不直接依赖这个package的其他package。
有两个保持所耦合的方法:
- 使用interface描述function或者method的行为
- 避免使用全局状态
在go程序中,变量声明可以在function或者method作用域内,也可以在package作用域内。如果一个变量是public变量,并且首字母大写,那么所有包都可以访问到这个变量。
可变的全局变量会导致程序之间,各个独立部分紧耦合。它对程序中的每个function都是不可见的参数。如果变量类型人为改变,或者被其他函数改变,那么任何依赖这个变量的函数都会崩溃。
如果你想减少全局变量带来的耦合:
- 将相关的变量转移到struct的参数中
- 使用interface减少类型和类型实现之间的耦合
5. 项目结构
5.1 使用尽可能少的,尽可能大的package
因为go语言中表述可见性的方法是用首字母区分,大写表示可见,小写表示不可见。如果一个标识符是可见的,那么它可以被任何任何其他的package使用。
鉴于此,怎么才能避免过于复杂的package依赖结构?
提示:除了
cmd/
和
internal
之外的每个package,都应该包含一些源码。
作者的建议是,使用尽可能少的package,尽可能大的package。大家的默认行为应该是不创建新的pcakge,如果创建过多的package,这会导致很多类型是public的。接下来会阐述更多的细节。
5.1.1 通过import语句管理文件中的代码
如果你在这样的规则设计package:以提供调用者什么服务来安排。那么是应该在一个package中的不同的file也如此设计呢?这里给出一些建议:
- 每个package开始于一个与目录同名的
.go
文件。比如package http
应该在一个http目录下的http.go
文件中定义 - 随着package内代码的增长,将不同的功能分布在不同的文件中。比如
message.go
包含Request
和Response
类型。client.go
包含Client
类型,server.go
包含Server
类型。 - 如果你发现你的文件中有相似的
import
声明,尝试合并他们,或者将他们的区别找出来,并且移动到新的包中。 - 不同的文件应该具备不同的职责,比如
message.go
应该负责HTTP序列化请求和响应。http.go
应该包含底层的网络处理逻辑,client.go
和server.go
实现了HTTP业务逻辑,请求路由等。
提示:以名词命名文件名
提示:go编译器并行编译不同的package,以及package不同的medhod和function。所以改变package内的函数位置不影响编译时间。
5.1.2 内部的测试好于外部的测试
go工具支持使用testing
pacakge在两个地方写测试用例。假设你的包叫做http2
,那么你可以增加一个http2_test.go
文件,使用package http2
。这样测试用例和代码在同一个package内,这称为内部测试。
go工具也支持一个特别的package声明:以test
结尾的包名字比如package http_test
。这允许你的测试用例文件与代码文件在同一个package目录下,然而编译时,这些测试用例并不会作为你的package代码的一部分。他们存在于自己的package内。这叫做外部测试。
当编写单元测试时,作者推荐使用内部测试。内部测试可以让你直接测试function或者method。
然而,应该将Example
测试用例放到外部测试文件中。这样当读者阅读godoc时,这些例子具备包前缀的标识,还易于拷贝。
提示:以上的建议有一些例外,如
net/http
,http并不表示是net的子包,如果你设计了一个这种package的层级结构,存在目录内不包含任何的.go文件,那么以上的建议不适用。
5.1.3 使用internal
包减少对外暴露的公共API
如果你的项目中包含了多个package,并且有一些函数被其他package使用,但是并不想将这些函数作为对外项目的公共API,那么可以使用internal/
。将代码放到此目录下,可以使得首字母大写的function只对本项目内公开调用,不对其他项目公开。
举例来说,/a/b/c/internal/d/e/f
的目录结构,c作为一个项目,internal
目录下的包只能被/a/b/c
import,不能被其他层级项目import:如/a/b/g
5.2 保持主函数尽量精简
main
函数以及main
包应该尽量精简。因为在项目中只有一个main
包,同时程序只可能在main.main
或者main.init
被调用一次。这导致在main.mian
中很难编写测试用例。应该将业务逻辑移动到其他的package中
提示:
main
应该解析参数,打开数据库连接,初始化logger等,将执行逻辑转移到其他package。
6. API设计
6.1 设计不会被滥用的API
如果在简单的场景,API被使用都很困难,那么API的调用将会很复杂。如果API的调用很复杂,那么它将会难以阅读,并且容易被忽视。
6.1.1 警惕使用同类型的多参数函数
给定两个或者更多相同类型的参数的函数,往往看起来很简单,但是不容易使用。举例:
func Max(a, b int) int
func CopyFile(to, from string) error
这两者的区别是什么呢?本命想第一个比较两个数的最大值,第二个将一个文件进行拷贝,但是这不是最重要的事情。
Max(8, 10) // 10
Max(10, 8) // 10
Max 的参数是可以交换位置的。不会引起歧义。
然而,对于CopyFile
则不同。
CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
这两者到底是从哪个文件复制到哪个文件呢。这很容易带来混淆和歧义。
一个可行的解决办法是引入一个辅助类型,增加此method:
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")
}
在上述的解决方法中,CopyTo
总会被正确的使用,不会带来歧义。
提示:带有多个同类型多参数的API很难被正确的使用。
6.2 不应该强迫API的调用方提供他们不需要关注的参
如果你的API不必要求调用方提他们不关注的参数,那么API将会更加的易于理解。
6.2.1 鼓励将nil
作为参数
如果用户不需要关注API的某个参数值,可以使用nil作为默认参数。这里给出一个net/http
package的例子:
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
有两个参数,一个是监听的地址,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)
在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)
}
可以想象在Server(l, handler)
中,会有if handler is nil``,使用
DefaultServeMux`
的逻辑。但是,如下的调用会导致panic:
http.Serve(nil, nil)
提示:不用将可为nil和不可为nil的参数放到一个函数的参数中。
http.ListenAndServe
的作者想让在一般情况下,用户理解更加简单,但是可能会导致使用上的不安全。
在代码行数上,显示的使用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)
提示:慎重考虑辅助函数给程序员节省的时间到底有多少。清晰比简洁更重要。
6.2.2 vars参数比[]T参数更好
将slice 作为作为一个函数的参数很常见。
func ShutdownVms(ids []string) error
将slice作为一个函数的参数有一个前提,就是假定大多数时候,函数的参数有多个值。但是实际上,作者发现大多数时候,函数的参数只有一个值,这时候往往要讲单个参数封装成slice,满足函数的参数格式。
此外,因为ids
参数是一个slice,可以将一个空slice或者nil作为参数,编译的时候也不会报错。而在单测时,你也要考虑到这种场景。
再给出一个例子,如果需要判断一些参数非0,可以通过以下的方式:
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
}
这看起来简洁了很多。但是也存在一个问题,如果不给任何的参数,那么anyPositive
会返回true,这不符合预期。
如果我们更改参数的形式,让调用者清楚至少应该传入一个参数,那么就会好很多,比如:
// anyPostive indicates if any value is greater than zero.
func anyPositive(first int, rest ...int) bool {
if first > 0 {
return true
}
for _, v := range rest {
if v > 0 {
return true
}
}
return false
}
6.3 让函数定义他们需要的行为
如果需要将一个数据结构写到磁盘中。可以像如下这么写:
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
但是上述的例子存在一些问题:函数名字叫做Save
明确了是持久化到硬盘,但是如果后续有需求要持久化到其他主机的磁盘上,那么还需要改函数名字,并且告知所有的调用者。
因为它将内容写到了磁盘上,Save
函数也不便于测试。为了校验行为的正确性,自测用例不得不读取文件。
我们也需要却道f
是写到了一个车临时的目录,并且每次都会被清理。
*os.File
也包含了很多方法,并不都是与Save
相关的。
如何优化呢?
// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
使用io.ReadWriteCloser
接口可以更通用的描述函数的作用。而且拓展了Save
的功能。
当调用者保存到本地磁盘时,接口实现传入*os.File
可以更明确的标识调用者的意图。
如何进一步优化呢?
首先,如果Save
遵循单一职责原则,那么它自己无法读取文件去验证内容,校验将由其他代码进行。
// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
所以我们可以缩小传入接口的方法范围,只进行写入和关闭文件。
其次,Save
的接口提供了关闭数据流的方法。那么就要考虑什么时候使用WC
关闭文件:也许Save
会无条件的关闭,或者在写入成功时关闭。
这带来一个问题:对于Save
的调用者来说,也谢写入成功数据之后,调用者还想继续追加内容。
// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
一个更好的解决方法是重写Save
,只提供io.Writer
,只进行文件的写入。
进行一系列优化后,Save
的作用很明确,可以保存数据到实现接口io.Writer
的地方。这既带来可拓展性,也减少了歧义:它只用来保存,不进行数据流的关闭以及读取操作。
7. 错误处理
作者在他的博客中已经写过了错误处理:
此处只补充一些博客中不涉及的内容。
7.1 通过消除错误,将错误处理程序消除
比提示错误处理更好的是,不需要进行错误处理。(改进代码以便不必进行错误处理)
这一部分作者从John Ousterhout的近期的书籍《A philosophy of Software Design》中获得启发。这本书中有一张叫做“定义不复存在的错误”(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
}
根据之前的建议,函数的入参使用的是接口io.Reader
而不是*File
。这个函数的功能是统计io.Reader读入的内容。
这个函数使用ReadString函数统计是否到结尾,并且累加。但是由于引入了错误处理,看起来有一些奇怪:
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
之所以这样书写,是因为ReadString函数当遇到结尾时会返回error。
我们可以这样改进:
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
改进的版本使用bufio.Scaner
替换了bufio.Reader
,这替改进了错误处理。
如果扫描器检查到了文本的一行,sc.Scan()
返回true
,如果检测不到或遇到其他错误,则返回false。而不是返回error。这简化了错误处理。并且我们可以将错误放到sc.Err()
中进行返回。
7.1.2 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
}
在WriteResponse
函数中,有很多的错误处理过程,这看起来十分重复繁琐。来看一个改进方法:
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
,它包含了io.Writer
,并且有自己的Write函数。当需要向response写入数据时,调用新定义的结构。而新结构中处理了error的情况,这样就不必每次在WriteResponse
中显示的处理err。
(我的思考是,这样虽然简化了err处理,但是这样增加了读者的阅读负担。并不能说是一种简化)
7.2 一次只处理一个错误
一个错误的返回只应该被处理一次,如果想互联错误则可以不去处理它:
// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
w.Write(buf)
}
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
发生错误时,我们将其计入了log,但是却仍然把错误返回了。可以想象,在调用WriteAll
的函数中,也会进行计入log,并且返回err。这导致很多荣誉的log被计入。它的调用者可能进行如下行为:
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
但是在WriteConfig
的调用中看来,发生了错误,但是却没有任何上下文信息:
err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
7.2.1 为错误增加上下文信息
我们可以使用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
}
这样既不会重复增加log,也可以保留错误的上下文信息。
7.2.2 使用github.com/pkg/errors来包装错误信息
使用fmt.Errorf
来注解错误信息看起来很好,但是它也有一个缺点,它掩盖了原始的错误信息。作者认为将错误原本的返回对于松耦合的项目很重要。这有两种情况,错误的原始类型才无关紧要:
- 判断是否为
nil
- 将错误信息写入log
但是有一些场景你需要保留原始的错误信息。这种情况下你可以使用erros包:
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)
}
}
这样错误信息会是如下的内容:
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
使用errrors
包既可以满足阅读者的需求,封装错误信息的上下文,又可以满足程序判断error原始类型的需求。
8. 并发
很多项选择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 {
}
}
这个一个简单的实现http 服务的程序,但是它也做了一些其他的事情:它在结尾的地方for死循环,这浪费了cpu,而且for内没有使用管道等通信机制,它将main处于阻塞状态。无法正常退出。
因为go runtime是协程方式调度,这个程序将会在单个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()
}
}
这看起来也有一些愚蠢,这代表没有真正理解问题的所在。
(Goshed()是指让出cpu时间片,让其他goroutine运行)
如果你对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避免了浪费cpu,但是并没有解决根本问题。
解决的方法是不要在协程中运行http.ListenAndServe()
,而是在main.main goroutine中运行。
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)
}
}
在http.ListenAndServer
中有实现了阻塞。作者提到许多的go程序员过度使用了go并发,适度才是关键。
这里插入一下自己的理解:
一般在程序的退出处理上,要进行阻塞,并监听相关信号(错误信息,退出消息,信号:sigkill/sigterm),一般select和channel 来配合使用。这里http.ListenAndServe
自己实现了select的阻塞,所以不必再自己实现一套。
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
首先,第一个API将所有的内容获取出,放到一个slice中返回,这是一个同步调用的接口,直到列出所有的内容,才返回。有可能耗费内存,或者花费大量的时间。
第二个API更具备go风格,它是一个异步接口。启动一个goroutine后,返回一个channel。后台goroutine会将目录内容写到channel中。如果channel关闭,证明内容写完了。
第二个channel版本的API有两个问题:
- 调用者无法区分出错的场景和空内容的场景,在调用者看来,就是channel关闭了。
- 即使调用者提前获取到了需要的内容,也无法提前结束从channel中读取,直到channel关闭。这个方法对目录内容多,占用内存的场景更好,但是这并不比直接返回slice更快。
有一个更更好的解放方法是使用回调函数:
func ListDirectory(dir string, fn func(string))
这就是filepath.WalkDir
的实现方法。
8.3 当goroutine将要停止时,不要启动它
这里给出监听两个不同端口的http服务的例子:8080是应用的端口,8001是请求性能分析/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
解耦。我们也遵循了上面的建议,将并发性交给调用者去做,比如go serveDebug()
。
但是上面的改进程序也存在一定的问题。如果serveApp
异常出错返回,那么main.main
也将返回,导致程序退出。并被其他托管程序重启(比如supervisor)
提示:就像将并发调用交给调用者一样,程序本身的状态监控和重启,应该交给外部程序来做。
然而,serveDebug
处在一个独立的goroutine,当它有错误返回时,并不影响其他的goroutine运行。这时调用者发现/debug
处理程序无法工作了,也会很困惑。
我们需要确保任何一个至关重要的goroutine如果异常退出了,那么整个程序也应该退出。
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 {}
}
上面的程序中,serverApp
和serveDebug
都在http服务异常时,获取error并写log。在主函数中,使用select进行阻塞。这存在几个问题:
- 如果
ListenAndServer
返回nil,那么log.Fatal
不会处理异常。这时可能端口已经关闭了,但是main无法感知。 -
log.Fatal
调用了os.Exit
,os.Exit
会无条件的结束程序,defers语句不会被执行,其他的goroutine也无法被通知到应该关闭。这个程序直接退出了,也不便于写单元测试。
提示:只应该在
main.main
或者init函数中使用
log.Fatal
我们应该做什么来保证各个goroutine安全退出,并且做好退出的清理工作呢?
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)
}
}
}
我们可以使用一个channel来收集返回的error信息,channel的容量和goroutine相同,例子中是2,在main函数中,通过阻塞的等待channel读取,来确保goroutine退出时,main函数可以感知到。
由于没有安全的关闭channel,我们不使用for range
`语句去便利channel,而是使用channel的容量作为读取的边界条件。
现在我们有了获取goroutine错误信息的机制。我们需要的还有从一个goroutine获取信号,并转发给其他的goroutine的机制。
下面的例子中,我们增加了一个辅助函数serve
,它实现了http.ListenAndServe
的启动http服务的功能,并且增加了一个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)
}
}
}
上面的例子,我们每次启动goroutine会得到一个done
channel,当从done
读物到错误信息时,close stop channel,会使得其他goroutine 正常退出。如此,就可以实现main
函数正常的退出。
提示,自己写这种处理退出的逻辑会显得重复和微妙。开源代码有实现类似的事情:
https://github.com/heptio/workgroup
,可以参考