【读书笔记】《Go With The Domain》6. Repository存储库模式
📔【读书笔记】《Go With The Domain》6. Repository存储库模式
2023-10-4
| 2024-3-24
0  |  0 分钟
type
status
date
slug
summary
tags
category
icon
password
Sub-item
Last edited time
Mar 24, 2024 03:12 AM
Parent item
领域
我一生中见过很多复杂的代码。通常,这种复杂性的原因是应用程序逻辑与数据库逻辑相结合。将应用程序的逻辑与数据库逻辑保持在一起会使您的应用程序变得更加复杂,难以测试和维护。
已经有一个经过验证的简单模式可以解决这些问题。该模式允许您将应用程序逻辑与数据库逻辑分开。它使您可以使代码更简单,更容易添加新功能。更大的好处是,您可以推迟决策选择数据库解决方案和模式。还有一个好处是开箱即用,不受数据库供应商锁定的影响。我想到的模式是Repository存储库
当我回想起我使用过的应用程序时,我记得很难理解它们是如何工作的。我总是害怕在那里做出任何改变——你永远不知道它可能会产生什么意想不到的不良副作用。当应用程序逻辑与数据库实现混合在一起时,很难理解。这也是重复的来源。
一些解决方法可能是构建端到端测试。但它掩盖了问题而不是真正解决问题。进行大量 E2E 测试速度缓慢、不稳定且难以维护。有时它们甚至会阻止我们创建新功能,而不是提供帮助。
在本章中,我将教你如何以务实、优雅、直接的方式在 Go 中应用这种模式。我还将深入讨论一个经常被跳过的主题——整洁事务处理。为此我准备了3种实现:Firestore、MySQL、简单内存。让我们直接看实际例子吧!

Repository存储库接口

使用存储库模式的想法是:让我们通过接口定义与数据库的交互来抽象数据库实现。您需要能够将此接口用于任何数据库实现——这意味着它应该不受任何数据库的任何实现细节的影响
我们先从trainer服务的重构开始。目前,该服务允许我们通过 HTTP APIgRPC获取有关Hour可用性的信息,同时还可以通过 HTTP API gRPC 更改Hour领域的可用性。
在上一章中,我们使用 DDD Lite 方法重构了 Hour 领域。因此,在Hour 领域不需要保留何时更新 Hour 的规则(规则由外部定义,领域只负责执行)。我们的领域层确保我们不能做任何“愚蠢”的事情。我们也不需要考虑任何验证。我们可以只使用类型并执行必要的操作。
我们只需要能够从数据库中获取 Hour 的当前状态并保存它。此外,如果两个人想同时安排培训,则只有一个人能够安排一小时的培训。通过接口体现我们的需求:
我们将使用 GetOrCreateHour 来获取数据,并使用 UpdateHour 来保存数据。我们将接口定义和 Hour 类型定义放在相同的包中。因此,如果在许多模块中使用此接口,我们可以避免重复(根据我的经验,可能经常出现这种情况)。它也是与 io.Writer 类似的模式,其中 io 包定义接口,所有实现都在单独的包中,实现解耦。
如何实现该接口?

读取数据

大多数数据库驱动程序都可以使用 ctx context.Context 进行取消、跟踪等。它不特定于任何数据库(这是一个常见的 Go 概念),因此您不应该担心ctx会破坏领域。
大多数情况下,我们会使用UUID或ID来查询数据,而不是使用time.Time。但这里没关系——这个时间的设计是独一无二的,不会重复。我可以想象一种情况,我们希望支持多名教练——但这里不支持多教练。即使要更改 UUID/ID 仍然很简单,基于 UUID 的接口可能如下所示:
在应用中如何使用该接口?
我们获取 hour.Hour 并检查它是否可用。你能猜出我们使用什么数据库吗?不,这就是重点!
正如我所提到的,我们可以避免数据库供应商锁定并能够轻松更换数据库。如果您可以更换数据库,则表明您正确实现了存储库模式。实际上,更改数据库的情况很少见。如果您使用非自托管的解决方案(例如 Firestore),则更重要的是降低风险并避免数据库供应商锁定。
这样做的另一个好处是我们可以推迟决定要使用哪种数据库实现。我将这种方法称为“领域优先”。我在领域驱动设计精简版(第 6 章)中深入描述了它。推迟有关数据库的决定可以在项目开始时节省一些时间。有了更多的信息和背景,我们也可以做出更好的决定。
当我们使用领域优先方法时,第一个也是最简单的存储库实现可能是内存中实现。

