Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Construction architecture multi-tenant

Avatar for hido hido
May 16, 2025
6

Construction architecture multi-tenant

Avatar for hido

hido

May 16, 2025

Transcript

  1. PHP, Web and Cloud Experts ➔ Développement PHP, JS, Go,

    Rust… ➔ DevOps et SRE ➔ Consulting et audits ➔ Formations ➔ [email protected] 💌
  2. Les différents types MULTI-TENANT 1 Shared application, shared database. MULTI-TENANT

    2 Shared application, separate database. SINGLE-TENANT Separate application, separate database.
  3. Les différents types ✓ Simplicité de mise en place ✓

    Réduction des coûts ⛌ Fuite de données ⛌ Performances ⛌ Logique applicative MULTI-TENANT 1 Shared Application, shared database
  4. Les différents types MULTI-TENANT 2 Shared Application, separate database ✓

    Meilleure isolation des données ✓ Meilleures ⛌ Attention aux migrations ⛌ Performances
  5. Les différents types SINGLE-TENANT Separate Application, separate database ✓ Séparation

    complète des données ✓ Indépendance de l’application ✓ Performances ⛌ Mise en place plus complexe ⛌ Gestion des mises à jour ⛌ Compétences
  6. Changement de base class DoctrineMultiDatabaseConnection extends Connection { public function

    changeDatabase(string $user, string $pw, string $db): void { if ($this->isConnected()) { $this->close(); } parent::__construct( "postgresql://{$user}:{$pw]}@host:port/{$db}", ..., ); } }
  7. Objectifs & contraintes ➔ Rester compatible avec les bundles ➔

    Une base de données par tenant ➔ Éviter les modifications applicatives ➔ Automatiser le processus ➔ Permettre un “super” utilisateur
  8. Choix de l’implémentation ✓ Simplicité de mise en place ✓

    Meilleure isolation des données ✓ Peu de modifications applicative ⛌ Performances ⛌ Portabilité
  9. Implémentation avec Symfony ➔ Gestion de la requête HTTP •

    Intercepter la requête • Identification du tenant ➔ Gérer les utilisateurs ➔ Appliquer les migrations ➔ Point sur les perfs
  10. #[AsEventListener(event: 'kernel.request', method: 'onKernelRequest')] class KernelRequestEvent { public function __construct(private

    readonly Security $security) {} public function onKernelRequest(): void { $user = $this->security->getUser(); $doctrineConnection->changeDatabase([ 'dbname' => $user->getSqlDbName(), 'user' => $user->getSqlUserName(), 'pw' => $user->getSqlPassword(), ]); } } Gestion de la requête
  11. Implémentation avec Symfony ➔ Gestion de la requête HTTP ➔

    Gérer les utilisateurs • Créer la base de données • Générer et importer les vues • Gêrer le discriminant ➔ Appliquer les migrations ➔ Point sur les perfs
  12. Gérer les utilisateurs class MultiTenantDatabaseManager { private array $tables; private

    array $manyTables; public function main(User $user) { $this->createDatabaseForUser($user); $this->populateTablesArrays(); $this->generateViewsForUser($user); $this->importForeignTablesForUser($user); } }
  13. Création de la base // createDatabaseForUser $this->connection->changeDatabase(['dbname' => $user->getSqlDbName()]); (new

    Application($this->kernel))->run( new ArrayInput(['command' => 'doctrine:database:create']), new NullOutput() ); $this->connection->executeStatement(" CREATE USER {$user->getSqlUserName()} WITH PASSWORD '{$user->getSqlPassword()}'; ");
  14. Gestion des tables // populateTablesArrays $metadatas = $this->entityManager->getMetadataFactory()->getAllMetadata(); foreach ($metadatas

    as $metadata) { $tableName = $metadata->getTableName(); $this->tables[$tableName] = []; $mappings = $metadata->getAssociationMappings(); foreach ($mappings as $mapping) { if ($mapping instanceof ManyToManyOwningSideMapping) { $tableName = $mapping->joinTable->name; $this->manyTables[$tableName] = $mapping->joinTableColumns; $this->manyTables[$tableName][] = $this->entityManager->getClassMetadata($mapping->sourceEntity)->getTableName(); $this->manyTables[$tableName][] = $this->entityManager->getClassMetadata($mapping->targetEntity)->getTableName(); } } }
  15. Création des vues // generateViewsForUser (base commune) $this->connection->changeDatabase(['dbname' => 'common']);

    foreach ($this->tables as $name => $options) { $viewName = "{$name}_{$psqlId}"; $this->connection->executeStatement(" CREATE VIEW {$viewName} AS SELECT * FROM {$name} WHERE owner_id = '{$user->getId()}'; GRANT SELECT, INSERT, UPDATE, DELETE on {$viewName} TO {$username}; "); }
  16. Création des vues (ManyToMany) // generateViewsForUser (base commune) $this->connection->changeDatabase(['dbname' =>

    'common']); foreach ($this->manyTables as $name => $options) { $view = "{$name}_{$psqlId}"; $this->connection->executeStatement(" CREATE VIEW {$view} AS SELECT * FROM {$name} WHERE {$options[0]} IN (SELECT id FROM {$options[2]} WHERE owner_id = '{$id}') OR {$options[1]} IN (SELECT id FROM {$options[3]} WHERE owner_id = '{$id}'); GRANT SELECT, INSERT, UPDATE, DELETE on {$view} TO {$username}; "); }
  17. Création des tables étrangères // importForeignTablesForUser (base du tenant) $this->connection->changeDatabase(['dbname'

    => $user->getSqlDbName()]); $this->connection->executeStatement(" CREATE EXTENSION IF NOT EXISTS postgres_fdw; CREATE SERVER IF NOT EXISTS common_fdw FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '127.0.0.1', port '5432', dbname 'common'); CREATE USER MAPPING IF NOT EXISTS FOR common SERVER common_fdw OPTIONS (user 'common'); CREATE USER MAPPING IF NOT EXISTS FOR {$user->getSqlUser()} SERVER common_fdw OPTIONS (user '{$user->getSqlUser()}', password '{$user->getSqlPassword()}'); "); foreach ($this->tables as $name => $options) { $viewName = "{$name}_{$psqlId}"; $this->connection->executeStatement(" IMPORT FOREIGN SCHEMA public LIMIT TO ({$viewName}) FROM SERVER common_fdw INTO public; ALTER TABLE {$viewName} RENAME TO {$name}; "); }
  18. Identifiants automatiques CREATE OR REPLACE FUNCTION override_id() RETURNS trigger as

    \$override_id\$ BEGIN NEW.owner_id := 'idDeMonUtilisateur'; RETURN NEW; END; \$override_id\$ LANGUAGE plpgsql; CREATE OR REPLACE TRIGGER monSuperTrigger BEFORE INSERT ON "maSuperTable" FOR EACH ROW EXECUTE PROCEDURE override_id();
  19. Implémentation avec Symfony ➔ Gestion de la requête HTTP ➔

    Gérer les utilisateurs ➔ Appliquer les migrations • Ecouter doctrine:migrations:migrate • Reconstruire les tables étrangères ➔ Point sur les perfs
  20. Écouter doctrine:migrations:migrate #[AsEventListener] class MigrationCommandListener { public function __construct(private KernelInterface

    $kernel) { } public function __invoke(ConsoleTerminateEvent $event): void { if ('doctrine:migrations:migrate' == $event->getCommand()->getName() && 0 == $event->getExitCode()) { $application = new Application($this->kernel); $input = new ArrayInput([ 'command' => 'app:database:foreigns', ]); $application->run($input, new NullOutput()); } } }
  21. Reconstruire les tables étrangères #[AsCommand(name: 'app:database:foreigns')] class GenerateForeignsCommand extends Command

    { #[\Override] protected function execute(): int { $tenants = $this->userRepository->findAllTenants(); ... foreach ($tenants as $tenant) { $this->tenancyManager->generateViewsForUser($tenant); $this->tenancyManager->importForeignTablesForUser($tenant); } ... } }
  22. Implémentation avec Symfony ➔ Gestion de la requête HTTP ➔

    Gérer les utilisateurs ➔ Appliquer les migrations ➔ Point sur les perfs • Benchmarks • Cache • Taille du curseur
  23. Vues matérialisées CREATE MATERIALIZED VIEW materialized_books AS ( SELECT *

    FROM books ) REFRESH MATERIALIZED VIEW cached_books ; Depuis Postgres 15: MERGE INTO books b USING books_stock_changes s ON s.book_id = b.id WHEN MATCHED UPDATE SET in_stock = s.returned
  24. Sous domaines (bonus) KernelEventListener.php: $subDomain = explode('.', $_SERVER['HTTP_HOST'])[0]; $tenant =

    $this->tenantManager->findTenantBySubdomain($subDomain); if(!$tenant) throw new NotFoundHttpException(); $doctrineConnection->changeDatabase($tenant); Caddyfile: *.nomdedomaine.com { root * /path/to/app/public php_server }
  25. Merci de votre attention ! ➔ Des questions ? [email protected]

    les-tilleuls.coop @coopTilleuls @les-tilleuls.coop