ASP.NET Core + SaasKit + PostgreSQL + Citus 的多租戶應用程式架構示例

為少 發表於 2022-05-13

image

確定分佈策略 中,
我們討論了在多租戶用例中使用 Citus 所需的與框架無關的資料庫更改。
當前部分研究如何構建與 Citus 儲存後端一起使用的多租戶 ASP.NET 應用程式。

示例應用

為了使這個遷移部分具體化,
讓我們考慮一個簡化版本的 StackExchange。
供參考,最終結果存在於 Github 上。

Schema

我們將從兩張表開始:


    CREATE TABLE tenants (
        id uuid NOT NULL,
        domain text NOT NULL,
        name text NOT NULL,
        description text NOT NULL,
        created_at timestamptz NOT NULL,
        updated_at timestamptz NOT NULL
    );

    CREATE TABLE questions (
        id uuid NOT NULL,
        tenant_id uuid NOT NULL,
        title text NOT NULL,
        votes int NOT NULL,
        created_at timestamptz NOT NULL,
        updated_at timestamptz NOT NULL
    );

    ALTER TABLE tenants ADD PRIMARY KEY (id);
    ALTER TABLE questions ADD PRIMARY KEY (id, tenant_id);

我們 demo 應用程式的每個租戶都將通過不同的域名進行連線。
ASP.NET Core 將檢查傳入請求並在 tenants 表中查詢域。
您還可以按子域(或您想要的任何其他 scheme)查詢租戶。

注意 tenant_id 是如何儲存在 questions 表中的。
這將使 :ref:colocate <colocation> 資料成為可能。
建立表後,使用 create_distributed table 告訴 Citus 對租戶 ID 進行分片:


    SELECT create_distributed_table('tenants', 'id');
    SELECT create_distributed_table('questions', 'tenant_id');

接下來包括一些測試資料。


    INSERT INTO tenants VALUES (
        'c620f7ec-6b49-41e0-9913-08cfe81199af', 
        'bufferoverflow.local',
        'Buffer Overflow',
        'Ask anything code-related!',
        now(),
        now());

    INSERT INTO tenants VALUES (
        'b8a83a82-bb41-4bb3-bfaa-e923faab2ca4', 
        'dboverflow.local',
        'Database Questions',
        'Figure out why your connection string is broken.',
        now(),
        now());

    INSERT INTO questions VALUES (
        '347b7041-b421-4dc9-9e10-c64b8847fedf',
        'c620f7ec-6b49-41e0-9913-08cfe81199af',
        'How do you build apps in ASP.NET Core?',
        1,
        now(),
        now());

    INSERT INTO questions VALUES (
        'a47ffcd2-635a-496e-8c65-c1cab53702a7',
        'b8a83a82-bb41-4bb3-bfaa-e923faab2ca4',
        'Using postgresql for multitenant data?',
        2,
        now(),
        now());

這樣就完成了資料庫結構和示例資料。 我們現在可以繼續設定 ASP.NET Core。

ASP.NET Core 專案

如果您沒有安裝 ASP.NET Core,請安裝 Microsoft 的 .NET Core SDK
這些說明將使用 dotnet CLI,
但如果您使用的是 Windows,
也可以使用 Visual Studio 2017 或更高版本。

使用 dotnet new 從 MVC 模板建立一個新專案:

dotnet new mvc -o QuestionExchange
cd QuestionExchange

如果您願意,可以使用 dotnet run 預覽模板站點。
MVC 模板幾乎包含您開始使用的所有內容,但 Postgres 支援並不是開箱即用的。
你可以通過安裝 Npgsql.EntityFrameworkCore.PostgreSQL 包來解決這個問題:

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

此包將 Postgres 支援新增到 Entity Framework Core、ASP.NET Core 中的預設 ORM 和資料庫層。
開啟 Startup.cs 檔案並將這些行新增到 ConfigureServices 方法的任意位置:

var connectionString = "connection-string";

services.AddEntityFrameworkNpgsql()
    .AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));

您還需要在檔案頂部新增這些宣告:

using Microsoft.EntityFrameworkCore;
using QuestionExchange.Models;

connection-string 替換為您的 Citus 連線字串。我的看起來像這樣:

Server=myformation.db.citusdata.com;Port=5432;Database=citus;Userid=citus;Password=mypassword;SslMode=Require;Trust Server Certificate=true;

您可以使用 Secret
Manager
來避免將資料庫憑據儲存在程式碼中(並意外將它們檢入原始碼控制中)。

接下來,您需要定義一個資料庫上下文。

新增 Tenancy(租賃) 到 App

