接上一篇~深夜看完釜山行,写会博客压压惊(咦?读起来好押韵啊)。
写上一句的妹子被抓走了,我来善后,我没有那么多图可以插!

原文链接

原文作者:Dave Cheney

S.O.L.I.D 原则在 Go 中的应用 -上 传送门

里式替换原则

里式替换原则由 Barbara Liskov 提出,如果两个类型表现的行为对于调用者来说没有差别,那么我们就可以认为这两个类型是可互相替换的。

在面向对象的语言中,里式替换原则通常解释为一个抽象基类拥有继承它的许多具体的子类。但 Go 中并没有类或继承,所以无法通过类的继承来实现替换。

接口

但是,我们可以通过接口实现替换。在 Go 中,类型并不需要指定他们实现的接口,只要在类型中提供接口要求的所有方法即可。

所以,Go 中的接口是隐式的(非侵入性的),而非显式的。这对于我们如何使用这门语言有着深远的影响。

一个好的接口应该是小巧的,比较流行的做法是一个接口只包含一个方法。因为一般情况下,小的接口往往意味着简洁的实现。

io.Reader
1
type Reader interface {
        // Read reads up to len(buf) bytes into buf.
        Read(buf []byte) (n int, err error)
}

下面让我们看下 Go 中我最爱的接口—io.Reader

io.Reader 接口的功能非常简单;将数据读取到提供的缓冲区中,并返回读取的字节数以及读取过程中遇到的错误。虽然看上去简单,但是确非常有用。

因为io.Reader可以处理任何可以表示为字节流的东东,我们几乎可以为所有东西构造读取器;比如:一个常量字符串,字节数组,标准输入,网络流,tar 文件,通过 ssh 远程执行命令的标准输出,等等。

而由于实现了同样的接口,这些具体实现都是互相可替换的。

我们可以用 Jim Weirich 的一句话来描述里式替换原则在 Go 中的应用。

Require no more, promise no less。

好,下面让我们继续看 SOLID 的第四个原则I。

接口隔离原则

第四个原则是接口隔离,Robert C. Martin 解释为:

调用者不应该被强制依赖那些他们不需要使用的方法。

在 Go 中,接口隔离原则的应用可以参考如何分离一个函数功能的过程。举个例子,我们需将一份文档持久化到磁盘。函数签名可以设计如下:

1
2
// Save 方法将文档的内容写到文件f
func Save(f *os.File, doc *Document) error

我们定义的 Save 方法,将 *os.File 作为文档写入的目标,这样的设计会有一些问题。

Save 方法的签名设计排除了将文档内容存储到网络设备上的可能。假设后续有将文档存储到网络存储设备上的需求,Save 方法的签名需要做出相应的改变,导致所有 Save 方法的调用方也需要做出改变。

由于 Save 方法直接在磁盘上操作文件,导致对测试不友好。为了验证 Save 所做的操作,测试需要在文档写入后从磁盘上读取文档内容来做验证。 除此之外,测试还要确保文件被写入到临时空间,之后被删除。

*os.File 定义了许多与 Save 操作不相关的方法,比如读取文件目录,检查一个路径是否是符号链接。如果 Save 方法的签名只描述 *os.File 部分相关的操作会更有帮助。

我们应该如何解决这些问题呢?

1
2
// Save 方法将文档文档内容写入到指定的ReadWriterCloser
func Save(rwc io.ReadWriteCloser, doc *Document) error

使用 io.ReadWriteCloser 我们可以根据接口隔离原则来重新定义 Save 方法,将更通用的文件描述接口作为参数。

重构后,任何实现了 io.ReadWriteCloser 接口的类型都可以替代之前的 *os.File 接口。这扩大了 Save 方法的应用场景,相比使用 *os.File 接口,Save 方法对调用者开说变得更加透明。

Save 方法的编写者也无需关心 *os.File 包含的那些不相关的方法,因为这些细节都被 io.ReadWriteCloser 接口屏蔽掉了。我们还可以进一步将接口隔离原则发挥一下。

首先,如果 Save 方法遵循单一职责原则,方法不应该读取文件内容来对刚写入的内容做验证,这应该是另一个代码片段应该做的事。因此,我们进一步细化传递给 Save 方法的接口定义,仅保留写入和关闭的功能。

1
2
// Save 方法将文档文档内容写入到指定的WriterCloser
func Save(wc io.WriteCloser, doc *Document) error

译者注:注意,这里接口名字是io.WriteCloser,而上一个签名的参数是io.ReadWriterCloser

其次,根据我们所期望的通用文件描述所具备的功能,给 Save 方法提供关闭流的机制。但是这会引发一个新的问题: wc 在什么时机关闭。Save 方法可以无条件的调用 Close 方法,或者是 Close 方法在执行成功的条件下才会被调用。

不管哪种关闭流的方式都会产生个问题,因为 Save 方法的调用者可能希望在写入的文档的流后面追加数据,而此时流已经被关闭。

1
2
3
4
5
6
type NopCloser struct {
io.Writer
}

// Close 重写 Close 方法,提供空实现
func (c *NopCloser) Close() error { return nil }

如上示例代码所示,一种粗暴的做法就是重新定一个类型,组合了 io.Writer , 重写 Close 函数,替换为空实现,防止 Save 方法关闭数据流。

但是,这违反了里氏替换原则,因为 NopCloser 并没有真正关闭流。

1
2
// Save 方法将文档文档内容写入到指定的Writer
func Save(w io.Writer, doc *Document) error

一种更加优雅的解决方案是重新定义 Save 方法的参数,将 io.Writer 作为参数,把 Save 方法的职责进一步细化,除了写入数据,其他不相关的事情都不做。

