Go依赖注入实用指南

您所在的位置:网站首页 lsp编译器 Go依赖注入实用指南

Go依赖注入实用指南

2023-04-02 20:30| 来源: 网络整理| 查看: 265

2002 年,Robert“Bob 叔叔”Martin出版了《敏捷软件开发、原则、模式和实践》一书,他在书中定义了可重用程序的五条原则,他称之为坚实原则。虽然在一本关于 10 年后发明的编程语言的书中包含这些原则似乎有些奇怪,但这些原则在今天仍然是相关的。

在本章中,我们将简要分析这些原则中的每一条,它们与依赖注入(DI之间的关系,以及这对 Go 意味着什么。SOLID 是五种流行的面向对象软件设计原则的缩写:

单一责任原则 开闭原理 利斯科夫替换原理 界面分离原理 依赖倒置原理 技术要求

本章的唯一要求是对对象和接口的基本理解以及开放的思维

本章所有代码可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch02 。

您可以在本章末尾的进一步阅读部分找到本章中提到的其他信息和其他参考资料的链接。

单一责任原则(SRP)

“一个班级应该只有一个理由去改变。” -罗伯特·C·马丁 Go 没有类,但如果我们稍微看一眼,用对象(结构、函数、接口或包)替换类,那么这个概念仍然适用。

为什么我们希望我们的对象只做一件事?让我们看一看做一件事的两个对象:

这些对象简单易用,用途广泛。

设计对象,使它们都只做一件事,从抽象上讲,听起来不错。但您可能认为,对整个系统这样做会添加更多的代码。是的,会的。然而,它并没有增加复杂性;事实上,这大大减少了它。每段代码都会更小,更容易理解,因此更容易测试。这一事实为我们提供了 SRP 的第一个优势: SRP 通过将代码分解成更小、更简洁的片段来降低复杂性

有了“单一责任原则”这样的名称,我们可以放心地认为这一切都与责任有关,但到目前为止,我们所谈论的只是变化。为什么会这样?让我们看一个例子:

// Calculator calculates the test coverage for a directory // and it's sub-directories type Calculator struct { // coverage data populated by `Calculate()` method data map[string]float64 } // Calculate will calculate the coverage func (c *Calculator) Calculate(path string) error { // run `go test -cover ./[path]/...` and store the results return nil } // Output will print the coverage data to the supplied writer func (c *Calculator) Output(writer io.Writer) { for path, result := range c.data { fmt.Fprintf(writer, "%s -> %.1f\n", path, result) } }

代码看起来很合理,一个成员变量和两个方法。然而,它不符合 SRP。假设应用程序成功了,我们决定还需要将结果输出到 CSV。我们可以添加一个方法来实现这一点,如下代码所示:

// Calculator calculates the test coverage for a directory // and it's sub-directories type Calculator struct { // coverage data populated by `Calculate()` method data map[string]float64 } // Calculate will calculate the coverage func (c *Calculator) Calculate(path string) error { // run `go test -cover ./[path]/...` and store the results return nil } // Output will print the coverage data to the supplied writer func (c Calculator) Output(writer io.Writer) { for path, result := range c.data { fmt.Fprintf(writer, "%s -> %.1f\n", path, result) } } // OutputCSV will print the coverage data to the supplied writer func (c Calculator) OutputCSV(writer io.Writer) { for path, result := range c.data { fmt.Fprintf(writer, "%s,%.1f\n", path, result) } }

我们已经更改了结构并添加了另一个Output()方法。我们为结构增加了更多的责任,同时也增加了复杂性。在这个简单的示例中,我们的更改仅限于一个方法,因此没有破坏前面代码的风险。然而,随着结构变得更大、更复杂,我们的更改不太可能如此干净。

相反,如果我们将职责分解为Calculate和Output,那么添加更多的输出只会定义新的结构。此外,如果我们决定不喜欢默认输出格式,我们可以将其与其他部分分开更改。

让我们尝试另一种实现:

// Calculator calculates the test coverage for a directory // and it's sub-directories type Calculator struct { // coverage data populated by `Calculate()` method data map[string]float64 } // Calculate will calculate the coverage func (c *Calculator) Calculate(path string) error { // run `go test -cover ./[path]/...` and store the results return nil } func (c *Calculator) getData() map[string]float64 { // copy and return the map return nil } type Printer interface { Output(data map[string]float64) } type DefaultPrinter struct { Writer io.Writer } // Output implements Printer func (d *DefaultPrinter) Output(data map[string]float64) { for path, result := range data { fmt.Fprintf(d.Writer, "%s -> %.1f\n", path, result) } } type CSVPrinter struct { Writer io.Writer } // Output implements Printer func (d *CSVPrinter) Output(data map[string]float64) { for path, result := range data { fmt.Fprintf(d.Writer, "%s,%.1f\n", path, result) } }

你注意到打印机有什么重要的地方吗?它们与计算毫无关联。它们可以用于相同格式的任何数据。这导致了 SRP 的第二个优势: SRP 增加了代码的潜在可重用性。

在覆盖率计算器的第一个实现中,为了测试Output()方法,我们将首先调用Calculate()方法。这种方法通过将计算与输出耦合,增加了测试的复杂性。考虑以下情景:

我们如何测试没有结果? 我们如何测试边缘条件,例如 0%或 100%覆盖率?

在去掉这些责任之后,我们应该鼓励自己以较少相互依赖的方式考虑每个部分的输入和输出,从而使测试更容易编写和维护。这导致了 SRP 的第三个优势: SRP 使测试更易于编写和维护。 SRP 也是提高通用代码可读性的一种很好的方法。请看下一个示例:

func loadUserHandler(resp http.ResponseWriter, req *http.Request) { err := req.ParseForm() if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64) if err != nil { resp.WriteHeader(http.StatusPreconditionFailed) return } row := DB.QueryRow("SELECT * FROM Users WHERE ID = ?", userID) person := &Person err = row.Scan(&person.ID, &person.Name, &person.Phone) if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } encoder := json.NewEncoder(resp) encoder.Encode(person) }

