W poprzedniej części omówiłem trzy strategie na organizację baz danych w aplikacjach multi-tenant. Dzisiaj zajmiemy się tym jak takie rozwiązanie wcielić w życie, przygotujemy mechanizm dynamicznie konfigurujący połączenie do odpowiedniej bazy danych na podstawie requestu HTTP (i nie tylko!).
Założenia
Wdrażamy rozwiązanie oparte o wspólną bazę danych z informacjami o instancjach oraz bazach danych z jakich korzystają. Bez znaczenia jest tutaj czy każdy klient będzie posiadał dedykowaną bazę czy kilku będzie umieszczonych w jednej.
Przez instancję rozumiem tutaj klienta oraz wszelkie jego dane, nie instancję jako instalację aplikacji.
Chcemy aby na podstawie żądania HTTP zmieniała się konfiguracja połączenia z bazą danych oraz docelowo zapewne innych serwisów naszej aplikacji.
Potrzebujemy jakiejś wartości przychodzącej od klienta naszej aplikacji, która pozwoli nam określić, z którą instancją mamy do czynienia.
Zależnie od tego co będzie robić Twoja aplikacja sposób tego w jaki będziesz dokonywać identyfikacji tenanta będzie się różnił, może to być np. subdomena, domena, nagłówek czy wpis w sesji. W naszym przykładzie będzie to domena.
Środowisko
Abyśmy mogli zacząć prace potrzebujemy oczywiście środowiska developerskiego. Nie chciałbym tutaj się skupiać na tym jak je postawić bo jest o tym masa artykułów, w tej serii będę korzystał z gotowego rozwiązania opartego o Dockera:
https://github.com/yahyaerturan/docker-symfony-flex
Nasza aplikacja będzie korzystała z Symfony. Zakładam, że znasz ten framework chociaż w podstawowym stopniu. Na potrzeby tej serii utworzyłem nowy projekt bazujący na szkielecie dostarczanym przez Symfony (w czasie pisania artykułu była to wersja 4.0.6).
1 | composer create-project symfony/skeleton multi-tenant-app |
Będziemy potrzebowali również Doctrine oraz DoctrineFixturesBundle.
1 2 | composer req orm-pack composer require --dev doctrine/doctrine-fixtures-bundle:"^2.4.1" |
Uwaga: instaluję doctrine/doctrine-fixtures-bundle w wersji 2.4.1 ponieważ w najnowszej wersji 3.x nie jest już dostępna opcja –fixtures, w której można wskazać ścieżkę do katalogu.
Konfiguracja
Na początek musimy skonfigurować dwa połączenia do bazy oraz EntityManagera dla każdego z nich.
Połączenie default będzie obsługiwało wspólną bazę zawierającą listę naszych instancji i je możemy po prostu skonfigurować na podstawie zmiennych środowiskowych.
Połączenie tenant będzie łączyło nas do bazy aktualnie używanej instancji, jak widać nie definiujemy tutaj danych do połączenia, pojawia się za to opcja wrapper_class, wskazujemy w niej klasę, która będzie użyta zamiast domyślnej Doctrine\DBAL\Connection, za jej pomocą wstawimy do połączenia dane do bazy instancji, wrócimy do niej później.
Każdy EntityManager ma swoją konfigurację mapowania a mapowane obiekty znajdują się w oddzielnych przestrzeniach nazw.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | parameters: db_common_name: '%env(DATABASE_COMMON_NAME)%' db_common_user: '%env(DATABASE_COMMON_USER)%' db_common_host: '%env(DATABASE_COMMON_HOST)%' db_common_password: '%env(DATABASE_COMMON_PASSWORD)%' doctrine: dbal: default_connection: default connections: default: driver: 'pdo_mysql' server_version: '5.7' charset: utf8mb4 dbname: '%db_common_name%' host: '%db_common_host%' user: '%db_common_user%' password: '%db_common_password%' tenant: driver: 'pdo_mysql' server_version: '5.7' charset: utf8mb4 wrapper_class: 'App\Service\TenantConnectionWrapper' orm: auto_generate_proxy_classes: '%kernel.debug%' default_entity_manager: default entity_managers: default: connection: default mappings: Common: is_bundle: false mapping: true type: xml dir: "%kernel.root_dir%/../config/doctrine/orm/default" prefix: App\Entity\Common alias: Common tenant: connection: tenant mappings: Shop: is_bundle: false mapping: true type: xml dir: "%kernel.root_dir%/../config/doctrine/orm/shop" prefix: App\Entity\Shop alias: Shop |
Źródło instancji
Jak już wspomniałem nasze rozwiązanie będzie zawierało jedną wspólną bazę danych z informacjami o dostępnych instancjach. Po stronie aplikacji będą one reprezentowane przez klasy Tenant i DatababaseConfig:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | <?php declare (strict_types=1); namespace App\Entity\Common; final class Tenant implements \JsonSerializable { private $id; private $name; private $domain; private $dbConfig; public function __construct(string $name, string $domain, DatabaseConfig $dbConfig) { $this->name = $name; $this->domain = $domain; $this->dbConfig = $dbConfig; } public function id(): int { return $this->id; } public function name(): string { return $this->name; } public function domain(): string { return $this->domain; } public function dbConfig(): DatabaseConfig { return $this->dbConfig; } public function jsonSerialize() { return [ 'id' => $this->id, 'name' => $this->name, 'domain' => $this->domain ]; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | <?php declare (strict_types=1); namespace App\Entity\Common; final class DatabaseConfig { private $database; private $host; private $user; private $password; public function __construct(string $database, string $host, string $user, string $password) { $this->database = $database; $this->host = $host; $this->user = $user; $this->password = $password; } public function database(): string { return $this->database; } public function host(): string { return $this->host; } public function user(): string { return $this->user; } public function password(): string { return $this->password; } } |
Aby móc z nich skorzystać potrzebujemy zdefiniować mapowanie dla ORM-a:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?xml version="1.0" encoding="UTF-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="App\Entity\Common\Tenant" table="tenants"> <id name="id" type="integer" column="id"> <generator strategy="AUTO" /> </id> <field name="name" type="string" length="50" unique="true" nullable="false" /> <field name="domain" type="string" length="50" unique="true" nullable="false" /> <embedded name="dbConfig" class="App\Entity\Common\DatabaseConfig" column-prefix="db_" /> </entity> </doctrine-mapping> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <?xml version="1.0" encoding="UTF-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <embeddable name="App\Entity\Common\DatabaseConfig"> <field name="database" type="string" length="50" nullable="false" /> <field name="host" type="string" length="50" nullable="false" /> <field name="user" type="string" length="50" nullable="false" /> <field name="password" type="string" length="100" nullable="false" /> </embeddable> </doctrine-mapping> |
Możemy teraz utworzyć schemat naszej bazy danych.
1 | bin/console doctrine:schema:create --em=default |
Mamy już encję Tenant, konfigurację jej mapowania oraz tabelę, teraz potrzebujemy dostępu do niej, oczywiście możemy pobrać EnityManagera z kontenera DI ale myślę, że bardziej elegancko będzie to zrobić tworząc dedykowane repozytorium.
Zacznijmy od zdefiniowania interfejsu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <?php declare (strict_types=1); namespace App\Repository; use App\Entity\Common\Tenant; interface TenantRepositoryInterface { public function findById(int $id):? Tenant; public function findByDomain(string $domain):? Tenant; public function findByName(string $name):? Tenant; } |
Teraz przejdźmy do implementacji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | <?php declare (strict_types=1); namespace App\Repository; use App\Entity\Common\Tenant; use Doctrine\ORM\EntityManagerInterface; final class DoctrineTenantRepository implements TenantRepositoryInterface { private $em; public function __construct(EntityManagerInterface $em) { $this->em = $em; } public function findById(int $id):? Tenant { return $this->em->find(Tenant::class, $id); } public function findByDomain(string $domain):? Tenant { return $this->em ->getRepository(Tenant::class) ->findOneBy( [ 'domain' => $domain, ] ); } public function findByName(string $name):? Tenant { return $this->em ->getRepository(Tenant::class) ->findOneBy( [ 'name' => $name, ] ); } } |
Skonfigurujmy jeszcze nasze repozytorium do użycia jako usługę:
1 2 3 4 5 | App\Repository\TenantRepositoryInterface: '@app.repository.tenant_repository' app.repository.tenant_repository: class: 'App\Repository\DoctrineTenantRepository' arguments: ['@doctrine.orm.default_entity_manager'] |
Możesz teraz ręcznie wprowadzić dane do tabeli tenants lub wykorzystać DoctrineFixturesBundle zainstalowany na początku:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php declare (strict_types=1); namespace App\DataFixtures\DefaultDb; use App\Entity\Common\DatabaseConfig; use App\Entity\Common\Tenant; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\Persistence\ObjectManager; final class TenantFixtures extends Fixture { public function load(ObjectManager $manager) { $dbConfig1 = new DatabaseConfig('db_1', 'db', 'db_user_1', 'password1'); $tenant1 = new Tenant('tenant1', 'tenant1.myapp.local', $dbConfig1); $dbConfig2 = new DatabaseConfig('db_2', 'db', 'db_user_2', 'password2'); $tenant2 = new Tenant('tenant2', 'tenant2.myapp.local', $dbConfig2); $manager->persist($tenant1); $manager->persist($tenant2); $manager->flush(); } } |
Należy pamiętać o wskazaniu opcji –fixtures z katalogiem naszych fikstur dla połączenia default przy wykonywaniu komendy. Zwróć uwagę na drugi parametr w konstruktorze DatabaseConfig, powinien to być host do Twojego serwera BD, w drugim parametrze konstruktora Tenant powinna być domena pod jaką będzie dostępna instancja.
1 | bin/console doctrine:fixtures:load --fixtures=src/DataFixtures/DefaultDb/ --em=default |
SQL tworzący bazy db_1, db_2 oraz odpowiednich użytkowników:
1 2 3 4 5 6 7 | CREATE USER 'db_user_1'@'%' IDENTIFIED BY 'password1'; CREATE DATABASE IF NOT EXISTS db_1; GRANT ALL ON db_1.* TO 'db_user_1'@'%'; CREATE USER 'db_user_2'@'%' IDENTIFIED BY 'password2'; CREATE DATABASE IF NOT EXISTS db_2; GRANT ALL ON db_2.* TO 'db_user_2'@'%'; |
Po wypełnieniu bazy naszymi instancjami jesteśmy gotowi rozpoczęcia prac nad rozpoznawaniem kto chce skorzystać z naszej aplikacji.
Dynamiczna Konfiguracja Doctrine na podstawie requestu
Wiemy, że nasze instancje możemy zidentyfikować na podstawie domeny, mamy w repozytorium gotową metodę findByDomain, potrzebujemy miejsca gdzie możemy ją wywołać i musi być to na dość wczesnym etapie działania aplikacji. Na szczęście Symfony daje możliwość słuchania zdarzeń wyrzucanych w trakcie przetwarzania żądania, istnieje kilka typów zdarzeń, możesz się z nimi zapoznać tutaj, dla nas istotny będzie kernel.request.
Plan jest taki – pobieramy z requestu domenę, na jej podstawie z repo wczytujemy odpowiedni obiekt klasy Tenant, utrwalamy gdzieś obiekt tenanta aby mieć do niego dostęp z innych miejsc aplikacji oraz konfigurujemy połączenie tenant.
Pobranie domeny i odpowiedniego dla niej tenanta nie stanowi problemu, nasz listener dostanie obiekt GetResponseEvent, z którego z sobie wybierzemy obiekt Request-u a repozytorium zostanie mu wstrzyknięte przez kontener DI. Musimy stworzyć miejsca gdzie przechowamy obiekt naszego tenanta i ustawimy odpowiednie dane połączenia w Doctrine.
Do zapewnienia dostępu do obiektu tenanta wykorzystamy prostą klasę TenantContext:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php declare (strict_types=1); namespace App\Service; use App\Entity\Common\Tenant; final class TenantContext { private $tenant; public function getTenant():? Tenant { return $this->tenant; } public function setTenant(Tenant $tenant) { $this->tenant = $tenant; } } |
Konfiguracja usługi:
1 2 3 4 5 | App\Service\TenantContext: '@app.service.tenant_context' app.service.tenant_context: public: true class: 'App\Service\TenantContext' |
Stworzyliśmy miejsce gdzie umieścimy nasz obiekt klasy Tenant, pozostało skonfigurować połączenie do bazy, pamiętasz opcję wrapper_class z ustawień Doctrine? Oto jak może wyglądać przykładowa klasa zmieniająca konfigurację:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | <?php declare (strict_types=1); namespace App\Service; use App\Entity\Common\Tenant; use Doctrine\DBAL\Connection; final class TenantConnectionWrapper extends Connection { public function initTenantConnection(Tenant $tenant) { $this->close(); $reflection = new \ReflectionObject($this); $refProperty = $reflection->getParentClass()->getProperty('_params'); $refProperty->setAccessible(true); $params = $refProperty->getValue($this); $params['host'] = $tenant->dbConfig()->host(); $params['user'] = $tenant->dbConfig()->user(); $params['password'] = $tenant->dbConfig()->password(); $params['dbname'] = $tenant->dbConfig()->database(); $refProperty->setValue($this, $params); } } |
Jak widać dziedziczy ona po domyślnej klasie połączenia oraz dostarcza nową metodę initTenantConnection, która przyjmuje obiekt klasy Tenant i na jego podstawie (z małą pomocą refleksji) zmienia parametry połączenia.
Stwórzmy teraz nasz event listener:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | <?php declare (strict_types=1); namespace App\EventListener; use App\Repository\TenantRepositoryInterface; use App\Service\TenantConnectionWrapper; use App\Service\TenantContext; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseEvent; final class TenantRequestListener { private $tenants; private $tenantConnection; private $context; public function __construct( TenantRepositoryInterface $tenants, TenantConnectionWrapper $tenantConnection, TenantContext $context ) { $this->tenants = $tenants; $this->tenantConnection = $tenantConnection; $this->context = $context; } public function onKernelRequest(GetResponseEvent $event) { $request = $event->getRequest(); $domain = $request->getHost(); $tenant = $this->tenants->findByDomain($domain); if (!$tenant) { $event->setResponse(new Response('Invalid domain', 404)); return; } $this->context->setTenant($tenant); $this->tenantConnection->initTenantConnection($tenant); } } |
Oprócz stworzenia klasy musimy jeszcze zarejestrować ją jako usługę i odpowiednio otagować aby framework wiedział jakich zdarzeń nasłuchuje:
1 2 3 4 5 | app.event_listener.tenaant_request_listener: class: 'App\EventListener\TenantRequestListener' arguments: ['@app.repository.tenant_repository', '@doctrine.dbal.tenant_connection', '@app.service.tenant_context'] tags: - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest } |
Od tej pory możemy mamy dostęp do obiektu tenanta za pomocą TenantContext-u, sprawdźmy to np. w kontrolerze:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | <?php declare (strict_types=1); namespace App\Controller; use App\Service\TenantContext; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Annotation\Route; final class ShopController { private $tenantContext; public function __construct(TenantContext $tenantContext) { $this->tenantContext = $tenantContext; } /** * @Route("/") */ public function index() { return new JsonResponse($this->tenantContext->getTenant()); } } |
Po wejściu na tenantX.myapp.local powinniśmy zobaczyć informacje o aktualnej instancji.
Konfiguracja instancji dla komend konsolowych
Korzystając z konsoli jedynym miejscem gdzie możemy przekazać dla jakiej instancji ma się wykonać komenda są jej opcje. Oczywiście nie będziemy do każdej naszej (i nie naszej) komendy dodawać kolejnej opcji przyjmującej nazwę (czy inną daną pozwalającą na identyfikację) instancji.
Z pomocą kolejny raz przychodzi mechanizm zdarzeń. Rozwiązanie będzie bardzo podobne do powyższego, zmieni się event jakiego słuchamy no i musimy jeszcze zdefiniować wspomnianą opcję komendy.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | <?php declare (strict_types=1); namespace App\EventListener; use App\Repository\TenantRepositoryInterface; use App\Service\TenantConnectionWrapper; use App\Service\TenantContext; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Input\InputOption; final class TenantCommandListener { private $tenants; private $tenantConnection; private $context; public function __construct( TenantRepositoryInterface $tenants, TenantConnectionWrapper $tenantConnection, TenantContext $context ) { $this->tenants = $tenants; $this->tenantConnection = $tenantConnection; $this->context = $context; } public function onConsoleCommand(ConsoleCommandEvent $event) { $command = $event->getCommand(); $input = $event->getInput(); $command->addOption('tenant', null, InputOption::VALUE_OPTIONAL); $input->bind($command->getDefinition()); $name = $input->getOption('tenant'); if (!$name) { return; } $tenant = $this->tenants->findByName($name); if (!$tenant) { throw new \Exception('Invalid tenant name'); } $this->context->setTenant($tenant); $this->tenantConnection->initTenantConnection($tenant); } } |
Tym razem otrzymujemy ConsoleCommandEvent, z niego wybieramy obiekt wykonywanej komendy i dodajemy nową opcję tenant gdzie będziemy przekazywać nazwę instancji. Dalsze działanie jest analogiczne tylko zamiast findByDomain korzystamy z findByName.
Po rejestracji usługi możemy uruchamiać komendy z opcją –tenant=XXX.
1 2 3 4 5 | app.event_listener.tenant_command_listener: class: 'App\EventListener\TenantCommandListener' arguments: [ '@app.repository.tenant_repository', '@doctrine.dbal.tenant_connection', '@app.service.tenant_context'] tags: - { name: kernel.event_listener, event: console.command, method: onConsoleCommand } |
Korzystamy z konfiguracji Instancji
Stwórzmy encję Product i repozytorium, które pozwolą nam na sprawdzenie działania tego co do tej pory zrobiliśmy.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | <?php declare (strict_types=1); namespace App\Entity\Shop; final class Product implements \JsonSerializable { private $id; private $name; public function __construct(string $name) { $this->name = $name; } public function id(): int { return $this->id; } public function name(): string { return $this->name; } public function jsonSerialize() { return [ 'id' => $this->id, 'name' => $this->name, ]; } } |
Mapowanie dla Doctrine:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?xml version="1.0" encoding="UTF-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="App\Entity\Shop\Product" table="products"> <id name="id" type="integer" column="id"> <generator strategy="AUTO" /> </id> <field name="name" type="string" length="50" unique="true" nullable="false" /> </entity> </doctrine-mapping> |
Repozytorium:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <?php declare (strict_types=1); namespace App\Repository; use App\Entity\Shop\Product; use Doctrine\ORM\EntityManagerInterface; final class DoctrineProductRepository implements ProductRepositoryInterface { private $em; public function __construct(EntityManagerInterface $em) { $this->em = $em; } public function all(): array { return $this->em ->getRepository(Product::class) ->findAll(); } } |
I jego rejestracja jako usługi, zwróć uwagę na to, że jako argument przekazujemy @doctrine.orm.tenant_entity_manager:
1 2 3 4 5 | App\Repository\ProductRepositoryInterface: '@app.repository.product_repository' app.repository.product_repository: class: 'App\Repository\DoctrineProductRepository' arguments: ['@doctrine.orm.tenant_entity_manager'] |
Zaktualizujmy nasz ShopController o nową metodę z listą produktów (pominąłem zmiany zmiany związane z wstrzykiwaniem repo):
1 2 3 4 5 6 7 | /** * @Route("/products") */ public function products() { return new JsonResponse($this->products->all()); } |
Oczywiście nasze bazy db_1 i db_2 są puste, stwórzmy w nich schematy:
1 2 | bin/console doctrine:schema:create --em=tenant --tenant=tenant1 bin/console doctrine:schema:create --em=tenant --tenant=tenant2 |
Ponownie możemy skorzystać z DoctirneFixturesBundle aby stworzyć kilka testowych rekordów:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <?php declare (strict_types=1); namespace App\DataFixtures\Tenant; use App\Entity\Shop\Product; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\Persistence\ObjectManager; final class ProductFixtures extends Fixture { public function load(ObjectManager $manager) { $tenant = $this->container->get('app.service.tenant_context')->getTenant(); $product1 = new Product($tenant->name() . ' product 1'); $product2 = new Product($tenant->name() . ' product 2'); $manager->persist($product1); $manager->persist($product2); $manager->flush(); } } |
Przy ładowaniu fikstur trzeba pamiętać o wskazaniu odpowiedniego katalogu, entity managera i nazwy instancji.
1 2 | bin/console doctrine:fixtures:load --fixtures=src/DataFixtures/Tenant/ --em=tenant --tenant=tenant1 bin/console doctrine:fixtures:load --fixtures=src/DataFixtures/Tenant/ --em=tenant --tenant=tenant2 |
Podsumowanie
Udało nam się skonfigurować Symfony i Doctrine abyśmy mogli połączyć się do odpowiedniej bazy danych zarówno dla żądań HTTP jak i dla komend konsolowych, rozwiązanie operuje na zdarzeniach generowanych przez Symfony dzięki czemu nie musieliśmy robić różnych dziwnych kombinacji w miejscach niekoniecznie do tego przeznaczonych.
Cały kod projektu znajdziesz na Githubie: https://github.com/rafalkot/multi-tenant-app