戲說領域驅動設計(廿四)——資源庫

SKevin發表於2022-04-25

  開講資源庫,這東西簡單來說就是用於持久化或查詢聚合的。注意!您需要與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使用,目標不對會影響系統的整體結構。另外,針對物件導向設計的一些思想與實踐,雖然上面寫得不多,但那才是重點呢。縱觀我寫的一系列文章,總會時不時的穿插一些物件導向相關的內容,有時間的話再回味一下。我覺得思想要比技術更實際,後者發展太快,以我們有限的精力很難追得上,把思想整透了絕對是一種高效的學習方法。

相關文章