译文 SOLID Go Design - Go 语言面向对象设计

llitfkitfk · 2016年10月17日 · 820 次阅读
本帖已被设为精华帖!

代码评审

为什么要代码评审? 如果代码评审是要捕捉糟糕的代码,那么你如何知道你审查的代码是好的还是糟糕的?

我在找一些客观的方式来谈论代码的好坏属性。

糟糕的代码

你可能会在代码审查中遇到以下这些糟糕的代码:

  • Rigid - 代码是否死板?它是否有强类型或参数以至于修改起来很困难?
  • Fragile - 代码是否脆弱?对代码做轻微的改变是否就会引起程序极大的破坏?
  • Immobile - 代码是否很难重构?
  • Complex - 代码是否过于复杂,是否过度设计?
  • Verbose - 代码是否过于冗长而使用起来很费劲?当查阅代码是否很难看出来代码在做什么?

当你做代码审查的时候是否会很高兴看到这些词语?

当然不会。

好的设计

如果有一些描述优秀的设计属性的方式就更好了,不仅仅是糟糕的设计,是否能在客观条件下做?

SOLID - 面向对象设计

在 2002 年,Robert Martin 的Agile Software Development, Principles, Patterns, and Practices 书中提到了五个可重用软件设计的原则 - "SOLID"(英文首字母缩略字):

这本书有点点过时,使用的语言也是十多年前的。但是,或许 SOLID 原则的某些方面可以给我们一个有关如何谈论一个精心设计的 Go 语言程序的线索。

1) Single Responsibility Principle - 单一功能原则

> A class should have one, and only one, reason to change. –Robert C Martin

现在 Go 语言显然没有classses - 相反,我们有更为强大的组合的概念 - 但是如果你可以看到过去class的使用,我认为这里有其价值。

为什么一段代码应该只有一个原因改变如此重要?当然,和你自己的代码要修改比较起来,发现自己代码所依赖的代码要修改会更令人头疼。而且,当你的代码不得不要修改的时候,它应该对直接的刺激有反应,而不应该是一个间接伤害的受害者。

所以,代码有单一功能原则从而有最少的原因来改变。

  • ##### Coupling & Cohesion - 耦合与内聚

这两个词语描绘了修改一段软件代码是何等的简单或困难。

Coupling - 耦合是两个东西一起改变 - 一个移动会引发另一个移动。 Cohesion - 内聚是相关联但又隔离,一种相互吸引的力量。

在软件方面,内聚是形容代码段自然吸引到另一个的属性。

要描述 Go 语言的耦合与内聚,我们可以要谈论一下 functions 和 methods,当讨论单一功能原则时它们很常见,但是我相信它始于 Go 语言的 package 模型。

  • ##### Pakcage 命名

在 Go 语言中,所有的代码都在某个 package 中。好的 package 设计始于他的命名。package 名字不仅描述了它的目的而且还是一个命名空间的前缀。Go 语言标准库里有一些好的例子:

  • net/http - 提供了 http 客户端和服务
  • os/exec - 执行外部的命令
  • encoding/json - 实现了 JSON 的编码与解码

在你自己的项目中使用其他 pakcage 时要用import声明,它会在两个 package 之间建立一个源码级的耦合。

  • ##### 糟糕的 pakcage 命名

关注于命名并不是在卖弄。糟糕的命名会失去罗列其目的的机会。

比如说serverprivatecommonutils 这些糟糕的命名都很常见。这些 package 就像是一个混杂的场所,因为他们好多都是没有原因地经常改变。

  • ##### Go 语言的 UNIX 哲学

以我的观点,涉及到解耦设计必须要提及 Doug McIlroy 的 Unix 哲学:小巧而锋利的工具的结合解决更大的任务或者通常原创作者并没有预想到的任务。

我认为 Go 语言的 Package 体现了 UNIX 哲学精神。实际上每个 package 自身就是一个具有单一原则的变化单元的小型 Go 语言项目。

2) Open / Closed Principle - 开闭原则

 Bertrand Meyer 曾经写道: > Software entities should be open for extension, but closed for modification. –Bertrand Meyer, Object-Oriented Software Construction

该建议如何应用到现在的编程语言上:

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
}

typeA有一个year字段以及Greet方法。 typeB嵌入了A做为字段,从而,使 B 提供的Greet方法遮蔽了A的,调用时可以看到 B 的方法覆盖了A

但是嵌入不仅仅是对于方法,它还能提供嵌入 type 的字段访问。如你所见,由于AB都在同一个 package 内,B可以访问A的私有year字段就像B已经声明过。

因此 嵌入是一个强大的工具,它允许 Go 语言 type 对扩展是开放的。

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
}

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

这是因为PrintLegs在 typeCat中定义。它会将Cat做为它的接收者,因此它会使用CatLegs方法。Cat并不了解已嵌入的 type,因此它的嵌入方法不能被修改。

由此,我们可以说 Go 语言的 types 对扩展开放,但是对修改是关闭的。

事实上,Go 语言接收者的方法仅仅是带有预先声明形式的参数的 function 的语法糖而已:

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())
}

第一个 function 的接收者就是你传进去的参数,而且由于 Go 语言不知道重载,所以说OctoCats并不能替换普通的Cats,这就引出了接下来一个原则:

3) Liskov Substitution Principle - 里氏替换原则