定義 Entity Framework Core 上下文和模型

資料庫上下文類提供程式碼和資料庫之間的介面。
Entity Framework Core 使用它來了解您的 data
schema
是什麼樣的,
因此您需要定義資料庫中可用的表。

在專案根目錄中建立一個名為 AppDbContext.cs 的檔案,並新增以下程式碼:

using System.Linq;
using Microsoft.EntityFrameworkCore;
using QuestionExchange.Models;
namespace QuestionExchange
{
    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions<AppDbContext> options)
            : base(options)
        {
        }

        public DbSet<Tenant> Tenants { get; set; }

        public DbSet<Question> Questions { get; set; }
    }
}

兩個 DbSet 屬性指定用於對每個表的行建模的 C# 類。
接下來您將建立這些類。在此之前,請在 Questions 屬性下方新增一個新方法:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var mapper = new Npgsql.NpgsqlSnakeCaseNameTranslator();
    var types = modelBuilder.Model.GetEntityTypes().ToList();

    // Refer to tables in snake_case internally
    types.ForEach(e => e.Relational().TableName = mapper.TranslateMemberName(e.Relational().TableName));

    // Refer to columns in snake_case internally
    types.SelectMany(e => e.GetProperties())
        .ToList()
        .ForEach(p => p.Relational().ColumnName = mapper.TranslateMemberName(p.Relational().ColumnName));
}

C# 類和屬性按慣例是 PascalCase,但 Postgres 表和列是小寫的(和 snake_case)。
OnModelCreating 方法允許您覆蓋預設名稱轉換並讓 Entity Framework Core 知道如何在資料庫中查詢實體。

現在您可以新增代表租戶和問題的類。
在 Models 目錄中建立一個 Tenant.cs 檔案:

using System;

namespace QuestionExchange.Models
{
    public class Tenant
    {
        public Guid Id { get; set; }

        public string Domain { get; set; }

        public string Name { get; set; }

        public string Description { get; set; }

        public DateTimeOffset CreatedAt { get; set; }

        public DateTimeOffset UpdatedAt { get; set; }
    }
}

還有一個 Question.cs 檔案,也在 Models 目錄中:

using System;

namespace QuestionExchange.Models
{
    public class Question
    {
        public Guid Id { get; set; }

        public Tenant Tenant { get; set; }

        public string Title { get; set; }

        public int Votes { get; set; }

        public DateTimeOffset CreatedAt { get; set; }

        public DateTimeOffset UpdatedAt { get; set; }
    }
}

注意 Tenant 屬性。
在資料庫中,問題表包含一個 tenant_id 列。
Entity Framework Core 足夠聰明,可以確定此屬性表示租戶和問題之間的一對多關係。
稍後在查詢資料時會用到它。

到目前為止,您已經設定了 Entity Framework Core 和與 Citus 的連線。
下一步是向 ASP.NET Core 管道新增多租戶支援。

安裝 SaasKit

SaasKit 是一款優秀的開源 ASP.NET Core 中介軟體。
該軟體包使您的 Startup 請求管道 租戶感知(tenant-aware) 變得容易,
並且足夠靈活以處理許多不同的多租戶用例。

安裝 SaasKit.Multitenancy 包:

dotnet add package SaasKit.Multitenancy

SaasKit 需要兩件事才能工作:租戶模型(tenant model)和租戶解析器(tenant resolver)。
您已經有了前者(您之前建立的 Tenant 類),因此在專案根目錄中建立一個名為 CachingTenantResolver.cs 的新檔案:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using SaasKit.Multitenancy;
using QuestionExchange.Models;

namespace QuestionExchange
{
    public class CachingTenantResolver : MemoryCacheTenantResolver<Tenant>
    {
        private readonly AppDbContext _context;

        public CachingTenantResolver(
            AppDbContext context, IMemoryCache cache, ILoggerFactory loggerFactory)
             : base(cache, loggerFactory)
        {
            _context = context;
        }

        // Resolver runs on cache misses
        protected override async Task<TenantContext<Tenant>> ResolveAsync(HttpContext context)
        {
            var subdomain = context.Request.Host.Host.ToLower();

            var tenant = await _context.Tenants
                .FirstOrDefaultAsync(t => t.Domain == subdomain);

            if (tenant == null) return null;

            return new TenantContext<Tenant>(tenant);
        }

        protected override MemoryCacheEntryOptions CreateCacheEntryOptions()
            => new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromHours(2));

        protected override string GetContextIdentifier(HttpContext context)
            => context.Request.Host.Host.ToLower();

        protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<Tenant> context)
            => new string[] { context.Tenant.Domain };
    }
}

ResolveAsync 方法完成了繁重的工作:給定傳入請求,它會查詢資料庫並查詢與當前域匹配的租戶。
如果找到,它會將 TenantContext 傳回給 SaasKit。
所有租戶解析邏輯完全取決於您 - 您可以按子域、路徑或任何其他您想要的方式分隔租戶。

此實現使用 租戶快取策略(tenant caching strategy) <http://benfoster.io/blog/aspnet-core-multi-tenancy-tenant-lifetime>__,
因此您不會在每個傳入請求上都使用租戶查詢來訪問資料庫。
第一次查詢後,租戶將被快取兩個小時(您可以將其更改為任何有意義的內容)。

準備好租戶模型(tenant model)和租戶解析器(tenant resolver)後,
開啟 Startup 類並在 ConfigureServices 方法中的任何位置新增此行:

services.AddMultitenancy<Tenant, CachingTenantResolver>();

接下來,將此行新增到 Configure 方法中,在 UseStaticFiles 下方但在 UseMvc 上方

app.UseMultitenancy<Tenant>();

Configure 方法代表您的實際請求管道,因此順序很重要!

更新檢視

現在所有部分都已就緒,您可以開始在程式碼和檢視中引用當前租戶。
開啟 Views/Home/Index.cshtml 檢視並用這個標記替換整個檔案:

@inject Tenant Tenant
@model QuestionListViewModel

@{
    ViewData["Title"] = "Home Page";
}

<div class="row">
    <div class="col-md-12">
        <h1>Welcome to <strong>@Tenant.Name</strong></h1>
        <h3>@Tenant.Description</h3>
    </div>
</div>

<div class="row">
    <div class="col-md-12">
        <h4>Popular questions</h4>
        <ul>
            @foreach (var question in Model.Questions)
            {
                <li>@question.Title</li>
            }
        </ul>
    </div>
</div>

@inject 指令從 SaasKit 獲取當前租戶,並且
@model 指令告訴 ASP.NET Core,
此檢視將由新模型類(您將建立)支援。
在 Models 目錄中建立 QuestionListViewModel.cs 檔案:


using System.Collections.Generic;

namespace QuestionExchange.Models
{
    public class QuestionListViewModel
    {
    public IEnumerable<Question> Questions { get; set; }
    }
}

查詢資料庫

HomeController 負責渲染您剛剛編輯的索引檢視。開啟它並用這個替換 Index() 方法:

public async Task<IActionResult> Index()
{
    var topQuestions = await _context
        .Questions
        .Where(q => q.Tenant.Id == _currentTenant.Id)
        .OrderByDescending(q => q.UpdatedAt)
        .Take(5)
        .ToArrayAsync();

    var viewModel = new QuestionListViewModel
    {
        Questions = topQuestions
    };

    return View(viewModel);
}

此查詢獲取此租戶的最新五個問題(當然,現在只有一個問題)並填充檢視模型。

對於大型應用程式,您通常會將資料訪問程式碼放在 service 或 repository 層中,
並將其置於 controller 之外。 這只是一個簡單的例子!

您新增的程式碼需要 _context_currentTenant,這在 controller 中尚不可用。
您可以通過以下方式提供這些向類新增建構函式:

public class HomeController : Controller
{
    private readonly AppDbContext _context;
    private readonly Tenant _currentTenant;

    public HomeController(AppDbContext context, Tenant tenant)
    {
        _context = context;
        _currentTenant = tenant;
    }

    // Existing code...

為避免編譯器報錯,請在檔案頂部新增以下宣告:

using Microsoft.EntityFrameworkCore;

測試應用程式

您新增到資料庫的測試租戶與(fake)域 bufferoverflow.localdboverflow.local 相關聯。
您需要 編輯 hosts 檔案 以在本地計算機上測試這些:

127.0.0.1 bufferoverflow.local
127.0.0.1 dboverflow.local

使用 dotnet run 或單擊 Visual Studio 中的 Start 啟動專案,
應用程式將開始偵聽 localhost:5000 之類的 URL。
如果您直接訪問該 URL,您將看到一個錯誤,因為您尚未設定任何 預設租戶行為

相反,訪問 http://bufferoverflow.local:5000
您將看到您的多租戶應用程式的一個租戶!
切換到 http://dboverflow.local:5000 檢視其他租戶。
新增更多租戶現在只需在 tenants 表中新增更多行即可。

更多

探索 Python/Django 支援分散式多租戶資料庫,如 Postgres+Citus