深度主題文
寫出好維護的程式碼 — 依賴注入 (Dependency Injection) 與控制反轉 (Inversion of Control)
2025年4月28日
💡 E+ 成長計畫深度主題文
本文為 E+ 成長計畫的深度主題文,開放免費閱讀。E+ 的訂閱讀者除了每週會收到一篇新內容外,也享有過去所有內容的閱讀權限。
如果你對於這類深度內容感興趣,歡迎加入 E+ 成長計畫,除了全系列所有完整深度文章外,也可觀看過去所有的直播回放。
在 vgod 前輩的《軟體工程師的修煉與成長》系列文中,他用「接力跑馬拉松」來比喻大型軟體開發,這個比喻非常的生動。相信多數人會有感,假如今天不是只有自己一個人寫,而是要跟很多人合作開發軟體,就會像接力一樣,遇到許多只有自己開發不會遇到的問題。
事實上在軟體開發的合作,不僅僅是跟目前會接觸到的同事合作,往往是要跟已經離開團隊的人合作,例如處理歷史程式碼,往往就是要面對某個多年前就離開團隊的人寫的東西。同時,也會需要跟還沒加入團隊的人合作,例如某個你多年前寫的東西,一個新加入團隊的人要維護,於是來找你。
在這些片刻,會讓人覺得苦惱或生氣的,往往是那些不好維護的程式碼。因此在接下來的幾期後端主題文中,我們會聚焦在如何有效寫出好維護的程式碼。
以下的程式碼,為什麼不好維護?
要培養出能夠寫出好維護程式碼的直覺,我們要先從能辨別「哪類程式碼不好維護」開始。以下是一個簡化的程式碼段落,該段落的程式碼是在處理使用者註冊後,會發送電子郵件確認信。
讀者們看過去,會覺得程式碼有什麼問題? 為什麼不好維護?
// --- 電子信箱相關服務 ---
class EmailService {
send(email: string, message: string): void {
// (發送電子郵件相關的程式碼,細節忽略)
}
}
// --- 使用者相關服務 ---
class UserService {
private emailService: EmailService;
constructor() {
this.emailService = new EmailService();
}
registerUser(email: string, password: string, name: string): void {
// (使用者註冊相關程式碼,細節忽略)
// 成功註冊後,發送電子郵件
this.emailService.send(email, `Welcome, ${name}!`);
}
}
在讀上面這一段程式碼時,推薦讀者們可以邊讀邊問以下的問題
- 這段程式碼好測試嗎? 為什麼?
- 假如想在不同環境用不同的電子信箱服務(例如在測試環境用模擬的信箱),容易做到嗎?
- 之後有其他的服務 (例如傳送簡訊),容易加嗎?
- 如果有情境是註冊後不要傳電子郵件,容易改嗎?
相信如果有問上面這些問題,會發現這段程式碼在可維護性上不是太理想。最核心的原因是把 emailService
寫死在註冊使用者的方法中,這會讓測試相對麻煩 (因為跑測試時就會真的發電子郵件)、讓要改要加都不容易 (因為會需要寫很多特定的邏輯,會讓 registerUser
這個方法變得肥厚)。
舉例來說,我們可能要寫下面的條件判斷,才能加上不同的通知傳送方式,當越多種方式,這個條件判斷就要有越多的 if...else..
。
if (notificationType === "email") {
this.emailService.sendEmail(email, `Welcome, User ${id}!`);
} else if (notificationType === "sms" && phone) {
this.smsService.sendSMS(phone, `Welcome, User ${id}!`);
}
相信這時讀者們會問,該如何解決這個問題呢? 依賴注入 (Dependency Injection 或簡稱 DI) 是在這種情境下,特別能派上用場的手段。
什麼是依賴注入 (Dependency Injection)?
在實際講什麼是依賴注入前,先讓我們看看如果要重構上面段落的程式碼,可以怎麼做。
class UserService {
// 通知服務是被傳進來,而不是寫死的
constructor(notificationService) {
this.notificationService = notificationService;
}
registerUser(userData: IUserData, password: string): void {
// (使用者註冊相關程式碼,細節忽略)
// 成功註冊後,發送通出
this.notificationService.sendWelcomeMessage(userData, userData.name);
}
}
試著觀察一下重構後的程式碼,我們可以發現重送通知的服務是被傳入的,而不是像原本的emailService
是寫死在 registerUser
。
這時候如果我們想要用不同的通知傳送方式,可以像這樣
// 有不同的傳送通知方式
const emailService = new EmailService();
const smsService = new SMSService();
// 將不同的方式聚合在一起
const multiChannelService = new MultiChannelNotificationService([
emailService,
smsService,
]);
// 然後要傳哪類通知,只要在使用 userService 時決定就好
const userService = new UserService(multiChannelService);
在上面的例子,可以看到 UserService
裡面完全不會有要選擇哪一個傳送方式的邏輯,因為要用哪一種方式傳,是在上一層決定的,所以不同的場合用 UserService
時,可以自由搭配選定 multiChannelService
中的陣列,要放哪類的通知傳送方式。
這種做法不僅解決原本版本如果要修改,會導致 registerUser
中的邏輯越變越臃腫導致難以維護的狀況,更可以讓測試變得更容易。因為不同的傳訊息服務是被傳進去的,所以我們可以很輕易地傳入 mock,例如 MockEmailService
或 MockSmsService
,這時如果實際呼叫 registerUser
,就裡頭呼叫的就會是 mock,非常方便。
事實上,我們在 軟體測試 — 好測試的程式碼與好維護的測試 這篇主題文中,就有提到可以透過這種方式,來讓程式碼更容易測試。而當時我們稱這種方式為依賴注入。
所以,什麼是依賴注入?
如果要試著拆解依賴注入這個詞,會像這樣
- **依賴 (dependency):**某個在方法或函式中會用到的、所依賴的東西
- **注入 (injection):**只要某個東西是做為參數傳入某個方法或函式,就會被稱為注入
把上面這兩個詞結合在一起,所謂的依賴注入,就是某個在方法或函式會用到的東西,不是寫死在該方法或函式中,而是用傳入的方式,傳進去讓該方法與函式可以使用。
依賴注入的本質是什麼?
在透過實際案例了解什麼是依賴注入後,讓我們試著探討依賴注入的本質是什麼。透過上面的案例,我們可以推得幾件事。
首先,透過注入參數,我們可以順利解耦合,避免什麼邏輯都寫在 UserService
當中,UserService
本身不須用理會什麼條件下要傳電子郵件、什麼條件下要傳簡訊通知,這些本來就不是 UserService
要關注的邏輯去除後,會讓 UserService
更乾淨好維護一點。未來在看 UserService
程式碼的維護者,可以不用去管那些不是核心的條件判斷。
第二,因為解耦合,所以有更多彈性,可以很輕鬆地支援不同類型的傳訊息方式。要加入這些不同的方式,不須用動到 UserService
,可以直接根據需求加到 multiChannelService
當中即可。
最後,如同前面談到的,依賴注入讓程式碼更容易寫測試。當程式碼有測試覆蓋,未來的開發者也會比較敢去改動。這能避免因為沒有測試,導致開發者擔心改 A 壞了 B,以致於不敢大膽重構程式碼的問題,對於長久維護性來說,非常有幫助。
傳入參數還能解決另一種問題
前面我們談了依賴注入這種「透過傳入參數,來做到解耦合,避免程式碼出現越來越多寫死的肥厚邏輯」。事實上有另一種也是透過傳參數,來避免某個方法或函式內的邏輯過於肥厚、難維護。
一樣讓我們先透過一個例子來理解 (此案例來自 Kent C. Dodds 的分享)
// filter 函式,可以根據不同條件過濾元素
function filter(array, {
filterNull = true,
filterUndefined = true,
filterEmptyString = false,
} = {}) {
let newArray = []
// 迭代過陣列
for (let index = 0; index < array.length; index++) {
const element = array[index]
// 每一輪的迭代,會根據這個條件判斷,來根據參數決定是否要過濾某類型的元素
if (
(filterNull && element === null) ||
(filterUndefined && element === undefined) ||
(filterEmptyString && element === '') ||
) {
continue
}
newArray[newArray.length] = element
}
return newArray
}
// 具體用法如下,根據傳入的不同條件來過濾
let input = [0, 1, undefined, 2, null, 3, 'four', '']
filter(input) // [ 0, 1, 2, 3, 'four', '' ]
filter(input, {filterNull: false}) // [ 0, 1, 2, null, 3, 'four', '' ]
filter(input, {filterUndefined: false}) // [ 0, 1, undefined, 2, 3, 'four', '' ]
讀者們看完上面這段程式碼,覺得會難維護的原因在哪呢?
相信多數人有發現,這個 filter
函式之所以難維護,是因為當今天如果要新增一個過濾的邏輯,就需要在現在已經有多行的 if
條件句底下,再多寫一個條件判斷。例如假如要把 0
過濾掉,就要再多加上 (filterZero && element === 0)
。如果有越來越多不同條件,整個 if
就會變超級巨大,很難維護。
什麼是控制反轉 (Inversion of Control)?
要解決上面的問題,控制反轉 (Inversion of Control 或簡稱 IoC) 是常會用到的手段。一樣我們先來看看重構後的程式碼,再來談什麼是控制反轉。
如果要重構上面的程式碼,我們可以這樣改寫
function filter(array, callback) {
const result = [];
// 迭代過陣列
for (let i = 0; i < array.length; i++) {
// 取出迭代的元素
const element = array[i];
// 透過 callback 來決定是否要過濾
if (callback(element, i)) {
result.push(element);
}
}
return result;
}
可以觀察到,上面這個寫法的做大區別,是傳入 callback
,這個 callback
讓我們可以很靈活的決定要過濾掉哪種類型的元素。
看到下面的用法,如果要把 null
或 undefined
過濾掉,只要傳入 element !== null && element !== undefined
的過濾條件即可。同理,如果要過濾掉除了字串以外的元素,只要傳入 typeof element === 'string'
即可。
let input = [0, 1, undefined, 2, null, 3, "four", ""];
const removeNullAndUndefined = (element) =>
element !== null && element !== undefined;
filter(input, removeNullAndUndefined); // [ 0, 1, 2, 3, 'four', '' ]
// 只保留字串
const keepOnlyStrings = (element) => typeof element === "string";
filter(input, keepOnlyStrings); // [ 'four', '' ]
事實上,這個重構的版本,是我們在 函式程式設計 (functional programming) — 高階函式 主題文談過的。但當時是從高階函式的角度切入,而在這篇主題文則從反轉控制的角度來談。
所謂的反轉控制,如字面上的意思,是把控制權反轉過來,從原本由函式來控制邏輯,反轉成在函式以外控制邏輯。
這樣聽起來很抽象,讓我們用 filter
的例子來理解。在原本版本的 filter
,邏輯都是寫在 filter
裡面,所以是由filter
函式本身來控制邏輯。而重構的版本,過濾與否是由 callback
來決定,而這個 callback
是從外傳進來的,所以在這個狀況下,控制權被反轉到函式之外。
反轉控制的本質是什麼?
在理解完反轉控制後,讓我們試著從本質的角度來看反轉控制。
第一個想談的本質,是函式或方法本身,不再追求控制,不再由函式或方法本身,來決定該怎麼做。而是挖一個空,讓使用函式的人決定。以 filter
來說,用的時候再決定要傳什麼 callback
,不必在寫 filter
函式時就決定。
第二個是保留核心,因為今天把控制交由傳入的 callback
來決定,filter
的核心可以保持不變。今天不管要怎麼樣過濾,都可以完全不用去動 filter
,只用傳一個不同的 callback
即可。
當把上面兩個本質合在一起看,就會發現反轉控制讓程式碼變更好維護。因為一來因為可以靈活的傳入不同 callback
,整體的靈活性變好了,不用因為想要有不同的過濾邏輯而要苦惱如何改程式碼。二來核心的東西不變,意味著要維護的部分變少了,因為可以很確信最核心的 filter
可以不用去管。
小結
希望透過這期的主題文,讀者們對於依賴注入 (Dependency Injection) 以及控制反轉 (Inversion of Control) 這兩個能協助讓程式碼更好維護的手段,有具體的理解。
我們一直很喜歡透過衣櫃整理衣服這個概念,來對比提升程式碼可維護性。今天當洗完衣服、烘乾後,可以完全不分類,直接拿出來後就全部塞到衣櫃。當這樣做,能非常快速完成收衣服這件事。然而當這樣做,通時必須承擔的代價,是要找衣服來穿的時候,會非常麻煩;這個麻煩度會隨著衣櫃中的衣服越來越多,而越來越麻煩。
寫程式也是類似的,假如用很快速、不假思考與分類的方式,可以很快速完成功能沒問題的程式碼。但是當這麼做,就會讓未來要維護的人很頭痛。當程式碼庫越來越龐大,用快速但沒有好好管理的方式寫,要承擔的維護成本會越來越大。因此,推薦讀者們,善用這些整理程式碼的方式,雖然在寫的時候,可能會多花一點時間,但是長遠來看,維護起來更輕鬆,會讓一切更值得。
最後,想在這期主題文埋一個讓讀者們可以思考的問題。相信多數人都聽過高內聚、低耦合 (high cohesion, loose coupling) 這個程式設計原則。而在今天我們談的兩個手段,基本上都是在談解耦合,讓耦合性變低。
相信讀者們讀過今天的案例分析,可以感受到低耦合的好處,只是高內聚呢? 什麼是高內聚? 為什麼重要? 推薦讀者可以先思考,我們會在下期的後端主題文來聊聊。
💡 E+ 成長計畫深度主題文
本文為 E+ 成長計畫的深度主題文,開放免費閱讀。E+ 的訂閱讀者除了每週會收到一篇新內容外,也享有過去所有內容的閱讀權限。
如果你對於這類深度內容感興趣,歡迎加入 E+ 成長計畫,除了全系列所有完整深度文章外,也可觀看過去所有的直播回放。