本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 架構進階模式

使用 DI(如 tsyringe / inversify)


簡介

在大型的 Node.js/Express 專案中,隨著功能不斷累積,相依關係(Dependency) 會變得越來越複雜。傳統的 require / import 直接在檔案裡寫死實例,會導致以下問題:

  1. 測試困難:無法輕易替換成 mock 物件。
  2. 耦合度高:模組之間相互依賴,改動一個檔案往往需要連帶修改多個檔案。
  3. 維護成本上升:新加入的服務或套件需要手動管理建立與注入的時機。

使用 依賴注入(Dependency Injection, DI) 可以將「建立物件」的責任交給容器(Container),讓程式碼只關注「我需要什麼」而不是「我怎麼取得它」。在 TypeScript 生態中,tsyringeinversify 是兩個最常見、且相容 Express 的 DI 套件。本篇文章將以 實務導向 的方式說明如何在 Express + TypeScript 專案中導入 DI,並提供完整範例、常見陷阱與最佳實踐,幫助你從初學者順利晉升為中級開發者。


核心概念

1. 什麼是依賴注入?

依賴注入是一種 設計模式,其核心思想是將物件的相依(Dependency)從「內部自行建立」抽離,改由外部 容器 注入。簡單來說:

  • 依賴(Dependency):一個類別需要使用的其他類別或服務。
  • 容器(Container):負責註冊、解析、管理相依關係的中心。
  • 注入方式:透過建構子(Constructor)、屬性(Property)或方法(Method)注入。

使用 DI 後,我們可以:

  • 解耦:類別只依賴抽象(介面 / 抽象類別),不直接依賴具體實作。
  • 易測試:測試時只要提供 mock 實作即可,無需啟動完整的 Express 伺服器。
  • 可擴充:新增或替換服務只需要在容器中註冊,原有程式碼不必變動。

2. 為什麼選擇 tsyringeinversify

套件 特色 何時選擇
tsyringe - 輕量、Zero‑Config
- 直接使用 TypeScript 裝飾器
- 支援 singleton、transient
專案規模中等、想快速上手且不想額外寫太多設定檔的情況
inversify - 功能最完整(多層容器、動態綁定、作用域控制)
- 社群成熟、文件豐富
- 支援 JavaScript (非 TS)
大型企業級專案、需要高度客製化或多層容器的情境

以下範例分別示範兩套件的 基本使用方式,讓你快速比較與選擇。


3. 基本範例 – 使用 tsyringe

3.1 安裝

npm i tsyringe reflect-metadata
npm i -D @types/reflect-metadata

注意reflect-metadata 必須在程式入口最上方 import,否則裝飾器會失效。

3.2 設定入口檔案(src/app.ts

import 'reflect-metadata';          // 必須最先執行
import express from 'express';
import { container } from 'tsyringe';
import { UserController } from './controllers/UserController';

const app = express();
app.use(express.json());

// 直接從 container 取得 controller 實例
app.use('/users', container.resolve(UserController).router);

app.listen(3000, () => console.log('Server running on http://localhost:3000'));

3.3 建立服務(Service)

// src/services/UserService.ts
import { injectable } from 'tsyringe';

export interface IUserService {
  getAll(): Promise<string[]>;
  create(name: string): Promise<string>;
}

@injectable()                     // 標記此類別可被容器注入
export class UserService implements IUserService {
  private users: string[] = [];

  async getAll(): Promise<string[]> {
    return this.users;
  }

  async create(name: string): Promise<string> {
    this.users.push(name);
    return name;
  }
}

3.4 建立控制器(Controller)

// src/controllers/UserController.ts
import { Router, Request, Response } from 'express';
import { inject, injectable } from 'tsyringe';
import { IUserService } from '../services/UserService';

@injectable()
export class UserController {
  public router = Router();

  constructor(
    @inject('UserService') private readonly userService: IUserService // 透過名稱注入
  ) {
    this.router.get('/', this.getAll.bind(this));
    this.router.post('/', this.create.bind(this));
  }

  private async getAll(_: Request, res: Response) {
    const users = await this.userService.getAll();
    res.json(users);
  }

  private async create(req: Request, res: Response) {
    const { name } = req.body;
    const created = await this.userService.create(name);
    res.status(201).json(created);
  }
}

3.5 註冊相依(在 src/container.ts

import { container } from 'tsyringe';
import { IUserService, UserService } from './services/UserService';

// 以字串 token 註冊,亦可使用 class 本身
container.register<IUserService>('UserService', {
  useClass: UserService,        // 預設為 transient(每次 resolve 都新建)
});

// 若想改為 singleton,只要改成 useClass + { lifecycle: Lifecycle.Singleton }

小技巧container.registerSingleton 能更簡潔地註冊單例。


4. 基本範例 – 使用 inversify

4.1 安裝

npm i inversify reflect-metadata
npm i -D @types/reflect-metadata

4.2 設定入口檔案(src/app.ts

import 'reflect-metadata';
import express from 'express';
import { container } from './inversify.config';
import { TYPES } from './types';
import { UserController } from './controllers/UserController';

const app = express();
app.use(express.json());

const userController = container.get<UserController>(TYPES.UserController);
app.use('/users', userController.router);

app.listen(3000, () => console.log('Server listening on 3000'));

4.3 定義類型 Token(src/types.ts

export const TYPES = {
  UserService: Symbol.for('UserService'),
  UserController: Symbol.for('UserController')
};

4.4 建立服務(Service)

// src/services/UserService.ts
import { injectable } from 'inversify';

export interface IUserService {
  getAll(): Promise<string[]>;
  create(name: string): Promise<string>;
}

@injectable()
export class UserService implements IUserService {
  private users: string[] = [];

  async getAll(): Promise<string[]> {
    return this.users;
  }

  async create(name: string): Promise<string> {
    this.users.push(name);
    return name;
  }
}

4.5 建立控制器(Controller)

// src/controllers/UserController.ts
import { Router, Request, Response } from 'express';
import { inject, injectable } from 'inversify';
import { IUserService } from '../services/UserService';
import { TYPES } from '../types';

@injectable()
export class UserController {
  public router = Router();

  constructor(
    @inject(TYPES.UserService) private readonly userService: IUserService
  ) {
    this.router.get('/', this.getAll.bind(this));
    this.router.post('/', this.create.bind(this));
  }

  private async getAll(_: Request, res: Response) {
    const users = await this.userService.getAll();
    res.json(users);
  }

  private async create(req: Request, res: Response) {
    const { name } = req.body;
    const created = await this.userService.create(name);
    res.status(201).json(created);
  }
}

4.6 設定容器(src/inversify.config.ts

import { Container } from 'inversify';
import { TYPES } from './types';
import { IUserService, UserService } from './services/UserService';
import { UserController } from './controllers/UserController';

const container = new Container();

// 註冊 Service 為單例(Singleton)
container.bind<IUserService>(TYPES.UserService).to(UserService).inSingletonScope();

// 註冊 Controller(預設為 transient)
container.bind<UserController>(TYPES.UserController).to(UserController);

export { container };

5. 進階範例 – 跨模組的 DI(使用 tsyringe 的自訂 Provider)

假設我們有 EmailService 需要根據環境變數選擇不同的郵件提供者(如 SendGrid、Mailgun),可以使用 Factory Provider

// src/services/EmailProvider.ts
export interface IEmailProvider {
  send(to: string, subject: string, body: string): Promise<void>;
}

// SendGrid 實作
export class SendGridProvider implements IEmailProvider {
  async send(to, subject, body) {
    // 呼叫 SendGrid API
    console.log(`[SendGrid] send to ${to}`);
  }
}

// Mailgun 實作
export class MailgunProvider implements IEmailProvider {
  async send(to, subject, body) {
    // 呼叫 Mailgun API
    console.log(`[Mailgun] send to ${to}`);
  }
}
// src/container.ts
import { container, Lifecycle } from 'tsyringe';
import { IEmailProvider, SendGridProvider, MailgunProvider } from './services/EmailProvider';

container.register<IEmailProvider>('EmailProvider', {
  useFactory: (c) => {
    if (process.env.EMAIL_DRIVER === 'sendgrid') {
      return new SendGridProvider();
    }
    return new MailgunProvider();
  }
}, { lifecycle: Lifecycle.Singleton });

在需要的服務中直接注入:

@injectable()
export class NotificationService {
  constructor(@inject('EmailProvider') private readonly email: IEmailProvider) {}

  async notifyUser(email: string) {
    await this.email.send(email, 'Welcome', 'Thanks for joining!');
  }
}

重點:Factory Provider 讓我們在執行階段決定實作,而不必在編譯期硬編碼。


常見陷阱與最佳實踐

陷阱 說明 解法 / 最佳實踐
忘記載入 reflect-metadata 裝飾器會在執行時找不到型別資訊,導致 undefined 注入錯誤。 在入口檔案(src/app.ts)最上方 必須 import 'reflect-metadata'
使用字串 token 時拼寫錯誤 tsyringe 允許字串作為 token,拼寫不一致會在執行時拋出 Token not found 建議使用 enumSymbol 常量集中管理 token(如 TYPES)。
單例 vs. Transient 混淆 把本應是 transient 的服務註冊成 singleton,會導致共享狀態(例如陣列)意外被改寫。 明確決定生命週期:container.registerSingleton(單例)或 container.register(預設 transient)。
循環相依(Circular Dependency) A 注入 B,B 同時注入 A,會造成 DI 容器無法解析。 使用 Factory Provider延遲注入lazyInject)來斷開循環。
測試時忘記重置容器 同一個測試案例之間共用同一容器,可能留下狀態污染。 在每個測試檔案的 beforeEach 呼叫 container.reset()tsyringe)或重新建立 Containerinversify)。

其他最佳實踐

  1. 依賴抽象:永遠針對介面(IUserService)編碼,而非具體實作(UserService)。
  2. 分層註冊:將所有註冊寫在 單一檔案container.tsinversify.config.ts),保持入口乾淨。
  3. 使用 @singleton(tsyringe)或 .inSingletonScope()(inversify):對於無狀態服務(如 Logger、Cache)使用單例,可減少資源浪費。
  4. 加入自動掃描(可選):inversifycontainer.load 能自動讀取多個模組,適合大型專案。
  5. 保持裝飾器簡潔:不要在建構子裡寫過多邏輯,保持「只注入」的職責。

實際應用場景

場景 為什麼需要 DI 範例說明
多資料庫切換(MySQL、PostgreSQL、MongoDB) 依賴注入讓資料庫客戶端抽象化,切換只需改容器註冊。 container.ts 中根據 process.env.DB_TYPE 註冊對應的 IDbClient 實作。
第三方 API 客戶端(如 Stripe、PayPal) 測試時不想真的呼叫外部服務,使用 mock 客戶端即可。 測試中 container.registerInstance('PaymentGateway', new MockPaymentGateway())
全域 Logger Logger 需要在多個服務中共用,同時支援不同環境(dev、prod)。 使用 container.registerSingleton('Logger', ConsoleLogger),在測試環境改為 SilentLogger
動態授權策略(RBAC、ABAC) 授權策略可能依據租戶或使用者類型變化,容器可在執行時注入相應策略。 container.register('AuthStrategy', { useFactory: ctx => new TenantStrategy(ctx.get('TenantId')) })
微服務間的 RPC 客戶端 每個微服務都有獨立的 RPC 客戶端,使用 DI 可在路由層直接注入。 UserController 注入 UserRpcClient,容器根據環境載入 gRPC 或 HTTP 客戶端實作。

總結

在 Express + TypeScript 的專案裡導入 DI(依賴注入),不只是「寫得更優雅」的技巧,更是 提升可測試性、降低耦合、加速開發 的關鍵。本文以 tsyringeinversify 為例,說明了:

  1. DI 的核心概念:容器、相依、注入方式。
  2. 兩大套件的安裝、設定與基本範例,讓你能快速在現有專案中加入 DI。
  3. 進階技巧(Factory Provider、跨模組注入)以及 常見陷阱 的避免方法。
  4. 實務場景:多資料庫、第三方 API、全域 Logger、動態授權與微服務 RPC,皆可藉由 DI 簡化實作與測試。

關鍵要點在設計階段先把介面抽出來,讓容器負責實例化選擇適合專案規模的套件(輕量 tsyringe 或功能完整 inversify在測試環境中使用容器重新註冊或重置,即可享受到乾淨、可維護、易測試的 Express 應用程式。

現在就把上述步驟套用到你的專案中,體驗 DI 為開發流程帶來的變化吧!祝開發順利 🚀.