Multi-tenancy

Molte applicazioni line-of-business sono progettate per lavorare con più clienti. È importante proteggere i dati in modo che i dati dei clienti non vengano "trapelati" o visti da altri clienti e potenziali concorrenti. Queste applicazioni vengono classificate come "multi-tenant" perché ogni cliente viene considerato un tenant dell'applicazione con il proprio set di dati.

Avviso

Questo articolo usa un database locale che non richiede l'autenticazione dell'utente. Le app di produzione devono usare il flusso di autenticazione più sicuro disponibile. Per altre informazioni sull'autenticazione per le app di test e produzione distribuite, vedere Proteggere i flussi di autenticazione.

Importante

Questo documento fornisce esempi e soluzioni "così come sono". Questi non sono destinati a essere "procedure consigliate", ma piuttosto "procedure di lavoro" per la vostra considerazione.

Suggerimento

È possibile visualizzare il codice sorgente per questo esempio in GitHub

Supporto di multi-tenancy

Esistono molti approcci per implementare la multi-tenancy nelle applicazioni. Un approccio comune (che a volte è un requisito) consiste nel mantenere i dati per ogni cliente in un database separato. Lo schema è lo stesso, ma i dati sono specifici del cliente. Un altro approccio consiste nel partizionare i dati in un database esistente da parte del cliente. A tale scopo, è possibile usare una colonna in una tabella o avere una tabella in più schemi con uno schema per ogni tenant.

Approccio Colonna per tenant? Schema per Tenant? Più database? Supporto di EF Core
Discriminare (colonna) No No Filtro query globale
Database per tenant No No Impostazione
Schema per tenant No No Non supportato

Per l'approccio basato su database per tenant, il passaggio al database corretto è semplice quanto fornire il stringa di connessione corretto. Quando i dati vengono archiviati in un singolo database, è possibile usare un filtro di query globale per filtrare automaticamente le righe in base alla colonna ID tenant, assicurandosi che gli sviluppatori non scrivano accidentalmente codice in grado di accedere ai dati da altri clienti.

Questi esempi dovrebbero funzionare correttamente nella maggior parte dei modelli di app, tra cui console, WPF, WinForms e ASP.NET app Core. Le app Blazor Server richiedono considerazioni speciali.

App Blazor Server e vita della factory

Il modello consigliato per l'uso di Entity Framework Core nelle app Blazor consiste nel registrare DbContextFactory, quindi chiamarlo per creare una nuova istanza di DbContext ogni operazione. Per impostazione predefinita, la factory è un singleton , quindi esiste una sola copia per tutti gli utenti dell'applicazione. Questa operazione è in genere corretto perché, anche se la factory è condivisa, le singole DbContext istanze non sono.

Per il multi-tenancy, tuttavia, il stringa di connessione può cambiare per utente. Poiché la factory memorizza nella cache la configurazione con la stessa durata, significa che tutti gli utenti devono condividere la stessa configurazione. Pertanto, la durata deve essere modificata in Scoped.

Questo problema non si verifica nelle app Blazor WebAssembly perché l'ambito del singleton è l'utente. Le app Blazor Server, d'altra parte, presentano una sfida unica. Anche se l'app è un'app Web, viene "mantenuta attiva" dalla comunicazione in tempo reale tramite SignalR. Viene creata una sessione per utente e dura oltre la richiesta iniziale. Per consentire nuove impostazioni, è necessario specificare una nuova factory per ogni utente. La durata di questa factory speciale è con ambito e viene creata una nuova istanza per sessione utente.

Soluzione di esempio (database singolo)

Una possibile soluzione consiste nel creare un semplice ITenantService servizio che gestisce l'impostazione del tenant corrente dell'utente. Fornisce callback in modo che il codice venga informato quando il tenant cambia. L'implementazione (con i callback omessi per maggiore chiarezza) potrebbe essere simile alla seguente:

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

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

DbContext può quindi gestire il multi-tenancy. L'approccio dipende dalla strategia del database. Se si archiviano tutti i tenant in un singolo database, è probabile che si usi un filtro di query. L'oggetto ITenantService viene passato al costruttore tramite inserimento delle dipendenze e usato per risolvere e archiviare l'identificatore del tenant.

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

Il OnModelCreating metodo viene sottoposto a override per specificare il filtro di query:

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

In questo modo ogni query viene filtrata in base al tenant in ogni richiesta. Non è necessario filtrare il codice dell'applicazione perché il filtro globale verrà applicato automaticamente.

Il provider tenant e DbContextFactory sono configurati nell'avvio dell'applicazione come illustrato di seguito, usando Sqlite come esempio:

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

Si noti che la durata del servizio è configurata con ServiceLifetime.Scoped. Ciò consente di accettare una dipendenza dal provider di tenant.

Nota

Le dipendenze devono sempre fluire verso il singleton. Ciò significa che un Scoped servizio può dipendere da un altro Scoped servizio o da un Singleton servizio, ma un Singleton servizio può dipendere solo da altri Singleton servizi: Transient => Scoped => Singleton.

Più schemi

Avviso

Questo scenario non è supportato direttamente da EF Core e non è una soluzione consigliata.

In un approccio diverso, lo stesso database può gestire tenant1 e tenant2 usando schemi di tabella.

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

Se EF Core non usa EF Core per gestire gli aggiornamenti del database con le migrazioni e dispone già di tabelle con più schemi, è possibile eseguire l'override dello schema in un in come DbContext OnModelCreating in questo (lo schema per la tabella CustomerData è impostato sul tenant):

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

Più database e stringa di connessione

La versione di più database viene implementata passando un stringa di connessione diverso per ogni tenant. Questa operazione può essere configurata all'avvio risolvendo il provider di servizi e usandolo per compilare il stringa di connessione. Al file di configurazione viene aggiunta appsettings.json una sezione stringa di connessione per tenant.

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

Il servizio e la configurazione vengono entrambi inseriti in DbContext:

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

Il tenant viene quindi usato per cercare il stringa di connessione in OnConfiguring:

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

Questa operazione funziona correttamente per la maggior parte degli scenari, a meno che l'utente non possa cambiare tenant durante la stessa sessione.

Cambio di tenant

Nella configurazione precedente per più database, le opzioni vengono memorizzate nella cache a Scoped livello di . Ciò significa che se l'utente modifica il tenant, le opzioni non vengono rivalutate e quindi la modifica del tenant non viene riflessa nelle query.

La soluzione più semplice quando il tenant può cambiare consiste nell'impostare la durata su Transient. Questo garantisce che il tenant venga rivalutato insieme al stringa di connessione ogni volta che viene richiesto un oggetto DbContext . L'utente può cambiare tenant con la frequenza desiderata. La tabella seguente consente di scegliere la durata più appropriata per la factory.

Scenario Database singolo Multiple databases (Più database)
L'utente rimane in un singolo tenant Scoped Scoped
L'utente può cambiare tenant Scoped Transient

L'impostazione predefinita di Singleton ha comunque senso se il database non assume dipendenze con ambito utente.

Note relative alle prestazioni

EF Core è stato progettato in modo che DbContext le istanze possano essere create rapidamente con il minor sovraccarico possibile. Per questo motivo, la creazione di un nuovo DbContext per operazione dovrebbe in genere essere corretto. Se questo approccio influisce sulle prestazioni dell'applicazione, è consigliabile usare il pooling DbContext.

Conclusione

Questo è il materiale sussidiario funzionante per l'implementazione di multi-tenancy nelle app EF Core. Se si hanno altri esempi o scenari o si desidera fornire commenti e suggerimenti, aprire un problema e fare riferimento a questo documento.