子兮子兮 子兮子兮

No can, but will.

目录
【译文】Go 中的适配器模式
/        

【译文】Go 中的适配器模式

每次遇到可测试性问题时,都存在潜在的设计问题。如果您的代码不可测试,那么它不是一个好的设计。
— Michael Feathers,“可测试性和良好设计之间的深度协同作用”
https://www.youtube.com/watch?v=4cVZvoFGJTU

没有数据库怎么测试数据库?别担心,这不是那些禅宗谜题之一。我有一些更实用但同样有启发性的想法。

测试外部依赖

没有一个程序是孤岛,我们经常需要与其他程序通信才能完成我们的工作。例如,我们可能会使用一些外部数据库,如 PostgreSQL,或互联网 API,如我们在 Go 的 API 客户端中处理的天气服务。

这种类型的任何外部依赖性都会带来设计问题和测试问题。有时我们可以通过使用 适配器模式 同时解决这两个问题。

适配器是一种将我们系统中处理特定依赖项的所有代码组合在一起的方法。例如,我们可以将知道如何与特定 API 通信的所有代码分组到一个包或函数中,我们可以将其称为该 API 的“适配器”。

适配器是大使

适配器有时也被称为 大使 :他们的工作是向外部系统“代表”我们,反之亦然。他们向外国大使馆传递重要信息,将其翻译成适当的“语言”以便被理解。反过来,他们用 我们 能理解的语言翻译并带回给我们。

适配器的最终效果是将有关外部系统细节的所有知识与程序的其余部分分离。我们可以把它当作一个有礼貌的大使,代表我们向一些外国势力提出问题,然后把结果装在一个形状方便的外交袋子里带回来。

将所有特定于依赖关系的知识封装在一个组件中,然后解决了我们的设计问题和可测试性问题。这意味着我们不需要在我们的测试中 调用 远程 API,反过来我们的测试状态不依赖于某些外部服务是否可用。

示例:数据库适配器

让我们看看适配器模式如何与依赖关系一起工作,例如某些 SQL 数据库。假设我们需要存储 Acme Widgets, Inc 的产品信息,并且我们希望使用经典的 CRUD 方法访问它:创建、读取、更新和删除。

假设我们定义了某小部件 Widget 结构:

type Widget struct {
	ID   string
	Name string
}

清单 widget/1

我们对 Widget 的构造函数的第一次尝试可能看起来像这样,省略了血淋淋的细节:

func Create(db *sql.DB, w Widget) (ID string, err error) {
	// SQL: 如果 widgets 表不存在,则创建它
	// SQL: 插入小部件信息到 widgets 表中
	// 处理可能的错误
	return w.ID, nil
}

我们使用某些 *sql.DB 对象来表示数据库句柄,使用一些特定的驱动程序(例如 Postgres)实例化。我们将使用它来执行必要的 SQL 查询(此处省略)以将指定的新小部件添加到数据库中。

依赖专门技术和业务逻辑不要混在一起

这当然很好,大多数使用数据库的 Go 应用程序看起来都像这样。但这在几个重要方面 有点 尴尬。首先,有关特定数据库服务的知识(例如,其 SQL 语法的特殊性)被嵌入到一个实际上只应包含 业务逻辑 的函数中。也就是说,为我们的特定客户或问题域实现有关 widgets 的规则的代码。

我们不希望这个关键的业务逻辑都与代码纠缠在一起,为特定的数据库服务器构造 SQL 查询。那只是糟糕的设计,因为它违反了 单一职责原则,即任何给定的函数都应该或多或少地做一件事。我们必须将相同的逻辑复制并粘贴到以不同方式存储 widgets 的任何其他函数。

更严重的问题是,现在如果没有可用的外部数据库并对其进行真正的查询,就不可能 测试 我们的 widget 逻辑。即使这只是一些本地测试服务,它仍然很烦人。我们不能只运行 go test :我们必须先使用 MakefileDocker Compose 文件或其他东西来启动 Postgres 服务器。

实际上,可以在 Go 测试中自动启动外部服务,方法是通过 os/exec 运行命令,或者使用 testcontainers 等包启动容器。这是一种有效的方法,但有点重量级:它是相扑,而不是柔道。

让我们发明一个抽象的“小部件存储器”

适配器模式为我们提供了一种更优雅的方式来设计这个问题。那将如何工作?好吧,潜在的问题是小部件逻辑与“在 Postgres 中存储东西”代码紧密耦合,令人不安。让我们从打破这种依赖开始。

具体来说,将小部件存储在 Postgres 中可能并不重要。因此,让我们发明一些完全抽象的 小部件存储器,由接口描述:

