戲說領域驅動設計(廿四)——資源庫
開講資源庫,這東西簡單來說就是用於持久化或查詢聚合的。注意!您需要與DAO分別:DAO操作的對象是數據實體;而資源倉庫的目標是聚合(不存在通過資源庫操作值對象的情況,值對象必須依賴於某個實體)。你完全可以把資源庫想像成為一個盒子,想要存儲聚合的時候直接放進去即可;想要修改只需要取出後再放進去,就能把原有的對象替換掉;想要刪除也只需要隨手從盒子取出扔掉即可,至於盒子本身如何實現存儲,作用用戶的你根本不必關心。當然了,作為程序員得關心,你得為了達到這樣一個目的去實現代碼。從技術的角度去看,您完全可以把資源庫當成領域模型序列化和反序列化的外觀模式。這裡需要注意一點,一個對象如果被放入了兩次,在資源倉庫中也只存在一個,有點類似於Java中的Set集合。至於如何區分不同的實體,當然是ID嘍,前面講過一百萬次了。
1、聚合接口的定義
我們在使用資源庫的時候需要注意把接口的聲明與實現進行分離。主要是因為這兩個組件分別屬於不同層:接口定義屬於業務模型層,這裡我還是應該再強調一下:資源庫不是DAO,您千萬別使用錯了,其操作的目標是聚合,不要啥啥都往裏面放。資源庫包含兩類操作:領域模型新建或修改後肯定需要進行持久化,是為寫操作,接收的參數應該是領域模型;涉及一些命令型的業務,肯定需要把領域模型查出來再操作,其返回值就應該是領域模型,此為讀操作。至於說是否需要批量存儲或需要根據什麼特殊條件把領域模型搞出來,這完全是由業務規定的。總得來說,資源庫關注的領域對象的操作。那麼DAO呢?其只管數據模型的相關操作,它面向的是數據庫表並提供CURD供能,反正是把數據給你了,至於你怎麼用,是想將其轉換成領域模型還是轉換成視圖模型發送給誰它也不會關心,所以DAO的設計其實簡單,無腦的針對每一張表做類似的功能即可。此外,DAO中的方法也不是由業務驅動的,每張表都應該有插入、刪除、更新等常規操作,代碼生成器就可以根據表的定義完美生成DAO的代碼。早期我經歷過一個C#的項目,看DAO的代碼寫的那叫一個複雜,大量的匿名函數和Lamda表達式的使用,可你別看代碼複雜卻非常的規範。當時以為是哪個大神整出來的,後來發現這個大神是「Code Smith」。甲方爸爸哪懂得這些,他們看到的就是開發速度很快。這也從一方面引申出了一個問題,為什麼現在很多的軟件集成廠商混得不好了?為什麼好多單位都強調自研?一方面是出於安全和全局的控制,總讓別人抓住小辮子也不好;另外一方面,乙方的確做得太差勁,仗着別人不懂糊弄人,自已把自己的飯碗搞砸了。
針對資源庫與DAO的區別,除了操作對象不一樣外,數量也有明顯的區別:資源庫是一個聚合一個;DAO是一個數據庫表一個。比如訂單聚合,其包含訂單實體與訂單項值對象,使用MySQL作為持久化設施。資源庫肯定是只有一個了,但表至少得兩張吧?我相信聰明的您應該不會把訂單與訂單項放在同一張表裡。
回歸主題,由於資源庫的目標是領域模型,其方法的定義也是由於業務來驅動的,我們曾經在前面的章節中舉過例子,在此不再贅述,唯一需要注意的是你應該將其放到BO層中;而針對資源庫的實現,畢竟他需要把領域模型進行序列化和反序列化,最終的實現不管是在倉庫中直接調用數據庫中間件組件還是通過DAO代理,肯定是涉及到了對於基礎設施的依賴。您懂的,領域模型不能依賴於基礎設施,所以資源庫的實現需要和其接口進行分開。一般來說,我們都是將實現放到基礎設施層,通過依賴注入的方式將其實現注入到應用服務中。
雖然說資源庫接口的定義由業務來驅動,我們還是會給其一些通用的能力,畢竟多數情況下領域模型還是需要進行存儲或根據ID查詢,所以在實踐中會在接口中聲明如「添加」、「更新」和「根據ID查詢」三類接口,這三類可以說是最基本的能力。至於刪除嘛,其實就是更新聚合的狀態,一般也很少會把數據做物理刪除。我個人習慣會定義一個刪除的接口,在實現的時候將實體的狀態變成某個代表不可用的值,比如「-1」。除了這些基本能力外,還有哪些接口就看業務需要嘍。說到此您應該可以想到資源庫的接口其實應該分成兩個對吧?一個是基本能力,一個是自定義附加能力,如下代碼片段所示。
public interface Repository<TID extends Comparable, TEntity extends EntityModel> { /** * 根據ID返回領域模型 * @param id 領域模型ID * @return 領域模型 */ TEntity findBy(TID id) throws PersistenceException; /** * 刪除領域實體 * @param entity 待刪除的領域實體 */ void remove(TEntity entity); /** * 刪除多個領域實體 * @param entities 待刪除的領域實體列表 */ void remove(List<TEntity> entities); /** *將領域實體存儲至資源倉庫中 * @param entity 待存儲的領域實體 */ void add(TEntity entity); /** * 將領域實體存儲至資源倉庫中 * @param entities 待存儲的領域實體列表 */ void add(List<TEntity> entities); /** *更新領域實體 * @param entity 待更新的領域實體 */ void update(TEntity entity); /** *更新領域實體 * @param entities 待更新的領域實體列表 */ void update(List<TEntity> entities); }
其實上面這段代碼在前面已經貼過了,不過我們既然專門講到資源庫,索引就再發一次。也不是為了水文字,免得您來回的翻多麻煩。再說了,都電子化時代了,又不廢紙。這裏面需要有兩點進行說明:1)接口的返回值最好都是「void」,也有人說返回「布爾」更好。反下我是不喜歡,資源庫執行出錯後直接拋個異常多好,正好能觸發事務的回滾,還順便把錯誤信息帶出來了。您使用「布爾」也只代表成功與否(也不一定是真的),錯誤原因帶不出來,只能寫日誌,代碼中充滿了與業務無關的內容,代碼亂不說,問題排查的過程也比較費勁。2)批量的方法,這個您看情況。有些情況下作批量的操作還是很有必要的,我們可以在基本能力中進行聲明,至於用不用那就看需求了,又不麻煩,實現的時候想簡單就循環調用對應的單對象操作方法或把批量的操作寫到SQL中。有了上面的基本的接口,我們再看看與業務相關的接口要如何定義。其實就是從「Repository」接口繼承一下,我還是拿訂單業務來演示,具體背景為:訂單有主子訂單的概念,當用戶將主訂單取消時相應的子訂單也需要進行取消。我們想像一下,取消操作大概需要三步:1)根據主訂單查詢子訂單列表;2)執行業務邏輯;3)將訂單信息進行存儲。第二步我們暫時不管,第三步就是批量的更新,在資源庫通用能力接口中已經定義了對應的方法。唯有第一步,一看就是需要定製的,那就新建一個訂單相關的資源庫,把接口的定義放到裏面去,代碼如下。
public interface OrderRepository extends Repository<Long, Order> { List<Order> queryByMasterOrderId(Long masterOrderId); }
還是需要說一下上面兩段代碼所處的位置。您還記得我說在進行DDD落地時需要寫一些基本的類庫吧?第一段代碼就需要放到基本類庫里,讓每一個業務資源庫接口都從其繼承;第二段當然就是放到BO層中了。通過上例的演示您應該可以看到,其實資源庫接口所包含的方法應該非常少,我所經歷的項目中使用過的資源庫不算通用能力,自定義的方法數量基本都不會超過3個。你定義的所有方法都是為了某一個命令型業務場景服務的,而很多的命令型業務所使用的資源庫方法其實都可以映射為幾種基本方法的組合,比如我們常見的電商下單業務:支付、取消、訂單完成等其實就調用了兩個方法:根據ID查詢訂單實體和更新訂單實體。其實在早期進行DDD探索學習的時候我也曾經誤用過,比如把所有的查詢都放到資源庫中,這種情況屬於典型的費力不討好。您想啊,把數據組裝成聚合多慢啊,你為了保障其完整性一次需要查好幾張表,結果大部分信息都用不上。另外,由於您把僅用於查詢的方法也放到了資源庫里,應用服務很容易就把領域模型直接泄露到外面。還有一點,您再想像一下查詢的本質:其主要目標是為外部提供數據,這裡外部可能是另外的服務也可能是前端系統,查詢結果的結構應該是越簡單越好,最好以一或二維的形式進行表現;而聚合的內部結構是立體的,各種對象相互關聯,這種複雜的關聯關係就決定了其內在的先天複雜性。面向對象的本質就是把責任盡量的細化,一個事情該由誰來干分工是非常明確的。所以領域實體更適合於命令型業務,應對查詢不是大材小用而是它根本就不擅長。此外,針對查詢的操作中你應該以追求執行速度為主,只要能保證數據的正確使用任何技術手段理論上來說都是允許的,而命令的操作則限制很多。
認識了資源庫接口的設計後,相信您應該對其輪廓有個基本的感性認識了,那應該如何完成其實現呢?請繼續讀。
2、接口的實現
資源庫的接口定義與實現在DDD中通常會在代碼層次上進行分離,定義部分我們上面已經說明,而實現部分由於其需要引入DAO或操作數據庫的組件所以一般會將其放在基礎設施層中。很多人在使用資源庫的時候非常容易犯的錯誤是把資源庫接口與實現放在一起包括我自己(曾經的我),糾其原因還是因為對於資源庫的理解度不夠,誰沒年輕過啊。既然我們講的是領域驅動設計,那就應該始終圍繞着這個目標進行一切工作包括分析、設計和實施等系統建設的各個階段所涉及的內容,資源庫的設計也一樣,你得分清什麼是領域相關的什麼是技術相關的。正常情況下一種對象只能屬於二者中的一個,唯有資源庫比較個性。實踐上為方便起見,我通常會把資源庫的實現放到一個名稱為「repository」的包中,這樣做只是為了更好的組織代碼,邏輯上你仍然需要將其作為基礎設施層的組件來看待。
同資源庫接口,我們在進行資源庫實現的時候並不是直接寫一個從實體資源倉庫接口如「OrderRepository」實現的類,而是首先會定義一個資源庫基類並在其中使用一些手段來簡化資源庫的使用或增加一些額外能力的支撐比如事務管理、業務預警埋點等。在繼續往下寫之前我們還是需要討論一下事務的問題,這東西在微服務架構+DDD中使用有一定的限制。首先一點關於事務的使用方式:分佈式事務與傳統事務。針對分佈式系統雖然可用的事務選擇相對比較多,但Saga已經成為事實上的標準,也就是通過事件的形式實現最終一致性,這裡的最終一致性可以跨BC也可以在單個服務中使用;關於傳統型事務,一般是指關係型數據庫的強事務也叫作剛性事務。如果你使用了Spring,事務的開啟非常簡單,只需要搞一個註解即可。還有一種方式當然就是使用Spring的編程事務了,如果在項目中使用了「工作單元」則通常會搭配這個。Saga不是本節的重點,這東西對業務有一定的入侵性,還會涉及到比如隔離性等問題,是相對比較高級的主題,我們會放到獨立的章節中進行講解。針對數據庫剛性事務,可以考慮將其下沉到資源庫中實現,封裝好後工程師不用每次都在應用服務中顯示聲明,萬一記性不好忘了呢?這年頭兒,正經的事情記不住;不正經的忘不了。通過使用工作單元(Unit of Work)模式,可以在代碼中玩很多的花樣比如記錄日誌、對領域模型進行最終驗證等。這個模式太有名了,網上一搜一大堆,不過下面我也會給出案例,就是為了證明:咱也會。上面說到的資源庫基類,我在使用的時候將其與工作單元進行了集成並將事務放到了工作單元中。
如果你在實現領域模型的時候使用了對象變更標記,比如當某個對象的屬性變更後將其標記為「dirty」,持久化階段發現對象有此標記時才真正的進行存儲。這種模式對於提升系統的性能有一定的作用,按需持久化可以避免無效的數據庫操作。由於需要細粒度的控制,所以使用工作單元會比較好。如果你並沒有這樣的標識或強烈的持久化性能要求,其實使用Spring的註解標記完全可以滿足事務需求,並不需要再使用額外的模式。而我之所以在項目中使用它並不是由於應用了前面所說的變更標記也不是為了向世人展示「我會」,而是因為需要在工作單元中作業務級監控埋點和日誌處理等工作,這些不放到工作單元的話就只能在應用服務中實現了,代碼看起來讓人非常不爽。
工作單元相關的理論不是本文的主要內容,建議在網上找一些相關的文章進行了解。我們先貼一些代碼,展示如何將其與資源庫進行集成。下面代碼片段為工作單元接口的定義,不過模式就是模式,您別看定義了這麼多接口方法其實並不會全部調用的,大多數用例只調用其中的某一個,因為我們根本不允許一個事務更新多個不同的聚合。
public interface UnitOfWorkRepository<TEntity extends EntityModel> { /** * 持久化新建的領域模型 * @param entity 待持久化的領域模型 */ void persistNewCreated(TEntity entity) throws PersistenceException; /** * 刪除領域模型 * @param entity 待刪除的領域模型 */ void persistDeleted(TEntity entity) throws PersistenceException; /** * 持久化已變化的領域模型 * @param entity 待持久化的領域模型 */ void persistChanged(TEntity entity) throws PersistenceException; }
下面代碼為工作單元的基類,以「createdEntities」屬性為例,是一個Map結構,鍵為聚合實例,值為用於執行新建操作的資源庫實例。我們把所有待插入數據庫的對象都放在這個屬性中。鍵信息相對簡單,值對象理解起來相對就會麻煩一點,簡單來說就是根據這裡的映射關係,來決定由哪個資源庫實例來執行鍵所對應的實體的插入存儲。還有一段意思的代碼是「persistNewCreated」,他會循環「createdEntities」中的元素,依次執行領域模型插入到數據庫的操作,其中插入方法由業務資源庫實現。核心方法「commit」一看名字就知道其主要用於事務的提交,不過在當前的代碼中並未開啟事務,是因為我把它放到了工作單元具體類中。
public abstract class UnitOfWorkBase implements UnitOfWork { //包含了所有新建的領域模型 Map<EntityModel, UnitOfWorkRepository> createdEntities = new HashMap<>(); @Override public void registerNewCreated(EntityModel entity, UnitOfWorkRepository<? extends EntityModel> repository) { if (entity == null || repository == null) { return; } if(this.deletedEntities.containsKey(entity ) || this.updatedEntities.containsKey(entity)){ return; } if(!this.createdEntities.containsKey(entity)){ this.createdEntities.put(entity, repository); } } //提交事務 @Override public CommitHandlingResult commit() { CommitHandlingResult result = new CommitHandlingResult(); try { this.validate(); this.persist(); } catch(ValidationException e) { logger.error(e.getMessage(), e); result = new CommitHandlingResult(false, e.getMessage()); } catch(Exception e) { logger.error(e.getMessage(), e); result = new CommitHandlingResult(false, OperationMessages.COMMIT_FAILED);
//業務監控埋點 } finally { this.clear(); } return result; } //持久化對象 protected abstract void persist() throws PersistenceException; //持久化新建的對象 protected void persistNewCreated() throws PersistenceException{ Iterator<Map.Entry<EntityModel, UnitOfWorkRepository>> iterator = this.createdEntities.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<EntityModel,UnitOfWorkRepository> entry = iterator.next(); entry.getValue().persistNewCreated(entry.getKey()); } } }
上述代碼展示了工作單元的基類,我們據此實現一個基於MySQL的工作單元,請參看下列代碼。其實很簡單,就是使用了Spring的編程事務。後續所有實體的持久化都會使用此工作單元完成,按此方法您如果有興趣的話可以試試將其應用在NoSql上看是什麼樣的結果。
final public class SimpleUnitOfWork extends UnitOfWorkBase { @Override protected void persist() throws PersistenceException { TransactionTemplate transactionTemplate = ApplicationContextProvider.getBean(TransactionTemplate.class); transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT); transactionTemplate.setPropagationBehavior(Propagation.REQUIRES_NEW.value()); Exception exception = transactionTemplate.execute(transactionStatus -> { try { persistDeleted(); persistChanged(); persistNewCreated(); return null; } catch (Exception e) { transactionStatus.setRollbackOnly(); return e; } }); if (exception != null) { throw new PersistenceException(exception); } } }
到此為止,我們只是實現了工作單元,並未將其與資源庫集成。具體集成的責任我給它放到了資源庫抽象類「RepositoryBase」中,可參看如下代碼片段。此抽象類同時實現了工作單元接口和資源庫接口,我們可以認為每一個資源庫都具備了工作單元的能力。這處代碼有兩處需要注意的:1)「unitOfWork」屬性應該使用「ThreadLocal」以避免因並發產生問題,因為我們會使用Spring管理資源庫實例,默認是單例的。而每一次聚合的變更或存儲都需要聲明一個新的工作單元實體,您如果不用「ThreadLocal」,線上的系統一定給你驚喜的。啊,多嘴一句,每次進行提交操作後您別忘了釋放「unitOfWork」所引用的對象,我代碼省略了不代表您也不寫,雖然ThreadLocal本身也會處理這些,不過小心使得萬年船;2)「add」方法將資源庫實例存儲在工作單元中。這段代碼有點繞,需要結合「SimpleUnitOfWork」進行理解,其實就是工作單元與資源倉庫之間有一個相互引用的關係。
public abstract class RepositoryBase<TID extends Comparable, TEntity extends EntityModel> implements Repository<TID, TEntity>, UnitOfWorkRepository<TEntity> { //工作單元 ThreadLocal<UnitOfWork> unitOfWork = new ThreadLocal<>(); /** * 將領域實體存儲至資源倉庫中 * @param entity 待存儲的領域實體 */ @Override public void add(TEntity entity) { if (entity == null) { return; } this.unitOfWork.get().registerNewCreated(entity, this); } /** * 持久化新建的領域模型 * @param entity 待持久化的領域模型 */ @Override public abstract void persistNewCreated(TEntity entity) throws PersistenceException; }
到目前為止我們已經把資源庫的抽象類和接口進行了說明,這些組件一般都會放到基本類庫中免得每實現一個資源庫的時候都再重新定義一次。接下來我們來演示如何在業務中使用資源庫。前文中我為訂單實體定義了一個資源庫接口「OrderRepository」,位於BO層中。現在我們需要為這個接口做一個實現類,位於「repository」包中,再提醒一下,這個包屬於基礎設施層,代碼片段如下。
@Repository("orderRepository") public class OrderRepositoryImpl extends RepositoryBase<Long, Order> implements OrderRepository { @Resource private OrderMapper orderMapper; @Resource private OrderEntryMapper orderEntryMapper; @Override public void persistNewCreated(Order oder) throws PersistenceException { if (oder == null) { throw new PersistenceException(); } try { OrderDataEntity orderDataEntity = this.ofOrderData(oder); List<OrderEntryDataEntity> orderEntryDataEntities = this.ofOrderEntryData(oder); this.orderMapper.save(orderDataEntity); this.orderEntryMapper.save(orderEntryDataEntities); } catch (Exception e) { throw new PersistenceException(e.getMessage(), e); } } }
在資源庫的實現類中,我們其實只需要實現「persist*」模式的方法,這些方法最原始的定義在「UnitOfWorkRepository」中,在「RepositoryBase」進行了實現,只不過實現的時候使用了「abstract」修飾,表示你需要在具體類中進行實現。結合我們上面所有的代碼,您會發現後續每次使用資源庫的時候只需要寫一個接口和接口的實現類即可,在實現類中也只需要實現3個「persist*」模式的方法。細心的您應該已經還發現了在資源庫中引入了一個DAO對象「OrderMapper」,相當於訪問數據庫的操作由這個DAO來進行代理。當然,您也可以直接在資源庫中寫數據庫訪問相關的代碼,不過這樣一是會增加資源庫的責任;二是你根本無法避免DAO的使用,因為針對查詢的業務你還是會用到它,不然你把代碼寫在哪裡?資源庫中?違反了我們前面所說的資源庫使用規則,那不是啪啪打臉嗎?既然DAO無論如何都應該存在,那為什麼不把所有數據庫的操作都統一放到它裏面呢?這種設計會讓代碼的責任更加單一,其中的好處不必多說,最起碼看起來比較爽。
上述代碼中,方法「ofOrderData」用於將領域模型轉換成數據模型,這裏面需要您做好分析。實體還好說,肯定是單獨的表;值對象就複雜一點,是和實體放在一個表中還是單獨使用一個表,既要從領域模型的角度考慮也要從數據操作的方便性方面進行考慮。這些虛話誰都會說,我其實想重點說一個有意思的場景即:數據庫字段的冗餘。比如在訂單實體中我們有一個「客戶詳情」實體屬性,在訂單項中肯定就不需要了。但是,在存儲的時候我們為了加速信息的查詢,不僅在訂單表,還需要在訂單項表中加入這個「客戶ID」字段作為冗餘。雖然這種設計不太符合數據庫範式的定義,但在當今大並發系統中的應用確非常普遍。查詢的時候級連的表越多速度越慢,這事兒按理您比我懂,而通過一些冗餘的手段確可以大大提升系統的性能,這叫什麼來着?「空間換時間」。這種冗餘的操作也可以在資源庫實現,如下代碼所示。
@Repository("orderRepository") public class OrderRepositoryImpl extends RepositoryBase<Long, Order> implements OrderRepository { private List<OrderEntryDataEntity> ofOrderEntryData(Order oder) { List<OrderEntryDataEntity> entites = new ArrayList(order.getEntries().count()); for(OrderEntry entry : order.getEntries()) { OrderEntryDataEntity entity = new OrderEntryDataEntity(); entity.setCustomerId(order.getCustomer().getId()); entity.setOrderId(order.getId()); …… entites.add(entry); } return entites; } }
3、數據關聯
這段內容其實屬於友情提示,那提示的是什麼呢?「數據關聯」!我們先看一下示例。在具體資源庫中需要實現三個方法,其中一個為「persistDeleted」,完整定義如下代碼所示。這裏面我標黑的代碼標識了如何刪除訂單與訂單項數據(注意:現實中一般不會進行數據的物理刪除,這裡只是用於演示)。有的工程師喜歡在數據庫中設計級聯刪除,也就是在刪除訂單的時候將其關聯的訂單項也一併刪除。當然,類似的操作還包含級聯更新,通過這些數據庫提供的能力在有些時候的確能減少開發的工作量,倒退個20-30年還是可以考慮的。但這些騷操作在現代分佈式系統中基本上是明令禁止的,比如我們常常使用的《阿里巴巴開發手冊》,其中也明確的標明了不可以使用級聯操作。我們不說其它的,僅是對於性能的影響你就不應該擁有它。在應用層進行級聯才是你的首選,更加的靈活也更好的控制是一方面,你的程序員兄弟在讀代碼的時候也可以很快的知道底層的數據處理邏輯到底是什麼。
public void persistDeleted(Order order) throws PersistenceException { if (oder == null) { throw new PersistenceException(); } try { OrderDataEntity orderDataEntity = this.ofOrderData(oder); List<OrderEntryDataEntity> orderEntryDataEntities = this.ofOrderEntryData(oder); this.orderMapper.delete(orderDataEntity); this.orderEntryMapper.delete(orderEntryDataEntities); } catch (Exception e) { throw new PersistenceException(e.getMessage(), e); } }
4、如何使用資源庫
資源庫定義好以後,我們一般會在應用服務中進行引用。注意:只能在應用服務中使用,千萬不要將其注入到實體或領域服務中。這種代碼我見過無數次,包括在一些體量非常龐大的系統中。架構師也的確使用了充血模型,結果是一個四不像。下代碼的代碼展示了如何正確使用資源庫,請一鍵三連加關注。
@Service public class OrderService { @Resource private OrderRepository orderRepository; public void cancel(Long orderId) { Order order = this.orderRepository.findBy(orderId); order.cancel(); this.orderRepository.update(order); //(1) TransactionScope transactionScope = TransactionScope.create(orderRepository);//(2) transactionScope.commit(); } }
”(1)”處的代碼比較簡單,因為訂單對象的屬性變了,所以我將其作為待變更的對象看待。那麼「(2)」處的是「TransactionScope」是什麼鬼?其實這是一個自己定義的類,類的名稱剽竊了C#的關鍵字,其實就是為了簡化資源庫的使用,我們貼一下這個類的定義。
final public class TransactionScope { private UnitOfWork unitOfWork; private RepositoryBase[] repositoryBases; private TransactionScope(UnitOfWork unitOfWork, RepositoryBase[] repositoryBases) { this.unitOfWork = unitOfWork; this.repositoryBases = repositoryBases; if (unitOfWork != null && repositoryBases != null) { for (RepositoryBase repositoryBase : repositoryBases) { repositoryBase.setUnitOfWork(unitOfWork);//(2) } } } public static TransactionScope create(RepositoryBase... repositoryBases) { UnitOfWork unitOfWork = new SimpleUnitOfWork();//(1) TransactionScope transactionScope = new TransactionScope(unitOfWork, repositoryBases); return transactionScope; } public CommitHandlingResult commit() throws CommitmentException { if (this.unitOfWork == null) { throw new CommitmentException(OperationMessages.COMMIT_FAILED); } CommitHandlingResult result = this.unitOfWork.commit();//(3) return result; } }
代碼「(1)」處我們實例化一個工作單元對象,每調用「create」的方法的時候都會實例化一次。而「create」方法中會接收多個資源庫對象,當你需要更新或插入這些聚合的時候會使用同一個事務。當然,這樣做只表示有這個能力不代表您應該使用,因為一個事務只能更新一個聚合,這是一個硬性且應該隨時遵守規則。代碼「(2)」當然就是把工作單元注入到資源庫中啦,您可以看看我們前面的代碼,每個資源庫都持有一個工作單元的引用。 代碼「(3)」其實就是在開啟事務後進行數據庫的更新,由工作單元完成責任代理。此刻,實體的任何變化才會反應到數據庫中。
5、多種方式持久化
我上面的例子所面向的全是關係型理數據庫,但實現中我們可能會使用多種存儲比如MongoDB、Redis、ES等。如果只是單純的把數據進行存儲,其實只需要換一種方式實現「persist*」方法。比如MongoDB使用「MongoTemplate」;ES使用「Elasticsearchtemplate」,這些都比較方便。不過有的時候我們需要將數據同時寫入兩種不同的存儲中間件中,比如MySQL+ES。此等情況下,最好在MySQL操作完成後在應用服務中發佈一個領域事件,由領域事件的訂閱方將數據放到ES中,這種一種簡化的CQRS模式。請盡量不要在資源庫中進行雙寫,因為你根本無法保證寫入一定是成功,除非ES和MySQL可以支持同一個事務。我覺得您就不用對此報有什麼期望了,明着告訴你「沒戲!」。所以說算來算去,還是Saga比較香。
還有一情況是MySQL+Redis,畢竟有的時候的確需要進行雙寫的。有很多方式可使用,比如:1)在DAO進行Redis和MySQL雙寫;2)使用上面的領域事件的方式;3)使用MySQL日誌拖尾等。一般來說,如果不是特別要求數據庫與Redis的強一致性,其實方式1比較好,又簡單又直觀,Redis寫入的速度快所以對性能影響也不大。
6、聚合的性能
聚合包含有整體的概念即事務整體、操作整體和業務整體。所以不論是通過資源庫進行查詢還是變更,都必須以聚合為單位。保存或變更還好說,一般的程序員也知道聚合所關聯的信息要同時寫入到數據庫中,主要是他不這麼干也不行,少數據業務上肯定出BUG了。對於查詢特別容易犯錯,有些開發打着性能的名義,直接通過資源庫查詢聚合中的某個子實體或值對象。費了好大勁查出來的領域模型卻是個殘疾人,你說虧不虧?而且你都不知道他這麼乾的目的是什麼。執行某個業務嗎?那也不能略過聚合根直接開搞啊,這麼干你還要聚合根幹什麼。單純的用於查詢操作?直接操作DAO多爽啊,資源庫不幹那種轉發的活,丟不起那個人。但原則並不是一成不變的,某些情況下你必須要學會妥協否則很容易陷入教條中。當聚合所包含的值對象特別多的時候,比如一個人類的X染色體包含1億+的鹼基對,你想把這些都放到一個聚合中嗎?你想一次把上億條數據一次全查出來嗎?此時您也就別考慮什麼整體不整體的概念了,踏實的分開單獨處理比什麼都強,君子都是見機行事的。
7、聚合存在層級關係時的處理
寫作本文的時候,為了保證某些內容的正確性以及避免被記憶偏差所影響,又重新回顧了一下IDDD這本經典書籍,搞笑的是再次讀的時候發現這本書比我相像中的要薄一點,說明當時看的時候可能心理是有抗拒的,即使是正常的書也會覺得厚。它在講資源庫的時候提到了層級關係的處理,而這個問題我的確也在真實的項目中遇到過。在真正討論這個主題之前,我們需要先進行一下思考:當領域模型存在繼承關係的時候,我們在子類中應該優先擴展什麼內容?數據還是行為?我在早期學習的時候比較傾向於數據,而現在更加的傾向於行為。當然,也可能是與當前的工作內容有關,導致產生這種假象。即便如此,當仔細回首過去做的東西的時候還是覺得當時的設計有欠妥當,或者說是抽象的不夠。以現在眼光來看,把行為擴展作為關注的重點的確會更好一點。實際上,這也是在做面向對象設計時非常值得注意的一點。
仔細考慮一下,所謂「領域驅動設計」,這裡在「領域」到底是指的什麼?我覺得可以分成三個方面:業務規則、業務場景和業務主體。簡單來說就是「誰在什麼場景下做了什麼事情」,前兩者的工作是對業務實體的識別,後面的業務規則則定義了實體如何實現其行為。通過這種定義我們可以看到:業務場景規定了行為的範圍,屬性對行為進行了支持,兩者都很重要,下得了廚房但上不得廳堂。只有行為才真實的表達了需求,讓系統嗨起來。想像一下我們使用過的軟件是不是都在滿足用戶的行為?以Word為例,你可以插入字符、保存文檔、修改字體顏色……所以作為設計師的您請務必不要過分的關注數據與數據的擴展,面向行為可以反向推斷出行為所需要的數據反之則沒戲,我給你一個表格的數據你能告訴我是由於什麼行為產出的嗎?這一段內容看似與資源庫無關,但為後面的內容作了鋪墊。重要的是您在實踐OOP的時候要注意自身的重心在哪裡。
有這樣一個例子:某電商系統中存在賬戶概念,賬戶包含企業賬戶與個人賬戶。個人賬號包含如「身份證號」、「真實姓名」等信息;企業賬號包含「營業執照號」、「企業統一社會信用代碼」兩類特殊信息。當然,他們也有一些共同的屬性,如登錄名、郵箱、密碼等。針對這樣的場景,很多人下意識的就會想到建立一個包含繼承關係的賬戶體系,也就是建立一個抽象的賬戶類,個人賬號和企業賬號分別從其繼承,如下圖所示。在領域模型中這樣設計無可厚非,假如企業賬戶與個人賬戶的「實名認證」邏輯不一樣,這樣的設計會同時存在數據擴展及方法擴展的情形。雖然我上面說應該將行為擴展作為重點,但不代表完全不需要數據擴展,那不是太過於極端了?我們設計的基本原則是遵循中庸的思想,這是作為設計的終極追求。很自然的去設計,該有的時候也不用藏着。
在這個案例中,BO層設計並不難,數據庫層設計也有多種選擇:你可以把企業賬戶和個人賬戶分別放到兩張表中;也可以做一個賬戶基本表,再整兩張擴展表分別用於企業與個人賬號。最大的問題就是資源庫的設計,您回看上面的代碼,發現資源庫接口在設計的時候需要傳入領域模型類型作為泛型參數,那麼在出現層級關係的時候應該傳遞什麼類型作為泛型的參數?針對資源庫這裡又產生了兩種選擇:單一資源庫和多重資源庫。單一資源庫是指針對賬戶的繼承關係只設計一個資源庫,直接將基類也就是上面的「賬戶」作為泛型類型;多重資源庫就相對簡單一點,針對每個子類都設計單獨的資源庫,將具體類型作為泛型參數。如果子類多的話,單一資源庫當然要省很多的事兒,一個就搞定;相對的,多資源庫就要多寫好多的代碼。如果您沒在實際中遇到過這樣的情形估計一定會優先選擇A方案,是我也一樣。不過現實其實很骨感的,我們來詳細分析一下。
當只有一個資源庫的時候,其針對的領域模型只能是賬戶這個抽象類。在資源庫裏面你需要根據某些標識符比如「賬戶類型」來決定到底要構建哪一種賬戶,這個好說,反正資源庫就是干封裝的事兒的。如果子類更多的是對父類行為的擴展,那在實現的時候就要簡單很多,寫一個通用的賦值方法即可搞定;如果涉及數據方面的不同,那沒辦法,你只能分別設置值了。單一資源庫的複雜性還不在這裡,當你需要使用資源庫的返回值的時候才鬧心呢。比如「findBy」方法,根據ID返回領域模型。單一資源庫返回的是一個抽象類型,在使用的時候你需要分辨其具體類型到底是什麼;既然是抽象類,你還需要將其強制轉換成具體的類型,要不然你就沒法獲取到那些擴展的數據。當然,你也可以針對企業和個人賬戶在應用服務端分別建立對應的方法,這樣也不就用過分的考慮領域模型的類型問題,不過強制轉換還是有必要的。這種方式在一個人全棧開發的時候其實也可以,一旦涉及多人開發就噁心了,你需要把資源庫的使用原則進行文檔化或至少要進行一次培訓,無形中增加了很多的工作量。畢竟來一個新人你就得培訓一次,萬一把你都干離職了,培訓的事情能否繼續傳達下去也不好說。反正現在干開發的都這樣,只要能完事兒就行,我死後還管他洪水滔天?再說了,項目不爛怎麼能做重構?不重構怎麼會有政績?所以說您也別抱怨代碼爛,爛一點吃虧的頂多是程序員,人家領導不關心那個。不過出現問題他可是找你,誰叫你寫得這麼爛的。
回歸重點,多重資源庫是我個從比較推薦的一種方式。雖然上面的案例中子類只有兩個,即便是多個的時候我仍然這樣堅持。雖然開發的時候工作量稍微多了一點,但用起來真的是簡單啊。至少你不會遇到使用單一資源庫時那種類型識別和強制轉換的噁心事兒,代碼也足夠直觀,單憑這兩點我認為你就應該這麼干。再說了,工作量也沒多多少。不就是領域模型存在數據擴展的情況嗎?我們在設計資源庫的時候也設計成帶繼承的,如下圖所示。代碼寫起來也灰常簡單,你把共享數據的賦值過程放到賬戶資源庫中,個性化的數據賦值操作放在兩個擴展資源庫中。雖然類多了那麼一丟丟,但先天就能幫助你識別領域模型的類型。如果在應用服務端面把企業賬戶與個人賬戶的業務分開處理,寫出的代碼就更加直觀了。
如果領域模型存在層級關係但並不會出現數據擴展的情況,在使用資源庫的時候則不用那麼費勁,一個就能搞定。客戶端並不用刻意關注具體的類型到底是什麼,因為子類都是對於行為的擴展,反正一個活兒只要有人干就行,領域模型的客戶端其實並不需要關心誰做。這也印證了我在上面提出的觀點:盡量的使用行為擴展,受益的不僅是BO層中的對象,與其有關係的底層設施也會簡單很多。
總結
本章乾貨比較多,代碼量也不少。不過那些都是技術層面的東西,怎麼寫都行。重點你得學習資源庫到底是幹什麼的,使用範圍是什麼,應當遵循哪些限制。大多數初學者都會將其作為DAO使用,目標不對會影響系統的整體結構。另外,針對面向對象設計的一些思想與實踐,雖然上面寫得不多,但那才是重點呢。縱觀我寫的一系列文章,總會時不時的穿插一些面向對象相關的內容,有時間的話再回味一下。我覺得思想要比技術更實際,後者發展太快,以我們有限的精力很難追得上,把思想整透了絕對是一種高效的學習方法。