S.O.L.I.D 原则在 Go 中的应用

前言

由于自己最近灵感枯竭,所以我决定翻译一篇别人的文章 O(∩_∩)O~。作为一个一直想学 Go,但想了好久还没入门的人,我挑了篇写 Go 的文章,顺便帮自己熟悉一下 Go。原文是 Dave Cheney 根据自己 GolangUK 的演讲所整理的,全文以 SOLID 原则为线路,讲述了什么样的 Go 代码才算是好代码,当然 SOLID 原则也适用于其他语言。

英文原文比较长,由我和 Kevin 合译。

世界上有多少个 Go 语言开发者?

介个世界上有多少 Go 开发者捏?在脑海中想一个数字,我们会在最后回到这个话题。
thinking

Code review

有多少人将 code review 当做自己工作的一部分?[听演讲的人都举起了手]。为什么要做 code review?[一些人回答为了阻止不好的代码]

如果 code review 是为了捕捉到不好的代码,那么问题来了,你怎么判断你正在 review 的代码是好还是不好呢?

我们可以很容易的说出“这代码好辣眼睛”或者“这源码写的太吊了”,就像说“这画真美”,“这屋子真大气”一样。但是这些都是主观的,我希望找到一些客观的方法来衡量代码是好还是不好。

Bad code

下面看一下在 code review 中,一段代码有哪些特点会被认为是不好的代码。

  • Rigid 代码是不是很僵硬?是否由于严格的类型和参数导致修改代码的成本提高
  • Fragile 代码是不是很脆弱?是否一点小的改动就会造成巨大的破坏?
  • Immobile 代码是否难以重构?
  • Complex 代码是否是过度设计?
  • Verbose 当你读这段代码时,能否清楚的知道它是做什么的?

👆这些都不是什么好听的词,没有人希望在别人 review 自己代码时听到这些词。

Good design

了解了什么是不好的代码之后,我们可以说“我不喜欢这段代码因为它不易于修改”或者“这段代码并没有清晰的告诉我它要做什么”。但这些并没有带来积极的引导。

如果我们不仅仅可以描述不好的设计,还可以客观的描述好的设计,是不是更有助于提高呢。
excited

SOLID

2002年,Robert Martin 出版了《敏捷软件开发:原则、模式与实践》一书,在书中他描述了可重用软件设计的五个原则,他称之为 SOLID 原则(每个原则的首字母组合在一起)。

  • 单一责任原则
  • 开放封闭原则
  • 里氏替换原则
  • 接口分离原则
  • 依赖倒置原则

这本书有点过时了,书中谈论的语言都已经超过了十年之久。尽管如此,在谈论什么样的 Go 代码才是好代码时,SOLID 的原则依然可以给我们一些启发。

So,这也就是我花时间想在本文和大家一起讨论的。

单一责任原则

忙成狗
SOLID 原则中的第一个原则就是单一责任原则Robert C Martin 说过 A class should have one, and only one, reason to change(修改某个类的时候,原因有且只有一个),说白了就是,一个类只负责一项职责。

虽然 Go 语言中并没有类的概念–但我们有更鹅妹子嘤的 composition (组合)的特性。

为什么修改一段代码只负责一项职责如此重要呢?如果一个类有两个职责R1,R2,那么修改R1时,可能会导致也要修改R2。修改代码是痛苦的,但更痛苦的是修改代码的原因是由于修改其他代码引起的。

所以当一个类只负责一个功能领域中的相应职责时,可以修改的它的原因也就最大限度的变少了。

耦合 & 内聚

这两个词是用来形容一段代码是否易于修改的。

耦合是指两个东西需要一起修改—对其中一个的改动会影响到另一个。

另一个相关但独立的概念是内聚,一般指相互吸引的迷之力量。

在软件开发领域中,内聚常常用来描述一段代码内各个元素彼此结合的紧密程度。

