内容简介:阅读了Dave Cheney 关于go编码的博客:实际应用下来,对我这个go入门者,提升效果显著。我对作者的文章进行整理翻译,提取精炼,加上自己的理解,分享出来。希望也能给大家带来帮助。
阅读了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 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 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,也可以保留错误的上下文信息。
使用 fmt.Errorf
来注解错误信息看起来很好,但是它也有一个缺点,它掩盖了原始的错误信息。作者认为将错误原本的返回对于松耦合的项目很重要。这有两种情况,错误的原始类型才无关紧要:
nil
但是有一些场景你需要保留原始的错误信息。这种情况下你可以使用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
,可以参考
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 开始起飞-golang编码技巧分享--Dave Cheney博客读后整理
- 起飞!
- 飞特 3.1,商城与后台起飞,模板与注解共存
- 飞特 3.1,商城与后台起飞,模板与注解共存
- (七) SpringBoot起飞之路-整合SpringSecurity(Mybatis、JDBC、内存)
- Blazor WebAssembly 3.2.0 正式起飞,blazor 适合你吗?
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。