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


背景

距離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抽象未必有這么簡單。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM