在上次反思DDD實踐之後,在類目樹管理項目中再次實踐DDD。從需求分析到建模和具體的落地,結合個人體會,都是乾貨。 ...
背景
距離DDD實踐反思寫完已經過去一年,期間發生了很多事情,比如換了工作,細節按下不表;新團隊的技術負責人對DDD在團隊里的落地很關心,問最近有沒有什麼進展?這就很尷尬了:之前我接手並主要負責的XXX服務在現階段是不太適合用DDD的,自身和外部其他幾個服務的邊界並不清楚(其中包含了一些歷史技術債),而且當前處於一個變化比較快的階段,也沒有什麼業務輸入,不太適合貿然重構,所以並沒有在XXX服務中搞DDD。
做技術總要有點追求嘛,雖然現階段工作最高優先順序還是保證業務快速發展,還是想繼續實踐下DDD的。
這時正巧一個老應用要做重構,在這個基礎上一個新的類目管理功能,雖然是一個新的領域,但是產品文檔確定的業務規則已經非常清晰,並且後續變化不會很大。美中不足的是需求對應的功能此時已經用傳統的CRUD的方式寫完了大半了,糾結了半天,還是決定:搞!
本文對於DDD的基礎術語就不再單獨講解了,下麵直接進入正題。
原則問題
關於DDD,我一年前觀點基本沒有變化,這裡再總結歸納一下。要先確定是否滿足以下條件,再考慮是不是要用DDD,不要為了DDD而DDD。永遠記住:沒有銀彈!
實踐時,你就會發現DDD在項目落地時做了很多折中,不能教條化地照搬。
- 業務規則要有一定的複雜性和穩定性。如果一個業務通過CRUD就能輕易的搞定且以後也不會變得很複雜,或者業務還一直在快速變化(這也意味著經常有很強的的項目時間節點要求和臨時性的規則),不要用DDD。
- 域的劃分是清晰的,建模是準確的,領域方法是可以梳理的且足夠豐富的,是考慮使用DDD先決條件。域的劃分不等於將一個應用強行拆成很多個應用,人為地提升系統複雜性。
- 不要帶來過多的額外成本,不要捨本逐末。如果因為DDD導致一個應用的開發、測試、運維成本翻倍,甚至引入了更多的bug,那麼就要反思下這次實踐是否成功了。
需求分析
這裡概括一下需求要點,已刨除掉需求具體的背景以及和本文無關的其他項目需求內容。
本次需要實現一個管理如下圖的類目樹結構的功能:
(圖源:https://t.cj.sina.com.cn/articles/view/7321552158/1b466051e001010bfc)
具體的規則和支持的操作:
- 類目節點組織成一棵或多顆樹,每個類目節點下可以有一個或多個子類目節點
1.1 子類目節點是有序的,可以進行重排序
1.2 最頂層的類目節點是根
1.3 類目節點上可以關聯多個同種類型的內容實體 - 類目節點可以新增、刪除、重命名、上架、下架
2.1 上架和下架是類目節點的狀態。如果類目節點下沒有關聯內容,或者它其下沒有上架的子類目節點,無法上架。
2.1 刪除節點時,其下的子節點和子節點關聯的內容需要一併刪除
建模
象徵性地畫一下限界上下文和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不同點:
- 可以包含業務邏輯、事務,本身會成為領域服務的一部分;
- 需要將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有時並不是要故意搞什麼高大上的概念,而是不得已而為之......只靠領域服務臣妾做不到啊