type Store interface {
	Store(Widget) (string, error)
}

清单 widget/1

我们可以使用我们选择的任何存储技术来实现这个接口。我们需要做的就是提供一个合适的 Store 方法,并让它工作。

现在我们可以更改 Create 以采用抽象的 Store ,而不是像 *sql.DB 这样的特定内容:

func Create(s Store, w Widget) (ID string, err error) {
	ID, err = s.Store(w)
	if err != nil {
		return "", err
	}
	return ID, nil
}

清单 widget/1

构建一个简单的 Store 用于测试

在真实的应用程序中, Create 可能会执行一些与小部件相关的业务逻辑(例如验证),我们可以想象要单独测试这些逻辑。

为此,出于测试目的,我们仍然需要实现 Store 的东西。但这可能是微不足道的,因为我们喜欢。事实上,我们可以使用 Go map。数据不会持久,但这并不重要,它只需要在测试期间持续存在。

type mapStore struct {
	m    *sync.Mutex
	data map[string]widget.Widget
}

func newMapStore() *mapStore {
	return &mapStore{
		m:    new(sync.Mutex),
		data: map[string]widget.Widget{},
	}
}

func (ms *mapStore) Store(w widget.Widget) (string, error) {
	ms.m.Lock()
	defer ms.m.Unlock()
	ms.data[w.ID] = w
	return w.ID, nil
}

清单 widget/1

即使这只是一个测试装置,我们仍然希望它是并发安全的,以便在必要时 可以 在并行测试之间共享 mapStore 。保护性互斥锁使这成为可能。

这与我们在 遍历文件系统 中开发的示例并无太大不同,在该示例中我们使用 fstest.MapFS 作为文件树接口 fs.FS 的快速、简单的实现。

测试业务逻辑

很好,准备工作完成后,我们可以继续为 Create 编写测试:

func TestCreate_GivesNoErrorForValidWidget(t *testing.T) {
	t.Parallel()
	s := newMapStore()
	w := widget.Widget{
		ID: "test widget",
	}
	wantID := "test widget"
	gotID, err := widget.Create(s, w)
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
	if wantID != gotID {
		t.Error(cmp.Diff(wantID, gotID))
	}
}

清单 widget/1

我们可以在没有任何笨拙的外部依赖项(例如 Postgres 服务)的情况下运行 测试。这使我们的测试套件运行起来更快、更容易,并且通过将小部件逻辑与存储逻辑分离,我们还改进了包的整体架构。

同样实现 Store 的 Postgres 适配器

但是,在实际程序中,我们可能希望将小部件数据存储在类似 Postgres 的东西中。所以我们还需要一个使用 Postgres 作为底层存储技术的 Store 的实现。

假设我们这样写,例如:

type PostgresStore struct {
	db *sql.DB
}

func (p *PostgresStore) Store(w Widget) (ID string, err error) {
	// 讨厌的 SQL 放在这里
	// 处理错误等
	return ID, nil
}

清单 widget/1

这是 Store 接口的一个同样有效的实现,因为它提供了一个 Store 方法。与我们之前构建的 mapStore 唯一的主要区别是它背后有大约 130 万行代码,因为它与 Postgres 对话。谢天谢地,我们不必测试所有代码就知道 Create 有效!

通过分块测试适配器行为

但是,我们还需要知道我们的 PostgresStore 有效。我们如何测试它?当然,我们可以将它连接到真正的 Postgres 服务,但这只会让我们回到起点。我们可以使用 分块 来避免这种情况吗?

使用 Go 中的 API 客户端中的天气客户端程序,我们将 API 适配器的行为拆分为入站和出站块。在那种情况下,出站部分知道如何根据用户的位置和密钥格式化请求的 URI,而入站部分知道如何将天气 API 的响应解码为我们可以使用的数据。这些行为块中的每一个都非常容易单独测试。

在我们的 PostgresStore 示例中,“出站”意味着,给定一个小部件,适配器生成正确的 SQL 查询以将其插入数据库。这很容易测试,因为它只是字符串匹配。我们可以尝试一个真正的 Postgres 并弄清楚 SQL 需要什么,然后检查适配器是否正确生成它。

“入站”方面呢?好吧,我们的 Store 接口故意非常简单:我们只能存储小部件信息,不能查询它。但是,在实际应用程序中,我们还需要从 Store 检索小部件,因此我们需要向接口添加一个 Retrieve 方法。它的行为将是我们的 Postgres 适配器的入站端。让我们简要谈谈这将涉及什么,以及如何测试它。

