Model Software-as-a-Service (dalej zwany, po prostu, SaaS) to w tym momencie prawdopodobnie najbardziej popularny sposób dystrybucji oprogramowania webowego. Polega on na dostarczeniu gotowego do użycia oprogramowania. Różni się on od modelu "on-premise", w którym to dostarczany jest jedynie software, a cały proces instalacji oraz późniejszego utrzymania aplikacji jest pozostawiony użytkownikowi.
Jednym z kluczowych wyborów, jaki musi zostać podjęty, jest wybór między modelem single-tenant a multi-tenant. W tym artykule pokażę, co trzeba wziąć pod uwagę przed wyborem podejścia i przede wszystkim pokażę, jak może wyglądać implementacja tego modelu przy użyciu popularnego frameworka PHP.
Zanim przejdziemy do możliwych rozwiązań, zastanówmy się, jakie nasza architektura powinna posiadać cechy i na co musimy zwrócić uwagę, zarówno z punktu widzenia biznesu, jak i zespołu deweloperskiego. Z najważniejszych, będą to:
Oprócz powyższych cech, wynikających wprost z definicji SaaS, pamiętajmy, że na wybór architektury oraz poszczególnych rozwiązań w aplikacji wpływają zwłaszcza elementy danej logiki biznesowej, potrzeb danej aplikacji. Warto sobie zadać m.in. następujące pytania:
Skoro już wiemy, jakie cechy powinno mieć nasze oprogramowanie, to rozważmy, jakie mamy możliwości pod kątem architektury najwyższego poziomu.
Chciałbym wyjaśnić termin, który w kontekście oprogramowania SaaS pojawia się dość często - "tenant". Z języka angielskiego najemca, lokator - niestety w tym kontekście bezpośrednie tłumaczenie według mnie nie pasuje. W artykule będę używał określenia angielskiego bądź "organizacja" lib "właściciel danych". Pod tym określeniem kryje się grupa użytkowników mająca dostęþ do tych samych danych - w kontekście SaaS to najczęściej pracownicy tej samej firmy, która wykupiła dostęp do naszej aplikacji.
Przede wszystkim mamy do rozważenia dwa główne modele:
Single-tenant, czyli tak naprawdę wiele instancji naszej aplikacji (budowanej już jako klasyczna aplikacja webowa - bez świadomości istnienia różnych właścicieli danych). Każda organizacja posiada niezależne środowisko - każda taka instancja posiada swoją własną bazę danych i wszystkie inne zależności potrzebne do jej uruchomienia.
Multi-tenant, czyli (z zasady) jedna instancja aplikacji współdzielona pomiędzy wszystkie organizacje. Rozdział danych pomiędzy organizacjami/właścicieli danych wykonany jest na poziomie samej aplikacji.
Każda organizacja posiada niezależną instancję, co jest najlepszą, fizyczną barierą dla danych. Również w zakresie dostępności usług, problem z jedną instancją nie wpływa na pozostałe.
Możemy bardzo łatwo wprowadzić nową wersję tylko dla części klientów lub jednemu, wymagającemu klientowi, dać mocniejszą maszynę. Dużo łatwiej również z takiego modelu wyprowadzić ofertę "on-premise".
Jak już zaznaczyłem wcześniej, aplikacja nie ma świadomości tego, że istnieją inne organizacje, więc programiści nie muszą wprowadzać dodatkowych mechanizmów, rozgraniczających dostęp do danych.
Łatwiejsze wdrożenie nowej organizacji, łatwiejsze (natychmiastowe) propagowanie zmian w aplikacji pomiędzy organizacjami, łatwiejsze backupy czy przenoszenie na inną platformę = utrzymujemy jedną maszynę i jej backupy.
Zdecydowanie niższy koszt utrzymania, który w przeciwieństwie do modelu single-tenant nie rośnie w tym samym tempie co liczba organizacji, które nasza aplikacja obsługuje.
I właśnie z tego ostatniego powodu twórcy aplikacji SaaS, mimo trudniejszej budowy oraz braku pełnej kontroli, najczęściej wybierają tę drugą opcję.
Czyli "Show me the code". Chciałbym pokazać, jak może wyglądać implementacja architektury multi-tenant ze wspólną bazą danych przy użyciu frameworka Symfony 4 oraz ORM Doctrine. Rozwiązanie, które zobaczycie poniżej, zostało wykorzystane w jednym z budowanych przez nas projektów - systemu CRM rozpowszechnianego w modelu SaaS, skierowanego dla branży mody, głównie na rynki Ameryki Północnej. Implementacja, którą zobaczycie, świetnie nadaje się jako podstawa MVP dla projektów startupowych.
Jeśli nie chcesz czytać opisu, możesz przejść bezpośrednio do samego kodu - cały znajdziesz udostępniony na moim Githubie.
Idee stojące za poniższym rozwiązaniem:
Na całe rozwiązanie składa się kilka elementów
Na początek najłatwiejsza rzecz - interfejs Tenant, którym oznaczymy encję - w naszym przykładzie będzie to Organization.
<?php
namespace App\Multitenancy;
interface Tenant
{
public function getId(): ?int;
}
Nie wszystkie dane są zależne od zalogowanej organizacji - mogą istnieć tabele słownikowe które będą dostępne dla wszystkich użytkowników. Musimy wiedzieć które encje mamy filtrować - do tego wykorzystamy inferfejs TenantAware.
<?php
namespace App\Multitenancy;
interface TenantAware
{
public function setTenant(Tenant $tenant);
public function getTenant(): ?Tenant;
}
Dodatkowo napiszmy sobie prosty Trait - TenantAwareTrait - który do każdej tabeli doda kolumnę TenantID
<?php
namespace App\Entity;
use App\Multitenancy\Tenant;
trait TenantAwareTrait
{
/**
* @ORM\ManyToOne(targetEntity="Organization")
* @ORM\JoinColumn(name="TenantID", referencedColumnName="id")
*/
protected $tenant;
public function setTenant(Tenant $tenant)
{
$this->tenant = $tenant;
}
public function getTenant(): Tenant
{
return $this->tenant;
}
}
Dzięki powyższym - interfejsowi i traitowi, nasza przykładowa encja Note wygląda następująco:
<?php
namespace App\Entity;
use App\Multitenancy\TenantAware;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass="App\Repository\NoteRepository")
*/
class Note implements TenantAware
{
use TenantAwareTrait;
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $message;
public function __construct(string $message)
{
$this->message = $message;
}
public function getId(): ?int
{
return $this->id;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
}
Cała nasza aplikacja będzie zależeć od tego, o jakim właścicielu danych mówimy, więc musimy mieć łatwy dostęp do tej wiedzy - wystarczy do tego zbudować prostą usługę (np. na kształt security.context z Symfony 2).
<?php
namespace App\Multitenancy;
use App\Multitenancy\Exception\TenantNotSet;
class TenantContext
{
private $tenant;
private $isInitialized = false;
public function initialize(Tenant $tenant)
{
if ($this->isInitialized) {
throw new \Exception("Tenant Context already initialized");
}
$this->isInitialized = true;
$this->tenant = $tenant;
}
/**
* @throws TenantNotSet
*/
public function getCurrentTenant(): Tenant
{
if (is_null($this->tenant)) {
throw new TenantNotSet("No tenant yet!");
}
return $this->tenant;
}
}
Powyższa klasa ma ważne zadanie - trzymać informacje o zalogowanej organizacji i nie dopuścić do jej zmiany w trakcie życia obiektu.
Zależy nam również na spójnym sposobie wyszukiwania danego właściciela danych - w naszym przypadku sprawę załatwi prosty interfejs i klasa, która większość swojego zadania oddaje do repozytorium Doctrine, ale w przyszłości mogą się tutaj pojawić dodatkowe warunki - np. status płatności.
<?php
namespace App\Multitenancy\Provider;
use App\Multitenancy\Tenant;
use App\Multitenancy\TenantProvider;
use App\Repository\OrganizationRepository;
class OrganizationTenantProvider implements TenantProvider
{
/**
* @var OrganizationRepository
*/
private $organizationRepository;
public function __construct(OrganizationRepository $organizationRepository)
{
$this->organizationRepository = $organizationRepository;
}
public function findBySubdomain(string $subdomain): ?Tenant
{
$organization = $this->organizationRepository->findOneBySubdomain($subdomain);
// add here additional logic - e.g. if organization is active
return $organization;
}
}
W architekturze single-tenant ten etap nie występuje, bo cała instancja opiera się na danych jednego właściciela, natomiast skoro wybraliśmy "multi-tenant" to pierwszym krokiem, jaki nasza aplikacja musi wykonać, jest ustalenie kogo dane będziemy chcieli pokazać. To, jak do tego podejdziemy, zależy od logiki biznesowej naszej aplikacji, ale mamy kilka opcji:
Na potrzeby naszego przykładu wybierzmy pierwszą opcję.
<?php
namespace App\Multitenancy\Resolver;
use App\Multitenancy\Exception\TenantNotFound;
use App\Multitenancy\Tenant;
use App\Multitenancy\TenantProvider;
use App\Multitenancy\TenantResolver;
use Symfony\Component\HttpFoundation\Request;
class SubdomainTenantResolver implements TenantResolver
{
private $tenantProvider;
public function __construct(TenantProvider $tenantProvider)
{
$this->tenantProvider = $tenantProvider;
}
public function resolve(Request $request): Tenant
{
// example - simple get subdomain
$stubs=explode(".", $request->getHost());
$subdomain=$stubs[0];
$tenant = $this->tenantProvider->findBySubdomain($subdomain);
if (!$tenant) {
throw new TenantNotFound();
}
return $tenant;
}
}
Powyższe 3 klasy spinają nam się w Listenerze kernel.request.
<?php
namespace App\Multitenancy\Http\Listener;
use App\Multitenancy\Doctrine\Filter\TenantFilter;
use App\Multitenancy\Exception\TenantNotFound;
use App\Multitenancy\TenantContext;
use App\Multitenancy\TenantResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
class RequestListener
{
/**
* @var TenantResolver
*/
private $resolver;
/**
* @var TenantContext
*/
private $tenantContext;
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(TenantResolver $resolver, TenantContext $tenantContext, EntityManagerInterface $entityManager)
{
$this->resolver = $resolver;
$this->tenantContext = $tenantContext;
$this->entityManager = $entityManager;
}
public function onKernelRequest(GetResponseEvent $event)
{
if (!$event->isMasterRequest()) {
// don't do anything if it's not the master request
return;
}
try {
$tenant = $this->resolver->resolve($event->getRequest());
$this->tenantContext->initialize($tenant);
/** @var TenantFilter $filter */
$filter = $this->entityManager->getFilters()->getFilter('tenant_filter');
$filter->setTenant($tenant);
} catch (TenantNotFound $exception) {
$event->setResponse(new Response("Tenant not found", Response::HTTP_NOT_FOUND));
}
}
}
Najważniejszy fragmentem powyższego kodu jest blok try, w którym mamy wszystkie początkowe etapy:
Przechodzimy do najważniejszej części implementacji - automatycznego filtrowania danych na podstawie właściciela oraz automatycznego ustawienia go przy dodawaniu rekordów.
Do filtrowania danych na podstawie właściciela wykorzystamy istniejący mechanizm filtrów w doctrine (dostępny od wersji 2.2):
<?php
namespace App\Multitenancy\Doctrine\Filter;
use App\Multitenancy\TenantAware;
use App\Multitenancy\Tenant;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
class TenantFilter extends SQLFilter
{
/** @var Tenant|null */
private $tenant;
public function setTenant(Tenant $tenant)
{
$this->tenant = $tenant;
}
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
{
if (!$this->tenant) {
return "";
}
// Check if the entity implements the TenantAware interface
if (!$targetEntity->reflClass->implementsInterface(TenantAware::class)) {
return "";
}
//add sql condition
$condition = $targetTableAlias . '.TenantID = ' . $this->tenant->getId(); // getParameter applies quoting automatically
return $condition;
}
}
YAML:
doctrine:
orm:
filters:
tenant_filter:
class: App\Multitenancy\Doctrine\Filter\TenantFilter
enabled: true
Powyższe sprawi dodanie fragmentów ‘WHERE TenantID=?’ do każdego zapytania o encję oznaczoną interfejsem TenantAware.
Ostatnim etapem jest przypisywanie właściciela do świeżo dodanych rekordów - do tego wykorzystamy mechanizm listenerów w doctrine:
<?php
namespace App\Multitenancy\Doctrine\Listener;
use App\Multitenancy\Exception\TenantNotSet;
use App\Multitenancy\TenantAware;
use App\Multitenancy\TenantContext;
use Doctrine\ORM\Event\LifecycleEventArgs;
class TenantListener
{
/**
* @var TenantContext
*/
private $tenantContext;
public function __construct(TenantContext $tenantContext)
{
$this->tenantContext = $tenantContext;
}
public function prePersist(LifecycleEventArgs $args)
{
$entity = $args->getEntity();
if ($entity instanceof TenantAware) {
try {
$tenant = $this->tenantContext->getCurrentTenant();
$entity->setTenant($tenant);
} catch (TenantNotSet $exception) {
if (is_null($entity->getTenant())) {
throw new \InvalidArgumentException("Tenant has to be set before you try to add entity related to it");
}
}
}
}
}
Dzięki powyższym cała reszta aplikacji, która operuje na tych danych, nie musi być świadoma w ogóle istnienia właściciela danych - będzie to dla niej całkowicie przeźroczyste. Dzięki temu łatwo również możemy np. utworzyć drugą instancję aplikacji tylko dla jednego klienta (jeśli np. okazałoby się, że ma on większe potrzeby względem wydajności).
Budowa aplikacji multitenancy różni się od zwykłego projektu, w których mamy dane widocznie “globalne” albo standardowe uprawnienia tylko na poziomie jednego użytkownika. Ale jak mam nadzieję pokazałem, budowa prostego (i bezpiecznego) rozwiązania, które będzie można później rozwijać, nie jest skomplikowana przy użyciu Symfony i Doctrine.