在過去的幾個月內,我主導著團隊完成了一項工程浩大(累積八個人月的工作量)的重構工作——為我們的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 Fowler和Jez Humble都曾在多年前撰文介紹過這個重構方案。
它的工作原理很簡單:當我們想要替換掉系統中的某個元件——名為X——時,首先為X元件創造一個抽象層,這一層裡面可能會有大大小小若干介面或是協議,把系統中對X元件的訪問都隔離在抽象層之下,系統只呼叫抽象的介面/協議,不會接觸到具體的API實現。如下圖所示。
這一步我們可以通過提取方法、提取類和介面等重構手法來完成;這以後系統就徹底跟X元件解耦了,它依賴的只是一組抽象介面,而非具體實現。這時候,我們就可以著手在這個抽象層下面,進行新元件的開發工作,讓它也實現同一套介面即可。
這之後,我們再使用特性開關(其原理及實現見下節),讓這個抽象層在生產環境下呼叫舊元件,測試環境下呼叫新元件,從而在完全不影響交付的情況下,完成對新元件的測試。測試結束後,就可以開啟開關,讓系統線上上使用新元件,等徹底穩定後,把開關程式碼和舊元件程式碼全部刪掉,替換工作就完成了。
在上述整個開發過程中,任何一個階段都可以做到細粒度的任務分解,然後小步提交,每次提交都自動觸發單元測試和整合測試,保證不影響現有功能。在頻繁提交的情況下,也不會出現大量的程式碼合併衝突,無論是做元件替換還是新特性開發,開發人員都可以基於同一套程式碼庫工作。這就大大減少了對系統的衝擊和交付風險。
下面介紹特性開關的原理與實現。
特性開關
先看一段程式碼:
在這個例子中,我們要替換一個Storyboard的佈局和相關ViewController的功能,耗時很久,如果直接在主幹上修改,就會直接影響到現有的App,在功能完成之前都無法上線;如果拉一條分支出來做,未來就又會有大量的合併衝突。使用如上的特性開關就會避免上述問題。
當shouldDisplayNewSearchResultsScreen
的值返回為真,就使用新的Storyboard,返回為假,就使用舊的Storyboard。這樣一來,只要開關處於關閉狀態,未完成的功能就是對使用者不可見的,我們就既可以在開發環境下自測,也可以部署到測試環境下做驗收測試,還可以針對開關為真的情況寫對應的單元測試,讓每次程式碼提交都有持續整合驗證。這期間還可以繼續釋出新版本,使用者完全感知不到影響,直到我們決定開啟開關為止。
特性開關可以有多種實現方式。
1. 預編譯引數
在預編譯引數中傳值,讓不同的xcconfig檔案傳入不同的值,然後在程式碼中做判斷。例如我們可以定義internal和production兩個Target,為內部發布和外部發布分別生成不同的ipa檔案,然後在internal的xcconfig檔案中定義
1 |
GCC_PREPROCESSOR_DEFINITIONS = INTERNAL_TARGET=1 |
而後就可以在Toggle程式碼中這樣寫
1 2 3 4 5 6 7 8 9 10 11 |
#ifdef INTERNAL_TARGET #define isInternalTarget YES #else #define isInternalTarget NO #endif //本特性只在Internal Target中可見 + (BOOL)shouldDisplayNewSearchResultsScreen { return isInternalTarget; } |
我們系統中絕大部分的特性開關都是用這種方式實現的。
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中提取成一個方法,放到另一個物件中實現,以便日後替換。然後把所有的資料訪問的方法都提取成一個協議,讓資料層之上的物件都依賴於這個協議,而不是具體物件。如下所示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@protocol REAPersistenceService - (NSArray *)getTodayUpcomingEvents; //其餘方法略過 @end @interface REACoreDataPersistenceService: NSObject + (instancetype)sharedInstance; @end @implemention REACoreDataPersistenceService - (NSArray *)getTodayUpcomingEvents { //封裝了NSFetchedResultsController的初始化和performFetch操作 } @end |
我們同時還需要使用特性開關,來決定給上層返回哪一個PersistenceService物件:
1 2 3 4 5 6 7 8 9 |
@implemention REAPersistenceServiceFactory + (id<REAPersistenceService>)service { if([REAToggle shouldUseRealm]) { return [REARealmPersistenceService sharedInstance]; } else { return [REACoreDataPersistenceService sharedInstance]; } } |
改造過後的ViewController就簡單多了
1 2 3 4 5 6 7 8 9 10 11 |
- (instancetype)init { self = [super init]; if (self) { _persistenceService = [REAPersistenceServiceFactory service]; } return self; } - (NSArray *)getTodayUpcomingEvents { return [self.persistenceService getTodayUpcomingEvents]; } |
第二種方式是ViewController把自己註冊為NSFetchedResultsController的delegate,實現了相應介面,當資料發生變化時重新整理UI。這個處理起來就比較棘手,因為我們希望提取之後的介面能夠適配於Realm,這樣才能無縫切換。然而Realm一方面目前沒有像CoreData那樣的細粒度通知,另一方面用的也不是delegate,而是提供了addNotificationBlock:方法,讓呼叫者可以註冊block。二者的介面並不相容。
這種情況下,我們的新協議就只能取二者交集:
1 2 3 |
@protocol REAPersistenceDataDelegate - (void)contentDidChange:(id)content; @end |
這個協議跟CoreData和Realm的介面都不一致,兩個PersistenceService都在內部做了適配和轉發。比如在Realm的實現中,我們讓它對外使用REAPersistenceDataDelegate協議來註冊delegate,對內依然使用addNotificationBlock:方法監聽,收到訊息以後再呼叫delegate的contentDidChange方法。
由於Realm沒有細粒度通知,本來還想用
1 |
- (void)objectDidChange:(id)object; |
這種方法來封裝CoreData的
1 2 3 4 5 |
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath |
現在也只好作罷,讓delegate收到資料後自己計算應當重新整理哪部分的資料。
2. 為資料物件提取協議
除了資料訪問的程式碼以外,我們還把所有的資料物件上的公有屬性和方法都提取了相應的協議,然後修改了整個App,讓它使用協議,而不是具體的資料物件。這也是為以後的切換做準備。
3. 使用Realm實現
前兩步完成之後,我們就建立起了一個完整的抽象層。在這層之上,App裡已經沒有了對CoreData和資料物件的依賴,我們可以在這層抽象之下,提供一套全新的實現,用來替換CoreData。
在實現過程中,我們還是遇到了不少需要磨合的細節,比如Realm中的一對多關聯是通過RLMArray實現的,並不是真正的NSArray,為了保證介面的相容性,我們就只能把property定義為RLMArray,再提供一個NSArray的getter方法。種種問題不一而足。
4. 切換開關狀態
上篇文章說到,我們在遷移過程中的特性開關是用NSUserDefaults實現的,在介面上手工切換開關狀態。這樣的好處是開發過程不會影響在Hockey和TestFlight上內部發布。直到實現完成後,我們再把開關改成
1 2 3 4 |
+ (BOOL)shouldUseRealm { return isInternalTarget; } |
讓測試人員可以在真機上測試。迴歸測試結束之後,再讓開關直接返回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也會過時被新的資料庫取代,如果保留這層抽象,就會讓那時候的遷移工作變得簡單。但無論怎麼做,過上一兩年後,新加入團隊的人都可能會覺得之前那些人做的很傻。
我們無法預見未來,只能根據當前的情況做出簡單而靈活的設計。這樣的設計應當服從這些設計原則:單一職責、關注點分離、不要和陌生人說話……讓我們的程式碼儘可能保持高內聚低耦合,保證良好的可測試性。時光會褪色,框架會過時,今天的優秀設計也會淪落成明天的遺留程式碼,但這些原則有著不動聲色的力量。