Mehrinstanzenfähigkeit

Viele Branchenanwendungen sind für die Zusammenarbeit mit mehreren Kunden konzipiert. Es ist wichtig, die Daten zu sichern, damit Kundendaten nicht kompromittiert werden oder von anderen Kunden und möglichen Wettbewerbern eingesehen werden können. Diese Anwendungen werden als „mehrinstanzenfähig“ klassifiziert, da jeder Kunde als Mandant der Anwendung mit eigenen Datengruppen betrachtet wird.

Wichtig

Dieses Dokument enthält vorgefertigte Beispiele und Lösungen. Diese sollten nicht als Best Practices angesehen werden, sie sollen Ihnen eher als „Arbeitsweisen“ dienen.

Tipp

Sie können den Quellcode für dieses Beispiel auf GitHub anzeigen.

Unterstützen der Mehrinstanzenfähigkeit

Es gibt viele Ansätze zur Implementierung der Mehrinstanzenfähigkeit in Anwendungen. Ein allgemeiner Ansatz (der manchmal erforderlich ist) besteht darin, Daten für jeden Kunden in einer separaten Datenbank aufzubewahren. Das Schema ist identisch, aber die Daten sind kundenspezifisch. Ein weiterer Ansatz besteht darin, die Daten in einer vorhandenen Datenbank nach Kunden zu partitionieren. Dazu können Sie eine Spalte in einer Tabelle verwenden oder eine Tabelle in mehreren Schemas mit einem Schema für jeden Mandanten verwenden.

Vorgehensweise Spalte für Mandant? Schema pro Mandant? Mehrere Datenbanken? EF Core-Unterstützung
Diskriminator (Spalte) Ja Nein Nein Globaler Abfragefilter
Datenbank pro Mandant Nein Nein Ja Konfiguration
Schema pro Mandant Nein Ja Nein Nicht unterstützt

Für den Ansatz „Datenbank pro Mandant“ ist der Wechsel zur richtigen Datenbank so einfach wie das Bereitstellen der richtigen Verbindungszeichenfolge. Wenn die Daten in einer einzelnen Datenbank gespeichert werden, kann ein globaler Abfragefilter verwendet werden, um Zeilen automatisch nach der Spalte „Mandanten-ID“ zu filtern, um sicherzustellen, dass Entwickler*innen nicht versehentlich Code schreiben, der auf Daten von anderen Kunden zugreifen kann.

Diese Beispiele sollten in den meisten App-Modellen einwandfrei funktionieren, einschließlich Konsolen-, WPF-, WinForms- und ASP.NET Core-Apps. Blazor Server-Apps erfordern besondere Berücksichtigung.

Blazor Server-Apps und die Lebensdauer der Factory

Das empfohlene Muster für die Verwendung von Entity Framework Core in Blazor-Apps besteht darin, die DbContextFactory zu registrieren und dann zum Erstellen einer neuen Instanz der DbContext für einzelne Vorgänge aufzurufen. Standardmäßig ist die Factory ein Singleton, sodass nur eine Kopie für alle Benutzer*innen der Anwendung vorhanden ist. Das ist in der Regel in Ordnung, da die Factory zwar freigegeben ist, die einzelnen DbContext-Instanzen jedoch nicht.

Bei der Mehrinstanzenfähigkeit kann sich die Verbindungszeichenfolge jedoch pro Benutzer*in ändern. Da die Factory die Konfiguration mit derselben Lebensdauer zwischenspeichert, bedeutet dies, dass alle Benutzer*innen dieselbe Konfiguration gemeinsam verwenden müssen. Daher sollte die Lebensdauer in Scoped geändert werden.

Dieses Problem tritt in Blazor WebAssembly-Apps nicht auf, da das Singleton auf den*die Benutzer*in beschränkt ist. Blazor Server-Apps stellen dagegen eine einzigartige Herausforderung dar. Obwohl es sich bei der App um eine Web-App handelt, wird sie mithilfe der Echtzeitkommunikation mit SignalR aktiv beibehalten. Pro Benutzer*in wird eine Sitzung erstellt und geht über die anfängliche Anforderung hinaus. Pro Benutzer*in sollte eine neue Factory bereitgestellt werden, um neue Einstellungen zuzulassen. Die Lebensdauer dieser speziellen Factory ist bereichsbezogen, und pro Benutzersitzung wird eine neue Instanz erstellt.

Beispiellösung (einzelne Datenbank)

Eine mögliche Lösung besteht darin, einen einfachen ITenantService-Dienst zu erstellen, der das Festlegen des aktuellen Mandanten des Benutzers bzw. der Benutzerin behandelt. Er stellt Rückrufe bereit, sodass Code benachrichtigt wird, wenn sich der Mandant ändert. Die Implementierung (wobei die Rückrufe aus Gründen der Übersichtlichkeit weggelassen werden) können wie folgt aussehen:

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

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