下面我准备从 Go 的包模型开始,聊聊 Go 开发中的耦合与内聚。

包名

在Go中,所有代码都必须有一个所属的包。一个包名要描述它的用途,同时也是命名空间的前缀。下面是 Go 标准库中一些好的包名:

  • net/http,提供 http 的客户端和服务端。
  • os/exec,可以运行运行外部命令。
  • encoding/json,实现了 JSON 文件的编码和解码。

不好的包名

现在让我们来喷一些不好的包名。这些包名并没有很好的展现出它们的用途,当然了前提是它们有-_-|||。

  • package server 是提供什么?。。。好吧就当是提供一个服务端吧,但是是什么协议呢?
  • package private 是提供什么?一些我不应该看👀的东西?
  • 还有 package common, package utils,同样无法清楚的表达它们的用途,开发者也不易保持它们功能的专一性。

上面这些包很快就会变成堆放杂七杂八代码的垃圾堆,而且会由于功能太杂乱而频繁修改。

Go 中的 UNIX 哲学

在我看来,任何关于解耦设计的讨论如果没有提到 Doug McIlroyUNIX 哲学都是不完整的。UNIX 哲学就是主张将若干简洁,清晰的模块组合起来完成复杂的任务,而且通常情况下这个任务都不是原作者所能预想到的。

我想 Go 中的包正体现了 UNIX 哲学的精神。因为每一个包都是一个拥有单一责任的简洁的 Go 程序。

开放封闭原则

open or close
第二个原则,也就是 SOLID 当中的 O,是由 Bertrand Meyer 提出的开放封闭原则。1988年,Bertrand Mey 在他的著作《面向对象软件构造》一书中写道:Software entities should be open for extension,but closed for modification(软件实体应当对扩展开放,对修改关闭)。

那么这个n年前的建议在 Go 语言中是如何应用的呢?

上面的代码中,我们有类型A,包含属性 year 和一个方法 Greet。我们还有类型B,B中嵌入(embedding)了类型A,并且B提供了他自己的 Greet 方法,覆盖了A的。

嵌入不仅仅是针对方法,还可以通过嵌入使用被嵌入类型的属性。我们可以看到,在上面的例子中,因为A和B定义在同一个包中,所以B可以像使用自己定义的属性一样使用A中的 private 的属性 year。

所以,嵌入是实现 Go 类型对扩展开放非常鹅妹子嘤的手段。

在这个例子中,我们有一个 Cat 类型,它拥有一个 Legs 方法可以获得腿的数目。我们将 Cat 类型嵌入到一个新类型 OctoCat 中,然后声明 Octocat 有5条腿。然而,尽管 OctoCat 定义了它自己的 Legs 方法返回5,在调用 PrintLegs 方法时依旧会打印“I have 4 legs”。

这是因为 PrintLegs 方法是定义在 Cat 类型中的,它将 Cat 作为接收者,所以会调用 Cat 类型的 Legs 方法。Cat 类型并不会感知到它被嵌入到其他类型中,所以它的方法也不会被更改。

所以,我们可以说 Go 的类型是对扩展开放,对修改关闭的。

实际上,Go 类型中的方法比普通函数多了一点语法糖—-将接收者作为一个预先声明的形参。(译者注:这块理解了好久😖。。。,不懂得可以看这篇参考文档)

由于 Go 并不支持函数重载,所以 OctoCat 类型并不能替代 Cat 类型。这也将引出下一个原则—里氏替换原则。

 

里式替换原则

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

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

接口

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

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

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

io.Reader

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

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

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

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

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

Require no more, promise no less。

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

接口隔离原则

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

一种更加优雅的解决方案是重新定义 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 开发者中建立起一种文化。

最后谢谢大家!

打赏支持我写出更多好文章,谢谢!

打赏作者

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

2 5 收藏 3 评论

关于作者:yemengying

啊啊啊啊啊啊 个人主页 · 我的文章 · 5 ·  

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部