实用围棋:编写现实世界中受支持程序的技巧

本文重点介绍编写Go代码的最佳实践。 它以演示文稿的形式组成,但没有通常的幻灯片。 我们将尝试简要清楚地介绍每个项目。

首先,您需要就最佳编程语言的含义达成一致。 在这里,您可以回忆起Go技术总监Russ Cox的话:

如果添加时间因素和其他程序员,那么软件工程就是编程的过程。

因此,Russ区分了编程软件工程的概念。 在第一种情况下,您可以自己编写程序,第二种情况下,您可以创建一种产品,其他程序员可以随时使用。 工程师来来去去。 团队成长或萎缩。 添加了新功能并修复了错误。 这就是软件开发的本质。

目录内容



1.基本原则


我可能是其中最早使用Go的用户之一,但这不是我个人的看法。 这些基本原则是Go本身的基础:

  1. 简单性
  2. 易读性
  3. 生产力

注意事项 请注意,我没有提到“性能”或“并发”。 有比Go更快的语言,但是它们肯定不能简单地进行比较。 有些语言将并行性放在首位,但是就可读性或编程效率而言,它们无法进行比较。

性能和并发性是重要的属性,但不如简单性,可读性和生产率重要。

简单性


“简单是可靠性的前提” -Edsger Dijkstra

为什么要追求简单? 为什么Go程序简单很重要?

我们每个人都遇到了难以理解的代码,对吧? 当您害怕进行更改时,因为它会破坏程序中您不太了解并且不知道如何解决的另一部分。 这就是困难。

“设计软件的方法有两种:第一种是使软件简单到没有明显缺陷,第二种是使软件变得如此复杂而没有明显缺陷。 第一个要困难得多。” -C.E. R. Hoar

复杂性使可靠的软件变得不可靠。 复杂性是杀死软件项目的原因。 因此,简单是Go的最终目标。 无论我们编写什么程序,它们都应该很简单。

1.2。 易读性


“可读性是可维护性不可或缺的一部分” -Mark Reinhold,JVM Conference,2018年

为什么代码可读性如此重要? 我们为什么要争取可读性?

“程序应该为人编写,而机器只能执行它们” -Hal Abelson和Gerald Sassman,“计算机程序的结构和解释”

不仅Go程序,而且通常所有软件都是人为人编写的。 机器还处理代码的事实是次要的。

一旦编写了代码,人们将反复阅读:数百次,甚至数千次。

“对于程序员而言,最重要的技能是有效交流思想的能力。” - 加斯顿·霍克Gaston Horker)

可读性是了解程序功能的关键。 如果您不懂代码,该如何维护? 如果不支持该软件,它将被重写; 这可能是您公司最后一次使用Go。

如果您正在为自己编写程序,请执行适合您的工作。 但是,如果这是联合项目的一部分,或者该程序将使用足够长的时间以更改其运行的要求,功能或环境,那么您的目标就是使该程序可维护。

编写支持的软件的第一步是确保代码清晰。

1.3。 生产力


“设计是组织代码的艺术,因此它可以在今天工作,但始终支持变更。” -Sandy Mets

作为最后一个基本原则,我想说一下开发人员的生产率。 这是一个很大的话题,但要归结为比例:您花了多少时间在有用的工作上,还有多少-等待工具的响应或无法理解的代码库中无望的徘徊。 Go程序员应该感到他们可以处理很多工作。

开玩笑,说Go语言是在C ++程序编译时开发的。 快速编译是Go的关键功能,也是吸引新开发人员的关键因素。 尽管编译器得到了改进,但通常来说,使用其他语言进行的分钟编译在Go上需要花费几秒钟。 因此,Go开发人员在使用动态语言时感觉像程序员一样高效,但是这些语言的可靠性没有任何问题。

如果我们从根本上谈论开发人员的生产力,那么Go程序员将理解阅读代码本质上比编写代码更为重要。 按照这种逻辑,Go甚至可以使用这些工具以某种样式格式化所有代码。 这消除了学习特定项目的特定方言时的丝毫困难,并有助于识别错误,因为与常规代码相比,它们看上去只是错误的。

Go程序员无需花几天时间调试奇怪的编译错误,复杂的构建脚本或在生产环境中部署代码。 而且最重要的是,他们不会浪费时间去理解同事写的东西。

当Go开发人员谈论可伸缩性时 ,它们意味着生产力。

2.标识符


我们将讨论的第一个主题-identifiers是名称的同义词:变量,函数,方法,类型,包等的名称。

“坏名字是不良设计的征兆” -Dave Cheney

鉴于Go的语法有限,对象名称会对程序的可读性产生巨大影响。 可读性是编写好代码的关键因素,因此选择好名字至关重要。

2.1。 基于清晰而非简短的名称标识符


“重要的是,代码必须显而易见。 您可以在一行中执行的操作,您必须在三行中执行。” - Ukia Smith

Go并未针对棘手的单行代码或程序中的最小行数进行优化。 我们没有优化磁盘上源代码的大小,也没有优化在编辑器中键入程序所需的时间。

“一个好名字就像个好玩笑。 如果您需要解释一下,那就不再有趣了。” - Dave Cheney

要获得最大清晰度,关键是我们选择用来标识程序的名称。 好名字固有的什么素质?

  • 一个好名字简明扼要 。 它不必最短,但也可以不包含多余的内容。 它具有很高的信噪比。
  • 一个好名字是描述性的 。 它描述了变量或常量的使用, 而不是内容的使用。 好的名称描述的是函数或方法的行为而不是实现的结果。 包装的目的, 而不是其内含物。 名称描述的事物越准确,就越好。
  • 一个好名字是可以预见的 。 用一个名字,您必须了解如何使用该对象。 名称应具有描述性,但遵循传统也很重要。 这就是Go程序员说“惯用语”的意思。

