重構加重寫保證版本功能的空中加油

在一個產品長期的研發過程中,必須時刻對程式碼保持警惕,一旦發現程式碼有腐爛的跡象,就需要考慮及時重構,剔除程式碼的壞味道,讓程式碼煥然一新。然而,在進度的逼迫下,我們承受了及時交付功能的壓力,團隊成員對糟糕程式碼的敏感度又不夠高,在這二者的夾擊之下,稍有疏忽,整個程式碼庫就有可能變得積重難返。
多數時候,我們疲於應對各種需求。一方面,我們釋出的版本正在支撐著客戶的生產應用;另一方面,我們還需要不斷地開發新功能,以滿足客戶不斷提出的新需求或者需求變更。同時,我們對產品有著雄心勃勃的計劃,希望它能夠在不斷的演化下變得更加強大。我們清醒地感知到,若任由程式碼如此發展下去,程式碼腐爛的結果會進而導致架構的腐爛。就像一架正在執行飛行任務的航空器,明知到缺少足夠的燃料,需要返回基地加油,卻又不得不繼續飛行,否則無法按時到達目的地。這時,我們就需要針對程式碼庫進行“空中加油”。
理想的方式是對現有的程式碼庫進行小步重構,然而眾多阻力讓我們沒有信心駕馭重構。這些阻力包括:
- 隨著功能的增加,程式碼庫開始變得龐大
- 程式碼的腐爛情況較為嚴重,需要在結構上做重大調整
- 團隊成員欠缺對大型程式碼庫的重構能力
- 單元測試與整合測試的測試覆蓋率太低
- 重構與新功能開發同時進行,破壞原有功能的風險太大
顯然,想要通過重構實現空中加油,談何容易!但為了避免未來架構的腐化,對現有程式碼庫進行手術又勢在必行。因此,基於當前程式碼庫現狀、產品需求與團隊成員能力,我確定了 “重構+重寫”的改造策略 ,美其名曰“空中加油”。
程式碼的壞味道
要解決的程式碼問題很多,這裡僅以其中一個關鍵部分作為示例。在我們的產品中,ElasticSearch作為儲存主題區資料的資料庫,但作為產品,我們還需要應對不同客戶的需求,例如針對資料規模相對較小的客戶,亦有可能使用關係型資料庫,例如Oracle來儲存主題區資料。因此在對主題區的資料進行建模時,我們並沒有利用ElasticSearch的儲存特性,形成主題區業務模型與資料模型的一一對應。主題區業務模型是一個樹模型,但在持久化到資料庫中時,卻是將業務模型拍平,形成關係資料庫的表結構。
在確定持久化架構時,一開始我就確定了兩個職責的分離,並以gateway和repository作為承擔不同職責的物件。gateway負責訪問主題區資料庫,完成對資料的CRUD操作,而repository則借鑑了DDD的資源庫概念,體現的是業務角度的資源存取。
我犯下的一個錯誤是沒有及時進行程式碼走查,在確定了此架構原則後,由於進度壓力與開發能力的問題,團隊成員在開發時並沒有體會到這種職責分離的本質,不斷地編寫程式碼,而實現卻與當初確定的原則漸行漸遠。等到我發現時,問題已經變得較為嚴重了:
- 部分Repository沒有守住邊界,為了滿足自己的業務,越俎代庖做了gateway應該做的事情
- repository沒有按照業務去定義,出現了許多從命名上大同小異的repository,每個開發人員似乎只熟悉或相信自己定義的repository
- 有時候為了重用,建立了許多不必要的抽象,繼承層次變得混亂
而這一切在引入兩層主題區(為了實現多資料來源處理等功能,我們引入了前置主題區和細節主題區)之後,就變得更加嚴重了。兩個不同的主題區資料結構幾乎一致,但訪問的ElasticSearch叢集以及index和type卻又不同。有的開發人員為了方便,例如他要開發的功能僅需要前置主題區,就定義了一個前置主題區專用的repository,從而引入各種混淆的repository。隨著需求的演化,最早定義在 ElasticSearchGateway
中的基本方法已經不能滿足需求,於是又不斷增加。但這些方法的操作目標大同小異,就出現了許多混淆不清的方法定義,例如查詢單條資料、查詢多條資料、按照條件查詢、查詢時執行聚合與排序以及按照範圍進行查詢,使得gateway的介面方法變得越來越亂,並被各個repository依賴。
需求與版本演化
在2018年12月底,經過不斷的測試和bug修復,我們按照客戶的進度要求交付了一個相對完整的版本,滿足了客戶的需求,並已部署到生產環境中投入使用。在投入使用之後,客戶的需求又源源不斷繼續湧來,使得我們釋出的版本始終不能保證穩定。按照產品的規劃,我們還需要實現一些重要的功能特性,並可能在未來支援更多的客戶。不同客戶的資料協議、資料來源支撐都不相同,這意味著許多相同的功能需要為不同的客戶提供各自專有的實現。
然而,程式碼的重構又變得刻不容緩。一方面我們要開發新功能,另一方面又需要對原有程式碼進行重構,而這些變更的釋出又需要按照計劃部署到生產環境。如前所述,由於眾多阻力,重構的風險太大。該怎麼辦?我們當然可以建立版本分支,例如為當前執行的版本建立一個release分支,然後在master上進行重構和新功能開發。問題在於,重構與新功能釋出的頻率並不一致,後者甚至可能要求每週釋出。如果新功能開發和重構都工作在master上,在釋出新功能到生產環境時,我們擔心重構會引入未知的bug,破壞已經交付的穩定功能。如果新功能在分支的release版本下開發,又因為重構的影響巨大,導致程式碼無法及時合併,或者修復程式碼衝突的代價太大。
重構加重寫的策略
為了解決這一問題,我們決定仍然將master同時作為開發與重構的版本分支,之前的release版本則作為特殊情況下的後備版本。針對程式碼的改造,我們則採用重構+重寫的策略。
當我們要重構一個類時,尤其是要重構該類的方法時,往往需要事先確定待重構的方法究竟有多少呼叫依賴。一旦該方法被多個類呼叫時,重構介面就成了一件非常棘手的工作。即使保留介面不變,僅僅是重構方法內部的實現,也需要慎之又慎,畢竟不同的呼叫者可能對這個方法實現會有不同的需求,如果程式碼編寫不夠清晰,極有可能在重構時不小心破壞功能,引入Bug。
這種判斷依賴的方式是由內自外的方式,而我們的策略則反其道而行之,先“守”住外部的呼叫點不變,然後以重寫的方式去替換這個呼叫。在重寫的時候,我們會站在呼叫者的角度,逐步地完成重寫,以求小步地完成替換。每替換一小塊功能,都編寫測試去覆蓋所有分支,同時採用手動測試的方式,在類生產環境下執行,以確保這次小範圍的替換沒有引入問題。
整個重構加重寫的過程如下所示:
- 從外部呼叫者發現它依賴的類
- 建立新的類,然後僅將當前外部呼叫者需要呼叫的方法原封不動地搬移到新類中
- 在呼叫者內部的呼叫點,將舊類替換為新類,並保證功能正確
- 編寫對應的測試覆蓋該功能,然後對新類進行重構,執行測試保證重構沒有引入錯誤
- 若新類的內部亦依賴了有待重構的舊有實現,則將新類視為當前的外部呼叫者,重複第1步
- 以此類推,直到舊有的類沒有別的依賴,則安全刪除舊有類
案例:重構AircraftRepository和ElasticSearchGateway
以我們現在的專案為例。ElasticSearchGateway本是需要重構的目標,但它的依賴非常多,如下圖所示,一共有50處依賴:

