数据点

适用于在领域驱动设计界定的上下文中共享数据的模式

Julie Lerman

下载代码示例

Julie Lerman纵观我的整个编程生涯,可重用代码和数据是一直追求的目标。因此,在开始学习领域驱动设计 (DDD) 时,我努力通过其指导在其界定的上下文(结果可能是重复代码甚至重复数据)中强制执行此类分离。当 DDD 领域中一些最优秀的人尝试“插手”帮助我了解我以前采取的方法的潜在弊端时,我有些释然了。最后,Eric Evans 解释说,一个人必须选择在哪方面为复杂性付出代价。由于 DDD 的核心是降低软件中的复杂性,因此结果是您在维护重复模型和潜在的重复数据方面付出代价。

在本专栏中,我曾经写过 DDD 概念以及它们与数据驱动体验的配合方式,第一次是在 2013 年 1 月的专栏中的《使用 DDD 界定的上下文收缩 EF 模型》一文中 (bit.ly/1isIoGE),然后是在由三部分内容组成的系列文章《领域驱动设计的编码:以数据为中心开发的提示》(其开始于 bit.ly/XyCNrU)中。在此系列的第一篇文章中,您可以找到名为“共享数据在复杂系统中可能是个大麻烦”的一节。查看一些其他的见解,了解有关我在此处展示的方法为什么比较有用的原因:

我不止一次被问到,如果您遵循一个比较极端的 DDD 模式(其将每个界定的上下文与其自己的数据库绑定),则如何做到在界定的上下文之间共享数据。虽然 Steve Smith 和我在 Pluralsight.com (bitly.com/PS-DDD) 上的“领域驱动设计基础知识”课程中谈论过此问题,但是我们实际上并没有实现过,因为这样做有点超出了该特定课程的关注范围。

有很多不同的方法可用于在多个界定的上下文中使用常用数据。在本专栏中,我打算特别介绍以下这个方案:将一个系统中的数据镜像到另一个系统中,其中第一个系统旨在编辑数据,而第二个系统只需要可以只读访问其中一些数据。

首先,我将设计一个基本模式,然后添加一些其他详细信息。该实施程序涉及到许多工作部件,包括控制反转 (IoC) 容器和消息队列。如果您熟悉这些工具,则应该能更好地理解该实施程序。我不打算深入探讨 IoC 和队列实施的详细细节,但是您可以在本文随附的下载示例中对其进行查看和调试。

示例方案:共享客户列表

我选择一个非常简单的方案来展示此模式。一个系统致力于客户服务。此处,用户可以保留客户数据以及大量的其他数据。该系统与数据存储机制交互,但这对于该示例并不重要。第二个系统旨在提取订单。在该系统中,用户需要访问客户,但实际上只需要确定下达订单的客户即可。这意味着,此界定的上下文只需要客户名称和标识符的只读列表。因此,连接到第二个系统的数据库需要基于第一个系统中保留的客户的客户名称和 ID 的最新列表。我选择完成这项任务的方法是将第一个系统中保留的有关每个客户的这两项数据镜像到第二个系统中。

镜像数据:高级

从很高的级别上来看,我应用的解决方案是,每次将一个客户插入到系统 A 时,该客户的 ID 和名称也应该同时被添加到系统 B 的数据存储中。如果我在系统 A 中更改现有客户的名称,则系统 B 也需要正确的名称,因此更改名称应让系统 B 数据存储中的名称也相应进行更新。此域并不删除数据,但是在未来进行改进时可能会删除系统 B 数据存储中的无效客户。我不会在该实施程序中对此进一步探讨。

因此,在上一部分中,我只关心系统 A 中有两个事件需要响应:

  • 插入客户
  • 更新现有客户的名称

在一个连接系统中,系统 B 可能向系统 A 公开要调用的方法,如 InsertCustomer 或 UpdateCustomerName。或者,系统 A 可能引发事件(例如,CustomerCreated 和 CustomerNameUpdated),以让其他系统(包括系统 B)去捕捉和响应。

为了响应每个事件,系统 B 需要在其数据库中执行相关操作。

由于这些系统是断开连接的,因此使用发布-订阅模式不失为一个更好的方法。系统 A 将一个或多个事件发布到某种类型的操作符。然后,一个或多个系统订阅这一相同的操作符,同时等待特定事件并执行它们自己的操作以响应这些事件。

发布-订阅符合 DDD 的原则,即要求这两个系统互相之间不了解,因此无法直接进行通信。因此,取而代之的是,我将使用一个名为“反损坏层”的概念。每个系统将通过打乱其与另一个系统之间的消息的操作符进行通信。

此操作符是一个消息队列。系统 A 将向该队列发送消息。系统 B 将从队列中检索消息。在我的示例中,我只有一个订户(系统 B),但是可以有许多订户。

事件消息中的内容是什么?

当发布的事件是 CustomerCreated 事件时,系统 A 将发送一条消息,内容为“已插入一个客户。这是客户的身份和名称。”这是完整的消息,只是以数据形式显示,而非以优美的英语句子显示。有关向消息队列发布事件的有趣部分在于该发布服务器并不关心哪些系统检索该消息或它们将如何响应。

系统 B 将通过在其数据库中插入或更新客户来响应该消息。事实上,系统 B 甚至并未执行此项任务;我将让某项服务来执行此作业。而且,我将让数据库决定如何执行更新。在本示例中,系统 B 数据库将使用其逻辑删除原始客户记录并插入一个新记录的存储过程执行客户“更新”。因为我将使用 GUID 作为身份密钥,所以将会正确保留客户的身份。我不想担心我的 DDD 软件中的数据库生成密钥。预创建的 GUID 与数据库增加的密钥是个棘手的主题。您需要定义您的逻辑,以符合公司的数据库实践。

最后,系统 B(订单系统)将拥有可以使用的客户完整列表。进一步探讨该工作流可以得知,如果订购系统需要有关特定客户的更多信息(例如,信用卡信息或当前发货地址),我可以使用其他机制(如调用某项服务)来根据需要检索数据。不过我不打算在此探讨该工作流。

与消息队列进行通信

允许您通过异步方式传达消息的系统称作事件总线。事件总线包含存储消息和将消息提供给任何需要它的人员的基础结构。它还提供用于与其进行交互的 API。我将探讨一个特定的实施程序,因为我发现该实施程序可以轻松地对此类通信有个入门了解:消息队列。可以选择许多消息队列。在 Pluralsight.com 上的“DDD 基础知识”课程中,Smith 和我选择使用 SQL Server Service Broker 作为我们的消息队列。因为我们都使用 SQL Server,所以对于我们而言设置起来比较简单,我们只需要编写 SQL 来将消息推送到队列并检索消息。

在撰写本文时,我决定是时候学习如何使用一个比较受欢迎的开源消息队列 RabbitMQ 了。这就表示将 RabbitMQ 服务器(以及 Erlang!)安装到我的计算机,并进入 RabbitMQ .NET Client,这样我就可以轻松地在我的应用程序中对其进行编码。有关 RabbitMQ 的更多信息,您可以访问 rabbitmq.com。此外,我还发现 Pluralsight.com 上的“适用于 .NET 开发人员的 RabbitMQ”课程很有帮助。

因此,系统 A 拥有一个将消息发送到 RabbitMQ 服务器的机制。但是,系统 B(订单系统)与此交互没有任何关系。系统 B 只是希望客户列表进入数据库,并不关心进入数据库的方式。一个单独的小型 Windows 服务将负责检查 RabbitMQ 消息队列中的消息并相应地更新订单系统的数据库。图 1 显示了整个过程的可视工作流。

消息队列允许陌生的系统共享消息(在本例中即更新系统 B 数据库)
图 1 消息队列允许陌生的系统共享消息(在本例中即更新系统 B 数据库)

向队列发送消息

我将从系统 A 中的 Customer 类开始(图 2 中所示)。为了简单起见,该示例中只包含几个属性——ID、名称、客户来源和一些日志记录日期。按照 DDD 模式,该对象内置了某些限制条件,以防止随意编辑。您通过 Create 工厂方法创建新的客户。如果您需要修复该名称,请使用 FixName 方法。

图 2 客户维护界定的上下文中的 Customer 类

public static Customer Create(string name, string source) {
   return new Customer(name, source);
  }
  private Customer(string name, string source){
    Id = Guid.NewGuid();
    Name = name;
    InitialDate = DateTime.UtcNow;
    ModifiedDate = DateTime.UtcNow;
    Source = source;
    PublishEvent (true);
  }
  public Guid Id { get; private set; }
  public string Name { get; private set; }
  public DateTime InitialDate { get; private set; }
  public DateTime ModifiedDate { get; private set; }
  public String Source { get; private set; }
  public void FixName(string newName){
    Name = newName;
    ModifiedDate = DateTime.UtcNow;
    PublishEvent (false);
  }
  private void PublishEvent(bool isNew){
    var dto = CustomerDto.Create(Id, Name);
    DomainEvents.Raise(new CustomerUpdatedEvent(dto, isNew));
 }}

请注意,构造函数和 FixName 方法均调用 PublishEvent 方法;而该方法又创建一个简单的 CustomerDto(只有一个 Id 和 Name 属性),并使用 Udi Dahan 的 2009 MSDN 杂志文章《采用域模型模式》(msdn.microsoft.com/magazine/ee236415) 一文中的 DomainEvents 类来引发新的 Customer­UpdatedEvent(请参阅图 3)。在我的示例中,我发布事件是在响应简单的操作。在实际实现中,您可能倾向于在数据已成功地永久性保存到系统 A 数据库之后再发布这些事件。

图 3 更新客户时封装事件的类

public class CustomerUpdatedEvent : IApplicationEvent{
  public CustomerUpdatedEvent(CustomerDto customer, 
   bool isNew) : this(){
    Customer = customer;
    IsNew = isNew;
  }
  public CustomerUpdatedEvent()
  {
    DateTimeEventOccurred = DateTime.Now;
  }
  public CustomerDto Customer { get; private set; }
  public bool IsNew { get; private set; }
  public DateTime DateTimeEventOccurred { get; set; }
  public string EventType{
    get { return "CustomerUpdatedEvent"; }
 }}

CustomerUpdatedEvent 封装是我需要了解有关此事件的全部内容:CustomerDto 以及指示客户是否为新客户的标识。一般事件处理程序还需要元数据。

然后,在我的应用程序中定义的一个或多个处理程序可以处理 CustomerUpdatedEvent。我只定义了一个处理程序,该服务称作 CustomerUpdatedService:

public class CustomerUpdatedService : IHandle<CustomerUpdatedEvent>
{
  private readonly IMessagePublisher _messagePublisher;
  public CustomerUpdatedService(IMessagePublisher messagePublisher){
    _messagePublisher = messagePublisher;
  }
  public void Handle(CustomerUpdatedEvent customerUpdatedEvent){
    _messagePublisher.Publish(customerUpdatedEvent);
}}

该服务将处理由我的代码通过使用指定消息发布服务器发布事件而引起的所有 CustomerUpdatedEvent 实例。我此处尚未指定发布服务器;我只是引用了一个抽象的概念 IMessagePublisher。我正在使用可以让我松散耦合我的逻辑的 IoC 模式。我这个人变化无常。今天,我可能想要一个消息发布服务器。明天,我可能想要另一个消息发布服务器。在背景中,我使用了 StructureMap (structuremap.net),该工具是 .NET 开发人员在 .NET 应用程序中管理 IoC 常用的工具。 通过 StructureMap,我可以指出在哪里找到处理由 DomainEvents.Raise 引起的事件的类。StructureMap 的编者 Jeremy Miller 在 MSDN 杂志中编写过非常棒的系列文章,名为《实用模式》,该系列文章与在本示例中运用的模式相关 (bit.ly/1ltTgTw)。通过 StructureMap,我对我的应用程序进行了配置,以便让它知道在看到 IMessagePublisher 时,它应该使用具体的类 RabbitMQMessagePublisher,其逻辑如下所示:

