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”(由英文首字母缩略而来)。

这本书有点儿过时,书中谈论的语言也是十多年前的。但是,或许 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 的(同名)方法。

但是嵌入不仅局限于方法,它还能提供嵌入类型的字段访问。如你所见,由于 AB 在同一个包内声明, 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
}

在上边这个例子中,类型 CatLegs 方法来计算它有几条腿。我们将 Cat 嵌入到一个新的类型 OctoCat 中,并声明 Octocats 有五条腿。然而,尽管 OctoCat 类型定义了一个返回 5 的 Legs 方法,但在 PrintLegs 方法被调用时会返回 4。

这是因为 PrintLegs 方法是在 Cat 类型中定义的。它将使用 Cat 做为它的接收者,因此它会使用 CatLegs 方法。 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 方法不太可能会保持单一功能原则,因为它要读取的用于验证的文件内容应该是另外一段代码的责任。因此我们可以缩小接口范围,只传入 writingclosing

// 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 开发者可以创建一个关心软件设计能够源远流长的社区文化。

感谢!🤠🤠🤠🤠🤠