原文链接:https://enterprisecraftsmanship.com/posts/ddd-bulk-operations/
将批量操作与领域驱动设计相结合是一个困难的问题。在这篇文章中,我们将看看为什么会这样,并讨论如何结合两个。
本文也是对读者提问的回应。这个问题包含一个有趣的例子,我将在本文中使用:
Hi Vladimir!
你有关于DDD环境下批量操作的文章吗?我没有发现任何有用的东西。
请考虑以下示例:
给定一个任务列表,我想为所有与所选月份和类别匹配的任务设置一个执行日期,
另外,我不能为已经完成的任务设置执行日期,
对于给定的月份和类别,最多可以有30000个任务。
目前,我创建了一个
SetExecutionDateDomainService
:查询
tasksRepository.GetBy(month, category)
,对于每个任务,检查
task.CanSetExecutionDate()
,如果为true则调用
taskRepository.Update(task)
。关于如何处理这个问题有什么意见/建议吗?
有三种方法可以处理此问题:
逐个处理对象(问题作者的处理方式),
依赖SQL批量更新,
结合使用规约和命令模式。
前两种选择有权衡。我特别喜欢第三个。
顺序处理
处理此问题最直接的方法是检索所有合适的对象,然后依次更新它们:
public class Task
{public int Month { get; private set; }public string Category { get; private set; }public bool IsCompleted { get; private set; }public DateTime? ExecutionDate { get; private set; }public bool CanSetExecutionDate(){return IsCompleted == false;}public void SetExecutionDate(DateTime executionDate){Guard.Require(CanSetExecutionDate(), "CanSetExecutionDate()");ExecutionDate = executionDate;}
}public class SetExecutionDateService
{public void SetExecutionDate(int month, string category, DateTime executionDate){IReadOnlyList<Task> tasks = _repository.GetBy(month, category);foreach (Task task in tasks){if (task.CanSetExecutionDate()){task.SetExecutionDate(executionDate);_repository.Update(task);}}}
}
该解决方案的主要优点是所有领域知识都包含在领域模型中。具体来说,执行日期何时可以设置的知识(CanSetExecutionDate
方法)。
这里的缺点是缺乏性能:单独处理和更新任务需要大量的数据库往返——每次更新一次。
在OLTP类型的操作(少量数据的事务处理)之外,DDD通常不能很好地工作。对于批量更新大量任务的用例也是如此——它不属于DDD的“舒适区”。
批量操作(或批量更新)是在一次数据库往返中更新大量数据。
使用原始SQL
如果DDD不能很好地与批量更新配合使用,那该怎么办?这就是原始SQL的闪光点。SQL专门设计用于处理大量相关数据,我们也可以将其用于我们的场景:
UPDATE dbo.Task
SET ExecutionDate = @ExecutionDate
WHERECategory = @Category ANDMonth = @Month ANDIsCompleted = 0 -- 领域知识重复
这种方法既快速又简单,但违反了DRY原则:您必须将哪些任务有资格设置执行日期的知识同时放到SQL(IsCompleted=0
行)和应用程序代码(CanSetExecutionDate
方法)中。
使用原始SQL可能不是一个坏的选择,特别是在简单的项目中,但是有更好的方法。
使用规约模式
简而言之,规约模式 是关于将一段领域知识封装到单个单元(称为规约)中,然后在三种场景中重用该单元:
数据检索
内存验证
创建新对象(下图中的“按顺序施工”)。
我还写过,虽然这个模式的想法看起来很有趣,但它与CQRS模式相反,因此应该被丢弃。原因是CQRS提供了另一个好处——松耦合,在绝大多数情况下比DRY更重要。
CQRS通过将单个统一模型拆分为两个来实现松耦合:一个用于读取(数据检索,原始SQL查询的范围),另一个用于写入(内存验证,DDD的范围)。这种分离就是矛盾所在:规约模式主张保持统一的模型。
那么,规约模式如何在批量更新的场景中提供帮助呢?
事实证明,您不仅可以使用规约来查询数据库,还可以更新数据库。首先让我展示这个模式的一个典型用法。然后我将演示如何为批量更新用例扩展它。
在上述设置任务执行日期的用例中,我们需要以下三个规约:
public sealed class TaskIsCompletedSpecification : Specification<Task>
{public override Expression<Func<Task, bool>> ToExpression(){return task => task.IsCompleted;}
}public sealed class TaskMonthSpecification : Specification<Task>
{private readonly int _month;public TaskMonthSpecification(int month){_month = month;}public override Expression<Func<Task, bool>> ToExpression(){return task => task.Month == _month;}
}// + TaskCategorySpecification, which is the same as TaskMonthSpecification
您可以在此GitHub仓储中找到基本
Specification
类和所有其他支持类的源代码。
根据这些规约,Task
如下所示:
public class Task
{public int Month { get; private set; }public string Category { get; private set; }public bool IsCompleted { get; private set; }public DateTime? ExecutionDate { get; private set; }public bool CanSetExecutionDate(){var spec = new TaskIsCompletedSpecification(); '1return spec.IsSatisfiedBy(this) == false; '1}public void SetExecutionDate(DateTime executionDate){Guard.Require(CanSetExecutionDate(), "CanSetExecutionDate()");ExecutionDate = executionDate;}
}
请注意'1'中TaskIsCompletedSpecification
的使用。它看起来可能是多余的(毕竟,此规约检查同一任务实例的IsCompleted字段),但在应用程序中分配域知识时保持一致是很重要的。一旦您引入了一个规约来保存一部分知识,所有其他类也应该开始使用它来遵守DRY原则。
以下是领域服务:
public class SetExecutionDateService
{public void SetExecutionDate(int month, string category, DateTime executionDate){var monthSpec = new TaskMonthSpecification(month);var categorySpec = new TaskCategorySpecification(category);var isNotCompletedSpec = new TaskIsCompletedSpecification().Not();Specification<Task> spec = monthSpec.And(categorySpec).And(isNotCompletedSpec); '1IReadOnlyList<Task> tasks = _repository.GetList(spec); '2foreach (Task task in tasks){if (task.CanSetExecutionDate()){task.SetExecutionDate(executionDate);_repository.Update(task);}}}
}
领域服务组合了三个规约(第1行)并将它们传递给仓储(“2”)。仓储如下所示(我使用的是NHibernate,但实体框架的代码是相同的):
public IReadOnlyList<Task> GetList(Specification<Task> specification)
{return _session.Query<Task>().Where(specification.ToExpression()).ToList();
}
这段代码依赖于复杂的ORM功能,它遍历规约的表达式树并将其转换为SQL。例如,此组合规约
var monthSpec = new TaskMonthSpecification(month);
var categorySpec = new TaskCategorySpecification(category);
var isNotCompletedSpec = new TaskIsCompletedSpecification().Not();
Specification<Task> spec = monthSpec.And(categorySpec).And(isNotCompletedSpec);
被翻译成
Month = @Month AND Category = @Category AND NOT(IsCompleted = 1)
C#表达式与ORM的结合是一对强大的组合。但即使是他们也只能带你走这么远。ORMs允许您使用表达式来查询数据库,但不能更新它。为了实现批量更新功能(将执行日期设置为一次数据库往返中的所有任务),我们需要更新数据库。
那么,该怎么办呢?
好消息是,使用规约模式处理数据库不必依赖ORMs或C#表达式。表达式树是一个方便的工具,可以简化规约的实现,但它们只是这样一个工具。
另一个工具是原始SQL本身。实际上,您可以将这两种方法结合起来:使用表达式树进行内存验证和查询数据库,使用原始SQL进行批量更新。其思想是,除了ToExpression方法外,每个规约还必须实现ToSql(),以便为updatesql查询生成适当的过滤器。
下面是基本规约类的外观(同样,请查看GitHub仓储以获取完整的源代码):
public abstract class Specification<T>
{public bool IsSatisfiedBy(T entity){Func<T, bool> predicate = ToExpression().Compile();return predicate(entity);}public abstract Expression<Func<T, bool>> ToExpression();/* And(), Or(), Not() methods */
}
您需要添加两个新的抽象方法:
public abstract string ToSql();
public abstract IEnumerable<SqlParameter> ToSqlParameters();
ToSql
将规约转换为SQL,ToSqlParameters
为该SQL提供必需的参数。
现在您需要在所有规约子类中实现这两个方法。举个例子:
public sealed class TaskMonthSpecification : Specification<Task>
{private readonly int _month;public TaskMonthSpecification(int month){_month = month;}public override Expression<Func<Task, bool>> ToExpression(){return task => task.Month == _month;}public override string ToSql(){return "[Month] = @Month";}public override IEnumerable<SqlParameter> ToSqlParameters(){yield return new SqlParameter("Month", _month);}
}
最后,批量更新是这样的:
// Domain service
public void SetExecutionDate(int month, string category, DateTime executionDate)
{var monthSpec = new TaskMonthSpecification(month);var categorySpec = new TaskCategorySpecification(category);var isNotCompletedSpec = new TaskIsCompletedSpecification().Not();Specification<Task> spec = monthSpec.And(categorySpec).And(isNotCompletedSpec);_repository.UpdateExecutionDate(executionDate, spec);
}// TaskRepository
public void UpdateExecutionDate(DateTime executionDate, Specification<Task> specification)
{string sql = @"UPDATE dbo.TaskSET ExecutionDate = @ExecutionDateWHERE " + specification.ToSql();using (DbCommand command = _session.Connection.CreateCommand()){command.CommandText = sql;command.Parameters.AddRange(specification.ToSqlParameters().ToArray());command.Parameters.Add(new SqlParameter("ExecutionDate", executionDate));command.ExecuteNonQuery();}
}
这种规约模式的使用带来了第四种场景,批量更新:
注意,这个用例并不与CQRS相矛盾:用于内存验证和批量更新的领域知识的重用发生在应用程序的写部分。因此,我想收回我先前的说法,即规约只在简单的场景中有用(在这种场景中松耦合并不是那么重要)。批量更新是这种模式的一个非常有效的用例,这种用例可以出现在任何复杂的应用程序中。
在上述实现中,有关如何为批量更新选择任务的业务需求都位于域层。这些要求是三个前提条件的组合,所有这些条件都包含在规约中:
特定月份的任务,
具有特定类别的任务,
未完成的任务。
那么,问题解决了?还没有。虽然我们已经封装了哪些任务适合更新的知识,但更新本身仍然分散在Task
领域类和TaskRepository
之间('1和'2):
public class Task
{/* Month, Category, IsCompleted, ExecutionDate properties */public bool CanSetExecutionDate(){var spec = new TaskIsCompletedSpecification();return spec.IsSatisfiedBy(this) == false;}public void SetExecutionDate(DateTime executionDate){Guard.Require(CanSetExecutionDate(), "CanSetExecutionDate()");ExecutionDate = executionDate; '1}
}// TaskRepository
public void UpdateExecutionDate(DateTime executionDate, Specification<Task> specification)
{string sql = @"UPDATE dbo.TaskSET ExecutionDate = @ExecutionDate '2WHERE " + specification.ToSql();using (DbCommand command = _session.Connection.CreateCommand()){command.CommandText = sql;command.Parameters.AddRange(specification.ToSqlParameters().ToArray());command.Parameters.Add(new SqlParameter("ExecutionDate", executionDate));command.ExecuteNonQuery();}
}
这是领域逻辑重复的另一个实例。为了解决这个问题,我们需要另一块拼图:命令模式。
遇见命令模式
上面清单中的重复似乎不是什么大事,因为它只是一个字段的赋值。但事实上,这是一件大事 — 还有一个先决条件要求任务不能完成,才能有执行日期:
public void SetExecutionDate(DateTime executionDate)
{/* 此前提条件是执行日期分配的固有部分 */Guard.Require(CanSetExecutionDate(), "CanSetExecutionDate()");ExecutionDate = executionDate;
}
设置执行日期的行为是整个SetExecutionDate
方法,而不仅仅是其中的赋值操作(=
)。该方法的前提条件也存在于SQL查询TaskRepository
生成的:
UPDATE dbo.Task
SET ExecutionDate = @ExecutionDate
WHERE [Month] = @MonthAND Category = @CategoryAND NOT(IsCompleted = 1) -- 前提条件
问题是没有任何东西可以阻止TaskRepository
在未查询此前提条件的情况下设置执行日期。IsCompleted
和ExecutionDate
字段之间的连接是一项重要的领域知识,您必须记住这一点,并在Task
和TaskRepository
中复制它们。
想象一下,不必指定DateTime
这样的基本类型,而必须指定一个包含多个字段的值对象。让Task
和TaskRepository
中的逻辑不同步变得非常容易。
那么,如何克服这个问题,避免赋值逻辑的重复呢?这就是命令模式发挥作用的地方。
命令模式本质上与规约的作用相同,但是命令不检查领域对象的属性,而是更改这些属性。您可以将这两种模式之间的差异想象为:
规约模式封装了要更新哪些数据的知识。
命令模式封装了如何更新数据的知识。
另外,虽然您可以在4种场景中使用规约,但命令仅在两种情况下有用:内存更新和批量更新。
Command
基类的如下:
public abstract class Command<T>
{/* 先决条件之外的限制 */protected readonly IReadOnlyList<Specification<T>> _restrictions; '1protected Command(IReadOnlyList<Specification<T>> restrictions){_restrictions = restrictions;}/* Command's 前提条件 */protected abstract IEnumerable<Specification<T>> GetPreconditions(); '2private Specification<T> CombinedSpecification =>GetPreconditions().Concat(_restrictions).Aggregate(Specification<T>.All, (x, y) => x.And(y));protected abstract void ExecuteCore(T entity);protected abstract string GetTableName();protected abstract string ToSqlCore();protected abstract IEnumerable<SqlParameter> ToSqlParametersCore();/* 内存更新 */public bool CanExecute(T entity){return CombinedSpecification.IsSatisfiedBy(entity);}public void Execute(T entity){if (CanExecute(entity) == false)throw new InvalidOperationException();ExecuteCore(entity);}/* 用于批量更新的SQL */public string ToSql(){return @"UPDATE " + GetTableName() + @"SET " + ToSqlCore() + @"WHERE " + CombinedSpecification.ToSql();}/* 用于批量更新的SQL参数 */public IReadOnlyList<SqlParameter> ToSqlParameters(){return CombinedSpecification.ToSqlParameters().Concat(ToSqlParametersCore()).ToArray();}
}
这个类看起来有点大,但背后的想法很简单 — 将前提条件放到命令中,这样就连省略这些前提条件的选项都没有了。除了先决条件(第2行)之外,还可以对命令施加其他限制(“1”)。
下面是我们的批量更新Command:
public class SetExecutionDateCommand : Command<Task>
{private readonly DateTime _executionDate;public SetExecutionDateCommand(DateTime executionDate, params Specification<Task>[] restrictions): base(restrictions){_executionDate = executionDate;}protected override IEnumerable<Specification<Task>> GetPreconditions(){yield return new TaskIsCompletedSpecification().Not();}protected override void ExecuteCore(Task entity){entity.ExecutionDate = _executionDate;}protected override string GetTableName(){return "dbo.Task";}protected override string ToSqlCore(){return "ExecutionDate = @ExecutionDate";}protected override IEnumerable<SqlParameter> ToSqlParametersCore(){yield return new SqlParameter("ExecutionDate", _executionDate);}
}
用法如下:
// SetExecutionDateService
public void SetExecutionDate(int month, string category, DateTime executionDate)
{var monthSpec = new TaskMonthSpecification(month); '1var categorySpec = new TaskCategorySpecification(category); '1var command = new SetExecutionDateCommand(executionDate, monthSpec, categorySpec);_repository.BulkUpdate(command);
}// TaskRepository
public void BulkUpdate(SetExecutionDateCommand command)
{using (DbCommand dbCommand = _session.Connection.CreateCommand()){dbCommand.CommandText = command.ToSql();dbCommand.Parameters.AddRange(command.ToSqlParameters().ToArray());dbCommand.ExecuteNonQuery();}
}
请注意,规约限制('1)是可选的(您可以将它们应用于命令,也可以不应用于命令),但规约前提条件是必需的。事实上,您甚至没有指定该前提条件的选项 — 它被放到命令本身中。这就是封装的本质:你不能总是相信自己会做正确的事情;你必须消除做错事的可能性。
另外请注意,我不熟悉应用程序的细节,并假设月份和类别限制是可选的。如果不是,您也应该将它们移到GetPreconditions
方法,在这种情况下,命令和领服务将变得更加简单:
public class SetExecutionDateCommand : Command<Task>
{private readonly DateTime _executionDate;private readonly int _month;private readonly string _category;public SetExecutionDateCommand(DateTime executionDate, int month, string category): base(new Specification<Task>[0]){_category = category;_month = month;_executionDate = executionDate;}protected override IEnumerable<Specification<Task>> GetPreconditions(){yield return new TaskIsCompletedSpecification().Not();yield return new TaskMonthSpecification(_month);yield return new TaskCategorySpecification(_category);}/* 剩下的一样 */
}// SetExecutionDateService
public void SetExecutionDate(int month, string category, DateTime executionDate)
{var command = new SetExecutionDateCommand(executionDate, month, category);_repository.BulkUpdate(command);
}
同样,由于其简单性,原始SQL可能仍然是大多数项目的更好选择,即使它不遵守 DRY 原则。但是,规约和命令模式的组合对于具有复杂域逻辑的项目可能很有用,您希望在内存中更新和批量更新之间重用这些逻辑。
总结
DDD适合于事务处理少量数据(OLTP),不能很好地处理批量操作。
批量操作(或批量更新)是在一次数据库往返中更新大量数据。
有三种方法可以处理批量更新:
顺序处理(遵循干燥原则,不利于性能),
使用原始SQL(有利于性能,违反了DRY原则)
结合使用规约和命令模式(坚持DRY和良好的性能)。
除了内存验证、查询数据库和创建新对象之外,批量操作是规约模式的第四个用例。
规约模式封装了要更新哪些数据的知识。命令模式封装了如何更新数据的知识。这两种模式都允许您在领域模型和批量操作之间重用这些知识。
命令使用规约作为
内存更新,
批量更新。
欢迎关注我的个人公众号”My IO“