マルチテナント

多くの基幹業務アプリケーションは、複数の顧客と連携するように設計されています。 顧客データが他の顧客や潜在的な競合他社によって "リーク" または閲覧されないように、データをセキュリティで保護することが重要です。 各顧客は独自のデータ セットを持つアプリケーションのテナントと見なされるため、これらのアプリケーションは "マルチテナント" として分類されます。

重要

このドキュメントでは、例と解決方法を "そのまま" 紹介しています。これらは、"ベスト プラクティス" ではなく、検討のための "作業プラクティス" となるものです。

ヒント

このサンプルのソースコードは GitHub で確認できます

マルチテナントのサポート

アプリケーション内でマルチテナントを実装するには、さまざまな方法があります。 一般的な方法の 1 つ (要件となる場合もあります) に、各顧客のデータを個別のデータベースに保持するという方法があります。 スキーマは同じですが、データは顧客固有です。 もう 1 つの方法は、既存のデータベース内で顧客ごとにデータをパーティション分割するという方法です。 これを行うには、テーブル内の列を使うか、テナントごとにスキーマを持つ複数のスキーマにテーブルを含めます。

アプローチ テナントの列は? テナントごとのスキーマは? 複数のデータベースは? EF Core サポート
識別子 (列) はい 番号 いいえ グローバル クエリ フィルター
テナントごとのデータベース いいえ いいえ はい 構成
テナントごとのスキーマ いいえ はい いいえ サポートされていません

テナントごとのデータベースの方法では、正しい接続文字列を指定するのと同じくらい簡単に、適切なデータベースへの切り替えを行うことができます。 データが単一データベースに格納されている場合は、グローバル クエリ フィルターを使ってテナント ID 列ごとに行を自動的にフィルター処理できるため、他の顧客がデータにアクセスできるコードを開発者が誤って記述しないようにすることができます。

これらの例は、コンソール、WPF、WinForms、ASP.NET Core アプリなどのほとんどのアプリ モデルで正常に機能するはずです。 Blazor サーバー アプリには、特別な考慮が必要です。

Blazor Server アプリとファクトリの有効期間

Blazor アプリで Entity Framework Core を使う場合にお勧めするのは、DbContextFactory を登録してから呼び出して、各操作の DbContext の新しいインスタンスを作成するというパターンです。 既定では、ファクトリは "シングルトン" であるため、アプリケーションのすべてのユーザーに対して、存在するコピーは 1 つのみです。 ファクトリが共有されても、個々の DbContext インスタンスは共有されないため、通常これは問題ありません。

ただし、マルチテナントの場合は、ユーザーごとに接続文字列が変わる可能性があります。 ファクトリでは同じ有効期間を持つ構成をキャッシュするため、すべてのユーザーが同じ構成を共有する必要があります。 そのため、有効期間を Scoped に変更する必要があります。

この問題は、Blazor WebAssembly アプリでは発生しません。シングルトンのスコープがユーザーに設定されているからです。 一方、Blazor Server アプリは、独自の課題を示しています。 このアプリは Web アプリですが、SignalR を使ったリアルタイム通信によって "キープ アライブ" されます。 セッションはユーザーごとに作成され、最初の要求を超えて続きます。 新しい設定を行うことができるように、新しいファクトリをユーザーごとに指定する必要があります。 この特殊なファクトリの有効期間はスコープ指定され、ユーザー セッションごとに新しいインスタンスが作成されます。

解決方法の例 (単一データベース)

解決方法としては、ユーザーの現在のテナントの設定を処理する単純な ITenantService サービスを作成する方法が考えられます。 コールバックが指定されるため、テナントが変更されるとコードが通知されます。 実装 (わかりやすくするためにコールバックは省略) は、次のようになります。

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

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

その後、DbContext を使って、マルチテナントを管理できます。 この方法は、データベース戦略によって異なります。 すべてのテナントを単一データベースに格納する場合は、クエリ フィルターを使うことになる可能性があります。 ITenantService は、依存関係の挿入を通じてコンストラクターに渡され、テナント識別子の解決と格納に使用されます。

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

OnModelCreating メソッドは、クエリ フィルターを指定するためにオーバーライドされます。

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

これを行うと、すべてのクエリが、すべての要求でテナントに対してフィルター処理されます。 グローバル フィルターが自動的に適用されるため、アプリケーション コードではフィルター処理する必要はありません。

テナント プロバイダーと DbContextFactory は、アプリケーションの起動時に次のように構成されます。例として Sqlite を使います。

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

サービスの有効期間ServiceLifetime.Scopedで構成されていることに注意してください。 これを行うと、テナント プロバイダーへの依存関係を受け取ることができます。

Note

依存関係のフローは、常にシングルトンに向かっている必要があります。 つまり、Scoped サービスは別の Scoped サービスまたは Singleton サービスに依存できますが、Singleton サービスは他の Singleton サービス (Transient => Scoped => Singleton) にのみ依存できます。

複数のスキーマ

警告

このシナリオは、EF Core では直接サポートされておらず、お勧めする解決方法ではありません。

別の方法では、同じデータベースで、テーブル スキーマを使って、tenant1tenant2 を処理することがあります。

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

移行を伴うデータベースの更新の処理に EF Core を使っておらず、既に複数のスキーマ テーブルがある場合は、次のように OnModelCreatingDbContext でスキーマをオーバーライドできます (テーブル CustomerData のスキーマはテナントに設定されます)。

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

複数のデータベースと接続文字列

複数のデータベース バージョンは、テナントごとに異なる接続文字列を渡して実装されます。 これは、サービス プロバイダーを解決し、これを使って接続文字列を構築すると、起動時に構成できます。 接続文字列がテナント セクションごとに appsettings.json 構成ファイルに追加されます。

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

サービスと構成の両方が DbContext に挿入されます。

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

次に、テナントを使って、OnConfiguring で接続文字列を検索します。

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

ユーザーが同じセッション中にテナントを切り替えなければ、これはほとんどのシナリオでうまくいきます。

テナントの切り替え

複数のデータベースの以前の構成では、オプションは Scoped レベルでキャッシュされます。 つまり、ユーザーがテナントを変更した場合、オプションは再評価 "されない" ため、テナントの変更はクエリに反映されません。

これを簡単に解決するには、テナントが変わる "可能性がある" 場合に有効期間を Transient. に設定するという方法があります。これを行うと、DbContext が要求されるたびに、接続文字列に合わせてテナントが再評価されるようになります。 ユーザーは、希望する頻度でテナントを切り替えることができます。 次の表を使うと、ファクトリにとって最も適切な有効期間を選ぶことができます。

シナリオ 1 つのデータベース 複数のデータベース
"ユーザーが単一テナントにとどまっている" Scoped Scoped
"ユーザーがテナントを切り替えることができる" Scoped Transient

Singleton の既定値は、データベースがユーザー スコープの依存関係を受け取らない場合であっても適切なままです。

パフォーマンス上の注意点

EF Core は、できるだけ少ないオーバーヘッドで DbContext インスタンスを迅速にインスタンス化できるように設計されています。 そのため、操作ごとに新しい DbContext を作成しても通常は問題ありません。 この方法がアプリケーションのパフォーマンスに影響する場合は、DbContext プールの使用を検討してください。

まとめ

これは、EF Core アプリでマルチテナントを実装するための作業ガイダンスです。 その他にも例やシナリオがある場合、またはフィードバックを提供したい場合は、問題を開くことに加え、このドキュメントを参照してください。