Die DbContext-Klasse kann dann die Mehrinstanzenfähigkeit verwalten. Der Ansatz hängt von Ihrer Datenbankstrategie ab. Wenn Sie alle Mandanten in einer einzelnen Datenbank speichern, verwenden Sie wahrscheinlich einen Abfragefilter. Der ITenantService-Dienst wird über die Abhängigkeitsinjektion an den Konstruktor übergeben und zum Auflösen und Speichern des Mandantenbezeichners verwendet.

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

Die OnModelCreating-Methode wird überschrieben, um den Abfragefilter anzugeben:

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

Dadurch wird sichergestellt, dass jede Abfrage bei jeder Anforderung nach dem Mandanten gefiltert wird. Es ist nicht erforderlich, im Anwendungscode zu filtern, da der globale Filter automatisch angewendet wird.

Der Mandantenanbieter und DbContextFactory werden während des Anwendungsstarts wie folgt konfiguriert, indem Sqlite als Beispiel verwendet wird:

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

Beachten Sie, dass die Dienstlebensdauer mit ServiceLifetime.Scoped konfiguriert wird. Dies ermöglicht es, eine Abhängigkeit vom Mandantenanbieter zu übernehmen.

Hinweis

Abhängigkeiten müssen immer in Richtung des Singletons fließen. Das bedeutet, dass ein Scoped-Dienst von einem anderen Scoped-Dienst oder einem Singleton-Dienst abhängig sein kann, aber ein Singleton-Dienst kann nur von anderen Singleton-Diensten abhängen: Transient => Scoped => Singleton.

Mehrere Schemas

Warnung

Dieses Szenario wird von EF Core nicht direkt unterstützt und ist keine empfohlene Lösung.

In einem anderen Ansatz kann dieselbe Datenbank tenant1 und tenant2 mithilfe von Tabellenschemas verarbeiten.

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

Wenn Sie EF Core nicht zum Verarbeiten von Datenbankupdates mit Migrationen verwenden und bereits über Tabellen mit mehreren Schemas verfügen, können Sie das Schema wie folgt in einer DbContext-Klasse in OnModelCreating außer Kraft setzen (das Schema für die Tabelle CustomerData ist auf den Mandanten festgelegt):

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

Mehrere Datenbanken und Verbindungszeichenfolgen

Die Version mit mehreren Datenbanken wird implementiert, indem eine andere Verbindungszeichenfolge für jeden Mandanten übergeben wird. Dies kann beim Start konfiguriert werden, indem der Dienstanbieter aufgelöst und zum Erstellen der Verbindungszeichenfolge verwendet wird. Der appsettings.json-Konfigurationsdatei wird eine Verbindungszeichenfolge nach Mandantenabschnitt hinzugefügt.

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

Der Dienst und die Konfiguration werden beide in DbContext eingefügt:

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

Der Mandant wird dann verwendet, um die Verbindungszeichenfolge in OnConfiguring nachzuschlagen:

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

Dies funktioniert für die meisten Szenarios einwandfrei, es sei denn, der*die Benutzer*in kann Mandanten während derselben Sitzung wechseln.

Wechseln von Mandanten

In der vorherigen Konfiguration für mehrere Datenbanken werden die Optionen auf der Scoped-Ebene zwischengespeichert. Dies bedeutet, dass die Optionen, wenn der*die Benutzer*in den Mandanten ändert, nicht neu ausgewertet werden und so die Mandantenänderung in Abfragen nicht widergespiegelt wird.

Wenn sich der Mandant ändern kann, besteht die einfache Lösung darin, die Lebensdauer für Transient. festzulegen. Dadurch wird sichergestellt, dass der Mandant zusammen mit der Verbindungszeichenfolge bei jeder Anforderung von DbContext neu ausgewertet wird. Der*die Benutzer*in kann Mandanten so oft wie nötig wechseln. In der folgenden Tabelle können Sie auswählen, welche Lebensdauer für Ihre Factory am sinnvollsten ist.

Szenario Einzeldatenbank Mehrere Datenbanken
Der Benutzer verbleibt in einem einzigen Mandanten Scoped Scoped
Der Benutzer kann Mandanten wechseln Scoped Transient

Der Standardwert von Singleton ist weiterhin sinnvoll, wenn Ihre Datenbank keine benutzerdefinierten Abhängigkeiten verwendet.

Leistungshinweise

EF Core wurde so konzipiert, dass DbContext-Instanzen mit möglichst geringem Aufwand schnell instanziiert werden können. Aus diesem Grund ist das Erstellen einer neuen DbContext-Klasse pro Vorgang in der Regel in Ordnung. Wenn sich dieser Ansatz auf die Leistung Ihrer Anwendung auswirkt, sollten Sie die Verwendung von DbContext-Pooling in Betracht ziehen.

Zusammenfassung

Dies ist ein Leitfaden für die Implementierung der Mehrinstanzenfähigkeit in EF Core-Apps. Wenn Sie weitere Beispiele oder Szenarios haben oder Feedback geben möchten, eröffnen Sie ein Issue, und verweisen Sie auf dieses Dokument.