La documentation officielle de NestJS explique très clairement comment se connecter à plusieurs bases de données en utilisant une configuration statique.
L’un de nos clients a un cas d’usage différent et souhaite que son application puisse se connecter à plusieurs bases de données chacune étant dédiée à un pays. Il veut pouvoir ajouter et supprimer des bases de données dynamiquement, sans avoir à modifier le code source.
Voici les principales différences entre ces deux modèles :
Statique | Dynamique | |
Structure de la base | Identique ou différente | Doit être identique |
Ajouter/supprimer une base | Modification du code | Modificaton de la configuration et redémarrage de l’application |
Mettre en place la configuration dynamique
Voici le fichier.env
tel qu’on souhaiterait l’utiliser :
DATABASE_SYSTEM_IDS=FR,DE,GB
DB_FR_TYPE=postgres
DB_FR_HOST=localhost
DB_FR_PORT=5432
DB_FR_USERNAME=fr
DB_FR_PASSWORD=fr
DB_FR_DATABASE=fr
DB_FR_SYNCHRONIZE=true
DB_DE_TYPE=postgres
DB_DE_HOST=localhost
DB_DE_PORT=5433
DB_DE_USERNAME=de
DB_DE_PASSWORD=de
DB_DE_DATABASE=de
DB_DE_SYNCHRONIZE=true
DB_GB_TYPE=postgres
DB_GB_HOST=localhost
DB_GB_PORT=5434
DB_GB_USERNAME=gb
DB_GB_PASSWORD=gb
DB_GB_DATABASE=gb
DB_GB_SYNCHRONIZE=true
DATABASE_SYSTEM_IDS est une liste séparée par des virgules d’identifiants système définis arbitrairement et qui représentent chacune des bases de données à laquelle on souhaite se connecter. Pour chaque identifiant système on définit un jeu de variables nommées DB_xx_* où xx prend la valeur de cet identifiant.
Ci-dessous la classe de configuration qui nous permet de gérer ce genre de configuration (fichier src/common/config/orm.config.ts) :
import { registerAs } from '@nestjs/config';
import * as dotenv from 'dotenv';
dotenv.config(); // used to get process.env access prior to AppModule instanciation
export const getDatabaseSystemIds = (): string[] => {
return process.env.DATABASE_SYSTEM_IDS.split(',');
};
export default registerAs('orm', () => {
const config = {};
getDatabaseSystemIds().forEach((systemId) => {
config[systemId] = {
type: process.env[`DB_${systemId}_TYPE`],
host: process.env[`DB_${systemId}_HOST`],
port: parseInt(process.env[`DB_${systemId}_PORT`]),
username: process.env[`DB_${systemId}_USERNAME`],
password: process.env[`DB_${systemId}_PASSWORD`],
database: process.env[`DB_${systemId}_DATABASE`],
synchronize: process.env[`DB_${systemId}_SYNCHRONIZE`] === 'true',
entities: [`${__dirname}/.. /.. /**/*.entity{.ts,.js}`],
};
});
return config;
});
Voici comment on génère dynamiquement une configuration TypeORM pour chaque base de données (fichier src/home/jnesis/www/wp.module.ts) :
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CarManufacturersModule } from './car-manufacturers/car-manufacturers.module';
import ormConfig, { getDatabaseSystemIds } from './common/config/orm.config';
// database connection for each system id
const databasesConfig = getDatabaseSystemIds().map((systemId) => {
return TypeOrmModule.forRootAsync({
name: `database-${systemId}`,
imports: [ConfigModule.forFeature(ormConfig)],
useFactory: (config: ConfigService) => config.get(`orm.${systemId}`),
inject: [ConfigService],
});
});
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
...databasesConfig,
CarManufacturersModule,
],
controllers: [],
providers: [],
})
export class AppModule {}
Notez que chacune de ces configurations TypeORM se voit assigner le nom database-xx où xx est l’identifiant système.
Mettre en place l'injection de dépendance dynamique
Maintenant que notre configuration est dynamique, nous avons besoin de nous connecter à une base de données en particulier en utilisant son identifiant système. L’injection de dépendance standard (à travers @InjectEntityManager , @InjectRepository, etc…) ne peut pas être utilisée car elle requiert de connaître la base de données à laquelle on souhaite se connecter au moment de la compilation.
Pour pouvoir sélectionner dynamiquement une base de données pendant l’exécution de l’application, il nous faut utiliser la fonctionnalité module reference de NestJS. De cette manière il est possible d’instancier un EntityManager depuis son nom de configuration TypeORM database-xx (méthode loadEntityManager dans le fichier src/car-manufacturers/car-manufacturers.service.ts) :
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { getEntityManagerToken } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { CarManufacturer } from './car-manufacturer.entity';
@Injectable()
export class CarManufacturersService {
constructor(private moduleRef: ModuleRef) {}
private async loadEntityManager(systemId: string): Promise<EntityManager> {
return this.moduleRef.get(getEntityManagerToken(`database-${systemId}`), {
strict: false,
});
}
async findAll(countryCode: string): Promise<CarManufacturer[]> {
const entityManager = await this.loadEntityManager(countryCode);
if (!entityManager) {
return [];
}
return entityManager.find(CarManufacturer);
}
}
Projet de démonstration
Vous trouverez un exemple complet et fonctionnel ici sur Github.
La limitation de structure identique
En réalité nous pourrions avoir des structures différentes même avec des bases de données dynamiques. Cela nécessite de définir la forme de chaque base en indiquant le jeu d’entitées qu’elle va utiliser.
Par exemple nous pourrions ajouter un paramètre au fichier .env :
DB_FR_ENTITIES=structureA
DB_DE_ENTITIES=structureB
DB_GB_ENTITIES=structureA
A partir de là il nous suffit de charger les entitées dynamiquement en se basant sur le nom de la structure en tant que suffixe (fichier src/common/config/orm.config.ts) :
entities: [`${__dirname}/.. /.. /**/*.${process.env[`DB_${systemId}_ENTITIES`]}.entity{.ts,.js}`],
Il faudra alors définir les entités pour chaque structure et elle peuvent être complètement différentes :
- StructureA: car-manufacturer.structureA.entity.ts
- StructureB: brand.structureB.entity.ts, address.structureB.entity.ts
Évidemment les requêtes vont être différentes d’une structure à l’autre, cela nécessitera donc des ajouts de conditions et branchements dans le code métier.