通过将接口隔离原则应用到 Save 方法,把方法功能更加明确化,它仅需要一种可以写入的东西就可以。方法的定义更具有普适性,现在我们可以使用Save 方法去保存数据到任何实现了 io.Writer 的设备。

Go 中非常重要的一个原则就是接受interface,返回structs。 – Jack Lindamood

上述引言是 GO 在这些年的发展过程中渗透到 GO 设计思想里中的非常有意思的约定。

Jack 的精悍言论可能跟实际会有细微差别,但是我认为这是 Go 设计中颇具有代表性的声明。

依赖倒置原则

最后一个原则是依赖倒置。可以这样理解:

上层模块不应该依赖于下层模块,他们都应该依赖于抽象。
抽象不应依赖于实现细节,实现细节应该依赖于抽象。 – Robert C. Martin

那么,对于 Go 语言开发者来讲,依赖倒置具体指的是什么呢?

如果你应用了我们上面讲述的4个原则,那么你的代码已经组织在独立的 package 中,每一个包的职责都定义清晰。你的代码的依赖声明应该通过接口来实现,这些接口仅描述了方法需要的功能行为,换句话说,你不需要为此做太多的改变。

因此我认为,在 Go 语言中,Martin 所讲的依赖倒置是你的依赖导入的结构。

在 Go 语言中,你的依赖导入结构必须是非循环的,不遵守此约定的后果是可能会导致编译错误,但是更为严重的是设计上的错误。

良好的依赖导入结构因该是平坦的,而不是层次结构很深。如果你有一个包,在没有其他包的情况下,不能正常工作,这可能你的代码包的组织结构没有划分好边界。

依赖倒置原则鼓励你将具体实现细节尽可能的放到依赖导入结构的最顶层,在 main package 或者更高层级的处理程序中,让低层级的代码去处理抽象的接口。

SOLID Go 设计

简要回顾一下,在将 SOLID 应用到 Go 语言时,每一个原则都陈述了其设计思想,但是所有原则都遵循了同一个中心思想。

单一职责原则鼓励你组织 function,type 到高内聚的包中,type 或者方法都应该有单一的职责。

开闭原则鼓励你通过组合简单类型来表达复杂类型。

里氏替换原则鼓励你通过接口来表达包之间的依赖关系,而非具体的实现。通过定义职责明确的接口,我们可以确保其具体实现足以满足接口契约。

接口隔离原则将里氏替换原则进一步提升,鼓励你在定义方法或者函数的时候仅包含他所需要的功能。如果仅需要一个 interface 类型的参数的方法就可以满足业务功能,那么我们可以认为这也满足了单一职责原则。

依赖倒置原则鼓励将你的 package 的依赖从编译阶段推迟到运行时,这样可以减少 import 的数量。

一句话来总结以上讲述: 善用 interface 可以将 SOLID 原则应用到 Go 编程中。

因为interface 让你将关注点放在描述包的接口上,而非具体的实现,这也是实现解耦的另一种方式,实际上这也是我们设计的目标,松耦合的软件更容易对修改开放。

就像 Sandi Metz 所说的:

软件设计的艺术就是合理组织代码,不仅能让它正常工作,也总是能够对修改开放。

如果 Go 作为一家公司从长远角度所做出的技术选型,那么对修改开放的特性必然是他们在做决策时非常认可的一个因素。

结尾

最后,回到演讲开始抛出的问题,到底有多少 Go 程序员?我的猜测是:到2020年,将有 500,000 名 Go 开发者。

这 50 万 Go 开发者都会做些什么呢?显然,他们会写很多 Go 的代码,坦诚的说,这些代码并非都是好的,甚至一些是坏的实践。

这不是耸人听闻,在座的各位,从其他语言转到 Go 的阵营,以大家的经验来谈,这个预言不是空穴来风。

C++ 的世界,只需用一部分语法就可以形成一种新的更简洁的语言。 – Bjarne Stroustrup

译者注:这里主要说明 C++ 过于复杂,并且臃肿冗余

Go 要成为一门成功的语言需要靠大家的努力,不要像 C++ 那样搞得一团糟,被我们吐槽。

臃肿,啰嗦,过于复杂,其他语言被喷的槽点有一天可能也会发生在 Go 身上,我不希望看到这一幕,所以我有一个小小的请求:

作为 Go 开发者,少谈论一些框架,多谈论一些设计,并且我们要不惜一切代价去关注代码重用而非性能。

我希望看到的是人们在讨论如何使用当下的语言来解决实际问题,不论这门语言是什么,有什么限制。

我希望听到的是人们在谈论如何写精心设计,解耦的,可重用,对改变开放的 Go 程序。

彩蛋

今天我们齐聚一堂,来聆听讲师们的演讲,但是现实是,相对于即将使用 Go 语言的开发者的数量而言,我们仅仅是大海中的一叶扁舟。

因此,我们有义务告诉他们应该如何编写设计良好的软件,可组合的软件,对修改开放的软件,使用 Go 语言如何实现,而这些需要从你开始做起。

我希望当你在谈论设计的时候,我今天所说的观点对你有所帮助,也希望你能自己做些研究工作,并应用到工程中去,然后希望你能够做以下事情:

  • 写一篇相关的博客
  • 在研讨会上分享你所做的事情
  • 把你所学的东西写成一本书
  • 明年再来参加会议的时候讲讲你取得的成就

通过做这些事情,我们可以在关心代码设计的 Go 开发者中建立起一种文化。

最后谢谢大家!

相关博客

扫码关注微信公众号,及时订阅更新