SOLID Go 设计(译)
接口让我们 Go 开发者描述了我们提供包是做什么的,而不是如何实现的。换个说法就是「解耦」,这确实是我们的目标,因为解耦的软件修改起来更容易。
原文链接:OLID Go Design - Dave Cheney
本文的写作基于我 2016 年 8 月 18 日在 GolangUK 上进行演讲的 Keynote 文档。
本次演讲的影像可在 YouTube 上进行查看。
世界上有多少 Go 开发者?
世界上有多少 Go 开发者?好好想一想这个数字并记在脑海里面,在演讲的最后我们再来看看这个数字。
代码评审
在座的各位有哪些人把代码评审作为日常工作的一部分?【整个房间的人都举起了手,真是令人鼓舞呐!】好了,哪为什么要进行代码评审?【有人大喊「移除坏的代码」】
如果进行代码评审是捕捉坏的代码,那么你怎么知道你所审查的代码是好是坏?
你可以说「那些代码丑陋极了」或者「哇,这些代码真优雅」,就如你可以说「这幅画真好看」或者「这间房间真漂亮」。但是这些描述都是形容词,接下来,让我来找一些客观的方式来谈一谈代码是好是坏。
糟糕的代
在进行代码审查的时候,你会选择下面哪些作为坏代码的特征?
- 死板(Rigid) - 代码是否死板?它是否有过于强类型或参数导致对其修改起来很困难?
- 脆弱(Fragile) - 代码是否脆弱?对代码做轻微的改变是否就会引起程序数不清的破坏?
- 难以改变(Immobile) - 代码是否很难重构?是否一个按键就会导致循环引用?
- 复杂(Complex) - 代码是否因为其它代码的缘故导致过度的设计?
- 冗长(Verbose) - 代码是否用起来很费劲?当查阅代码时,是否很难看出来代码在做什么?
以上的都是听起来很不错的词汇吗?当你进行代码审查的时候看到这些词语会愉快吗?
当然不会。
好的设计
如果有一些描述优秀的设计属性的方式就更好了,不仅仅是糟糕的设计,是否能在客观条件下做?
SOLID - 面向对象设计
在 2002 年,Robert Martin 写的 Agile Software Development, Principles, Patterns, and Practices 一书中,他介绍了五个可重用软件设计的原则 - “SOLID”(由英文首字母缩略而来)。
- 单一功能原则(Single Responsibility Principle)
- 开闭原则(Open / Closed Principle)
- 里氏替换原则(Liskov Substitution Principle)
- 接口隔离原则(Interface Segregation Principle)
- 依赖反转原则(Dependency Inversion Principle)
这本书有点儿过时,书中谈论的语言也是十多年前的。但是,或许 SOLID 原则中的某些方面可以给我们一些关于如何精心设计的 Go 程序的线索。
单一功能原则
SOLID 的第一条原则,S,就是单一功能原则(Single Responsibility Principle)。
A class should have one, and only one, reason to change. – Robert C Martin
Go 中显然没有类(Classes)这个概念,然而,我们有更为强大的组合(Composition)概念。当然,如果你可以回顾一下类这个术语的使用,我认为这里面自有其价值。
为什么一段代码的改变应该只有一个原因是如此的重要?跟你自己的修改代码引起的烦恼比起来,发现自己代码所依赖的代码的修改会更令人头疼。而且,当你的代码不得不要修改的时候,它应该负责直接作为促进因素,而不应该是附带的受害者。
所以,有单一功能原则代码因此要有最少的原因来改变。
耦合与内聚
耦合和内聚( Coupling & Cohesion)这两个词语用于描绘修改一段软件代码的难易程度。
耦合(Coupling) 简单的用于描述两个东西将同时改变:其中一个变化会引发另外一个变化。
内聚(Cohesion) 是相互关联但又隔离,一种相互吸引的力量。
在软件领域,内聚用来描述一段代码自然地与另外一段代码有联系的属性。
要描述 Go 程序中的耦合与内聚,我们可以要看一下函数(functions)和方法(methods),当讨论单一功能原则时它们很常见,但是首先还是来看看 Go 的包(package)模型。
Go 包
在 Go 中,所有的代码都在某个包中,设计得好的包始于命名。包名不仅提供了其目的的描述,还提供了一个命名空间的前缀。Go 标准库里有一些好的例子:
net/http
:提供了 HTTP 客户端和服务端os/exec
:执行外部的命令encoding/json
:实现了 JSON 的编码与解码
当你通过使用 import
声明来在自己的项目中使用其它包的时候,它会在两个包之间建立一个源码级别的耦合,两个包彼此关联。
坏的包名
关注命名并不是出于卖弄。糟糕的命名将失去罗列其目的的机会。
server
包提供了什么?好吧,正如是期望的服务功能,但是什么协议的实现?
private
包提供了什么?一些我不该看到的功能?它是否应该提供一些导出的标识符?
还有 common
,就像它的同伙 utils
一样,经常会被一些包维护者使用。诸如此类的包名已经成为了杂项的垃圾场,因为其责任的多样性导致这些包被毫无来由的频繁修改。
Go 的 Unix 哲学
在我看来,涉及到解耦设计必须要提及 Doug McIlroy 的 Unix 哲学:小巧而有力的工具的结合起来可以解决更大和更常见的任务,这些任务通常是其连原作者并没有预想到的任务。
我认为 Go 的包正体现了 Unix 哲学精神,实际上每个包自身就是一个具有单一原则变化单元的小型 Go 项目。
开闭原则
第二条 SOLID 原则,O,是由 Bertrand Meyer 与 1988 提出的开闭原则(Open / Closed Principle),他写道:
Software entities should be open for extension, but closed for modification. – Bertrand Meyer, Object-Oriented Software Construction
该建议如何应用到 20 多年后的编程语言中呢?
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
中的,所以调用者能看到 B
的方法,它提供的 Greet
的方法会覆盖 A
的(同名)方法。
但是嵌入不仅局限于方法,它还能提供嵌入类型的字段访问。如你所见,由于 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
类型定义了一个返回 5 的 Legs
方法,但在 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
,这就引出了接下来一个原则。
里氏替换原则
里氏替换原则(Liskov Substitution Principle)由 Barbara Liskov 提出,大意是,如果调用者不能区分两种类型行为上的不同,那么他们是可替代的。
基于类(Class)的编程语言,里氏替换原则通常被解释为一个抽象基类的各种具体子类的规范。但是 Go 没有类或者继承(Inheritance),因此就不能以抽象类的层次结构实现替换。
接口(Interfaces)
然而,Go 的接口(Interface)有能力替换。在 Go 中,类型不需要声明他们具体要实现的某个接口,相反的,任何想要实现接口的类型仅需提供与接口声明相匹配的方法即可。
就 Go 而言,接口是隐式实现的,而非显式的,这也深刻地影响着接口在语言中的使用方式。
精心设计的接口更可能是小巧的,流行的做法是一个接口只包含一个方法。逻辑上来讲小巧的接口使实现变得简单,反之就很难做到。这就产生了由常见行为连接的简单实现而组成的包。
io.Reader
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
读取数据到提供的缓冲区(buffer)中,并返回调用者读取数据的字节(bytes)的数量以及读取期间可能的错误。它简单但强大。
因为 io.Reader
可以处理任何能表示为字节流(bytes)的数据,我们可以在任何事情上构建 Readers:字符串(string)常量、字节(byte)数组、标准输入、网络数据流、gzip 后的 tar 文件以及通过 SSH 远程执行的命令的标准输出等等。
因为它们都满足了相同的协议契约,所有这些实现相互之间都是可替换的。
因此,里氏替换原则应用在 Go 中,可以用 Jim Weirich 的格言来概括:
Require no more, promise no less. – Jim Weirich
接下来让我们切换到 SOLID 第四个原则。
接口隔离原则
第四个原则是接口隔离原则(Interface Segregation Principle),描述如下:
Clients should not be forced to depend on methods they do not use. – Robert C. Martin
在 Go 中,接口隔离原则的应用可以表示成:为了完成一个孤立的行为,我们需要一个单独的方法。举个具体的例子,编写一个方法来保存一个文档结构到磁盘这样的任务。
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
我可以定义一个名为 Save
方法,它使用 *os.File
做为保存 Document
的文件。但是这样做存在一些问题。
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
方法,让它接收一个接口类型来描述更为通用的像形如文件这样的类型(file-shaped)。
随着修改,任何实现了 io.ReadWriteCloser
接口类型都可以代替之前的 *os.File
。这使得 Save
不仅扩展了应用范围,同时也对 Save
的调用者说明了 *os.File
的哪些方法是与当前操作相关的。
做为 Save
的作者,我不再有在 *os.File
上调用无关的方法机会了,因为它们都被隐藏于 io.ReadWriteCloser
接口之中了。我们还可以进一步地应用接口隔离原则。
首先,(上述) Save
方法不太可能会保持单一功能原则,因为它要读取的用于验证的文件内容应该是另外一段代码的责任。因此我们可以缩小接口范围,只传入 writing
和 closing
。
// Save writes the contents of doc to the supplied WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
其次,通过向 Save
提供一种机制来关闭它的数据流,它来自于我们之前一直想要把这个参数看起来像文件这样的类型(file-shaped),这会导致另外一个问题: 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
稍作回顾,这句话是一个非常有意思的文化现象(meme),这几年以来,它已经渗透到了 Go 的潮流文化之中了。
这条推文有好几个细微差别的版本,这不怪 Jack,但我认为它代表了一些可拥护的 Go 设计原则。
依赖反转原则
最后一条 SOLID 原则,依赖反转原则(Dependency Inversion Principle),定义如下:
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 程序员而言,依赖反转意味着什么呢:
如果你应用前面提到的所有的原则,你的代码应该已经被分解成离散的且带有明确责任和目的的包了。你的代码应该描述了它依赖的接口,并且这些接口只描述他们需要的功能行为。换句话说,它们不会再过多的改变。
因此,我认为 Martin 在这里所讲的在,就是 Go 应用的上下文(context),即你的导入图(import graph)。
在 Go 中,你的导入图必须是非循环。不遵守非循环会导致编译错误,但是更为严重的是,这代表了一系列的设计错误。
相同情况下,精心设计的导入图应该是宽且相对扁平的,而不是又高又窄。如果你有一个包,它的函数在没有其他包的支持的情况下便无法操作,这也许表明了你的代码中没有好好思考包的边界。
依赖反转原则鼓励你尽可能将具体细节负责往导入图上层的地方放,如 main
包或者高层级的处理程序(handler)等,让低层级代码来处理抽象的接口。
SOLID Go 程序设计
回顾一下,当应用 Go 程序设计中,每个 SOLID 原则都是强有力的声明,但当是加在一起他们则有一个中心主题。
单一功能原则鼓励你在包中组织函数、类型以及方法时,表现出自然的内聚力。类型属于彼此,函数或方法为单一的目的而服务。
开闭原则鼓励你使用嵌入将简单的类型组合成更为复杂的类型。
里氏替换原则鼓励你在包之间表达依赖关系时用接口,而非具体类型。通过定义小巧的接口,我们可以更加确信具体的实现能很好地满足接口协议。
接口隔离原则让上一条规则走得更远,它鼓励你仅取决于所需行为来定义函数和方法。因为如果你的函数仅仅需要只有一个方法的接口类型做为参数,那么它很有可能只有一个责任。
依赖反转原则鼓励你在编译时将包所依赖的东西移除,在 Go 中我们可以看到这样做使得运行时用到的某个特定的包的 import
声明的数量减少。
如果把整个演讲概括一下,大概就是:能让你将 SOLID 应用到 Go 中就是接口(interfaces)。
因为接口让我们 Go 开发者描述了我们提供包是做什么的,而不是如何实现的。换个说法就是「解耦」,这确实是我们的目标,因为解耦的软件修改起来更容易。
正如 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
五十万 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 开发者,让我们的语言更成功的机会来自于我们集体的能力,我们需要确保别人在谈论 Go 时像取笑如今的 C++ 那样的情况发生。
那些嘲弄其他语言的故事是膨胀的、冗余的和复杂的,也许有一天就会轮到 Go,我不想看到这样的事情发生,所以我有一个请求。
我们 Go 开发者应该多谈论设计而非框架,我们更应该关注重用带来的代价,而非性能带来的代价。
我希望看到是今天人们谈论关于如何使用编程语言,无论是设计解决方案还是解决实际问题的选择或局限。
我希望听到的是人们谈论如何通过精心设计、解耦、重用以及适应变化的方式来设计 Go 语言程序。
最后一点
今天如此多的人来到这里听优秀的演讲是非常棒的事情,但是事实上,不管这样的会议规模如何增长,相较于不断增长的使用 Go 的开发者,我们只不过是很小一部分。
所以我们要告诉世界上其他的开发者好的软件应该要怎么写。让我们告诉他们怎么用 Go 来编写优秀的、可组合且易于改变的软件。请从你做起。
我希望你开始谈论设计,也许是我今天在这里提及的,但更希望是你自己研究之后应用到你自己项目中的观点。我希望你去做以下几点:
- 写一篇博文
- 开班来讲解你们做的事情
- 把你学习的东西写成一本书
- 来年回到这个会议,并分享你的成就
因为通过这做些事情,我们 Go 开发者可以创建一个关心软件设计能够源远流长的社区文化。
感谢!🤠🤠🤠🤠🤠