Architecture multilocataire

De nombreuses applications métier sont conçues pour fonctionner avec plusieurs clients. Il est important de sécuriser les données afin que les données client ne soient pas « fuitées »ou vues par d’autres clients et concurrents potentiels. Ces applications sont classées comme « multi-tenant », car chaque client est considéré comme un tenant de l’application, avec son propre jeu de données.

Important

Ce document fournit des exemples et des solutions « tels que ». Il ne s’agit pas des « meilleures pratiques », mais plutôt de « pratiques de travail » à prendre en compte.

Conseil

Vous pouvez visualiser le code source de cet exemple sur GitHub

Prise en charge de la multilocation

Il existe de nombreuses approches pour implémenter la multilocation dans les applications. Une approche courante (qui est parfois une exigence) consiste à conserver les données de chaque client dans une base de données distincte. Le schéma est identique, mais les données sont spécifiques au client. Une autre approche consiste à partitionner les données dans une base de données existante par client. Pour ce faire, vous pouvez utiliser une colonne dans une table, ou avoir une table dans plusieurs schémas avec un schéma pour chaque tenant.

Approche Colonne pour le tenant ? Schéma par tenant ? Bases de données multiples ? Prise en charge EF Core
Discriminateur (colonne) Oui No Non Filtres de requête globale
Base de données par client Non Non Oui Configuration
Schéma par tenant Non Oui Non Non pris en charge

Pour l’approche de base de données par tenant, il suffit de fournir de fournir la chaîne de connexion correcte pour passer à la base de données appropriée. Lorsque les données sont stockées dans une base de données unique, un filtre de requête global peut être utilisé pour filtrer automatiquement les lignes par l’ID de colonne du tenant, ce qui garantit que les développeurs n’écrivent pas accidentellement du code pouvant accéder aux données d’autres clients.

Ces exemples doivent fonctionner correctement dans la plupart des modèles d’application, notamment la console, WPF, WinForms et les applications ASP.NET Core. Les applications Blazor Server nécessitent une attention particulière.

Applications Blazor Server et vie de la fabrique

Le modèle recommandé pour utiliser Entity Framework Core dans les applications Blazor consiste à inscrire la DbContextFactory, puis à l’appeler pour créer une instance de DbContext à chaque opération. Par défaut, la fabrique est un singleton, une seule copie existe donc pour tous les utilisateurs de l’application. Cela est généralement approprié, car bien que la fabrique soit partagée, les instances individuelles DbContext ne le sont pas.

Toutefois, pour l’architecture multi-tenancy, la chaîne de connexion peut changer par utilisateur. La fabrique mettant en cache la configuration avec la même durée de vie, cela signifie que tous les utilisateurs doivent partager la même configuration. Par conséquent, la durée de vie doit être modifiée en Scoped.

Ce problème ne se produit pas dans les applications Blazor WebAssembly, car le singleton est limité à l’utilisateur. Les applications Blazor Server, d’autre part, présentent une difficulté particulière. Bien que l’application soit une application web, elle est « conservée active » par la communication en temps réel avec SignalR. Une session est créée par utilisateur et dure au-delà de la requête initiale. Une nouvelle fabrique doit être fournie par utilisateur pour autoriser les nouveaux paramètres. La durée de vie de cette fabrique spéciale est limitée et une nouvelle instance est créée par session utilisateur.

Exemple de solution (base de données unique)

Une solution possible consiste à créer un service ITenantService simple, qui gère le paramétrage du tenant actuel de l’utilisateur. Il fournit des rappels, de sorte que le code est averti lorsque le tenant change. L’implémentation (avec les rappels omis pour plus de clarté) peut ressembler à ceci :

