在 ASP.NET Core 中執行租戶服務

不定時更新翻譯系列,此係列更新毫無時間規律,文筆菜翻譯菜求各位看官老爺們輕噴,如覺得我翻譯有問題請挪步原博客地址

本博文翻譯自:
http://gunnarpeipman.com/2017/08/tenant-providers/

在我之前關於 Entity Framework core 2.0 全局查詢過濾器的文章中,我提出了一個想法,當構建模型時,如何自動地將查詢過濾器應用到所有的領域實體中,也就是說領域實體總是來自同一租戶。這篇文章更深入地介紹了在 ASP.NET Core 應用程序中檢測當前租戶的可能解決方案,並建議一些租戶提供者將爲實際應用程序中提供多租戶的支持作爲出發點。

注意! 請閱讀我之前在Entity Framework core 2.0 全局查詢過濾器中的文章,這篇文章將繼續下去,並期待讀者熟悉我爲多租戶提供的解決方案。另外,將多租戶規則應用到所有領域實體的方法是從我以前的全局查詢過濾器中獲取的,而不是在這裏複製的。

如何檢測當前租戶?

情況是這樣的。數據上下文是在請求傳入和構建模型全局查詢過濾器時構建的。其中一個過濾器是關於當前租戶的。在代碼中還需要租戶ID,但模型還沒有準備好。同一時間,租戶ID只能在數據庫中使用。我們該怎麼辦?

一些想法:

  • 在數據上下文中使用數據庫連接,並對租戶表進行直接查詢
  • 爲租戶的信息和操作使用單獨的數據上下文
  • 保持租戶信息在雲存儲上可用
  • 使用域名的哈希值作爲租戶ID

注意! 在本文中,我希望在web應用程序中通過host的header檢測租戶。

我在這篇文章中使用的租戶表如下圖所示。

ef-core-tenants-table

注意! 依賴於解決方案的租戶ID也可以是其他的,而不是像上圖所示的int類型。

使用數據上下文連接數據庫

這可能是最輕量級的解決方案了,因爲不需要添加額外的類,也不再需要租戶提供程序。而且使用IHttpContextAccessor很容易獲得當前host的header。


public class PlaylistContext : DbContext
{
    private int _tenantId;
    private string _tenantHost;
 
    public DbSet<Playlist> Playlists { get; set; }
    public DbSet<Song> Songs { get; set; }
 
    public PlaylistContext(DbContextOptions<PlaylistContext> options,
                           IHttpContextAccessor accessor)
        : base(options)
    {
        _tenantHost = accessor.HttpContext.Request.Host.Value;
    }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var connection = Database.GetDbConnection();
        using (var command = connection.CreateCommand())
        {
            connection.Open();
 
            command.CommandText = "select ID from Tenants where Host=@Host";
            command.CommandType = CommandType.Text;
 
            var param = command.CreateParameter();
            param.ParameterName = "@Host";
            param.Value = _tenantHost;
 
            command.Parameters.Add(param);
            _tenantId = (int)command.ExecuteScalar();
            connection.Close();
        }
 
        foreach (var type in GetEntityTypes())
        {
            var method = SetGlobalQueryMethod.MakeGenericMethod(type);
            method.Invoke(this, new object[] { modelBuilder });
        }
 
        base.OnModelCreating(modelBuilder);
    }
 
    // Other methods follow
}

上面的代碼是基於數據上下文所持有的數據庫連接創建命令,並運行sql命令,以通過host的header來獲取租戶ID。

這個解決方案的代碼量是比較少的,但是它會用主機名檢測內部細節的方法來污染數據上下文。

爲租戶使用單獨的數據上下文

第二種方法是使用單獨的web應用程序訪問特定的租戶上下文。可以編寫租戶提供程序(請參閱我的Entity Framework core 2.0 全局查詢過濾器),並將其注入到主數據上下文

讓我們從文章開頭提到的租戶表開始。


public class Tenant
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Host { get; set; }
}

現在,讓我們構建租戶數據上下文。這個上下文不依賴於其他有依賴關係的自定義接口和類。它只使用租戶模型。請注意,租戶集是私有的,其他類只能通過host的header查詢租戶ID。


public class TenantsContext : DbContext
{
    private DbSet<Tenant> Tenants { get; set; }
 
    public TenantsContext(DbContextOptions<TenantsContext> options)
        : base(options)
    {
    }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Tenant>().HasKey(e => e.Id);
    }
 
    public int GetTenantId(string host)
    {
        var tenant = Tenants.FirstOrDefault(t => t.Host == host);
        if(tenant == null)
        {
            return 0;
        }
 
        return tenant.Id;
    }
}

現在是時候回到ITenantProvider並編寫使用租戶數據上下文的實現了。這個提供程序包含檢測host的header和獲取租戶ID的所有邏輯,在實際應用中它將更加複雜,但是在這裏我將使用簡單的版本。


public class WebTenantProvider : ITenantProvider
{
    private int _tenantId;
 
    public WebTenantProvider(IHttpContextAccessor accessor,
                                TenantsContext context)
    {
        var host = accessor.HttpContext.Request.Host.Value;
 
        _tenantId = context.GetTenantId(host);
    }
 
    public int GetTenantId()
    {
        return _tenantId;
    }
}

現在,需要檢查租戶並找到它的ID,因爲已經到了重新編寫主數據上下文的時候了,所以它使用新的租戶提供程序。


public class PlaylistContext : DbContext
{
    private int _tenantId;
 
    public DbSet<Playlist> Playlists { get; set; }
    public DbSet<Song> Songs { get; set; }
 
    public PlaylistContext(DbContextOptions<PlaylistContext> options,
                           ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantId = tenantProvider.GetTenantId();
    }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var type in GetEntityTypes())
        {
            var method = SetGlobalQueryMethod.MakeGenericMethod(type);
            method.Invoke(this, new object[] { modelBuilder });
        }
 
        base.OnModelCreating(modelBuilder);
    }
 
    // Other methods follow
}

在web應用程序的啓動類中,必須在ConfigureServices()方法中 爲框架級定義的所有依賴項進行依賴注入。


public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
 
    var connection = Configuration["ConnectionString"];
    services.AddEntityFrameworkSqlServer();
    services.AddDbContext<PlaylistContext>(options => options.UseSqlServer(connection));
    services.AddDbContext<TenantsContext>(options => options.UseSqlServer(connection));
    services.AddScoped<ITenantProvider, WebTenantProvider>();
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
}

這個解決方案更優雅,因爲它將與租戶相關的功能從主數據上下文中移出。ITenantProvider是主數據上下文唯一必須知道的東西,現在它也可以在其他不一定是web應用程序的項目中使用。

將租戶信息存儲在雲存儲中

我現在說的是,租戶並不是一直都在使用,而不是租戶提供程序查詢數據庫,在需要的時候可以緩存租戶信息,並在需要時更新它。考慮到雲的場景,最好讓租戶信息在web應用程序的多個實例中都可以訪問。我的選擇是雲存儲。

讓我們從json格式的簡單的租戶文件開始,讓我們期望它是一些內部應用程序或後臺任務的職責,以使這個文件保持最新。這是我使用的樣本文件。


[
  {
    "Id": 2,
    "Name": "Local host",
    "Host": "localhost:30172"
  },
  {
    "Id": 3,
    "Name": "Customer X",
    "Host": "localhost:3331"
  },
  {
    "Id": 4,
    "Name": "Customer Y",
    "Host": "localhost:33111"
  }
]

要讀取雲存儲應用程序中的文件,需要了解存儲帳戶連接字符串、容器名稱和雲名稱。Blob是租戶文件。我再次使用ITenantProvider接口,併爲Azure 雲存儲創建了一個新的實現。我把它叫做BlobStorageTenantProvider。它很簡單,不需要考慮很多實際的方面,比如刷新租戶信息和處理鎖。


public class BlobStorageTenantProvider : ITenantProvider
{
    private static IList<Tenant> _tenants;
 
    private int _tenantId = 0;
 
    public BlobStorageTenantProvider(IHttpContextAccessor accessor, IConfiguration conf)
    {
        if(_tenants == null)
        {
            LoadTenants(conf["StorageConnectionString"], conf["TenantsContainerName"], conf["TenantsBlobName"]);
        }
 
        var host = accessor.HttpContext.Request.Host.Value;
        var tenant = _tenants.FirstOrDefault(t => t.Host.ToLower() == host.ToLower());
        if(tenant != null)
        {
            _tenantId = tenant.Id;
        }
    }
 
    private void LoadTenants(string connStr, string containerName, string blobName)
    {
        var storageAccount = CloudStorageAccount.Parse(connStr);
        var blobClient = storageAccount.CreateCloudBlobClient();
        var container = blobClient.GetContainerReference(containerName);
        var blob = container.GetBlobReference(blobName);
 
        blob.FetchAttributesAsync().GetAwaiter().GetResult();
 
        var fileBytes = new byte[blob.Properties.Length];
 
        using (var stream = blob.OpenReadAsync().GetAwaiter().GetResult())
        using (var textReader = new StreamReader(stream))
        using (var reader = new JsonTextReader(textReader))
        {
            _tenants = JsonSerializer.Create().Deserialize<List<Tenant>>(reader);
        }
    }
 
    public int GetTenantId()
    {
        return _tenantId;
    }
}

提供者的代碼可能不是很好,但是它比以前的代碼好,因爲不需要額外的數據庫調用,而且租戶id是由內存服務的。

用host的header的哈希值作爲租戶ID

第三種方法是最簡單的方法,但這意味着租戶ID與host的 header相同,或者從它派生而來。我不喜歡這種做法,因爲如果客戶想要更改host的 header,那麼更改將分佈在整個數據庫中。客戶可能希望從服務自動提供的自定義主機名開始,然後使用他們自己的子域名。

這裏是作爲主機名的租戶ID的代碼。


public class PlaylistContext : DbContext
{
    private string _tenantId;
 
    public DbSet<Playlist> Playlists { get; set; }
    public DbSet<Song> Songs { get; set; }
 
    public PlaylistContext(DbContextOptions<PlaylistContext> options,
                            IHttpContextAccessor accessor)
        : base(options)
    {
        _tenantId = accessor.HttpContext.Request.Host.Value;
    }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var type in GetEntityTypes())
        {
            var method = SetGlobalQueryMethod.MakeGenericMethod(type);
            method.Invoke(this, new object[] { modelBuilder });
        }
 
        base.OnModelCreating(modelBuilder);
    }
 
    // Other methods follow
}

可以使用MD5代替主機的名稱,但它不會改變主機的問題。

總結

這篇文章是關於在Entity Framework Core 2.0中真正的去利用全局查詢過濾器。雖然這裏所展示的代碼是簡單的而不我們實際運用場景所需要的,但在構建真正的解決方案之前,它們仍然是很好的例子。我儘量讓解決方案儘可能的接近完美的架構原則。我認爲讀者他們自己的多租戶應用程序可以在這裏提供的解決方案中獲得幫助。

歡迎轉載,轉載請註明翻譯原文出處(本文章),原文出處(原博客地址),然後謝謝觀看

如果覺得我的翻譯對您有幫助,請點擊推薦支持:)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章