在過去的幾個月內(nèi),我主導(dǎo)著團(tuán)隊完成了一項工程浩大(累積八個人月的工作量)的重構(gòu)工作——為我們的App替換數(shù)據(jù)庫。之所以能夠把這種傷筋動骨的事情稱之為重構(gòu),是因為在這段時間內(nèi),我們每天向主干合并兩到三次代碼,期間App上線五次,用戶沒有感知到任何影響。在這篇文章中,我將講述我們?nèi)绾卧诓挥绊懴到y(tǒng)外部行為,也不影響正常交付的情況下,替換掉了數(shù)據(jù)庫實現(xiàn)。
一、背景
沒有人喜歡遺留系統(tǒng),”遺留“這個詞本身就意味著難以理解、難以維護(hù)的代碼,同時也意味著每一次改動,每一次增加新特性都步履維艱。然而在我們的職業(yè)生涯中,又總是難免與遺留代碼相逢,因為如果沒有清晰的設(shè)計意圖貫穿軟件的整個生命周期,沒有持續(xù)演進(jìn)架構(gòu),沒有持之以恒的良好重構(gòu)素養(yǎng),今天的優(yōu)秀設(shè)計就會成為明天的遺留代碼。
REA的iOS app就是這樣的遺留系統(tǒng)。在多年以前,人們做了個決策,用CoreData做本地存儲,替換掉NSUserDefaults。這之間的歷史已經(jīng)遠(yuǎn)不可考,但自從我加入項目以來,整個團(tuán)隊已經(jīng)被它高昂的學(xué)習(xí)曲線、復(fù)雜的數(shù)據(jù)Migration流程以及過時陳舊的設(shè)計折磨的苦不堪言。于是我們決心把CoreData換掉。但直到我開始認(rèn)真記錄系統(tǒng)中有哪些類在調(diào)用CoreData API的時候,我才看清了原來CoreData只是這個復(fù)雜龐大的系統(tǒng)中種種問題的冰山一角而已。
二、系統(tǒng)面貌
在一個有著良好分層結(jié)構(gòu)的系統(tǒng)中,每一層都有它自己的職責(zé):顯示層負(fù)責(zé)響應(yīng)用戶事件,調(diào)用業(yè)務(wù)層的邏輯,最后做數(shù)據(jù)呈現(xiàn);業(yè)務(wù)邏輯層負(fù)責(zé)業(yè)務(wù)規(guī)則與數(shù)據(jù)處理;數(shù)據(jù)訪問層封裝底層數(shù)據(jù)庫的操作,網(wǎng)絡(luò)訪問層與其并列,負(fù)責(zé)網(wǎng)絡(luò)請求、json解析等等。無論是MVC、MVVM、VIPER,歸根結(jié)底都是在”單一職責(zé)“、“關(guān)注點分離”、“高內(nèi)聚低耦合”的原則下變化,只是表現(xiàn)形式和涵蓋的層次各異。
而在我們的代碼中,幾乎所有的顯示層對象,包括ViewController、ViewModel,甚至View里面都混雜了大量的CoreData API調(diào)用,直接進(jìn)行數(shù)據(jù)庫操作。大概有以下兩種方式
方式一
初始化NSFetchedResultsController,然后發(fā)起請求

方式二
把自身當(dāng)做CoreData的delegate,對數(shù)據(jù)庫變化后作出響應(yīng)

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

