マルチテナント
多くの基幹業務アプリケーションは、複数の顧客と連携するように設計されています。 顧客データが他の顧客や潜在的な競合他社によって "リーク" または閲覧されないように、データをセキュリティで保護することが重要です。 各顧客は独自のデータ セットを持つアプリケーションのテナントと見なされるため、これらのアプリケーションは "マルチテナント" として分類されます。
警告
この記事では、ユーザーの認証を必要としないローカル データベースを使用します。 運用アプリでは、使用可能な最も安全な認証フローを使用する必要があります。 デプロイされたテスト アプリと運用アプリの認証の詳細については、「セキュリティで保護された認証フロー」をご覧ください。
重要
このドキュメントでは、例と解決方法を "そのまま" 紹介しています。これらは、"ベスト プラクティス" ではなく、検討のための "作業プラクティス" となるものです。
ヒント
このサンプルのソースコードは 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)
OnModelCreating
メソッドは、クエリ フィルターを指定するためにオーバーライドされます。
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<MultitenantContact>()
これを行うと、すべてのクエリが、すべての要求でテナントに対してフィルター処理されます。 グローバル フィルターが自動的に適用されるため、アプリケーション コードではフィルター処理する必要はありません。
テナント プロバイダーと 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 では直接サポートされておらず、お勧めする解決方法ではありません。
別の方法では、同じデータベースで、テーブル スキーマを使って、tenant1
と tenant2
を処理することがあります。
- Tenant1 -
tenant1.CustomerData
- Tenant2 -
tenant2.CustomerData
移行を伴うデータベースの更新の処理に EF Core を使っておらず、既に複数のスキーマ テーブルがある場合は、次のように OnModelCreating
の DbContext
でスキーマをオーバーライドできます (テーブル 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 アプリでマルチテナントを実装するための作業ガイダンスです。 その他にも例やシナリオがある場合、またはフィードバックを提供したい場合は、問題を開くことに加え、このドキュメントを参照してください。
.NET