让我们更详细地考虑这些属性。

2.2。 证件长度


有时,Go的样式会因变量名简短而受到批评。 正如罗伯·派克(Rob Pike) 所说 :“ Go程序员需要正确长度的标识符。”

Andrew Gerrand提供更长的标识符来表示重要性。

“名称声明与使用对象之间的距离越大,名称就应该越长” - 安德鲁·格朗德Andrew Gerrand)

因此,可以提出一些建议:

  • 如果声明和最后一次使用之间的距离很小,则短变量名是好的。
  • 长变量名应说明理由; 它们越长,就应该越重要。 详细标题与页面上的权重无关。
  • 不要在变量名称中包括类型名称。
  • 常量名称应描述内部值,而不是如何使用该值。
  • 对于循环和分支,首选单字母变量;对于参数和返回值,建议使用单独的单词;对于软件包级别的函数和声明,建议使用多个单词。
  • 对于方法,接口和包,最好使用单个单词。
  • 请记住,程序包名称是调用方用于引用的名称的一部分。

考虑一个例子。

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 } 

在第十行中,声明了范围p的变量,并且仅从下一行调用一次。 也就是说,变量在页面上的生存时间很短。 如果读者对p在程序中的作用感兴趣,则只需阅读两行。

为了进行比较,在函数参数中声明了人员,并使用了7行。 sumcount ,因此它们证明了较长的名称是合理的。 读者需要扫描更多代码以找到它们:这证明了更多专有名称的合理性。

您可以为sum选择s ,为count选择c (或n ),但这会将程序中所有变量的重要性降低到同一水平。 您可以将p替换为people ,但是会有一个问题,即for ... range的迭代变量for ... range 。 一个person看起来会很奇怪,因为寿命短的迭代变量的名称要比其派生的多个值的名称长。

提示 。 用空行分隔函数流,因为段落之间的空行破坏了文本流。 在AverageAge ,我们有三个连续的操作。 首先,检查除以零,然后得出总年龄和人数的结论,最后是-平均年龄的计算。

2.2.1。 最主要的是上下文


重要的是要了解大多数命名技巧都是特定于上下文的。 我想说这是一个原则,而不是一条规则。