在 Postgres 的情况下,实现 Retrieve 意味着执行 SQL 查询以获取所需的数据,然后将生成的 sql.Row 对象(如果有)转换为我们的 Widget 类型。

使用 sqlmock 伪造数据库

正如我们所见,使用真实数据库进行测试很尴尬,但伪造 sql.DB 也非常困难。幸运的是,我们不必这样做,因为 sqlmock 包正是完成了这项有用的工作。

我们可以使用 sqlmock 构建一个非常轻量级的 DB 对象,它除了用一些静态数据响应特定查询外什么都不做。毕竟,我们不需要测试 Postgres 是否有效。如果没有,那不是我们的问题,谢天谢地。

我们这边需要测试的是,如果我们得到一个包含一些指定数据的行对象,我们可以将它正确地转换成 Widget

让我们编写一个辅助函数来使用这个假数据库构造一个 PostgresStore

import "github.com/DATA-DOG/go-sqlmock"

func fakePostgresStore(t *testing.T) widget.PostgresStore {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		db.Close()
	})
	query := "SELECT id, name FROM widgets"
	rows := sqlmock.NewRows([]string{"id", "name"}).
		AddRow("widget01", "Acme Giant Rubber Band")
	mock.ExpectQuery(query).WillReturnRows(rows)
	return widget.PostgresStore{
		DB: db,
	}
}

清单 widget/1

我们称之为“假 PostgresStore ”,但这只是一种说法。这是一个完全真实的 PostgresStore ,并且在该抽象中隐藏了一个真实的 *sql.DB 。它只是没有连接到真正的 Postgres 服务。相反,我们模拟了一个(非常简单的)Postgres 服务器,它只接受一个特定的 SQL 查询,并且总是用一行假数据进行响应。

针对我们的假 DB 测试适配器

现在我们可以在测试中使用我们的“假” PostgresStore 。我们将调用它的 Retrieve 方法并检查我们是否取回了我们的预制测试数据所描述的 Widget

func TestPostgresStore_Retrieve(t *testing.T) {
	t.Parallel()
	ps := fakePostgresStore(t)
	want := widget.Widget{
		ID:   "widget01",
		Name: "Acme Giant Rubber Band",
	}
	got, err := ps.Retrieve("widget01")
	if err != nil {
		t.Fatal(err)
	}
	if !cmp.Equal(want, got) {
		t.Error(cmp.Diff(want, got))
	}
}

清单 widget/1

井井有条!最后,让我们编写 Retrieve 方法并检查它是否通过了我们的测试。

func (ps *PostgresStore) Retrieve(ID string) (Widget, error) {
	w := Widget{}
	ctx := context.Background()
	row := ps.DB.QueryRowContext(ctx,
		"SELECT id, name FROM widgets WHERE id = ?", ID)
	err := row.Scan(&w.ID, &w.Name)
	if err != nil {
		return Widget{}, err
	}
	return w, nil
}

清单 widget/1

而且我们 仍然 不需要真正的 Postgres 服务或任何其他外部依赖项。当然,我们对代码正确性的信心仅限于我们对 SQL 查询正确性的信心,也可能不正确。

同样,我们的伪造返回的固定行数据可能与真实服务返回的不匹配。所以在某些时候我们需要针对真实服务测试程序。

不过,我们在这里所做的工作大大减少了我们对该服务的依赖范围。事实上,我们可能只需要它来进行我们偶尔运行的一两个测试,只是为了确认我们对它的行为方式的假设仍然有效。

适配器只是好的设计

由于可测试性和良好设计之间的协同作用,我们为简化测试而引入的模式实际上为我们的程序带来了更好的架构。我们通过创建 Store 抽象和插入其中的 PostgresStore 适配器,将“了解小部件”代码与“了解 Postgres”代码分离。

可以肯定的是,这种变化使我们的程序更容易测试,但也更容易理解和推理。事实上,它使它更容易测试,因为它更容易推理。我们不必担心两个不同且不相关的行为层会相互干扰并弄乱我们的测试结果。

如果我们想在某个时候为不同的数据库后端引入选项,例如 SQLite、MySQL 或一些任意的云存储 API,现在要容易得多。我们需要做的就是编写一个合适的适配器来实现 Store 接口。

我们甚至可以做到这一点,以便用户可以提供他们 自己的 Store 适配器,与他们想要的任何类型的存储引擎对话,他们将神奇地与我们的 Widget 业务逻辑一起工作。多么令人愉快!


内容声明
标题: 【译文】Go 中的适配器模式
链接: https://zixizixi.cn/the-adapter-pattern-in-go 来源: iTanken
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可,转载请保留此声明