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

TL;DR

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).

Będziemy potrzebowali również Doctrine oraz DoctrineFixturesBundle.

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.

Ź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:

Aby móc z nich skorzystać potrzebujemy zdefiniować mapowanie dla ORM-a:

Możemy teraz utworzyć schemat naszej bazy danych.

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:

Teraz przejdźmy do implementacji:

Skonfigurujmy jeszcze nasze repozytorium do użycia jako usługę:

Możesz teraz ręcznie wprowadzić dane do tabeli tenants lub wykorzystać DoctrineFixturesBundle zainstalowany na początku:

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.

SQL tworzący bazy db_1, db_2 oraz odpowiednich użytkowników:

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:

Konfiguracja usługi:

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ę:

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:

Oprócz stworzenia klasy musimy jeszcze zarejestrować ją jako usługę i odpowiednio otagować aby framework wiedział jakich zdarzeń nasłuchuje:

Od tej pory możemy mamy dostęp do obiektu tenanta za pomocą TenantContext-u, sprawdźmy to np. w kontrolerze:

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.

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.

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.

Mapowanie dla Doctrine:

Repozytorium:

I jego rejestracja jako usługi, zwróć uwagę na to, że jako argument przekazujemy @doctrine.orm.tenant_entity_manager:

Zaktualizujmy nasz ShopController o nową metodę z listą produktów (pominąłem zmiany zmiany związane z wstrzykiwaniem repo):

Oczywiście nasze bazy db_1 i db_2 są puste, stwórzmy w nich schematy:

Ponownie możemy skorzystać z DoctirneFixturesBundle aby stworzyć kilka testowych rekordów:

Przy ładowaniu fikstur trzeba pamiętać o wskazaniu odpowiedniego katalogu, entity managera i nazwy instancji.

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