iindex什么区别? 例如,您不能明确地说这样的代码

 for index := 0; index < len(s); index++ { // } 

从根本上比

 for i := 0; i < len(s); i++ { // } 

我认为第二种选择并不更糟,因为在这种情况下,区域iindexfor循环主体的限制,并且附加的冗长性对程序的理解几乎没有增加。

但是,哪个功能更易读?

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 



 func (s *SNMP) Fetch(o []int, i int) (int, error) 

在此示例中, oid是SNMP Object ID(SNMP对象ID)的缩写,而附加的o缩写则迫使您在读取代码时从已记录的符号切换为较短的代码。 同样,将index缩小到i会更难理解,因为在SNMP消息中,每个OID的子值都称为索引。

提示 。 不要在一个广告中同时使用长和短形式参数。

2.3。 不要按类型命名变量


您不称您的宠物为“狗”和“猫”,对吗? 出于相同的原因,您不应在变量名称中包括类型名称。 它应该描述内容,而不是类型。 考虑一个例子:

 var usersMap map[string]*User 

这个公告有什么好处? 我们看到这是一张地图,并且与*User类型有关:这可能很好。 但是usersMap 确实是一个地图,Go作为一种静态类型的语言,不允许在需要标量变量的地方偶然使用这样的名称,因此Map后缀是多余的。

考虑添加其他变量的情况:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

现在,我们有了map类型的三个变量: usersMapcompaniesMapproductsMap ,所有行都映射到不同的类型。 我们知道这些是映射,并且如果在代码需要map[string]*User尝试使用companiesMap ,则编译器将引发错误。 在这种情况下,很明显Map后缀不能提高代码的清晰度,这些只是多余的字符。

我建议避免使用任何类似于变量类型的后缀。

提示 。 如果users名称没有足够清楚地描述本质,则也可以使用usersMap

本技巧也适用于功能参数。 例如:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

*Config参数的config名称是多余的。 我们已经知道这是*Config ,它会立即写入它旁边。

在这种情况下,如果变量的生存期足够短,请考虑使用confc

如果在我们区域的某个点上有多个*Config ,那么名称conf1conf2含义就不如originalupdated ,因为后者更难以混淆。

注意事项 不要让包名窃取好的变量名。

导入的标识符的名称包含程序包的名称。 例如, context包中的Context类型将称为context.Context 。 这使得不可能在包中使用变量或context类型。

 func WriteLog(context context.Context, message string) 

这将无法编译。 这就是为什么在声明ctx时的原因。例如在本地使用ctx类型,例如ctx类的名称。

 func WriteLog(ctx context.Context, message string) 

2.4。 使用单一命名样式


好名声的另一个特性是,它应该是可预测的。 读者必须立即理解它。 如果这是一个通用名称,那么读者有权假定它与上次相比没有改变含义。

例如,如果代码在数据库描述符中四处移动,则每次显示该参数时,它都应具有相同的名称。 代替d *sql.DBdbase *sql.DBDB *sql.DBdatabase *sql.DB之类的各种组合,最好使用一件事:

 db *sql.DB 

理解代码更容易。 如果您看到db ,那么您知道它是*sql.DB ,它是在本地声明的或由调用者提供的。

关于方法接受者的类似建议; 对于此类型的每种方法,请使用相同的收件人名称。 因此,在这种类型的各种方法中,读者将更容易学习接收者的用法。

注意事项 接受收件人简称协议与先前提出的建议相矛盾。 这是早期选择成为标准样式的情况之一,例如使用CamelCase而不是snake_case

提示 。 Go样式指向从其类型派生的收件人的单字母名称或缩写。 可能会发现收件人名称有时与方法中的参数名称冲突。 在这种情况下,建议使参数名称更长一些,并且不要忘记依次使用它。

最后,传统上,一些单字母变量与循环和计数相关联。 例如, ijk通常是for循环中的归纳变量, n通常与计数器或累加器相关联, v是编码函数中value的典型缩写, k通常用于map键,而s通常用作string类型参数的缩写。

与上面的db示例一样,程序员希望 i是一个归纳变量。 如果他们在代码中看到它,他们希望很快就会看到一个循环。

提示 。 如果嵌套循环太多,以至于ijk变量用完了,那么您可能想将函数分成较小的单元。

2.5。 使用单一声明样式


Go至少有六种不同的方式来声明变量。

  •  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声明程序包级别的变量的要求是一致的,这与短声明语法相反,尽管我稍后将争辩说根本不应该使用程序包级别的变量。
  • 使用初始化声明时,请使用:= 。 这使读者很清楚:=左侧的变量:=有意初始化的。

    为了解释原因,让我们看一下前面的示例,但是这次我们专门初始化每个变量:

     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) 

在这里, players显式初始化为0 ,这是多余的,因为在任何情况下players器的初始值为零。 因此,最好弄清楚我们要使用空值:

 var players int 

那第二个运算符呢? 我们无法确定类型并写

 var things = nil 

因为nil 没有类型 。 相反,我们有一个选择:或者我们使用零值来切片...

 var things []Thing 

...或创建一个零元素切片?

 var things = make([]Thing, 0) 

在第二种情况下,切片的值为零,我们使用简短的声明形式向读者表明:

 things := make([]Thing, 0) 

这告诉读者我们决定显式初始化things

因此,我们得出第三个声明:

 var thing = new(Thing) 

在这里,变量的显式初始化和“独特”关键字new的引入都被某些Go程序员所不喜欢。 使用推荐的简短语法会产生

 thing := new(Thing) 

这清楚地表明thing明确初始化为new(Thing)的结果,但仍留下非典型的new 。 可以使用文字解决问题:

 thing := &Thing{} 

这类似于new(Thing) ,并且这种复制使一些Go程序员感到不安。 但是,这意味着我们用指向Thing{}的指针和Thing值为零的显式初始化thing

但是最好考虑到使用零值声明thing的事实,并使用运算符的地址在json.Unmarshall传递thing的地址:

 var thing Thing json.Unmarshall(reader, &thing) 

注意事项 当然,任何规则都有例外。 例如,有时两个变量紧密相关,因此写起来很奇怪

 var min int max := 1000 

更具可读性的声明:

 min, max := 0, 1000 

总结一下:

  • 在声明变量而不进行初始化时,请使用var语法。
  • 在声明和显式初始化变量时,请使用:=

提示 。 明确指出复杂的事物。

 var length uint32 = 0x80 

这里的length可以与需要特定数字类型的库一起使用,并且此选项更清楚地表明,与在short声明中相比,将类型length特别选择为uint32:

 length := uint32(0x80) 

在第一个示例中,我通过显式初始化使用var声明来故意破坏规则。 背离标准使读者了解发生了不寻常的事情。

2.6。 为团队工作


我已经说过,软件开发的本质是创建可读的,受支持的代码。 您的大部分职业可能会从事联合项目。 在这种情况下,我的建议是:遵循团队采用的风格。

在文件中间更改样式很烦人。 一致性很重要,尽管这会损害个人喜好。 我的经验法则是:如果代码适合gofmt ,那么问题通常不值得讨论。

提示 。 如果要在整个代码库中重命名,请不要将其与其他更改混合使用。 如果有人使用git bisect,他将不想遍历数千个重命名来查找另一个修改后的代码。

3.评论


在继续讨论更重要的观点之前,我想花几分钟评论。

“好的代码有很多注释,而坏的代码则需要很多注释。” -实用程序员Dave Thomas和Andrew Hunt

注释对于程序的可读性非常重要。 每个评论应执行以下一项操作,并且只能执行以下一项操作:

  1. 解释代码的作用。
  2. 解释他的做法。
  3. 解释原因

第一种形式非常适合评论公共角色:

 // Open     . //           . 

第二种方法非常适合方法内部的注释:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

第三种形式(“为什么”)的独特之处在于它不会替代或替代前两种形式。 这些注释解释了导致以当前形式编写代码的外部因素。 通常没有这种上下文,很难理解为什么以这种方式编写代码。

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

在此示例中,当HealthyPanicThreshold设置为零百分比时,可能不会立即知道会发生什么。 该注释旨在澄清值为0会禁用恐慌阈值。

3.1。 变量和常量中的注释应描述其内容,而不是目的


我之前说过,变量或常量的名称应描述其用途。 但是对变量或常量的评论应该准确地描述内容 ,而不是目的

 const randomNumber = 6 //     

在此示例中,注释描述了为什么 randomNumber为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 

在HTTP上下文中,数字100称为StatusContinue ,如RFC 7231中的6.2.1节所定义。

提示 。 对于没有初始值的变量,注释应描述谁负责初始化此变量。

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

这里的注释告诉读者, dowidth函数负责维护sizeCalculationDisabled的状态。

提示 。 隐藏在眼前。 这是凯特·格雷戈里(Kate Gregory )的建议 。 有时,变量的最佳名称隐藏在注释中。

 //   SQL var registry = make(map[string]*sql.Driver) 

作者添加了一条评论,因为名称registry无法充分说明其目的-这是一个注册表,但是什么是注册表?

如果将变量重命名为sqlDrivers,则很明显它包含SQL驱动程序。

 var sqlDrivers = make(map[string]*sql.Driver) 

现在,注释已变得多余,可以删除。

3.2。 始终记录公开可用的字符


软件包的文档是由godoc生成的,因此您应在软件包中声明的每个公共字符上添加注释:变量,常量,函数和方法。

以下是《 Google风格指南》中的两个指南:

  • 任何既不明显又不简洁的公共职能都应予以评论。
  • 无论长度或复杂性如何,都应对库中的任何函数进行注释。


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

此规则有一个例外:您无需记录实现该接口的方法。 具体而言,请勿执行以下操作:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

此评论没有任何意义。 他没有说明该方法的作用:更糟糕的是,他派人去某处寻找文档。 在这种情况下,我建议完全删除该评论。

这是io包中的示例。

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

请注意, LimitedReader声明紧随其后的是使用它的函数,而LimitedReader.Read声明紧随LimitedReader本身的声明。 尽管没有记录LimitedReader.Read本身,但是可以理解,这是io.Reader的实现。

提示 。 在编写函数之前,请写一个描述它的注释。 如果您发现很难编写注释,则表明您将要编写的代码难以理解。

3.2.1。 不要评论错误的代码,将其重写


“不要评论错误代码-重写它” -Brian Kernighan

仅在注释中指出代码片段的难度是不够的。 如果您遇到以下评论之一,则应在开始时注明要重构。 只要知道其金额,您就可以忍受技术债务。

在标准库中,习惯上以TODO风格留下评论的用户的名字作为注释。

 // TODO(dfc)  O(N^2),     . 

这不是解决问题的义务,但是指定的用户可能是提出问题的最佳人选。 其他项目随TODO一起提供日期或票证编号。

3.2.2。 不必注释掉代码,而是对其进行重构


“好的代码是最好的文档。 当您要添加注释时,请问自己一个问题:“如何改进代码以便不需要此注释?” 重构并发表评论以使其更加清晰。” -史蒂夫·麦康奈尔

功能只能执行一项任务。 如果由于某些片段与该函数的其余部分无关而要写注释,则可以考虑将其提取到单独的函数中。

较小的功能不仅更清晰,而且更易于彼此分开测试。 将代码隔离到单独的函数中时,其名称可以替换注释。

4.包装结构


“编写适当的代码:不显示任何其他模块多余的模块,并且不依赖于其他模块的实现” -Dave Thomas

每个软件包本质上都是一个单独的小型Go程序。 就像函数或方法的实现对调用者而言无关紧要一样,构成包的公共API的函数,方法和类型的实现也无关紧要。

一个好的Go软件包会在源代码级别上争取与其他软件包的最小连接,以便随着项目的发展,一个软件包中的更改不会在整个代码库中级联。 这种情况极大地阻碍了程序员在此代码库上工作。

在本节中,我们将讨论包装设计,包括其名称以及编写方法和函数的技巧。

4.1。 一个好的包装始于一个好名字


一个好的Go软件包以质量名称开头。可以将它视为一个简短的演示文稿,仅限于一个单词。

像上一节中的变量名一样,包名非常重要。无需考虑此程序包中的数据类型,最好提出以下问题:“此程序包提供什么服务?” 通常答案不是“此程序包提供X类型”,而是“此程序包允许您通过HTTP连接”。

提示通过功能而不是内容选择包名称。

4.1.1。好的包装名称必须唯一


每个包在项目中都有一个唯一的名称。如果您遵循为软件包目的提供名称的建议,这没有任何困难。如果事实证明这两个软件包的名称相同,则很可能是:

  1. .
  2. . , .

4.2. base , common util


昵称的一个常见原因是所谓的服务包,随着时间的推移,各种帮助程序和服务代码会在其中累积。由于很难在那里找到唯一的名称。这通常会导致一个事实,即程序包名称是从其包含的内容派生而来的:实用程序。在大型项目中通常会找到

类似utils或的名称,helpers在大型项目中,根深蒂固的包层次结构已根植,并且共享了辅助功能。如果将某些功能提取到新程序包中,则导入将分解。在这种情况下,包的名称不反映包的用途,而仅反映由于项目的不正确组织导致导入功能失败的事实。

在这种情况下,我建议分析从何处调用软件包。utils helpers,并在可能的情况下将相应功能移至调用数据包。即使这意味着重复一些辅助代码,也比在两个软件包之间引入导入依赖关系要好。

“ [少量]复制比错误的抽象要便宜得多” -Sandy Mets

如果在许多地方使用实用程序功能,而不是使用一个带有实用程序功能的整体式软件包,则最好制作多个程序包,每个程序包都集中在一个方面。

提示对服务包使用复数形式。例如,strings用于字符串处理实用程序。

当两个或多个实现的某些通用功能或客户端和服务器的通用类型合并到单独的程序包中时,经常会遇到名称类似base或的common程序包。我认为在这种情况下,有必要通过将客户端,服务器和通用代码组合在一个软件包中并使用与其功能相对应的名称来减少软件包的数量。

例如,为了net/http不做单独包装clientserver,而是有文件client.go,并server.go与相应的数据类型,以及transport.go用于运输总量。

提示重要的是要记住,标识符名称包括程序包名称。

  • Get一个包中的功能net/http成为http.Get另一个包中链接。
  • Reader包中的类型strings在导入其他包时转换为strings.Reader
  • Error软件包中的接口net显然与网络错误相关。

4.3。 无需深潜即可快速回来


由于Go在控制流中不使用异常,因此无需深入研究代码即可为tryand 块提供顶级结构catch随着功能的进展,Go代码会在屏幕上向下显示,而不是多层结构。我的朋友马特里尔(Matt Ryer)称这种做法为“视线”

这可以通过使用边界运算符实现:在函数的输入处带有前提条件的条件块。这是包中的一个示例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成功退出的条件现在,函数的最后一行返回一个错误,您需要跟踪函数在相应的括号中的执行,以了解到这一点的方法。该选项更难阅读,从而降低了编程和代码支持的质量,因此Go倾向于在早期使用边界运算符并返回错误。return nil



4.4。 使空值有用


假设不存在显式初始化程序,则每个变量声明将自动使用与清零内存的内容相对应的值(即zero)进行初始化。值的类型由以下选项之一确定:对于数字类型-零,对于指针类型-nil,对于切片,映射和通道相同。

始终设置已知默认值的能力对于程序的安全性和正确性很重要,并且可以使Go程序更轻松,更紧凑。这就是Go程序员在说“给一个有用的零值”时要记住的。

考虑一种类型sync.Mutex该类型包含两个表示互斥量内部状态的整数字段。这些字段在任何声明中自动为null。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() } 

