|
前端 DDD 框架 Remesh 的浅析
173 前端 DDD 框架 Remesh 的浅析http://zoo.zhengcaiyun.cn/blog/article/domaindrivendesign1. 什么是 DDDDDD(Domain-Driven Design):领域驱动设计。首先需要了解,所谓的「领域」,其实不仅仅在于程序表现形式,更适合说是对特定业务的描述,通常由该业务的垂直协作方共同确定,比如产品需求、系统架构、程序代码,由一群“专业的”人承接,这意味着其中的每一个人,可能都是该「领域」内的专家,而「领域模型」成了他们之间的「通用语言」,或者说,「领域知识」让彼此能够坐在一起讨论问题,再换句话说,产品也可以使用此通用语言来“组织代码”。这也是 DDD 的战略意义。MVC 与 DDD这里有必要谈及一下后端传统的 MVC 架构,通常会采用一种「贫血模型」,即将「行为」和「状态」拆分至不同的对象中,也就是我们常说的 POJO 和 Service,这样做的好处是,在开发业务代码时,心智负担较小,对于简单业务效率很高。往往开发人员会形成一种惯性思维,接到需求时,先三下五除二,把实体和服务先定义好。可是我们仔细想想,这种方式其实违背了面向对象的本质,将一个对象的状态和行为强行拆分,变成了面向过程开发,Service 中的逻辑随着业务复杂度提升,变得失控。当业务复杂度膨胀至一定程度,甚至会产生牵一发而动全身的风险。而 DDD 带来的「充血模型」,完全规避了这个问题,POJO 和 Service 变成 Domain,可以理解为高内聚(对于 Domian 的设计不是本文导论的重点),Domain 的 State 仅由其 Action 完成,禁止任何外部的直接修改,将业务风险收敛至领域内部,一切将变得井然有序。DDD 优势业务层面:基于领域知识的通用语言,快速交付,极低的协作成本架构层面:利于结构布局,利于资源聚焦代码层面:复杂度治理DDD 弊端主要在于战术层面心智负担大,对现有业务拆解成本大,对团队要求较高缺少战术意义上的最佳实践,从头造轮子难度较大简单业务下,效率很低(缺少开箱即用的框架)对前端的思考DDD 近几年在后端的落地颇有成效,社区也产出了较多的相关文章,如微软的《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 带来的战略意义,前端往往也能深受启发。DDD 概念早在 2003 年就已提出,也是近几年随着技术和架构的演进重新回到人们视野,并且发光发热。那么,我该什么时候使用 DDD 呢?存在复杂的业务及领域概念(如:商品、订单、履约等),代码复杂度与业务复杂度成正比,好的领域模型可以极大地降低代码复杂度,避免牵一发而动全身。想复用业务逻辑(如:多端实现),将逻辑抽离至领域层,可以帮助业务快速实现。2. DDD 示例通常一个商品有以下几种场景:创建、编辑、上架、审核、撤回,其对应的状态有,草稿、审核中、已上架,如果我们用传统的逻辑去写,往往是以下代码:class Goods { private isDraft: boolean; // 是否草稿 private isAuditing: boolean; // 是否审核状态 private isPublished: boolean; // 是否已上架 private info: any; // 商品信息 // 状态初始化 constructor(info: any) { this.isDraft = true; this.isAuditing = false; this.isPublished = false; this.info = info; } // 编辑操作 edit(info: any) { if (!this.isDraft) { throw new Error('仅草稿状态下可编辑'); } this.info = info; } // 上架操作 publish() { if (!this.isDraft) { throw new Error('仅草稿状态下可上架'); } this.isAuditing = true; this.isDraft = false; } // 审核操作 audit(result: boolean) { if (!this.isAuditing) { throw new Error('仅在途状态下可审核'); } if (!result) { this.isDraft = true; } this.isPublished = result; this.isAuditing = false; } // 撤回操作 revert() { this.isAuditing = false; this.isDraft = true; }}这种写法只能说,无功无过,现实场景中,一个商品的状态可能非常复杂,那这个类的逻辑代码也将会变得十分庞大且复杂,那我们该如何使用 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 ublishedGoods(this.info); } else { return new DraftGoods(this.info); } } // 可以撤回 revert() { return new DraftGoods(this.info); }}// 已上架的商品class ublishedGoods { private info: any; constructor(info: any) { this.info = info; } // 获取商品信息 getInfo() { return this.info; }}经过上述转化,我们将「有多个状态的实体」,变成「多个有状态的实体」,这样做的好处是,我们仅需要关心不同状态下,实体的业务逻辑操作,将代码聚焦于业务实现,真正做到了领域知识的表达,便于横向扩展。
前文说到,DDD 的战略价值目前是大于战术价值的,根本原因是目前社区缺少成熟的框架或轮子,开发者若能只专注领域模型的构建,其他的交给框架来处理,才能充分发挥 DDD 的战术优势。Remesh 便是前端 DDD 实践的产物,且看改造示例:// 草稿商品type DraftGoods = { type: 'DraftGoods'; content: any;};// 审核中商品type AuditingGoods = { type: 'AuditingGoods'; content: any;};// 已发布商品type ublishedGoods = { type: 'PublishedGoods'; content: any;};// 商品领域模型export const GoodsDomain = Remesh.domain({ name: 'GoodsDomain', impl: (domain) => { // 商品状态,初始化草稿状态 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 ublishCommand = 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) { // 审核成功,返回已发布的商品 return GoodsState().new({ ...info, type: 'PublishedGoods' }) as ublishedGoods; } 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,  ublishCommand, AuditCommand, RevertCommand } }; }});3. Remesh基于 CQRS 模式的 DDD 在前端的落地框架,仅关心业务逻辑,若是想在前端尝试领域划分,Remesh 不失为一种选择。CQRSCQRS(Command Query Responsibility Segregation):命令查询职责分离,也就是读写分离。命令是指会对实体数据发生变化的操作,如新增、删除、修改;查询即字面理解,不会对实体数据造成改变。通常而言,采用 CQRS 模式带来的性能收益是巨大的,而收益也往往带来挑战,比如要保证数据同步高可用,读写模型设计的双倍工作量等。CQRS 是对 DDD 的一种补充,并且有几种子模式来实现:单服务/多服务、共享模型/不同模型、共享数据源/不同数据源,根据使用场景决定。核心概念Domain(领域),可以简单理解为关于业务逻辑的 ComponentState:Domain 的状态Query:查询 StatesCommand:更新 StatesEvent:Domian 中可能发生的事件Effect:副作用,用于发送 Query 或 Command
乍一看这结构,你会不会觉得与 Redux 等框架有点像?待我们查看其源码来做判断。源码实现分析我们以官网提供的 React 示例来看 Define your domain(https://github.com/remesh-js/remesh#define-your-domain)npm install --save remesh rxjs可以明确了解到,remesh 采用 RxJS 进行事件分发与数据流转,这也意味着,remesh 的本质在于对数据操作的高度抽象,利用 RxJS 的能力达到数据更新的效果。
我们来看 Remesh.domian 的定义:Remesh.domain => RemeshDomainRemeshDomain => RemeshDomainActionlet domainUid = 0export const RemeshDomain = >( options: RemeshDomainOptions,): RemeshDomain => { // 优化策略:缓存无参数时的 RemeshDomainAction 实例 let cacheForNullary: RemeshDomainAction | null = null const Domain: RemeshDomain = ((arg: U[0]) => { if (arg === undefined & cacheForNullary) { return cacheForNullary } const result: RemeshDomainAction = { type: 'RemeshDomainAction', Domain, arg, } if (arg === undefined) { cacheForNullary = result } return result }) as unknown as RemeshDomain // 定义 RemeshDomain 相关信息,可选参数 name, impl 等 Domain.type = 'RemeshDomain' Domain.domainId = domainUid++ Domain.domainName = options.name // 领域名称 Domain.impl = options.impl as (context: RemeshDomainContext, arg: U[0]) => T // 具体业务实现 Domain.inspectable = options.inspectable ?? true return Domain}接着是在前端框架中通过 hook 的方式引入 useRemeshDomain(CountDomain())export const useRemeshDomain = function >( domainAction: RemeshDomainAction,) { // 获取注入的 store const store = useRemeshStore() // 若 domainAction 不在暂存的集合中,会调用 createDomainStorage 进行创建,且看下文 const domain = store.getDomain(domainAction) // React useEffect(() => { // 当 domain 被激活后,事件订阅开始生效,会通过 RxJS 进行事件分发 store.igniteDomain(domainAction) }, [store, domainAction]) // Vue onMounted(() => { store.igniteDomain(domainAction) }) return domain}为了更好的解释,上述代码在 Remesh 的源码基础上稍加改动,可以看出,其主要针对不同框架的实现做了适配// remesh-react.tsxexport const useRemeshReactContext = () => { const context = useContext(RemeshReactContext) if (context === null) { throw new Error(`You may forget to add `) } return context}export const useRemeshStore = (): RemeshStore => { const context = useRemeshReactContext() return context.remeshStore}// remesh-vue.tsexport const useRemeshStore = () => { const store = inject(RemeshVueInjectKey) if (!store) { throw new Error('RemeshVue plugin not installed') } return store}可以清晰的看到,React 采用了 Context 的方式注入 store,而 Vue 采用 Provide Inject 的方式注入 store,现在我们对 React 实现进行分析,来看以下 的实现export const RemeshRoot = (props: RemeshRootProps) => { // 可自定义 store const storeRef = useRef(props.store) if (!storeRef.current) { // 通常情况下会创建一个无 options 的 store storeRef.current = RemeshStore('options' in props ? props.options : {}) } const store = storeRef.current const contextValue: RemeshReactContext = useMemo(() => { return { remeshStore: store, } }, [store]) return {props.children}}可以看一下 RemeshStore 的结构,俨然是一个 store 管理工具,实时也确实如此,remesh 后续的状态查询以及更新都是基于 store 库执行。export const RemeshStore = (options?: RemeshStoreOptions) => { // ... return { name: options.name, getDomain, // 接上文,在 useRemeshDomain 中会调用此方法 igniteDomain, query: getCurrentQueryValue, send, // ... }}调用 getDomain 时会优先判断 domainAction 是否存在于缓存中,若不存在则创建,关键的来了,我们定义在 Domian 中的 impl 方法,此时会被调用。const createDomainStorage = >( domainAction: RemeshDomainAction, ): RemeshDomainStorage => { // ... // domain 上下文对象,标准化创建过程,也是可以链式操作的原因(代码已简化) const domainContext: RemeshDomainContext = { state: (options) => { return RemeshState(options) }, query: (options) => { return RemeshQuery(options) }, event: (options: Omit, 'impl'> | RemeshEventOptions) => { return RemeshEvent(options) }, command: (options) => { return RemeshCommand(options) }, effect: (effect) => { if (!currentDomainStorage.ignited) { currentDomainStorage.effectList.push(effect) } }, // ... } // domain 中 query, command, event 的具体实现 const domain = toValidRemeshDomainDefinition(domainAction.Domain.impl(domainContext, domainAction.arg)) // domain 的存贮对象 const currentDomainStorage: RemeshDomainStorage = { id: uid++, type: 'RemeshDomainStorage', Domain: domainAction.Domain, get domain() { return domain }, arg: domainAction.arg, domainContext, domainAction, effectList: [], ignited: false, } // ... return currentDomainStorage }关于 store 中 send、query 实现的基本状态更新以及事件机制,由于篇幅有限,不再展开描述,相信以上内容已经达到抛砖引玉的效果。4. 总结Remesh 采用了一种独特的方式,使 DDD 能够在前端得以应用,为了达到这一效果,开发者可能会需要适应不同的代码风格,需要熟悉领域模型的设计。由于项目处于起步阶段,目前仍在迭代中,不建议在生产环境使用,或者说 DDD 在前端的战术设计仍未有最佳实践,但 Remesh 带来的意义是非凡的,实现方式可能多样,解决的问题永远只会是同一个,我们始终在代码优化的路上艰难前行,DDD 将来未必是一条歧路。参考链接用DDD(领域驱动设计)和ADT(代数数据类型)提升代码质量(https://zhuanlan.zhihu.com/p/475789952)remesh(https://github.com/remesh-js/remesh)从MVC到DDD的架构演进(https://zhuanlan.zhihu.com/p/456915280)看「前端」,持续为你推送精选好文招贤纳士前端团队,一个年轻富有激情和创造力的前端团队,隶属于研发部,Base 在风景如画的杭州。团队现有 80 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。
|
|