ExpressJS (TypeScript) – 架構進階模式
使用 DI(如 tsyringe / inversify)
簡介
在大型的 Node.js/Express 專案中,隨著功能不斷累積,相依關係(Dependency) 會變得越來越複雜。傳統的 require / import 直接在檔案裡寫死實例,會導致以下問題:
- 測試困難:無法輕易替換成 mock 物件。
- 耦合度高:模組之間相互依賴,改動一個檔案往往需要連帶修改多個檔案。
- 維護成本上升:新加入的服務或套件需要手動管理建立與注入的時機。
使用 依賴注入(Dependency Injection, DI) 可以將「建立物件」的責任交給容器(Container),讓程式碼只關注「我需要什麼」而不是「我怎麼取得它」。在 TypeScript 生態中,tsyringe 與 inversify 是兩個最常見、且相容 Express 的 DI 套件。本篇文章將以 實務導向 的方式說明如何在 Express + TypeScript 專案中導入 DI,並提供完整範例、常見陷阱與最佳實踐,幫助你從初學者順利晉升為中級開發者。
核心概念
1. 什麼是依賴注入?
依賴注入是一種 設計模式,其核心思想是將物件的相依(Dependency)從「內部自行建立」抽離,改由外部 容器 注入。簡單來說:
- 依賴(Dependency):一個類別需要使用的其他類別或服務。
- 容器(Container):負責註冊、解析、管理相依關係的中心。
- 注入方式:透過建構子(Constructor)、屬性(Property)或方法(Method)注入。
使用 DI 後,我們可以:
- 解耦:類別只依賴抽象(介面 / 抽象類別),不直接依賴具體實作。
- 易測試:測試時只要提供 mock 實作即可,無需啟動完整的 Express 伺服器。
- 可擴充:新增或替換服務只需要在容器中註冊,原有程式碼不必變動。
2. 為什麼選擇 tsyringe 或 inversify?
| 套件 | 特色 | 何時選擇 |
|---|---|---|
| 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。 |
建議使用 enum 或 Symbol 常量集中管理 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)或重新建立 Container(inversify)。 |
其他最佳實踐
- 依賴抽象:永遠針對介面(
IUserService)編碼,而非具體實作(UserService)。 - 分層註冊:將所有註冊寫在 單一檔案(
container.ts或inversify.config.ts),保持入口乾淨。 - 使用
@singleton(tsyringe)或.inSingletonScope()(inversify):對於無狀態服務(如 Logger、Cache)使用單例,可減少資源浪費。 - 加入自動掃描(可選):
inversify的container.load能自動讀取多個模組,適合大型專案。 - 保持裝飾器簡潔:不要在建構子裡寫過多邏輯,保持「只注入」的職責。
實際應用場景
| 場景 | 為什麼需要 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(依賴注入),不只是「寫得更優雅」的技巧,更是 提升可測試性、降低耦合、加速開發 的關鍵。本文以 tsyringe 與 inversify 為例,說明了:
- DI 的核心概念:容器、相依、注入方式。
- 兩大套件的安裝、設定與基本範例,讓你能快速在現有專案中加入 DI。
- 進階技巧(Factory Provider、跨模組注入)以及 常見陷阱 的避免方法。
- 實務場景:多資料庫、第三方 API、全域 Logger、動態授權與微服務 RPC,皆可藉由 DI 簡化實作與測試。
關鍵要點:在設計階段先把介面抽出來,讓容器負責實例化; 選擇適合專案規模的套件(輕量
tsyringe或功能完整inversify); 在測試環境中使用容器重新註冊或重置,即可享受到乾淨、可維護、易測試的 Express 應用程式。
現在就把上述步驟套用到你的專案中,體驗 DI 為開發流程帶來的變化吧!祝開發順利 🚀.