什么是 DDD
领域驱动设计(Domain-Driven Design,简称 DDD)是一种面向对象软件设计方法,其目的是将软件系统的核心业务领域(Domain)抽象出来,并以此为基础进行设计和实现。
领域驱动设计的核心思想是将领域模型作为软件设计的中心,通过对领域模型的深入理解和设计,提高软件系统的可维护性、可扩展性和可重用性。领域模型是描述业务领域中重要概念、实体、关系和操作的一组对象和方法的抽象表示
DDD 主要解决什么问题
DDD 旨在解决业务逻辑的复杂性,而业务逻辑大部分场景下是不存在于前端。业务逻辑往往包含大量的业务规则和约束。这些业务规则通常是在后端实现的,因为后端需要处理数据的验证、处理、计算和存储等
DDD 适用于前端吗
首先上面提到了 DDD 主要解决的是复杂业务场景逻辑问题,那么 DDD 是否适用于前端的一个核心要素就在于:复杂的核心业务逻辑是否存在前端?
我认为是大部分情况下复杂的业务逻辑是不在前端的,也就是说 DDD大部分情况下是不适合前端业务的
因为业务逻辑是高层级的策略,其他所有东西都依赖于它。此外,一般来说我们需要保证业务的稳定性、可靠性、可扩展性、可维护性。如果将业务逻辑放在前端,可能会导致多端之间的数据不一致或者逻辑不同步,这可能会对用户体验和软件的可靠性造成影响
然而,我们也不能完全排除在前端使用 DDD 的可能性。在一些复杂的应用中,前端可能需要处理一些业务逻辑,比如业务表单校验规则、权限控制规则等。在这种情况下,DDD 的一些思想和方法可能有助于组织前端代码,使其更易于理解和维护
前端是低层级细节
就电商系统软件架构而言,前端通常被视为一个低层级的细节,相对而言较易变。因此,前端在采用新的技术栈时相对容易废弃原有技术体系(比如我们商家端的业务从低代码语言转换成 Pro-Code),而不是为一个新的后端语言废弃原有的后端,在这里起作用的因素是稳定性和易变性。
什么是细节
细节指的是如何实现原则,也就是执行原则的方式,细节是原则的实现。要确定你正在编写的代码是原则还是细节的一种简单方法是问下自己:这段代码是否是强制执行有关我的业务领域中规则的实现,还是只是使一些事情得以执行?
什么是策略
策略是指我们正在编写的代码应该遵循什么样的规则和原则。主要涉及在我们编写代码的领域中存在的业务逻辑、规则和抽象概念。
高层级策略
高层策略(high-level policy)通常指的是在应用程序中贯穿各个模块和组件的核心业务逻辑和规则,这些逻辑和规则是应用程序的核心价值所在,而且通常是不会轻易改变的。
比如,在一个电商平台中,核心的高层策略可能包括如何处理订单流程、如何计算商品最终价格、如何管理库存等等。这些规则是与具体实现无关的,而且可能需要与其他模块进行协作来实现。
将高层策略放在后端,可以确保这些规则得到了保护和统一的执行,而且可以通过后端提供的接口和服务来保证数据和逻辑的一致性。与此同时,前端可以专注于展示和交互层面的处理,将高层策略与具体实现分离开来,使得应用程序更容易维护和扩展。
策略和细节的关系图
对应到前端的策略和细节的一个结构图如下所示:
在软件架构中,我们可以将其分为两个层次(领域和基础)
在领域层中,我们拥有所有重要的东西:实体、业务逻辑、规则和事件。这是我们软件中不可替代的部分,无法简单地使用另一个库或框架替代。
而在基础层中,则包含了所有用于执行领域层代码的实际实现。
前端很难具备稳定性
从上文中我们可以知道领域层是具备了最高级别的稳定性和策略,这是因为领域层包含了能够贴切描述你的应用系统业务逻辑和运行方式的领域模型代码,通常来说当前业务模型不会发生重大的变化,这意味着描述这层业务的领域层代码也不需要进行大的变化,所以一般来说领域层是稳定性最高的。
依据稳定依赖原则,稳定的模块是我们可以依赖的,将不易变的模块组织成依赖于稳定模块的结构是有意义的,但永远不要让稳定模块依赖于不稳定的模块
然而,UI 层的复用性通常较差。前端 UI 需要在多样化的设计稿中进行开发,导致代码差异化无法收敛。不同的用户心智、设计语言、业务背景、以及业务服务,都会对前端 UI 逻辑造成非常大的影响。举个例子,不同业务线的后端服务请求响应数据结构差异化可能直接导致数据处理逻辑无法复用。在一些 C 端场景中,这种情况尤为突出,比如电商、社交等。
针对面向 B 端的前端,目前业界已经有了一些常用的组件库,例如 Antd、Fusion、MerlionUI 等等。这些组件库已经具备了高稳定性,即它们已经定义了 B 端前端的基础组件标准和基础层,大部分情况下不会进行大的变更。然而,由于 B 端业务场景的差异性,前端在 UI 层上仍需要大量的业务组件和视图层的工作量。例如,在电商网站下单的订单模型中,面向买家用户时展示的是以买家用户为中心的订单处理状态和履约进度信息,而在面向卖家用户时则需要展示对这笔订单的状态流转的标准操作流程。
前端很难复合开闭原则
通常而言当需要更改某个功能时,前端开发人员通常需要直接修改代码,而不是添加新的功能或模块。假设我们在开发一个商品详情组件,可能需要展示商品的名称、价格、描述、图片、评论等信息。这些信息是所有商品都需要展示的,所以可以将它们定义为稳定层的核心规则逻辑。
但是,在不同的业务场景中,可能需要对商品信息页面进行一些定制化的展示,比如在大促活动期间需要展示大促标签和氛围图,或者在跨境电商业务中需要展示关税和物流信息,再或者当我们商品详情展示在不同国家和地区的时候商品名称和价格的位置会发生变化,而这些业务规则属于易变的低层级细节,但是往往在业务量比较小、低层级的业务规则没法隐藏到稳定层的时候,这部分工作量往往就会落在前端身上,最后前端视图层和业务组件层会有大量的业务规则逻辑判断。通常而言这种方式违反了开闭原则,因为它需要修改现有的代码来实现新的功能,而不是扩展功能模块,这就是因为我们将所有高层级策略放在后端并确保前端不包含高层级策略时所做的工作。
在这种情况下,前端可以通过配置文件或者运营控制台等方式来配置这些低层级细节规则,而稳定层的商品信息组件可以通过这些配置来实现不同的业务场景的展示需求。
前端业务复杂度主要在哪
前端业务复杂度主要包括但不限于技术栈的复杂度、业务逻辑的复杂度、UI 交互的复杂度等。
技术栈的复杂度在哪
通常而言我们所说的前端技术栈泛指:Vue、React、Angular、JQuery 等基于 MVVM、操作 DOM 的技术栈。
为什么这么说?因为前端框架其实本质上是高策略层级的,每个前端框架的一般都是来解决以下问题:
- 定义状态 (data、state)
- 状态变化检测 (Object.defineProperty、Proxy、React reconcilliation)
- 对状态更改做出反应 (Observable、hooks、单向数据流)
所以当你选择好一个框架之后,其实你就已经是在这个高层级策略下面执行低层级细节的编码。举个例子 Vue 和 React 实现状态变化检测和 Reactive 的策略是不一样的,对于开发者而言在这个策略下的实际编码思想也是差异巨大的,Vue 是基于 Proxy 来做双向绑定,而 React 是基于调度更新算法来更新 vdom 树
React 带给我们的编程范式是函数式编程*(函数式编程 Functional Programming 是一种编程范式,它的核心思想是使用纯函数来进行编程),当我们选择了 React 这套 UI 框架和生态之后,我们天然写出来的代码就是基于函数式的。为什么是函数式的?背后的原因实际上是因为 React 原生的响应方案,也就是监测变量引用(reference)的变化,然后整个子树去协调更新。
函数式编程具备几个特点*:纯函数、不可变性、函数组合。 React 响应方案因为要保证在输入(props)是一致的情况下,输出(vdom)的结果也是一致的。所以我们对 React 状态逻辑的封装大部分也需要满足这个特性,这也是为什么我们在组件内部要通过 setState()
而不是 state.xxx
来变更状态,这也就是我们通常所说的**状态不可变性。**
另外函数式编程又帮我们解决了组件的之前的组合问题,一般来讲我们基于 React 来开发页面的模式一般是:Page = Compose(ComponentA + ComponentB + Fusion/Antd)
而 Component = Compose(Fusion/Antd + React hooks + Events + State)
而这种组合的特性在业务层如果没有一个比较好的组件依赖原则的话,会导致组件之间耦合比较严重,又因为组件内部的复杂度也是 compose 的各种“组件”,所以当系统内的各种"组件"的依赖关系越来越复杂的时候,甚至“组件”之间的依赖出现环的时候,业务系统的复杂度就跟着线性递增了
总结一下在业务前端应用中技术栈的复杂度主要体现在以下方面:
- 组件和模块的组织:在组件化和模块化设计中,如何组织组件和模块,使得它们的依赖关系合理、清晰,以保证代码的可维护性和可扩展性。
- 状态逻辑的组织和管理:在大型前端应用中,状态管理是一个重要的问题。状态管理需要考虑状态的一致性和可变性,以及如何处理状态的变化。
- 异步数据处理:现代前端应用需要处理大量的异步数据请求和处理。异步数据处理需要考虑异步数据的请求和响应、数据缓存、数据更新和状态管理等问题。
业务逻辑的复杂度
业务逻辑的复杂度通常来自于业务需求本身,例如业务规则、流程、数据处理等。业务逻辑的复杂度可能因业务领域的不同而有所不同,例如电商、本地生活、直播等领域都有各自的业务逻辑和复杂性。
在前端开发中,业务逻辑复杂度可能表现为需要进行大量的数据处理、业务规则的验证、复杂的页面流程设计等。在前端中,如果没有一个清晰的业务逻辑划分和抽象,代码可能会变得非常复杂,难以维护和扩展。因此,在前端开发中,对于业务逻辑的划分和抽象非常重要,这样才能更好地应对业务逻辑的变化和复杂性。
UI 交互的复杂度
UI 交互的复杂度主要在于如何实现复杂的交互逻辑和动画效果,以及如何处理用户的输入和反馈。具体来说,UI 交互的复杂度主要体现在:用户体验设计、跨端兼容性、性能优化
怎么降低前端业务复杂度
这里我们主要重点关注怎么降低技术栈和业务逻辑的复杂性带来的复杂度。
文章的开头我们讲了,领域驱动设计的主要目的是为了解决业务逻辑的复杂性。 领域驱动设计的核心思想是将领域模型作为我们业务架构设计的中心, 实际上来说我们只是需要借助领域驱动设计的一些思想在前端业务开发中进行实践,前面提到在我们前端业务工程会出现大量不满足组件构建的无依赖环原则,这里主要的原因是因为前端开发者在以UI 层为中心进行开发,而如果我们切换视角以 ViewModel、Model 为中心来构建我们的前端业务的话,整体系统设计思路会发生变化
领域模型
大部分前端代码与实际业务领域无关,这部分前端主要专注在表单验证、API 请求、事件响应、列表渲染等等,然而也有部分业务的前端也确确实实跟业务领域相关,领域业务模型也确实会影响到 UI 层。领域模型通常是一组具有业务含义的类或者对象,它们通过方法、属性等方式封装了系统的关键业务逻辑。无论如何,只要它能被系统中的其他不同应用复用就可以。
通常来说我们可以通过手动创建或者抽象工厂方法构造出模型数据,把这些数据响应式地映射到视图层,再根据视图层触发的事件调用模型层里的函数或方法来更新模型层数据。
状态管理
状态管理主要分两个部分:一个是管理对业务模型层的可变状态和不可变状态,一个管理视图层的可变状态和不可变状态。
视图层上有些状态不是从模型层数据里来的,是纯粹的页面状态,比如数据正在加载的标志、下拉框的联动,等等,这些和模型层无关,且随着需求的变化而动态变化。
在基于 React 渲染方案中,我们既可以利用 React 原生的响应方案也可以借助三方库(mobx)的方案来实现这部分状态管理,选择的方案不同可能会带来在编程范式的差异(FP、OOP)
视图层
视图层是最不稳定的一层,UI 组件的实现通常受到业务需求的影响,随着需求的变化,UI 组件的实现也需要进行相应的调整和变更。同时,为了保持软件的稳定性和可维护性,需要遵循稳定依赖原则,确保其他基础组件不会依赖于视图层。
基于 React 的视图层又会有伴随着事件响应、生命周期等等副作用。Hooks 实际上只是视图层的东西,背后都是依赖于 React 的响应原理,因此,在我看来,Hooks 会通过合并同类项进入视图层。
架构分层
首先,在互联网行业中,很难一开始就完成完美的系统设计。相反,系统往往需要逐步发展,通过不断迭代和引入新的功能模块来逐步成型。对于现有系统,通过一次大规模的重构难以解决所有问题。好的系统设计需要不断投入工作并逐步积累细节,最终才能获得完善的系统。因此,在日常工作中需要高度重视设计和细节改进。
其次,专业化分工和代码复用是提高软件生产率的重要手段,此外,同一领域服务可以支持不同的上层应用逻辑。这种分工和复用背后的思想是将系统分为多个水平层,并明确定义每个层的角色和任务,以降低单个层的复杂性。同时,每个层只需向相邻层提供一致的接口,可以使用不同的方法进行实现,这为软件重用提供了支持。因此,分层是解决复杂性问题的重要原则。
方案一
在这个方案中我们视图层跟模型层的之间的接口是通过 ViewModel 来进行管理,视图层依赖 Hooks 和 ViewModel 来进行生命周期、事件、响应方案, 而 ViewModel 中既包含当前视图层自身的状态管理也耦合领域服务和领域模型,这个架构设计中不稳定层包含:View、Hooks、Lifecycle、ViewModel,而稳定层包含:Service、Repository、Model。
方案二
在这个方案中我们视图层跟模型层的之间是直接依赖关系,视图层直接依赖 Hooks、Model、State。这个架构设计中不稳定层包含:View、Hooks、Lifecycle、State、Model,而稳定层包含:Service、Repository。
文件目录设计
我们根据上述结构图的分层思想,在实际项目中定义了以下的文件目录:
├── shared │ ├── components // 公用基础组件, 组件之间不能互相耦合 │ ├── constants // 全局变量 │ │ ├── page.ts │ ├── domains // 领域层 │ │ ├── page │ │ │ ├── page.model.ts // 实体 │ │ │ └── page.service.ts // 领域Service服务 │ │ ├── ... │ └── util // 公用函数 │ └── http.ts ├── components // 公共业务组件,业务组件之间不能互相耦合,但是可以依赖公共基础组件 ├── modules // 模块视图,模块可以是compose(公共业务组件, 公共基础组件) └── page // 页面视图层 ├── index │ ├── index.tsx │ ├── components ├── ...
常见问题
问题一: Modules 跟 Components 的区分实际上过于理想化,正常业务开发,很可能不好判断我当前这个组件是要放 Modules 还是 Components 里面,甚至使用者都会疑惑我到底是要去 Modules 里面去找还是去 Components 去找
Module = compose(ComponentA + ComponentB + ComponentC)。如果 ModuleA 只是一个特殊的 ComponentA, 就放到 component 里面,模块里面不耦合太多业务逻辑,纯粹的 View 层的 compose。只是这个模块需要被多个页面引用,比如: PageA = compose(ModuleA + ComponentD + PageA logic ) PageB = compose(ModuleA + ComponentC + ComponentB + Hooks )
问题二:那你的 Components 的颗粒度到底是多细呢?我的 Components 里面的 Component 能引用其他的 Component 吗?
不行,关注点分离,架构分层,就是要让依赖树足够清晰,Component 可以依赖 shared/components 也可以依赖 Fusion + MerlionUI,但是 components 之间最好不要互相耦合
在具体选择方案时,需要考虑业务场景的差异,简单的业务属性要警惕把问题复杂化,警惕过度设计,复杂的业务要全面评估和判断好方案,选择适合自己的设计方案。
另外两种设计方案中不稳定层并不意味着这是一个强耦合层,不稳定只是代表这一层中的结构会随着业务的变更而频繁变更,我们需要根据业务场景来判断哪些部分需要转化为稳定层,并确保依赖关系结构的清晰和整洁。
总结
根据上文推导过程可知,如果我们要在前端业务工程上深度应用领域驱动设计的思想来实践最好需要几个前提
- 前提一:开发者需要站在领域模型层为中心的视角来进行系统设计
- 前提二:开发者需要对业务领域模型足够理解,前后端的业务领域模型要对齐
- 前提三:后端能提供业务领域的标准化 CRUD 接口
写在最后:虽然技术在软件开发中扮演了重要的角色,但任何技术都不是银弹,作为工程师、架构师,我们需要对技术选型、架构设计、系统设计进行深思熟虑,并进行全面的评估和判断。