如何对Go代码解偶

原文,文章作者也是《Hands-On Dependency Injection in Go》的作者,向原作者表示感谢。


你是否曾经由于添加某个新功能却导致另一个功能出问题?修复好这个,另一个又出问题,就好象打地鼠一般。

你是否曾经花费大量的时间调试Bug,最后却发现问题潜伏在另一个、甚至毫不相关的模块中?

这些问题都是由于高耦合引起的。

在这篇文章中,我们来使用解偶的方式来让代码更加易于理解、维护、测试。

什么是耦合?

在软件开发中,耦合是用来测量2个部分(对象、包、函数)之间相互依赖程度的指标。参考下面的例子:

type Config struct {
	DSN            string
	MaxConnections int
	Timeout        time.Duration
}

type PersonLoader struct {
	Config *Config
}

这2个对象不能离开另一个单独存在(Roy个人理解:虽然可以单独声明Config对象,但单独这个对象是什么也做不了的。),所以说这2者是高耦合的(tightly coupled)。

为什么高耦合的代码是个问题?

高耦合代码会带来很多问题,但最明显的一个就是会导致散弹式修改(shotgun surgery)。散弹式修改是一个术语,用来描述那种修改一个地方的代码导致要修改很多其他地方代码的情况。

考虑下面的代码:

func GetUserEndpoint(resp http.ResponseWriter, req *http.Request) {
	// get and check inputs
	ID, err := getRequestedID(req)
	if err != nil {
		resp.WriteHeader(http.StatusBadRequest)
		return
	}

	// load requested data
	user, err := loadUser(ID)
	if err != nil {
		// technical error
		resp.WriteHeader(http.StatusInternalServerError)
		return
	}
	if user == nil {
		// user not found
		resp.WriteHeader(http.StatusNoContent)
		return
	}

	// prepare output
	switch req.Header.Get("Accept") {
	case "text/csv":
		outputAsCSV(resp, user)

	case "application/xml":
		outputAsXML(resp, user)

	case "application/json":
		fallthrough

	default:
		outputAsJSON(resp, user)
	}
}

现在假设我们要在User对象中添加一个Password字段,但我们不想让这个字段出现在API返回数据中,我们不得不同时修改outputAsCSV()outputAsXML()outputAsJSON()这3个函数。

这看上去似乎没什么问题,但试想如果我们还有其他的接口(endpoint)也将User作为输出的一部分,比如"GetAllUsers",我们不得不做同样的工作。这一切都是由于"GetUser"接口和User类型输出的形式是高耦合的。

换言之,我们把渲染逻辑从"GetUserHandler"接口转移到User类型中,这样我们修改一个地方就可以了。更重要的是,如果我们需要添加新的字段,User类显然更加明显和容易找到,这样也提高了整体代码的可维护性。

在我们深入讨论如何解偶之前,我们还需要讨论一下依赖倒置原则(Dependency Inversion Principle)。

依赖倒置原则

依赖倒置原则(DIP)由Robert C. Martin在1996年在 Dependency Inversion Principle中提出,他对此定义如下:

顶层模块不应该依赖底层模块。这2者都应该依赖于抽象。抽象不应该依赖于细节。细节应该依赖于抽象。 High level modules should not depend on low level modules. Both should depend on abstractions. Abstractions should not depend upon details. Details should depend on abstractions Robert C. Martin

(Roy注:为了统一下面把modules、packages都翻译成模块。)

正如Robert所言,大智慧往往浓缩成非常精炼的句子。下面是我的理解并且翻译成Go代码:

  1. 顶层模块不应该依赖底层模块 - 当我们编写Go程序时,一些包被main()函数调用,这些可以认为是顶层模块。相反的,一些和外部资源交互的模块,比如数据库,典型的不由main()调用而是由逻辑层调用,这就要低1-2级。

顶层模块应该依赖于抽象而不是这些实现细节的模块,这有助于解偶。

  1. 结构体不应该依赖于结构体 - 当一个结构体接受另一个结构体作为方法参数或成员变量时:
type PizzaMaker struct{}

func (p *PizzaMaker) MakePizza(oven *SuperPizaOven5000) {
	pizza := p.buildPizza()
	oven.Bake(pizza)
}

这样的代码耦合度很高导致及其不灵活,让我们考虑一个实际情况:我走进一家旅行社并说:“我想购买一张澳洲航空在星期四3点30分飞往悉尼的15D座位机票。“这种说法旅行社是很难满足我的要求的。

但如果我放松要求,就好像我们将输入的参数由结构体类型改成接口类型一般:“我想购买一张星期四飞往悉尼的机票。“这样旅行社可以更加灵活处理我的请求,也就更可能买到机票。

修改代码如下:

type PizzaMaker struct{}

func (p *PizzaMaker) MakePizza(oven Oven) {
	pizza := p.buildPizza()
	oven.Bake(pizza)
}

type Oven interface {
	Bake(pizza Pizza)
}

这样就可以使用任何类型来实现Bake()方法。

  1. 接口不应该依赖结构体 - 和前文类似,根据特定的情况,我们定义接口:
type Config struct {
	DSN            string
	MaxConnections int
	Timeout        time.Duration
}

type PersonLoader interface {
	Load(cfg *Config, ID int) *Person
}

我们将PersonLoaderConfig这个特定的额结构体绑定到了一起,也就是说想在其他项目中复用PersonLoader将需要修改代码,而这些修改可能导致Bug。换句话说,如果我们像下面这样定义PersonLoader

type PersonLoaderConfig interface {
	DSN() string
	MaxConnections() int
	Timeout() time.Duration
}

type PersonLoader interface {
	Load(cfg PersonLoaderConfig, ID int) *Person
}

这样我们就可以在其他地方复用PersonLoader了。

上面的结构体应该用作提供逻辑实现接口而不是用作传递数据。(原文’Structs above should be taken to mean structs that provide logic and/or implement interfaces and does not include structs that are used as Data Transfer Objects’,翻译的有点别扭。)

解偶

了解背景后,我们来深入了解一下如何解偶。这个例子中我们有2个对象,PersonBlueShoes分别在2个不同的包中:

img1

正如图中所示,它们是高耦合的,Person没法离开BlueShoes单独存在。

如果你之前使用Java/C++,本能的解偶方法是在shoes包中定义一个接口: img2

在大多数语言中到此为止了,然而在Go中,我们可以更进一步。

在我们这么做之前,需要注意一个问题。

你也许注意到了,Person结构只实现了一个Walk()方法,而在Footwear中实现了Walk()Run()。这种差异导致了PersonFootwear之间的关系有些不明确并且违反了Robert C. Martin提出的另一个原则: 接口隔离原则(Interface Segregation Principle ,ISP)

使用者不应该被强迫依赖那些它们不使用的方法。 Clients should not be forced to depend on methods they do not use. Robert C. Martin

幸运的是,我们可以同过在people包中而非shoes包中定义接口来解决这些问题:

img3

这也许看起来是个小事,不值得为此花费时间,但实际上这是意义深远的。在上面这个例子中2个包完全解偶了,people再也不需要依赖或使用shoes包了。

通过这种改变,people包更加简洁明了易于发现,而且以后修改shoes包不会影响到people包。

小结

正如我在《Hands-On Dependency Injection in Go》中写的,Go语言中一个流行的概念和Unix哲学相似:

Write programs that do one thing and do it well. Write programs to work together

(这句就不翻译了,翻译的没意境。)

这个概念充满在Go标准库中,甚至影响到了Go语言的设计。像隐式实现接口(即没有“implements”关键字)使我们(该语言的用户)能够实现解耦代码,这些代码可以用于单一目标并且易于编写。

低耦合的代码更易于理解,因为你需要的所有信息都在一个地方,反过来说使代码更容易测试和扩展。

所以下次你看到一个具体的对象作为函数参数或者成员变量,问问自己"这真的必要吗?",“如果把这个改成接口类型,会不会更加灵活、易于理解和维护?”

Happy Coding!