该原则由 Barbara Liskov 提出,大致上,它规定了两种类型如果调用者不能区分出他们行为的不同,那么他们是可替代的。

基于 class 的编程语言,里氏替换原则通常被解释为一个抽象基类的各种具体子类的规范。但是 Go 语言没有 class 或者 inheritance(继承),因此就不能以抽象类的层次结构实现替换。

  • ##### Interfaces - 接口

相反,Go 语言的 interface 才有权替换。在 Go 语言中,type 不需要声明他们具体要实现的某个 interface,相反的,任何想要实现 interface 的 type 仅需提供与 interface 声明所匹配的方法。

就 Go 语言而言,隐式的 interface 要比显式的更令人满意,这也深刻地影响着他们使用的方式。

精心设计的 interface 更可能是小巧的,流行的做法是一个 interface 只包含一个方法。逻辑上来讲小巧的 interface 使实现变得简单,反之就很难做到。这就导致了由常见行为连接的简单实现而组成的 package。

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

我最喜爱的 Go 语言 interface - io.Reader

interfaceio.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"第四个原则。

4) 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方法使用一个 interface 来描述更为通用的类型。

随着修改,任何实现了io.ReadWriteCloser接口的 type 都可以代替之前的*os.File。这使得 Save不仅扩展了它的应用范围同时也给Save的调用者说明了 type*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提供一种机制来关闭它的数据流,会导致另外一个问题:wc会在什么情况下关闭。Save可能会无条件的调用Close或在成功的情况下调用Close

如果它想要在写入 document 之后再写入额外的数据时会引起Save的调用者一个问题。

type NopCloser struct {
        io.Writer
}

// Close has no effect on the underlying writer.
func (c *NopCloser) Close() error { return nil }

一个原始解决方案回事定义一个新的 type,在其内嵌入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

5) 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 语言来讲,依赖反转意味着什么呢:

如果你应用以上所有的原则,代码已经被分解成离散的有明确责任和目的的 package,你的代码应该描述了它的依赖 interface 以及这些 interface 应该只描述他们需要的功能行为。换句话说就是他们不会再过多的改变。

因此,我认为 Martin 所讲的在 Go 语言的应用是 context,即你 import graph(译注:后文用 “导入图” 代替)的结构。

在 Go 语言中,你的导入图必须是非循环。不遵守此非循环的需求会导致编译错误,但是更为严重的是它代表了一系列的设计错误。

所有条件都相同的情况下精心设计的导入图应该是广泛的以及相对平坦的,而不是又高又窄。如果你有一个 package 的函数在没有其他 package 的情况下就无法操作,也许这就表明了代码没有考虑 pakcage 的边界。

依赖反转原则鼓励你尽可能地像导入图一样在mainpackage 或者最高层级的处理程序内对具体细节负责,让低层级代码来处理抽象的接口。

“SOLID” Go 语言设计

回顾一下,当应用到 Go 语言设计中,每个 “SOLID” 原则都是强有力的声明,但是加在一起他们有一个中心主题。

  • 单一功能原则鼓励你在 package 中构建 functions、types 以及方法表现出自然的凝聚力。types 属于彼此,functions 为单一目的服务。
  • 开闭原则鼓励你使用嵌入将简单的 type 组合成更为复杂的。
  • 里氏替换原则鼓励你在 package 之间表达依赖关系时用 interface,而非具体类型。通过定义小巧的 interface,我们可以更有信心地切实满足其合约。
  • 接口隔离原则鼓励你仅取决于所需行为来定义函数和方法。如果你的函数仅仅需要有一个方法的 interface 做为参数,那么它很有可能只有一个责任。
  • 依赖反转原则鼓励你在编译时将 package 所依赖的东西移除 - 在 Go 语言中我们可以看到这样做使得运行时用到的某个特定的 package 的import声明的数量减少。(译注:待更新)

如果总结这个演讲(译注:该篇文章取自 Dave 大神在 Golang UK Conference 2016 的演讲文字内容,文章结尾处有 YouTube 链接(需要翻墙))它可能会是: > interfaces let you apply the SOLID principles to Go programs

因为 interface 描绘了他们的 pakcage 的规定,而不是如何规定的。换个说法就是 “解耦”,这确实是我们的目标,因为解耦的软件修改起来更容易。

就像 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 语言程序员应当讨论更多的是设计而非框架。我们应当不惜一切代价地关注重用而非性能。

我想要看到是今天的人们谈论关于如何使用编程语言,无论是设计解决方案还是解决实际问题的选择和局限性。

我想要听到的是人们谈论如何通过精心设计、解耦、重用以及适应变化的方式来设计 Go 语言程序。

...还有一点

我们需要告诉世界优秀的软件该如何编写。告诉他们使用 Go 语言如何编写优秀的、可组合的及易于变化的软件。

...

感谢!

相关博文:

  1. Inspecting errors
  2. Should methods be declared on T or *T
  3. Ice cream makers and data races
  4. Stupid Go declaration tricks

原文视频:Golang UK Conference 2016 - Dave Cheney - SOLID Go Design

原文链接:SOLID Go Design


  • 如有翻译有误或者不理解的地方,请评论指正
  • 待更新的译注之后会做进一步修改翻译
  • 翻译:田浩浩
  • 邮箱:<llitfkitfk@gmail.com>
更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
xiemengjun 将本帖设为了精华贴 02月11日 01:43
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册