Multi Tenant – jedna baza danych per tenant

Wprowadzenie

W poprzednim wpisie z mini serii o tworzeniu aplikacji multi tenant pokazałem Ci, w jaki sposób można określić, z jaką organizacją aktualnie pracujemy. Kolejnym problemem, jakim się zajmiemy, jest sposób przechowywania danych. Dwa najczęściej używane podejście to dedykowana baza danych dla każdej organizacji oraz trzymanie wszystkiego w jednej bazie. W tym wpisie zajmę się pierwszym podejściem, natomiast w innym omówię drugie podejście.

Plusy dedykowanych baz danych

Na początku porównajmy oba podejścia. Każde z nich ma swoje wady i zalety. A do tego w zależności od sytuacji, w której aktualnie jesteś, decyzja o wyborze jednego lub drugiego podejścia może nie być łatwa.

W podejściu z dedykowaną bazą danych dla jednego klienta sytuacja jest dość prosta i łatwa do zaimplementowania w kodzie (o czym dokładnie niżej). Sprowadza się ona na ogół do tego, że przygotowujemy jakiś fragment kodu (np. ConnectionStringFactory), który będzie odpowiedzialny za tworzenia connection stringa do bazy danych na podstawie aktualnego tenanta. Następnie pozostały kod aplikacji będzie na ogół taki sam jak w zwykłej aplikacji single tenant.

Łatwość implementacji tego podejścia jest jednym z jego plusów. Szczególnie w sytuacji, gdy masz za zadanie zmianę aplikacji single tenant na multi tenant. Taka konwersja przy podejściu z dedykowanymi bazami danych nie musi być specjalnie czasochłonna i mocno problematyczna. Dlatego w takiej sytuacji na ogół decyduję się na podejście z dedykowanymi bazami danych.

Kolejnym plusem podejścia z wieloma bazami danych jest dużo większa izolacja danych między poszczególnymi organizacjami. Z racji tego, że dane są w dedykowanych bazach danych, szansa, że się wymieszają jest dużo mniejsza. Dlatego, gdy wrażliwość danych jest bardzo istotna, osobiście również wybieram to podejście. Oczywiście szansa wymieszania danych nadal występuje (możemy mieć błąd w określaniu connection stringa). Ale ilość miejsc w kodzie, który jest na to narażony, jest dużo mniejsza. Łatwiej to pokryć automatycznymi testami, które będą to ciągle weryfikowały.

Kolejnym ciekawym plusem podejścia z wieloma bazami jest możliwość oferowania tak zwanej bazy „premium”. Szczególnie w środowisku chmurowym możemy niezależnie zarządzać pulą zasobów dla poszczególnych baz danych. Dzięki temu możemy ręcznie podbijać wersję bazy danych dla kluczowych klientów i oferować im lepszą wydajność za oczywiście trochę większą opłatę :). Realizacje tego w podejściu z jedną bazą jest dużo trudniejsze.

Również strategia tworzenia backupów danych jest dużo prostsza, bo możemy je tworzyć niezależnie dla każdej bazy i również odtwarzać je niezależnie od siebie.

Szkolenie modularny monolit w .NET 5

Szkolenie modularny monolit w .NET 5

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie o modularnym monolicie w .NET.

Minusy dedykowanych baz danych

Oczywiście podejście z dedykowanymi bazami ma też swoje minusy. Pierwszym są koszty utrzymania baz danych. W szczególności w środowiskach chmurowych płacimy za poszczególne bazy danych. Oczywiście, tak jak w Azure, mamy możliwość skorzystania z pul baz danych, ale dalej takie rozwiązanie jest droższe niż jedna duża baza danych, która zawiera wiele organizacji. Rozwiązaniem może być rozliczenie za rdzeń wirtualny, ale to bardziej sprawdza się w sytuacji, gdy mamy już trochę tych baz danych.

W przypadku rozliczania się z pojedynczych baz danych również i tutaj nie mamy korzyści wynikających z współdzielenia zasobów, bo w tej sytuacji każda baza ma swoje własne zasoby.

Kolejnym minusem jest konieczność posiadania fragmentu infrastruktury, która będzie tworzyła bazy danych w momencie tworzenia nowego tenanta. Do tego konieczne będzie również usuwanie nieużywanych tenantów, szczególnie gdy będziemy płacili za bazy danych niezależnie.

Dodatkowym minusem będą różnego rodzaju analizy, które chcielibyśmy wykonać w naszej aplikacji. Z racji tego, że mamy wiele baz danych, to wykonanie zbiorczego raportu będzie trudniejsze, z uwagi na konieczność wykonania zapytania na każdej bazie danych, a później połączenia wyników. W przypadku jednej bazy wystarczy jedno zapytanie.

W podejściu z dedykowanymi bazami danych mamy sytuację podobną jak w przypadku aplikacji single tenant, tylko oczywiście mowa o samym przechowywaniu danych. Więc wady i zalety będę podobne – o czym pisałem w pierwszym wpisie z serii tworzenia aplikacji multi tenant.

Minusami i plusami podejścia z współdzieloną bazą danych zajmiemy się w kolejnym wpisie z serii. Teraz czas, aby zobaczyć, jak zaimplementować podejście z wieloma bazami.

Katalog organizacji

W aplikacji multi tenant występuje katalog tenantów/organizacji. Na ogół jest to dedykowana baza danych, w której znajdują się informacje o wszystkich organizacjach oraz informacje potrzebne do ich działania – na przykład informacja o bazie danych danego tenanta.

W pierwszym przykładzie, który przygotowałem do tej mini serii, katalog tenantów jest bardzo prosty. Jest to jedna tabela, która zawiera dwie kolumny: Id tenanta oraz jego identyfikator. Id jest guidem, natomiast identyfikator to wartość użyta do rozróżnienia tenantów. W tym przykładzie wykorzystałem identyfikowanie tenantów na podstawie adresu url, gdzie pierwszy człon w adresie to właśnie identyfikator organizacji.

Pojedynczy tenant jest reprezentowany przez klasę Tenant:

public class Tenant
{
public Guid Id { get; set; }
public string Identifier { get; set; }
}
view raw Tenant.cs hosted with ❤ by GitHub

Oczywiście w realnej aplikacji będziesz miał tutaj dużo więcej przydatnych informacji.

Sam dostęp do tenantów jest robiony przez dedykowany data context (w przykładzie użyłem Entity Framework), który jest niezależny od samego data contextu używanego w pozostałej części aplikacji:

Jest to tutaj o tyle istotne, że TenantDataContext będzie korzystał z connection stringu na sztywno ustawionego w pliku konfiguracyjnym. Natomiast ApplicationDbContext będzie miał w locie tworzony connection string na podstawie aktualnego tenanta.

Pobranie aktualnego tenanta

W aplikacji mam stworzone kilka typów do pobrania aktualnego tenanta (za chwilkę będzie nam to potrzebne).

TenantStore służy do pobrania tenanta z bazy danych na podstawie identyfikatora:

public interface ITenantStore
{
Task<Tenant> GetTenantAsync(string identifier);
}
public class DatabaseTenantStore : ITenantStore
{
private readonly TenantDataContext _db;
public DatabaseTenantStore(TenantDataContext db)
{
_db = db;
}
public async Task<Tenant> GetTenantAsync(string identifier)
{
if (string.IsNullOrEmpty(identifier))
{
return null;
}
return await _db.Tenants.FirstOrDefaultAsync(t => t.Identifier.ToLower() == identifier.ToLower());
}
}

Natomiast TenantAccessService zwraca instancje aktualnego tenanta. Używa TenantStore oraz TenantResolutionStrategy (typ opisywany w poprzednim wpisie, gdzie poruszałem strategie określania tenanta):

public interface ITenantAccessService
{
Task<Tenant> GetTenantAsync();
}
public class TenantAccessService : ITenantAccessService
{
private readonly ITenantResolutionStrategy _tenantResolutionStrategy;
private readonly ITenantStore _tenantStore;
private Tenant _currentTenant;
public TenantAccessService(ITenantResolutionStrategy tenantResolutionStrategy, ITenantStore tenantStore)
{
_tenantResolutionStrategy = tenantResolutionStrategy;
_tenantStore = tenantStore;
}
public async Task<Tenant> GetTenantAsync()
{
if (_currentTenant != null)
{
return _currentTenant;
}
var tenantIdentifier = await _tenantResolutionStrategy.GetTenantIdentifierAsync();
_currentTenant = await _tenantStore.GetTenantAsync(tenantIdentifier);
return _currentTenant;
}
}

Generowanie connection stringa

Mając już klasy, które umożliwią nam pobranie aktualnego tenanta, przyszedł czas na wygenerowanie connection stringa. Można to robić na różne sposoby, wszystko zależy od tego, gdzie znajdują się bazy danych. Gdy wszystko jest na jednym serwerze, wtedy jest łatwiej. Wystarczy na przykład tylko podmienić nazwy baz danych. Nic nie stoi na przeszkodzie, aby bazy danych znajdowały się na różnych serwerach, w różnych lokalizacjach na świecie.

Connection stringi można generować na podstawie danych w katalogu. Czasami można spotkać się z tym, że w katalogu jest informacje o serwerze, nazwie bazy danych oraz zaszyfrowanym loginie i haśle. Można również przechowywać całe gotowe connection stringi w jakimś bezpiecznym miejscu (np. Azure Key Vault) i pobierać je na podstawie id tenanta, na przykład poprzez użycie sekretu spod klucza [id tenant]_connectionstring.

W przykładowej aplikacji bazy danych znajduje się SQL Server działający w kontenerze Docker. Dlatego w mojej implementacji będę tylko zmieniał nazwy baz danych w connection stringu zapisanym w appsettings.json i który wskazuje na bazę danych z katalogiem tenantów. Nazwa bazy danych będzie taka sama jak identyfikator tenanta (czyli pierwszy człon w adresie url).

Do wygenerowania connection stringa wykorzystuję ConnectionStringBuilder:

public interface IConnectionStringBuilder
{
Task<string> BuildAsync();
}
public class ConnectionStringBuilder : IConnectionStringBuilder
{
private readonly ITenantAccessService _tenantAccessService;
private readonly IConfiguration _configuration;
public ConnectionStringBuilder(ITenantAccessService tenantAccessService,
IConfiguration configuration)
{
_tenantAccessService = tenantAccessService;
_configuration = configuration;
}
public async Task<string> BuildAsync()
{
var defaultConnection = _configuration.GetConnectionString("DefaultConnection");
var tenant = await _tenantAccessService.GetTenantAsync();
if (tenant == null)
{
return defaultConnection;
}
var builder = new SqlConnectionStringBuilder(defaultConnection);
builder.InitialCatalog = tenant.Identifier;
return builder.ConnectionString;
}
}

Metoda BuildAsync jest odpowiedzialna za zbudowanie connection stringa. Za pomocą ITenantAccessService pobieram aktualnego tenanta. W connection stringu z konfiguracji podmieniam nazwę bazy na identyfikator tenanta.

Rejestracja data context

Na końcu pozostaje nam jeszcze zarejestrować klasy data context w kontenerze i użyć ConnectionStringBuilder. Wygląda to tak (jest to fragment klasy Startup i metody ConfigureServices):

public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<TenantDataContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddDbContext<ApplicationDbContext>((s, options) =>
options.UseSqlServer(s.GetRequiredService<IConnectionStringBuilder>().BuildAsync().Result));
//inne rejestracje
}
}
view raw Startup.cs hosted with ❤ by GitHub

Rejestracja TenantDataContext, czyli kontekstu do katalogu tenantów jest standardowa, czyli używamy SQL Servera i przekazujemy connection string z konfiguracji. Natomiast rejestracja ApplicationDbContext wykorzystuje właśnie ConnectionStringBuilder, który jest odpowiedzialny za utworzenia connection stringa. Tutaj niestety nie możemy w sposób asynchroniczny wywołać metody BuildAsync, dlatego ręcznie zwracamy wynik taska z użyciem właściwości Result.

W ten oto sposób możemy w aplikacji multi tenant zaimplementować podejście z dedykowanymi bazami danych dla każdej organizacji. Zwróć uwagę, że praktycznie nie robiliśmy żadnych zmian w samej aplikacji, a jedynie dodaliśmy kilka typów dla obsługi aktualnego tenanta oraz zmieniliśmy trochę rejestrację głównego data context aplikacji. Dlatego to podejście jest dość fajne w sytuacji, gdy chcemy zmigrować istniejącą aplikację single tenant do rozwiązania multi tenant. Szczególnie, że na ogół mamy pewną ilość istniejących instancji aplikacji, posiadających swoje dedykowane bazy danych.

Przykład

W repozytorium https://github.com/danielplawgo/MultiTenant znajduje się przykład do tego wpisu. Demo1 właśnie jest przykładową aplikacją, która wykorzystuje podejście z dedykowanymi bazami danych dla każdej organizacji. A powyższe fragmenty kodu pochodzą właśnie z tego przykładu. W opisie repozytorium jest informacja, w jaki sposób uruchomić ten przykład.

Podczas ostatniej migracji systemu single tenant do multi tenant wykorzystałem część rozwiązań z wpisów https://michael-mckenna.com/multi-tenant-asp-dot-net-core-application-tenant-resolution, więc pojawiać się będą one również i w moim przykładach. Zachęcam do zapoznania się również i z tymi wpisami.

Podsumowanie

W tym wpisie pokazałem Ci, w jaki sposób można podejść do przechowywania danych w aplikacji multi tenant. Skupiliśmy się na podejściu, w którym każdy tenant ma swoją dedykowaną bazę danych. Omówiliśmy plusy i minusy takiego podejścia. A na końcu pokazałem Ci, jak można zaimplementować to rozwiązanie w przykładowej aplikacji.

W kolejnym wpisie z tej mini serii pokaże Ci drugie podejście, w którym dane wszystkich organizacji znajdują się w tej samej bazie danych.

Szkolenie modularny monolit w .NET 5

Szkolenie modularny monolit w .NET 5

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie o modularnym monolicie w .NET.

6 thoughts on “Multi Tenant – jedna baza danych per tenant

  • Pingback: dotnetomaniak.pl
    • Hej Tesla!

      Dzięki za przypomnienie mi tego wpisu. Faktycznie podczas ostatniej migracji aplikacji single tenant do multi tenant w kwietniu ubiegłego roku wykorzystałem cześć rzeczy z tego wpisu. Szczególnie rozpicie tych różnych typów do obsługi tenantów. Wcześniej robiłem jeden duży TenantService, który robił wszystko w środku. A w tamtym projekcie rozbicie tego na różne klasy według tego wpisu ułątwiło migrację.

      A później na podstawie tego projektu przeniosłem to do moich przykładów. Dodałem informacje w treści wspisów, że część pokazywanych rozwiązań pochodzi z tego wpisu. Dzięki!

  • Świetny artykuł! Jesteśmy w sytuacji, w której chcemy przejść na układ 1 aplikacja, wiele baz danych, wielu klientów na subdomenach. Aplikacja jest napisana w .net framework + sql srv. Jak duża to jest przeróbka? Czy to podejście wymaga wielu dniówek programisty i przerobienia całości aplikacji, czy też wystarczy dodać prosty „routing tenatów” gdzieś na niskiej warstwie?

    • Akurat podejście z wieloma bazami danych jest łatwiejsze w implementacji. Bo w praktyce na ogół wystarczy tylko zaimplementować podmianę connection stringa nad podstawie aktualnego tenanta, gdzieś w niskiej warstwie aplikacji. Więc nie powinno być to czasochłonne, w szczególności w porównaniu na przykład do podejścia z jedną bazą danych.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.