将DDD应用到数据库设计中 - lazypro


本文将介绍如何将域驱动设计和数据库组合在一起的另一个示例。接下来,我们将提供一个带有 MySQL 数据库的普通街头gashapon(扭蛋娃娃机)店的真实设计。

用户故事
正如我们之前所做的那样,我们从描述用户故事开始,并通过该故事了解我们的需求。

  • 会有很多机器,每台机器都有不同的物品组合。
  • 为了让“慷慨”的客户一次购买所有物品,我们提供了一次抽取大量物品的选项。
  • 当我们用完物品时,我们需要立即补充它们。
  • 如果一个人一次画了很多物品,如果物品用完了,我们会立即补充物品,以便他们继续画画。

这样的故事其实是每个扭蛋娃娃机店都会发生的场景,描述清楚后,我们就知道如何构建领域模型了。
一般来说,我们需要对扭蛋机和扭蛋实体进行建模。

用例
然后,我们根据用户故事定义更精确的用例。与故事不同,用例将清楚地描述发生了什么以及系统应该如何反应。

  • 因为这是一家在线扭蛋店,所以可以多人同时抽奖。但就像实体机器一样,每个人都必须按顺序绘制。
  • 在加卡的过程中,用户必须等到所有的加卡都加满后才能进行抽奖。
  • 当A和B各同时抽50,但机器里只有70时,其中一个会得到批次中的50,而另一个先抽20,然后等到补完再抽30。

有了用例,我们既可以画流程图,也可以根据它编写流程。在此示例中,我选择将流程编写Python如下。
def draw(n, machine):
    gachas = machine.pop(n)
    
    if len(gachas) < n:
        machine.refill()
        gachas += draw(n - len(gachas), machine)
    
    return gachas

在用户故事中,我们提到我们将对扭蛋机和扭蛋进行建模。所以machine在这个例子中的代码是扭蛋机,它提供了pop和refill方法。另一方面,gachas 是一个列表gacha。
在我们进一步讨论之前,必须说明一件非常重要的事情。pop和方法都refill必须是原子的。为了避免竞速条件,这两种方法都必须是原子的,不能被抢占,同时会有多个用户同时绘制。

数据库建模
我们已经有了machine和gacha,这两个对象对于熟悉面向对象编程的开发者来说是非常简单的,但是它们应该如何与数据库集成呢?

正如在《企业应用程序架构模式》一书中提到的,有三个选项可以描述数据库上的域逻辑。

  1. 事务脚本
  2. 表模块
  3. 领域模型

这本书分别解释了这三种选择的优缺点,根据我的经验,我更喜欢使用Table Module。原因是,数据库是一个独立的组件,而对于应用程序来说,数据库实际上是一个Singleton。为了能够控制对这个Singleton的并发访问,必须有一个单一的、统一的和公共的接口。
使用Transaction Script,对数据库的访问分布在整个源代码中,当应用程序变大时几乎无法管理。另一方面,Domain Model过于复杂,因为它为表的每一行创建一个特定的实例,这在实现原子操作方面非常复杂。所以我选择了折衷表模块作为与数据库交互的公共接口。

以上述machine为例。
由于我们已经完成了域对象的构建,让我们定义表的模式GachaTable。

在表格中,我们可以看到有两台机器,一台以侏罗纪公园Jurassic Park为主题,另一台以冰河世纪Ice Age为主题,两者都有两个单独的扭蛋。侏罗纪公园的机器看起来像三个,但实际上已经绘制了一个。
Gacha的领域模型直截了当,gacha_id或者更详细,可能是主题加项目,比如:侏罗纪公园和霸王龙。
一个更有趣的话题是machine. machine需要定义几个属性。首先,它应该可以在构造函数中指定一个 id,其次,有两个原子方法,pop和refill. 我们将在以下部分重点介绍这两种方法。

原子性的POP
实现一个原子性弹出哇哇POP并不难:先按序号排序,然后取出第一n行,最后设置is_drawn为true. 我们以侏罗纪公园机器为例。

START TRANSACTION;
SELECT * FROM GachaTable WHERE machine_id = 1 AND is_drawn = false ORDER BY gacha_seq LIMIT n FOR UPDATE;
UPDATE GachaTable SET is_drawn = true WHERE gacha_seq IN ${resultSeqs};
COMMIT;

为了避免丢失更新,MySQL 提供了三种方法。在这个例子中,要实现原子更新,最简单的方法是添加FOR UPDATE到末尾SELECT以抢占这些行。
更新完成后,SELECT可以将 的结果包装到Gacha实例中并返回。这样,调用者将能够获得抽出的扭蛋并知道抽出了多少个。

原子性Refill
另一种原子方法是refill. 在填充过程中不被打断很简单。COMMIT因为只有在条件下,其余客户端才会读取 MySQL 事务Repeatable Read。
START TRANSACTION; for gacha in newGachaPackage(): INSERT INTO GachaTable VALUES ${gacha}; COMMIT;
这就是全部?不,不是。
当两个用户都画n,但没有足够的n扭蛋来draw时,就会出现这个问题。

上面的顺序图显示,当A和B同时抽签时,A会抽到r个gachas,而B不会,正如我们预期的那样。然而,A和B会一起refill,导致同一批嘎查被refill两次。
通常情况下,这不会造成任何重大问题。因为我们按序列号排列流行,我们可以保证第二批只有在第一批被抽干后才会被抽出。但是如果我们想改变项目,那么新的项目就会比我们预期的晚放出来。
另一方面,两个人同时refill,那么冗余度就变成了两倍,如果系统同时有非常多的用户,那么冗余度可能会变成几倍,占用大量的资源。
如何解决这个问题?在《解决MySQL中的幻象读取》一文中提到,我们可以将冲突具体化。换句话说,我们添加一个外部同步机制来调解所有并发的用户。

在此示例中,我们可以添加一个新表MachineTable.

此表还允许原始machine_id的GachaTable具有附加的外键引用目标。当我们这样做时refill,我们必须先锁定这台机器,然后才能对其进行更新。

START TRANSACTION;
SELECT * FROM MachineTable WHERE machine_id = 1 FOR UPDATE;
SELECT COUNT(*) FROM GachaTable WHERE machine_id = 1 AND is_drawn = false;
if !cnt:
    for gacha in newGachaPackage():
        INSERT INTO GachaTable VALUES ${gacha};
    
COMMIT;

首先,我们获得独占锁,然后重新确认GachaTable是否需要重新refill,最后,我们实际将数据插入其中。如果没有重新确认,那么仍有可能重复refill。
这里有一些扩展的讨论。

  1. 为什么我们需要一个额外的MachineTable?我们不能锁定原来的GachaTable吗?由于幻象读取,MySQL的可重复读取不能避免在新数据情况下的幻象读取带来的写偏移。
  2. 锁定MachaTable时,从GachaTable获取计数时不需要锁定GachaTable吗?实际上,这是没有必要的。因为它会进入补给过程,一定是因为Gacha已经被抽走了,大家都在等待补给,所以不用担心弹出的问题。

结论
在这篇文章中,我们通过一个真实的例子来解释在将领域驱动设计与数据库设计相结合时需要考虑的问题。
最终的结果将包含两个对象,Gacha和Machine,而数据库也将包含两个表,GachaTable和MachineTable。在Machine中的所有方法在本质上都是原子性的。

正如我们在之前的设计步骤中所描述的,我们需要首先定义正确的用户故事和用例,然后开始建模,最后是实现。与普通的应用程序实现不同,数据库是作为一个大的Singleton存在的,所以我们需要将数据库的设计也更好地整合到我们的领域设计中。
为了尽量减少数据库对整个设计的影响,正确建立领域模型是至关重要的。当然,在这篇文章中,我们采用了表模块的方法来进行领域设计,这有它的优点和缺点。

优点是通过使用Machine领域模型,我们能够模拟出扭蛋娃娃机Machine的真实外观,并为所有用户提供一个共同的界面来同步处理。通过将娃娃机行为封装到Machine中,未来的娃娃机扩展可以很容易地进行。GachaTable和MachineTable的所有操作也将由一个单一对象控制。

缺点是Machine里面实际上包含了娃娃机机器和gacha表,对于严格的面向对象的宗教来说,这太粗糙了。当更多的人参与到项目中时,每个人对对象和数据表的理解开始出现分歧,导致设计崩溃。对于一个大型组织来说,表模块有它的范围,更好的跨部门协作依赖于完整的文档和设计审查,这会影响每个人的工作效率。