内存数据库实现示例

在内存中做一个简单的映射。 getOrCreateHour 有 5 行(没有注释和一个换行符)!
不幸的是,内存实现有一些缺点。最大的一个是它在重启后不保留服务的数据。但对于功能性的 pre-alpha 版本来说已经足够了。为了使我们的应用程序做好生产准备,我们需要一些更持久的东西。

MySQL实现示例

我们已经知道我们的模型是什么样子以及它的行为如何。在此基础上,我们来定义 SQL 模式。
当我使用 SQL 数据库时,我的默认选择是:
  • sqlx – 对于更简单的数据模型,它提供了有用的函数,有助于使用结构来解组查询结果。当模式由于关系和多个模型而变得更加复杂时,就需要......
  • SQLBoiler - 非常适合具有许多字段和关系的更复杂模型,它基于代码生成。因此,它的速度非常快,而且您不必担心传递了无效的 interface{} 代替另一个 interface{}。生成的代码基于 SQL 架构,因此可以避免大量重复。
我们目前只有一张表,所以sqlx 就足够了。让我们用“传输类型”来映射我们的数据库模型。
💡
注意,你可能会问为什么不给hour.Hour添加db属性呢?根据我的经验,最好将领域类型与数据库完全分离。它更容易测试,我们不会重复验证,并且不会引入大量样板文件。 如果架构发生任何更改,我们可以仅在存储库实现中进行更改,而不是在项目的一半中进行。 Miłosz 在《何时远离 DRY》中描述了类似的情况。我还在《领域驱动设计精简版》中更深入地描述了该规则。
怎么使用该结构体?
对于 SQL 实现来说,这很简单,因为我们不需要保持向后兼容性。在前面的章节中,我们使用 Firestore 作为主数据库。让我们在此基础上准备实现,保持向后兼容性。

Firestore实现

当您想要重构遗留应用程序时,抽象数据库可能是一个很好的起点。
有时,应用程序是以数据库为中心的方式构建的。在我们的例子中,这是一种以 HTTP 响应为中心的方法——我们的数据库模型基于 Swagger 生成的模型。换句话说,我们的数据模型基于 API 返回的 Swagger 数据模型。它会阻止我们抽象数据库吗?当然不是!它只需要一些额外的代码来反序列化即可。
使用领域优先方法,我们的数据库模型会更好,就像在 SQL 实现中一样。但我们就在我们所在的地方。让我们一步步砍掉这个旧遗产吧。我也感觉 CQRS 会在这方面帮助我们。
💡
注意,在实践中,数据的迁移可能很简单,只要没有其他服务直接通过数据库集成即可。不幸的是,当我们使用遗留响应/以数据库为中心或 CRUD 服务时,这是一个乐观的假设...…
不幸的是,用于事务性和非事务性查询的 Firebase 接口并不完全兼容。为了避免重复,我创建了 getDateDTO,它可以通过传递 getDocumentFn 来处理这种差异。
即使需要一些额外的代码,也不错。至少它可以很容易地进行测试。

更新数据

