Theme / v4.9.0

Ponytail

讓 AI 編碼代理像最懶的資深工程師

實戰範例

實戰範例 008:直球對決:移除不必要的依賴注入 (DI) 容器

剖析依賴注入容器 (DI Container) 在中小型專案中帶來的認知負擔,並提倡回歸簡單的直接構造與工廠方法。

實戰範例 008:直球對決:移除不必要的依賴注入 (DI) 容器

簡介與核心精神

在現代後端開發框架 (例如 Spring Boot, NestJS, 或 .NET Core) 中,依賴注入容器 (Dependency Injection Container, IoC Container) 幾乎成為了架構的標準配備。依賴注入 (DI) 本身是一個優秀的概念,它提倡將物件的創建與業務邏輯分離,有助於單元測試的進行。

然而,許多開發者在建立一個只有十幾個類別的中小型專案,或者是無伺服器 (Serverless) 雲函數時,依然盲目地引入如 InversifyJSAwilixTSyringe 等重量級 DI 容器。在 Ponytail 的極簡開發哲學中,「魔法越多的框架,除錯的成本越高」。當一個依賴無法正確注入,或者出現循環依賴 (Circular Dependency) 時,DI 容器拋出的錯誤訊息往往像天書一樣難懂。

本篇實戰範例將大膽挑戰這個業界常態。我們將展示在一個中小型模組中,如何移除複雜且充滿裝飾器 (Decorators) 與反射 (Reflection) 魔法的 DI 容器,回歸最純粹、最容易追蹤的「手動依賴注入」與「簡單工廠模式 (Simple Factory)」。

實際場景描述

團隊正在開發一個輕量級的訂閱通知微服務 (Notification Service),主要職責是從資料庫讀取使用者清單,並透過不同的管道 (Email, SMS) 發送通知。

負責架構的資深工程師為了展現「企業級」的架構風範,引入了 InversifyJS 作為 DI 容器。他定義了無數的 Symbol 作為識別符,並在每一個類別上標註 @injectable()@inject() 裝飾器。結果,一個本來只需三個類別就能搞定的腳本,變成了一個必須先經過繁瑣「註冊綁定 (Binding)」過程才能啟動的龐然大物。

新進工程師在追蹤 NotificationController 時,完全無法透過 IDE (如 VSCode) 的 “Go to Definition” 找到真正實作的類別,因為介面和實作被 DI 容器的綁定邏輯徹底切斷了。

原始的過度設計代碼

讓我們看看這個充滿依賴注入魔法,卻讓人迷失方向的原始設計。

// 檔案:src/types.ts
// DI 容器必須使用的神秘符號字典
export const TYPES = {
  DatabaseRepository: Symbol.for("DatabaseRepository"),
  EmailSender: Symbol.for("EmailSender"),
  NotificationService: Symbol.for("NotificationService"),
  NotificationController: Symbol.for("NotificationController")
};

// 檔案:src/repositories/DatabaseRepository.ts
import { injectable } from "inversify";

@injectable()
export class DatabaseRepository {
  public async getUsersToNotify(): Promise<string[]> {
    // 模擬資料庫查詢
    return ["[email protected]", "[email protected]"];
  }
}

// 檔案:src/services/EmailSender.ts
import { injectable } from "inversify";

@injectable()
export class EmailSender {
  public async sendEmail(address: string, content: string): Promise<void> {
    console.log(`Sending email to ${address}: ${content}`);
  }
}

// 檔案:src/services/NotificationService.ts
import { injectable, inject } from "inversify";
import { TYPES } from "../types";
import { DatabaseRepository } from "../repositories/DatabaseRepository";
import { EmailSender } from "./EmailSender";

@injectable()
export class NotificationService {
  // 透過裝飾器與 Symbol 進行隱式注入
  constructor(
    @inject(TYPES.DatabaseRepository) private dbRepo: DatabaseRepository,
    @inject(TYPES.EmailSender) private emailSender: EmailSender
  ) {}

  public async broadcast(message: string): Promise<void> {
    const users = await this.dbRepo.getUsersToNotify();
    for (const user of users) {
      await this.emailSender.sendEmail(user, message);
    }
  }
}

