Aufbau eines einfachen Dependency Injection Containers in Express ohne NestJS
Wenhao Wang
Dev Intern · Leapcell

Einleitung
In der Welt der Webentwicklung sind die Aufrechterhaltung sauberer, testbarer und skalierbarer Codebasen von größter Bedeutung. Wenn Anwendungen komplexer werden, kann die Verwaltung von Abhängigkeiten zu einer erheblichen Herausforderung werden. Eng gekoppelte Komponenten führen zu fragilem Code, machen Refactoring zu einem Albtraum und Unit-Tests zu einer mühsamen Aufgabe. Dependency Injection (DI) ist ein leistungsstarkes Entwurfsmuster, um diese Probleme anzugehen, lose Kopplung zu fördern und die Modularität zu verbessern. Während Frameworks wie NestJS im Handumdrehen robuste, meinungsbildende DI-Container anbieten, rechtfertigen viele bestehende oder kleinere Express.js-Projekte keine vollständige Framework-Überarbeitung. Dieser Artikel untersucht, wie man manuell einen einfachen, aber effektiven Dependency Injection Container direkt innerhalb einer Express-Anwendung implementiert und nutzt dabei die dynamische Natur von JavaScript, um die Codeorganisation und Testbarkeit ohne den Overhead eines voll ausgestatteten Frameworks zu verbessern.
Dependency Injection verstehen
Bevor wir in die Implementierung eintauchen, wollen wir ein klares Verständnis der beteiligten Kernkonzepte schaffen:
- Dependency Injection (DI): Ein Entwurfsmuster, bei dem Komponenten ihre Abhängigkeiten von einer externen Quelle erhalten, anstatt sie selbst zu erstellen. Diese "Inversion of Control" macht Komponenten unabhängiger und wiederverwendbarer.
 - IoC Container (Inversion of Control Container): Ein Framework oder ein Mechanismus, der für die Verwaltung des Lebenszyklus und der Abhängigkeiten von Komponenten verantwortlich ist. Er instanziiert Objekte und injiziert deren benötigte Abhängigkeiten. In unserem Fall werden wir eine sehr einfache Version davon bauen.
 - Service: Eine Klasse oder Funktion, die eine bestimmte Aufgabe ausführt und oft von anderen Diensten abhängt. Dienste sind die primären Kandidaten für Dependency Injection.
 - Provider: Ein Mechanismus, der dem IoC-Container mitteilt, wie eine Instanz einer bestimmten Abhängigkeit erstellt wird. Dies kann eine Konstruktorfunktion, eine Factory-Funktion oder eine vorhandene Instanz sein.
 
