您想要更易于维护的代码吗?那么更容易测试呢?更容易扩展?依赖注入(DI)可能正是您需要的工具。
在本章中,我们将定义 DI,可能是以一种有点非典型的方式,并探索可能表明您需要 DI 的代码气味。我们还将简要讨论 Go 以及我希望您如何处理本书中提出的想法。
你准备好和我一起踏上更好的旅程了吗?
我们将讨论以下主题:
- 为什么 DI 很重要?
- 什么是 DI?
- 我应该什么时候申请 DI?
- 作为一名围棋程序员,我该如何提高?
希望你已经安装好了。可从下载 https://golang.org/ 或您喜欢的套餐经理。
本章所有代码可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch01 。
作为专业人士,我们永远不应该停止学习。学习是确保我们保持需求并继续为客户提供价值的唯一真正途径。医生、律师和科学家都是备受尊敬的专业人士,都致力于不断学习。为什么程序员应该有所不同?
在这本书中,我们将开始一段旅程,从一些代码开始完成工作,通过选择性地应用 Go 中可用的各种 DI 方法,我们将把它转换成更易于维护、测试和扩展的东西。
本书中并非所有内容都是传统的或惯用的,但我想请您在否认之前先尝试一下。如果你喜欢,那太棒了。如果没有,至少你学会了你不想做的事。
DI 是编码方式,我们所依赖的资源(即函数或结构)是抽象。因为这些依赖关系是抽象的,所以对它们的更改不需要对代码进行更改。这个词的意思是解耦。
这里使用抽象这个词可能有点误导。我指的不是像 Java 中那样的抽象类;Go 没有那个。然而,Go 确实有接口和函数文本(也称为闭包。
考虑下面的一个接口示例和使用它的{ To}T0}函数:
// Saver persists the supplied bytes
type Saver interface {
Save(data []byte) error
}
// SavePerson will validate and persist the supplied person
func SavePerson(person *Person, saver Saver) error {
// validate the inputs
err := person.validate()
if err != nil {
return err
}
// encode person to bytes
bytes, err := person.encode()
if err != nil {
return err
}
// save the person and return the result
return saver.Save(bytes)
}
// Person data object
type Person struct {
Name string
Phone string
}
// validate the person object
func (p *Person) validate() error {
if p.Name == "" {
return errors.New("name missing")
}
if p.Phone == "" {
return errors.New("phone missing")
}
return nil
}
// convert the person into bytes
func (p *Person) encode() ([]byte, error) {
return json.Marshal(p)
}
在前面的例子中,Saver
做什么?它在某处节省了一些钱。它是如何做到这一点的?我们不知道,在处理SavePerson
函数时,我们也不在乎。
让我们来看另一个使用函数文本**:**的示例
// LoadPerson will load the requested person by ID.
// Errors include: invalid ID, missing person and failure to load
// or decode.
func LoadPerson(ID int, decodePerson func(data []byte) *Person) (*Person, error) {
// validate the input
if ID <= 0 {
return nil, fmt.Errorf("invalid ID '%d' supplied", ID)
}
// load from storage
bytes, err := loadPerson(ID)
if err != nil {
return nil, err
}
// decode bytes and return
return decodePerson(bytes), nil
}
decodePerson
做什么?它将bytes
转换成一个人。怎样我们现在不需要知道。
这是我要向大家强调的 DI 的第一个优点:
DI 通过以抽象或通用的方式表达依赖关系,减少了处理一段代码所需的知识
现在,假设前面的代码来自一个系统,该系统将数据存储在网络文件共享(NFS中)。我们将如何为此编写单元测试?始终访问 NFS 将是一种痛苦。由于完全不相关的问题,例如网络连接,任何此类测试失败的频率也比它们应该的要高。
另一方面,通过依赖抽象,我们可以用伪代码替换保存到 NFS 的代码。这样,我们只在与 NFS 隔离的情况下测试代码,如以下代码所示:
func TestSavePerson_happyPath(t *testing.T) {
// input
in := &Person{
Name: "Sophia",
Phone: "0123456789",
}
// mock the NFS
mockNFS := &mockSaver{}
mockNFS.On("Save", mock.Anything).Return(nil).Once()
// Call Save
resultErr := SavePerson(in, mockNFS)
// validate result
assert.NoError(t, resultErr)
assert.True(t, mockNFS.AssertExpectations(t))
}
如果前面的代码看起来不熟悉,不要担心;我们将在本书后面深入研究所有部分。
这给我们带来了 DI 的第二个优势:
DI 使我们能够在独立于依赖项的情况下测试代码
考虑到前面的示例,我们如何测试错误处理代码?我们可以在每次运行测试时通过一些外部脚本关闭 NFS,但这可能会很慢,并且肯定会惹恼依赖它的其他人。
另一方面,我们可以快速制作一个总是失败的伪Saver
,如下代码所示:
func TestSavePerson_nfsAlwaysFails(t *testing.T) {
// input
in := &Person{
Name: "Sophia",
Phone: "0123456789",
}
// mock the NFS
mockNFS := &mockSaver{}
mockNFS.On("Save", mock.Anything).Return(errors.New("save failed")).Once()
// Call Save
resultErr := SavePerson(in, mockNFS)
// validate result
assert.Error(t, resultErr)
assert.True(t, mockNFS.AssertExpectations(t))
}
上述测试快速、可预测且可靠。我们希望从测试中得到的一切!
这给了我们 DI 的第三个优势:
DI enables us to quickly and reliably test situations that are otherwise difficult or impossible
让我们不要忘记 DI 的传统销售宣传。明天,如果我们决定保存到 NoSQL 数据库而不是 NFS,我们的SavePerson
代码将如何更改?一点也没有。我们只需要编写一个新的Saver
实现,这给了我们 DI 的第四个优势:
DI reduces the impact of extensions or changes
归根结底,DI 是一个工具——一个方便的工具,但不是什么灵丹妙药。它是一种可以使代码更易于理解、测试、扩展和重用的工具,它还可以帮助减少经常困扰新开发人员的循环依赖性问题的可能性。
俗话说对于一个只有锤子的人来说,每一个问题都像一颗钉子是古老的,但从来没有比编程更真实的了。作为专业人士,我们应该不断努力获取更多的工具,以便更好地为我们的工作做好准备。DI 虽然是一种非常有用的工具,但只对特定的指甲有用。在我们的例子中,这些指甲是代码。代码气味是代码中潜在深层问题的迹象。
有许多不同类型的代码气味;在本节中,我们将只研究那些可以通过 DI 缓解的问题。在后面的章节中,我们将在尝试从代码中删除这些气味时引用它们。
代码气味通常分为四类:
- 代码膨胀
- 变革阻力
- 白费力气
- 紧耦合
代码膨胀的气味是指在结构或函数中添加了笨拙的代码板,使它们变得难以理解、维护和测试的情况。它们经常出现在较旧的代码中,通常是逐渐降级和缺乏维护的结果,而不是有意选择的结果。
它们可以通过对源代码的视觉扫描或使用圈复杂度检查器(一种指示代码复杂度的软件度量)如 gocyclo(来找到 https://github.com/fzipp/gocyclo )。
这些气味包括:
-
长方法:当代码在计算机上运行时,它是为人类编写的。任何超过 30 行的方法都应该分成更小的块。虽然它对计算机没有影响,但它使我们人类更容易理解。
-
长结构:与长方法类似,结构越长,越难理解和维护。长结构通常也表示结构做得太多。将一个结构拆分为几个较小的结构也是提高代码可重用性潜力的好方法。
-
长参数列表:长参数列表还表明该方法可能做了超出其应做的事情。在添加新特性时,很容易向现有函数添加新参数以说明新用例。这是一个滑坡。对于现有用例,这个新参数是可选的/不必要的,或者表示方法的复杂性显著增加。
-
长条件块:Switch 语句令人惊叹。问题是它们很容易被滥用,并且像众所周知的兔子一样繁殖。然而,也许最重要的问题是它们对代码可读性的影响。长条件块占用大量空间并中断函数的可读性。考虑下面的代码:
func AppendValue(buffer []byte, in interface{}) []byte{
var value []byte
// convert input to []byte
switch concrete := in.(type) {
case []byte:
value = concrete
case string:
value = []byte(concrete)
case int64:
value = []byte(strconv.FormatInt(concrete, 10))
case bool:
value = []byte(strconv.FormatBool(concrete))
case float64:
value = []byte(strconv.FormatFloat(concrete, 'e', 3, 64))
}
buffer = append(buffer, value...)
return buffer
}
通过将interface{}
作为输入,无论我们想在哪里使用它,我们几乎都不得不使用这样一个开关。我们最好将interface{}
更改为接口,然后向接口添加必要的操作。标准库中的json.Marshaller
和driver.Valuer
接口更好地说明了这种方法。
将 DI 应用于这些气味通常会将单个代码片段分解为更小、独立的片段,从而降低其复杂性,从而使它们更易于理解、维护和测试。
在这些情况下,很难和/或很慢地添加新功能。类似地,测试通常更难编写,尤其是针对故障条件的测试。与代码膨胀类似,这些气味可能是逐渐退化和缺乏维护的结果,但也可能是由于缺乏前期规划或糟糕的 API 设计造成的。
可以通过检查拉取请求日志或提交历史记录,特别是确定新功能是否需要在代码的不同部分进行许多小更改来找到它们。 如果您的团队跟踪功能速度,并且您注意到它正在下降,这也是一个可能的原因。
这些气味包括:
- 鸟枪手术:这是指对一个结构进行的微小更改需要对其他结构进行更改。这些变化意味着所使用的组织或抽象是不正确的。通常,所有这些更改都应该在一个类中。
在下面的示例中,您可以看到向 person 数据添加电子邮件字段将如何导致所有三个结构的更改(
Presenter
、Validator
和Saver
:
// Renderer will render a person to the supplied writer
type Renderer struct{}
func (r Renderer) render(name, phone string, output io.Writer) {
// output the person
}
// Validator will validate the supplied person has all the
// required fields
type Validator struct{}
func (v Validator) validate(name, phone string) error {
// validate the person
return nil
}
// Saver will save the supplied person to the DB
type Saver struct{}
func (s *Saver) Save(db *sql.DB, name, phone string) {
// save the person to db
}
- 泄漏实现细节:Go 社区中比较流行的习惯用法之一是接受接口,返回结构。这是一个吸引人的说法,但它的简单掩盖了它的聪明。当一个函数接受一个结构时,它将用户与一个特定的实现联系在一起——这种严格的关系使得将来的更改或额外的使用变得困难。从广义上讲,如果实现细节发生变化,API 也会发生变化,并强制用户进行变化。
将 DI 应用于这些气味通常是未来的一项良好投资。虽然不修复它们不是致命的,但代码将逐渐降级,直到您处理众所周知的大泥球。你知道 a 类软件包,没有人理解,没有人信任,只有勇敢或愚蠢的人才愿意改变。DI 使您能够从实现选择中分离出来,从而使重构、测试和单独维护小块代码变得更容易。
在这些情况下,维护代码的成本高于需要的成本。他们通常是由于懒惰或缺乏经验造成的。复制/粘贴代码总是比仔细重构代码容易。问题是,像这样编码就像吃不健康的零食。现在感觉很好,但长期后果很糟糕。
可以通过仔细查看源代码并扪心自问我真的需要这些代码吗?或我能让这更容易理解吗?
使用 dupl 等工具(https://github.com/mibk/dupl 或 PMD(https://pmd.github.io/ 还将帮助您确定需要调查的代码区域。
这些气味包括:
- 重复代码过多:首先,请不要对这件事发狂。虽然在大多数情况下,重复的代码是一件坏事,但有时复制代码会导致系统更易于维护和发展。我们将在第 8 章、*依赖项注入【配置】*中处理这种气味的常见来源。
- 过度评论:给你身后的人留个便条,即使距离现在只有 6 个月,也是一件友好而专业的事情。但当那张便条成为一篇文章时,就到了重构的时候了:
// Excessive comments
func outputOrderedPeopleA(in []*Person) {
// This code orders people by name.
// In cases where the name is the same, it will order by
// phone number.
// The sort algorithm used is a bubble sort
// WARNING: this sort will change the items of the input array
for _, p := range in {
// ... sort code removed ...
}
outputPeople(in)
}
// Comments replaced with descriptive names
func outputOrderedPeopleB(in []*Person) {
sortPeople(in)
outputPeople(in)
}
- 过于复杂的代码:代码越难让别人理解,就越糟糕。通常,这是因为有人试图太花哨或在结构或命名上没有投入足够的精力。从更自私的角度来看,如果你是唯一一个理解一段代码的人,那么你是唯一一个能够处理它的人。意思是,你注定要永远保持它。以下代码的作用是什么:
for a := float64(0); a < 360; a++ {
ra := math.Pi * 2 * a / 360
x := r*math.Sin(ra) + v
y := r*math.Cos(ra) + v
i.Set(int(x), int(y), c)
}
- 干/湿代码:不要重复自己(干)原则旨在通过将责任分组并提供清晰的抽象来减少重复工作。相比之下,在湿代码(有时称为浪费每个人的时间代码)中,您会在许多地方发现同样的责任。这种气味经常出现在格式化或转换代码中。这类代码应该存在于系统边界,即转换用户输入或格式化输出。
虽然这些气味中的许多在没有 DI 的情况下是可以修复的,但 DI 提供了一种更简单的方法来将重复内容提升并转换为抽象内容,然后可以使用抽象内容来减少重复并提高代码的可读性和可维护性。
对于人们来说,紧密耦合可能是一件好事。对于 Go 代码,它实际上不是。耦合是一种度量对象如何相互关联或相互依赖的方法。当存在紧密耦合时,这种相互依赖性迫使对象或包一起演化,增加了复杂性和维护成本。
与耦合相关的气味可能是最阴险和顽固的,但在处理时,迄今为止是最有益的。它们通常是缺乏面向对象设计或接口使用不足的结果。
遗憾的是,我没有一个方便的工具来帮助你找到这些气味,但我相信,在本书的结尾,你将不会有困难发现和处理它们。
通常,我发现先以紧密耦合的形式实现一个特性,然后在提交代码之前向后进行解耦和彻底的单元测试是很有用的。对我来说,它在正确的抽象不明显的情况下特别有用。
这些气味包括:
- 对神的依赖:这些是知道太多或做太多的大型物体。虽然这是一种常见的代码气味,应该避免像瘟疫一样的东西,但从 DI 的角度来看,问题是太多的代码依赖于这一个对象。当它们存在并且我们不小心时,不久 Go 就会因为循环依赖而拒绝编译。有趣的是,Go 不是在对象级别而是在包级别考虑依赖项和导入。所以我们也必须避免使用 God 包。我们将在第 8 章中解决一个非常常见的 God 对象问题,通过配置进行依赖注入。
- 循环依赖:这是包 A 依赖于包 B,包 B 依赖于包 A 的地方。这是一个容易犯的错误,有时很难摆脱。
在下面的示例中,虽然配置可以说是一个God
对象,因此是一种代码味道,但我很难找到更好的方法从单个 JSON 文件导入配置。相反,我认为需要解决的问题是orders
包对config
包的使用
package config
import ...
// Config defines the JSON format of the config file
type Config struct {
// Address is the host and port to bind to.
// Default 0.0.0.0:8080
Address string
// DefaultCurrency is the default currency of the system
DefaultCurrency payment.Currency
}
// Load will load the JSON config from the file supplied
func Load(filename string) (*Config, error) {
// TODO: load currency from file
return nil, errors.New("not implemented yet")
}
在尝试使用config
包时,您可以看到Currency
类型属于Package
包,因此将其包含在config
中,如上例所示,会导致循环依赖:
package payment
import ...
// Currency is custom type for currency
type Currency string
// Processor processes payments
type Processor struct {
Config *config.Config
}
// Pay makes a payment in the default currency
func (p *Processor) Pay(amount float64) error {
// TODO: implement me
return errors.New("not implemented yet")
}
- 对象狂欢:当一个对象对另一个对象的内部有太多的了解和/或访问时,或者换句话说,对象之间的封装不足时,就会发生这种情况。因为对象是在臀部处连接的,所以它们经常必须一起进化,增加了理解代码和维护代码的成本。考虑下面的代码:
type PageLoader struct {
}
func (o *PageLoader) LoadPage(url string) ([]byte, error) {
b := newFetcher()
// check cache
payload, err := b.cache.Get(url)
if err == nil {
// found in cache
return payload, nil
}
// call upstream
resp, err := b.httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// extract data from HTTP response
payload, err = ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// save to cache asynchronously
go func(key string, value []byte) {
b.cache.Set(key, value)
}(url, payload)
// return
return payload, nil
}
type Fetcher struct {
httpClient http.Client
cache *Cache
}
在本例中,PageLoader
重复调用Fetcher
的成员变量。如此之多以至于,如果Fetcher
的实施发生变化,PageLoader
很可能会受到影响。在这种情况下,这两个对象应该合并在一起,因为PageLoader
没有额外的功能。
- 溜溜球问题:这种气味的标准定义是*,因为继承图太长、太复杂,程序员必须不断翻阅代码才能理解它*。考虑到 Go 没有继承权,你会认为我们不会遇到这个问题。然而,如果你足够努力,用过多的构图,这是可能的。为了解决这个问题,最好让关系尽可能的肤浅和抽象。这样,在进行更改时,我们可以将注意力集中在更小的范围内,并将许多小对象组合到更大的系统中。
- 功能嫉妒:当一个函数大量使用另一个对象时,它会嫉妒它。通常,一种指示,表示函数应该从它羡慕的对象移开。DI 可能不是解决这一问题,但这种气味确实表明高耦合,因此,是一个指标,考虑应用 DI 技术:
func doSearchWithEnvy(request searchRequest) ([]searchResults, error) {
// validate request
if request.query == "" {
return nil, errors.New("search term is missing")
}
if request.start.IsZero() || request.start.After(time.Now()) {
return nil, errors.New("start time is missing or invalid")
}
if request.end.IsZero() || request.end.Before(request.start) {
return nil, errors.New("end time is missing or invalid")
}
return performSearch(request)
}
func doSearchWithoutEnvy(request searchRequest) ([]searchResults, error) {
err := request.validate()
if err != nil {
return nil, err
}
return performSearch(request)
}
当您的代码变得不那么耦合时,您会发现各个部分(包、接口和结构)将变得更加集中。这被称为具有高内聚。低耦合和高内聚都是可取的,因为它们使代码更易于理解和使用。
在我们阅读本书的过程中,您将看到一些奇妙的编码技术和一些不太好的技术。我想请你们花点时间思考一下,哪一个是哪一个。持续的学习应该与健康的怀疑态度相调和。对于每种技术,我都会列出其优缺点,但我会请您深入挖掘。问问自己以下几点:
- 这项技术的目的是什么?
- 应用此技术后,我的代码会是什么样子?
- 我真的需要它吗?
- 使用这种方法有什么缺点吗?
即使你内心的怀疑者否定了这项技术,你至少学会了识别一些你不喜欢也不想使用的东西,学习永远是一种胜利。
就我个人而言,我尽量避免使用术语惯用的围棋,但一本围棋书如果不以某种形式加以阐述,可能是不完整的。我避免使用它,因为我经常看到它被用作打人的棍子。本质上,这不是惯用语,因此它是错误的并且,进一步说,我是惯用语,因此比你更好。我相信编程是一门手艺,虽然一门手艺的应用应该有某种形式的一致性,但它应该像所有手艺一样灵活。毕竟,创新往往是通过改变或打破规则来实现的。那么,惯用的围棋对我来说意味着什么呢?
我将尽可能宽松地定义它:
- 用
gofmt
格式化您的代码:对于我们程序员来说,真正少了一件争论的事情。这是官方风格,有官方工具支持。让我们找些更实质性的东西来争论。 - 阅读、应用并定期回顾有效 Go(中的想法 https://golang.org/doc/effective_go.html 和代码评审意见(https://github.com/golang/go/wiki/CodeReviewComments :这些页面中蕴含着大量的智慧,如此之多以至于可能不可能从一次阅读中收集到全部信息。
- 积极应用Unix 理念:它指出我们应该设计做一件事的代码,但要做得好,并且与其他代码**配合得好。
虽然这三件事对我来说是最低限度的,但还有一些其他想法引起了共鸣:
- 接受接口和返回结构:虽然接受接口会导致代码很好地解耦,但返回的结构可能会给您带来矛盾。我知道他们一开始是这样对我的。虽然输出一个接口可能感觉它更松散耦合,但事实并非如此。无论您将其编码为什么,输出只能是一件事。如果您需要返回接口,那么返回接口是可以的,但是强迫自己这样做只会导致编写更多的代码。
- 合理默认值:自从切换到 Go 后,我发现很多情况下我想让我的用户能够配置模块,但这种配置经常不被使用。在其他语言中,这可能导致多个构造器或很少使用的参数,但通过应用此模式,我们最终得到了更干净的 API 和更少的代码来维护。
如果你问我新围棋程序员最常犯的错误是什么,我会毫不犹豫地告诉你,围棋引入了其他语言模式。我知道这是我早期最大的错误。我的第一个 Go 服务看起来像是用 Go 编写的 Java 应用程序。结果不仅不理想,而且相当痛苦,特别是当我试图实现诸如继承之类的目标时。我也有过类似的经验,你可能会在Node.js
中看到,用函数式编程 Go。
简言之,请不要这样做。尽可能经常地重读有效的围棋和围棋博客,直到你发现自己在使用小界面,毫无保留地启动围棋程序,热爱频道,并想知道为什么你需要比组合更多的东西来实现好的多态性。
在本章中,我们开始了一段旅程,这段旅程将导致更易于维护、扩展和测试的代码。
我们从定义 DI 开始,并研究它能给我们带来的一些好处。通过几个例子,我们了解了 Go 中的情况。
在那之后,我们开始识别需要注意的代码气味,这可以通过应用 DI 来解决或缓解。
最后,我们检查了我认为的 Go 代码的外观,我向您提出了挑战,让您对本书中介绍的技术持怀疑态度并加以批判。
- 什么是 DI?
- DI 的四大突出优势是什么?
- 它解决了哪些问题?
- 为什么怀疑很重要?
- 成语围棋对你来说意味着什么?
Packt 还有许多其他学习 DI 和 Go 的优秀资源: