Construire une application multi-tenant avec API Platform, Symfony et Doctrine (partie 1)
Publié le 10 juin 2024
Dans cet article, nous allons parcourir ensemble les différents chemins que nous avons suivis afin d’implémenter une architecture multi-tenant au sein d’une application utilisant Symfony, API Platform et Doctrine. Cet article fera l’objet d’une deuxième partie où nous détaillerons une solution complètement fonctionnelle que nous avons mise en place et d’un troisième volet où nous regarderons d’un œil critique la solution finale, afin d’en trouver les limites et de possibles évolutions.
Avant de détailler en profondeur, nous allons définir ensemble ce qu’est une application multi-tenant. Selon Wikipédia, cela désigne un principe d'architecture logicielle permettant à une application de servir plusieurs organisations clientes en offrant un partitionnement virtuel des données. Et dans un cas concret ? Qu’est-ce que cela donne ?
Imaginons qu'une entreprise souhaite mettre en place des bibliothèques privées à disposition de ses employés au sein de ses différentes agences. Chacune de ces agences possède un stock différent mais les besoins métier sont exactement les mêmes. L’architecture multi-tenant se prête alors parfaitement à notre cas d’utilisation : nous allons pouvoir créer une unique application, utilisée dans toutes les agences 🥳
Voyons schématiquement comment ça se présente :
Une fois connecté à notre application avec son compte utilisateur, les actions de l'utilisateur seront restreintes aux données de son agence. Mais alors, comment mettre en place ce type d’architecture logicielle dans une application ?
Chez Les-Tilleuls.coop, après avoir vu la conférence de Tugdual Saunier où il explique en détails les différents types et enjeux du multi-tenant, nous avons recherché une implémentation efficace qui allierait Symfony, Doctrine et API Platform.
Les objectifs à mettre en place
Dans le cadre de notre application, nous nous sommes fixés différents buts à atteindre :
- Base de données relationnelle en utilisant PostgreSQL comme SGBD.
- Isoler les ressources des agences dans une base de données propre à chacune.
- Garantir le même état structurel de toutes les bases de données en respectant les propriétés ACID sur nos migrations.
- Utiliser Doctrine pour générer et jouer les migrations des entités de notre application.
- En tant que chef d’agence identifié par le rôle “CLIENT_ADMIN” nous pouvons ajouter des sous-utilisateurs qui sont liés à notre base de données.
- En tant que super utilisateur, nous avons accès à toutes les données de toutes les agences.
- Utiliser API Platform (avec la couche Symfony).
- Cloisonner les utilisateurs lors de leur connexion à l’application.
Séparation des données
Après de nombreuses recherches sur les différentes pistes envisageables, nous nous sommes arrêtés sur l’utilisation d’une configuration Multi EntityManager de Doctrine
Notre application fonctionnera de cette façon :
- Les entités sont divisées en deux catégories : Common et Clients
- Un EntityManager configuré pour chaque catégorie
- Séparer les migrations en fonction de l’EntityManager
Nous nous retrouvons avec une configuration d’application qui ressemble à :
De cette manière, nous pouvons lancer les commandes doctrine:migrations:diff
et doctrine:migrations:migrate
avec l’option --configuration=config/doctrine_migrations_clients.yaml
afin de générer et jouer nos migrations en se basant uniquement sur les entités dans le dossier src/Entity/Clients
.
Cette configuration est un bon début, cependant, ce n’est absolument pas prévu pour être utilisé sur une architecture multi-tenant. En effet, lancer une migration avec la configuration client ne veut pas dire qu’elle se jouera sur toutes nos bases de données clients. Ce que veut dire cette configuration, c’est qu’elle utilisera la connexion Doctrine nommée clients
qui est paramétrée sur une URL différente à celle de common
.
Le problème c’est que l’URL de connexion à un SGBD précise une base de données spécifique, notre migration ne se joue donc, au mieux, que pour un seul client.
Nos recherches nous ont menés à une option dans la configuration des connexions Doctrine qui permet de spécifier une wrapper_class
que nous pouvons faire étendre de la classe Connection
initiale. Créer notre propre classe nous permettra de réécrire l’URL utilisée par la connexion et ainsi jouer notre migration SQL dans une autre base de données que celle prédéfinie.
Notre mission est donc de coupler cette fonctionnalité avec notre configuration des deux EntityManager
différents afin d’obtenir ce résultat :
La base commune stockera nos utilisateurs et sera utilisée pour la connexion à l’application. Une fois l’utilisateur connecté, on utilisera notre classe pour se brancher à sa base de données et ainsi pouvoir le cloisonner à ses ressources pour la suite de ses actions.
La question subsistante est la suivante : comment gérer les mises à jour des bases de données si je veux modifier le schéma ?
Première approche
L’idée qui nous est parue naturelle est la suivante : lorsque je joue une migration, je vais la re-jouer autant de fois que je possède de base de données clients afin que chacune d’entre elles possède les modifications.
Dans un premier temps, il nous faut implémenter notre changement de base de données. Pour ce faire, nous allons créer notre propre classe offrant une méthode capable de modifier l’URL utilisée puis nous allons changer la configuration de la connexion clients
afin de l’utiliser.
class DoctrineMultidatabaseConnection extends Connection
{
public function changeDatabase(string $dbName): void {
$params = $this->getParams();
if ($this->isConnected()) { $this->close(); }
$params['url'] = "postgresql://" . $params['user'] . ":" . $params['password'] . "@" . $params['host'] . ":" . $params['port'] . "/" . $dbName;
$params['dbname'] = $dbName;
parent::__construct($params,$this->_driver, $this->_config, $this->_eventManager);
}
}
connections:
clients:
name: clients
wrapper_class: App\Connection\DoctrineMultidatabaseConnection
Maintenant que nous pouvons changer la base de données courante, il nous manque la possibilité d’exécuter les migrations sur toutes les bases de données.
Nous avons décidé de créer notre propre commande de migration qui récupère la liste des utilisateurs responsable d’une agence (rôle CLIENT_ADMIN
) et exécute les migration clients en se connectant à la base de chacun.
// App\Command\MultitenantMigrationCommand
protected function execute(InputInterface $input, OutputInterface $output): int
{
$databases = $this->getClientDatabases();
$application = new Application($this->kernel);
$arguments = [
'command' => 'doctrine:migrations:migrate,
'--config' => 'config/doctrine_migrations_clients.yaml',
];
$commandInput = new ArrayInput($arguments);
foreach ($databases as $database) {
$this->changeDatabase($database->getName());
$application->run($commandInput, $output);
}
}
Cette solution a été implémentée lors de notre première version et bien qu'elle fonctionne, cela nous amène différents problèmes.
Premièrement, elle nous force à écrire notre propre commande afin de jouer la migration pour chaque client. Bien qu’elle ne soit pas compliquée à mettre en place, on s’éloigne du comportement natif de Doctrine et nous force à assurer la maintenance de ce bout de code.
Ensuite, nous nous sommes rendus compte que chaque migration était jouée dans une transaction indépendante. Cela pourrait causer des différences d’état entre les bases de données clients et ne respecte donc pas les propriétés ACID.
Notre migration peut se passer correctement sur la base de données de notre client A mais casser sur celle de notre client B entraînant donc un rollback uniquement pour cette deuxième transaction, la première ayant déjà été commit, il est trop tard.
Une nouvelle idée
Pour contourner les problèmes listés ci-dessus, nous avons pensé à une solution qui permettrait de jouer les ordres SQL sur toutes les bases de données clients en lançant la commande native doctrine:migrations:migrate
une seule fois.
La liste des bases de données clients est connue à l’avance et comme nous l’avons vu précédemment, il est possible d’étendre le système de connexion de Doctrine pour en enrichir son comportement.
L’idée serait de surcharger la méthode exécutant les ordres SQL pour les jouer sur chaque base de données.
// App\Connection\DoctrineMultidatabaseConnection
public function executeStatement($sql, array $params = [], array $types = [])
{
$databases = $this->getClientDatabases();
foreach ($databases as $database) {
$this->changeDatabase($database->getName());
parent::executeStatement($sql, $params, $types);
}
}
De cette manière, si on décide d’ajouter un attribut disponible
à nos stocks, la migration génèrera un ordre SQL du type ALTER TABLE livres ADD COLUMN disponible BOOLEAN;
Lorsque Doctrine jouera la migration, cet ordre SQL sera exécuté dans notre boucle en changeant de base de données à chaque fois pour y exécuter la requête.
Lancer une migration nous confirme le bon fonctionnement : chaque base client possède maintenant une colonne indiquant la disponibilité de nos livres ! Problème ? Nous avons maintenant une transaction par requête SQL ; c’est pire qu’avant.
Pourquoi ? Le changement de base de données lors de notre appel à changeDatabase
entre chaque ordre SQL provoque un commit automatique de la transaction par le SGBD.
Cette fois, non seulement nos bases de données peuvent différer l’une de l’autre mais surtout, une partie de la migration peut être exécutée et pas le reste. Nous nous éloignons d'une solution au problème initial car celle-ci manque de fiabilité : comment mettre en place une solution multi-tenant en alliant simplicité d’utilisation et fiabilité du système ?
La dernière mais la bonne ?
Et si on essayait de voir le problème différemment ? Avons-nous vraiment besoin de séparer physiquement les ressources dans plusieurs bases de données ? Et si cette séparation n’était que virtuelle ?
Réfléchissons aux besoins :
- Toutes les entités de l’application sont les mêmes pour tous les clients.
- Chaque client possède un CRUD uniquement sur ses données.
- Les migrations respectent les propriétés ACID et impactent l’ensemble de mes données clients.
- On veut garder un comportement le plus proche possible de celui de Symfony.
On pourrait se dire qu’une simple condition WHERE
sur un champ stockant le créateur dans nos ordres SQL pourrait faire l’affaire. Après tout, SELECT * FROM livres WHERE owner = ‘A’;
ne me retournera que les données de mon utilisateur A.
Cette idée n’est pas très loin de notre solution finale, cependant, utiliser cette implémentation apportera son lot de problèmes :
- Perte du comportement natif à Symfony (on doit réécrire les Repository afin d’y ajouter la condition).
- Les données ne sont pas vraiment cloisonnées aux clients (que se passe-t-il en cas de faille SQL ou l’utilisateur pourrait lui-même exécuter une requête sans conditions ?).
- Nous devons gérer un discriminant et stocker le propriétaire de la ressource à chaque création d’une entité ce qui peut vite devenir pénible.
Mais alors, comment avons-nous adapté cette idée pour supprimer ces désavantages ?
En creusant un peu plus dans nos recherches, nous avons décidé d'exploiter les vues SQL ainsi que l’extension postgres_fdw permettant la création de tables étrangères pour ne garder que les avantages d’une implémentation monobase tout en supprimant les principaux désavantages listés ci-dessus.
Retrouvez l’article dédié à l’explication et à l’intégration de cette solution prochainement sur le blog !