Das Kernprinzip hinter DI ist, dass ein Modul seine Abhängigkeiten nicht instanziieren, sondern deklarieren sollte. Eine externe Entität (der Container) "injiziert" diese Abhängigkeiten dann zur Laufzeit. Dies führt zu mehreren Vorteilen: erhöhte Testbarkeit (Abhängigkeiten können einfach gemockt werden), reduzierte Kopplung und verbesserte Wartbarkeit.
Aufbau eines einfachen DI Containers
Unser einfacher DI-Container konzentriert sich auf einige wichtige Fähigkeiten: Registrierung von Diensten, Auflösung von Diensten und deren Abhängigkeiten sowie optional die Verwaltung von Singletons.
Die Container-Klasse
Zuerst erstellen wir eine Container-Klasse, die unsere registrierten Dienste speichert und Methoden zur Registrierung und Auflösung bereitstellt.
// container.js class Container { constructor() { this.services = new Map(); } /** * Registriert einen Dienst beim Container. * @param {string} name - Der eindeutige Name des Dienstes. * @param {class | Function} ServiceProvider - Der Klassenkonstruktor oder die Factory-Funktion für den Dienst. * @param {Array<string>} dependencies - Ein Array von Namen der Dienste, von denen dieser Dienst abhängt. * @param {boolean} isSingleton - Ob dieser Dienst ein Singleton sein soll. */ 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 // Zur Speicherung der Singleton-Instanz }); } /** * Löst auf und gibt eine Instanz des angeforderten Dienstes zurück. * @param {string} name - Der Name des aufzulösenden Dienstes. * @returns {any} Eine Instanz des Dienstes. * @throws {Error} Wenn der Dienst oder seine Abhängigkeiten nicht aufgelöst werden können. */ 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; // Vorhandene Singleton-Instanz zurückgeben } const resolvedDependencies = serviceEntry.dependencies.map(depName => { // Abängigkeiten rekursiv auflösen return this.resolve(depName); }); let serviceInstance; if (typeof serviceEntry.provider === 'function') { // Prüfen, ob es sich um einen Klassenkonstruktor oder eine normale Funktion handelt try { // Versuch, als Klasse zu instanziieren serviceInstance = new serviceEntry.provider(...resolvedDependencies); } catch (e) { // Wenn es sich um eine normale Funktion handelt oder die Klasseninstanziierung fehlschlug (z.B. Versuch, eine Factory-Funktion mit new aufzurufen) // Als Factory-Funktion behandeln serviceInstance = serviceEntry.provider(...resolvedDependencies); } } else { // Wenn der Provider keine Funktion ist, annehmen, dass es sich um ein vorinstanziiertes Objekt handelt (direkter Wert) serviceInstance = serviceEntry.provider; } if (serviceEntry.isSingleton) { serviceEntry.instance = serviceInstance; // Singleton-Instanz speichern } return serviceInstance; } } const container = new Container(); module.exports = container;
Beispiel-Dienste
Nehmen wir an, wir haben einen LoggerService und einen UserService, der vom LoggerService und einem EmailService abhängt.
// 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}`); // In einer echten Anwendung würde dies mit einer E-Mail-Versand-API integriert 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}`); // ... Logik zum Speichern des Benutzers in der 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}`); // ... Logik zum Abrufen des Benutzers aus der DB ... return { id, name: "John Doe", email: "john.doe@example.com" }; } } module.exports = UserService;
Registrierung von Diensten
Nun müssen wir diese Dienste in unserem container registrieren. Dies geschieht normalerweise während der Bootstrap-Phase der Anwendung.
// app.js (oder eine Initialisierungsdatei) const container = require('./container'); const LoggerService = require('./services/LoggerService'); const EmailService = require('./services/EmailService'); const UserService = require('./services/UserService'); // LoggerService als Singleton registrieren container.register('LoggerService', LoggerService, [], true); // EmailService als Singleton registrieren container.register('EmailService', EmailService, [], true); // UserService registrieren und seine Abhängigkeiten angeben container.register('UserService', UserService, ['LoggerService', 'EmailService']); // Sie können auch ein vorinstanziiertes Objekt oder eine Factory-Funktion registrieren 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);
Integration mit Express.js
Die Hauptherausforderung in Express.js besteht darin, wie dieser Container innerhalb von Routenhandlern oder Middleware genutzt werden kann, da diese normalerweise direkt von Express aufgerufen werden und nicht von unserem Container. Ein gängiges Muster ist, die benötigten Dienste innerhalb des Routenhandlers aus dem Container zu holen.
// server.js const express = require('express'); const app = express(); const container = require('./container'); // Unser DI-Container wurde initialisiert // Dienste initialisieren (dies würde normalerweise in app.js oder einer Index-Datei erfolgen) require('./app'); // Diese Datei registriert alle unsere Dienste beim Container app.use(express.json()); // Beispielroute, die injizierte Dienste verwendet 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}`); });
Anwendungsfälle
Dieser manuelle DI-Container ist in mehreren Szenarien besonders nützlich:
- Bestehende Express-Projekte: Wenn Sie ein besseres Abhängigkeitsmanagement einführen möchten, ohne eine vollständige Framework-Migration durchzuführen.
 - Kleinere Microservices: Wo der Overhead eines großen Frameworks unverhältnismäßig zur Komplexität des Dienstes sein könnte.
 - Lernen und Verstehen: Eine großartige Möglichkeit, die Grundlagen von Dependency Injection zu verstehen, bevor man sich mit fortschrittlicheren Frameworks beschäftigt.
 - Testbarkeit: In Unit-Tests können Sie Abhängigkeiten einfach durch Mocking oder Austauschen von Diensten durch Registrierung verschiedener Anbieter für die Testumgebung simulieren.
 
Fazit
Die manuelle Implementierung eines einfachen Dependency Injection Containers in einer Express.js-Anwendung ohne die Abhängigkeit von Frameworks wie NestJS ist ein praktischer Ansatz zur Verbesserung der Code-Struktur, Testbarkeit und Wartbarkeit. Durch das Verständnis der Kernprinzipien von DI und die Erstellung einer einfachen Container-Klasse können Entwickler Dienstabhängigkeiten effektiv verwalten, was zu saubereren und modulareren Express-Anwendungen führt. Dies befähigt Entwickler, DI-Prinzipien selektiv dort anzuwenden, wo sie den größten Nutzen bringen, und fördert skalierbare und robuste Backend-Systeme mit größerer Kontrolle über die architektonischen Entscheidungen.