最近一年來,我所在的項目為一個傳統行業客戶的 IT 核心系統做遺留系統改造,我參與了該系統一個業務模塊的拆分和服務化,在這過程中落地了一些有意思的實踐,特此記錄下來和大家分享。
項目背景
這是一個運行了至少 15 年的單體系統,采用的技術棧是 JDK8、Servlet、JSP、Oracle、JDBC、存儲過程、Weblogic,從這些關鍵詞就能感受到它的滄桑感。整個系統都在一個代碼倉庫中,或按業務或按功能劃分成了 30 多個 maven 模塊,模塊間可以任意調用彼此的方法,也可以隨意訪問彼此業務的數據庫表。最讓人糟心的是,大部分的業務邏輯都寫在復雜的 SQL 語句和存儲過程里,幾百行的 SQL、一堆表的 join、層層調用的存儲過程比比皆是。當然,該系統也沒有落下“沒有自動化測試”這個遺留系統的典型標簽。
圖1 單個代碼倉庫里包含的各種業務模塊和技術模塊
客戶PO是一個對技術有理想有抱負的人物,不希望這個系統再繼續腐化下去,所以找到我司對該系統進行現代化改造,其中一個落地措施就是對這個單體系統進行拆分和服務化。經過挑選,A 業務成為了這一批改造的試點對象。
這次拆分的目標是:將 A 業務的代碼和數據庫表從原有代碼和數據庫中拆分出來,形成獨立的 A 服務及其數據庫,實現 A 業務的代碼獨立、數據獨立、部署獨立。
圖2 拆分目標
總體策略
這次服務拆分的策略歸納起來有三條:
(1) 先代碼拆分、后數據拆分
代碼和數據是服務拆分的兩個重要物理實體。先拆代碼還是先拆數據,在《Monolith to Microservices》中介紹了這兩種方式的優劣。我們考慮到在現有代碼極其復雜的前提下,先拆數據會給代碼帶來更大的復雜性,并且在出現問題、需要回滾的情況下,拆分前后的數據一致性也十分困難,因此我們選擇了先代碼拆分的策略。
圖3 先代碼拆分、后數據拆分
(2) 以單個頁面請求為單位進行拆分
拆分工作由 10 位開發人員承擔,如何劃分大家工作內容呢:按數據庫表?按 Servlet?按頁面?我們選擇的是按請求來劃分。A 業務的后端 Servlet 提供了近 300 個功能,每個功能對應一個前端請求URL。我們以單個請求為顆粒度劃分開發任務,并按我們熟悉的敏捷開發方式創建 Jira 卡片、安排到每個迭代中。按這樣的顆粒度劃分后,大多數開發任務可以對應到1、2、3、5天的工作量,非常有利于安排每個迭代的內容、分批上線、形成緊湊的工作節奏、縮小每個開發任務的測試范圍。
(3) 代碼先完整復制,再修改
新服務的框架搭起來以后,是一開始就把 A 業務的代碼復制到新創建的服務中,還是在做開發任務的時候才把涉及到的代碼復制到新服務中再做修改?我們選擇的是前者,因為后者在多人并行開發的時候會遇到復制沖突的問題,與其這樣,不如一開始就整體復制好,在做開發任務的時候再修改涉及到的代碼。當然,一開始還需要把一些公共代碼或者依賴的其他業務代碼也復制過來,以保證 A 服務的代碼能編譯通過。
使用 Feature Toggle 用于功能回滾
只要是對代碼的改動,就有可能引入 bug。—— 我說的
雖然Dev、QA團隊盡力最大努力,但依舊無法避免拆分出來的服務上線后出bug,一旦出現,需要盡快切換回原有系統以減少對業務的影響。結合我們以頁面請求為單位進行拆分的方式,我們引入 feature toggle 作為切換新舊系統的開關,控制前端發來的請求是發送給原有系統還是發送給拆分出來的服務。
圖4 使用 Feature Toggle 切換新舊系統
在實現的時候,所有請求默認還是先發給原有系統,我們在原有系統的后端新增了一個請求過濾器,在過濾器中提取請求的 URL,根據 URL 判斷:如果是已經拆分出去的功能請求,并且數據庫中記錄的 toggle 是開啟的,則將請求轉發給新服務處理;反之依舊交由原有系統處理。
既然將回滾作為快速恢復功能的手段,那引入了一項開發約定:在拆分過程中,只允許修改新服務的代碼,不允許修改原有系統的代碼。
Feature Toggle 不僅在處理線上問題時可以用來及時止血,還給團隊帶來了額外的好處:
- 在上線前,Dev和QA可以切換開關,快速比對某個功能的改造前后的效果是否一致。?
- 到了上線時間,但測試尚未充分,或者在年底大促的業務高峰期擔心引入 bug 影響業務的時候,就出現了“開發完成但不能上線”的情況,這時關閉對應的 toggle 即可讓拆分后的功能暫緩上線。?
使用代碼分析工具簡化數據庫表的使用分析
每一個拆分任務的重點工作之一,是識別該功能讀寫的表是否是 A 業務的表。因為只有 A 業務的表,最終才會拆分到 A 數據庫中;反之如果不是 A 業務的表,被視為只有原有系統才能直接讀寫,在 A 服務中無法讀寫,需要改為調用原有系統新增加 API 的方式來取代原有的數據操作。
如果是一個簡單的功能,尚可通過肉眼查看代碼來識別都操作了哪些表。但凡功能稍微復雜,人工查看的效率和準確性就大打折扣甚至不可行。好在我司的一個大牛為該項目開發了代碼分析工具,它可以通過分析編譯后的 Java 字節碼文件,得到方法調用鏈上所有方法的調用關系,以及 SQL 和存儲過程里讀寫的表,并將分析結果形成一個樹形結構并以 xmind 或者 svg 的格式保存下來。開發人員有了分析結果,不費吹灰之力就能知道當前拆分的功能涉及哪些表,以及在調用哪個方法的時候用到了這些表,從而對接下來要拆分的代碼心中有數。
圖5 一個稍微復雜一點的方法調用鏈分析結果
如果沒有這個分析工具,Dev 可能要花好幾天甚至好幾周去分析一個復雜的待拆分功能,而現在只需要幾秒鐘,分析結果就呈現在眼前。這個工具被客戶譽為“神器”,我們在用的時候也時常感嘆:“自動化真香!”
使用 Code Owner 保持新舊代碼的一致
在拆分過程中,如果有新需求涉及 A 業務的變更,則原有系統和新服務中的代碼都需要同步修改,否則就會出現二者的功能不一致:
- 如果只修改了原有系統而未修改新服務,那么該功能在拆分改造后,功能就會和改造前存在差異。?
- 如果只修改了新服務而未修改原有系統,那么一旦 toggle 關閉,則原有系統則無法提供新需求的功能。?
客戶使用的版本控制系統是 BitBucket,并且以提交 Pull Request(PR)的方式合并新代碼。因此我們使用了 BitBucket 的 Code Owner 功能(Github、Gitlab 也有該功能)監控原有系統中 A 業務涉及的模塊和文件夾,同時也監控了新服務所有代碼,并將拆分團隊的兩位骨干Dev設置為 Code Owner。這樣一旦在 PR 中包含被監控代碼的改動,則會自動把 Code Owner 設置為 PR 的 Reviewer,Code Owner 收到系統通知后會檢查代碼是否做了同步修改。如果代碼修改未同步,則不允許合并 Pull Request。
結語
讓我們面對現實吧,我們今天所做的一切就是在編寫明天的遺留系統 —— Martin Fowler
我們正在書寫、即將面對、正在面對遺留系統。在與遺留系統的相愛相殺中,需要我們基于項目目標和現狀、結合過往經驗、經過剪裁和取舍,才能迎面不斷出現的挑戰。我以此文拋磚引玉,歡迎大家交流拍磚。