namespace Common
{
    public interface ITenantService
    {
        string Tenant { get; }

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

DbContext peut ensuite gérer l’architecture multi-tenancy. L’approche dépend de votre stratégie de base de données. Si vous stockez tous les tenants dans une base de données unique, vous allez probablement utiliser un filtre de requête. Le ITenantService est transmis au constructeur via l’injection de dépendances et est utilisé pour résoudre et stocker l’identificateur du tenant.

public ContactContext(
    DbContextOptions<ContactContext> opts,
    ITenantService service)
    : base(opts) => _tenant = service.Tenant;

La méthode OnModelCreating est remplacée pour spécifier le filtre de requête :

protected override void OnModelCreating(ModelBuilder modelBuilder)
    => modelBuilder.Entity<MultitenantContact>()
        .HasQueryFilter(mt => mt.Tenant == _tenant);

Cela garantit que chaque requête est filtrée sur le tenant sur chaque requête. Il n’est pas nécessaire de filtrer dans le code de l’application car le filtre global est automatiquement appliqué.

Le fournisseur de tenant et DbContextFactory sont configurés au démarrage de l’application comme suit, en utilisant Sqlite comme exemple :

builder.Services.AddDbContextFactory<ContactContext>(
    opt => opt.UseSqlite("Data Source=singledb.sqlite"), ServiceLifetime.Scoped);

Notez que la durée de vie du service est configurée avec ServiceLifetime.Scoped. Cela lui permet de prendre une dépendance vis-à-vis du fournisseur de tenant.

Remarque

Les dépendances doivent toujours circuler vers le singleton. Cela signifie qu’un service Scoped peut dépendre d’un autre service Scoped ou d’un service Singleton, mais qu’un service Singleton ne peut dépendre que d’autres services Singleton : Transient => Scoped => Singleton.

Schémas multiples

Avertissement

Ce scénario n’est pas directement pris en charge par EF Core et n’est pas une solution recommandée.

Dans une approche différente, la même base de données peut gérer tenant1 et tenant2 en utilisant des schémas de table.

  • Tenant1 - tenant1.CustomerData
  • Tenant2 - tenant2.CustomerData

Si vous n’utilisez pas EF Core pour gérer les mises à jour de base de données avec des migrations et que vous disposez déjà de tables multi-schémas, vous pouvez remplacer le schéma dans un DbContext dans OnModelCreating comme ceci (le schéma de la table CustomerData est défini sur le tenant) :

protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.Entity<CustomerData>().ToTable(nameof(CustomerData), tenant);

Plusieurs bases de données et chaînes de connexion

La version à plusieurs bases de données est implémentée en transmettant une chaîne de connexion différente pour chaque tenant. Cela peut être configuré au démarrage en résolvant le fournisseur de services et en l’utilisant pour générer la chaîne de connexion. Une chaîne de connexion par section de tenant est ajoutée au fichier de configuration appsettings.json.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "TenantA": "Data Source=tenantacontacts.sqlite",
    "TenantB": "Data Source=tenantbcontacts.sqlite"
  },
  "AllowedHosts": "*"
}

Le service et la configuration sont tous deux injectés dans le DbContext :

public ContactContext(
    DbContextOptions<ContactContext> opts,
    IConfiguration config,
    ITenantService service)
    : base(opts)
{
    _tenantService = service;
    _configuration = config;
}

Le tenant est ensuite utilisé pour rechercher la chaîne de connexion dans OnConfiguring :

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var tenant = _tenantService.Tenant;
    var connectionStr = _configuration.GetConnectionString(tenant);
    optionsBuilder.UseSqlite(connectionStr);
}

Cela fonctionne correctement pour la plupart des scénarios, sauf si l’utilisateur peut changer de tenant pendant une même session.

Changement de tenant

Dans la configuration précédente, pour plusieurs bases de données, les options sont mises en cache au niveau Scoped. Cela signifie que si l’utilisateur modifie le tenant, les options ne sont pas réévaluées et que la modification du tenant n’est donc pas reflétée dans les requêtes.

La solution simple pour cela lorsque le tenant peut changer est de définir la durée de vie sur Transient.. Ceci garantit que le tenant est réévalué avec la chaîne de connexion chaque fois qu’un DbContext est demandé. Les utilisateurs peuvent changer de tenant aussi souvent qu’ils le souhaitent. Le tableau suivant vous aide à choisir la durée de vie la plus pertinente pour votre fabrique.

Scénario Base de données unique Bases de données multiples
L’utilisateur reste dans un seul tenant Scoped Scoped
L’utilisateur peut changer de tenant Scoped Transient

La valeur par défaut de Singleton est toujours logique si votre base de données n’accepte pas les dépendances délimitées par l’utilisateur.

Remarques relatives aux performances

EF Core a été conçu pour que les instances DbContext puissent être instanciées rapidement avec le moins de surcharge possible. Pour cette raison, créer un nouveau DbContext par opération doit généralement fonctionner. Si cette approche a un impact sur les performances de votre application, envisagez d’utiliser le regroupement DbContext.

Conclusion

Il s’agit de conseils de travail pour l’implémentation d’une architecture multi-tenancy dans les applications EF Core. Si vous avez d’autres exemples ou scénarios, ou souhaitez fournir des commentaires, veuillez ouvrir un problème et faire référence à ce document.