作者简介
本文作者为携程国际部门机票App团队的佳璐、熠暘、文焕。
一、前言
2019年上半年携程机票前台团队基于clean architecture思想,结合具体业务特点和复杂度,对App机票查询列表页进行了一次技术重构。重构后的机票列表页视图与逻辑分离,多个业务模块分治业务场景,降低整体业务复杂度,提升了页面的可维护性,可测试性。
在近一年的业务迭代过程中开发团队发现了新的问题,并在原有1.0版本架构上做进一步优化。
二、架构优化
软件架构是软件的基本结构,针对业务场景实现合适的软件架构是软件可维护、可拓展的重要因素之一。
随着业务发展,大量业务逻辑迁移至前端实现以减少请求服务的次数,带给用户更平滑、顺畅的使用体验,前端的业务复杂度大大提高。现阶段前端的主要业务场景可抽象为:
1)请求服务。
2)根据业务逻辑将服务数据转化为业务状态。
3)根据展示逻辑将业务状态转化为展示状态,并渲染至界面。
4)响应用户交互,根据展示逻辑更新展示状态,根据业务逻辑更新业务状态。
前端页面的复杂度在于业务逻辑、展示逻辑繁多复杂,且业务逻辑间、展示逻辑间存在大量联动关系。如下图,大量复杂的业务逻辑、展示逻辑互相关联,导致整个页面的复杂度指数级上升。
2.1 原有问题
原架构借鉴clean architecture思想,将页面拆分为多个同构的业务模块,业务模块间可以嵌套组合。单个业务模块使用MVP模式进行管理:
View - React代码,只负责界面展示、样式和响应用户交互。
Presenter - 连接View和Model,连接外部模块,不存在业务逻辑。
Model - 业务实体,封装了业务逻辑和展示逻辑供Presenter调用。
其架构如图:
对比原架构设计与实际业务场景,可以发现其设计存在不合理之处:
业务逻辑实现在业务模块内,与展示逻辑强耦合。当界面不展示业务模块时,对应的业务逻辑也无法执行,容易出现程序bug。
业务逻辑与展示逻辑难以复用。
页面内多个业务模块实现同一业务逻辑时,只能通过拷贝相关代码解决。
跨页面复用模块时,由于不同页面间的业务逻辑存在差异,导致无法直接复用。
模块间数据通信方式复杂,由于业务逻辑实现在不同业务模块内且业务模块在页面中呈树状结构,页面逻辑复杂时数据通信容易出现下图中的状态。
2.2 解决方案
新架构针对上述问题进行优化,核心改动点为:
优化数据通信方式,模块只与Service通信,实现单向数据流。
新增业务Service概念,承载页面业务逻辑,业务模块调整为只承载展示逻辑。
最新架构如图:
单向数据流
新架构下业务模块间无法通信,只可与业务Service通信,并且业务模块只是业务Service方法的调用方,业务逻辑的计算在业务Service实现,最终实现了单向数据流。
对于业务模块触发业务数据更新(例如用户交互),其流程如下:
对于业务数据更新触发业务模块刷新(例如请求返回), 其流程如下:
对于业务模块触发业务数据更新,同时联动引起其他业务模块刷新,其流程如下:
整体数据流如下:
业务Service
新架构中,页面拆分为多个同构业务模块和多个业务Service,业务模块根据界面展示内容进行划分,仍使用MVP模式进行管理,业务Service根据业务领域进行划分,使用面向对象方式进行管理。
业务模块中View职责不变,Presenter不再与其他模块直接连接、新增与业务Service的连接,Model不再负责业务逻辑,专注于展示逻辑。
业务Service则专注于特定业务领域的业务逻辑,为上层业务模块和其他业务Service提供支持。
拆分后的业务模块与业务Service,更符合单一职责原则(SRP原则),两者的可复用性也大大提升。跨页面复用业务模块时,只要其展示逻辑、交互逻辑相同即可直接复用。页面内涉及相同业务逻辑的业务模块,调用业务Service方法即可完成功能。
业务Service还能提取成为公用类库,不同平台(例如h5、online、app)存在相似业务场景时,即使上层的界面展示、交互方式不同,采用的UI框架不同也能进行复用,降低跨平台开发的成本。
三、插件功能优化
前端页面中除了业务功能外,还需实现大量非业务性功能,例如用户行为埋点、线上监控等。
3.1 原有问题
原架构中这类非业务性功能通常散落在代码各处,自身缺乏收口方式,对正常业务代码侵入性强,严重影响代码的可读性、可维护性。
以最常见的埋点功能为例,假设现在需对页面内具有联动关系的展示数据进行监控,当数据间展示不同步时上送报错埋点。在原架构下我们的实现方式为:
// ModuleA/Presenter/index.ts
export class ModuleAPresenter {
private monitor: Monitor;
constructor(monitor: Monitor) {
this.monitor = monitor;
}
public updateView() {
// 收集模块A的最新状态。
this.monitor.updateState(this.model.getViewModel(), 'ModuleA');
this.view.updateView(this.model.getViewModel());
}
}
// Monitor/index.ts
export class Monitor {
private stateMap = new Map();
public updateState(state, moduleName) {
this.stateMap.set(moduleName, state);
// 检查模块A、模块B的状态是否同步。
this.checkState();
}
}
上述代码有几个明显的问题:
1)埋点代码直接侵入业务代码,两者互相强耦合,后续对埋点逻辑的改动很可能破坏业务代码,反之亦然。
2)业务模块需持有埋点类的实例,增加了对Monitor类的依赖,降低了自身的可复用性、可测试性。
3)对埋点逻辑的修改需要改动多个位置的代码,产生了”散弹枪式修改“的坏味道。
3.2 解决方案
基于面向切面编程的思想,在架构设计时预留”切面“并提供插件功能。用户可将非业务性功能封装在插件内维护与业务代码完全隔离,插件可通过切面获取如程序生命周期、特定用户行为等必要信息,无需入侵业务模块代码。同时业务模块也可访问插件实例,利用插件收集的数据完成特定功能。
面向切面编程(Aspectoriented programming)旨在将业务主体与非业务性功能分离,以提高程序的模块化程度。它将代码逻辑切分为不同的业务功能集,每个功能集包含了多个功能点,部分功能点会在多个功能集中都有出现,它们被称为”切面“。非业务性功能利用切面进行封装、维护,使原本分散在整个页面中的逻辑变得可管理、可维护。
上述例子使用插件改写后如下:
// ModuleA/Presenter/index.ts
export class ModuleAPresenter {
public updateView() {
// 业务模块中不再有无关逻辑
this.view.updateView(this.model.getViewModel());
}
}
// Monitor/index.ts
export class MonitorPlugin implements IGrtPlugin {
private stateMap = new Map();
// ”切面“方法
public onUpdateState(state, moduleName) {
this.stateMap.set(moduleName, state);
// 检查模块A、模块B的状态是否同步。
this.checkState();
}
}
改动后的代码业务功能与非业务性功能完全解耦,且埋点功能的相关逻辑完全收口在Monitor类内,代码的可读性、可维护性有效提升。
四、小结
新架构针对业务功能,优化了现有代码结构,使其能够更好地应对愈发复杂的业务场景,实现业务功能,同时保证实现代码的可维护性。
针对非业务性功能,提出插件功能,利用面向切面编程思想,使非业务性功能收口在插件类内,不入侵业务模块代码。
【推荐阅读】
“携程技术”公众号后台回复“新书”,
可免费获得两本书的试读样章~
《携程架构实践》
京东
当当
《携程人工智能实践》
京东
当当
“携程技术”公众号
分享,交流,成长