Condividi tramite


Multitenenza

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 inquilino? Schema per inquilino? Più database? Supporto di EF Core
Discriminare (colonna) NO NO Filtro query globale
Database per inquilino NO NO Impostazione
Schema per istanza NO NO Non supportato

Per l'approccio basato su un database per singolo tenant, passare al database corretto è semplice quanto fornire la stringa di connessione corretta. 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 il ciclo di 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 corretta perché le singole istanze DbContext non sono condivise, anche se la fabbrica lo è.

Per il multi-tenancy, tuttavia, la 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 è limitato all'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 è limitata a un ambito e viene creata una nuova istanza per ogni 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 avvisato 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 sovrascritto 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 per il tenant ad 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 non stai utilizzando EF Core per gestire gli aggiornamenti del database con le migrazioni e disponi già di tabelle con più schemi, è possibile eseguire l'override dello schema in un DbContext in OnModelCreating come questo (lo schema per la tabella CustomerData è impostato sul locatario):

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'inizio risolvendo il provider di servizi e usandolo per costruire la stringa di connessione. Nel file di configurazione viene aggiunta una stringa di connessione per ogni sezione di tenant appsettings.json.

{
  "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 trovare la 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 locatario durante la stessa sessione.

Cambio di tenant

Nella configurazione precedente per più database, le opzioni vengono memorizzate nella cache a livello Scoped. Ciò significa che se l'utente modifica il tenant, le opzioni non vengono rivalutate e quindi la modifica del tenant non si riflette 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 quale vita utile ha più senso per la fabbrica.

Scenario Database singolo Multiple databases (Più database)
L'utente ha una permanenza 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.