如絲般順滑:DDD再實踐之類目樹管理

五嶽發表於2022-04-10

背景

距離DDD實踐反思寫完已經過去一年,期間發生了很多事情,比如換了工作,細節按下不表;新團隊的技術負責人對DDD在團隊裡的落地很關心,問最近有沒有什麼進展?這就很尷尬了:之前我接手並主要負責的XXX服務在現階段是不太適合用DDD的,自身和外部其他幾個服務的邊界並不清楚(其中包含了一些歷史技術債),而且當前處於一個變化比較快的階段,也沒有什麼業務輸入,不太適合貿然重構,所以並沒有在XXX服務中搞DDD。

做技術總要有點追求嘛,雖然現階段工作最高優先順序還是保證業務快速發展,還是想繼續實踐下DDD的。
這時正巧一個老應用要做重構,在這個基礎上一個新的類目管理功能,雖然是一個新的領域,但是產品文件確定的業務規則已經非常清晰,並且後續變化不會很大。美中不足的是需求對應的功能此時已經用傳統的CRUD的方式寫完了大半了,糾結了半天,還是決定:搞!

本文對於DDD的基礎術語就不再單獨講解了,下面直接進入正題。

原則問題

關於DDD,我一年前觀點基本沒有變化,這裡再總結歸納一下。要先確定是否滿足以下條件,再考慮是不是要用DDD,不要為了DDD而DDD。永遠記住:沒有銀彈

實踐時,你就會發現DDD在專案落地時做了很多折中,不能教條化地照搬。

  1. 業務規則要有一定的複雜性和穩定性。如果一個業務通過CRUD就能輕易的搞定且以後也不會變得很複雜,或者業務還一直在快速變化(這也意味著經常有很強的的專案時間節點要求和臨時性的規則),不要用DDD。
  2. 域的劃分是清晰的,建模是準確的,領域方法是可以梳理的且足夠豐富的,是考慮使用DDD先決條件。域的劃分不等於將一個應用強行拆成很多個應用,人為地提升系統複雜性。
  3. 不要帶來過多的額外成本,不要捨本逐末。如果因為DDD導致一個應用的開發、測試、運維成本翻倍,甚至引入了更多的bug,那麼就要反思下這次實踐是否成功了。

需求分析

這裡概括一下需求要點,已刨除掉需求具體的背景以及和本文無關的其他專案需求內容。
本次需要實現一個管理如下圖的類目樹結構的功能:

(圖源:https://t.cj.sina.com.cn/articles/view/7321552158/1b466051e001010bfc)

具體的規則和支援的操作:

  1. 類目節點組織成一棵或多顆樹,每個類目節點下可以有一個或多個子類目節點
    1.1 子類目節點是有序的,可以進行重排序
    1.2 最頂層的類目節點是根
    1.3 類目節點上可以關聯多個同種型別的內容實體
  2. 類目節點可以新增、刪除、重新命名、上架、下架
    2.1 上架和下架是類目節點的狀態。如果類目節點下沒有關聯內容,或者它其下沒有上架的子類目節點,無法上架。
    2.1 刪除節點時,其下的子節點和子節點關聯的內容需要一併刪除

建模

象徵性地畫一下限界上下文和ER圖,因為隱藏了很多細節所以看上去很簡單。ER圖裡並沒有聚合根,要問為什麼請繼續往後看。
限界上下文

ER圖

再實踐——落地

怎麼用程式碼表示領域物件:故弄玄虛還是打牢地基?

DDD只在腦中有概念是不夠的,為了將概念轉化為程式碼,第一步就是把這些概念變成程式碼,這樣才能指導後續的編寫。

實際上,這就可以看做是折中的開始了,因為DDD本身是不關心具體儲存的,但是做模型設計,你必須考慮如何持久化。

值物件

本文中為了實現類目樹本身並不會用到繼承以下值物件的類,為了完整性考慮才寫出來的。

點選檢視程式碼
/**
 * 值物件抽象類
 *
 */
public abstract class ValueObject {
}

/**
 * 欄位型值物件
 *
 * 表示這個值物件會使用表欄位來儲存。
 *
 * 並不總是表示一個單一的欄位, 可能是多個欄位組合而成。
 * 
 * 你可以把列舉也看做值物件,但enum是沒法繼承這個類的。
 */
public abstract class FieldValueObject extends ValueObject {
}

/**
 * 物件關係對映型值物件
 *
 * 表示這個值物件會使用關聯式資料庫對映的方式來儲存。
 * 這裡沒有使用id,需要視情況而定
 */
public abstract class OrmValueObject extends ValueObject {

    /**
     * 建立時間
     */
    protected Date created;

    /**
     * 更新時間
     */
    protected Date lastModified;
}

實體

點選檢視程式碼
/**
 * 實體抽象類
 * 封裝了所有實體的通用屬性
 */
public abstract class Entity {
    /**
     * id
     */
    protected Long id;

    /**
     * 建立時間
     */
    protected Date created;

    /**
     * 更新時間
     */
    protected Date lastModified;

    /**
     * 是否邏輯刪除
     */
    protected Boolean deleted;
}

聚合根

除了實體本身的屬性,空的。

點選檢視程式碼
/**
 * 聚合根 
 */
public abstract class Aggregate extends Entity{
}

什麼模型?那必須是充血模型

話說大了,其實這節列出來的都快成失血模型了。充血模型在哪裡?等到下面一節就有了,先看看這些貧血模型提升下血壓吧:

點選檢視程式碼
/**
 * 內容類目節點
 *
 */
public class Category extends Entity {

    /**
    * 名稱
    */
    private String name;

    /**
    * 層級, 0表示根
    */
    private Integer level;

    /**
    * 父節點id, 如果不存在則為0
    */
    private Long parentId;

    /**
    * 根節點id, 如果是根節點則是它本身
    */
    private Long rootId;

    /**
    * 內容型別
    */
    private ContentTypeEnum contentType;

    /**
    * 節點狀態
    * 0-下架,1上架 
    */
    private CategoryStatusEnum status;

    /**
    * 節點順序, 有小到大遞增
    */
    private Integer index;

    /**
     * 節點路徑, 不含它自己
     * 用於冗餘
     */
    private List<Long> path;

    public boolean isOff() {
        return status!=null && StringUtils.equals(status.getCode(), CategoryStatusEnum.OFF.getCode());
    }

    public boolean isOn() {
        return status!=null && StringUtils.equals(status.getCode(), CategoryStatusEnum.ON.getCode());
    }
}
/**
 * 類目節點上的內容
 *
 */
public class Content extends Entity {

    /**
    * 所屬的類目id
    */
    private Long categoryId;

    /**
    * 所屬類目節點的根id, 用於冗餘查詢
    */
    private Long rootId;

    /**
    * 內容id
    */
    private String contentId;

    /**
    * 內容型別
    */
    private ContentTypeEnum contentType;

    /**
    * 內容在類目樹上的路徑(id),,用於冗餘查詢
    */
    private List<Long> path;
}

領域服務的根基之一——Repository

CRUD也可以用Repository,你也可以把Repository用Tunnel代替,這裡還是使用Repository來表示將持久化的物件載入到記憶體中、將記憶體物件持久化的服務。
Repository與直接呼叫mybatis提供的mapper/DAO不同點:

  1. 可以包含業務邏輯、事務,本身會成為領域服務的一部分;
  2. 需要將DO轉化為Model,不能直接把DO給外部使用。

在本次需求裡,Repository具體提供了哪些方法就不列舉了,可以看下面一個方法,它通過事務繫結了兩個動作,保證新建的根節點的rootId欄位是它自己建立時生成的主鍵。

點選檢視程式碼
@Repository
public class CategoryRepository {
    @Resource
    private CategoryDAO categoryDAO;

    // 其他方法略

    /**
     * 建立根節點
     */
    @Transactional(rollbackFor = Exception.class)
    public long addRoot(Category category) {
        CategoryDO categoryDO = CategoryConverter.toDO(category);
        categoryDAO.insert(categoryDO);
        categoryDAO.updateRootId(categoryDO.getId());
        category.setId(categoryDO.getId());
        return categoryDO.getId();
    }
}

豁然開朗:聚合根

直到這裡,除了看似玄虛的建模抽象類,幾乎和CRUD沒什麼區別對不對?

重點來了:聚合根!
先抽象出聚合根,再將領域方法合理地抽象到聚合根,DDD才算是開始落地。再回顧一下【需求分析】這一節,所有的操作都是和節點有關的,但是單個節點不能支援所有的操作,比如子節點排序,是包含了一個節點下所有的子節點的操作。那麼,將一棵類目樹作為聚合根,所有對節點的操作都抽象為 對一棵樹上某個節點及關聯節點的操作,是不是就把操作本身和聚合根聯絡到了一起呢?

點選檢視程式碼
/**
 * 類目樹 - 聚合根
 * 領域物件(樹)的領域方法, 本身包含了操作節點的持久化管理, 即所有操作需要滿足:
 * 對樹及樹的節點的操作, 記憶體中的物件必須和持久化的保持一致, 如果進行持久化, 記憶體中存在的也需要進行更新, 反之亦然
 *
 * 使用樹進行操作, 需要注意不要在同一個流程中對同一個物件混用樹和repository進行操作, 否則會發生資料不一致
 *
 */
public class CategoryTree extends Aggregate {

    /**
     * 類目樹的根節點id
     */
    final private long rootId;

    /**
     * 類目節點快取
     * 可能不是全部的節點都會快取
     * key: 節點id
     * value: 節點
     */
    final private Map<Long, Category> nodeMap = Maps.newHashMap();

    /**
     * 節點倉儲
     */
    final private CategoryRepository categoryRepository;

    /**
     * 節點內容倉儲
     */
    final private ContentRepository contentRepository;

    /**
     * 節點併發鎖
     */
    final private RedisLock redisLock;

    /**
     * 初始化, 資料懶載入
     *
     * @param rootId
     * @param categoryRepository
     * @param classifiedContentRepository
     * @param redisLock
     */
    public categoryTree(Long rootId, categoryRepository categoryRepository,
                               ContentRepository contentRepository, RedisLock redisLock) {
        this.rootId = rootId;
        this.deleted = false;
        this.categoryRepository = categoryRepository;
        this.contentRepository = contentRepository;
        this.redisLock = redisLock;
    }

   // 領域方法, 見下文
   ... ...
}

你會發現,如果想要在聚合根實現領域方法,因為會涉及持久化,聚合根一定是和Repository繫結在一起的。那麼,聚合根很自然的變成了充血模型

雖然聚合根是類目樹的根節點,我不推薦將所有這課類目樹的所有節點都加在到記憶體中,而是在每次操作時按需載入,操作完直接持久化,否則你會面對著無休止的資料一致性的糾結。

領域服務的前戲——工廠類

聚合根裡包含了Repository、Redis併發鎖,總不能每次new的時候都手動注入一次吧?

如果不用new來建立物件,很自然的可以想到用工廠類來做這些髒活累活。

點選檢視程式碼
@Service
public class CategoryTreeFactory {

    @Resource
    private CategoryRepository categoryRepository;

    @Resource
    private ContentRepository contentRepository;

    @Resource
    private RedisLock redisLock;

    /**
     * 通過根構造(載入)樹
     * @param rootId
     * @return
     */
    public ContentCategoryTree build(long rootId) {
        ContentCategory root = categoryRepository.loadOne(rootId);
        if(root == null) {
            throw new RuntimeException("根節點不存在");
        }
        if(root.getRootId() != rootId) {
            throw new RuntimeException("rootId對應的節點不是根節點");
        }
        return new CategoryTree(rootId, categoryRepository, contentRepository, redisLock);
    }

    /**
     * 通過節點構造(載入)類目樹
     *
     * @param categoryId
     * @return
     */
    public CategoryTree buildByNode(long categoryId) {
        Category category = categoryRepository.loadOne(categoryId);
        if(category == null) {
            throw new RuntimeException("類目節點不存在");
        }
        return build(category.getRootId());
    }

    /**
     * 建立一個只有根的新樹
     * @param name
     * @param contentType
     * @return
     */
    public CategoryTree buildNewTree(String name, ContentTypeEnum contentType) {
        int index = 1;
        Set<String> rootNameSet = Sets.newHashSet();
        List<ContentCategory> roots = contentCategoryRepository.loadRoots();
        if(!CollectionUtils.isEmpty(roots)) {
            // 獲得新的根節點的順序
            index = roots.get(roots.size()-1).getIndex() + 1;
            roots.forEach(p->rootNameSet.add(p.getName()));
        }
        // 以後改成按型別名稱排序
        if(rootNameSet.contains(name)) {
            throw new RuntimeException("根節點名稱重複");
        }

        Category root = new Category();
        root.setName(name);
        root.setStatus(CategoryStatusEnum.OFF);
        root.setContentType(contentType);
        root.setIndex(index);
        // 臨時設定一個id,規避持久化問題
        root.setRootId(CategoryConstant.ROOT_PARENT_ID);
        root.setParentId(CategoryConstant.ROOT_PARENT_ID);
        root.setLevel(CategoryConstant.ROOT_LEVEL);
        root.setDeleted(false);
        long rootId = categoryRepository.addRoot(root);
        return build(rootId);
    }
}

領域服務

接下來,就要在聚合根充實領域服務了,這一步是和抽象聚合根是緊密結合在一起的。

模板方法

這裡先鋪墊一下,為了提高程式碼的複用性,需要因地制宜的抽一下模板方法。在本例中,有兩種:

  • 只操作單個節點
  • 自下而上操作每個節點
    後續也有可能自下而上操作的,實現起來和自下而上操作類似。

先看下適用於不同場景的兩個方法介面

點選檢視程式碼
/**
 * 類目操作方法介面
 * 只適用於單個節點
 *
 */
public interface CategorySingleOperation<R> {

    /**
     * 方法介面
     * @return
     */
    R process();
}

/**

  • 類目操作方法介面
  • 適用於遍歷時的節點

/
public interface CategoryTraverseOperation {
/
*
* 方法介面
* @return
*/
void process(Long categoryNodeId);
}

再看下兩種場景對應的模板方法,它們把一些通用操作封裝了一下。自下而上的操作時,使用了堆疊和對列。

點選檢視程式碼
    /**
     * 對一個節點進行操作模板方法
     * @param func     具體的操作
     * @param nodeId   節點id
     * @param withLock 是否加互斥鎖
     * @param <R>
     * @return
     */
    private <R> R executeForOneNode(Long nodeId, boolean withLock, CategorySingleOperation<R> func) {
        Category node = nodeMap.get(nodeId);
        if(node == null) {
            node = categoryRepository.loadOne(nodeId);
            nodeMap.put(nodeId, node);
        }
        if(node == null) {
            throw new RuntimeException("待處理的節點不存在");
        }

        if(withLock) {
            if(!redisLock.acquire(buildLockKey(nodeId), SystemConstants.CATEGORY_LOCK_TIME)) {
                throw new RuntimeException("併發鎖獲取失敗");
            }
            R r = func.process();
            redisLock.release(buildLockKey(nodeId));
            return r;
        } else {
            return func.process();
        }
    }

    /**
     * 從一個節點開始, 自上而下逐層進行操作模板方法
     * @param func     具體的操作
     * @param nodeId   節點id
     * @param withLock 是否加互斥鎖
     * @return
     */
    private void executeForDownUpByLevel(Long nodeId, boolean withLock, CategoryTraverseOperation func) {
        Category node = loadOne(nodeId);
        if(node == null) {
            throw new MeiJianException(PbdErrorCodeEnum.NO_DATA.getCode(), "節點不存在!");
        }

        // 按層組裝節點
        LinkedList<Category> queueForTraverse = Lists.newLinkedList();
        LinkedList<Long> stackForHandle = Lists.newLinkedList();

        queueForTraverse.offer(node);
        while(!queueForTraverse.isEmpty()) {
            Category currentNode = queueForTraverse.poll();
            stackForHandle.push(currentNode.getId());
            List<Category> children = categoryRepository.loadByParentId(currentNode.getId());
            if(!CollectionUtils.isEmpty(children)) {
                children.forEach(queueForTraverse::offer);
            }
        }

        // 自底向上處理
        while(!stackForHandle.isEmpty()) {
            Long currentCategoryId = stackForHandle.pop();
            if(withLock) {
                if(!redisLock.acquire(buildLockKey(nodeId), SystemConstants.CATEGORY_LOCK_TIME)) {
                    throw new RuntimeException("併發鎖獲取失敗");
                }
                func.process(currentCategoryId);
                redisLock.release(buildLockKey(nodeId));
            } else {
                func.process(currentCategoryId);
            }
        }
    }

領域方法

終於到這裡了。前面經過噼裡啪啦一頓抽象,領域方法寫起來已經很簡單了,下面舉幾個例子,分別展示單個節點操作和自底向上操作一個節點下的所有節點的寫法。
實際上不止這幾個方法,通過模板方法省掉了大量重複程式碼,看上去也乾淨整潔很多,這裡就不一一列舉了。

點選檢視程式碼
    /**
     * 增加類目節點, 序號為父節點下最大值
     * @param parentId
     * @param name
     * @param contentType
     * @return
     */
    public Long addContentCategory(Long parentId, String name, ContentTypeEnum contentType) {
        return executeForOneNode(parentId, true,  () -> {
            int index = 0;
            Category parent = loadOne(parentId);
            List<Category> children = categoryRepository.loadByParentId(parent.getId());
            if(!CollectionUtils.isEmpty(children)) {
                for(Category child: children) {
                    if(child.getIndex() > index) {
                        index = child.getIndex();
                    }
                }
            }
            index++;
            return categoryRepository.add(buildCategory(name, contentType, parent, index));
        });
    }

    /**
     * 刪除節點及節點上的內容
     * 為了防止髒資料, 從底向上刪
     *
     * @param categoryId
     */
    public void deleteNodes(Long categoryId) {
        executeForDownUpByLevel(categoryId, false, currentCategoryId-> {
            contentRepository.deleteByCategoryId(currentCategoryId);
            categoryRepository.delete(currentCategoryId);
        });
    }

讀寫分離也是如此絲滑自然

面對一部分需求裡的內容,你會發現CQRS有時並不是要故意搞什麼高大上的概念,而是不得已而為之......只靠領域服務臣妾做不到啊?

比如,為了通過UI展示一顆類目樹,你需要提供一個介面一次性把所有類目節點查出來,並且保持樹的結構;
再比如,你要展示一個類目節點及其下面所有子級類目節點關聯的內容,對於子級還要像子級的子級這樣遞迴下去。

對於第一個場景,總不能把模型轉VO這件事在聚合根裡做吧?我選擇另寫一個CategoryReadService包裹著一些Repository來承載這種層級查詢,順便把其他所有的純查詢請求都用它來對接;
對於第二個場景,直接上ES走搜尋了。
再補一個場景,一些刁鑽的查詢需求會破壞你原先自洽的mysql索引設計。

可以這樣歸納:不要讓查詢破壞你的建模和設計

小結

整個實踐下來發現,居然在無意間把聚合根、實體工廠、領域方法、讀寫分離都串起來了。程式碼很有條理,複用性也比較高,收穫頗豐,對DDD也有了新的認識。

不過話說回來,這次也算是佔了建模難度低的便宜,類目樹它本身是一顆樹,可以用資料結構裡樹的相關知識做抽象,其他的場景用DDD抽象未必有這麼簡單。

相關文章