正如我之前提到的,确保只有一个人可以在一小时内安排一次培训至关重要。为了处理这种情况,我们需要使用乐观锁和事务。即使事务是一个非常常见的术语,我们也要确保我们与乐观锁定达成一致。
乐观并发控制假设许多事务可以频繁完成而不会相互干扰。运行时,事务使用数据资源而不获取这些资源的锁。在提交之前,每个事务都会验证没有其他事务修改了它所读取的数据。如果检查发现冲突的修改,则提交事务将回滚并可以重新执行。
从技术上讲,事务处理并不复杂。我遇到的最大挑战有点不同——如何以一种整洁的方式管理事务,不会对应用程序的其余部分产生太大影响,不依赖于实现,并且是明确且快速的。
我尝试了很多想法,比如通过 context.Context 传递事务、在 HTTP/gRPC/消息中间件级别处理事务等。我尝试的所有方法都有很多重大问题 - 它们有点神奇、不明确,并且在某些方面很慢。
目前,我最喜欢的方法是基于传递给更新函数的闭包的方法
基本思想是,当我们运行 UpdateHour 时,我们需要提供具体的 updateFn 来更新提供的Hour。因此,在实际执行事务时:
  • 根据提供的 UUID 或任何其他参数(在我们的例子中 hourTime time.Time)获取(在我们的例子中为 h *Hour)所有参数,并提供给 updateFn
  • 执行闭包(在我们的例子中为 updateFn
  • 保存返回值(这里是*Hour,如果需要,我们可以返回更多)
  • 如果从闭包返回错误,则执行回滚
它在实践中是如何使用的?
正如您所看到的,我们从某个(是未知的!)数据库中获取 Hour 实例。之后,我们将这Hour 设为可用。如果一切正常,就返回该Hour并保存。
作为领域驱动设计精简版的一部分,所有验证都转移到了领域级别,因此我们确信我们没有做任何“愚蠢”的事情。也大大简化了这段代码。
在我们的 updateFn 例子中,我们仅返回 (*Hour, error) – 如果需要,您可以返回更多值。您可以返回事件源事件、读取的数据模型等。
理论上,我们还可以使用我们提供给 updateFn hour.*Hour。但不建议这样做。使用返回值给我们带来了更大的灵活性(如果需要,我们可以替换 hour.*Hour 的不同实例)。
创建多个类似 UpdateHour 的函数并保存额外的数据也没什么可怕的。实现应该重复使用相同的代码。

内存数据库事务实现

内存实现还是最简单的。我们需要获取当前值,执行闭包并保存结果。
在map中存储一个副本而不是指针。因此,我们确定没有“提交”(m.hours[hourTime] = *updatedHour)时我们的值不会保存。

Firestore事务实现

Firestore实施更为复杂,但这再次与向后兼容性有关。当我们的数据模型更好时,可能会跳过getDateDTOdomainHourFromDateDTOupdateHourInDataDTO可这些函数。这是另一个不使用以数据库为中心/以响应为中心的方法的原因!
如您所见,我们获取 *hour.Hour,调用 updateFn,并将结果保存在 RunTransaction 中。尽管有些额外的复杂性,但这种实现仍然清晰、易于理解和测试

MySQL 事务实现

让我们将其与 MySQL 实现进行比较,我们以更好的方式设计了模型。即使实现方式相似,最大的区别还是处理事务的方式。在 SQL 驱动程序中,事务由 *db.Tx 表示。我们使用这个特定的对象来调用所有查询并执行回滚和提交。为了确保我们不会忘记关闭事务,我们在defer中进行回滚和提交。
在这种情况下,我们还可以通过将 for Update == true 传递给 getOrCreateHour 来获取Hour。该标志将 FOR UPDATE 语句添加到我们的查询中。 FOR UPDATE 语句至关重要,防止在查询时被其他操作并行修改。
UpdateHour 退出时,执行 finishTransaction。当提交或回滚失败时,我们还可以覆盖返回的错误。

总结

即使存储库方法添加了更多代码,这种投资也是完全值得的。实际上,您可能会多花 5 分钟来完成此操作,并且您的投资很快就会得到回报。
在本章中,我们错过了一个重要部分——测试。现在添加测试应该容易得多,但如何正确执行它可能仍然不清楚。
我将在下一章介绍测试。此重构的完整差异(包括测试)可在 GitHub30 上找到。
提醒一下 – 您还可以使用一个命令运行该应用程序 并在 GitHub 上找到整个源代码!
另一种效果很好的技术是 Clean/Hexagonal 架构——Miłosz 在后续的整洁架构中对此进行了介绍。
 
架构设计
  • 读书笔记
  • 技术架构
  • 【读书笔记】《Go With The Domain》10. 结合 DDD、CQRS 和整洁架构【缓存】Redis使用及数据类型
    目录