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.
Avertissement
Cet article utilise une base de données locale qui ne nécessite pas l’authentification des utilisateurs. Les applications de production doivent utiliser le flux d’authentification le plus sécurisé disponible. Pour plus d’informations sur l’authentification pour les applications de test et de production déployées, consultez Flux d’authentification sécurisés.
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.