整理出來這幅圖以后,看著眼前密密麻麻的API調(diào)用,看著眾多臃腫龐大的ViewController,我的大腦幾乎失去了思考的能力,不知道如何下手。
三、方案選型
冷靜過后,我最先排除掉的是重寫這種簡單粗暴的方式。表面上看來,我們可以通過重寫得到一個干凈利落的方案,層次結(jié)構(gòu)清晰,職責(zé)分離;但與之相伴的是巨大的風(fēng)險:
范圍不可控——遺留系統(tǒng)的難點就在于牽一發(fā)而動全身,影響范圍極廣。稍不留神,重寫的工作就會如野火燎原般蔓延開來,不可收拾。
長時間無法上線——在整個過程中,直到最后完成的那一刻之前,系統(tǒng)會處于一直不可用的狀態(tài)。漫長的時間里,所有的新功能都被阻塞,不能交付。沒有哪個產(chǎn)品團(tuán)隊能承擔(dān)這樣的結(jié)果。
第二個被排除掉的方案是特性分支。把重寫的工作放到分支上完成,其他人繼續(xù)在主干上開發(fā)新特性,直到重寫結(jié)束再合并回主干——這種做法確實比直接重寫要好上那么一點點,因為新特性還是可以不受影響的;但長期沒有跟主干合并的分支,在經(jīng)歷上四五個月的重寫之后,天知道到最后要花多長時間來處理合并沖突?
既想減小對系統(tǒng)的影響,又想不影響新功能上線,又不想處理大量的合并沖突,最后的方案就只剩下了一種,那就是抽象分支(Branch by Abstraction)+特性開關(guān)(Feature Toggle)。
抽象分支
抽象分支這個名字的緣起是針對版本庫分支而言的,它允許開發(fā)者在一條“抽象”的分支上并行工作,無需創(chuàng)建一條實際的分支,從而避免無謂的合并開銷。Martin Fowler和Jez Humble都曾在多年前撰文介紹過這個重構(gòu)方案。
它的工作原理很簡單:當(dāng)我們想要替換掉系統(tǒng)中的某個組件——名為X——時,首先為X組件創(chuàng)造一個抽象層,這一層里面可能會有大大小小若干接口或是協(xié)議,把系統(tǒng)中對X組件的訪問都隔離在抽象層之下,系統(tǒng)只調(diào)用抽象的接口/協(xié)議,不會接觸到具體的API實現(xiàn)。如下圖所示。

這一步我們可以通過提取方法、提取類和接口等重構(gòu)手法來完成;這以后系統(tǒng)就徹底跟X組件解耦了,它依賴的只是一組抽象接口,而非具體實現(xiàn)。這時候,我們就可以著手在這個抽象層下面,進(jìn)行新組件的開發(fā)工作,讓它也實現(xiàn)同一套接口即可。

這之后,我們再使用特性開關(guān)(其原理及實現(xiàn)見下節(jié)),讓這個抽象層在生產(chǎn)環(huán)境下調(diào)用舊組件,測試環(huán)境下調(diào)用新組件,從而在完全不影響交付的情況下,完成對新組件的測試。測試結(jié)束后,就可以打開開關(guān),讓系統(tǒng)在線上使用新組件,等徹底穩(wěn)定后,把開關(guān)代碼和舊組件代碼全部刪掉,替換工作就完成了。

在上述整個開發(fā)過程中,任何一個階段都可以做到細(xì)粒度的任務(wù)分解,然后小步提交,每次提交都自動觸發(fā)單元測試和集成測試,保證不影響現(xiàn)有功能。在頻繁提交的情況下,也不會出現(xiàn)大量的代碼合并沖突,無論是做組件替換還是新特性開發(fā),開發(fā)人員都可以基于同一套代碼庫工作。這就大大減少了對系統(tǒng)的沖擊和交付風(fēng)險。
下面介紹特性開關(guān)的原理與實現(xiàn)。
特性開關(guān)
先看一段代碼:

在這個例子中,我們要替換一個Storyboard的布局和相關(guān)ViewController的功能,耗時很久,如果直接在主干上修改,就會直接影響到現(xiàn)有的App,在功能完成之前都無法上線;如果拉一條分支出來做,未來就又會有大量的合并沖突。使用如上的特性開關(guān)就會避免上述問題。
當(dāng)shouldDisplayNewSearchResultsScreen的值返回為真,就使用新的Storyboard,返回為假,就使用舊的Storyboard。這樣一來,只要開關(guān)處于關(guān)閉狀態(tài),未完成的功能就是對用戶不可見的,我們就既可以在開發(fā)環(huán)境下自測,也可以部署到測試環(huán)境下做驗收測試,還可以針對開關(guān)為真的情況寫對應(yīng)的單元測試,讓每次代碼提交都有持續(xù)集成驗證。這期間還可以繼續(xù)發(fā)布新版本,用戶完全感知不到影響,直到我們決定打開開關(guān)為止。
特性開關(guān)可以有多種實現(xiàn)方式。
1. 預(yù)編譯參數(shù)
在預(yù)編譯參數(shù)中傳值,讓不同的xcconfig文件傳入不同的值,然后在代碼中做判斷。例如我們可以定義internal和production兩個Target,為內(nèi)部發(fā)布和外部發(fā)布分別生成不同的ipa文件,然后在internal的xcconfig文件中定義
GCC_PREPROCESSOR_DEFINITIONS = INTERNAL_TARGET=1
而后就可以在Toggle代碼中這樣寫
#ifdef INTERNAL_TARGET
#define isInternalTarget YES
#else
#define isInternalTarget NO
#endif
//本特性只在Internal Target中可見
+ (BOOL)shouldDisplayNewSearchResultsScreen
{
return isInternalTarget;
}
我們系統(tǒng)中絕大部分的特性開關(guān)都是用這種方式實現(xiàn)的。
2. NSUserDefaults
有些功能可能對App有破壞性的影響,即便是設(shè)成只對Internal Target可見,也會影響到QA的回歸測試。我們給Internal Target做了個Developer Settings界面,讓開發(fā)人員可以自己修改開關(guān)狀態(tài),把開關(guān)的值存放在NSUserDefaults里面,默認(rèn)返回false,只有在界面上手工切換之后才會返回true。測試和開發(fā)互相不受影響。
向Realm遷移的特性開關(guān)使用的就是這種方式。
3. 服務(wù)器取值
配置參數(shù)的值也可以通過服務(wù)器下發(fā)。這種做法的好處是比較靈活,在啟用/禁用某項功能的時候不需要發(fā)布新版本,只需要后臺配置,缺點是會增加集成和后臺開發(fā)的工作量。
4. A/B測試
還有一個辦法是使用第三方的A/B測試服務(wù),如果缺少后臺開發(fā)人員的話,這也是一個選擇。但第三方的穩(wěn)定性往往就會成為制約因素,Parse為推送通知提供過A/B測試服務(wù),但是它到了17年就會被關(guān)閉了;我們用Amazon的A/B測試框架用了一段時間,然后Amazon也宣布今年8月份停用……目前我們還在尋找備選方案。
四、?技術(shù)實現(xiàn)
在具體落實抽象分支和特性開關(guān)的時候,一共分成了如下幾個階段:
1. 建立數(shù)據(jù)訪問層
前文說過,系統(tǒng)中ViewController使用NSManageContext的方式一共有兩種。
第一種是直接初始化NSFetchedResultsController,發(fā)起請求,這種方式比較好處理,我們首先把跟數(shù)據(jù)請求有關(guān)的操作從ViewController中提取成一個方法,放到另一個對象中實現(xiàn),以便日后替換。然后把所有的數(shù)據(jù)訪問的方法都提取成一個協(xié)議,讓數(shù)據(jù)層之上的對象都依賴于這個協(xié)議,而不是具體對象。如下所示
@protocol REAPersistenceService <NSObject>
- (NSArray *)getTodayUpcomingEvents;
//其余方法略過
@end
@interface REACoreDataPersistenceService: NSObject<REAPersistenceService>
+ (instancetype)sharedInstance;
@end
@implemention REACoreDataPersistenceService
- (NSArray *)getTodayUpcomingEvents {
//封裝了NSFetchedResultsController的初始化和performFetch操作
}
@end
我們同時還需要使用特性開關(guān),來決定給上層返回哪一個PersistenceService對象:
@implemention REAPersistenceServiceFactory
+ (id<REAPersistenceService>)service {
if([REAToggle shouldUseRealm]) {
return [REARealmPersistenceService sharedInstance];
} else {
return [REACoreDataPersistenceService sharedInstance];
}
}
改造過后的ViewController就簡單多了
- (instancetype)init {
self = [super init];
if (self) {
_persistenceService = [REAPersistenceServiceFactory service];
}
return self;
}
- (NSArray *)getTodayUpcomingEvents {
return [self.persistenceService getTodayUpcomingEvents];
}
第二種方式是ViewController把自己注冊為NSFetchedResultsController的delegate,實現(xiàn)了相應(yīng)接口,當(dāng)數(shù)據(jù)發(fā)生變化時刷新UI。這個處理起來就比較棘手,因為我們希望提取之后的接口能夠適配于Realm,這樣才能無縫切換。然而Realm一方面目前沒有像CoreData那樣的細(xì)粒度通知,另一方面用的也不是delegate,而是提供了addNotificationBlock:方法,讓調(diào)用者可以注冊block。二者的接口并不兼容。
這種情況下,我們的新協(xié)議就只能取二者交集:
@protocol REAPersistenceDataDelegate<NSObject>
- (void)contentDidChange:(id)content;
@end
這個協(xié)議跟CoreData和Realm的接口都不一致,兩個PersistenceService都在內(nèi)部做了適配和轉(zhuǎn)發(fā)。比如在Realm的實現(xiàn)中,我們讓它對外使用REAPersistenceDataDelegate協(xié)議來注冊delegate,對內(nèi)依然使用addNotificationBlock:方法監(jiān)聽,收到消息以后再調(diào)用delegate的contentDidChange方法。
由于Realm沒有細(xì)粒度通知,本來還想用
- (void)objectDidChange:(id)object;
這種方法來封裝CoreData的
- (void)controller:(NSFetchedResultsController *)controller
didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath
forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath
現(xiàn)在也只好作罷,讓delegate收到數(shù)據(jù)后自己計算應(yīng)當(dāng)刷新哪部分的數(shù)據(jù)。
2. 為數(shù)據(jù)對象提取協(xié)議
除了數(shù)據(jù)訪問的代碼以外,我們還把所有的數(shù)據(jù)對象上的公有屬性和方法都提取了相應(yīng)的協(xié)議,然后修改了整個App,讓它使用協(xié)議,而不是具體的數(shù)據(jù)對象。這也是為以后的切換做準(zhǔn)備。
3. 使用Realm實現(xiàn)
前兩步完成之后,我們就建立起了一個完整的抽象層。在這層之上,App里已經(jīng)沒有了對CoreData和數(shù)據(jù)對象的依賴,我們可以在這層抽象之下,提供一套全新的實現(xiàn),用來替換CoreData。
在實現(xiàn)過程中,我們還是遇到了不少需要磨合的細(xì)節(jié),比如Realm中的一對多關(guān)聯(lián)是通過RLMArray實現(xiàn)的,并不是真正的NSArray,為了保證接口的兼容性,我們就只能把property定義為RLMArray,再提供一個NSArray的getter方法。種種問題不一而足。
4. 切換開關(guān)狀態(tài)
上篇文章說到,我們在遷移過程中的特性開關(guān)是用NSUserDefaults實現(xiàn)的,在界面上手工切換開關(guān)狀態(tài)。這樣的好處是開發(fā)過程不會影響在Hockey和TestFlight上內(nèi)部發(fā)布。直到實現(xiàn)完成后,我們再把開關(guān)改成
+ (BOOL)shouldUseRealm
{
return isInternalTarget;
}
讓測試人員可以在真機(jī)上測試?;貧w測試結(jié)束之后,再讓開關(guān)直接返回true,就可以向App Store提交了。
5. 數(shù)據(jù)遷移
這個無需多說,寫個MigrationManager之類的類,用來把數(shù)據(jù)從CoreData中讀出,寫到Realm里面去。這個類大概要保留上三四個版本,等絕大部分用戶都已經(jīng)升級到新版本之后才會刪掉。
6. 后續(xù)清理
特性開關(guān)是不能一直存活下去的,否則代碼中的分支判斷會越來越多。我們一般都會在上線一兩個星期之后,發(fā)現(xiàn)沒有出現(xiàn)特別嚴(yán)重的crash,就把跟開關(guān)有關(guān)的代碼全都刪掉。
在第一步建立數(shù)據(jù)訪問層的時候,我們創(chuàng)建出了一個特別龐大的PersistenceService,它里面含有所有的數(shù)據(jù)訪問方法。這只是為了方便切換而已,切換完成后,我們還是要根據(jù)訪問數(shù)據(jù)的不同,建立一個個小的Repository,然后讓ViewModel對象訪問Repository讀寫數(shù)據(jù),把PersistenceService刪掉。
最后形成的架構(gòu)如圖所示

五、總結(jié)
四個多月的時間里,看著自己的構(gòu)思落地生根到開花結(jié)實,看著代碼結(jié)構(gòu)從混亂變成有序,心里的滿足感無可言喻?;仡^望去崎嶇征途,其間的爭執(zhí)、焦慮、興奮、堅定,盡皆化成了一行行代碼融入系統(tǒng)的底層結(jié)構(gòu),化成了沉甸甸的收獲。
首先,要勇敢
面對混亂的代碼庫,人們最容易做出的選擇就是復(fù)制黏貼??纯辞叭嗽趺醋觯透肇埉嫽韼坠P。以前的代碼是這么寫的,我照樣拷一份過來,改一改就能實現(xiàn)新需求。這種做法我們不能說它錯,然而它既不能讓這個系統(tǒng)變得更好一點,更干凈一點,也不能讓我們的技術(shù)得到提升。它能以最快的速度完成眼下的需求,結(jié)果是為團(tuán)隊留下更多的技術(shù)債。
欠下的債終究是要還的,團(tuán)隊里一定要有人站出來跟大家說,我們不能讓代碼繼續(xù)腐爛下去,我們要有清晰的目標(biāo)和正確的策略,在重構(gòu)中讓優(yōu)秀的設(shè)計漸漸涌現(xiàn)。這才是正途。
要有正確的方法
Martin Fowler在博客中總結(jié)過重構(gòu)的幾種流程,在遺留代碼中工作,Long-Term Refactoring是不可或缺的。
人們需要預(yù)見到在未來的產(chǎn)品規(guī)劃中,哪些組件應(yīng)當(dāng)被替換,哪部分架構(gòu)需要作出調(diào)整,把它們放到迭代計劃里面來,當(dāng)做日常工作的一部分。抽象分支和特性開關(guān)在Long-Term Refactoring可以發(fā)揮顯著的效果,它們是持續(xù)交付的保障。
技術(shù)債同樣需要適當(dāng)管理,按照嚴(yán)重程度和所需時間綜合排序,一點點把債務(wù)償還?;蛟S有人覺得這是浪費時間,但跟一路披荊斬棘,穿越溪流,攀過險峰,歷盡艱難險阻相比,我寧愿朝著另一個方向走上一段,因為那邊有高速公路。
遺留代碼的出現(xiàn),也意味著在過往的歲月中團(tuán)隊忽略了對代碼質(zhì)量的關(guān)注。為了不讓代碼繼續(xù)腐化,童子軍規(guī)則必須要養(yǎng)成習(xí)慣。
設(shè)計會過時,但設(shè)計原則不會
很多技術(shù)決策都不是非黑即白的,它們更像是在種種約束下做出的權(quán)衡。比如在本文的例子中,當(dāng)CoreData被Realm所替換以后,抽象層還要不要保留?ViewModel應(yīng)該直接調(diào)用Repository,還是RepositoryProtocol?有人會覺得這一層抽象就好比只有單一實現(xiàn)的接口一樣,沒有存在的價值,有人會覺得幾年后Realm也會過時被新的數(shù)據(jù)庫取代,如果保留這層抽象,就會讓那時候的遷移工作變得簡單。但無論怎么做,過上一兩年后,新加入團(tuán)隊的人都可能會覺得之前那些人做的很傻。
我們無法預(yù)見未來,只能根據(jù)當(dāng)前的情況做出簡單而靈活的設(shè)計。這樣的設(shè)計應(yīng)當(dāng)服從這些設(shè)計原則:單一職責(zé)、關(guān)注點分離、不要和陌生人說話……讓我們的代碼盡可能保持高內(nèi)聚低耦合,保證良好的可測試性。時光會褪色,框架會過時,今天的優(yōu)秀設(shè)計也會淪落成明天的遺留代碼,但這些原則有著不動聲色的力量。