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

来源:https://www.cnblogs.com/wuyuegb2312/archive/2022/04/10/16071893.html
-Advertisement-
Play Games

在上次反思DDD實踐之後,在類目樹管理項目中再次實踐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有時並不是要故意搞什麼高大上的概念,而是不得已而為之......只靠領域服務臣妾做不到啊

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • Vue2升級為Vue3之後有很多新內容,但也有很多坑,這裡講下我今天剛學Vue3遇到的坑。可以直接到最後看main.js。 首先就是Element-ui,前端vue一般都使用這個插件,但這個插件在Vue3中就不能用了(應該是暫時,目前2022年4月10日),but它有一個兄弟可以用,它叫elemen ...
  • 2022第十三屆藍橋杯第一次開放了web組賽道,博主作為一名前端小白,參加了這次比賽。一共十個題目,目的均是實現特定的網頁效果,考點包含三件套、jQuery和vue,這裡簡單的進行一下個人的題解記錄。 ...
  • 基於個人寫的以下關於Vue框架基礎學習的三篇隨筆,在此基礎上,做一個階段性的知識總結,以此來檢驗自己對Vue這一段時間學習的成果,內容不多,但很值得一看。(思維導圖詳解) ...
  • VUE生命周期函數 可謂是一個個鮮活的生命在服務於各個在使用VUE框架的碼農~ beforeCreate: 創建實例之前; 初始化 註入&校驗 把data、methods、props、computed、provide、watch...依次掛載到實例上 methods 中普通的方法和computed中 ...
  • 正文 1. 阿裡雲DataV 2. 積木報表jimureport 3. 百度Sugar 4. 帆軟 最經常的工作是將一些項目的數據從資料庫導出,然後分門別類的列到excel表格中,領導看起來眼花繚亂。 要是能以圖表可視化展現出來,領導就可以看到項目近幾個月的走勢,也知道之後要怎麼決策了。 嘗試了使用 ...
  • 前端周刊發表每周前端技術相關的大事件、文章教程、一些框架的版本更新、以及代碼和工具。每周定期發表,歡迎大家關註、轉載。 如果外鏈不能訪問,關註公眾號「前端每周看」,裡面有解決辦法 大事件 Veni,vidi,formatae! 宣佈Rome Formatter:超快速的 JavaScript 格式化 ...
  • 狀態模式(State Pattern)指允許一個對象在其內部狀態改變時改變它的行為,對象看起來似乎修改了它的類。 一般用來實現狀態機,而狀態機常用在游戲、工作流引擎等系統的開發中: 有限狀態機(Finite State Machine,FSM),狀態機有三個組成部分:狀態(State)、事件(Eve ...
  • 1 .解決跳轉問題:添加一個login方法,跳轉返回一個字元串。 中央控制器DispacherServlet調用EmpController,所以字元串返回給中央控制器。如下圖所示:中央控制器幫我們統一的做 資源的轉發(forward/include) 或 重定向。 1.1 更新 EmpControl ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...