// 檔案:src/inversify.config.ts (依賴綁定地獄)
import "reflect-metadata";
import { Container } from "inversify";
import { TYPES } from "./types";
import { DatabaseRepository } from "./repositories/DatabaseRepository";
import { EmailSender } from "./services/EmailSender";
import { NotificationService } from "./services/NotificationService";

const container = new Container();

// 冗長且容易出錯的註冊過程
container.bind<DatabaseRepository>(TYPES.DatabaseRepository).to(DatabaseRepository);
container.bind<EmailSender>(TYPES.EmailSender).to(EmailSender);
container.bind<NotificationService>(TYPES.NotificationService).to(NotificationService);

export { container };

// 檔案:src/index.ts
import { container } from "./inversify.config";
import { TYPES } from "./types";
import { NotificationService } from "./services/NotificationService";

// 神秘的解析過程
const notificationService = container.get<NotificationService>(TYPES.NotificationService);

notificationService.broadcast("System maintenance at midnight.")
  .then(() => console.log("Broadcast complete."));

在這段程式碼中,我們為了實作「依賴注入」,付出了極大的代價:

  1. 必須引入 reflect-metadata,這是一個修改 JS 執行階段環境的補丁。
  2. 開發者必須手動維護一個龐大且容易打錯字的 TYPES 字典。
  3. 程式碼的追蹤變得困難,IDE 無法理解 container.get 到底回傳了什麼具體的實例,型別系統在此處顯得脆弱。

開發者與 AI 的對話記錄

當新功能需要增加一個 SMSSender 時,開發者在註冊綁定時忘記修改 types.ts,導致執行階段崩潰。他再次向 AI 求助。

開發者

「AI,我快被這個 DI 容器搞瘋了!我只是想加一個發簡訊的服務,結果 InversifyJS 噴了一堆找不到綁定的錯誤,錯誤訊息長達 50 行,根本看不懂到底哪裡沒注入。DI 容器不是為了讓系統更解耦、更好維護嗎?」

AI (Ponytail 模式)

「這就是過度依賴工具的陷阱。依賴注入 (DI) 是個好東西,但『依賴注入容器』(DI Container) 卻往往是中小專案的毒藥。

你的專案只有不到十個類別,你為什麼需要一個在 Runtime (執行階段) 動態解析依賴、使用 Reflection 魔法的容器?這就像用航空母艦去送披薩一樣荒謬。」

開發者

「可是前輩說,如果沒有 DI 容器,我們怎麼做單元測試 (Unit Testing)?怎麼抽換實作?」

AI (Ponytail 模式)

「DI 和 DI 容器是兩碼子事。『依賴注入』是指你的類別不自己 new 物件,而是由外面傳進來,這樣你就可以在測試時傳入 Mock 物件。這完全不需要任何容器!

我們可以直接用最樸素的 TypeScript 寫一個裝配函式 (Wiring Function) 或簡單工廠,手動 new 這些物件並傳入建構子。這不僅擁有 100% 的編譯期型別安全,出錯時也是在編譯階段就告訴你,而不是等到程式跑起來才崩潰。拔掉容器吧,回歸直球對決!」

重構與優化:純粹的手動依賴注入 (Pure DI)

我們大刀闊斧地移除了 inversifyreflect-metadata 等所有外部套件。刪除了所有的裝飾器與 Symbol 定義。我們只使用原生的 TypeScript 建構子注入 (Constructor Injection)。

// 檔案:src/repositories/DatabaseRepository.ts
// 乾淨的類別,沒有任何裝飾器污染
export class DatabaseRepository {
  public async getUsersToNotify(): Promise<string[]> {
    return ["[email protected]", "[email protected]"];
  }
}

// 檔案:src/services/EmailSender.ts
export class EmailSender {
  public async sendEmail(address: string, content: string): Promise<void> {
    console.log(`Sending email to ${address}: ${content}`);
  }
}

// 檔案:src/services/NotificationService.ts
import { DatabaseRepository } from "../repositories/DatabaseRepository";
import { EmailSender } from "./EmailSender";

export class NotificationService {
  // 純粹的建構子注入。
  // 在測試時,你可以輕易地傳入 mock 版本的 dbRepo 或 emailSender
  constructor(
    private dbRepo: DatabaseRepository,
    private emailSender: EmailSender
  ) {}

