高速公路換輪胎——為遺留系統替換資料庫

發表於2016-05-05

在過去的幾個月內,我主導著團隊完成了一項工程浩大(累積八個人月的工作量)的重構工作——為我們的App替換資料庫。之所以能夠把這種傷筋動骨的事情稱之為重構,是因為在這段時間內,我們每天向主幹合併兩到三次程式碼,期間App上線五次,使用者沒有感知到任何影響。在這篇文章中,我將講述我們如何在不影響系統外部行為,也不影響正常交付的情況下,替換掉了資料庫實現。

一、背景

沒有人喜歡遺留系統,”遺留“這個詞本身就意味著難以理解、難以維護的程式碼,同時也意味著每一次改動,每一次增加新特性都步履維艱。然而在我們的職業生涯中,又總是難免與遺留程式碼相逢,因為如果沒有清晰的設計意圖貫穿軟體的整個生命週期,沒有持續演進架構,沒有持之以恆的良好重構素養,今天的優秀設計就會成為明天的遺留程式碼。

REA的iOS app就是這樣的遺留系統。在多年以前,人們做了個決策,用CoreData做本地儲存,替換掉NSUserDefaults。這之間的歷史已經遠不可考,但自從我加入專案以來,整個團隊已經被它高昂的學習曲線、複雜的資料Migration流程以及過時陳舊的設計折磨的苦不堪言。於是我們決心把CoreData換掉。但直到我開始認真記錄系統中有哪些類在呼叫CoreData API的時候,我才看清了原來CoreData只是這個複雜龐大的系統中種種問題的冰山一角而已。

二、系統面貌

在一個有著良好分層結構的系統中,每一層都有它自己的職責:顯示層負責響應使用者事件,呼叫業務層的邏輯,最後做資料呈現;業務邏輯層負責業務規則與資料處理;資料訪問層封裝底層資料庫的操作,網路訪問層與其並列,負責網路請求、json解析等等。無論是MVC、MVVM、VIPER,歸根結底都是在”單一職責“、“關注點分離”、“高內聚低耦合”的原則下變化,只是表現形式和涵蓋的層次各異。

而在我們的程式碼中,幾乎所有的顯示層物件,包括ViewController、ViewModel,甚至View裡面都混雜了大量的CoreData API呼叫,直接進行資料庫操作。大概有以下兩種方式

方式一

初始化NSFetchedResultsController,然後發起請求

高速公路換輪胎——為遺留系統替換資料庫

方式二

把自身當做CoreData的delegate,對資料庫變化後作出響應

高速公路換輪胎——為遺留系統替換資料庫

粗略統計了一下,系統中一共有25個類與NSManageContext緊緊耦合。形成了下圖中混亂的局面:

高速公路換輪胎——為遺留系統替換資料庫

整理出來這幅圖以後,看著眼前密密麻麻的API呼叫,看著眾多臃腫龐大的ViewController,我的大腦幾乎失去了思考的能力,不知道如何下手。

三、方案選型

冷靜過後,我最先排除掉的是重寫這種簡單粗暴的方式。表面上看來,我們可以通過重寫得到一個乾淨利落的方案,層次結構清晰,職責分離;但與之相伴的是巨大的風險:

範圍不可控——遺留系統的難點就在於牽一髮而動全身,影響範圍極廣。稍不留神,重寫的工作就會如野火燎原般蔓延開來,不可收拾。

長時間無法上線——在整個過程中,直到最後完成的那一刻之前,系統會處於一直不可用的狀態。漫長的時間裡,所有的新功能都被阻塞,不能交付。沒有哪個產品團隊能承擔這樣的結果。

第二個被排除掉的方案是特性分支。把重寫的工作放到分支上完成,其他人繼續在主幹上開發新特性,直到重寫結束再合併回主幹——這種做法確實比直接重寫要好上那麼一點點,因為新特性還是可以不受影響的;但長期沒有跟主幹合併的分支,在經歷上四五個月的重寫之後,天知道到最後要花多長時間來處理合並衝突?

