Sytuacja kobiet w IT w 2024 roku
29.05.201910 min
Jerzy Zawadzki
Polcode Sp. z o.o.

Jerzy ZawadzkiCTOPolcode Sp. z o.o.

Architektura SaaS: multi-tenant w Symfony

Sprawdź, jakie korzyści płyną z podejścia multi-tenant w SaaS i zobacz, jak je dobrze zaimplementować w Symfony.

Architektura SaaS: multi-tenant w Symfony

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.

Architektura

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:

  • Bezpieczeństwo- w każdej aplikacji ma to znaczenie, ale w SaaS powinno być to na pierwszym miejscu z kilku powodów. Pierwszy to przede wszystkim dana logika biznesowa oraz typ przechowywanych danych - bardzo często w formie SaaS dostarczane jest oprogramowanie dla firm (np. CRM), więc niejednokrotnie mamy do czynienia z danymi kluczowymi dla działania danej firmy, a w tym modelu to na nas spada obowiązek zabezpieczenia tych danych oraz backupy. Drugi powód to "data separation" w kontekście dostępu dla użytkowników, którzy nie tylko mają, bądź nie mają uprawnień do danej funkcjonalności, ale przede wszystkim mają dostęp do danych tylko swojej organizacji. Delikatnie mówiąc, wysoce niepożądanym jest, żeby pracownicy jednej firmy widzieli dane innej;
  • Koszt zarówno stworzenia, jak i późniejszego utrzymania danego rozwiązania;
  • Skalowalność, w której musimy wziąć pod uwagę nie tylko rosnący ruch w serwisie,  a głównie przyrost ilości danych w organizacjach. Z doświadczenia powiem, że już dla kilkudziesięciu organizacji, liczba rekordów w niektórych źródłach danych (np. zamówieniach), może bardzo łatwo przekroczyć 1 milion;
  • Łatwość utrzymanianaszej aplikacji, czyli wszystkie kwestie związane z jej rozwojem, backupami, czy ewentualnym przeniesieniem na inną platformę.


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:

  • Jak będzie wyglądała rejestracja konta w serwisie? Jak będziemy przypisywać użytkowników do poszczególnych kont/organizacji?
  • Jak będzie wyglądała kwestia płatności?
  • Czy oprócz wersji SaaS oferujemy również wersję instalacyjną (on-premise)?
  • Czy istnieją części aplikacji korzystające z danych różnych organizacji/właścicieli?
  • Czy istnieje możliwość, że jeden użytkownik będzie częścią kilku organizacji?
  • Czy powinien istnieć globalny panel administratora (właściciela aplikacji) do zarządzania poszczególnymi kontami? Jaki funkcjonalności powinien posiadać?


Skoro już wiemy, jakie cechy powinno mieć nasze oprogramowanie, to rozważmy, jakie mamy możliwości pod kątem architektury najwyższego poziomu.

Single-tenant vs Multi-tenant

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.

Zalety architektury single-tenant

Bezpieczeństwo

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.

Większa kontrola nad aplikacją

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

Łatwiejsza w budowie, niższy koszt wykonania

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.

Zalety architektury multi-tenant

Łatwiejsza w utrzymaniu

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

Niższy koszt utrzymania

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

Przykładowa implementacja

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:

  • Jedna baza danych, do której mają dostęp wszystkie organizacje
  • Lista organizacji jest trzymana w tej samej bazie danych
  • Każda tabela z danymi organizacji posiada kolumnę TenantID
  • Chcemy mieć spójne i łatwo dostępne źródło danych o zalogowanej organizacji
  • NIe chcemy pamiętać i filtrować każdego zapytania
  • Nie chcemy pamiętać i ustawiać TenantID za każdym razem


Na całe rozwiązanie składa się kilka elementów

  • oznaczanie, które encje powinny być filtrowane względem organizacji - Tenant Aware,
  • główna encja organizacji - Tenant,
  • przechowywanie danych o aktualnie zalogowanej organizacji,czyli spójne, główne źródło wiedzy dla wszystkich elementów aplikacji o tym, kto aktualnie z niej korzysta - Tenant Context
  • źródło danych organizacji - Tenant Provider
  • mechanizm ustalania organizacji/właściciela danych,czyli określenia czyje dane będziemy pokazywali - Tenant Resolver
  • automatyczne filtrowanie wyników z bazy danych na podstawie właściciela - Tenant Filter
  • automatyczne dodawanie informacji o zalogowanej organizacji do dodawanych rekordów - Tenant Listener


Tenant

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;
}


Tenant Aware

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;
    }
}


Tenant Context

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.

Tenant Provider

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;
    }
}


Tenant Resolver

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 podstawie domeny/subdomeny
  • na podstawie zalogowanego użytkownika.


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:

  1. Sprawdzamy, jakiej organizacji chcemy dane (resolve)
  2. Ustawiamy Tenant Context
  3. Ustawiamy filtr Doctrine na konkretną organizację


Tenant Filter i Tenant Listener

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

Podsumowanie

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.

<p>Loading...</p>