具有有用的空值的类型的另一个示例是bytes.Buffer您可以声明并开始对其进行写入,而无需显式初始化。

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

此结构的零值表示len两者cap相等0,并且y array是指向具有备份分片数组value的内存的指针nil这意味着您无需显式剪切,只需声明即可。

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

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中实现弱连接:

  1. 使用接口来描述功能或方法所需的行为。
  2. 避免全球地位。

在Go中,我们可以在函数或方法的范围内以及程序包的范围内声明变量。当一个变量是公开可用的,并且带有大写字母的标识符时,则其范围实际上是整个程序的全局范围:任何包在任何时候都可以看到此变量的类型和内容。

可变的全局状态在程序的独立部分之间提供了紧密的关系,因为全局变量成为程序中每个函数的不可见参数!当此变量的类型改变时,任何依赖于全局变量的函数都会被违反。如果程序的另一部分更改了此变量,则可能会违反依赖于全局变量状态的任何函数。

如何减少全局变量创建的连接性:

  1. 将相应的变量作为字段移动到需要它们的结构中。
  2. 使用接口可以减少行为与该行为的实现之间的联系。

5.项目结构


让我们讨论一下如何将软件包组合到一个项目中。这通常是单个Git存储库。

像软件包一样,每个项目都应该有一个明确的目标。如果它是一个库,它必须做一件事,例如XML解析或日记。您不应该在一个项目中结合多个目标,这将有助于避免产生可怕的库common

