W części II nauczyliśmy naszą aplikację rozpoznawać instancję na podstawie requestu i konfigurować połączenie do odpowiedniej bazy danych, problem w tym, że dane instancji, bazę, jej użytkownika oraz zawartość musieliśmy stworzyć “ręcznie”, w tym wpisie trochę ten proces zautomatyzujemy.
Przypomnijmy sobie co jest niezbędne do działania aplikacji:
- rekord w tabeli tenants we wspólnej bazie danych,
- baza danych dla naszego tenanta,
- user bazy danych naszego tenanta,
- schemat dla tej bazy,
- zazwyczaj jakieś początkowe dane.
ZAPIS tenanta
Zacznijmy od funkcjonalności pozwalającej na zapis naszego tenanta w BD, jak pewnie pamiętasz ostatnio stworzyliśmy repozytorium TenantRepository, zaktualizujmy jego interfejs o nową metodę save(Tenant $tenant).
1 2 3 4 5 6 | interface TenantRepositoryInterface { // ... public function save(Tenant $tenant): void; } |
Naturalnie musimy również ją zaimplementować w DoctrineTenantRepository.
1 2 3 4 5 6 7 8 9 10 | final class DoctrineTenantRepository implements TenantRepositoryInterface { // ... public function save(Tenant $tenant): void { $this->em->persist($tenant); $this->em->flush(); } } |
Potrzebujemy miejsca do wykonania naszej nowej metody, proponuję stworzenie komendy konsolowej tenant:create. Komenda “poprosi” nas o podanie nazwy instancji oraz domeny pod jaką będzie uruchomiona, utworzy obiekt klasy Tenant i utrwali go w BD, na koniec wyświetli szczegóły rekordu.
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 | <?php declare (strict_types=1); namespace App\Command; use App\Entity\Common\DatabaseConfig; use App\Entity\Common\Tenant; use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; final class CreateTenantCommand extends ContainerAwareCommand { protected function configure() { $this->setName('tenant:create') ->setDescription('Creates a new tenant'); } protected function execute(InputInterface $input, OutputInterface $output) { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); $name = $helper->ask($input, $output, new Question('Name: ')); $domain = $helper->ask($input, $output, new Question('Domain: ')); $dbConfig = new DatabaseConfig( 'db_'.$name, 'db', 'db_'.$name, bin2hex(random_bytes(10)) ); $tenant = new Tenant($name, $domain, $dbConfig); $tenantRepository = $this->getContainer()->get('app.repository.tenant_repository'); $tenantRepository->save($tenant); $output->writeln('ID: ' . $tenant->id()); $output->writeln('Name: ' . $tenant->name()); $output->writeln('Domain: ' . $tenant->domain()); $output->writeln('DB user: ' . $tenant->dbConfig()->user()); $output->writeln('DB name: ' . $tenant->dbConfig()->database()); $output->writeln('DB host: ' . $tenant->dbConfig()->host()); $output->writeln('DB password: ' . $tenant->dbConfig()->password()); } } |
Sam proces rejestracji nowego tenanta zdecydowanie zasługuje na wyniesienie go do osobnego bytu jednak dla zachowania prostoty przykładów pozostawię go tutaj.
Tworzenie bazy danych
Po wykonaniu komendy w tabeli tenants pojawi się nowy rekord zawierający informacje potrzebne do połączenia z bazą danych tenanta, na razie są one jednak bezużyteczne ponieważ nigdzie nie tworzymy ani bazy ani jej usera.
Przygotujemy nową usługę TenantDatabaseService odpowiedzialną za operacje na bazie tenanta, jej publiczna metoda setupForTenant utworzy BD, odpowiedniego usera oraz wypełnieni bazę schematem i danymi.
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 | <?php declare (strict_types=1); namespace App\Service; use App\Entity\Common\Tenant; use App\Service\DatabaseFiller\FillerInterface; use Doctrine\DBAL\Connection; final class TenantDatabaseService { private $tenant; private $commonConnection; public function __construct(Connection $commonConnection) { $this->commonConnection = $commonConnection; } public function setupForTenant(Tenant $tenant): void { $this->tenant = $tenant; $this->createDatabase(); $this->createDatabaseUser(); $this->fillDatabase(); } private function createDatabase(): void { } private function createDatabaseUser(): void { } private function fillDatabase(): void { } } |
Mamy zarys naszej klasy, zajmijmy się metodą createDatabase. Doctrine dostarcza nam SchemaManager, dzięki niemu w prosty sposób możemy utworzyć nową bazę:
1 2 3 4 5 6 | private function createDatabase(): void { $this->commonConnection->getSchemaManager()->createDatabase( $this->tenant->dbConfig()->database() ); } |
W celu użycia nowej klasy oczywiście rejestrujemy ją jako usługę, jako argument przekazujemy połączenie do wspólnej bazy:
1 2 3 4 | app.service.tenant_database_service: class: 'App\Service\TenantDatabaseService' public: true arguments: ['@doctrine.dbal.default_connection'] |
Wróćmy do komendy CreateTenantCommand i dodajmy wywołanie nowej funkcjonalności po zapisaniu obiektu tenanta w repo:
1 2 3 4 5 6 7 8 | protected function execute(InputInterface $input, OutputInterface $output) { // ... $tenantRepository->save($tenant); $databaseService = $this->getContainer()->get('app.service.tenant_database_service'); $databaseService->setupForTenant($tenant); } |
Po tych zmianach uruchomienie komendy tenant:create utworzy również odpowiednią bazę danych.
Tworzenie użytkownika bazy danych
Ta część jest również bardzo prosta, Doctrine co prawda nie dysponuje gotową metodą ale musimy jedynie wykonać dwa zapytania, w najprostszej postaci wygląda to mniej więcej tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 | private function createDatabaseUser(): void { $user = $this->tenant->dbConfig()->user(); $database = $this->tenant->dbConfig()->database(); $password = $this->tenant->dbConfig()->password(); $sql = <<<SQL CREATE USER '{$user}'@'%' IDENTIFIED BY '{$password}'; GRANT ALL ON {$database}.* TO '{$user}'@'%'; SQL; $this->commonConnection->exec($sql); } |
Tworzenie schematu i wypełnianie bazy
Jest już rekord tenanta, baza i user, niestety baza świeci pustkami, nie ma w niej tabel, a co za tym idzie również danych. Pora zastanowić się w jaki sposób dostarczymy schemat do nowej bazy i czym ją uzupełnimy. Wypełnienie jest oczywiście opcjonalne ale zazwyczaj coś w niej na starcie musi być, np. konto pierwszego użytkownika.
Aby stworzyć schemat możemy np.:
- użyć Doctrine SchemaTool,
- sklonować tabele z bazy “templatki”,
- zaimportować SQL-kę.
Do wypełnienia bazy możemy użyć np.:
- DoctrineFixturesBundle,
- sklonować dane z bazy “templatki”,
- zaimportować SQL-kę.
Zacznijmy od stworzenia interfejsu dla naszego “wypełniacza bazy”:
1 2 3 4 5 6 7 8 9 10 11 12 | <?php declare (strict_types=1); namespace App\Service\DatabaseFiller; use App\Entity\Common\Tenant; interface FillerInterface { public function fillDatabaseOfTenant(Tenant $tenant); } |
Możemy też na początek stworzyć jego pustą implementację:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?php declare (strict_types=1); namespace App\Service\DatabaseFiller; use App\Entity\Common\Tenant; final class NullFiller implements FillerInterface { public function fillDatabaseOfTenant(Tenant $tenant) { echo 'Filling DB'; } } |
Wróćmy do metody fillDatabase z klasy TenantDatabaseService:
1 2 3 4 | private function fillDatabase(): void { $this->filler->fillDatabaseOfTenant($this->tenant); } |
Oczywiście trzeba również odpowiednio zmodyfikować konstruktor klasy.
Konfiguracja usług:
1 2 3 4 5 6 7 8 9 | app.service.tenant_database_service: class: 'App\Service\TenantDatabaseService' public: true arguments: ['@doctrine.dbal.default_connection', '@app.service.database_filler'] app.service.database_filler: '@app.service.database_filler.null_filler' app.service.database_filler.null_filler: class: 'App\Service\DatabaseFiller\NullFiller' |
Po zdefiniowaniu kontraktu FillerInterface podmiana implementacji nie będzie problemem.
schemat przy pomocy Doctrine
Chyba najprostszy sposób na uruchomienie generowania schematu przez Doctrine to wywołanie komendy doctrine:schema:create.
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\Service\DatabaseFiller; use App\Entity\Common\Tenant; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\HttpKernel\KernelInterface; final class DoctrineSchemaFiller implements FillerInterface { private $kernel; public function __construct(KernelInterface $kernel) { $this->kernel = $kernel; } public function fillDatabaseOfTenant(Tenant $tenant) { $consoleApp = new Application($this->kernel); $consoleApp->setAutoExit(false); $input = new ArrayInput([ 'command' => 'doctrine:schema:create', '--em' => 'tenant', '--tenant' => $tenant->name(), ]); $consoleApp->run($input, new NullOutput()); } } |
W tym miejscu możemy również wywołać komendę doctrine:fixtures:load:
1 2 3 4 5 6 7 8 9 | $input = new ArrayInput([ 'command' => 'doctrine:fixtures:load', '--em' => 'tenant', '--tenant' => $tenant->name(), '--fixtures' => 'src/DataFixtures/Tenant/', '--no-interaction' ]); $consoleApp->run($input, new NullOutput()); |
Konfiguracja w services.yaml:
1 2 3 4 5 | app.service.database_filler: '@app.service.database_filler.doctrine_filler' app.service.database_filler.doctrine_filler: class: 'App\Service\DatabaseFiller\DoctrineSchemaFiller' arguments: ['@kernel'] |
Schemat z szablonowej instancji
Te podejście wymaga utrzymywania jednej instancji, z której nikt nie korzysta, służy ona jedynie do skopiowania jej zawartości do nowych baz.
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\Service\DatabaseFiller; use App\Entity\Common\Tenant; use App\Service\TenantConnectionWrapper; use Doctrine\DBAL\Connection; final class TemplateFiller implements FillerInterface { private $connection; private $tenantConnection; private $templateDatabase; public function __construct( Connection $connection, TenantConnectionWrapper $tenantConnection, string $templateDatabase ) { $this->connection = $connection; $this->tenantConnection = $tenantConnection; $this->templateDatabase = $templateDatabase; } public function fillDatabaseOfTenant(Tenant $tenant) { $this->tenantConnection->initTenantConnection($tenant); $templateDb = $this->templateDatabase; $tenantDb = $tenant->dbConfig()->database(); $showTablesSql = "SHOW TABLES FROM `{$templateDb}`"; $tables = $this->connection->executeQuery($showTablesSql)->fetchAll(); foreach ($tables as $table) { $table = array_values($table)[0]; $showSql = "SHOW CREATE TABLE {$templateDb}.{$table}"; $createSql = $this->connection->executeQuery($showSql)->fetch()['Create Table']; $this->tenantConnection->exec($createSql); $insertSql = "INSERT INTO {$tenantDb}.{$table} SELECT * FROM {$templateDb}.{$table}"; $this->connection->exec($insertSql); } } } |
Konfiguracja usługi:
1 2 3 4 5 6 7 8 9 10 11 | parameters: ... template_filler_template: 'db_1' services: ... app.service.database_filler: '@app.service.database_filler.template_filler' app.service.database_filler.template_filler: class: 'App\Service\DatabaseFiller\TemplateFiller' arguments: ['@doctrine.dbal.default_connection', '@doctrine.dbal.tenant_connection', '%template_filler_template%'] |
Schemat z pliku SQL
Podobnie jak w pierwszym przykładzie uruchomimy tutaj gotową komendę, tym razem doctrine:database:import:
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 | <?php declare (strict_types=1); namespace App\Service\DatabaseFiller; use App\Entity\Common\Tenant; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\HttpKernel\KernelInterface; final class SqlFileFiller implements FillerInterface { private $kernel; private $files = []; public function __construct(KernelInterface $kernel, array $files) { $this->kernel = $kernel; $this->files = $files; } public function fillDatabaseOfTenant(Tenant $tenant) { $consoleApp = new Application($this->kernel); $consoleApp->setAutoExit(false); $input = new ArrayInput([ 'command' => 'doctrine:database:import', 'file' => $this->files, '--connection' => 'tenant', '--tenant' => $tenant->name(), ]); $consoleApp->run($input, new NullOutput()); } } |
Listę plików do importu przekażemy w konstruktorze pobierając ją z konfiguracji:
1 2 3 4 5 6 7 8 9 10 11 12 | parameters: ... sql_file_filler_files: - '%kernel.project_dir%/data/sql/schema.sql' - '%kernel.project_dir%/data/sql/data.sql' services: ... app.service.database_filler: '@app.service.database_filler.sql_file_filler' app.service.database_filler.sql_file_filler: class: 'App\Service\DatabaseFiller\SqlFileFiller' arguments: ['@kernel', '%sql_file_filler_files%'] |
Podsumowanie
Stworzyliśmy dziś narzędzia pozwalające obsłużyć proces od zapisu obiektu Tenant w repozytorium do wypełnienia instancji początkowymi danymi.
Dzięki wprowadzeniu interfejsu App\Service\DatabaseFiller\FillerInterface możemy zmieniać strategię inicjalizacji nowej bazy, przestawiłem tylko trzy, na pewno jest ich jeszcze kilka. Rzecz jasna sposób tworzenia schematu i jego wypełniania może się różnić, przykładowo schemat możemy stworzyć z pliku SQL, a wypełnić go fiksturami.
Zaktualizowany kod projektu jest dostępny na Githubie: https://github.com/rafalkot/multi-tenant-app