我敢打赌这花了五秒钟多的时间才明白。这个代码怎么样?

func loadUserHandler(resp http.ResponseWriter, req *http.Request) { userID, err := extractIDFromRequest(req) if err != nil { resp.WriteHeader(http.StatusPreconditionFailed) return } person, err := loadPersonByID(userID) if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } outputPerson(resp, person) }

通过在函数级别应用 SRP,我们减少了函数的膨胀并提高了其可读性。该函数的唯一职责是协调对其他函数的调用。

这与 DI 有什么关系?

当我们将 DI 应用于代码时,毫不奇怪地注入了依赖项,通常是以函数参数的形式。如果您看到一个具有许多注入依赖项的函数,这可能表明该方法做得太多。

此外,应用 SRP 将为我们的对象设计提供信息。因此,这有助于我们确定何时何地使用 DI。

这对围棋意味着什么?

在[第 1 章] 01.html

Go 接口、结构和函数

在接口和结构级别,应用 SRP 会产生许多小型接口。符合 SRP 的函数具有很少的输入,并且非常短(即,它的代码屏幕少于一个)。这两个特性本质上都解决了我们在[第 1 章] 01.html

通过解决代码膨胀问题,我们发现 SRP 的一个较少宣传的优点是它使代码更容易理解。简单地说,当一段代码做一件事时,它的目的就更清楚了。

在将 SRP 应用于现有代码时,您通常会将代码分成更小的部分。您可能会自然地对此感到厌恶,因为您可能还需要编写更多的测试。在将结构或接口拆分为多个部分的情况下,这可能是正确的。但是,如果您正在重构的代码具有很高的单元测试覆盖率,那么您可能已经有了许多需要的测试。他们只需要移动一下。

另一方面,当将 SRP 应用于函数以减少膨胀时,不需要新的测试;原始功能的测试完全可以接受。让我们看一个loadUserHandler()的测试示例,如前一个示例所示:

