NestJSなしでExpressに基本的な依存性注入コンテナを構築する
Wenhao Wang
Dev Intern · Leapcell

はじめに
Web開発の世界では、クリーンでテスト可能でスケーラブルなコードベースを維持することが最優先事項です。アプリケーションが複雑になるにつれて、依存関係の管理は大きな課題となる可能性があります。密結合されたコンポーネントは、壊れやすいコードにつながり、リファクタリングを悪夢のようなものにし、単体テストを困難な作業にします。依存性注入(DI)は、これらの問題に対処するための強力なデザインパターンとして登場し、疎結合を促進し、モジュール性を向上させます。NestJSのようなフレームワークは、堅牢で意見のあるDIコンテナをすぐに利用できますが、多くの既存または小規模なExpress.jsプロジェクトでは、完全なフレームワークのオーバーホールを正当化できない場合があります。この記事では、フル機能のフレームワークのオーバーヘッドなしで、Expressアプリケーション内に直接、シンプルでありながら効果的な依存性注入コンテナを手動で実装し、JavaScriptの動的な性質を活用してコードの編成とテスト可能性を向上させる方法を探ります。
依存性注入の理解
実装の詳細に入る前に、関連するコアコンセプトを明確に理解しておきましょう。
- 依存性注入(DI): コンポーネントが、それ自体で作成するのではなく、外部ソースから依存関係を受け取るデザインパターンです。この「制御の反転」により、コンポーネントはより独立して再利用可能になります。
 - IoCコンテナ(制御の反転コンテナ): コンポーネントのライフサイクルと依存関係を管理する責任を負うフレームワークまたはメカニズムです。オブジェクトをインスタンス化し、その必要な依存関係を注入します。この場合、非常に基本的なバージョンを構築します。
 - サービス: 特定のタスクを実行し、しばしば他のサービスに依存するクラスまたは関数です。サービスは依存性注入の主な候補です。
 - プロバイダー: IoCコンテナに特定の依存関係のインスタンスをどのように作成するかを伝えるメカニズムです。これは、コンストラクタ関数、ファクトリ関数、または既存のインスタンスである可能性があります。
 
