深度主題文

寫出好維護的程式碼 — 依賴注入 (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,例如 MockEmailServiceMockSmsService,這時如果實際呼叫 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  讓我們可以很靈活的決定要過濾掉哪種類型的元素。

看到下面的用法,如果要把 nullundefined 過濾掉,只要傳入 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+ 成長計畫,除了全系列所有完整深度文章外,也可觀看過去所有的直播回放。