提示以我的经验,存储库common最终与最大的使用者紧密相关,这使得在不对common阻塞版本进行更新的情况下,如果不对使用者和使用者进行更新,就很难对以前的版本进行更正(后端口修复),这会导致许多不相关的更改,而且它们会一路中断API

如果您有一个应用程序(Web应用程序,Kubernetes控制器等),则该项目可能具有一个或多个主软件包。例如,在我的Kubernetes控制器中,有一个软件包cmd/contour可用作部署在Kubernetes集群中的服务器和调试客户端。

5.1。 套餐更少,但更大


在代码审查中,我注意到从其他语言转到Go的程序员的典型错误之一:他们倾向于滥用软件包。

Go不提供能见度精细的系统:语言是不够的访问修饰符作为Java( ,public 和隐性)。没有C ++的友好类的类似物。在Go中,我们只有两个访问修饰符:这是公共标识符和私有标识符,由标识符的首字母(大写/小写)指示。如果标识符是public,则其名称以大写字母开头,任何其他Go包都可以引用该标识符。protectedprivatedefault



您可能会听到“已导出”或“未导出”这两个词作为公共和私有同义词。

给定有限的访问控制功能,可以使用哪些方法来避免过于复杂的程序包层次结构?

提示在每个软件包中,除了,cmd/internal/必须提供源代码。

我已经反复说过,最好不要使用较大的数据包。您的默认位置应该是不创建新程序包。这会导致太多类型成为公共类型,从而导致可用API的范围越来越小。下面我们将更详细地考虑本论文。

提示来自Java吗?

如果您来自Java或C#世界,那么请记住一条潜规则:Java软件包等效于一个源文件.goGo程序包等效于整个Maven模块或.NET程序集。

5.1.1。使用导入说明按文件对代码进行排序


如果按服务组织软件包,是否应该对软件包中的文件进行相同的处理?如何知道何时将一个文件分割.go成几个文件您怎么知道您走得太远并且需要考虑合并文件?

这是我使用的建议:

  • 用一个文件启动每个软件包.go为该文件提供与目录相同的名称。例如,该软件包httphttp.go位于directory目录下的文件http
  • 随着软件包的增长,您可以将各种功能拆分为几个文件。例如,文件messages.go将包含类型RequestResponse,file client.go-type Client,file server.go-type服务器。
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

. .

. Go . ( — Go). .

5.1.2。内部测试优先于外部测试


该工具在两个位置go支持软件包testing如果您有程序包http2,则可以编写文件http2_test.go并使用程序包声明http2它编译的代码http2_test.go因为它是包的一部分http2在口语中,这种测试称为内部测试。

该工具go还支持以测试结尾的特殊程序包声明,即http_test这允许测试文件与代码位于同一包中,但是在编译此类测试时,它们不属于程序包代码的一部分,而是位于其自己的包中。这使您可以编写测试,就像另一个程序包正在调用您的代码一样。这种测试称为外部测试。

我建议对单元单元测试使用内部测试。这使您可以直接测试每个功能或方法,从而避免了外部测试的繁琐工作。

但是有必要将测试函数的示例(Example放在外部测试文件中。这样可以确保在godoc中查看示例时,这些示例将获得适当的软件包前缀,并且可以轻松复制。

. , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


如果您的项目有多个程序包,则可能会找到要供其他程序包使用的导出功能,但不适用于公共API。在这种情况下,该工具会go识别一个特殊的文件夹名称internal/,该名称可用于放置对您的项目开放但对其他人关闭的代码。

要创建这样的程序包,请将其放置在带有名称的目录中internal/或它的子目录中。当团队go看到带有路径的包的导入时internal,它将检查调用包在目录或子目录中的位置internal/

例如,一个包.../a/b/c/internal/d/e/f只能从目录树导入一个包.../a/b/c,而不能从任何目录.../a/b/g或任何其他存储库导入(参见文档)。

5.2。最小主包装


一个函数main和一个程序包main必须具有最少的功能,因为它的main.main行为就像一个单例:程序只能具有一个函数main,包括测试。

由于它main.main是单例,因此对被调用对象有很多限制:它们仅在main.main期间被调用main.init,并且只能被调用一次这使得编写代码测试变得困难main.main因此,您需要努力从主要功能(最好是从主要包)派生尽可能多的逻辑。

提示func main()必须分析标志,打开与数据库,记录器等的连接,然后将执行转移到高级对象。

6. API结构


我认为最重要的项目设计建议。

原则上,所有先前的句子都不具有约束力。这些只是基于个人经验的建议。我不会在代码审查中过多地提出这些建议。

API是另一回事,在这里我们更认真地对待错误,因为可以固定其他所有内容而不会破坏向后兼容性:在大多数情况下,这些只是实现细节。

对于公共API,从一开始就应该认真考虑其结构,因为随后的更改将对用户造成破坏。

6.1。设计很难被设计滥用的API


«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是可交换的:参数的顺序无关紧要。不论是否比较八和十或十和八,最大值八和十都是十。

但是对于CopyFile,情况并非如此。

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

这些操作员中的哪个将备份您的演示文稿,哪个将被上周的版本覆盖?您必须先检查文档才能知道。在代码审查的过程中,不清楚参数的顺序是否正确。再次,查看文档。

一种可能的解决方案是引入一种负责正确调用的辅助类型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


几年前,我做了一个关于使用功能选项演示以使默认情况下的API更加容易。演示的本质是您应该为主要用例开发一个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实现逻辑中“如果处理程序相等nil,请使用DefaultServeMux”。nil对一个参数的接受会使调用者认为可以nil为两个参数传递该参数。但是这样Serve

 http.Serve(nil, nil) 

导致可怕的恐慌。

提示不要在同一个函数签名的参数搭配nil,而不是nil

作者http.ListenAndServe尝试简化默认情况下的API用户的寿命,但安全性受到影响。

在场时,nil显式和间接使用之间的行数没有差异DefaultServeMux

  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() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

提示认真考虑辅助函数将为程序员节省多少时间。清晰胜于简洁。

提示避免使用仅需要测试的参数的公共API。避免导出参数值仅在测试期间不同的API。相反,导出包装器函数可以隐藏此类参数的传递,并且在测试中使用类似的帮助器函数来传递测试所需的值。

6.2.2。使用可变长度参数而不是[] T


通常,函数或方法采用一片值。

 func ShutdownVMs(ids []string) error 

这只是一个虚构的示例,但这是很常见的。问题在于这些签名假定将使用多个记录来调用它们。如经验所示,通常只用一个参数调用它们,这些参数必须“打包”在切片内才能满足功能签名的要求。

另外,由于参数ids是切片,您可以将空切片或零传递给函数,编译器会很高兴。由于测试应涵盖此类情况,因此增加了额外的测试负担。

为了提供此类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这不是最糟糕的选择。如果在没有参数的情况下anyPositive返回true,则更糟

但是,最好能够更改anyPositive的签名,以确保至少有一个参数传递给调用方。这可以通过组合常规参数和可变长度参数(可变参数)的参数来完成:

 // 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 } 

现在,anyPositive您不能使用少于一个参数进行调用。

6.3。让函数确定所需的行为。


假设我的任务是编写一个保留Document磁盘结构的函数

 // Save      f. func Save(f *os.File, doc *Document) error 

我可以编写一个Save写入Document文件的函数*os.File。但是有一些问题。

签名Save消除了通过网络记录数据的可能性。如果将来出现这种要求,则必须更改函数的签名,这将影响所有调用对象。

Save同样令人讨厌的测试,因为它直接与磁盘上的文件一起使用。因此,为了验证其操作,测试必须在写入后读取文件的内容。

而且我必须确保将其f写入一个临时文件夹并随后将其删除。

*os.File还定义了许多与方法无关的方法,Save例如,读取目录并检查路径是否为符号链接。好吧,如果签名Save仅描述相关部分*os.File

该怎么办?

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

借助io.ReadWriteCloser它,您可以应用接口分离的原理-并Save在描述文件更常规属性的接口上重新定义它

进行此类更改后,io.ReadWriteCloser可以将实现该接口的任何类型替换为上一个类型*os.File

这同时扩展了范围Save,并向调用方阐明了*os.File与其操作相关的类型方法

作者Save不能再调用这些不相关的方法*os.File,因为他隐藏在接口的后面io.ReadWriteCloser

但是我们可以进一步扩展接口分离的原理。

首先,如果Save 遵循单一责任的原则,他不太可能会读取他刚刚编写的文件来检查其内容-其他代码应该执行此操作。

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

因此,您可以缩小接口的规格以Save仅写和关闭。

其次,线程关闭机制y Save是它与文件一起工作时的传统。问题是,wc它将在什么情况下关闭。

无论是Save事业Close无条件,不管是在成功的情况下。

这给调用者带来了一个问题,因为他可能想在编写文档后将数据添加到流中。

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

最好的选择是重新定义“保存”以仅与一起使用io.Writer,从而将运算符从所有其他功能中保存下来,除了将数据写入流中。

在应用了接口分离原理之后,该函数同时在要求方面变得更加具体(它只需要一个可以写入的对象),而在功能方面则变得更加通用,因为现在我们可以使用它Save在实现了任何地方保存数据了io.Writer

7.错误处理


在博客上做了一些介绍写了很多 关于该主题的文章,因此我不再重复。相反,我想介绍与错误处理有关的其他两个方面。



7.1。通过消除错误本身消除对错误处理的需要


我提出了许多改进错误处理语法的建议,但是最好的选择是根本不处理它们。

我不会说“删除错误处理”。我建议更改代码,以便没有错误处理。

约翰·奥斯特豪特(John Osterhout)的最新软件开发哲学书启发了我提出这一建议其中一章的标题为“从现实中消除错误”。让我们尝试应用此建议。

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而不接受*os.File;调用者已经提供了io.Reader我们要计算其内容的内容。

我们创建bufio.Reader,然后在循环中调用方法ReadString,增加计数器,直到到达文件末尾,然后返回读取的行数。

至少我们要编写这样的代码,但是该函数负担着错误处理。例如,有一个奇怪的构造:

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

检查错误之前,我们增加了行数-这看起来很奇怪。

之所以要这样写,是因为ReadString如果它比换行符早到达文件末尾,它将返回一个错误。如果文件末尾没有新行,则会发生这种情况。

要尝试解决此问题,请更改行计数器的逻辑,然后查看是否需要退出循环。

这种逻辑仍然不完美,您能找到错误吗?

但是我们还没有完成检查错误。遇到文件末尾时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,但是添加了一个很好的抽象级别,这有助于消除错误处理。

. bufio.Scanner , .

如果扫描程序遇到字符串并且未发现错误,则该方法sc.Scan()返回一个值true。因此,for仅在扫描器缓冲区中有一行文本时才调用循环体。这意味着CountLines当没有新行或文件为空时,新文件将处理情况。

其次,由于它在检测到错误时sc.Scan返回false,因此循环for在到达文件末尾或检测​​到错误时结束。该类型会bufio.Scanner记住它遇到的第一个错误,并且使用该方法,sc.Err()我们可以在退出循环后立即恢复该错误。

最后,它sc.Err()负责处理io.EOF并将其转换为nil是否到达文件末尾而没有错误。

提示如果遇到过多的错误处理,请尝试将一些操作提取到帮助程序类型中。

7.1.2。写响应


我的第二个例子是受“错误是价值观”一文的启发

前面我们看到了如何打开,写入和关闭文件的示例。虽然有错误处理,但它并不过分,因为可以将操作封装在诸如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并检查错误。然后,对于每个标题,我们都写一个键和标题值,每次检查错误。最后,我们在标题部分增加了一个标题部分\r\n,检查错误,然后将响应主体复制到客户端。最后,尽管我们不需要检查来自的错误io.Copy,但是我们需要将其从两个返回值转换为唯一的return值WriteResponse

这是很多单调的工作。但是,您可以通过应用小型包装器来简化任务errWriter

errWriter满足合同io.Writer,因此可以用作包装器。errWriter通过函数传递记录,直到检测到错误为止。在这种情况下,它拒绝输入并返回上一个错误。

 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 } 

如果你申请errWriterWriteResponse,代码清晰显著改善。您不再需要检查每个单独操作中的错误。错误消息将作为字段检查移至函数的末尾,从而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 } 

在此示例中,如果在time内发生错误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的风格是使用边界运算符,在函数执行时检查先决条件,然后尽早返回。

在此示例中,作者检查了错误,将其注册,但忘记了返回。因此,出现了一个细微的问题。

Go错误处理合同指出,在存在错误的情况下,无法对其他返回值的内容进行假设。由于JSON封送失败,因此内容buf未知:它可能不包含任何内容,但更糟的是,它可能包含半写的JSON片段。

由于程序员在检查并记录错误之后忘记返回,因此损坏的缓冲区将被传送WriteAll该操作很可能会成功,因此将无法正确写入配置文件。但是,该功能正常完成,并且出现问题的唯一迹象是日志中的一行,其中JSON封送失败,而不是配置记录失败。

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 } 

如果将错误记录与返回一行结合在一起,则更难忘记返回并避免意外继续。

如果在写入文件时发生I / O错误,该方法Error()将产生如下内容:

 could not write config: write failed: input/output error 

7.2.2。使用github.com/pkg/errors包装错误


该模式fmt.Errorf可以很好地记录错误消息,但是错误类型会被忽略。我认为将错误处理为不透明值对于松散耦合的项目很重要,因此,如果只需要使用其值,则源错误的类型无关紧要:

  1. 确保它不为零。
  2. 在屏幕上显示或记录。

但是,碰巧您需要还原原始错误。要注释此类错误,可以使用类似我的软件包的内容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风格的错误:

 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允许您以方便的格式为人和机器将上下文添加到错误值。在最近的一次演讲中,我告诉您,在即将发布的Go版本中,这样的包装器将出现在标准库中。

8.并发


