淺析前端 DDD 框架 Remesh
大廠技術(shù) 高級前端 Node進階
點擊上方 程序員成長指北,關(guān)注公眾號
回復1,加入高級Node交流群
DDD(Domain-Driven Design):領(lǐng)域驅(qū)動設(shè)計。首先需要了解,所謂的「領(lǐng)域」,其實不僅僅在于程序表現(xiàn)形式,更適合說是對特定業(yè)務的描述,通常由該業(yè)務的垂直協(xié)作方共同確定,比如產(chǎn)品需求、系統(tǒng)架構(gòu)、程序代碼,由一群“專業(yè)的”人承接,這意味著其中的每一個人,可能都是該「領(lǐng)域」內(nèi)的專家,而「領(lǐng)域模型」成了他們之間的「通用語言」,或者說,「領(lǐng)域知識」讓彼此能夠坐在一起討論問題,再換句話說,產(chǎn)品也可以使用此通用語言來“組織代碼”。這也是 DDD 的戰(zhàn)略意義。

MVC 與 DDD
這里有必要談及一下后端傳統(tǒng)的 MVC 架構(gòu),通常會采用一種「貧血模型」,即將「行為」和「狀態(tài)」拆分至不同的對象中,也就是我們常說的 POJO 和 Service,這樣做的好處是,在開發(fā)業(yè)務代碼時,心智負擔較小,對于簡單業(yè)務效率很高。往往開發(fā)人員會形成一種慣性思維,接到需求時,先三下五除二,把實體和服務先定義好。可是我們仔細想想,這種方式其實違背了面向?qū)ο蟮谋举|(zhì),將一個對象的狀態(tài)和行為強行拆分,變成了面向過程開發(fā),Service 中的邏輯隨著業(yè)務復雜度提升,變得失控。當業(yè)務復雜度膨脹至一定程度,甚至會產(chǎn)生牽一發(fā)而動全身的風險。
而 DDD 帶來的「充血模型」,完全規(guī)避了這個問題,POJO 和 Service 變成 Domain,可以理解為高內(nèi)聚(對于 Domian 的設(shè)計不是本文導論的重點),Domain 的 State 僅由其 Action 完成,禁止任何外部的直接修改,將業(yè)務風險收斂至領(lǐng)域內(nèi)部,一切將變得井然有序。
DDD 優(yōu)勢
業(yè)務層面:基于領(lǐng)域知識的通用語言,快速交付,極低的協(xié)作成本 架構(gòu)層面:利于結(jié)構(gòu)布局,利于資源聚焦 代碼層面:復雜度治理
DDD 弊端
主要在于戰(zhàn)術(shù)層面
心智負擔大,對現(xiàn)有業(yè)務拆解成本大,對團隊要求較高 缺少戰(zhàn)術(shù)意義上的最佳實踐,從頭造輪子難度較大 簡單業(yè)務下,效率很低(缺少開箱即用的框架)
對前端的思考
DDD 近幾年在后端的落地頗有成效,社區(qū)也產(chǎn)出了較多的相關(guān)文章,如微軟的《Tackle Business Complexity in a Microservice with DDD and CQRS Patterns》(https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) 。DDD 帶來的戰(zhàn)略意義,前端往往也能深受啟發(fā)。DDD 概念早在 2003 年就已提出,也是近幾年隨著技術(shù)和架構(gòu)的演進重新回到人們視野,并且發(fā)光發(fā)熱。那么,我該什么時候使用 DDD 呢?
存在復雜的業(yè)務及領(lǐng)域概念(如:商品、訂單、履約等),代碼復雜度與業(yè)務復雜度成正比,好的領(lǐng)域模型可以極大地降低代碼復雜度,避免牽一發(fā)而動全身。 想復用業(yè)務邏輯(如:多端實現(xiàn)),將邏輯抽離至領(lǐng)域?qū)樱梢詭椭鷺I(yè)務快速實現(xiàn)。
2. DDD 示例
通常一個商品有以下幾種場景:創(chuàng)建、編輯、上架、審核、撤回,其對應的狀態(tài)有,草稿、審核中、已上架,如果我們用傳統(tǒng)的邏輯去寫,往往是以下代碼:
class Goods {
private isDraft: boolean; // 是否草稿
private isAuditing: boolean; // 是否審核狀態(tài)
private isPublished: boolean; // 是否已上架
private info: any; // 商品信息
// 狀態(tài)初始化
constructor(info: any) {
this.isDraft = true;
this.isAuditing = false;
this.isPublished = false;
this.info = info;
}
// 編輯操作
edit(info: any) {
if (!this.isDraft) {
throw new Error('僅草稿狀態(tài)下可編輯');
}
this.info = info;
}
// 上架操作
publish() {
if (!this.isDraft) {
throw new Error('僅草稿狀態(tài)下可上架');
}
this.isAuditing = true;
this.isDraft = false;
}
// 審核操作
audit(result: boolean) {
if (!this.isAuditing) {
throw new Error('僅在途狀態(tài)下可審核');
}
if (!result) {
this.isDraft = true;
}
this.isPublished = result;
this.isAuditing = false;
}
// 撤回操作
revert() {
this.isAuditing = false;
this.isDraft = true;
}
}
這種寫法只能說,無功無過,現(xiàn)實場景中,一個商品的狀態(tài)可能非常復雜,那這個類的邏輯代碼也將會變得十分龐大且復雜,那我們該如何使用 DDD 來對其進行邏輯抽離呢?且看以下寫法:
// 草稿商品
class DraftGoods {
private info: any;
constructor(info: any) {
this.info = info;
}
// 可以編輯
edit(info: any) {
this.info = info;
}
// 可以上架
publish() {
return new AuditingGoods(this.info);
}
}
// 審核中商品
class AuditingGoods {
private info: any;
constructor(info: any) {
this.info = info;
}
// 審核成功或失敗
audit(result: boolean) {
if (result) {
return new PublishedGoods(this.info);
} else {
return new DraftGoods(this.info);
}
}
// 可以撤回
revert() {
return new DraftGoods(this.info);
}
}
// 已上架的商品
class PublishedGoods {
private info: any;
constructor(info: any) {
this.info = info;
}
// 獲取商品信息
getInfo() {
return this.info;
}
}
經(jīng)過上述轉(zhuǎn)化,我們將「有多個狀態(tài)的實體」,變成「多個有狀態(tài)的實體」,這樣做的好處是,我們僅需要關(guān)心不同狀態(tài)下,實體的業(yè)務邏輯操作,將代碼聚焦于業(yè)務實現(xiàn),真正做到了領(lǐng)域知識的表達,便于橫向擴展。 前文說到,DDD 的戰(zhàn)略價值目前是大于戰(zhàn)術(shù)價值的,根本原因是目前社區(qū)缺少成熟的框架或輪子,開發(fā)者若能只專注領(lǐng)域模型的構(gòu)建,其他的交給框架來處理,才能充分發(fā)揮 DDD 的戰(zhàn)術(shù)優(yōu)勢。Remesh 便是前端 DDD 實踐的產(chǎn)物,且看改造示例:
// 草稿商品
type DraftGoods = {
type: 'DraftGoods';
content: any;
};
// 審核中商品
type AuditingGoods = {
type: 'AuditingGoods';
content: any;
};
// 已發(fā)布商品
type PublishedGoods = {
type: 'PublishedGoods';
content: any;
};
// 商品領(lǐng)域模型
export const GoodsDomain = Remesh.domain({
name: 'GoodsDomain',
impl: (domain) => {
// 商品狀態(tài),初始化草稿狀態(tài)
const GoodsState = domain.state({
name: 'GoodsDomain',
default: {
type: 'DraftGoods',
content: null
}
});
// 商品查詢
const GoodsQuery = domain.query({
name: 'GoodsQuery',
impl: ({ get }) => {
// 返回商品信息
return get(GoodsState());
}
});
// 編輯命令
const EditCommand = domain.command({
name: 'EditCommand',
impl: ({}, info: DraftGoods) => {
// 返回更新后的商品
return GoodsState().new(info);
}
});
// 上架命令
const PublishCommand = domain.command({
name: 'PublishCommand',
impl: ({ get }) => {
const info = get(GoodsState());
// 上架后,返回審核中的商品
return GoodsState().new({
...info,
type: 'AuditingGoods'
}) as AuditingGoods;
}
});
// 審核命令
const AuditCommand = domain.command({
name: 'AuditCommand',
impl: ({ get }, result: boolean) => {
const info = get(GoodsState());
if (result) {
// 審核成功,返回已發(fā)布的商品
return GoodsState().new({
...info,
type: 'PublishedGoods'
}) as PublishedGoods;
} else {
// 審核失敗,返回草稿的商品
return GoodsState().new({
...info,
type: 'DraftGoods'
}) as DraftGoods;
}
}
});
// 撤回命令
const RevertCommand = domain.command({
name: 'RevertCommand',
impl: ({ get }) => {
const info = get(GoodsState());
// 撤回后,返回草稿的商品
return GoodsState().new({
...info,
type: 'DraftGoods'
}) as DraftGoods;
}
});
return {
query: {
GoodsQuery
},
command: {
EditCommand,
PublishCommand,
AuditCommand,
RevertCommand
}
};
}
});
3. Remesh
基于 CQRS 模式的 DDD 在前端的落地框架,僅關(guān)心業(yè)務邏輯,若是想在前端嘗試領(lǐng)域劃分,Remesh 不失為一種選擇。
CQRS
CQRS(Command Query Responsibility Segregation):命令查詢職責分離,也就是讀寫分離。命令是指會對實體數(shù)據(jù)發(fā)生變化的操作,如新增、刪除、修改;查詢即字面理解,不會對實體數(shù)據(jù)造成改變。
通常而言,采用 CQRS 模式帶來的性能收益是巨大的,而收益也往往帶來挑戰(zhàn),比如要保證數(shù)據(jù)同步高可用,讀寫模型設(shè)計的雙倍工作量等。CQRS 是對 DDD 的一種補充,并且有幾種子模式來實現(xiàn):單服務/多服務、共享模型/不同模型、共享數(shù)據(jù)源/不同數(shù)據(jù)源,根據(jù)使用場景決定。
核心概念
Domain(領(lǐng)域),可以簡單理解為關(guān)于業(yè)務邏輯的 Component
State:Domain 的狀態(tài) Query:查詢 States Command:更新 States Event:Domian 中可能發(fā)生的事件 Effect:副作用,用于發(fā)送 Query 或 Command 乍一看這結(jié)構(gòu),你會不會覺得與 Redux 等框架有點像?待我們查看其源碼來做判斷。
源碼實現(xiàn)分析
我們以官網(wǎng)提供的 React 示例來看 Define your domain(https://github.com/remesh-js/remesh#define-your-domain)
npm install --save remesh rxjs
可以明確了解到,remesh 采用 RxJS 進行事件分發(fā)與數(shù)據(jù)流轉(zhuǎn),這也意味著,remesh 的本質(zhì)在于對數(shù)據(jù)操作的高度抽象,利用 RxJS 的能力達到數(shù)據(jù)更新的效果。 我們來看 Remesh.domian 的定義:
Remesh.domain => RemeshDomainRemeshDomain => RemeshDomainAction
let domainUid = 0
export const RemeshDomain = <T extends RemeshDomainDefinition, U extends Args<Serializable>>(
options: RemeshDomainOptions<T, U>,
): RemeshDomain<T, U> => {
// 優(yōu)化策略:緩存無參數(shù)時的 RemeshDomainAction 實例
let cacheForNullary: RemeshDomainAction<T, U> | null = null
const Domain: RemeshDomain<T, U> = ((arg: U[0]) => {
if (arg === undefined && cacheForNullary) {
return cacheForNullary
}
const result: RemeshDomainAction<T, U> = {
type: 'RemeshDomainAction',
Domain,
arg,
}
if (arg === undefined) {
cacheForNullary = result
}
return result
}) as unknown as RemeshDomain<T, U>
// 定義 RemeshDomain 相關(guān)信息,可選參數(shù) name, impl 等
Domain.type = 'RemeshDomain'
Domain.domainId = domainUid++
Domain.domainName = options.name // 領(lǐng)域名稱
Domain.impl = options.impl as (context: RemeshDomainContext, arg: U[0]) => T // 具體業(yè)務實現(xiàn)
Domain.inspectable = options.inspectable ?? true
return Domain
}
接著是在前端框架中通過 hook 的方式引入 useRemeshDomain(CountDomain())
export const useRemeshDomain = function <T extends RemeshDomainDefinition, U extends Args<Serializable>>(
domainAction: RemeshDomainAction<T, U>,
) {
// 獲取注入的 store
const store = useRemeshStore()
// 若 domainAction 不在暫存的集合中,會調(diào)用 createDomainStorage 進行創(chuàng)建,且看下文
const domain = store.getDomain(domainAction)
// React
useEffect(() => {
// 當 domain 被激活后,事件訂閱開始生效,會通過 RxJS 進行事件分發(fā)
store.igniteDomain(domainAction)
}, [store, domainAction])
// Vue
onMounted(() => {
store.igniteDomain(domainAction)
})
return domain
}
為了更好的解釋,上述代碼在 Remesh 的源碼基礎(chǔ)上稍加改動,可以看出,其主要針對不同框架的實現(xiàn)做了適配
// remesh-react.tsx
export const useRemeshReactContext = () => {
const context = useContext(RemeshReactContext)
if (context === null) {
throw new Error(`You may forget to add <RemeshRoot />`)
}
return context
}
export const useRemeshStore = (): RemeshStore => {
const context = useRemeshReactContext()
return context.remeshStore
}
// remesh-vue.ts
export const useRemeshStore = () => {
const store = inject(RemeshVueInjectKey)
if (!store) {
throw new Error('RemeshVue plugin not installed')
}
return store
}
可以清晰的看到,React 采用了 Context 的方式注入 store,而 Vue 采用 Provide Inject 的方式注入 store,現(xiàn)在我們對 React 實現(xiàn)進行分析,來看以下 <RemeshRoot> 的實現(xiàn)
export const RemeshRoot = (props: RemeshRootProps) => {
// 可自定義 store
const storeRef = useRef<RemeshStore | undefined>(props.store)
if (!storeRef.current) {
// 通常情況下會創(chuàng)建一個無 options 的 store
storeRef.current = RemeshStore('options' in props ? props.options : {})
}
const store = storeRef.current
const contextValue: RemeshReactContext = useMemo(() => {
return {
remeshStore: store,
}
}, [store])
return <RemeshReactContext.Provider value={contextValue}>{props.children}</RemeshReactContext.Provider>
}
可以看一下 RemeshStore 的結(jié)構(gòu),儼然是一個 store 管理工具,實時也確實如此,remesh 后續(xù)的狀態(tài)查詢以及更新都是基于 store 庫執(zhí)行。
export const RemeshStore = (options?: RemeshStoreOptions) => {
// ...
return {
name: options.name,
getDomain, // 接上文,在 useRemeshDomain 中會調(diào)用此方法
igniteDomain,
query: getCurrentQueryValue,
send,
// ...
}
}
調(diào)用 getDomain 時會優(yōu)先判斷 domainAction 是否存在于緩存中,若不存在則創(chuàng)建,關(guān)鍵的來了,我們定義在 Domian 中的 impl 方法,此時會被調(diào)用。
const createDomainStorage = <T extends RemeshDomainDefinition, U extends Args<Serializable>>(
domainAction: RemeshDomainAction<T, U>,
): RemeshDomainStorage<T, U> => {
// ...
// domain 上下文對象,標準化創(chuàng)建過程,也是可以鏈式操作的原因(代碼已簡化)
const domainContext: RemeshDomainContext = {
state: (options) => {
return RemeshState(options)
},
query: (options) => {
return RemeshQuery(options)
},
event: (options: Omit<RemeshEventOptions<any, any>, 'impl'> | RemeshEventOptions<any, any>) => {
return RemeshEvent(options)
},
command: (options) => {
return RemeshCommand(options)
},
effect: (effect) => {
if (!currentDomainStorage.ignited) {
currentDomainStorage.effectList.push(effect)
}
},
// ...
}
// domain 中 query, command, event 的具體實現(xiàn)
const domain = toValidRemeshDomainDefinition(domainAction.Domain.impl(domainContext, domainAction.arg))
// domain 的存貯對象
const currentDomainStorage: RemeshDomainStorage<T, U> = {
id: uid++,
type: 'RemeshDomainStorage',
Domain: domainAction.Domain,
get domain() {
return domain
},
arg: domainAction.arg,
domainContext,
domainAction,
effectList: [],
ignited: false,
}
// ...
return currentDomainStorage
}
關(guān)于 store 中 send、query 實現(xiàn)的基本狀態(tài)更新以及事件機制,由于篇幅有限,不再展開描述,相信以上內(nèi)容已經(jīng)達到拋磚引玉的效果。
4. 總結(jié)
Remesh 采用了一種獨特的方式,使 DDD 能夠在前端得以應用,為了達到這一效果,開發(fā)者可能會需要適應不同的代碼風格,需要熟悉領(lǐng)域模型的設(shè)計。
由于項目處于起步階段,目前仍在迭代中,不建議在生產(chǎn)環(huán)境使用,或者說 DDD 在前端的戰(zhàn)術(shù)設(shè)計仍未有最佳實踐,但 Remesh 帶來的意義是非凡的,實現(xiàn)方式可能多樣,解決的問題永遠只會是同一個,我們始終在代碼優(yōu)化的路上艱難前行,DDD 將來未必是一條歧路。
參考鏈接
用DDD(領(lǐng)域驅(qū)動設(shè)計)和ADT(代數(shù)數(shù)據(jù)類型)提升代碼質(zhì)量(https://zhuanlan.zhihu.com/p/475789952)
remesh(https://github.com/remesh-js/remesh)
從MVC到DDD的架構(gòu)演進(https://zhuanlan.zhihu.com/p/456915280)
Node 社群 我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學習、共建。下方加 考拉 好友回復「Node」即可。
“分享、點贊、在看” 支持一波??