  public async broadcast(message: string): Promise<void> {
    // 邊界防護:檢查空訊息
    if (!message || message.trim() === '') {
      throw new Error("Cannot broadcast empty message.");
    }

    const users = await this.dbRepo.getUsersToNotify();
    
    // 平行處理提升效能
    const promises = users.map(user => this.emailSender.sendEmail(user, message));
    await Promise.all(promises);
  }
}

// 檔案:src/factory.ts (取代原本龐大複雜的 DI 容器)
import { DatabaseRepository } from "./repositories/DatabaseRepository";
import { EmailSender } from "./services/EmailSender";
import { NotificationService } from "./services/NotificationService";

/**
 * 簡單直白的依賴裝配工廠 (Simple Wiring Factory)
 * 擁有 100% 的 TypeScript 型別檢查。少傳一個參數?編譯器立刻報錯。
 */
export function createNotificationService(): NotificationService {
  const dbRepo = new DatabaseRepository();
  const emailSender = new EmailSender();
  
  // 手動注入相依性。清晰、無魔法、所見即所得。
  return new NotificationService(dbRepo, emailSender);
}

// 檔案:src/index.ts
import { createNotificationService } from "./factory";

// 系統進入點變得極其簡單
const notificationService = createNotificationService();

notificationService.broadcast("System maintenance at midnight.")
  .then(() => console.log("Broadcast complete."))
  .catch(err => console.error("Broadcast failed:", err));

重構亮點分析

  1. 移除黑魔法與執行期錯誤:不再依賴 reflect-metadata,消除了 DI 容器在 Runtime 才噴出找不到依賴的風險。所有依賴組裝錯誤都會在編譯期被 TypeScript 抓出。
  2. 極致的可追蹤性 (Traceability):當你在 new NotificationService(...) 上按下 Ctrl+Click 時,IDE 會完美跳轉。程式碼的依賴圖譜是透明且線性的。
  3. 沒有犧牲任何測試能力:由於依然保持了「建構子注入」的原則,在寫單元測試時,開發者可以毫無障礙地傳入 Mock 或 Stub 物件:new NotificationService(mockRepo, mockSender)。這就是所謂的 Pure DI。
  4. 專案瘦身與啟動加速:移除了重量級容器套件,減少了套件安裝體積,並免除了容器在啟動時掃描裝飾器與建立映射表的運算開銷,特別適合冷啟動要求嚴苛的 Serverless 環境。

效益分析表格與解讀

放棄花俏的工具,擁抱原生的語言特性,為這段程式碼帶來了全方位的提升。

評估維度 使用 DI 容器 (InversifyJS) 純手動 DI (Ponytail 原則) 改善程度 / 解讀
依賴套件數量 多了 inversify, reflect-metadata 零額外依賴 降低了專案複雜度與潛在的依賴漏洞風險。
型別與編譯安全 弱 (以字串或 Symbol 查找,易打錯) 極強 (編譯器強制檢查建構子參數) 徹底消滅 Runtime Injection Error。忘記傳參數連編譯都過不了。
樣板代碼 (Boilerplate) 滿坑滿谷的 @injectable(), TYPES 完全消除 業務類別回歸純粹的 ES6 Class,不再被框架特定的裝飾器污染。
可追蹤性與 IDE 支援 差 (切斷了實作的直接參照) 完美 (原生的函式呼叫) 開發者可以順暢地使用工具進行代碼追蹤與重構。
啟動效能 較慢 (需執行反射與容器綁定) 極快 (純粹的物件實例化) 無魔法開銷,執行效率達到原生語言極限。

結論總結

「不要把『依賴注入』和『依賴注入容器』混為一談。」

依賴注入是一種偉大的設計模式,它讓我們的程式碼解耦並具備可測試性。但 DI 容器只是一種自動化組裝的工具。當專案規模尚未達到數百個類別、數千個依賴關係時,手動進行組裝 (Pure DI / Manual DI) 往往是最簡單、最直觀、也最安全的做法。

Ponytail 再次向我們證明了極簡主義的力量:拒絕不必要的框架抽象,用最直白的語言特性解決問題。這不僅讓程式碼更容易被人類理解,更確保了系統長期的強健與易維護性。