阅读了Dave Cheney 关于go编码的博客:Practical Go: Real world advice for writing maintainable Go programshtml
实际应用下来,对我这个go入门者,提高效果显著。git
我对做者的文章进行整理翻译,提取精炼,加上本身的理解,分享出来。但愿也能给你们带来帮助。程序员
但愿你们支持原做者,原汁原味的内容能够点击 连接 阅读。文中部分例子为我的添加,若有不足敬请包容指出^ _ ^github
(PS:如涉及侵权,请与我联系,我会及时删除文章,知识传播无界,望你们支持)golang
我的认为,编码的最佳实践本质是为了提升代码的迭代产能,减小bug的概率。(成本、效率、稳定)算法
做者Dave Cheney提到,go语言的最佳实践的指导原则,须要考虑3点:sql
简洁是对于人而言的,若是代码很复杂,甚至违法人的惯性理解,那么修改和维护是牵一发而动全身的。数据库
由于代码被阅读的次数远远多于被修改的次数。在做者看来,代码被人的阅读和修改的需求,比被机器执行的需求更强烈。go编码最佳实践第一步就应该肯定代码的可读性。编程
在我我的看来,相似于一致性算法中, raft为何比paxos传播和应用更广,一个很重要的缘由就是raft更加易于理解,raft做者在论文中也提到,raft设计的最重要的初衷就是,paxos太难懂了。可读性的重要性应该排在首位的。json
良好的编码习惯,能够提升代码的交流效率。使得同事们看到代码就知道实现了什么,而没必要去逐行阅读,大大节约了时间,提升开发效率。
此外,对于go语言自己而言,不管在编译速度仍是debug时间花费上,go相对C++也是开发效率大大提升的。
命名对编写可读性好的go程序相当重要!
曾经听到这样的一个言论:对变量的命名要像给本身孩子起名同样慎重。
其实,不光是变量命名,还包括function、method、type、package等,命名都很重要。
就像编码不是为了在尽可能短的行数内,写完程序。而是为了写出可读性高的程序。
一样的,咱们的命名标识也不是越短越好,而是容易被他人理解。
一个好名字应该具有的特色:
简短:一个好名字应该在具有高辨识度的状况下,尽可能简短。
judgeAuth
(容易歧义),judgeUserLoginAuthority
(冗长)judgeLoginAuth
描述性的:一个好的名字应该是描述变量和常量的用途,而非他们的内容;描述function的结果,或者method的行为,而不是他们的操做;描述package的目的,而非包含的内容。描述的准确性衡量了名字的好坏。
leader_operation
,好的名字election
ReturnElection
,好的名字NewElection
ElectionState
,好的名字Role
可预测的:一个好的名字,仅经过名字,你们就能够推断他们的用途。应该遵循你们的惯用理解。下面会详细阐述。好比
i,j,k
经常使用来在迭代中描述引用计数值n
一般用来表示计数累加值v
一般表示一个编码函数的值k
一般用在map中的keys
一般用来表示字符串关于名字的长度,咱们有这些建议:
举一个做者文中的例子说明:
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者之间添加空行,能够增长可读性。
以上强调的原则须要在上下文中去实际判断才行,万事无绝对。
func (s *SNMP) Fetch(oid []int, index int) (int, error)
与
func (s *SNMP) Fetch(o []int, i int) (int, error)
相比,显然使用oid命名更具有可读性,而使用短变量o则不容易理解。
由于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)
尽可能不要将常见的变量名,换成其余的意思,这样会形成读者的歧义。
并且对于代码中一个类型的变量,不要屡次改换它的名字,尽可能使用一个名字。好比对于数据库处理的变量,不要每次出现不一样的名字,好比d *sql.DB
,dbase *sql.DB
,DB *sql.DB
,最好使用惯用的,一致的名字db *sql.DB
。这样你在其余的代码中,看到变量db时,也能推测到它是*sql.DB
还有一些惯用的短变量名字,这里提一下:
i, j, k
用做循环中的索引n
用在计数和累加v
表示值k
表示一个map或者slice 的keys
表示字符串对于一个变量的声明有多重声明类型:
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来讲,= 右侧的类型,就是=左侧的类型,上面三个例子中,最后一个使用:=
的例子,既能充分标识类型,又足够简洁。
编程生涯大部分时间都是和做为团队的一员,参与其中。做者建议你们最好保持团队原来的编码风格,即便那不是你偏心的风格。要不人会致使整个工程风格不一致,这会更糟糕。
注释很重要,注释应该作到如下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的做用并很差理解,增长注释大大增长可理解性。
在上文中提到,变量和常量的名字又应该描述他们的目的。然而他们的注释最好描述他们的内容。
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
由于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 }
提示:在写函数的内容前,最好先把函数的注释写出
若是遇到了不完善的代码,应该记录一个issue,以便后续去修复。
传统的方法是在代码上记录一个todo,以便提醒。好比
// TODO(dfc) this is O(N^2), find a faster way to do this.
好的代码自己就是注释。若是要在一段代码上添加注释,要问问本身,可否优化这段代码,而不用添加注释。
函数应该只作一件事,若是你发现要在这个函数的注释里,提到其余函数,那么该想一想拆解这个冗余的函数。
此外,函数越精简,越便于测试。并且函数名自己就是最好的注释。
每一个go 的package 实际上都是本身的小型go程序。就比如一个function或者method的实现对调用者无关同样,包内的对外暴露的function,method和类型的实现,和调用者无关。
一个好的go长须应该努力下降耦合度,这样随着项目的演化,一个package的变化不会影响到整个程序的其余package。
接下来会讨论如何设计一个package,包括名字,类型,和编写method和funciton的一些技巧。
package 的名字应该尽可能简短,最好用一个单词表示。考虑package名字的时候,不要想着我要在package内写哪些类型,而是想着这个package要提供哪些服务。要以package提供哪些服务命名。
一个项目那的package名字应该都是不一样的。若是你发现可能要取相同的pcakge名字,那么多是如下缘由:
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起名时要考虑这一点
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。显而后者更加易读。
若是一个变量声明,可是不给定初始值,则会被自动赋值为空值。若是充分利用这些默认的空值,可让代码更加精简。
好比对于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, " ")) }
书写可维护的程序关键是保持松耦合--对一个package的更改,不该该影响到其余不直接依赖这个package的其余package。
有两个保持所耦合的方法:
在go程序中,变量声明能够在function或者method做用域内,也能够在package做用域内。若是一个变量是public变量,而且首字母大写,那么全部包均可以访问到这个变量。
可变的全局变量会致使程序之间,各个独立部分紧耦合。它对程序中的每一个function都是不可见的参数。若是变量类型人为改变,或者被其余函数改变,那么任何依赖这个变量的函数都会崩溃。
若是你想减小全局变量带来的耦合:
由于go语言中表述可见性的方法是用首字母区分,大写表示可见,小写表示不可见。若是一个标识符是可见的,那么它能够被任何任何其余的package使用。
鉴于此,怎么才能避免过于复杂的package依赖结构?
提示:除了cmd/
和internal
以外的每一个package,都应该包含一些源码。
做者的建议是,使用尽量少的package,尽量大的package。你们的默认行为应该是不建立新的pcakge,若是建立过多的package,这会致使不少类型是public的。接下来会阐述更多的细节。
若是你在这样的规则设计package:以提供调用者什么服务来安排。那么是应该在一个package中的不一样的file也如此设计呢?这里给出一些建议:
.go
文件。好比package http
应该在一个http目录下的http.go
文件中定义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内的函数位置不影响编译时间。
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文件,那么以上的建议不适用。
internal
包减小对外暴露的公共API若是你的项目中包含了多个package,而且有一些函数被其余package使用,可是并不想将这些函数做为对外项目的公共API,那么可使用internal/
。将代码放到此目录下,可使得首字母大写的function只对本项目内公开调用,不对其余项目公开。
举例来讲,/a/b/c/internal/d/e/f
的目录结构,c做为一个项目,internal
目录下的包只能被/a/b/c
import,不能被其余层级项目import:如/a/b/g
main
函数以及main
包应该尽可能精简。由于在项目中只有一个main
包,同时程序只可能在main.main
或者main.init
被调用一次。这致使在main.mian
中很难编写测试用例。应该将业务逻辑移动到其余的package中
提示:
main
应该解析参数,打开数据库链接,初始化logger等,将执行逻辑转移到其余package。
若是在简单的场景,API被使用都很困难,那么API的调用将会很复杂。若是API的调用很复杂,那么它将会难以阅读,而且容易被忽视。
给定两个或者更多相同类型的参数的函数,每每看起来很简单,可是不容易使用。举例:
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很难被正确的使用。
若是你的API没必要要求调用方提他们不关注的参数,那么API将会更加的易于理解。
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)
提示:慎重考虑辅助函数给程序员节省的时间到底有多少。清晰比简洁更重要。
将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 }
若是须要将一个数据结构写到磁盘中。能够像以下这么写:
// 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
的地方。这既带来可拓展性,也减小了歧义:它只用来保存,不进行数据流的关闭以及读取操做。
做者在他的博客中已经写过了错误处理:
此处只补充一些博客中不涉及的内容。
比提示错误处理更好的是,不须要进行错误处理。(改进代码以便没必要进行错误处理)
这一部分做者从John Ousterhout的近期的书籍《A philosophy of Software Design》中得到启发。这本书中有一张叫作“定义不复存在的错误”(Define Errors Out of Existence),这里会应用到go语言中。
让咱们写一个同机文件行数的代码
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()
中进行返回。
来看一个处理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处理,可是这样增长了读者的阅读负担。并不能说是一种简化)
一个错误的返回只应该被处理一次,若是想互联错误则能够不去处理它:
// 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
咱们可使用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原始类型的需求。
不少项选择go语言是由于它的并发特性。go团队不遗余力让并发实现更加低成本。可是使用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) } }() 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的阻塞,因此没必要再本身实现一套。
这两个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有两个问题:
有一个更更好的解放方法是使用回调函数:
func ListDirectory(dir string, fn func(string))
这就是filepath.WalkDir
的实现方法。
这里给出监听两个不一样端口的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
,能够参考