“整洁架构” 和商家前端的重构之路
1. 背景
团队归属于后方业务支撑部门,组内的项目都以 pc 中后台应用为主。对比移动端应用,代码库比较庞大,业务逻辑也相对复杂。在持续的迭代过程中,我们发现当前的代码仓库仍然有不少可以优化的点:
可以减弱对 ui 框架的依赖
21 年前端平台决定技术栈统一迁移到 React 生态,后续平台的基础建设也都围绕 React 展开,这就使得商家使用 Vue 生态做开发的系统面临技术栈迁移的难题,将业务逻辑和 UI 框架节藕变得异常重要。
代码风格可以更加统一
随着代码量和团队成员的增加,应用里风格迥异的代码也越来越多。为了能够持续迅速的进行迭代,团队急需一套统一的顶层代码架构设计方案。
可以集成自动化测试用例
随着业务变得越来越复杂,在迅速的迭代过程中团队需要频繁地对功能进行回归,因此我们对于自动化单测用例的诉求也变的越来越强烈。
为了完成以上的优化,四组对现有的应用架构做了一次重构,而重构的核心就是整洁架构。
2. 整洁架构 (The Clean Architecture)
整洁架构 (The clean architecture) 是由 Robert C. Martin (Uncle Bob) 在 2012 年提出的一套代码组织的理念,其核心主要是依据各部分代码作用的不同将其拆分成不同的层次,在各层次间制定了明确的依赖原则,以达到以下目的:
- 与框架无关:无论是前端代码还是服务端代码,其逻辑本身都应该是独立的,不应该依赖于某一个第三方框架或工具库。一套独立的代码可以把第三方框架等作为工具使用。
- 可测试:代码中的业务逻辑可以在不依赖 ui、数据库、服务器的情况下进行测试
- 和 ui 无关:代码中的业务逻辑不应该和 ui 做强绑定。比如把一个 web 应用切换成桌面应用,业务逻辑不应该受到影响。
- 和数据库无关:无论数据库用的是 mysql 还是 mongodb,无论其怎么变,都不该影响到业务逻辑。
- 和外部服务无关:无论外部服务怎么变,都不影响到使用该服务的业务逻辑。
为了实现以上目的,整洁架构把应用划分成了 entities、use cases、interface adapters (MVC、MVP 等)、Web/DB 等至少四层。这套架构除了分层之外,在层与层之间还有一个非常明确的依赖关系,外层的逻辑依赖内层的逻辑。
Entity
entities 封装了企业级的业务逻辑和规则。entities 没有什么固定的形式,无论是一个对象也好,是一堆函数的集合也好,唯一的标准就是能够被企业的各个应用所复用。
Use Case
entities 封装了企业里最通用的一部分逻辑,而应用各自的业务逻辑就都封装在 use case 里面。日常开发中最常见的对于某个模型的 crud 操作就属于 usecase 这一层。
Interface Adapter
这一层类似于胶水层,需要负责内圈的 entity 和 use case 同外圈的 external interfaces 之间的数据转化。需要把外层服务的数据转化成内层 entity 和 usecase 可以消费的数据,反之亦然。如上面图上画的,这一层有时候可能很简单 (一个转化函数), 有时候可能复杂到包含一整个 MVC/MVP 的架构。
External Interfaces
我们需要依赖的外部服务,第三方框架,以及需要糊的页面 UI 都归属在这一层。这一层完全不感知内圈的任何逻辑,所以无论这一层怎么变 (ui 变化),都不应该影响到内圈的应用层逻辑 (usecase) 和企业级逻辑 (entity)。
依赖原则
在整洁架构的原始设计中,并不是强制一定只能写这么四层,根据业务的需要还可以拆分的更细。不过无论怎么拆,都需要遵守前面提到的从外至内的依赖原则。即 entity 作为企业级的通用逻辑,不能依赖任何模块。而外层的 ui 等则可以使用 usecase、entity。
3. 重构
前面介绍了当前代码库目前的一些具体问题,而整洁架构的理念正好可以帮助我们优化代码可维护性。
作为前端,我们的业务逻辑不应该依赖视图层 (ui 框架及其生态),同时应当保证业务逻辑的独立性和可复用性 (usecase & entity)。最后,作为数据驱动的端应用,要保证应用视图渲染和业务逻辑等不受数据变动的影响 (adapter & entity)。
根据以上的思考,我们对 “整洁架构” 做了如下落地。
Entities
对于前端应用来说,在 entity 层我们只需要将服务端的生数据做一层简单的抽象,生成一个贫血对象给后续的渲染和交互逻辑使用。
interface IRawOrder { amount: number barCode: string orderNo: string orderType: string skuId: number deliveryTime: number orderTime: number productImg: string status: number } export default function buildMakeOrder({ formatTimestamp, formatImageUrl, }: { formatTimestamp: (timestamp: number, format?: string) => string formatImageUrl: ( image: string, config?: { width: number; height: number }, ) => string }) { return function makeOrder(raw?: IRawOrder) { if (!raw || !raw.orderNo) { Monitor.warn('脏数据') return null; } return { amount: raw.amount, barCode: raw.barCode, orderNo: raw.orderNo, orderType: raw.orderType, skuId: raw.skuId, status: raw.status, statusDescription: selectStatusDescription(raw.status), deliveryTime: formatTimestamp(raw.deliveryTime), orderTime: formatTimestamp(raw.orderTime), productImg: formatImageUrl(raw.productImg), } } } function selectStatusDescription(status: number): string { switch (status) { case 0: return '待支付' case 1: return '待发货' case 2: return '待收货' case 3: return '已完成' default: return '' } }
以上是商家后台订单模型的 entity 工厂函数,工厂主要负责对服务端返回的生数据进行加工处理,让其满足渲染层和逻辑层的要求。除了抽象数据之外,可以看到在 entity 工厂还对数据进行了校验,将脏数据、不符合预期的数据全部处理掉或者进行兜底 (具体操作要看业务场景)。
有一点需要注意的是,在设计 entity 的时候 (尤其是基础 entity) 需要考虑复用性。举个例子,在上面 orderEntity 的基础上,我们通过简单的组合就可以生成一个虚拟商品订单 entity:
import { makeOrder } from '@/entities' export default function buildMakeVirtualOrder() { return function makeVirtualOrder(raw?: IRawPresaleOrder) { const order = makeOrder(raw) if(! order || !raw.virtualOrderType) { Monitor.warn('脏数据') return null } return { ...order, virtualOrderType: raw.virtualOrderType, virtualOrderDesc: selectVirtualOrderDesc(raw.virtualOrderType) } } }
如此一来,我们就通过 entity 层达到了两个目的:
- 把前端的逻辑和服务端接口数据隔离开,无论服务端怎么变,前端后续的渲染、业务代码不需要变,我们只需要变更 entitiy 工厂函数;并且经过 entity 层处理过后,所有流入后续渲染 & 交互逻辑的数据都是可靠的;对于部分异常数据,前端应用可以第一时间发现并报警。
- 通过对业务模型进行抽象,实现了模块间的组合、复用。另外,抽象出的 entity 对代码的维护性也有非常大的帮助,开发者可以非常直观的知道所使用的 entity 所包含的所有字段。
Usecase
usecase 这一层即是围绕 entity 展开的一系列 crud 操作,以及为了页面渲染做的一些联动 (通过 ui store 实现)。由于当前架构的原因 (没有 bff 层),usecase 还可能承担部分微服务串联的工作。
举个例子,商家后台订单页面在渲染前有一堆准备逻辑:
- 根据 route 的 query 参数以及一些商家类型参数来决定默认选中哪个 tab
- 根据是国内商家还是境外商家,调用对应的供应商接口来更新供应商下拉框
现在大致的实现是:
{ mounted() { const { subType } = this.$route.query /* 7-15行处理了几种分支链路场景下对subType的赋值问题 */ if (Number(subType) === 0 || subType) { this.subType = subType.toString() } else { if (this.user.merchant.typeId === 4) { this.subType = this.tabType.cross } else { this.subType = this.tabType.ordinarySpot } } /* getAllLogisticsCarrier有没有对subType赋值呢?光看这段代码完全不确定 */ this.getAllLogisticsCarrier() /* 21-22行又多出来一个分支需要对subType进行再次赋值 */ if (this.isPersonPermission && !this.crossUser) { this.subType = this.tabType.warehouse } }, getAllLogisticsCarrier() { let getCarrier = API.getAllLogisticsCarrier if (this.crossUser) { getCarrier = API.getOrderShipAllLogistics } getCarrier({}).then(res => { if (res.code === 200) { const options = [] .......... // 给options赋值 this.options2 = options } }) }, }
我们能看到 7-15、24-125 行对 this.subType 进行了赋值。但由于我们无法确定 20 行的函数是否也对 this.subType 进行了赋值,所以光凭 mounted 函数的代码我们并不能完全确定 subType 的值究竟是什么,需要跳转到 getAllLogisticsCarrier 函数确认。这段代码在这里已经做了简化,实际的代码像 getAllLogisticsCarrier 这样的调用还有好几个,要想搞清楚逻辑就得把所有函数全看一遍,代码的可读性一般。同时,由于函数都封装在 ui 组件里,因此要想给函数覆盖单测的话也需要一些改造。
为了解决问题,我们将这部分逻辑都拆分到 usecase 层:
// prepare-order-page.ts import { tabType } from '@/constants' interface IParams { subType?: number merchantType: number isCrossUser: boolean isPersonPermission: boolean } /* 做依赖倒置主要是为了方便后续的单测和复用 */ export default function buildPrepareOrderPage({ queryLogisticsCarriers, }: { queryLogisticsCarriers: () => Promise<{ carriers: ICarrires }> }) { return async function prepareOrderPage(params: IParams) { const activeTab = selectActiveTab(params) const { carriers } = queryLogisticsCarriers(params.isCrossUser) return { activeTab, carriers, } } } function selectActiveTab({ subType, isCrossUser, isPersonPermission, merchantType, }: IParams) { if (isPersonPermission && !isCrossUser) { return tabType.warehouse } if (Number(subType) === 0 || subType) { return subType.toString() } if (merchantType === 4) { return tabType.cross } return tabType.ordinarySpot }
// query-logistics-carriers export default function buildQueryLogisticsCarriers({ fetchAllLogisticsCarrier, fetchOrderShipAllLogistics, }: { fetchAllLogisticsCarrier: () => Promise<{ data: {carriers: ICarrires }}> fetchOrderShipAllLogistics: () => Promise<{ data: {carriers: ICarrires }}> }) { return async function queryLogisticsCarriers(isCrossUser: boolean) { if (isCrossUser) { return fetchAllLogisticsCarrier() } return fetchOrderShipAllLogistics() } } // index.vue { mounted() { const {activeTab, carriers} = prepareOrderPage(params) this.subType = activeTab; this.options = buildCarrierOptions(carriers) // 将carries转换成下拉框option } }
首先,可以看到所有 usecase 一定是一个纯函数,不会存在副作用的问题。
其次,prepareOrderPage usecase 专门为订单页定制,拆分后一眼就能看出来订单页的准备工作需要干决定选中的 tab 和拉取供应商列表两件事情。而另一个拆分出来的 queryLogisticsCarriers 则是封装了商家后台跨境、国内两种逻辑,后续无论跨境还是国内的逻辑如何变更,其影响范围被限制在了 queryLogisticsCarriers 函数,我们需要对其进行功能回归;而对于 prepareOrderPage 来说,queryLogisticsCarriers 只是 () => Promise<{ carriers: ICarrires }> 的一个实现而已,其内部调用 queryLogisticsCarriers 的逻辑完全不受影响,不需要进行回归。
最后,而由于我们做了依赖倒置,我们可以非常容易的给 usecase 覆盖单测:
import buildPrepareOrderPage from '@/utils/create-goods'; function init() { const queryLogisticsCarriers = jest.fn(); const prepareOrderPage = buildPrepareOrderPage({ queryLogisticsCarriers }); return { prepareOrderPage, queryLogisticsCarriers, }; } describe('订单页准备逻辑', () => { it('当用户是国内商家且在入仓白名单上,在打开订单页时,默认打开入仓tab', async () => { const { prepareOrderPage } = init(); const params = { merchantType: 2 isCrossUser: false isPersonPermission: true } const { activeTab } = await prepareOrderPage(params) expect(activeTab).toEqual({tabType.warehouse}); }); it('当用户是跨境商家,在打开订单页时,默认打开跨境tab', async () => { const { prepareOrderPage } = init(); const params = { merchantType: 4 isCrossUser: true isPersonPermission: true } const { activeTab } = await prepareOrderPage(params) expect(activeTab).toEqual({tabType.cross}); }); ...... });
单测除了进行功能回归之外,它的描述 (demo 里使用了 Given-When-Then 的格式,由于篇幅的原因,关于单测的细节在后续的文章再进行介绍) 对于了解代码的逻辑非常非常非常有帮助。由于单测和代码逻辑强行绑定的缘故,我们甚至可以将单测描述当成一份实时更新的业务文档。
除了方便写单测之外,在通过 usecase 拆分完成之后,ui 组件真正成为了只负责 “ui” 和监听用户交互行为的组件,这为我们后续的 React 技术栈迁移奠定了基础;通过 usecase 我们也实现了很不错的模块化,对于使用比较多的一些 entity,他的 crud 操作可以通过独立的 usecase 具备了在多个页面甚至应用间复用的能力。
Adapter
上面 usecase 例子中的 fetchAllLogisticsCarrier 就是一个 adapter,这一层起到的作用是将外部系统返回的数据转化成 entity,并以一种统一的数据格式返回回来。
这一层很核心的一点即是可以依赖 entity 的工厂函数,将接口返回的数据转化成前端自己设计的模型数据,保证流入 usecase 和 ui 层的数据都是经过处理的 “干净数据”。除此之外,通常在这一层我们会用一种固定的数据格式返回数据,比如例子中的 {success: boolean, data?: any}。这样做主要是为了抹平对接多个系统带来的差异性,同时减少多人协作时的沟通成本。
type Request = (url: string, params: Record<string, any>) => Promise<any>; import makeCarrier from '@/entities/makeCarrier' export default function buildFetchAllLogisticsCarrier({request}: {request: Request}) { return async function fetchAllLogisticsCarrier() { // TODO: 异常处理 const response = await request('/fakeapi', info) if (!response || !resposne.code === 200) { return { success: false } } return { success: true, data: { carriers: response.list?.map(makeCarrier) } } } }
通过 Adapter + entity 的组合,我们基本形成了前端应用和后端服务之间的防腐层,使得前端可以在完全不清楚接口定义的情况下完成 ui 渲染、usecase 等逻辑的开发。在服务端产出定义后,前端只需要将实际接口返回适配到自己定义的模型 (通过 entity) 即可。这一点对前端的测试周提效非常非常非常重要,因为防腐层的存在,我们可以在测试周完成需求评审之后根据 prd 的内容设计出业务模型,并以此完成需求开发,在真正进入研发周后只需要和服务端对接完成 adapter 这一层的适配即可。
在实践过程中,我们发现在对接同一个系统的时候 (对商家来说就是 stark 服务) 各个 adapter 对于异常的处理几乎一模一样 (上述的 11-15 行),我们可以通过 Proxy 对其进行抽离实现复用。当然,后续我们也完全有机会根据接口定义来自动生成 adapter。
UI
在经过前面的拆分之后,无论咱们的 UI 层用 React 还是 Vue 来写,要做的工作都很简单了:
- 监听交互事件并调用对应的 usecase 来进行响应
- 通过 usecase 来获取 entity 数据进行渲染
由于 entity 已经做了过滤和适配处理,所以在 ui 层我们可以放心大胆的用,不需要再写一堆莫名其妙的判断逻辑。另外由于 entity 是由前端自己定义的模型,无论开发过程中服务端接口怎么变,受影响的都只有 entity 工厂函数,ui 层不会受到影响。
最后,在 ui 层我们还剩下令人头痛的技术栈迁移问题。整个团队目前使用 vue 的项目有 10 个,按迭代频率和项目规模迁移的方案可以分为两类:
- 迭代频繁的大应用:主要包括代码行数较多、逻辑较为复杂的几个中大型应用。这些应用想要一把梭直接完成迁移成本极高,但同时每个迭代又有相当的需求。基于这种情况,对于这三个应用我们采取了微前端的方式进行迁移。每个应用分别起一个对应的 React 应用,对于新页面以及部分逻辑已经完全和 ui 解藕迁移成本不高的业务,都由 React 应用来承接,最后通过 module federation 的方式实现融合。
- 迭代不频繁的小应用:剩下的应用均是复杂度不高的小应用,这部分应用迭代的需求不多,以维护为主。因此我们的方案是对现有逻辑进行整洁架构重构,在 ui 和逻辑分层之后直接对 ui 层进行替换完成迁移。
4. 后续
通过整洁架构我们形成了统一的编码规范,在前端应用标准化的道路上迈下了坚实的一步。可以预见的是整个标准化的过程会非常漫长,我们会陆续往标准中增加新的规范使其更加完善,短期内在规划中的有:
- 单测即文档:上面提到了 usecase 通过依赖倒置来配合单测落地,后续团队期望将一些业务逻辑的实现细则通过单测的描述来进行沉淀,解决业务文档实时性的问题。
- 完善监控体系:前端常遇到的 3 种异常包括 代码逻辑异常、性能瓶颈 (渲染卡顿、内存不足等)、数据导致异常。对于数据异常,我们可以在 entity 层映射的过程中加入对异常数据的埋点上报来填补目前监控的空白。(代码逻辑异常通过 sentry 已经监控,性能监控对于中后台应用不需要)
- 推荐12个开源的跨平台桌面项目
- 为什么kafka性能下降这么快,我用RocketMQ的时候不会这样子
- iOS16新功能被曝耗电严重,暂时别用了!
- 我所知道的webpack5那些不太一样的改变
- 谈谈你对Kafka副本Leader选举原理的理解?
- 这样Debug,排查问题效率大大提升
- 将你的 Python 脚本转换为命令行程序
- MyBatis批量插入几千条数据慎用foreach
- 低代码平台能提升开发效率吗?
- 自定义的配置文件,如何注入到SpringBoot?
- 一个能干掉90%候选人的Kafka面试连环炮
- ThreadLocal 你真的用不上吗?
- Java中容器设计的进化史:从白盒到黑盒,再到跻身为设计模式之一的迭代器
- 浅谈自旋锁和JVM对锁的优化
- 手把手教你实现一个常用的 antd form 组件
- .NET开发框架优缺点剖析
- Python中的Deque: 实现高效的队列和堆栈
- 信息架构如何设计?5000字干货帮你快速掌握!
- C 进阶教程:C 标准模板库初学者指南
- 导致Spring事务失效的原因有哪些?