public class RabbitMqMessagePublisher : IMessagePublisher{
  public void Publish(Shared.Interfaces.IApplicationEvent applicationEvent) {
    var factory = new ConnectionFactory();
    IConnection conn = factory.CreateConnection();
    using (IModel channel = conn.CreateModel()) {
      [code to define the RabbitMQ channel]
      string json = JsonConvert.SerializeObject(applicationEvent, Formatting.None);
      byte[] messageBodyBytes = System.Text.Encoding.UTF8.GetBytes(json);
      channel.BasicPublish("CustomerUpdate", "", props, messageBodyBytes);
 }}}

请注意,我已经删除了许多与配置 RabbitMQ 相关的代码行。您可以在下载内容 (msdn.microsoft.com/magazine/msdnmag1014) 中查看完整的列表。

该方法的精髓在于其将事件对象的 JSON 表示形式发布到队列中。当我添加一个名为 Julie Lerman 的新客户后,该字符串类似如下:

{
"Customer":
  {"CustomerId":"a9c8b56f-6112-42da-9411-511b1a05d814",
    "ClientName":"Julie Lerman"},
"IsNew":true,
"DateTimeEventOccurred":"2014-07-22T13:46:09.6661355-04:00",
"EventType":"CustomerUpdatedEvent"
}

在发布此消息后,客户维护系统的投入就完成了。

在我的示例应用程序中,我使用一组测试,以便于使消息发布到队列中(如图 4 中所示)。不用创建在完成时将检查队列的测试,我只要在计算机上浏览至 RabbitMQ Manager 并使用其工具即可。注意,在测试构造函数中,我初始化了一个名为 IoC 的类。这也就是我在其中配置 StructureMap 以便连接 IMessagePublisher 和我的事件处理程序的地方。

图 4 发布到测试中的 RabbitMq

[TestClass]
public class PublishToRabbitMqTests
{
  public PublishToRabbitMqTests()
  {IoC.Initialize();
  }
  [TestMethod]
  public void CanInsertNewCustomer()
  {
    var customer = Customer.Create("Julie Lerman", 
      "Friend Referral");
    Assert.Inconclusive("Check RabbitMQ Manager for a message re this event");
  }
  [TestMethod]
  public void CanUpdateCustomer() {
    var customer = Customer.Create("Julie Lerman", 
      "Friend Referral");
    customer.FixName("Sampson");
    Assert.Inconclusive("Check RabbitMQ Manager for 2 messages re these events");
}}

检索消息和更新订单系统的数据库

消息位于等待检索的 RabbitMQ 服务器上。而且,该任务由持续运行、定期轮询队列中是否有新消息的 Windows 服务执行。当它看到消息时,该服务会检索并处理该消息。该消息也可由其他订户处理。为了本示例,我创建了一个简单的控制台应用程序,而非一项服务。这可允许我在学习时轻松地运行和调试 Visual Studio 中的“服务”。对于下一个小版本,我可能查看 Microsoft Azure WebJobs (bit.ly/1l3PTYH),而非纠缠于 Windows 服务或使用我的控制台应用程序调试。

该服务使用类似的模式通过 Dahan 的 DomainEvents 类引发事件、响应处理程序类中的事件以及初始化使用 StructureMap 来查找事件处理程序的 IoC 类。

该服务通过 RabbitMQ .NET Client Subscription 类侦听 RabbitMQ 的消息。您可以在下列 Poll 方法(其中,_subscription 对象一直在侦听消息)中看到 RabbitMQ 的逻辑。每次检索消息时,都会将我存储在队列中的 JSON 反序列化回 CustomerUpdatedEvent,然后引发事件:

