公司的核心小程序项目经过过去将近 1 年半时间的发展,业务流程已经基本完成了从 0 到 1 的建设。于此同时由于前期业务发展很快,开发时间紧,任务重,很多代码在没有经过前期思考就写入了代码库中,造成当前项目代码逐渐腐化。目前该业务正处于从 1 ~ n 发展的阶段,业务已经从探索模式转换到了打造精品的阶段,这个阶段不可避免的有大量基于用户建议而做出产品交互的调整需求,对前端的挑战很大,但是由于代码库已经逐渐腐化,维护和开发成本较前期急剧上升,已经开始对业务产生了一定的影响。为了解决上述问题,提升后续的开发和维护效率,我们去年下半年开始尝试对系统进行业务架构升级,通过大半年的摸索和实践,逐步摸索出一套适用于团队业务的架构方案,下面来给大家详细介绍一下:
当前问题分析
所有的方案的产出都需要贴合实际场景,那在给出具体的方案之前,我们先分析一下到底当前的系统有哪些问题?这些问题又是怎么增加我们代码开发和维护成本的?
在经过一段时间的细致分析后,我们发现主要有以下这 4 个问题
(1)页面中 UI 逻辑,业务逻辑和请求逻辑都维护在一起,耦合度高
以上是项目开发初期我们设计的架构方式,当时该项目处于探索阶段,在设计的初期也没有过多去考虑后期维护成本的问题,所以就采用了基于组件的架构方式进行项目的开发,虽然在开发过程中也抽象了不少通用的业务组件,但是架不住业务的迅速发展,快速的添加,修改功能,修复 bug,最终导致页面的整体逻辑非常复杂,即包含了处理业务逻辑的代码,又包含了请求接口的代码,同时呢,交互和展示的逻辑判断也参杂在其中,整个页面代码逻辑耦合度非常高。一旦涉及到一个功能的调整,就算很简单的一个 UI 逻辑修改,首先都需要深入到页面的源码中去寻找相关的逻辑,做对应的修改,同时又因为各种逻辑都维护在一个文件里,这些小小的修改也有可能对系统的其他部分产生一定的影响。
我们来看看下面出自实际的业务中的伪代码示例:
export default class MedicationList extends BaseComponent {
data = {
bizParamA,
bizParamB,
bizParamC,
calendarShow,
uiList
},
// 接口请求
async fetchXXXList (param) {
const resp = await apiYYY.fetchXXX(param)
if (resp.success) {
this.setData({
uiList: formatXXXData(resp.data)
})
}
},
// 交互逻辑
closeCalendarModal () {
this.setData({
calendarShow: false
})
},
// 包含交互和业务逻辑
async confirmCalendar (date) {
try {
// 转换业务数据
let startDate = formatDate(date, 'yyyy-MM-dd')
// 获取相关业务参数
const { bizParamA, bizParamB, bizParamC } = this.data
// 调接口,更新状态
const resp = await apiXXX.updateXXX({bizParamA, bizParamB})
if (resp.success) {
// 调接口,获取数据
this.fetchXXXList(bizParamC)
// 处理 UI 逻辑
this.closeCalendarModal()
// 跳转新页面
wx.navigateTo({
url: 'xxxxxx'
})
}
} catch (e) {
// 上报
}
})
},
...
...
可以看到,这里也就仅仅展示了一个页面的部分逻辑,就让代码开始变的有一些混乱了,如果你是一个不熟悉这段代码的开发者,你要搞清楚这些字段的意思可能就需要通读大部分代码,还不用说这些字段之间的关联,方法的含义,方法的关联等。另外随着页面越来越大,这些交叉的逻辑之间的关系会变的越来越复杂,当整个业务像我们这样持续增长个 1,2 年后,你就会发现这个页面的代码已经变的面目全非了,这个时候哪怕一个最小的改动,都会影响整个业务系统的稳定。
(2)后端接口频繁变化
同样的,随着业务迅速的发展,后端服务也不可避免的处在不断的更新和重构之中,这就会导致一些后端接口经常会发生变化,包括但不限于返回字段的变化,数据结构的变化等。在这个情况下,由于在当前的项目中接口请求的逻辑和其他逻辑是混在一起的,在复杂的页面中一旦需要调整某个接口数据,可能就会牵扯到其他很多相关的代码逻辑。同时因为一个接口可能会被多个模块同时适用,一旦发生接口发生调整,就需要调整多个地方,也很容易发生忘记调整的情况,造成对业务的影响。
(3)同一个接口调用在多个业务模块重复编写
后端的同一个接口可能会存在被多个前端页面同时需要的情况,之前团队的开发模式一般是在各自负责的模块下来去重新写一份调用该接口的代码。这样进行处理虽然在一定程度上保障了其他页面的稳定性,但是其带来的其他影响则会比较严重,如造成代码重复,这会让其他维护者觉得很懵逼,另外就像上一个问题中说的,如果后端接口一旦发生变化,那这几个同时依赖该接口的模块就都需要统一进行处理,牵扯的影响面较大。
(4)前端同事往往只关心交互逻辑,会忽略整体业务逻辑
最后一个问题是关于开发思维的问题,在过去很长一段时间的开发中,前端同事往往重视的是页面交互的实现,认为交互就是前端的业务逻辑,如果这样想的话往往就会忽略对真实业务逻辑的理解,当大家关心的仅仅是自己开发和维护的那几个页面,不可避免的就会出现很多重复的代码,也就很自然会出现上面说的各种逻辑混在一起的情况,如果这时恰巧团队中有人员变动,新来的同事在处理相关页面的问题时因为不熟悉对方的代码,又没人去问,害怕出错就直接在代码上加上定制逻辑,久而久之,系统就在这样一个循环里快速走向“腐化”,对系统后期维护造成很大的麻烦。
那明确了上面这些造成代码维护难的问题,我们接下来针对这些问题给出相应的解决方案。
架构演进1 - 抽离单独 store 层
首先,为了解决页面内部逻辑多,耦合严重的问题,我们自然会想到使用分层架构,分层的目的就是为了解耦。我们将原先的组件架构做了分层处理,先单独抽象了一个 store 层,将所有业务相关的逻辑统一抽象到 store 层里进行相关处理,尽量在视图层只留下页面交互相关的代码。
通过一些调研,我们决定使用 mobx 来支持创建我们的 store 层。这里简单说一下为什么选用 mobx 而不是其他同类型的状态管理方案?之所以选择 mobx 主要出于以下几点考虑。
- 支持 OOP 编程方式,和当前项目的组织方式很契合。
- 性能较好,同时内部有优化“批量更新的操作”,减少小程序 setData 的开销。
- 小程序官方介绍的状态管理方案:链接
- 框架无关性
关于更多前端状态管理方案的对比,可点击链接查看。
在抽象了 store 层之后,再来看看改造后的页面伪代码
...
...
// 封装后的业务逻辑
@comify({
storeBindingOptions: [{
store: xxxStore,
fields: [bizParamA, bizParamB, bizParamC, list],
actions: [updateXXX, fetchXXXList]
}]
})
...
export default class MedicationList extends BaseComponent {
data = {
calendarShow
},
closeCalendarModal () {
this.setData({
calendarShow: false
})
},
// ... 这里省略其他若干和下列类似的逻辑
async confirmCalendar (date) {
try {
// 之前一大堆业务处理逻辑,封装到 store 层
// 在 UI 层仅调用 store 暴露出的方法
await this.updateXXX(date)
this.fetchXXXList()
// 仅处理 UI 和交互逻辑
this.closeCalendarModal()
// 跳转新页面
wx.navigateTo({
url: 'xxxxxx'
})
} catch (e) {
// 上报
}
})
},
...
...
可以明显看出,改造之后的代码在组件内部的逻辑相较于之前清晰了很多,在组件内部仅需要处理交互相关的逻辑,所有业务逻辑都统一通过 store 层暴露的接口来进行调用,基本实现了关注点分离。
架构演进2 - 增加接口防腐
在解决了页面内部逻辑多,耦合严重的问题之后,我们接下来解决后端接口频繁变化对前端产生影响的问题。
为了解决这个问题,我们在之前架构的基础上引入防腐层设计,来降低后端接口的变化对前端系统造成的影响。
前端实现防腐的方式有很多,常见的有使用 node bff 层作为防腐,使用 rxjs 来实现防腐,我们考虑到学习成本和实施成本,以及结合我们当前的业务现状后,决定使用常规的 format 函数来构建我们的防腐层。
下面来看项目中一些实际用例,构建了防腐层之后,后端接口就算有字段变动,也仅仅只是需要在防腐层修改代码,不会影响到上层的其他逻辑。
当然,防腐层除了能转换数据之外,其实也能提供不少其他的功能,如提升接口稳定性,接口时序调整等,想了解的同学可点击这里查看。
架构演进3 - DDD
在通过前两步的升级优化之后,我们前 3 个主要的问题已经得到了很大的缓解。但是如果不解决第 4 个问题,我们可能很快又会回到之前的状况中去。
为什么呢?因为前端的传统开发模式是将业务和设计稿进行关联,认为设计稿就是业务,如果思想不转变,不可避免又会将交互逻辑和真实的业务逻辑耦合在一起,很难做到彻底的分层。
解决这个问题的方法就是真正去识别和理解业务。领域建模(DDD)就是很好的一种了解真实业务的方式。它要求我们抛开软件的页面,实现逻辑,运行环境,框架等等应用层面的东西,将自己当作一个真正的业务人员,去结合业务目标,梳理业务流程,分析业务角色,逐渐将整个业务的全景描绘出来。常见的领域建模方式有四色建模法和领域事件驱动等,我们采用的是四色建模的方式,关于四色建模法更详细的信息可点击这里 查看。
在建立好一些领域模型之后,我们将通用的一些领域模型沉淀下来,将之前散落在各个地方的业务逻辑统一到对应的领域模型之中,统一维护和管理。如在医疗领域比较常见的随访,我们在项目中将它抽象成一个随访领域模型,系统中所有和随访相关的业务逻辑都放在里面统一处理。一旦形成这种领域的概念之后,后续如果有随访领域的需求调整,就很自然的会想到在这一部分去修改代码。
在完成前两步的基础上,我们基于 DDD又抽象了一些通用的领域模型,如医疗领域常见的量表、问诊及用户模块。
基于领域模型的引入,我们业务架构在做了一个升级,如下图所示
架构演进4 - DDD + Prensent(未来想法)
在完成了上面三次架构升级之后,基本上整个系统的架构已经非常清晰了,各层也都能很好的“各司其职”,也能解决我们前面所提到的绝大部分问题。但是如果想更进一步,就需要将视图层进一步打薄,这么做的好处是让系统能脱离框架的限制(当然不可能 100%),用最小的成本实现一套代码,多端复用的目的。
这也是我们最近在做的一些实验性尝试,即在上面架构的基础上,将之前属于 store 层的业务数据流沉淀到领域层,然后再抽象出一个交互控制层(Present), 将原本属于组件里维护的交互逻辑移到该层处理,这样就把视图层做到最薄,将迁移框架的成本降到最低。
目前这套架构方案正在公司的跨端业务组件库里进行尝试,大家如果感兴趣下次可以写一篇文章介绍这部分内容。
对于最终这套架构方案的实现,我做了一个 Todo-list 的 Demo,实现了快捷的将一套代码逻辑同时应用在 vue 和原生小程序中,感兴趣的同学可以 clone 下来玩一玩。
相关代码链接:https://github.com/ChrisMiaoMiao/frontend-clean-architecture-weapp
总结
以上就是过去半年时间里,我司核心小程序业务架构升级的过程,这次架构升级的过程不仅仅是技术的升级,更多的是一种开发思维的升级过程。传统的前端开发思维是基于页面交互来运行的,其认为页面交互就是业务本身,这在一些中小型的项目开发上可能没什么大问题,但是一旦随着项目维护时间变长,业务规模增大,采用页面思维的编码模式就会逐渐暴露出它的问题,因为采用页面思维去编码,就注定很难对全局业务有透彻的理解,也很难去展现和传递系统中的一些业务知识,一旦项目发生人员变动,后续的维护者面对支离破碎,业务丢失的系统,很容易就会陷入不知道怎么改,不敢改的泥潭中,只能在旧有的系统中增加更多的定制逻辑来尽量保证系统的正常运行,这样却又进一步加剧了系统的腐化,到最后只能推倒重来,极大影响业务的后续发展。
那上文提到的这些通过采用 DDD 的思维和分层架构的方式个人觉得能很大程度上去缓解上述问题,首先 DDD 要求你更多的从真实业务的角度去思考系统,将业务和代码进行映射,基于领域的分层架构则针对各个职责进行分层,这样你在编码时,就能很自然的将系统中各个职责的代码维护在属于自己的地方,真正做到关注点分离,在领域层你就能查看到整个系统的业务知识,在交互控制层你能看到交互相关的逻辑,在数据请求和防腐层你能看到系统是怎么样和后端数据进行交互的。这样就能很利于代码的维护。
当然 DDD 和分层架构也不是万能的,它们各自有自己适用的场景和局限,因为它们上手成本较高,样板代码较多,所以一些中小型项目如果想要实施建议优先考虑成本。同时因为上手成本高,如果团队中大部分同事没有相关的意识,则在开发前期要通过严格的 review 代码及宣讲相关概念来辅助大家尽快理解和上手开发,另外个人觉得最难的就是领域模型的建设,需要各个职能团队(业务,产品,前,后端)通力协作,统一语言,定期更新领域模型,并同步给团队各个成员,这是一项很耗费精力的工作。
谢谢大家的阅读,欢迎大家留言区进行讨论,如果文中出现相关错误也欢迎指正。
参考资料
- Moving Away from React and Vue.js
- Clean Architecture on Frontend
- Scalable Frontend #1 — Architecture Fundamentals
- Scalable Frontend #2 — Common Patterns
- Scalable Frontend #3 — The State Layer
- 整洁前端
- 这可能是大型复杂项目下数据流的最佳实践
- 如何打造更稳健的前端业务模块代码组织形式?
- 领域驱动设计在前端中的应用
- Redux/Mobx/Akita/Vuex对比 - 选择更适合低代码场景的状态管理方案
- 基于 Observable 构建前端防腐策略
- Observable 防腐层项目实战
- 前端分层:把业务逻辑从交互代码中解救出来
- Angular 架构模式与最佳实践
amazing
获益良多