一、前言
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類內,代碼的可讀性、可維護性有效提升。
四、小結
新架構針對業務功能,優化了現有代碼結構,使其能夠更好地應對愈發複雜的業務場景,實現業務功能,同時保證實現代碼的可維護性。
針對非業務性功能,提出插件功能,利用面向切面編程思想,使非業務性功能收口在插件類內,不入侵業務模塊代碼。
作者介紹:
佳璐、熠暘、文煥,攜程國際部門機票App團隊。
本文轉載自公衆號攜程技術(ID:ctriptech)。
原文鏈接: