领域驱动设计(DDD)能给前端带来什么?
为什么需要DDD?
软件发展过程:频繁变更带来软件质量的下降
而这又是软件发展的规律导致的:
- 软件是对真实世界的模拟,真实世界往往十分复杂
- 人在认识真实世界的时候总有一个从简单到复杂的过程
- 因此需求的变更是一种必然,并且总是由简单到复杂演变
- 软件初期的业务逻辑非常清晰明了,慢慢变得越来越复杂
可以看到需求的不断变更和迭代导致了项目变得越来越复杂,那么问题来了,项目复杂性提高的根本原因是需求变更引起的吗?
那么在需求变更的过程中如何进行解耦和扩展呢?DDD发挥作用的时候来了。
什么是DDD?
数据驱动
有了业务需求,创建数据库表,然后编写业务逻辑。数据驱动以数据库为中心,其中最重要的设计是数据模型,但随着业务的增长和项目的推进,软件开发和维护的难度会急剧增加。
以客户关系管理(Customer Relationship Management,CRM)为例,其中很重要的概念有销售、机会、客户、私海、公海,实体的定义分别如下。
- 销售(Sales):公司的销售人员,一个销售可以拥有多个销售机会。
- 机会(Opportunity):销售机会,每个机会包含至少一个客户信息,且归属于一个销售人员。
- 客户(Customer):客户,也就是销售的对象。
- 私海(Private sea):专属于某个销售人员的领地(Territory),私海里面的客户,其他销售人员不能触碰。
- 公海(Public sea):公共的领地,所有销售人员都可以从公海里捡入客户到其私海。
按照我们曾经学习的数据库建模理论,对于上面的场景,不难画出图7-2所示的实体联系(Entity Relationship,ER)图。
可以看到,上图所示的ER图中不存在公海和私海,因为所谓的机会在私海,就是这个机会是不是归属某个销售,这样我们只需要看机会上是否有salesId。如果有,说明机会被某个销售占有,也就是在私海中;反之,这个机会就在公海中。
在这种开发模式下,最后的产出是几张数据库表,以及针对表中数据进行操作的事务脚本。
领域驱动
领域驱动设计(domin-driven design)不同于传统的针对数据库表结构的设计,领域模型驱动设计自然是以提炼和转换业务需求中的领域知识为设计的起点。在提炼领域知识时,没有数据库的概念,亦没有服务的概念,一切围绕着业务需求而来,即:
- 现实世界有什么事物 -> 模型中就有什么对象
- 现实世界有什么行为 -> 模型中就有什么方法
- 现实世界有什么关系 -> 模型中就有什么关联
在DDD中按照什么样的原则进行领域建模呢?
单一职责原则(Single responsibility principle)即SRP:软件系统中每个元素只完成自己职责内的事,将其他的事交给别人去做。
研发过程如下图所示。领域模型对应的是业务实体,在程序中主要表现为类、聚合根和值对象,它更加关注业务语义的显性化表达,而不是数据的存储和数据之间的关系。这是“领域驱动设计”和“数据驱动设计”之间显著的区别。
仍以上面的CRM为例。假如我们先不考虑数据模型,而是采用面向对象分析(Object Oriented Analysis,OOA)对这个场景进行领域建模,那么可以得到图7-5所示的领域模型。
可以看到,在上图中,领域模型的描述更加贴近业务,一些重要的业务术语和概念没有丢失,更完整地表达了业务语义。即使是产品经理或者业务人员,也不难看懂这样的领域模型,甚至他们可以和技术人员一起参与到梳理领域模型和创建活动中来。
通过DDD的战略设计和战术设计,我们可以为问题域划分出合适的子域,并对域中的业务进行建模。下图所示是我们在实际工作中为CRM进行的领域战略设计。
如何进行DDD?
1 建立统一语言
统一语言是提炼领域知识的产出物,获得统一语言就是需求分析的过程,也是团队中各个角色就系统目标、范围与具体功能达成一致的过程。
使用统一语言可以帮助我们将参与讨论的客户、领域专家与开发团队拉到同一个维度空间进行讨论,若没有达成这种一致性,那就是鸡同鸭讲,毫无沟通效率,相反还可能造成误解。因此,在沟通需求时,团队中的每个人都应使用统一语言进行交流。
一旦确定了统一语言,无论是与领域专家的讨论,还是最终的实现代码,都可以通过使用相同的术语,清晰准确地定义领域知识。重要的是,当我们建立了符合整个团队皆认同的一套统一语言后,就可以在此基础上寻找正确的领域概念,为建立领域模型提供重要参考。
举个例子,不同玩家对于英雄联盟(league of legends)的称呼不尽相同;国外玩家一般叫“League”,国内玩家有的称呼“撸啊撸”,有的称呼“LOL”等等。那么如果要开发相关产品,开发人员和客户首先需要统一对“英雄联盟”的语言模型。
2 事件风暴(Event Storming)
事件风暴会议是一种基于工作坊的实践方法,它可以快速发现业务领域中正在发生的事件,指导领域建模及程序开发。它是Alberto Brandolini发明的一种领域驱动设计实践方法,被广泛应用于业务流程建模和需求工程,基本思想是将软件开发人员和领域专家聚集在一起,相互学习,类似头脑风暴。
会议一般以探讨领域事件开始,从前向后梳理,以确保所有的领域事件都能被覆盖。
什么是领域事件呢?
领域事件是领域模型中非常重要的一部分,用来表示领域中发生的事件。一个领域事件将导致进一步的业务操作,在实现业务解耦的同时,还有助于形成完整的业务闭环。
3 进行领域建模,将各个模型分配到各个限界上下文中,构建上下文地图
领域建模时,我们会根据场景分析过程中产生的领域对象,比如命令、事件等之间关系,找出产生命令的实体,分析实体之间的依赖关系组成聚合,为聚合划定限界上下文,建立领域模型以及模型之间的依赖。
DDD能给前端项目带来什么
ddd首要的核心是一个项目要形成客户、产品、开发、测试各方一致的通用语言,用来描述业务。也就是说要有一套业务术语体系,这套术语名词要应用于产品需求沟通,功能描述,测试编写,函数和变量命名等所有相关环节。这个事情是项目整体层面的,前端只是其中一小部分,也就是说前端要使用ddd所形成的通用术语体系。
通过领域模型 (feature)组织项目结构,降低耦合度
很多通过react脚手架生成的项目组织结构是这样的:
-components
component1
component2
-actions.ts
...allActions
-reducers.ts
...allReducers
这种代码组织方式,比如actions.ts 中的 actions 其实没有功能逻辑关系;当增加新的功能的时候,只是机械的往每个文件夹中加入对应的component,action,reducer,而没有关心他们功能上的关系。那么这种项目的演进方向就是:
项目初期:规模小,模块关系清晰 —> 迭代期:加入新的功能和其他元素 —> 项目收尾:文件结构,模块依赖错综复杂。
因此我们可以通过领域模型的方式来组织代码,降低耦合度。
1. 首先从功能角度对项目进行拆分。
将业务逻辑拆分成高内聚松耦合的模块。从而对 feature 进行新增,重构,删除,重命名等变得简单 ,不会影响到其他的feature,使项目可扩展和可维护。
2. 再从技术角度进行拆分
可以看到componet, routing,reducer 都来自等多个功能模块
- 技术上的代码按照功能的方式组织在feature下面,而不是单纯通过技术角度进行区分。
- 通常是由一个文件来管理所有的路由,随着项目的迭代,这个路由文件也会变得复杂。那么可以把路由分散在feature中,由每个feature 来管理自己的路由。
通过feature来组织代码结构的好处是:当项目的功能越来越多时,整体复杂度不会指数级上升,而是始终保持在可控的范围之内,保持可扩展,可维护。
如何组织 componet,action,reducer?
文件夹结构该如何设计?
- 按feature组织组件,action 和 reducer
- 组件和样式文件在同一级
- Redux放在单独的文件
1. 每个feature下面分为 redux文件夹 和 组件文件
2. redux文件夹下面的 action.js 只是充当loader的作用,负责将各个action引入,而没有具体的逻辑。reducer 同理
3. 项目的根节点还需要一个 root loader 来加载 feature 下的资源
如何组织 router
组织router的核心思想是把每个路由配置分发到每个 feature 自己的路由表中,那么需要:
- List item
- 每个feature都有自己专属的路由配置
- 顶层路由(页面级别的路由)通过JSON配置,然后解析JSON到React Router
1.每个feature有自己的路由配置
2.顶层的routerConfig引入各个feature的子路由
import { App } from '../features/home';
import { PageNotFound } from '../features/common';
import homeRoute from '../features/home/route';
import commonRoute from '../features/common/route';
import examplesRoute from '../features/examples/route';
const childRoutes = [
homeRoute,
commonRoute,
examplesRoute,
];
const routes = [{
path: '/',
componet: App,
childRoutes: [
... childRoutes,
{ path:'*', name: 'Page not found', component: PageNotFound },
].filter( r => r.componet || (r.childRoutes && r.childRoutes.length > 0))
}]
export default routes
3.解析JSON 路由到 React Router
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import routeConfig from './common/routeConfig';
function renderRouteConfig(routes, path) {
const children = [] // children component list
const renderRoute = (item, routeContextPath) => {
let newContextPath;
if (/^\//.test(item.path)) {
newContextPath = item.path;
} else {
newContextPath = `${routeContextPath}/${item.path}`;
}
newContextPath = newContextPath.replace(/\/+/g, '/');
if (item.component && item.childRoutes) {
const childRoutes = renderRouteConfigV3(item.childRoutes, newContextPath);
children.push(
<Route
key={newContextPath}
render={props => <item.component {...props}>{childRoutes}</item.component>}
path={newContextPath}
/>,
);
} else if (item.component) {
children.push(
<Route key={newContextPath} component={item.component} path={newContextPath} exact />,
);
} else if (item.childRoutes) {
item.childRoutes.forEach(r => renderRoute(r, newContextPath));
}
};
routes.forEach(item => renderRoute(item,path))
return <Switch>children</Switch>
}
function Root() {
const children = renderRouteConfig(routeConfig, '/');
return (
<ConnectedRouter>{children}</ConnectedRouter>
);
}