通常选择Go是因为它具有并发功能。开发人员在提高效率(就硬件资源而言)和性能方面做了很多工作,但是Go的并行功能可用于编写既不生产也不可靠的代码。在本文的结尾,我想给出一些技巧,说明如何避免Go的并发功能的某些陷阱。

Go的顶级并发支持由渠道以及说明selectgo如果您从教科书或大学学习过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时间,因为for{}在最后一行中,它main阻止gorutin main,而不执行任何I / O,无需等待阻止,发送或接收消息,或与sheduler进行某种连接。

由于Go运行时通常由sheduler提供服务,因此该程序将在处理器上毫无意义地运行,并且可能以活动锁(活动锁)结束。

如何解决?这是一个选择。

 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永远阻止。这很有用,因为现在我们不仅仅为了call而旋转整个处理器runtime.GoSched()但是,我们只治疗症状,不治疗原因。

我想向您展示另一个解决方案,希望您已经想到了。不用http.ListenAndServe在goroutine 中运行,而留下主要goroutine问题,只需http.ListenAndServe在主要goroutine中运行即可

提示如果退出函数main.main,则Go程序将无条件终止,无论在程序执行期间运行的其他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) } } 

因此,这是我的第一个建议:如果goroutine直到收到另一个结果后才能取得进展,那么通常自己做而不是委派工作就容易了。

这通常消除了将结果从goroutine传输回流程启动器所需的大量状态跟踪和通道操纵。

提示许多Go程序员滥用goroutine,尤其是刚开始时。就像生活中的其他一切一样,成功的关键在于节制。

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返回目录条目将通过其传输的通道。关闭通道时,这表明没有更多目录条目。由于通道的填充发生在return之后ListDirectory,因此可以假设goroutines开始填充通道。

在第二个选项中,没有必要实际使用goroutine:您可以选择一个足以存储所有目录条目的通道,而不会阻塞,填充,关闭它,然后将该通道返回给调用方。但这不太可能,因为在这种情况下,使用大量内存在通道中缓冲所有结果时会出现相同的问题。

ListDirectory通道版本还有两个问题:

  • 如果使用封闭通道作为信号来ListDirectory通知没有更多要处理的元素,则由于错误而无法通知调用者元素的不完整集合。调用者无法传达空目录和错误之间的区别。在这两种情况下,似乎都将立即关闭该通道。
  • 调用者在关闭通道时必须继续从通道中读取数据,因为这是了解通道填充goroutine已停止工作的唯一方法。这是对使用的严重限制ListDirectory:即使他收到了所有必要的数据,呼叫者也会花时间从通道中读取内容。就中型和大型目录的内存使用而言,这可能更有效,但是该方法并不比基于原始切片的方法快。

在这两种情况下,解决方案都是使用回调:在每个目录条目执行时都会在其上下文中调用的函数。

 func ListDirectory(dir string, fn func(string)) 

毫不奇怪,该功能filepath.WalkDir可以这样工作。

提示如果您的函数启动goroutine,则必须为调用者提供一种显式停止该例程的方法。通常最容易在调用方上保留异步执行模式。

8.3。切勿在不知道何时停止运行goroutine


在前面的示例中,不必要使用goroutine。但是Go的主要优势之一是一流的并发功能。实际上,在许多情况下,并行工作是非常适当的,因此有必要使用goroutines。

这个简单的应用程序在两个不同的端口上提供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() } 

断处理程序serveAppserveDebug以不同的功能,我们从他们分开main.main我们也沿袭了以往的建议,并确保serveAppserveDebug离开任务,以确保呼叫者的并行性。

但是这种程序的性能存在一些问题。如果我们退出serveApp,然后退出main.main,那么程序将终止,并且将由流程管理器重新启动。

提示正如Go中的函数将并行性留给调用方一样,应用程序也应退出监视其状态并重新启动调用它们的程序。不要让应用程序自行重启:最好从应用程序外部处理此过程。

但是,它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从中检查错误,ListenAndServe并在必要时致电log.Fatal由于两个处理程序都在goroutine中工作,因此我们在中编写了主例程select{}

这种方法有很多问题:

  1. 如果ListenAndServe返回错误nil,则不会有任何调用log.Fatal,并且该端口上的HTTP服务将退出而不会停止应用程序。
  2. log.Fatal调用os.Exit无条件退出程序;延迟的调用将不起作用,其他goroutine将不会收到关闭通知,程序只会停止。这使得很难为这些功能编写测试。

提示log.Fatal在函数main.main使用init

实际上,我们希望将发生的任何错误传达给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) } } } 

Goroutine返回状态可以通过通道获得。通道大小等于我们要控制的goroutine的数量,因此done不会阻止发送到该通道,因为这将阻止goroutine的关闭并导致泄漏。

由于done无法安全地关闭通道,因此for range在所有goroutines都报告之前,我们无法在通道周期中使用该惯用法。相反,我们以一个周期运行所有正在运行的goroutine,这等于通道的容量。

现在,我们有一种方法可以干净地退出每个goroutine并修复它们遇到的所有错误。剩下的只是向第一个goroutine发送信号以完成工作。

http.Server关于完成,因此我将此逻辑包装在辅助函数中。辅助serve接受地址和http.Handler,同样地http.ListenAndServe,以及所述信道stop,我们使用运行的方法Shutdown

 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,这使该通道上的每个gorutin都关闭了自己的通道http.Server反过来,这导致所有剩余的goroutines返回ListenAndServe当所有正在运行的gorutins都停止时,它main.main结束并且该过程完全停止。

提示自己编写这样的逻辑是重复的工作,并且有犯错误的风险。查看类似此程序包的东西,它将为您完成大部分工作。

Source: https://habr.com/ru/post/zh-CN441842/


All Articles