对于我的整个编码生涯,重用代码和重用数据一直是一个驱动目标。所以当我开始学习领域驱动设计(DDD)的时候,我与DDD所强调的跨边界上下文的分离做过斗争,边界上下导致的结果可能是重复的代码甚至重复的数据。当DDD中最好的一些思想企图帮助我看清我的老方法中的潜在缺陷时我有了一个小而合适的想法。最终,Eric Evans解释为必须选择在哪里为复杂性付费。因为DDD介绍的是在软件方面减少复杂性,结果是你可能需要在维护重复模型和重复数据方面付出代价。
在这个专栏中,我已经写过关于DDD的概念和如何在它上应用数据驱动的经验,2013年1月的第1个专栏“Shrink EF model with DDD Bounded Contexts”(bit.ly/isIoGE)和”Coding for Domain-Driven Design: Tips for Data-Focused Devs”的3部系列,它在“bit.ly/XyCNrU”.在这个系列的第一个,你能找到一个标题是”Shared Data Can Be a Curse in Complex Systems.”至于为什么我这里描述的方法是有用的请仔细的看看那篇文章。
我已经被问过好多次关于如果你遵循极端的DDD模式中的每一个上下文绑定到它自己的数据库时如何在上下文之间共享数据。Steve Smith和我在我们在Pluralsight.com(bitly.com/PS-DDD)上的领域驱动设计基础课程中讨论过这个问题,虽然对于那种基础课程的关注点这个问题有一点高级。
有很多种方法在边界上下文间使用公共数据。在这个专栏中我们将关注一个特定的场景:从一个系统到另一个系统映射数据,第1个系统负责编辑数据,第2个系统仅仅需要只读权限访问那些数据。
我将首先列出一个基本模式,然后添加一些更加详细的内容。这个实现涉及到一些工作单元,包括控制反转(IOC)容器和消息队列。如果你熟悉这些工具,实现应该是更加容易理解。我不会详细的讲解关于IOC和队列的实现,但是你可以看和调试这篇文章附加的代码示例。
一个简单场景:共享用户列表
我选择了一个非常简单的场景来描述这个模式。一个系统作为用于服务。在这里,用户能够维护客户数据和其它一些数据。这个系统与一个数据存储结构交互,但是对于这个例子来说它不是重点。第2个系统用于下单。在这个系统中,用户需要访问客户信息,但是仅仅是标示下单的客户。也就是说这个边界上下文仅需要读取客户的姓名和标示。因此第2个系统连接的数据库需要一个在第一个系统中维护的客户数据的最新的客户名和标示。我使用的方法是在第2个系统中映射第一个系统中维护的客户的那两个数据片段。
映射数据:高层次
在一个非常高的层次,解决方案是每一次插入客户到系统A时,第2个系统的数据存储器也应该插入那个客户的ID和名称。如果我在系统A中修改已存在客户的名字,那么系统B也需要获得正确的名字,所以名字的改变也应该引起系统B的数据存储器的更新。这个领域不删除数据,但是未来的强化版可能会在系统B中删除没激活的客户。我不想在这个实现中实现这个。
从上面的片段可以看到,我仅仅关心系统A中的两个事件,我需要响应这两个事件的发生:
1.客户的插入
2.已存在客户的名称更新
在一个相连接的系统中,系统B可以暴露一个被系统A调用的方法,例如InsertCustomer或者UpDateCustomerName。或者系统A触发一个事件,例如CustomerCreated和CustomerNameUpdated,对于其它系统,包括系统B,可以捕获和响应这个事件。
为了响应每一个事件,系统B需要在它的数据库上做一些事情。
因为这些系统不是互相连接的,一个更好的方式是使用发布-订阅模式。系统A将在一些操作类型上发布一个或多个事件,然后一个或多个系统订阅相同的操作,等待特定事件的发生,最后执行它们自己的动作作为响应。
DDD的原则是这两个系统并不知道彼此,因为订阅-发布不能直接连接另一个系统。所以取而代之,我将使用一个称为防腐层的概念。每一个系统将通过一个能够在两者之间摆渡消息的操作来通信。
这个操作即使消息队列。系统A将发消息到队列。系统B从队列接受消息。在我的例子中,我只有一个订阅者系统B,但是可以有多个订阅者。
什么是事件消息?
当发布的事件是CustomerCreated,系统A将发送一个消息说“客户已经被创建。这是客户的标示和名字。”这是一个完整的消息。发布一个事件到消息队列的有意思的部分是发布者不需要关心什么系统会接收消息已经做怎样的响应。
系统B将响应此事件并插入或者更新数据库中的客户。在现实中,系统B甚至不执行这个任务;我将使用一个服务做这个工作。初次之外,我将让数据库决定怎样更新。在这种情况下,系统B的数据库将使用存储过程客户“更新”,它通过删除源客户记录然后插入一个新记录。因为我使用GUIDs作为标示键,用户的标示也是可以维护的。在我的DDD软件中我不担心数据库生成键。预先生成GUIDs与数据库自增键的比较是一个很烦的主题。你需要根据你公司的数据库实践去决定你的业务。
最终,系统B(订单系统)有一个可以使用的完整客户列表。沿着这个工作流程,如果订单系统需要更多关于特定客户的数据,例如信用卡信息和当前配送地址,我可以使用其它机制,例如调用一个服务来获取这些数据。最然如此,但我不会在这里实现这个流程。
与消息系统通信
允许你已异步方式交流消息的系统被称为事件总线。一个事件总线包括一个存储消息和提供消息给需要获取此消息对象的基础架构。它也提供一些API与它交互。我将关注一个我认为是一个简单的开始这种消息队列交流的典型场景。有很多消息队列可供选择。在DDD基础课程上,Smith和我选择使用SQL Server Service Broker作为我们的消息队列。因为我们两都使用SQL Server,对于我们它的设置很简单,并且我们只需要写SQL推消息到队列然后获取它们。
在写这篇文章的时候,我决定是时候学习一个更加流行且开元的消息队列,RabbitMQ。这要求在我的机器上安装RabbitMQ(和Erlang!),和拉数据的RabbitMQ的.NET客户端,因此我们在我的应用中很容易写代码。你可以从rabbitmq.com获取更多内容。我也发现Pluralsight.com上的关于rabbitmq的课程非常有用。
系统A发送消息到RabbitMQ服务器。但是系统B(订单系统)不需要做任何事情。系统B简单的期望用户列表到数据库中并不关心它是如何进去的。一个单独的小得Windows服务将负责检查RabbitMQ消息队列和更新响应的订单系统数据库。图1显示了整个过程的可视化工作流。
发送消息到队列
在系统A中,我从Customer类开始,如图2所示。对于一个简单实例,它仅包含很少的属性-客户的ID,Name,地方和一些日志时间。根据DDD模式,一个对象需要内置约束防止任意的修改。你使用创建工厂方法创建一个新客户。如果你需要固定的姓名,你使用FixName方法。
Figure 2 The Customer Class in the Customer Maintenance-Bounded Context
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’s 2009 MSDN杂志文章“Employing the Domain Model Pattern”介绍的DomainEvents类来抛出一个新的CustomerUpdatedEvent事件(图3)。在我的例子中,我在一些简单动作上发布了这些时间。在真实的实现中,你可能更倾向于在数据已经成功持久化到系统A的数据库后发布这些事件。
Figure 3 A Class That Encapsulates an Event When a Customer Is Updated
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包括了所有我想要知道的内容:CustomerDtod带有一个标志说明客户是否是新的。对于一个泛型数据处理器将需要一些元数据。
在我的例子中,CustomerUpdatedEvent可以被多个处理器处理。我只定义了一个处理器,CustomerUpdatedService服务。
public class CustomerUpdatedService : IHandle{
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程序员中用于管理应用的IoC工具。StructureMap让我指明在哪里找到处理DomainEvents抛出的事件的处理器。StructureMap的作者,Jeremy Miller,在MSDN杂志上写了一些非常精彩的系列文章”Patterns in Practice”,在这个例子(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管理器,使用它的工具。注意测试构造函数中我初始化了一个叫IoC的类。这是我配置StructureMap绑定IMessagePublisher和我的事件处理器的地方。
Figure 4 Publishing to RabbitMq in Tests
[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服务持续的跑,周期性的从队列中获取新消息处理。当它看到一个消息,服务获取并且处理它。消息也可以被其它订阅者处理。在这个简单的例子中,我穿件了一个简单的控制台程序,并不是一个服务。这允许我在学习的时候简单地运行和调试“服务”。在下一个迭代中,我可能使用Microsoft Azure WebJobs(bit.ly/1l3PTYH),而不是纠结使用Windows服务还是控制台程序。
服务使用了与抛出事件相同的模式,在处理类中响应事件并且初始化一个IoC类通过StructureMap定位事件处理器。
服务使用RabbitMQ .NET客户端订阅类监听RabbitMQ的消息。你可以在下面的Pull方法中看到这段逻辑,_subscription 对象持续监听消息。每次消息被获取,它反序列化JSON成CustomerUpdatedEvent,然后抛出事件
private void Poll() {
while (Enabled)
{
var deliveryArgs = _subscription.Next();
var message = Encoding.Default.GetString(deliveryArgs.Body);
var customerUpdatedEvent = JsonConvert.DeserializeObject(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{
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方法。
Figure 5 The InsertCustomer and UpdateCustomer Methods
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将执行数据库的插入命令。对于update,它将发送CustomerID和CustomerName到存储过程,它将使用我或者DBA定义的语句执行更新。
因此,服务在数据库上执行需要的工作确保订单系统中的用户列表与客户管理系统中的用户总是最新的。
是的,这有大量的层和迷惑的片段
因为我使用了一个简单的例子来描述这个工作流,你可能认识这个解决方案是极其过度的。但是记住,关键点是如何在当你使用DDD原则解决复杂软件问题时如何协调这种方案。当我关注客户维护领域时,我不关心其它系统。通过Ioc,处理器和消息队列使用抽象,我可以不需要修改领域自身,满足额外系统的需求。Customer类简单地引发事件。对于这个演示,这是一个最简单的地方确保堵住对这个工作流的理解,但是它可能对于你的领域来说有点混乱。你可以在应用的其它地方引发事件,可能是从仓储引发因为它是把改变放到数据存储器的地方。
这个专栏下载的解决方案使用RabbitMQ,它需要在你的电脑上安装它的轻量级,开源服务器版本。我已经在下载的readme文件中包含了引用。我也在我的博客上放了一个短视频,你可以看到我一步步写代码,监控RabbitMQ管理器和在数据库上查看结果。
原文链接: Data Points : A Pattern for Sharing Data Across Domain-Driven Design Bounded Contexts