DDD与批量操作
原文鏈接:https://enterprisecraftsmanship.com/posts/ddd-bulk-operations/
將批量操作與領(lǐng)域驅(qū)動設(shè)計相結(jié)合是一個困難的問題。在這篇文章中,我們將看看為什么會這樣,并討論如何結(jié)合兩個。
本文也是對讀者提問的回應(yīng)。這個問題包含一個有趣的例子,我將在本文中使用:
Hi Vladimir!
你有關(guān)于DDD環(huán)境下批量操作的文章嗎?我沒有發(fā)現(xiàn)任何有用的東西。
請考慮以下示例:
給定一個任務(wù)列表,我想為所有與所選月份和類別匹配的任務(wù)設(shè)置一個執(zhí)行日期,
另外,我不能為已經(jīng)完成的任務(wù)設(shè)置執(zhí)行日期,
對于給定的月份和類別,最多可以有30000個任務(wù)。
目前,我創(chuàng)建了一個SetExecutionDateDomainService:
查詢tasksRepository.GetBy(month, category),
對于每個任務(wù),檢查task.CanSetExecutionDate(),
如果為true則調(diào)用taskRepository.Update(task)。
關(guān)于如何處理這個問題有什么意見/建議嗎?
有三種方法可以處理此問題:
逐個處理對象(問題作者的處理方式),
依賴SQL批量更新,
結(jié)合使用規(guī)約和命令模式。
前兩種選擇有權(quán)衡。我特別喜歡第三個。
順序處理
處理此問題最直接的方法是檢索所有合適的對象,然后依次更新它們:
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);}}} }該解決方案的主要優(yōu)點是所有領(lǐng)域知識都包含在領(lǐng)域模型中。具體來說,執(zhí)行日期何時可以設(shè)置的知識(CanSetExecutionDate方法)。
這里的缺點是缺乏性能:單獨處理和更新任務(wù)需要大量的數(shù)據(jù)庫往返——每次更新一次。
在OLTP類型的操作(少量數(shù)據(jù)的事務(wù)處理)之外,DDD通常不能很好地工作。對于批量更新大量任務(wù)的用例也是如此——它不屬于DDD的“舒適區(qū)”。
批量操作(或批量更新)是在一次數(shù)據(jù)庫往返中更新大量數(shù)據(jù)。
使用原始SQL
如果DDD不能很好地與批量更新配合使用,那該怎么辦?這就是原始SQL的閃光點。SQL專門設(shè)計用于處理大量相關(guān)數(shù)據(jù),我們也可以將其用于我們的場景:
UPDATE?dbo.Task SET?ExecutionDate?=?@ExecutionDate WHERECategory?=?@Category?ANDMonth?=?@Month?ANDIsCompleted?=?0?--?領(lǐng)域知識重復(fù)這種方法既快速又簡單,但違反了DRY原則:您必須將哪些任務(wù)有資格設(shè)置執(zhí)行日期的知識同時放到SQL(IsCompleted=0行)和應(yīng)用程序代碼(CanSetExecutionDate方法)中。
使用原始SQL可能不是一個壞的選擇,特別是在簡單的項目中,但是有更好的方法。
使用規(guī)約模式
簡而言之,規(guī)約模式 是關(guān)于將一段領(lǐng)域知識封裝到單個單元(稱為規(guī)約)中,然后在三種場景中重用該單元:
數(shù)據(jù)檢索
內(nèi)存驗證
創(chuàng)建新對象(下圖中的“按順序施工”)。
我還寫過,雖然這個模式的想法看起來很有趣,但它與CQRS模式相反,因此應(yīng)該被丟棄。原因是CQRS提供了另一個好處——松耦合,在絕大多數(shù)情況下比DRY更重要。
CQRS通過將單個統(tǒng)一模型拆分為兩個來實現(xiàn)松耦合:一個用于讀取(數(shù)據(jù)檢索,原始SQL查詢的范圍),另一個用于寫入(內(nèi)存驗證,DDD的范圍)。這種分離就是矛盾所在:規(guī)約模式主張保持統(tǒng)一的模型。
那么,規(guī)約模式如何在批量更新的場景中提供幫助呢?
事實證明,您不僅可以使用規(guī)約來查詢數(shù)據(jù)庫,還可以更新數(shù)據(jù)庫。首先讓我展示這個模式的一個典型用法。然后我將演示如何為批量更新用例擴展它。
在上述設(shè)置任務(wù)執(zhí)行日期的用例中,我們需要以下三個規(guī)約:
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類和所有其他支持類的源代碼。
根據(jù)這些規(guī)約,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的使用。它看起來可能是多余的(畢竟,此規(guī)約檢查同一任務(wù)實例的IsCompleted字段),但在應(yīng)用程序中分配域知識時保持一致是很重要的。一旦您引入了一個規(guī)約來保存一部分知識,所有其他類也應(yīng)該開始使用它來遵守DRY原則。
以下是領(lǐng)域服務(wù):
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);}}} }領(lǐng)域服務(wù)組合了三個規(guī)約(第1行)并將它們傳遞給倉儲(“2”)。倉儲如下所示(我使用的是NHibernate,但實體框架的代碼是相同的):
public?IReadOnlyList<Task>?GetList(Specification<Task>?specification) {return?_session.Query<Task>().Where(specification.ToExpression()).ToList(); }這段代碼依賴于復(fù)雜的ORM功能,它遍歷規(guī)約的表達式樹并將其轉(zhuǎn)換為SQL。例如,此組合規(guī)約
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的結(jié)合是一對強大的組合。但即使是他們也只能帶你走這么遠。ORMs允許您使用表達式來查詢數(shù)據(jù)庫,但不能更新它。為了實現(xiàn)批量更新功能(將執(zhí)行日期設(shè)置為一次數(shù)據(jù)庫往返中的所有任務(wù)),我們需要更新數(shù)據(jù)庫。
那么,該怎么辦呢?
好消息是,使用規(guī)約模式處理數(shù)據(jù)庫不必依賴ORMs或C#表達式。表達式樹是一個方便的工具,可以簡化規(guī)約的實現(xiàn),但它們只是這樣一個工具。
另一個工具是原始SQL本身。實際上,您可以將這兩種方法結(jié)合起來:使用表達式樹進行內(nèi)存驗證和查詢數(shù)據(jù)庫,使用原始SQL進行批量更新。其思想是,除了ToExpression方法外,每個規(guī)約還必須實現(xiàn)ToSql(),以便為updatesql查詢生成適當(dāng)?shù)倪^濾器。
下面是基本規(guī)約類的外觀(同樣,請查看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將規(guī)約轉(zhuǎn)換為SQL,ToSqlParameters為該SQL提供必需的參數(shù)。
現(xiàn)在您需要在所有規(guī)約子類中實現(xiàn)這兩個方法。舉個例子:
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();} }這種規(guī)約模式的使用帶來了第四種場景,批量更新:
注意,這個用例并不與CQRS相矛盾:用于內(nèi)存驗證和批量更新的領(lǐng)域知識的重用發(fā)生在應(yīng)用程序的寫部分。因此,我想收回我先前的說法,即規(guī)約只在簡單的場景中有用(在這種場景中松耦合并不是那么重要)。批量更新是這種模式的一個非常有效的用例,這種用例可以出現(xiàn)在任何復(fù)雜的應(yīng)用程序中。
在上述實現(xiàn)中,有關(guān)如何為批量更新選擇任務(wù)的業(yè)務(wù)需求都位于域?qū)印_@些要求是三個前提條件的組合,所有這些條件都包含在規(guī)約中:
特定月份的任務(wù),
具有特定類別的任務(wù),
未完成的任務(wù)。
那么,問題解決了?還沒有。雖然我們已經(jīng)封裝了哪些任務(wù)適合更新的知識,但更新本身仍然分散在Task領(lǐng)域類和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();} }這是領(lǐng)域邏輯重復(fù)的另一個實例。為了解決這個問題,我們需要另一塊拼圖:命令模式。
遇見命令模式
上面清單中的重復(fù)似乎不是什么大事,因為它只是一個字段的賦值。但事實上,這是一件大事?—?還有一個先決條件要求任務(wù)不能完成,才能有執(zhí)行日期:
public?void?SetExecutionDate(DateTime?executionDate) {/*?此前提條件是執(zhí)行日期分配的固有部分?*/Guard.Require(CanSetExecutionDate(),?"CanSetExecutionDate()");ExecutionDate?=?executionDate; }設(shè)置執(zhí)行日期的行為是整個SetExecutionDate方法,而不僅僅是其中的賦值操作(=)。該方法的前提條件也存在于SQL查詢TaskRepository生成的:
UPDATE?dbo.Task SET?ExecutionDate?=?@ExecutionDate WHERE?[Month]?=?@MonthAND?Category?=?@CategoryAND?NOT(IsCompleted?=?1)?--?前提條件問題是沒有任何東西可以阻止TaskRepository在未查詢此前提條件的情況下設(shè)置執(zhí)行日期。IsCompleted和ExecutionDate字段之間的連接是一項重要的領(lǐng)域知識,您必須記住這一點,并在Task和TaskRepository中復(fù)制它們。
想象一下,不必指定DateTime這樣的基本類型,而必須指定一個包含多個字段的值對象。讓Task和TaskRepository中的邏輯不同步變得非常容易。
那么,如何克服這個問題,避免賦值邏輯的重復(fù)呢?這就是命令模式發(fā)揮作用的地方。
命令模式本質(zhì)上與規(guī)約的作用相同,但是命令不檢查領(lǐng)域?qū)ο蟮膶傩?#xff0c;而是更改這些屬性。您可以將這兩種模式之間的差異想象為:
規(guī)約模式封裝了要更新哪些數(shù)據(jù)的知識。
命令模式封裝了如何更新數(shù)據(jù)的知識。
另外,雖然您可以在4種場景中使用規(guī)約,但命令僅在兩種情況下有用:內(nèi)存更新和批量更新。
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();/*?內(nèi)存更新?*/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參數(shù)?*/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();} }請注意,規(guī)約限制('1)是可選的(您可以將它們應(yīng)用于命令,也可以不應(yīng)用于命令),但規(guī)約前提條件是必需的。事實上,您甚至沒有指定該前提條件的選項?—?它被放到命令本身中。這就是封裝的本質(zhì):你不能總是相信自己會做正確的事情;你必須消除做錯事的可能性。
另外請注意,我不熟悉應(yīng)用程序的細節(jié),并假設(shè)月份和類別限制是可選的。如果不是,您也應(yīng)該將它們移到GetPreconditions方法,在這種情況下,命令和領(lǐng)服務(wù)將變得更加簡單:
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可能仍然是大多數(shù)項目的更好選擇,即使它不遵守 DRY 原則。但是,規(guī)約和命令模式的組合對于具有復(fù)雜域邏輯的項目可能很有用,您希望在內(nèi)存中更新和批量更新之間重用這些邏輯。
總結(jié)
DDD適合于事務(wù)處理少量數(shù)據(jù)(OLTP),不能很好地處理批量操作。
批量操作(或批量更新)是在一次數(shù)據(jù)庫往返中更新大量數(shù)據(jù)。
有三種方法可以處理批量更新:
順序處理(遵循干燥原則,不利于性能),
使用原始SQL(有利于性能,違反了DRY原則)
結(jié)合使用規(guī)約和命令模式(堅持DRY和良好的性能)。
除了內(nèi)存驗證、查詢數(shù)據(jù)庫和創(chuàng)建新對象之外,批量操作是規(guī)約模式的第四個用例。
規(guī)約模式封裝了要更新哪些數(shù)據(jù)的知識。命令模式封裝了如何更新數(shù)據(jù)的知識。這兩種模式都允許您在領(lǐng)域模型和批量操作之間重用這些知識。
命令使用規(guī)約作為
內(nèi)存更新,
批量更新。
歡迎關(guān)注我的個人公眾號”My IO“
總結(jié)
- 上一篇: dotnet中的counter
- 下一篇: 运维管理工具-- Deploy Assi