func TestLoadUserHandler(t *testing.T) { // build request req := &http.Request{ Form: url.Values, } req.Form.Add("UserID", "1234") // call function under test resp := httptest.NewRecorder() loadUserHandler(resp, req) // validate result assert.Equal(t, http.StatusOK, resp.Code) expectedBody := `` + "\n" assert.Equal(t, expectedBody, resp.Body.String()) }

这个测试可以应用于我们函数的任何一种形式,并将实现相同的功能。在这种情况下,我们是为了可读性而重构的,我们不希望有任何东西阻止我们这样做。此外,从 API(公共方法或其他人调用的函数)进行测试更稳定,因为 API 契约的更改可能性小于内部实现。

围棋包

在包级别应用 SRP 可能更难。系统通常是分层设计的。例如,通常可以看到 HTTP REST 服务的层按以下方式排列:

这些抽象是好的和清楚的;然而,当我们的服务有多个端点时,问题开始出现。我们很快就会得到一个充满完全不相关逻辑的怪物包。另一方面,好的软件包小而简洁,目的明确。

很难找到正确的抽象概念。通常,当我需要灵感时,我会求助于专家并检查标准 Go 库。举个例子,让我们来看看 PosiT0 包:

正如您所看到的,每个不同的类型都整齐地组织在自己的包中,但所有包仍然按照父目录进行逻辑分组。我们的 REST 服务将对其进行分解,如下图所示:

我们最初的抽象是在正确的轨道上,只是从太高的层次。 encoding包的另一个不明显的方面是共享代码在父包中。当开发一个功能时,程序员通常会认为我需要我之前编写的代码,并试图将代码提取到commons或utils包中。请抵制这种诱惑重用代码是绝对正确的,但您应该抵制通用包名称的诱惑。这样的包由于没有明确的目的而本质上违反了 SRP。

另一个常见的诱惑是在现有代码旁边添加新代码。让我们想象一下,我们正在编写前面提到的encoding包,我们制作的第一个编码器是 JSON。接下来,我们添加了 GobEncoder,一切都很顺利。再加上几个编码器,我们突然有了一个包含大量代码和大量导出 API 的实质性包。在某个时候,我们的小encoding包的文档变得太长,用户很难理解。类似地,包中的代码太多,扩展和调试工作会因为很难找到东西而减慢。 SRP 帮助我们确定改变的原因;改变的多重原因表明多重责任。分离这些责任使我们能够开发更好的抽象。

如果你从一开始就有时间或意愿去做,那太棒了。然而,应用 SRP 并从一开始就找到正确的抽象是困难的。您可以通过先打破规则,然后使用后续更改来发现软件想要如何发展来应对这种情况,使用发展的力量作为重构的基础。

开/闭原理(OCP)

“软件实体(类、模块、函数等)应开放进行扩展,但应关闭进行修改。” -伯特兰·迈耶

在讨论软件工程时,open和closed这两个术语不是我经常听到的,因此,也许它们需要一些解释。

开放意味着我们应该能够通过添加新的行为和特性来扩展或调整代码。关闭意味着我们应该避免对现有代码进行更改,这些更改可能导致 bug 或其他类型的回归。

这两个特征看起来可能相互矛盾,但谜题中缺少的是范围。当谈到开放时,我们谈论的是软件的设计或结构。从这个角度来看,开放意味着很容易添加新包、新接口或现有接口的新实现。

当我们谈论关闭时,我们谈论的是现有代码,并最小化我们对它所做的更改,特别是其他人使用的 API。这给我们带来了 OCP 的第一个优势: OCP 有助于降低增加和扩展的风险

您可以将 OCP 视为一种风险缓解策略。修改现有代码总是有一些风险,尤其是对其他人使用的代码的更改。虽然我们可以也应该通过单元测试来保护自己不受这种风险的影响,但这些测试仅限于我们想要的场景和我们可以想象的误用;他们不会涵盖我们的用户能想到的一切。

以下代码不符合 OCP:

func BuildOutput(response http.ResponseWriter, format string, person Person) { var err error switch format { case "csv": err = outputCSV(response, person) case "json": err = outputJSON(response, person) } if err != nil { // output a server error and quit response.WriteHeader(http.StatusInternalServerError) return } response.WriteHeader(http.StatusOK) }

第一个暗示出问题的是switch语句。不难想象需求发生变化的情况,我们可能需要添加甚至删除输出格式。

如果我们需要添加另一种格式,需要改变多少?见下文:

我们需要为开关添加另一个案例条件:此方法已经有 18 行了;在一个屏幕上看不到所有格式之前,我们还需要添加多少格式?这个switch陈述存在于其他多少地方?它们也需要更新吗? 我们需要编写另一个格式化函数:这是不可避免的三个更改之一 该方法的调用方必须更新才能使用新格式:这是另一个不可避免的更改 我们必须添加另一组测试场景来匹配新的格式:这也是不可避免的;然而,这里的测试可能会比单独测试格式更长

一开始只是一个小而简单的改变,现在开始感到比我们预期的更艰巨和危险。

让我们用一个抽象来替换 format 输入参数和switch语句,如下代码所示:

func BuildOutput(response http.ResponseWriter, formatter PersonFormatter, person Person) { err := formatter.Format(response, person) if err != nil { // output a server error and quit response.WriteHeader(http.StatusInternalServerError) return } response.WriteHeader(http.StatusOK) }

这次有多少变化?让我们看看:

我们需要定义PersonFormatter接口的另一个实现 必须更新方法的调用方才能使用新格式 我们必须为新的PersonFormatter编写测试场景

这要好得多:我们只做了三个不可避免的更改,我们根本没有更改主要功能。这向我们展示了 OCP 的第二个优势: OCP 可以帮助减少添加或删除功能所需的更改数量。

另外,如果在添加新的格式化程序后,我们的新结构中碰巧出现了一个 bug,那么新代码只能在一个地方出现。这是 OCP 的第三个优点: OCP 将 bug 的位置缩小到只包含新代码及其用法。

让我们看另一个例子,在这个例子中,我们没有应用 DI:

func GetUserHandlerV1(resp http.ResponseWriter, req *http.Request) { // validate inputs err := req.ParseForm() if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64) if err != nil { resp.WriteHeader(http.StatusPreconditionFailed) return } user := loadUser(userID) outputUser(resp, user) } func DeleteUserHandlerV1(resp http.ResponseWriter, req *http.Request) { // validate inputs err := req.ParseForm() if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64) if err != nil { resp.WriteHeader(http.StatusPreconditionFailed) return } deleteUser(userID) }

如您所见,我们的两个 HTTP 处理程序都从表单中提取数据,然后将其转换为数字。有一天,我们决定加强输入验证,确保数字为正。可能的结果是什么?一些非常讨厌的鸟枪手术。然而,在这种情况下,没有办法。我们把事情搞得一团糟;现在我们需要把它清理干净。希望修复非常明显,将重复的逻辑提取到一个位置,然后在那里添加新的验证,如以下代码所示:

func GetUserHandlerV2(resp http.ResponseWriter, req *http.Request) { // validate inputs err := req.ParseForm() if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } userID, err := extractUserID(req.Form) if err != nil { resp.WriteHeader(http.StatusPreconditionFailed) return } user := loadUser(userID) outputUser(resp, user) } func DeleteUserHandlerV2(resp http.ResponseWriter, req *http.Request) { // validate inputs err := req.ParseForm() if err != nil { resp.WriteHeader(http.StatusInternalServerError) return } userID, err := extractUserID(req.Form) if err != nil { resp.WriteHeader(http.StatusPreconditionFailed) return } deleteUser(userID) }

遗憾的是,原始代码没有减少,但它确实更易于阅读。除此之外,我们已经证明了自己不会对UserID字段的验证进行任何进一步的更改。

对于我们的两个例子,满足 OCP 的关键是找到正确的抽象

这与 DI 有什么关系?

在[第一章] 01.html

这对围棋意味着什么?

通常,在讨论 OCP 时,示例中充斥着抽象类、继承、虚拟函数以及 Go 没有的各种东西。还是这样?

抽象类到底是什么?它究竟想达到什么目的?

它试图为多个实现之间共享的代码提供一个位置。我们可以在围棋中这样做,它被称为组合。您可以在以下代码中看到它在工作:

type rowConverter struct { } // populate the supplied Person from *sql.Row or *sql.Rows object func (d *rowConverter) populate(in *Person, scan func(dest ...interface{}) error) error { return scan(in.Name, in.Email) } type LoadPerson struct { // compose the row converter into this loader rowConverter } func (loader *LoadPerson) ByID(id int) (Person, error) { row := loader.loadFromDB(id) person := Person // call the composed "abstract class" err := loader.populate(&person, row.Scan) return person, err } type LoadAll struct { // compose the row converter into this loader rowConverter } func (loader *LoadPerson) All() ([]Person, error) { rows := loader.loadAllFromDB() defer rows.Close() output := []Person for rows.Next() { person := Person // call the composed "abstract class" err := loader.populate(&person, rows.Scan) if err != nil { return nil, err } } return output, nil }

在前面的例子中,我们已经将一些共享逻辑提取到一个rowConverter结构中。然后,通过将该结构嵌入到其他结构中,我们可以在不做任何更改的情况下使用它。我们已经实现了抽象类和 OCP 的目标。我们的代码是开放的;我们可以嵌入任何我们喜欢但封闭的地方。嵌入类不知道它是嵌入的,也不需要进行任何更改。

之前,我们将关闭定义为保持不变,但将范围仅限于 API 中其他人导出或使用的部分。期望内部实现细节(包括私有成员变量)永远不会改变是不合理的。实现这一点的最佳方法是隐藏这些实现细节。这称为封装。

在包级别,封装很简单:我们将其设置为私有。这里有一个很好的经验法则,就是把所有事情都保密,只有在你真正需要的时候才公开。同样,我的理由是避免风险和工作。你出口某物的那一刻,就是某人可以信赖它的那一刻。一旦他们依赖它,它就应该关闭;您必须维护它,任何更改都有较高的损坏风险。通过适当的封装,包中的更改应该对现有用户不可见。

在对象级别,private 并不意味着它在其他语言中所起的作用,因此我们必须学会自己的行为。访问私有成员变量会使对象紧密耦合,这一决定会反过来影响我们 Go 的类型系统的一个我最喜欢的特性是能够将方法附加到任何东西上。假设您正在为运行状况检查编写 HTTP 处理程序。它只返回状态204(无内容)。我们需要满足的接口如下:

type Handler interface { ServeHTTP(ResponseWriter, *Request) }

一个简单的实现可能如以下代码所示:

// a HTTP health check handler in long form type healthCheck struct { } func (h *healthCheck) ServeHTTP(resp http.ResponseWriter, _ *http.Request) { resp.WriteHeader(http.StatusNoContent) } func healthCheckUsage() { http.Handle("/health", &healthCheckLong) }

我们可以创建一个新的结构来实现一个接口,但这至少需要五行代码。我们可以将其减少为三个,如下代码所示:

// a HTTP health check handler in short form func healthCheck(resp http.ResponseWriter, _ *http.Request) { resp.WriteHeader(http.StatusNoContent) } func healthCheckUsage() { http.Handle("/health", http.HandlerFunc(healthCheck)) }

在本例中,秘方隐藏在标准库中。我们正在将函数转换为http.HandlerFunc类型,该类型附带了ServeHTTP方法。这个漂亮的小把戏让我们很容易满足http.Handler接口。正如我们在本章中已经看到的,朝接口的方向发展会使我们获得更易于维护和扩展的耦合更少的代码。

利斯科夫替换原理(LSP)

“如果对于类型 S 的每个对象 o1,有一个类型 T 的对象 o2,使得对于根据 T 定义的所有程序 P,当 o1 代替 o2 时,P 的行为不变,则 S 是 T 的子类型。” -芭芭拉·利斯科夫

在读了三遍之后,我仍然不确定我是否把它弄明白了。谢天谢地,Robert C.Martin 让我们更容易理解,并总结如下:

“子类型必须可以替换其基类型。” -罗伯特 C.马丁

我可以跟着。然而,他不是又在谈论抽象类了吗?可能正如我们在 OCP 一节中看到的,虽然 Go 没有抽象类或继承,但它有一个组合和接口实现。

让我们退一步,看看这个原则的动机。LSP 要求亚型可以相互替代。我们可以使用 Go 接口,这将始终适用。

但是等一下,这个代码呢:

func Go(vehicle actions) { if sled, ok := vehicle.(*Sled); ok { sled.pushStart() vehicle.startEngine() } vehicle.drive() } type actions interface { drive() startEngine() } type Vehicle struct { } func (v Vehicle) drive() { // TODO: implement } func (v Vehicle) startEngine() { // TODO: implement } func (v Vehicle) stopEngine() { // TODO: implement } type Car struct { Vehicle } type Sled struct { Vehicle } func (s Sled) startEngine() { // override so that is does nothing } func (s Sled) stopEngine() { // override so that is does nothing } func (s Sled) pushStart() { // TODO: implement }

它使用一个接口,但显然违反了 LSP。我们可以通过添加更多接口来解决此问题,如以下代码所示:

func Go(vehicle actions) { switch concrete := vehicle.(type) { case poweredActions: concrete.startEngine() case unpoweredActions: concrete.pushStart() } vehicle.drive() } type actions interface { drive() } type poweredActions interface { actions startEngine() stopEngine() } type unpoweredActions interface { actions pushStart() } type Vehicle struct { } func (v Vehicle) drive() { // TODO: implement } type PoweredVehicle struct { Vehicle } func (v PoweredVehicle) startEngine() { // common engine start code } type Car struct { PoweredVehicle } type Buggy struct { Vehicle } func (b Buggy) pushStart() { // do nothing }

然而,这并不是更好。这段代码仍然有味道,这表明我们可能使用了错误的抽象或组合。让我们再次尝试重构:

func Go(vehicle actions) { vehicle.start() vehicle.drive() } type actions interface { start() drive() } type Car struct { poweredVehicle } func (c Car) start() { c.poweredVehicle.startEngine() } func (c Car) drive() { // TODO: implement } type poweredVehicle struct { } func (p poweredVehicle) startEngine() { // common engine start code } type Buggy struct { } func (b Buggy) start() { // push start } func (b Buggy) drive() { // TODO: implement }

那好多了。Buggy这句话并没有强制执行毫无意义的方法,也没有包含任何不需要的逻辑,而且两种车型的使用都很好且干净。这说明了 LSP 的一个关键点: LSP 指的是行为而不是实施。

对象可以实现它喜欢的任何接口,但这并不意味着它在行为上与同一接口的其他实现一致。请看以下代码:

type Collection interface { Add(item interface) Get(index int) interface } type CollectionImpl struct { items []interface } func (c *CollectionImpl) Add(item interface{}) { c.items = append(c.items, item) } func (c *CollectionImpl) Get(index int) interface{} { return c.items[index] } type ReadOnlyCollection struct { CollectionImpl } func (ro *ReadOnlyCollection) Add(item interface{}) { // intentionally does nothing }

在前面的例子中,我们通过实现所有的方法满足了 API 合同(如交付),但我们将不需要的方法变成了不可操作的方法。通过让我们的ReadOnlyCollection实现Add()方法,它满足了接口,但引入了潜在的混淆。当您有一个接受Collection的函数时会发生什么?当您致电Add()时,您希望发生什么?

在这种情况下,修复方法可能会让你大吃一惊。我们可以将关系翻转过来,而不是将MutableCollection变成ImmutableCollection,如下代码所示:

type ImmutableCollection interface { Get(index int) interface } type MutableCollection interface { ImmutableCollection Add(item interface) } type ReadOnlyCollectionV2 struct { items []interface } func (ro *ReadOnlyCollectionV2) Get(index int) interface{} { return ro.items[index] } type CollectionImplV2 struct { ReadOnlyCollectionV2 } func (c *CollectionImplV2) Add(item interface{}) { c.items = append(c.items, item) }

这种新结构的一个好处是,我们现在可以让编译器确保在需要MutableCollection的地方不使用ImmutableCollection。

这与 DI 有什么关系?

通过遵循 LSP,无论我们注入的依赖项是什么,我们的代码都会一致地执行。另一方面,违反 LSP 会导致我们违反 OCP。这些冲突导致我们的代码对实现有太多的了解,这反过来破坏了注入依赖项的抽象。

这对围棋意味着什么?

当使用组合(尤其是未命名变量形式)来满足接口时,LSP 的应用与面向对象语言中的应用一样。

在实现接口时,我们可以使用 LSP 关注的一致行为作为一种检测与错误抽象相关的代码气味的方法。

接口隔离原则(ISP)

“不应强迫客户依赖他们不使用的方法。” -罗伯特·C·马丁

就我个人而言,我更喜欢一个更直接的定义——接口应该减少到尽可能小的尺寸

让我们首先讨论为什么胖接口可能是件坏事。Fat 接口有更多的方法,因此可能更难理解。它们还需要更多的工作来使用,无论是通过实现、模拟还是存根。 Fat 接口表示更多的责任,正如我们在 SRP 中看到的,一个对象的责任越大,它就越想改变。如果界面发生变化,它会对所有用户产生连锁反应,违反 OCP,并导致大量的鸟枪手术。这是 ISP 的第一个优势: ISP 要求我们定义瘦接口

对于许多程序员来说,他们的自然趋势是添加到现有的接口,而不是定义一个新的接口,从而创建一个胖接口。这导致了这样一种情况:有时是单一的实现与接口的用户紧密耦合。这种耦合使得界面、它们的实现和用户都更难以改变。考虑下面的例子:

type FatDbInterface interface { BatchGetItem(IDs ...int) ([]Item, error) BatchGetItemWithContext(ctx context.Context, IDs ...int) ([]Item, error) BatchPutItem(items ...Item) error BatchPutItemWithContext(ctx context.Context, items ...Item) error DeleteItem(ID int) error DeleteItemWithContext(ctx context.Context, item Item) error GetItem(ID int) (Item, error) GetItemWithContext(ctx context.Context, ID int) (Item, error) PutItem(item Item) error PutItemWithContext(ctx context.Context, item Item) error Query(query string, args ...interface{}) ([]Item, error) QueryWithContext(ctx context.Context, query string, args ...interface{}) ([]Item, error) UpdateItem(item Item) error UpdateItemWithContext(ctx context.Context, item Item) error } type Cache struct { db FatDbInterface } func (c *Cache) Get(key string) interface{} { // code removed // load from DB _, _ = c.db.GetItem(42) // code removed return nil } func (c *Cache) Set(key string, value interface{}) { // code removed // save to DB _ = c.db.PutItem(Item) // code removed }

不难想象所有这些方法都属于一个结构。像GetItem()和GetItemWithContext()这样的方法对很可能共享很多(如果不是几乎全部的话)相同的代码。另一方面,GetItem()的用户不太可能也使用GetItemWithContext()。对于这个特定用例,更合适的接口如下:

type myDB interface { GetItem(ID int) (Item, error) PutItem(item Item) error } type CacheV2 struct { db myDB } func (c *CacheV2) Get(key string) interface{} { // code removed // load from DB _, _ = c.db.GetItem(42) // code removed return nil } func (c *CacheV2) Set(key string, value interface{}) { // code removed // save from DB _ = c.db.PutItem(Item) // code removed }

利用这个新的瘦接口,函数签名更加明确和灵活。这让我们看到了 ISP 的第二个优势: ISP 导致显式输入。

瘦接口也更直接,更全面地实现,使我们远离 LSP 的任何潜在问题。

如果我们使用一个接口作为输入,并且该接口需要是 fat,这就有力地表明该方法违反了 SRP。考虑下面的代码:

func Encrypt(ctx context.Context, data []byte) ([]byte, error) { // As this operation make take too long, we need to be able to kill it stop := ctx.Done() result := make(chan []byte, 1) go func() { defer close(result) // pull the encryption key from context keyRaw := ctx.Value("encryption-key") if keyRaw == nil { panic("encryption key not found in context") } key := keyRaw.([]byte) // perform encryption ciperText := performEncryption(key, data) // signal complete by sending the result result


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3