
阿里妹導讀
一次專案包含非常多的流程,有需求拆解,業務建模,專案管理,風險識別,程式碼模組設計等等,如果我們在每次專案中,都將精力大量放在這些過程的思考上面,那我們剩餘的,放在業務上思考的精力和時間就會大大減少;這也是為什麼我們要 總結經驗/方法論/正規化 的原因;這篇文章旨在建立程式碼模組設計上的思路,給出了兩種非常常用的設計正規化,減少未來在這一塊的精力開銷。
一、領域模型驅動的程式碼正規化
領域模型驅動的程式碼正規化,是圍繞著領域知識設計的,需要先理解業務模型,再將業務模型對映到軟體的物件模型中來;本章節重點在我們有了業務模型之後的程式碼模式,具體業務模型如何構建在《架構之道:人人都是架構師》中有詳細討論;

上圖中間就是該模式最重要的領域,領域層程式碼作為系統的最核心資產模組,可以被打包遷移到任何應用上,而不關心具體的三方服務提供方和具體的持久化方案,即外部服務的變化對領域層程式碼是沒有任何侵入的;
從上圖來看,領域物件(ENTITY、AGGREGATION、VALUEOBJECT、MODEL)用於描述業務模型,是業務關係最重要的體現;為了遮蔽持久化方案的細節,我們使用倉庫(REPOSITORY)來查詢和持久化領域物件;有的時候我們期望直接獲取到一個非空的領域物件,而不關心這個物件從哪裡來,如何構造,那我們就需要工廠(FACTORY)來幫我們生產這個物件;當領域內依賴外部服務能力時,需要門面(FACADE)幫助我們遮蔽具體的服務提供方;
有了以上這些模型物件和基礎能力模組,我們需要領域服務(DomainServie)層作為“上帝之手”幫我們編排具體的業務邏輯;本章對領域服務有更細的三層劃分,第一層是實體操作服務(BaseUpdateDomainService),用於收斂操作實體/聚合的真實變更行為;第二層是業務流程服務(BaseBizDomainService),用於收斂基礎的有業務語義的行為;第三層是用例領域服務(UserCaseDomainService),用於對映具體的業務需求用例場景。
1.1 領域模型物件

-
實體
由標識定義,而不依賴它的所有屬性和職責,並在整個生命週期中有連續性。這句話在初看的時候非常晦澀,簡單來說,就是一個標識沒變的物件,在其他自身屬性發生變化後,它依然是它,那麼它就是實體;以上圖為例,一個商品的商品id沒變,即使它的標題改了,圖片改了,優惠資訊變了,它發生了翻天覆地的變化,但它依然是它,它有唯一的標識來表明它還是它,只是一些屬性發生了變化;透過這種方式來識別實體的目的,是因為領域中的關鍵物件,通常並不由它們的屬性定義,而是由可見的/不可見的標識來定義,且有完整的生命週期,在這個週期內它如何變化,它都依然是它;透過這種方式識別出實體這種領域關鍵物件,也是領域驅動設計和資料驅動設計最大的差別,資料驅動設計是先識別出我們需要哪些資料表,然後將這些資料表對映為物件模型;而領域驅動設計是先透過業務模型識別出實體,再將實體對映為所需要的資料表。
-
值物件
用於描述領域的某個方面而本身沒有唯一標識的物件。被例項化後用來表示一些設計元素,對於這些設計元素,我們只關心它們是什麼,而不關心它們是誰。如上圖舉個例子,一個商品實體的發貨地址Address物件有區域資訊、門牌資訊、時區資訊這幾個屬性,其中的門牌號從111修改成222後,它就已經不再是修改前的那個它了,因為門牌號222並不等於門牌號111的地址。即它是沒有生命週期的,它的equals方法由它的屬性值決定(實體的equals方法由唯一標識決定);
-
聚合
聚合是一組實體和值物件的組合;內部包含一個聚合根,和由聚合根關聯起來的實體和值物件;以上圖為例,有商品、優惠、庫存這三個實體和地址這一個值物件,對於一個商品而言,完整的商品資訊需要包含優惠、庫存、地址這些資訊,那麼在商品模型中,商品就是聚合根,其內部透過優惠id關聯它的優惠資訊,透過庫存id關聯商品的庫存資訊;聚合將這組關聯關係建立,對外提供統一的操作,比如需要刪除某個商品,那麼這個聚合的內部可以在一個事務(或分散式事務)中,對庫存進行清空,對優惠進行清理,最終對商品進行刪除。
1.2 查詢/構造能力

-
倉庫
倉庫是可持久化的領域物件和真實物理儲存操作之間的媒介,隨意的資料庫查詢會破壞領域物件的封裝,所以需要抽象出倉庫這種型別,它定義領域物件的獲取和持久化方法,具體實現不由領域層感知;至於具體用了什麼儲存,如何寫入和查詢,是否使用快取,這些邏輯統一封裝在倉庫的實現層,對於後續遷移儲存、增刪快取,都可以做到不侵蝕業務領域。
比如整個領域模組需要打包給海外業務使用,在海外我們需要用當地的儲存,那麼這個遷移對於領域層是沒有侵入的,只需要在基礎設施層修改倉庫的實現即可;
再比如我們的資料庫存在效能瓶頸,需要在資料庫上增加一層快取,這個操作對領域也是沒有侵入的,只需要在倉庫的實現處,增加快取的讀寫即可,對業務邏輯無感。
-
三方能力門面
門面用於封裝第三方的能力,設計初衷本質和倉庫是一樣的,目的都是遮蔽具體的三方能力實現,讓穩定的領域層不去依賴無法把控變化方向的第三方;上圖中的三個case是比較經典的三個例子:
-
我們的模型中依賴外部查詢獲取的商品模型,這個模型中有商品標題、商品圖片、店鋪名稱這幾個資訊,那麼我們需要在Domain層定義一個商品類ItemInfo,包含這幾個屬性,然後在Domain層定義一個獲取ItemInfo物件的服務介面,比如叫ItemFacade,方法是getItemInfo;接下來我們需要在Infrastructure層實現Domain層定義的這個介面,比如具體的實現是依賴Ic的介面,將ItemDO轉換為Domain層的ItemInfo;可以發現,這樣的設計讓Domain對商品資訊的獲取源是無感的,當我們的能力需要部署到海外,或者IC某一天進行了重大改革,需要對模型進行大改,那麼我們只需要重新實現Infrastructrue中ItemFacadeImpl即可。這個思想其實就是依賴倒置的思想,穩定不應該依賴變化,變化應該依賴穩定;因為第三方的變化方向是無法把控的,它的變化不應該侵入到我們的領域知識內部。
-
我們的領域還依賴一些訊息傳送、限流等基礎功能,也需要在Domain層定義相應能力的Facade,在Infrastructure層實現,目的同上,將metaq替換成notify/swift時,領域層是無感的。
-
工廠
當建立一個實體物件或聚合的操作很複雜,甚至有很多領域內部的結構需要暴露的時候,就可以用工廠進行封裝。一種相對簡單粗暴的判斷方法是看這個類的構造方法實現是否複雜,並且看著這些邏輯不應該由這個類實現,那麼不妨用工廠來構造這個物件吧!
1.3 領域服務

有一些對實體/聚合/值物件進行編排操作的概念並不適合被建模為物件,那麼它應該被抽象為領域服務,化作一隻上帝之手,做領域物件間流程操作的編排。服務很重要的特徵,它的操作應該是無狀態的。本文基於開發實踐,對領域服務做了三層更細節的劃分:
-
實體操作服務
即圖中的BaseUpdateDomainService,是最基礎的一類領域服務,用於收口某個實體的真實物理操作,它的流程中一般包含一個核心的update/insert操作,作用在寫資料庫上,依情況可以增加:
BeforeUpdateHandlers和AfterUpdateHandlers,用於更新前後的一些額外業務操作;
如1.1中的圖,我們在記憶體中操作完某個庫存實體物件,需要更新db的時候,可以呼叫它對應的服務,在這個服務中,我們除了將變更的值更新db,還需要對外發送訊息,更新前也需要執行一些校驗的回撥,那麼校驗回撥可以放在BeforeUpdateHandler中,對外的訊息可以放在AfterUpdateHandler中;抽象出這樣的一個服務,好處是可以收斂最基礎的變更操作,不至於不同的入口對某個物件的更新,還會出現不一樣的操作(比如需要傳送更新訊息,不同的入口操作更新,有的傳送訊息,有的不傳送)。
一般每個實體都需要有一個對應的操作服務(或者模型極其簡單可以省略這一層),操作服務可以依賴其他的操作服務,比如1.1中商品模型的更新,是需要依賴庫存更新服務和優惠更新服務的。
-
業務流程服務
這一層的領域服務對應圖中的BaseBizDomainService,用於收斂一些通用的業務流程,可以直接對接業務介面或者用於上層的用例編排;比如我們在商家請求、接到第三方訊息、具體的某幾個用例中,需要先查詢ItemFacade,然後進行一些業務邏輯判斷,然後根據情況對1.1商品模型中的優惠進行清理,那麼就可以將這段邏輯收斂到“XX優惠清理領域服務”中,在多個上層場景需要進行該操作時,直接呼叫這個領域服務即可。
-
用例領域服務
這一層對應圖中的UserCaseDomainService作為領域服務的最上層,也是最具體的一層,用於實現 定製的/不可複用的 用例場景業務邏輯,只直接對接對外的api。
1.4 包結構實踐

該包結構即描述了上述的所有模組,其中infrastructure模組依賴domain模組;domain模組的pom檔案理論上不應該有任何三方依賴(除了一些工具類)。
二、過程驅動的程式碼正規化
領域驅動的程式碼,重點是抽象領域模型,沉澱領域物件實體,它用模型間的關係以及模型直接的操作來沉澱知識;過程驅動的程式碼,重點是抽象能力,沉澱函式,並用編排引擎串聯執行過程,實現對知識的描述。
在面向物件大張旗鼓的今天,大多數人對面向過程程式設計嗤之以鼻,但有些場景使用過程驅動的程式設計思路,反而能更好地描述業務規則以及業務流程,比如前臺表達的渲染鏈路,或是章節一中 比較重的領域服務,使用過程驅動能更好地描述資料處理的過程以及產品用例流程。

上圖能力庫中的能力點是我們過程驅動最核心的部分,是我們對能力的抽象,一般一個能力只沉澱一個具體的原子方法,並決策流程是否能執行下去;往上是階段劃分,不同的能力在具體的業務流程中,是處於不同階段的,這裡的階段劃分是指從流程階段的維度對能力點進行分類並放在不同的包中,讓我們的流程更加清晰;再往上是不同場景下我們對能力點的執行鏈路編排,並對外做統一輸出。
2.1 能力點
能力點通常由一個介面定義,入參是執行上下文,出參是流程是否需要繼續。
public interface AbilityNode<C extends Context> {
/**
* 執行一個渲染節點
* @param context 執行上下文
* @return 是否還需要繼續往下執行
*/
boolean execute(C context);
基於這個介面,我們可以實現非常多原子能力節點。java中,這些能力節點作為bean由spring容器統一管理,執行時取出即用。
在一個業務場景下,我們的能力點往往會非常多,那麼我們就需要對他們進行基於業務場景的階段劃分,並分門管理;比如我們在前臺投放場的實踐中,按照召回、補全、過濾、排序、渲染,劃分了五個階段,每一個能力點被歸類到其中一個階段中進行管理。
2.2 能力編排
有了能力點,我們需要基於編排引擎將這些能力串聯起來,用以描述業務規則或是業務流程;這些能力執行的過程有些僅僅是可序列執行的,有些是也可以並行執行的,下面給出一套通用的流程協議以及實現過程:
public abstract classAbilityChain<C extends Context<?>> {
@Resource
private ThreadPool threadPool;
public abstract C initContext(Request request);
public Response execute(Request request){
//獲取渲染上下文
C ctx;
try {
ctx = this.initContext(request);
if (ctx == null || ctx.getOrder() == null || CollectionUtils.isEmpty(ctx.getOrder().getNodeNames())) {
return null;
}
} catch (Throwable t) {
log.error("{} catch an exception when getRenderContext, request={}, e="
, this.getClass().getName(), JSON.toJSONString(requestItem), t);
return null;
}
try {
//執行所有節點
for (List<String> nodes : ctx.getOrder().getNodeNames()) {
List<AbilityNode<C>> renderNodes = renderNodeContainer.getAbilityNodes(nodes);
boolean isContinue = true;
if (renderNodes.size() > 1) {
//併發執行多個節點
isContinue = this.concurrentExecute(renderNodes, ctx);
} elseif (renderNodes.size() == 1){
isContinue = this.serialExecute(renderNodes, ctx);
}
if (!isContinue) {
break;
}
}
return ctx.getResponse();
} catch (Throwable t) {
log.error("RenderChain.execute catch an exception, e=", t);
return null;
}
}
/**
* 併發執行多個node,如果不需要繼續進行了則返回false,否則返回true
*/
private boolean concurrentExecute(List<AbilityNode<C>> nodes, C ctx){
if (CollectionUtils.isEmpty(nodes)) {
returntrue;
}
long start = System.currentTimeMillis();
Set<Boolean> isContinue = Sets.newConcurrentHashSet();
List<Runnable> nodeFuncList = nodes.stream()
.filter(Objects::nonNull)
.map(node -> (Runnable)() -> isContinue.add(this.executePerNode(node, ctx)))
.collect(Collectors.toList());
linkThreadPool.concurrentExecuteWaitFinish(nodeFuncList);
//沒有node認為不繼續,就是要繼續
return !isContinue.contains(false);
}
/**
* 序列執行多個node,如果不需要繼續進行了則返回false,否則返回true
*/
private boolean serialExecute(List<AbilityNode<C>> nodes, C ctx){
if (CollectionUtils.isEmpty(nodes)) {
returntrue;
}
for (AbilityNode<C> node : nodes) {
if (node == null) {
continue;
}
boolean isContinue = this.executePerNode(node, ctx);
if (!isContinue) {
//不再繼續執行了
returnfalse;
}
}
returntrue;
}
/**
* 執行單個渲染節點
*/
private boolean executePerNode(AbilityNode<C> node, C context){
if (node == null || context == null) {
returnfalse;
}
try {
boolean isContinue = node.execute(context);
if (!isContinue) {
context.track("return false, stop render!");
}
return isContinue;
} catch (Throwable t) {
log.error("{} catch an exception, e=", nodeClazz, t);
throw t;
}
}
}
其中流程協議為:
[
[
"Ability1"
],
[
"Ability3",
"Ability4",
"Ability6"
],
[
"Ability5"
]
]
其中Ablitiy3 4 6表示需要併發執行,Ability1、3/4/6、5表示需要序列執行。
2.3 切面
有了能力點和流程編排引擎,基於過程編碼的程式碼骨架就已經有了;但是往往我們還需要一些無法沉澱為能力的邏輯,需要在每個節點(或指定節點)執行前/後進行,這就需要切面的能力;

如圖,比如流程埋點、預校驗就非常適合放到切面這一層實現。下圖是我們在實踐中,基於切面實現的能力點追蹤,可以清晰地看到每個能力點執行的過程以及資料變更情況。

2.4 包結構實踐

該包結構即描述了上述的所有模組,chain目錄下為多個業務場景流程,圍繞著外層的AbilityNode和AbilityChain進行實現,編排出符合業務場景邏輯的流程。
寫在後面
文章中的程式碼正規化可以應用在大部分場景,在專案初起的時候直接套用,可以省下大部分關於包模組劃分的思考精力,並且在後續迭代中,團隊統一規範,持續按照這個框架演進,可以讓程式碼更加井井有條,減少一些詭異的類職責劃分問題。
高可用及共享儲存Web服務
隨著業務規模的增長,資料請求和併發訪問量增大、靜態檔案高頻變更,企業需要搭建一個高可用和共享儲存的網站架構。
點選閱讀原文檢視詳情。