它的其中一個方法 queryByCondition()
有32處依賴,如下圖所示:

該方法是我們需要修改的目標。現在,我們反其道而行之,在外部定位一個面向某個業務發起呼叫的類,如針對航空器位置業務的一個類 AircraftProcessor
,它的呼叫關係如下所示:

圖中標記為灰色的類,就是我本希望重構的類,然而根據前面的分析,它們都有多處呼叫者,要進行重構,就可能牽一髮動全身,要做到改變現有程式碼的結構而不破壞其功能,就好比做一臺精密的腦顱手術一般,難度非常大。為此,我們選擇的做法是定義一個新的 AircraftRepository
類,並站在呼叫者 AircraftLocationPreFilter
的角度,將它需要的方法原封不動地拷貝到新類中,並在呼叫者內部以新類替換對舊類的呼叫。如果已有自動化測試覆蓋這一路徑,則執行測試,看這一替換是否影響了原有的功能實現。如果沒有自動化測試,則需要編寫新的測試去覆蓋它。可以考慮編寫單元測試和整合測試。
針對新類中的實現,可以在不改變其功能實現的前提下,對其進行重構。這個重構包括調整原有的方法介面,也包括對內部實現進行程式碼質量改進。由於新類只有這一個依賴點,重構產生的影響就會被限制到一個很小的範圍。
例如呼叫者 AircraftLocationPreFilter
呼叫了 queryDistinctOrgStationBy()
方法,它的定義為:
public List<FlightPath.Track> queryDistinctOrgStationBy(String craftNo, String scopeField, String gt, String lt, String aggField, String sortField) {}
AircraftLocationPreFilter
對該方法的呼叫如下所示:
private List<FlightPath.Track> getPeriodTimeTracks(String craftNo, Date startTime, Date endTime) { return aircraftRepository.queryDistinctOrgStationBy( craftNo, UPDATE_TIME_FIELD, DateUtil.transformTime(startTime, DateUtil.YYYY_MM_DD_T_HH_MM_SS), DateUtil.transformTime(endTime, DateUtil.YYYY_MM_DD_T_HH_MM_SS), ORG_STATION_AGG_FIELD, ORIGINAL_TIMESTAMP_SORT_FIELD); }
這個方法的定義存在問題。一方面它的方法名未能清晰表達查詢航空器路徑的意圖,另一方面,方法簽名暴露了太多不必要的欄位,例如方法的第2、5、6三個引數就應該封裝到 AircraftRepository
中,而對第3、4兩個引數的轉換邏輯也不應該暴露出來。現在可以對新 AircraftRepository
的方法自由地進行重構,例如通過“修改方法簽名”的重構手法,將方法簽名修改為:
public List<FlightPath.Track> queryTracksBy(String craftNo, Date startTime, Date endTime) {}
方法簽名的修改會直接影響對該方法的呼叫,呼叫程式碼修改為:
private List<FlightPath.Track> getPeriodTimeTracks(String craftNo, Date startTime, Date endTime) { return aircraftRepository.queryTracksBy(craftNo, startTime, endTime); }
現在,針對 AircraftProcessor
與 AircraftLocationPreFilter
進行自動化與手動測試,保證修改的程式碼分支沒有受到任何影響。
重寫、替換、然後重構,這就是主要的三個步驟,如下圖所示:

黃色的 AircraftRepository
是新建的類,它的程式碼僅為呼叫者 AircraftLocationPreFilter
提供服務,因此重構它的程式碼並沒有太大壓力。 注意 ,這裡的一個要點是 僅拷貝 AircraftLocationPreFilter
需要呼叫的舊AircraftRepository方法,而非原封不動地將整個舊 AircraftRepository
拷貝到新類中 ,因此新類的程式碼數量以及依賴點都非常的少。我們能夠清晰地知道這個改動影響到了哪些程式碼分支,就通過測試去保護這些分支,使得這一步驟是充滿信心的,保證不會引入新的Bug。一旦確認了新類的方法沒有任何問題,就可以找到原方法的其他依賴者,採用同樣的方式進行替換。在此過程中,舊 AircraftRepository
不受任何影響,仍然保留著原貌。除非隨著替換過程的推進,這個舊類的所有呼叫者都轉向了新類,它才正式退出歷史舞臺。
新 AircraftRepository
類的方法與實現雖然進行了重構,但它對 ElasticSearchGateway
的依賴仍然未變。現在可以如法炮製,針對 AircraftRepository
對 ElasticSearchGateway
的呼叫,做相似的重構加重寫,如下圖所示:

AircraftRepository
僅用到了舊 ElasticSearchGateway
的一個 queryByScopeAndTerm()方法
:
class AircraftRepository... private List<AircraftLocation> queryLocations(String craftNo, String scopeField, String min, String max)... List<SearchConsequence> consequences = gateway .queryByScopeAndTerm(condition, ORG_STATION_AGG_FIELD, ORIGINAL_TIMESTAMP_SORT_FIELD, scopeField, min, max, AIRCRAFT_LOCATION_TABLE);
為之建立的新 ElasticSearchGateway
也就只包含 queryByScopeAndTerm
方法。當新 ElasticSearchGateway
替換了舊類後,重複之前的步驟,再次針對 AircraftProcessor
與 AircraftLocationPreFilter
進行自動化與手動測試,保證修改的程式碼分支沒有受到任何影響。同理,新類的程式碼數量和依賴點都非常少,再對新類進行重構就變得更加簡單。我們認為舊類的 queryByScopeAndTerm()
方法既無法清晰地表達意圖,也不利於應對各種查詢產生的變化。這時,我們引入了Builder模式,修改原有介面為DSL風格的介面。在新的 AircraftRepository
類中,保持了原來的呼叫方式:
private List<AircraftLocation> queryLocations(String craftNo, String scopeField, String min, String max) { Map<String, Object> condition = new HashMap<>(); condition.put(CRAFT_NO_FIELD, craftNo); QueryResult queryResult = gateway.queryByScopeAndTerm(condition, ORG_STATION_AGG_FIELD, ORIGINAL_TIMESTAMP_SORT_FIELD, scopeField, min, max, AIRCRAFT_LOCATION_TABLE); return queryResult.all(AircraftLocation.class); }
重構了新 ElasticSearchGateway
的 queryByScopeAndTerm()
方法之後,呼叫程式碼變成了:
private List<AircraftLocation> queryLocations(String craftNo, String scopeField, String min, String max) { QueryResult queryResult = gateway.query() .from(AIRCRAFT_LOCATION_TABLE) .and(CRAFT_NO_FIELD, craftNo) .range(scopeField, min, max) .aggregateBy(ORG_STATION_AGG_FIELD) .orderBy(ORIGINAL_TIMESTAMP_SORT_FIELD) .run(); return queryResult.all(AircraftLocation.class); }
通過這樣的重構加重寫方式,我們新引入的類既在設計上通過重構得到了改進,還能借助這種小步前行的逐步替換方式,確保新實現在業務場景中得到了驗證,規避了引入新bug的風險。一旦新介面得到了質量保證,就可以逐漸地替換舊有實現,直到舊有的repository與gateway類不再有任何依賴時,就可以將它們刪除,換來更加整潔的程式碼了。
抽象分支
事實上,這種重構加重寫方式與“ 抽象分支(Branch By Abstraction) ”實踐不謀而合。在Martin Fowler討論抽象分支的文章中,他詳細地講述了這個過程。他將我們要替換的類或者模組稱之為Flawed Supplier,抽象分支的做法是先為Flawed Supplier建立一層抽象層,並將客戶端的呼叫程式碼指向該抽象層,同時為其建立單元測試:

接下來提供一個新的supplier,它同樣實現了這個抽象層。一旦這個新的supplier實現完畢,就可以逐步修改客戶端程式碼,轉而使用這個新的supplier。最後,在驗證了功能沒有問題,也沒有任何客戶端程式碼還需要舊有supplier後,就可以去掉它:

重構加重寫策略中新增的類就是這個新Supplier。但我並沒有引入一個額外的抽象層,也未建立一個全新的Supplier,而是採用拷貝的方式直接重用程式碼,以避免引入不必要的bug。可以認為新增的類其實是要替換類的子集,保留了相同的介面和實現,從而避免多餘的實現與依賴,使得重構可以更好地進行。
結論
重構加重寫的策略雖然慢,但每一步前進的步伐都非常穩健,充分利用了程式碼量與依賴點少的新類來降低重構的難度。每完成一個新類的重構,我們都需要測試去驗證。如果之前沒有測試保障,則要求為新實現編寫測試去覆蓋,相當於在這個修改過程中慢慢地償還了過去欠下的技術債務。由於舊類沒有受到任何影響,即使重構或重寫失敗,我們還能夠安全地返航。
在這個過程中,會有很長一段時間存在 新舊共存 的狀態。在執行重構的過程中,如果別的團隊成員正在開發的新功能需要呼叫被重構的介面,由於重構還沒有完成或未通過全面的測試,則允許該新功能繼續呼叫舊有的類,保證了新功能的開發不受影響。倘若替換已經完成,舊類不再有存在價值時,則需要及時果斷地將其刪除,新開發的功能也要立即轉為對新類的呼叫。由於新類的功能已經得到了保證,不必擔心呼叫它會引入錯誤。
執行重構加重寫的過程需要小步前行,並及時提交新增或重構的程式碼,同時提高自動化測試的覆蓋率。所有工作都在一個版本上進行,並保證重構加重寫的功能都是正確可用的,保證該工作版本隨時處於可上線的狀態。
在進行重構加重寫時,還允許靈活調整人力資源。倘若需求變更或新需求開發的優先順序高,交付壓力大,就可以只安排少數人員專注於重構加重寫,其他人員全力以赴新功能的開發,甚至在壓力太大的情況下,停止進行重構加重寫。反之,若開發壓力較小,又可以勻出更多的人來進行重構加重寫,儘快還清技術債。顯然,這種策略使得我們進可攻退可守,理想狀態下,甚至能夠在一直保證不敗的前提下,擁有隨時發起進攻的選擇權。