Evolving Frameworks#
前面章節的 implementation patterns 假設「修改程式碼的成本」遠低於「理解與溝通程式碼意圖的成本」。然而,framework 開發違反了這個假設——framework 開發者無法直接修改 client 端的程式碼。
- 以 JUnit 為例,修改 JUnit 的設計本身通常不難,但若大量下游工具開發者和測試撰寫者都必須跟著改,部署成本就非常高昂
- 不相容的更新(incompatible updates) 代價極高,因此應盡可能避免
在發佈 JUnit 4 時,作者團隊花了將近一半的工程預算來降低 client 端的部署成本——確保新式測試能搭配舊工具運作,舊式測試也能搭配新工具運作,同時保留未來自由修改 JUnit 的空間。
本章概述在開發 framework 時,implementation patterns 會產生哪些變化,涵蓋以下主題:
- Framework 開發面臨的挑戰
- 如何降低不相容升級的衝擊
- 如何設計 framework 以避免不相容升級
Changing Frameworks without Changing Applications#
Framework 開發與維護的**根本兩難(fundamental dilemma)**在於:framework 需要持續演進,但破壞既有 client 程式碼的代價極高。
- 理想的升級是新增功能而不改變任何既有功能
- 但完全相容的升級並非總是可行
複雜度與相容性的取捨#
- 維持向後相容性(backward compatibility) 往往會增加 framework 的複雜度
- 當維持完美相容的成本超過對 client 的價值時,就需要做出取捨
- 改善 framework 開發經濟效益的兩個方向:
- 降低不相容升級發生的機率
- 降低不相容升級發生時的成本
在一般應用程式開發中,降低複雜度到最低是讓程式碼易於理解的好策略。但在 framework 開發中,有時增加複雜度反而更符合成本效益——為了增強 framework 開發者在不破壞 client 端的情況下改進 framework 的能力。
可適用性 vs. 演進自由度#
- 一般的 implementation patterns 追求程式碼「盡可能廣泛適用,同時保持易於理解」
- 在 framework 開發中,會犧牲部分適用性來換取未來修改設計的自由度
- 例如:一般程式碼中作者傾向將 field 設為
protected,但開發 framework 時則設為privateprotectedfield 讓 client 更容易使用 superclass,但不利於未來演進privatefield 讓 framework 開發者能自由更改資料結構而不影響 client
設計目標#
目標是 framework 在以下兩組特性之間取得平衡:
- 夠複雜以支持演進,但夠簡單以方便使用
- 夠窄以保留演進空間,但夠廣以發揮實際用途
這些額外的設計約束,正是 framework 開發比一般應用程式開發風險更高、成本更高的原因。
quadrantChart
title Framework vs. Application 設計取捨
x-axis "簡單" --> "複雜"
y-axis "受限" --> "自由演進"
Application: [0.2, 0.3]
Framework: [0.7, 0.8]Incompatible Upgrades#
即使 framework 升級可能破壞 client 端程式碼,仍有方法可以降低升級成本。
漸進式升級策略#
- 分階段進行小步升級,讓 client 預先知道即將到來的變化,並自行決定何時投入修改
- Deprecation(棄用):將程式碼標記為已棄用但保持功能運作一或多個版本,發出「client 需要遷移到新 API」的訊號
- Deprecation 是更通用策略的一個實例——維持兩種不同架構(parallel architectures) 來解決同一問題
- 平行架構增加了複雜度,但降低了升級的衝擊
Java Collection 的案例#
- Java 的舊
Vector和Enumerator類別在引入新的Collection體系時,被設計為向前相容(forward-compatible) - 現在(以 Java 來說是永遠)舊式 collection 程式碼都能繼續運行
利用 Package 提供漸進存取#
- 在新 package 中引入新類別,可以使用與舊類別相同的名稱
- 例如:將
org.junit.Assert升級為org.junit.newandimproved.Assert,client 只需修改 import 語句 - 修改 import 比修改程式碼邏輯的風險和侵入性更低
API 與實作分開變更#
- 另一種漸進策略是在同一版本中只改 API 或只改實作,不同時改兩者
- 中間版本(新介面搭配舊實作,或舊介面搭配新實作)讓各方有時間適應變化方向
- 在問題還小的時候就能解決技術問題
退休策略(Retirement Strategy)#
Framework 提供者與 client 之間的協議包含:多久 client 程式碼需要被迫升級。
- Sun 的承諾是舊程式碼永久可用
- Eclipse 則只在主版本號內維持相容性
- Framework 提供者需要在快速演進與提供穩定平台之間取得平衡
自動化升級工具#
- Eclipse 提供了另一種降低不相容升級成本的方式:提供自動化工具來升級 client 程式碼
- Eclipse 2.x 到 3.0 的升級中:
- 確保大多數 2.x plug-in 在 3.0 中無需修改即可運作
- 同時提供轉換工具,讓 2.x plug-in 完全符合 3.0 規範
透過組合多種策略,Eclipse 在保持快速演進的自由度的同時,也為既有 client 提供了大致穩定的功能。
flowchart TD
A["需要升級 Framework"] --> B{"能維持\n相容性?"}
B -->|"是"| C["直接新增功能"]
B -->|"否"| D["選擇漸進策略"]
D --> E["Deprecation\n標記棄用但保持運作"]
D --> F["平行架構\n新舊並存"]
D --> G["Package 版本化\n新 package 新名稱"]
D --> H["API/實作分開變更\n一次只改一邊"]
E & F & G & H --> I["提供自動化\n升級工具"]降低修改成本的其他方式#
- 如果 client 能透過簡單的 find/replace 操作切換到升級後的功能,修改成本就能降低
- 若方法名稱改變,保持參數順序不變會讓 client 升級更容易
- 未來或許能隨 framework 升級一起發佈 refactoring 集合,但目前降低升級成本可能會限制設計選擇
考慮 Client 社群#
- 如果現有 client 熱衷使用最新功能,他們會願意投入升級的心力
- 如果升級能大幅擴展 client 基數,可能值得忍受既有 client 的抱怨
- 但要小心不要為了虛幻的前景而疏遠真實的客戶,導致升級後反而失去所有 client
遠比不相容升級更理想的做法是:引入新功能的同時不影響既有 client 程式碼。本章後續將討論如何撰寫可以在不干擾 client 的情況下升級的 framework。
Encouraging Compatible Change#
要讓 framework 升級維持相容性,client 程式碼應盡可能依賴最少的 framework 細節。
- 但 client 必須依賴某些細節,否則 framework 就沒有存在意義
- 理想狀況:client 只依賴那些你不想改變的細節
- 由於成長和變化不可預測,無法預先決定哪些細節不會改變
- 但可以打概率牌:
- 減少可見細節的數量
- 優先揭露不太可能改變的細節
- 在提供有用功能的同時保留設計變更的自由度
相容性的變體#
在規劃升級時需要決定提供哪種相容性:
- 向後相容(Backward-compatible):client 仍能呼叫舊方法、傳遞舊物件給 framework
- 向前相容(Forward-compatible):framework 能將新式物件傳給 client,且表現與舊物件一致
- 選擇的相容性風格會影響開發和測試的工作量
JUnit 最新版本因為同時提供向前和向後相容而成本大增。使用者回報了幾個開發時未考慮到的相容性缺陷。但考量到龐大的既有測試基礎和大多不急於升級的 client,作者對此決定仍感到滿意。
Framework 以物件表示時的關鍵議題#
Java 中大多數 framework 表現為由 client 建立、使用或精煉的物件。以下分四個面向討論如何表示 framework,使 client 能使用所需功能,同時 framework 開發者能繼續演進:
- 使用風格(Style of Use)
- 抽象層級(Abstraction)
- 物件建立(Creation)
- 方法結構(Methods)
Style of Use#
Framework 支援三種主要使用風格:instantiation(實例化)、configuration(配置) 和 implementation(實作)。每種風格在易用性、彈性和穩定性之間提供不同的組合。也可以在單一 framework 中混合使用。
flowchart LR
A["Instantiation\n實例化"] --> B["Configuration\n配置"]
B --> C["Implementation\n實作"]
A -.- A1["最簡單\n只有資料變化\n例:new ServerSocket()"]
B -.- B1["中等複雜\n可容納邏輯變化\n例:TreeSet + Comparator"]
C -.- C1["最複雜/最彈性\n任意邏輯插入\n例:extends/implements"]Instantiation(實例化)#
- 最簡單的使用風格
- 範例:
new ServerSocket()——建立實例後透過呼叫方法來使用 - 適用於 client 只需要資料變化而非邏輯變化的情境
Configuration(配置)#
- 較複雜但更彈性的風格
- Client 建立 framework 物件,並傳入自己的物件,在預定時機被呼叫
- 範例:
TreeSet搭配自訂的Comparator
Comparator<Author> byFirstName= new Comparator<Author>() {
public int compare(Author book1, Author book2) {
return book1.getFirstName().compareTo(book2.getFirstName());
}
};
SortedSet<Author> sorted= new TreeSet<Author>(byFirstName);- 優點:能容納邏輯變化和資料變化
- 限制:
- 一旦開始呼叫 client 物件,就需要持續以相同方式和時機呼叫,否則有破壞 client 程式碼的風險
- 只能處理少數幾個維度的變化——超過一兩個配置選項就會變得過於複雜
Implementation(實作)#
- 當 client 需要比 configuration 更多的邏輯插入點時使用
- Client 建立自己的類別,供 framework 使用
- 只要 client 類別 extends framework 類別或 implements framework interface 即可
- 三種風格中最有可能限制未來設計自由度——framework 提供的 superclass 或 interface 的每一個細節都需要被保留
上述 Comparator 範例也可視為 implementation 風格的簡單版本——byFirstName 是 collection framework 的 comparator 抽象的一個實作。
Implementation 風格的擴展性遠優於 configuration,因為它能處理任意數量的獨立變化,每個變化由 framework 定義的 hook method 表示。
JUnit 的混合使用#
JUnit 混合了四種使用風格:
| 風格 | 說明 |
|---|---|
| Library class | JUnitCore 的靜態方法 run(Class...) 用於執行所有測試 |
| Instantiation | JUnitCore 也可實例化,提供更精細的測試執行和通知控制 |
| Configuration | @Test、@Before、@After annotation 是一種配置形式,讓測試撰寫者標識在特定時機執行的程式碼 |
| Implementation | @RunWith annotation 是一種實作形式,需要非標準測試執行行為的測試撰寫者可以實作自己的 runner |
Abstraction#
Implementation 風格引出了一個問題:抽象實體應該用 interface 還是 superclass 來表示? 兩種方式各有優缺,且不互斥——framework 可以同時提供 interface 和該 interface 的預設實作。
Interface#
- 優點:interface 記錄的細節極少,client 不會「意外」使用超出 framework 意圖的內容
- 缺點:在 interface 中新增方法會破壞所有 client 的實作
- 若能確保 client 只「使用」interface 而非「實作」它,就能新增方法而不破壞 client 程式碼
- 次要優點:client 類別可以同時 implement 多個 interface
儘管 interface 具有脆弱性,它們在 Java 世界中仍被廣泛用來表達抽象——這本身就是一個支持使用它們的論點。
flowchart TD
A["選擇抽象機制"] --> B{"抽象是否\n經常變動?"}
B -->|"是"| C["Superclass\n新增方法不破壞 client"]
B -->|"否"| D{"client 需要\n多重繼承?"}
D -->|"是"| E["Interface\n支援多重 implements"]
D -->|"否"| F{"偏好最少細節\n暴露?"}
F -->|"是"| E
F -->|"否"| CVersioned Interface(版本化介面)#
- 當需要為 interface 新增操作時,可以建立 sub-interface 並在其中放入新操作
- Client 可以將符合新 interface 的物件傳入任何接受舊 interface 的地方
- 既有程式碼繼續照常運作
代價是更大的 framework 複雜度——framework 需要在執行時明確分派(dispatch)。例如 AWT 有兩個版本的 layout manager interface,在多處出現這樣的程式碼:
...
if (layout instanceof LayoutManager2) {
LayoutManager2 layout2= (LayoutManager2) layout;
layout2.newOperation();
}
...Versioned interface 是在不影響 client 的前提下為既有 interface 抽象引入新操作的合理折衷方案,但不適合作為頻繁變化的抽象的長期解決方案。頻繁變化的抽象應以 superclass 來表示。
Superclass#
- 優缺點與 interface 相反:class 能指定比 interface 更多的細節,但新增操作到 superclass 不會破壞既有程式碼
- 限制:client 類別只能 extend 單一 framework class
減少 Superclass 的約束#
- Framework 中的 field 應永遠設為 private;若 client 需要存取資料,透過 getter 提供
- 仔細審查方法,只將必要的方法設為
public或(更好的)protected - 遵循這些規則,superclass 只比等效的 interface 多暴露少許細節,但允許 client 更彈性地插入邏輯
關鍵字的使用#
abstract:向 client 傳達哪些邏輯是必須填入的。在可能的地方提供合理預設實作,讓 client 容易上手- 但在 superclass 中引入新的 abstract method 會造成不相容升級——client 必須實作才能編譯
final:- 用於 class 時,阻止 client 建立 subclass,強制使用 instantiation 或 configuration 風格
- 用於 method 時,讓 framework 開發者能假設特定程式碼一定被執行
final應保留在有重大效益且對 client 造成最少問題的情境中。作者曾花兩天試圖以程式方式建立 SWT 事件用於測試,卻因(看似不必要的)final類別而無法實現,最終只能自行撰寫重複的事件類別。
Java 的 Package 可見性問題#
- Java 的 packaging 機制存在漏洞:跨多個 package 組織的 framework 需要一種「在 framework 內可見但對 client 不可見」的宣告
- 解決方案:將 package 分為 published 和 internal,在 internal package 路徑中加入
internal名稱 - 例如 Eclipse 中:
org.eclipse.jdt...和org.eclipse.jdt.internal.... - Internal package 提供介於揭露和隱藏 framework 細節之間的中間地帶——client 可以自行決定要承擔多少建立在潛在不穩定部分之上的責任
Creation#
如果 framework 發佈了任何 concrete class,就需要決定 client 如何實例化它們。以下四種風格不互斥,可以對不同部分使用不同風格:
No Creation(禁止建立)#
- 最簡單且最不強大的選項
- Framework 內部自行建構物件(例如 SWT 事件),確保物件格式正確
- Framework 程式碼可以假設物件的不變量
禁止 client 建立 framework 類別的實例,會排除 framework 開發者未預期到的合法用途。Framework 的價值往往來自最初未預期的使用方式——切斷意外用途的機會也減少了發現額外價值的機會。
Constructors#
- 簡單的選項,但對未來變更造成重大約束
- 發佈 constructor 等於承諾:類別名稱、建立所需參數、類別所在 package、回傳的 concrete class 都不會改變
- 例如:Sun 發佈了
new ArrayList()後,就承諾在java.utilpackage 中永遠保持名為ArrayList的類別 - 優點:對 client 而言簡單明瞭
Static Factories#
- 比 constructor 稍微複雜,但讓 framework 開發者有更多未來設計變更的自由度
- 例如
ArrayList.create()取代 constructor——回傳物件的名稱、package、concrete class 都可以改變而不影響 client 程式碼 - 更進一步:集中在 library class 中——
Collections.createArrayList()- 只有 library class 需要留在原始 package,其他類別可以自由移動
- 額外優點:factory method 的名稱可以向 client 清楚傳達不同建構變體的意義
- 缺點:建立過程越抽象,越難從程式碼中直接看出物件是在哪裡建立的
Factory Object#
- 向 factory object 發送訊息來建立實例,例如:
Collections.factory().createArrayList() - 比 static factory 更彈性但更複雜
Factory object 在局部使用(locally) 時展現其威力:
- 例如:需要在行動裝置上使用節省空間的 collection 時,可以用 space-saving collection factory 初始化物件;在伺服器上則用標準 collection factory
- 也可用於建立成套搭配的類別——例如 Windows widget 和 Linux widget 不互通時,透過 factory object 確保 client 只建立相容的類別
Creation 小結#
物件建立方式如何影響 framework 的易用性和可變性:
- 一種策略是對可能改變的類別提供 factory method,對穩定的類別提供 constructor
- 但一致的建立策略(所有物件都透過 factory method 或 factory object 建立)也有其價值
flowchart LR
A["No Creation\n禁止建立"] --> B["Constructors\n建構子"]
B --> C["Static Factories\n靜態工廠方法"]
C --> D["Factory Object\n工廠物件"]
A -.- A1["最受限\n框架完全控制"]
B -.- B1["簡單\n綁定類別名稱"]
C -.- C1["彈性\n可回傳子類別"]
D -.- D1["最彈性\n支援成套建立"]Methods#
除了物件建立之外,其他方法也會影響 framework 的使用和演進。通用策略不變:在幫助 client 解決問題的前提下,盡可能揭露最少的細節。
Getter 和 Setter 的考量#
- 對 client 可見的 getter 和 setter 只在資料結構穩定時才適合
- 鼓勵 client 依賴內部資料結構會大幅縮減未來 framework 演進的選項
- Setter 比 getter 更危險:通常可以找到替代方式來計算原本存在 field 中的值
與其發佈 setter,不如發佈一個以 client 要解決的問題命名的方法,而非以實作方式命名。
範例:Widget 的可見性狀態#
假設一個圖形 widget library 提供了 setVisible(boolean) 方法:
- 問題:當引入第三種狀態
inactive時怎麼辦? - Superclass 方案:發佈 intention-revealing method,如
visible()和invisible()。之後新增inactive()就不會影響 client 程式碼 - Interface 方案:新增
inactive()到 interface 會破壞所有 client 實作。改為定義列舉型別States並發佈setVisible(State)方法boolean參數暗示只有兩種可能狀態,是設計資訊外洩給 client 的例子- 使用列舉型別參數的單一方法保留了新增其他狀態的自由度
Getter/Setter 的使用時機#
- 並非永遠不該發佈 getter 和 setter
- 如果重要的 framework 功能目前是透過回傳或設定 field 實現的,就發佈 accessor
- 但命名方法時不要揭露實作細節給 client
以預設參數維持相容性#
另一個方法層級的策略:當為已發佈的方法新增參數時,提供預設值。
例如,假設 JUnit 想讓 run 方法能接受 TestResult 參數:
直接修改會破壞 client 程式碼:
public TestResult run(Class... classes) {
....run tests in classes...
}
public void run(TestResult result, Class... classes) {
...run tests in classes...
}改為保留舊方法並提供預設參數:
public TestResult run(Class... classes) {
TestResult result= new TestResult();
run(result, classes);
return result;
}透過提供預設參數,即使 interface 新增了方法,client 程式碼仍能繼續運作。
Conclusion#
Framework 開發和演進需要與應用程式開發不同的 implementation patterns。
價值觀的轉變#
- 在應用程式開發中,簡單性(simplicity) 是首要指令
- 在 framework 開發中,簡單性的優先順序低於保持進一步成長 framework 的自由度
- 開發經濟學的主導因素從「理解程式碼的成本」轉變為「升級 client 程式碼的成本」
- 當應用程式的一部分被抽取成 framework 時,許多設計決策需要重新審視
Framework 的演進方式#
Framework 以多種方式演進:
- 既有方法的計算邏輯需要改進
- 計算需要能處理新種類的參數
- 經過微調後可用於解決完全出乎意料的問題
- 既有的 framework 內部實作細節需要被公開
Framework 是交集而非聯集#
在 JUnit 中有效的一個隱喻是:將 framework 視為一個領域中所有有用功能的交集(intersection)而非聯集(union)。Framework 開發者的工作是確保 client 能擴展 framework 來解決剩餘的問題。
- 如果每個潛在使用者有 90% 的需求相同、10% 獨特,那麼滿足所有開發者的 framework 會比只滿足共同需求的 framework 大得多
- Framework 開發者的目標:滿足使用者的共同需求,但不包攬所有獨特需求
- 如果大多數使用者必須新增相同的功能,那它屬於 framework;獨特的部分最好交由有直接需求的人處理
從具體實例衍生 Framework#
- 鼓勵從多個具體範例中衍生 framework,而非從通用情境出發
- 作者在半打自動化測試嘗試之後,才從反覆撰寫相同程式碼的經驗中看出哪些問題是所有測試共通的、哪些是個別情境獨有的
使用一致的隱喻#
- 從一個或多個清楚且一致的隱喻(metaphor) 中提取 framework 的概念
- 例如:若使用複式簿記(double-entry bookkeeping) 作為記錄歷史資訊的隱喻,client 就知道去找
Account和Transaction - 有意識地選擇和應用隱喻,並傳達給 client,能讓 framework 更容易學習、使用和擴展
部署 framework 不必是演進和成長的終點。在建構 framework 時投入細心,可以同時為 client 應用程式提供穩定的基礎,也為進一步的 framework 開發奠定動態的基石。