DIのコア原則は、モジュールが依存関係をインスタンス化するのではなく、宣言すべきであるということです。外部エンティティ(コンテナ)が、実行時にこれらの依存関係を「注入」します。これにより、いくつかの利点があります。テスト可能性の向上(依存関係を簡単にモックできる)、結合の削減、保守性の向上です。
シンプルなDIコンテナの構築
私たちのシンプルなDIコンテナは、いくつかの主要な機能に焦点を当てます。サービスの登録、サービスとその依存関係の解決、およびオプションでシングルトンの管理です。
コンテナクラス
まず、登録されたサービスを保持し、それらを登録および解決するためのメソッドを提供するContainerクラスを作成しましょう。
// container.js class Container { constructor() { this.services = new Map(); } /** * サービスをコンテナに登録します。 * @param {string} name - サービスのユニークな名前。 * @param {class | Function} ServiceProvider - サービスのクラスコンストラクタまたはファクトリ関数。 * @param {Array<string>} dependencies - このサービスが依存するサービスの名の配列。 * @param {boolean} isSingleton - このサービスがシングルトンであるべきかどうか。 */ register(name, ServiceProvider, dependencies = [], isSingleton = false) { if (this.services.has(name)) { console.warn(`Service "${name}" is being re-registered.`); } this.services.set(name, { provider: ServiceProvider, dependencies: dependencies, isSingleton: isSingleton, instance: null // シングルトンインスタンスを格納する }); } /** * 要求されたサービスのインスタンスを解決して返します。 * @param {string} name - 解決するサービスのの名前。 * @returns {any} サービスのインスタンス。 * @throws {Error} サービスまたはその依存関係が解決できない場合。 */ resolve(name) { const serviceEntry = this.services.get(name); if (!serviceEntry) { throw new Error(`Service "${name}" not found.`); } if (serviceEntry.isSingleton && serviceEntry.instance) { return serviceEntry.instance; // 既存のシングルトンインスタンスを返す } const resolvedDependencies = serviceEntry.dependencies.map(depName => { // 依存関係を再帰的に解決する return this.resolve(depName); }); let serviceInstance; if (typeof serviceEntry.provider === 'function') { // クラスコンストラクタか通常の関数かを確認する try { // クラスとしてインスタンス化を試みる serviceInstance = new serviceEntry.provider(...resolvedDependencies); } catch (e) { // 通常の関数である場合、またはクラスインスタンス化が失敗した場合(例:ファクトリ関数をnewしようとした場合) // ファクトリ関数として扱う serviceInstance = serviceEntry.provider(...resolvedDependencies); } } else { // プロバイダーが関数でない場合、事前にインスタンス化されたオブジェクト(直接の値)とみなす serviceInstance = serviceEntry.provider; } if (serviceEntry.isSingleton) { serviceEntry.instance = serviceInstance; // シングルトンインスタンスを格納する } return serviceInstance; } } const container = new Container(); module.exports = container;
サンプルサービス
LoggerServiceと、LoggerServiceおよびEmailServiceに依存するUserServiceがあると仮定してみましょう。
// services/LoggerService.js class LoggerService { log(message) { console.log(`[LOG] ${message}`); } error(message) { console.error(`[ERROR] ${message}`); } } module.exports = LoggerService; // services/EmailService.js class EmailService { send(to, subject, body) { console.log(`Sending email to ${to} with subject "${subject}" and body: ${body}`); // 本番アプリでは、これはメール送信APIと統合される return true; } } module.exports = EmailService; // services/UserService.js class UserService { constructor(loggerService, emailService) { this.logger = loggerService; this.emailService = emailService; } createUser(name, email) { this.logger.log(`Attempting to create user: ${name}`); // ... DBにユーザーを保存するロジック ... const user = { id: Date.now(), name, email }; this.emailService.send(email, 'Welcome!', `Hello ${name}, welcome to our service!`); this.logger.log(`User ${name} created successfully.`); return user; } getUser(id) { this.logger.log(`Fetching user with ID: ${id}`); // ... DBからユーザーを取得するロジック ... return { id, name: "John Doe", email: "john.doe@example.com" }; } } module.exports = UserService;
サービスの登録
次に、これらのサービスをcontainerに登録する必要があります。これは通常、アプリケーションのブートストラップフェーズで行われます。
// app.js (または初期化ファイル) const container = require('./container'); const LoggerService = require('./services/LoggerService'); const EmailService = require('./services/EmailService'); const UserService = require('./services/UserService'); // LoggerServiceをシングルトンとして登録 container.register('LoggerService', LoggerService, [], true); // EmailServiceをシングルトンとして登録 container.register('EmailService', EmailService, [], true); // UserServiceを登録し、その依存関係を指定 container.register('UserService', UserService, ['LoggerService', 'EmailService']); // 事前にインスタンス化されたオブジェクトやファクトリ関数も登録できます container.register('Config', { database: 'mongodb://localhost/mydb', port: 3000 }, [], true); container.register('DatabaseConnection', (config) => { console.log(`Connecting to database: ${config.database}`); return { query: (sql) => console.log(`Executing SQL: ${sql} on ${config.database}`) }; }, ['Config'], true);
Express.jsとの統合
Express.jsにおける主な課題は、ルートハンドラやミドルウェア内でこのコンテナをどのように活用するかということです。これらは通常、Express自体によって呼び出され、私たちのコンテナによって直接呼び出されるわけではありません。一般的なパターンは、ルートハンドラ内で必要なサービスをコンテナから取得することです。
// server.js const express = require('express'); const app = express(); const container = require('./container'); // 初期化されたDIコンテナ // サービスの初期化(通常はapp.jsまたはindexファイルで行われる) require('./app'); // このファイルがコンテナにすべてのサービスを登録する app.use(express.json()); // 注入されたサービスを使用した例ルート app.post('/users', (req, res) => { try { const userService = container.resolve('UserService'); const { name, email } = req.body; if (!name || !email) { return res.status(400).send('Name and email are required.'); } const newUser = userService.createUser(name, email); res.status(201).json(newUser); } catch (error) { const logger = container.resolve('LoggerService'); logger.error(`Error creating user: ${error.message}`); res.status(500).send('Internal server error.'); } }); app.get('/users/:id', (req, res) => { try { const userService = container.resolve('UserService'); const logger = container.resolve('LoggerService'); const userId = req.params.id; logger.log(`Received request to get user by ID: ${userId}`); const user = userService.getUser(userId); if (!user) { return res.status(404).send('User not found.'); } res.json(user); } catch (error) { const logger = container.resolve('LoggerService'); logger.error(`Error fetching user: ${error.message}`); res.status(500).send('Internal server error.'); } }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { const logger = container.resolve('LoggerService'); logger.log(`Server running on port ${PORT}`); });
アプリケーションシナリオ
この手動DIコンテナは、いくつかのシナリオで特に役立ちます。
- 既存のExpressプロジェクト: 完全なフレームワーク移行なしで、より良い依存関係管理を導入したい場合。
 - 小規模なマイクロサービス: 大規模なフレームワークのオーバーヘッドが、サービスの複雑さに対して不均衡になる可能性がある場合。
 - 学習と理解: より高度なフレームワークを検討する前に、依存性注入の基本を理解するのに役立ちます。
 - テスト可能性: 単体テストでは、テスト環境のために異なるプロバイダーを登録することで、依存関係を簡単にモックまたはスワップできます。
 
結論
NestJSのようなフレームワークに依存せずに、Express.jsアプリケーションでシンプルな依存性注入コンテナを手動で実装することは、コード構造、テスト可能性、保守性を向上させるための実用的なアプローチです。DIのコア原則を理解し、基本的なContainerクラスを作成することで、開発者はサービス依存関係を効果的に管理でき、よりクリーンでモジュール化されたExpressアプリケーションにつながります。これにより、開発者はDI原則を最も価値をもたらす場所に選択的に適用できるようになり、アーキテクチャの選択においてより大きな制御をもって、スケーラブルで堅牢なバックエンドシステムを育成できます。