private void Poll() {
  while (Enabled) {
    var deliveryArgs = _subscription.Next();
    var message = Encoding.Default.GetString(deliveryArgs.Body);
    var customerUpdatedEvent =
      JsonConvert.DeserializeObject<CustomerUpdatedEvent>(message);
    DomainEvents.Raise(customerUpdatedEvent);
}}

该服务包含单个类 Customer:

public class Customer{
  public Guid CustomerId { get; set; }
  public string ClientName { get; set; }
}

当 CustomerUpdatedEvent 被反序列化后,其 Customer 属性(最初由客户管理系统中的 CustomerDto 进行填充)会反序列化到该服务的 Customer 对象。

引发的事件身上所发生的情况是该服务中最有趣的部分。以下是处理该事件的类 CustomerUpdatedHandler:

public class CustomerUpdatedHandler : IHandle<CustomerUpdatedEvent>{
  public void Handle(CustomerUpdatedEvent customerUpdatedEvent){
    var customer = customerUpdatedEvent.Customer;
    using (var repo = new SimpleRepo()){
      if (customerUpdatedEvent.IsNew){
        repo.InsertCustomer(customer);
      }
      else{
        repo.UpdateCustomer(customer);
 }}}}

该服务使用实体框架 (EF) 与数据库进行交互。图 5 显示相关交互封装在简单的存储库的两种方法中,即 InsertCustomer 和 UpdateCustomer。如果事件的 IsNew 属性为 True,则该服务将调用存储库的 InsertCustomer 方法。反之,则调用 UpdateCustomer 方法。

图 5 InsertCustomer 和 UpdateCustomer 方法

public void InsertCustomer(Customer customer){
  using (var context = new CustomersContext()){
    context.Customers.Add(customer);
    context.SaveChanges();
}}
public void UpdateCustomer(Customer customer){
  using (var context = new CustomersContext()){
    var pId = new SqlParameter("@Id", customer.CustomerId);
    var pName = new SqlParameter("@Name", customer.ClientName);
    context.Database.ExecuteSqlCommand      
      ("exec ReplaceCustomer {0}, {1}", 
        customer.CustomerId, customer.ClientName);
}}

这些方法使用 EF DbContext 执行相关的逻辑。对于某个插入对象,它添加客户,然后调用 SaveChanges。EF 将执行数据库插入命令。对于某个更新对象,它将 CustomerID 和 CustomerName 发送到存储过程中,该存储过程使用我或我信任的 DBA 定义的任何逻辑来执行更新。

因此,该服务在数据库中执行必要的工作以确保订单系统中的客户列表始终包含最新的客户名单,与客户维护系统中保留的一样。

是的,这涉及到许多层和部分!

因为我使用了简单的示例来演示此工作流,因此您可能在想该解决方案实在太大材小用。不过,请记住,重点是此示例演示了如何在使用 DDD 实践解决复杂的软件问题时构建一个解决方案。在探讨客户维护领域时,我并不关心其他系统。通过使用抽象概念与 IoC、处理程序和消息队列,我可以满足外部系统的需求,无需将领域本身复杂化。Customer 类只引发事件。对于此演示,这是最容易确保该工作流对读者有意义的地方,但是对于您的领域可能仍然比较复杂。当然,您可以在您应用程序中的其他位置引发该事件,也许可以从存储库中引发,因为其即将把更改内容推送到自己的数据存储中。

在本专栏中下载的示例解决方案确实使用了 RabbitMQ,并且要求在您的计算机上安装其轻量型开源服务器。我在可下载的自述文件中附加了参考资料。我还将在我的博客(位于 thedatafarm.com)上发布一个简短的视频,以便您可以看到我逐步调试代码、检查 RabbitMQ Manager 和数据库以查看结果的过程。


Julie Lerman 是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。您可以在全球的用户组和会议中看到她对数据访问和其他 .NET 主题的演示。她是《Programming Entity Framework》(2010) 以及“代码优先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客网址为 thedatafarm.com/blog。通过她的 Twitter(网址为 twitter.com/julielerman)关注她,并在 juliel.me/PS-Videos 上观看其 Pluralsight 课程。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Cesar de la Torre