既想減小對系統的影響,又想不影響新功能上線,又不想處理大量的合併衝突,最後的方案就只剩下了一種,那就是抽象分支(Branch by Abstraction)+特性開關(Feature Toggle)。

抽象分支

抽象分支這個名字的緣起是針對版本庫分支而言的,它允許開發者在一條“抽象”的分支上並行工作,無需建立一條實際的分支,從而避免無謂的合併開銷。Martin FowlerJez Humble都曾在多年前撰文介紹過這個重構方案。

它的工作原理很簡單:當我們想要替換掉系統中的某個元件——名為X——時,首先為X元件創造一個抽象層,這一層裡面可能會有大大小小若干介面或是協議,把系統中對X元件的訪問都隔離在抽象層之下,系統只呼叫抽象的介面/協議,不會接觸到具體的API實現。如下圖所示。

高速公路換輪胎——為遺留系統替換資料庫

這一步我們可以通過提取方法、提取類和介面等重構手法來完成;這以後系統就徹底跟X元件解耦了,它依賴的只是一組抽象介面,而非具體實現。這時候,我們就可以著手在這個抽象層下面,進行新元件的開發工作,讓它也實現同一套介面即可。

高速公路換輪胎——為遺留系統替換資料庫

這之後,我們再使用特性開關(其原理及實現見下節),讓這個抽象層在生產環境下呼叫舊元件,測試環境下呼叫新元件,從而在完全不影響交付的情況下,完成對新元件的測試。測試結束後,就可以開啟開關,讓系統線上上使用新元件,等徹底穩定後,把開關程式碼和舊元件程式碼全部刪掉,替換工作就完成了。

高速公路換輪胎——為遺留系統替換資料庫

在上述整個開發過程中,任何一個階段都可以做到細粒度的任務分解,然後小步提交,每次提交都自動觸發單元測試和整合測試,保證不影響現有功能。在頻繁提交的情況下,也不會出現大量的程式碼合併衝突,無論是做元件替換還是新特性開發,開發人員都可以基於同一套程式碼庫工作。這就大大減少了對系統的衝擊和交付風險。

下面介紹特性開關的原理與實現。

特性開關

先看一段程式碼:

高速公路換輪胎——為遺留系統替換資料庫

在這個例子中,我們要替換一個Storyboard的佈局和相關ViewController的功能,耗時很久,如果直接在主幹上修改,就會直接影響到現有的App,在功能完成之前都無法上線;如果拉一條分支出來做,未來就又會有大量的合併衝突。使用如上的特性開關就會避免上述問題。

shouldDisplayNewSearchResultsScreen的值返回為真,就使用新的Storyboard,返回為假,就使用舊的Storyboard。這樣一來,只要開關處於關閉狀態,未完成的功能就是對使用者不可見的,我們就既可以在開發環境下自測,也可以部署到測試環境下做驗收測試,還可以針對開關為真的情況寫對應的單元測試,讓每次程式碼提交都有持續整合驗證。這期間還可以繼續釋出新版本,使用者完全感知不到影響,直到我們決定開啟開關為止。

特性開關可以有多種實現方式。

1. 預編譯引數

在預編譯引數中傳值,讓不同的xcconfig檔案傳入不同的值,然後在程式碼中做判斷。例如我們可以定義internal和production兩個Target,為內部發布和外部發布分別生成不同的ipa檔案,然後在internal的xcconfig檔案中定義

而後就可以在Toggle程式碼中這樣寫

我們系統中絕大部分的特性開關都是用這種方式實現的。

2. NSUserDefaults

有些功能可能對App有破壞性的影響,即便是設成只對Internal Target可見,也會影響到QA的迴歸測試。我們給Internal Target做了個Developer Settings介面,讓開發人員可以自己修改開關狀態,把開關的值存放在NSUserDefaults裡面,預設返回false,只有在介面上手工切換之後才會返回true。測試和開發互相不受影響。

向Realm遷移的特性開關使用的就是這種方式。

3. 伺服器取值

配置引數的值也可以通過伺服器下發。這種做法的好處是比較靈活,在啟用/禁用某項功能的時候不需要釋出新版本,只需要後臺配置,缺點是會增加整合和後臺開發的工作量。

4. A/B測試

還有一個辦法是使用第三方的A/B測試服務,如果缺少後臺開發人員的話,這也是一個選擇。但第三方的穩定性往往就會成為制約因素,Parse為推送通知提供過A/B測試服務,但是它到了17年就會被關閉了;我們用Amazon的A/B測試框架用了一段時間,然後Amazon也宣佈今年8月份停用……目前我們還在尋找備選方案。

四、技術實現

在具體落實抽象分支和特性開關的時候,一共分成了如下幾個階段:

1. 建立資料訪問層

前文說過,系統中ViewController使用NSManageContext的方式一共有兩種。

第一種是直接初始化NSFetchedResultsController,發起請求,這種方式比較好處理,我們首先把跟資料請求有關的操作從ViewController中提取成一個方法,放到另一個物件中實現,以便日後替換。然後把所有的資料訪問的方法都提取成一個協議,讓資料層之上的物件都依賴於這個協議,而不是具體物件。如下所示

我們同時還需要使用特性開關,來決定給上層返回哪一個PersistenceService物件:

改造過後的ViewController就簡單多了

第二種方式是ViewController把自己註冊為NSFetchedResultsController的delegate,實現了相應介面,當資料發生變化時重新整理UI。這個處理起來就比較棘手,因為我們希望提取之後的介面能夠適配於Realm,這樣才能無縫切換。然而Realm一方面目前沒有像CoreData那樣的細粒度通知,另一方面用的也不是delegate,而是提供了addNotificationBlock:方法,讓呼叫者可以註冊block。二者的介面並不相容。

這種情況下,我們的新協議就只能取二者交集:

這個協議跟CoreData和Realm的介面都不一致,兩個PersistenceService都在內部做了適配和轉發。比如在Realm的實現中,我們讓它對外使用REAPersistenceDataDelegate協議來註冊delegate,對內依然使用addNotificationBlock:方法監聽,收到訊息以後再呼叫delegate的contentDidChange方法。

由於Realm沒有細粒度通知,本來還想用

這種方法來封裝CoreData的

現在也只好作罷,讓delegate收到資料後自己計算應當重新整理哪部分的資料。

2. 為資料物件提取協議

除了資料訪問的程式碼以外,我們還把所有的資料物件上的公有屬性和方法都提取了相應的協議,然後修改了整個App,讓它使用協議,而不是具體的資料物件。這也是為以後的切換做準備。

3. 使用Realm實現

前兩步完成之後,我們就建立起了一個完整的抽象層。在這層之上,App裡已經沒有了對CoreData和資料物件的依賴,我們可以在這層抽象之下,提供一套全新的實現,用來替換CoreData。

在實現過程中,我們還是遇到了不少需要磨合的細節,比如Realm中的一對多關聯是通過RLMArray實現的,並不是真正的NSArray,為了保證介面的相容性,我們就只能把property定義為RLMArray,再提供一個NSArray的getter方法。種種問題不一而足。

4. 切換開關狀態

上篇文章說到,我們在遷移過程中的特性開關是用NSUserDefaults實現的,在介面上手工切換開關狀態。這樣的好處是開發過程不會影響在Hockey和TestFlight上內部發布。直到實現完成後,我們再把開關改成

讓測試人員可以在真機上測試。迴歸測試結束之後,再讓開關直接返回true,就可以向App Store提交了。

5. 資料遷移

這個無需多說,寫個MigrationManager之類的類,用來把資料從CoreData中讀出,寫到Realm裡面去。這個類大概要保留上三四個版本,等絕大部分使用者都已經升級到新版本之後才會刪掉。

6. 後續清理

特性開關是不能一直存活下去的,否則程式碼中的分支判斷會越來越多。我們一般都會在上線一兩個星期之後,發現沒有出現特別嚴重的crash,就把跟開關有關的程式碼全都刪掉。

在第一步建立資料訪問層的時候,我們建立出了一個特別龐大的PersistenceService,它裡面含有所有的資料訪問方法。這只是為了方便切換而已,切換完成後,我們還是要根據訪問資料的不同,建立一個個小的Repository,然後讓ViewModel物件訪問Repository讀寫資料,把PersistenceService刪掉。

最後形成的架構如圖所示

高速公路換輪胎——為遺留系統替換資料庫

五、總結

四個多月的時間裡,看著自己的構思落地生根到開花結實,看著程式碼結構從混亂變成有序,心裡的滿足感無可言喻。回頭望去崎嶇征途,其間的爭執、焦慮、興奮、堅定,盡皆化成了一行行程式碼融入系統的底層結構,化成了沉甸甸的收穫。

首先,要勇敢

面對混亂的程式碼庫,人們最容易做出的選擇就是複製黏貼。看看前人怎麼做,就跟著照貓畫虎來幾筆。以前的程式碼是這麼寫的,我照樣拷一份過來,改一改就能實現新需求。這種做法我們不能說它錯,然而它既不能讓這個系統變得更好一點,更乾淨一點,也不能讓我們的技術得到提升。它能以最快的速度完成眼下的需求,結果是為團隊留下更多的技術債。

欠下的債終究是要還的,團隊裡一定要有人站出來跟大家說,我們不能讓程式碼繼續腐爛下去,我們要有清晰的目標和正確的策略,在重構中讓優秀的設計漸漸湧現。這才是正途。

要有正確的方法

Martin Fowler在部落格中總結過重構的幾種流程,在遺留程式碼中工作,Long-Term Refactoring是不可或缺的。

人們需要預見到在未來的產品規劃中,哪些元件應當被替換,哪部分架構需要作出調整,把它們放到迭代計劃裡面來,當做日常工作的一部分。抽象分支和特性開關在Long-Term Refactoring可以發揮顯著的效果,它們是持續交付的保障。

技術債同樣需要適當管理,按照嚴重程度和所需時間綜合排序,一點點把債務償還。或許有人覺得這是浪費時間,但跟一路披荊斬棘,穿越溪流,攀過險峰,歷盡艱難險阻相比,我寧願朝著另一個方向走上一段,因為那邊有高速公路。

遺留程式碼的出現,也意味著在過往的歲月中團隊忽略了對程式碼質量的關注。為了不讓程式碼繼續腐化,童子軍規則必須要養成習慣。

設計會過時,但設計原則不會

很多技術決策都不是非黑即白的,它們更像是在種種約束下做出的權衡。比如在本文的例子中,當CoreData被Realm所替換以後,抽象層還要不要保留?ViewModel應該直接呼叫Repository,還是RepositoryProtocol?有人會覺得這一層抽象就好比只有單一實現的介面一樣,沒有存在的價值,有人會覺得幾年後Realm也會過時被新的資料庫取代,如果保留這層抽象,就會讓那時候的遷移工作變得簡單。但無論怎麼做,過上一兩年後,新加入團隊的人都可能會覺得之前那些人做的很傻。

我們無法預見未來,只能根據當前的情況做出簡單而靈活的設計。這樣的設計應當服從這些設計原則:單一職責、關注點分離、不要和陌生人說話……讓我們的程式碼儘可能保持高內聚低耦合,保證良好的可測試性。時光會褪色,框架會過時,今天的優秀設計也會淪落成明天的遺留程式碼,但這些原則有著不動聲色的力量。

相關文章