2019年上半年携程机票前台团队基于clean architecture思想,结合具体业务特点和复杂度,对App机票查询列表页进行了一次技术重构。重构后的机票列表页视图与逻辑分离,多个业务模块分治业务场景,降低整体业务复杂度,提升了页面的可维护性,可测试性。
在近一年的业务迭代过程中开发团队发现了新的问题,并在原有1.0版本架构上做进一步优化。
软件架构是软件的基本结构,针对业务场景实现合适的软件架构是软件可维护、可拓展的重要因素之一。
随着业务发展,大量业务逻辑迁移至前端实现以减少请求服务的次数,带给用户更平滑、顺畅的使用体验,前端的业务复杂度大大提高。现阶段前端的主要业务场景可抽象为:
1)请求服务。
2)根据业务逻辑将服务数据转化为业务状态。
3)根据展示逻辑将业务状态转化为展示状态,并渲染至界面。
4)响应用户交互,根据展示逻辑更新展示状态,根据业务逻辑更新业务状态。
前端页面的复杂度在于业务逻辑、展示逻辑繁多复杂,且业务逻辑间、展示逻辑间存在大量联动关系。如下图,大量复杂的业务逻辑、展示逻辑互相关联,导致整个页面的复杂度指数级上升。
原架构借鉴clean architecture思想,将页面拆分为多个同构的业务模块,业务模块间可以嵌套组合。单个业务模块使用MVP模式进行管理:
其架构如图:
对比原架构设计与实际业务场景,可以发现其设计存在不合理之处:
新架构针对上述问题进行优化,核心改动点为:
最新架构如图:
单向数据流
新架构下业务模块间无法通信,只可与业务Service通信,并且业务模块只是业务Service方法的调用方,业务逻辑的计算在业务Service实现,最终实现了单向数据流。
对于业务模块触发业务数据更新(例如用户交互),其流程如下:
对于业务数据更新触发业务模块刷新(例如请求返回), 其流程如下:
对于业务模块触发业务数据更新,同时联动引起其他业务模块刷新,其流程如下:
整体数据流如下:
业务Service
新架构中,页面拆分为多个同构业务模块和多个业务Service,业务模块根据界面展示内容进行划分,仍使用MVP模式进行管理,业务Service根据业务领域进行划分,使用面向对象方式进行管理。
业务模块中View职责不变,Presenter不再与其他模块直接连接、新增与业务Service的连接,Model不再负责业务逻辑,专注于展示逻辑。
业务Service则专注于特定业务领域的业务逻辑,为上层业务模块和其他业务Service提供支持。
拆分后的业务模块与业务Service,更符合单一职责原则(SRP原则),两者的可复用性也大大提升。跨页面复用业务模块时,只要其展示逻辑、交互逻辑相同即可直接复用。页面内涉及相同业务逻辑的业务模块,调用业务Service方法即可完成功能。
业务Service还能提取成为公用类库,不同平台(例如h5、online、app)存在相似业务场景时,即使上层的界面展示、交互方式不同,采用的UI框架不同也能进行复用,降低跨平台开发的成本。
前端页面中除了业务功能外,还需实现大量非业务性功能,例如用户行为埋点、线上监控等。
原架构中这类非业务性功能通常散落在代码各处,自身缺乏收口方式,对正常业务代码侵入性强,严重影响代码的可读性、可维护性。
以最常见的埋点功能为例,假设现在需对页面内具有联动关系的展示数据进行监控,当数据间展示不同步时上送报错埋点。在原架构下我们的实现方式为:
// 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)对埋点逻辑的修改需要改动多个位置的代码,产生了”散弹枪式修改“的坏味道。
基于面向切面编程的思想,在架构设计时预留”切面“并提供插件功能。用户可将非业务性功能封装在插件内维护与业务代码完全隔离,插件可通过切面获取如程序生命周期、特定用户行为等必要信息,无需入侵业务模块代码。同时业务模块也可访问插件实例,利用插件收集的数据完成特定功能。
面向切面编程(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类内,代码的可读性、可维护性有效提升。
新架构针对业务功能,优化了现有代码结构,使其能够更好地应对愈发复杂的业务场景,实现业务功能,同时保证实现代码的可维护性。
针对非业务性功能,提出插件功能,利用面向切面编程思想,使非业务性功能收口在插件类内,不入侵业务模块代码。
作者介绍:
佳璐、熠暘、文焕,携程国际部门机票App团队。
领取专属 10元无门槛券
私享最新 技术干货