来源:cyningsun.github.io/08-03-2019/…html
在座的各位有谁把 code review 做为平常工做的一部分?【整个房间举起了手,鼓舞人心】。好的,为何要进行 code review ?【有人高呼“阻止不良代码”】git
若是代码审查是为了捕捉糟糕的代码,那么你如何知道你正在审查的代码是好仍是糟糕?程序员
正如你可能会说“这幅画很漂亮”或“这个房间很漂亮”,如今你能够说“代码很难看”或“源代码很漂亮”,但这些都是主观的。我正在寻找以客观方式谈论代码好或坏的特征。github
你在 code review 中可能会遇到如下这些糟糕代码的特征:json
这些词是正向吗?你是否乐于看到这些词用于审核您的代码?数组
想必不会。promise
但这是一个进步,如今咱们能够说“我不喜欢它,由于它太难修改”,或“我不喜欢它,由于我不知道代码试图作什么”,但如何正向引导呢?网络
若是有一些方法能够描述糟糕的设计,以及优秀设计的特征,而且可以以客观的方式作到这一点,那不是很好吗?app
2002年,Robert Martin 出版了他的书 Agile Software Development, Principles, Patterns, and Practices 其中描述了可重用软件设计的五个原则,并称之为 SOLID
(英文首字母缩写)原则:框架
这本书有点过期了,它所讨论的语言是十多年前使用的语言。可是,也许 SOLID
原则的某些方面能够给咱们提供些线索,关于怎样谈论一个精心设计的 Go 程序。
SOLID的第一个原则,S,是单一责任原则。
A class should have one, and only one, reason to change.
– Robert C Martin
如今 Go 显然没有 classses
- 相反,咱们有更强大的组合概念 - 可是若是你能回顾一下 class
这个词的用法,我认为此时会有必定价值。
为何一段代码只有一个改变的缘由很重要?嗯,就像你本身的代码可能会改变同样使人沮丧,发现您的代码所依赖的代码在您脚下发生变化更痛苦。当你的代码必须改变时,它应该响应直接刺激做出改变,而不该该成为附带损害的受害者。
所以,具备单一责任的代码修改的缘由最少。
描述改变一个软件是多么容易或困难的两个词是:耦合和内聚。
在软件上下文中,内聚是描述代码片断之间天然相互吸引的特性。
为了描述Go程序中耦合和内聚的单元,咱们可能会将谈谈函数和方法,这在讨论 SRP
时很常见,可是我相信它始于 Go 的 package 模型。
SRP: Single Responsibility Principle
在 Go 中,全部的代码都在某个 package 中,一个设计良好的 package 从其名称开始。包的名称既是其用途的描述,也是名称空间前缀。Go 标准库中的一些优秀 package 示例:
net/http
- 提供 http 客户端和服务端os/exec
- 执行外部命令encoding/json
- 实现JSON文档的编码和解码当你在本身的内部使用另外一个 pakcage 的 symbols 时,要使用 import
声明,它在两个 package 之间创建一个源代码级的耦合。 他们如今彼此知道对方的存在。
这种对名字的关注可不是迂腐。命名不佳的 package 若是真的有用途,会失去罗列其用途的机会。
server
package 提供什么? ..., 嗯,但愿是服务端,可是它使用哪一种协议?private
package 提供什么?我不该该看到的东西?它应该有公共符号吗?common
package,和它的伴儿 utils
package 同样,常常被发现和其余'伙伴'一块儿发现咱们看到全部像这样的包裹,就成了各类各样的垃圾场,由于它们有许多责任,因此常常毫无理由地改变。
在我看来,若是不说起 Doug McIlroy 的 Unix 哲学,任何关于解耦设计的讨论都将是不完整的;小而锋利的工具结合起来,解决更大的任务,一般是原始做者没法想象的任务。
我认为 Go package 体现了 Unix 哲学的精神。实际上,每一个 Go package 自己就是一个小的 Go 程序,一个单一的变动单元,具备单一的责任。
第二个原则,即 O,是 Bertrand Meyer
的开放/封闭原则,他在1988年写道:
Software entities should be open for extension, but closed for modification.
– Bertrand Meyer, Object-Oriented Software Construction
该建议如何适用于21年后写的语言?
package main
type A struct {
year int
}
func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) }
type B struct {
A
}
func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) }
func main() {
var a A
a.year = 2016
var b B
b.year = 2016
a.Greet() // Hello GolangUK 2016
b.Greet() // Welcome to GolangUK 2016
}
复制代码
咱们有一个类型 A ,有一个字段 year 和一个方法 Greet。咱们有第二种类型,B 它嵌入了一个 A,由于 A 嵌入,所以调用者看到 B 的方法覆盖了 A 的方法。由于A做为字段嵌入B ,B能够提供本身的 Greet 方法,掩盖了 A 的 Greet 方法。
但嵌入不只适用于方法,还能够访问嵌入类型的字段。如您所见,由于A和B都在同一个包中定义,因此 B 能够访问 A 的私有 year 字段,就像在 B 中声明同样。
所以嵌入是一个强大的工具,容许 Go 的类型对扩展开放。
package main
type Cat struct {
Name string
}
func (c Cat) Legs() int { return 4 }
func (c Cat) PrintLegs() {
fmt.Printf("I have %d legs\n", c.Legs())
}
type OctoCat struct {
Cat
}
func (o OctoCat) Legs() int { return 5 }
func main() {
var octo OctoCat
fmt.Println(octo.Legs()) // 5
octo.PrintLegs() // I have 4 legs
}
复制代码
在这个例子中,咱们有一个 Cat 类型,能够用它的 Legs 方法计算它的腿数。咱们将 Cat 类型嵌入到一个新类型 OctoCat 中,并声明 Octocats 有五条腿。可是,虽然 OctoCat 定义了本身的 Legs 方法,该方法返回5,可是当调用 PrintLegs 方法时,它返回4。
这是由于 PrintLegs 是在 Cat 类型上定义的。 它须要 Cat 做为它的接收器,所以它会发送到 Cat 的 Legs 方法。Cat 不知道它嵌入的类型,所以嵌入时不能改变其方法集。
所以,咱们能够说 Go 的类型虽然对扩展开放,但对修改是封闭的。
事实上,Go 中的方法只不过是围绕在具备预先声明形式参数(即接收器)的函数的语法糖。
func (c Cat) PrintLegs() {
fmt.Printf("I have %d legs\n", c.Legs())
}
func PrintLegs(c Cat) {
fmt.Printf("I have %d legs\n", c.Legs())
}
复制代码
接收器正是你传入它的函数,函数的第一个参数,而且由于Go不支持函数重载,OctoCat不能替代普通的Cat 。 这让我想到了下一个原则。
由Barbara Liskov 提出的里氏替换原则粗略地指出,若是两种类型表现出的行为使得调用者没法区分,则这两种类型是可替代的。
在基于类的语言中,里氏替换原则一般被解释为,具备各类具体子类型的抽象基类的规范。 可是Go没有类或继承,所以没法根据抽象类层次结构实现替换。
相反,替换是Go接口的范围。在Go中,类型不须要指定它们实现特定接口,而是任何类型实现接口,只要它具备签名与接口声明匹配的方法。
咱们说在Go中,接口是隐式地而不是显式地知足的,这对它们在语言中的使用方式产生了深远的影响。
设计良好的接口更多是小型接口; 流行的作法是一个接口只包含一个方法。从逻辑上讲,小接口使实现变得简单,反之则很难。所以造成了由普通行为的简单实现组成的 package。
type Reader interface {
// Read reads up to len(buf) bytes into buf.
Read(buf []byte) (n int, err error)
}
复制代码
这令我很容易想到了我最喜欢的 Go 接口 io.Reader
。
io.Reader
接口很是简单; Read
将数据读入提供的缓冲区,并将读取的字节数和读取期间遇到的任何错误返回给调用者。看起来很简单,但很是强大。
由于 io.Reader
能够处理任何表示为字节流的东西,因此咱们几乎能够在任何东西上建立 Reader
; 常量字符串,字节数组,标准输入,网络流,gzip的tar文件,经过ssh远程执行的命令的标准输出。
而且全部这些实现均可以互相替代,由于它们实现了相同的简单契约。
所以,适用于Go的里氏替换原则,能够经过已故 Jim Weirich 的格言来归纳。
Require no more, promise no less.
– Jim Weirich
顺利转入”SOLID”第四个原则。
第四个原则是接口隔离原则,其内容以下:
Clients should not be forced to depend on methods they do not use. –Robert C. Martin
在Go中,接口隔离原则的应用能够指的是,隔离功能完成其工做所需的行为的过程。举一个具体的例子,假设我已经完成了‘编写一个将Document结构保存到磁盘的函数’的任务。
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error 复制代码
我能够定义此函数,让咱们称之为 Save
,它将给定的 Document 写入到 *os.File
。 可是这样作会有一些问题。
Save的签名排除了将数据写入网络位置的选项。假设网络存储可能之后成为需求,此功能的签名必须改变,并影响其全部调用者。
因为 Save
直接操做磁盘上的文件,所以测试起来很不方便。要验证其操做,测试必须在写入后读取文件的内容。 此外,测试必须确保将 f
写入临时位置并随后将其删除。
*os.File
还定义了许多与 Save
无关的方法,好比读取目录并检查路径是不是文件连接。 若是 Save
函数的签名能只描述 *os.File
相关的部分,将会很实用。
咱们如何处理这些问题呢?
// Save writes the contents of doc to the supplied ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error 复制代码
使用 io.ReadWriteCloser
咱们能够应用接口隔离原则,使用更通用的文件类型的接口来从新定义 Save
。
经过此更改,任何实现了 io.ReadWriteCloser
接口的类型均可以代替以前的 *os.File
。使得 Save
应用程序更普遍,并向 Save
调用者阐明,*os.File
类型的哪些方法与操做相关。
作为Save
的编写者,我再也不能够选择调用 *os.File
的那些不相关的方法,由于它隐藏在 io.ReadWriteCloser
接口背后。咱们能够进一步采用接口隔离原理。
首先,若是 Save
遵循单一责任原则,它将不可能读取它刚刚编写的文件来验证其内容 - 这应该是另外一段代码的责任。所以,咱们能够将咱们传递给 Save
的接口的规范缩小,仅写入和关闭。
// Save writes the contents of doc to the supplied WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error 复制代码
其次,经过向 Save
提供一个关闭其流的机制,咱们继续这种机制以使其看起来像文件类型的东西,这就产生一个问题,wc
会在什么状况下关闭。Save
可能会无条件地调用 Close
,抑或在成功的状况下调用 Close
。
这给 Save
的调用者带来了问题,由于它可能但愿在写入文档以后将其余数据写入流。
type NopCloser struct {
io.Writer
}
// Close has no effect on the underlying writer.
func (c *NopCloser) Close() error { return nil }
复制代码
一个粗略的解决方案是定义一个新类型,它嵌入一个 io.Writer
并覆盖 Close
方法,以阻止Save
方法关闭底层数据流。
但这样可能会违反里氏替换原则,由于NopCloser实际上并无关闭任何东西。
// Save writes the contents of doc to the supplied Writer.
func Save(w io.Writer, doc *Document) error 复制代码
一个更好的解决方案是从新定义 Save
只接收 io.Writer
,彻底剥离它除了将数据写入流以外作任何事情的责任。
经过应用接口隔离原则,咱们的Save功能,同时获得了一个在需求方面最具体的函数 - 它只须要一个可写的参数 - 而且具备最通用的功能,如今咱们可使用 Save
保存咱们的数据到任何一个实现 io.Writer
的地方。
A great rule of thumb for Go is accept interfaces, return structs.
– Jack Lindamood
退一步说,这句话是一个有趣的模因,在过去的几年里,它渗透入 Go 思潮。
这个推特大小的版本缺少细节,这不是Jack的错,但我认为它表明了第一个正当有理的Go设计传统
最后一个SOLID原则是依赖倒置原则,该原则指出:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
– Robert C. Martin
可是,对于Go程序员来讲,依赖倒置在实践中意味着什么呢?
若是您已经应用了咱们以前谈到的全部原则,那么您的代码应该已经被分解为离散包,每一个包都有一个明肯定义的责任或目的。您的代码应该根据接口描述其依赖关系,而且应该考虑这些接口以仅描述这些函数所需的行为。 换句话说,除此以外没什么应该要作的。
因此我认为,在Go的上下文中,Martin所指的是 import graph 的结构。
在Go中,import graph 必须是非循环的。 不遵照这种非循环要求将致使编译失败,但更为严重地是它表明设计中存在严重错误。
在全部条件相同的状况下,精心设计的Go程序的 import graph 应该是宽的,相对平坦的,而不是高而窄的。 若是你有一个 package,其函数没法在不借助另外一个 package 的状况下运行,那么这或许代表代码没有很好地沿 pakcage 边界分解。
依赖倒置原则鼓励您将特定的责任,沿着 import graph 尽量的推向更高层级,推给 main package 或顶级处理程序,留下较低级别的代码来处理抽象接口。
回顾一下,当应用于Go时,每一个SOLID原则都是关于设计的强有力陈述,但综合起来它们具备中心主题。
若是要总结一下本次演讲,那可能就是这样:interfaces let you apply the SOLID principles to Go programs
。
由于接口让Go程序员描述他们的 package 提供了什么 - 而不是它怎么作的。换个说法就是“解耦”,这确实是目标,由于越松散耦合的软件越容易修改。
正如Sandi Metz所说:
Design is the art of arranging code that needs to work today, and to be easy to change forever.
– Sandi Metz
由于若是Go想要成为公司长期投资的语言,Go程序的可维护性,更容易变动,将是他们决策的关键因素。
最后,让咱们回到我打开本次演讲的问题; 世界上有多少Go程序员?这是个人猜想:
By 2020, there will be 500,000 Go developers.
- me
50万Go程序员会用他们的时间作些什么?好吧,显然,他们会写不少Go代码,实话实说,并非全部的都是好的代码,有些会很糟糕。
请理解,我如此说并不是残酷,可是,在这个房间里,每个有着其余语言发展经验的人——大家来自的语言,来到Go——从你本身的经验中知道,这个预言有一点是真的。
Within C++, there is a much smaller and cleaner language struggling to get out.
– Bjarne Stroustrup, The Design and Evolution of C++
全部的程序员都有机会让咱们的语言成功,依靠咱们的集体能力,不要把人们开始谈论Go的事情弄得一团糟,就像他们今天对C++的笑话同样。
嘲弄其余语言的叙述过于冗长、冗长和过于复杂,总有一天会转向GO,我不想看到这种状况发生,因此我有一个请求。
Go程序员须要少谈框架,多谈设计。咱们须要中止不惜一切代价关注性能,转而尽心尽力地专一于重用。
我想看到的是人们在谈论如何使用咱们今天使用的语言,不管其选择和限制,设计解决方案和解决实际问题。
我想听到的是人们在谈论如何以精心设计,解耦,重用,最重要的是响应变化的方式设计Go程序。
今天在座的各位都能听到来自众多演讲者的演讲,这太好了,但事实是,不管此次会议规模有多大,与Go生命周期中使用Go的人数相比,咱们只是一小部分。
所以,咱们须要告诉世界上其余地方应该如何编写好软件。优秀的软件,可组合的软件,易于更改的软件,并向他们展现如何使用Go进行更改。从你开始。
我但愿你开始谈论设计,也许使用我在这里提出的一些想法,但愿你能作本身的研究,并将这些想法应用到你的项目中。那我想要你:
由于经过作这些事情,咱们能够创建一种Go开发人员的文化,他们关心设计用于持久的程序。
谢谢。