Connexion dynamique à plusieurs bases de données avec NestJS

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 :

 StatiqueDynamique
Structure de la baseIdentique ou différenteDoit être identique
Ajouter/supprimer une baseModification du codeModificaton 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_*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/app.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-xxxx 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.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *