具有实体框架的多租户SaaS体系结构
#sql #dotnet #database #csharp

SaaS解决方案非常普遍。它们是软件产品的首选模型,因为许多原因,例如几乎没有维护开销,成本效益和容易的客户入职。但是,您为SaaS解决方案选择的软件体系结构可能会决定您在业务中的成功。但是,在我们研究使用实体框架和.NET核心创建多租户应用程序的代码之前,让我们了解多租户体系结构是什么。为此,我们必须首先了解单租户体系结构是什么。

什么是单租户建筑

在单租户软件体系结构中,每个客户都会获得自己的编译应用程序,数据库,托管等版本。如果要推开软件更新或补丁,则必须分别为每个客户完成。下图描述了单租户结构:

Single-Tenant Software Architecture

以示例来描述单租户体系结构的最佳方法。想象一下,您开始了为客户创建网站的业务。您会找到一些客户并开始开发其网站。您为每个客户端创建一个数据库。然后,您为每个客户购买托管,并在这些网络服务器上安装其网站。一切都很好,直到您在代码中找到错误。您将如何为所有客户端修复该错误?如果您必须进行数据库架构更改以修复该错误,则必须使用这些更改来更新每个客户端的数据库。您将必须更新源代码,然后将补丁推到每个客户端的Web服务器。对于少数客户来说,这可能很容易,但是随着客户群的增长,随着时间的推移,它会增加大量的维护开销。想象您的业务做得很好,现在您有50个客户。如果您的软件有任何更改,则必须将这些更改推向所有50个客户端网站。我认为很容易想象您必须花多少时间来维护所有这些不同的客户网站。

单租户建筑根本不错。它只是利弊。它可能不是许多解决方案的最佳体系结构,但是有时单租户架构带来了很多好处。这一切都取决于您业务的本质。

什么是多租户建筑

有不同的方法来实现多租户体系结构,但是本文中我们要创建的最常见方法和一种方法是所有客户使用相同的编译应用程序,数据库,托管等的类型。所有客户数据都存储在同一数据库中,所有客户都共享相同的应用程序。这几乎就像他们在您的网站中租用空间一样,这就是为什么使用“租户”一词的原因,因此客户或客户通常被称为多租户应用程序中的租户。如果您有成千上万的客户,并且需要为解决方案推动更新,则只需为一个数据库和一个网站做到这一点,所有客户都将获得更新。下图描述了多租户体系结构:

Multi-Tenant Software Architecture

设计数据库

如果我们要将所有客户的数据放在同一数据库中,我们必须以某种方式告诉哪些记录属于哪些客户。有不同的方法可以做到这一点。例如,我们可以为每个客户创建一个新的数据库架构。我们可以为每个架构使用客户的名称,因此,如果“不错的印刷”和“最佳保险”是我们的客户,则可以将“ Nice Printing”客户的所有桌子放在“ Nice_printing”架构中,以及所有表格可以将“最佳保险”客户放置在“ Best_insurance”架构中。只要客户数量且易于管理,这将起作用。如果有成千上万的客户,这种方法可能会导致维护问题。

另一种方法是将所有客户的数据放在同一张表中,但分配每行的唯一客户ID,可以告诉每行属于哪个客户。然后,如果我们想查询特定客户的数据,我们可以通过客户ID进行过滤。这种方法将更容易维护,因为我们可以将数千个客户的数据放在同一组表中,如果我们必须对数据库设计进行更新,则不必为每个客户单独使用它们。这是我们将在本文中使用的数据库设计。

首先,让我们创建一个名为Tenants的表。我们将在该表中存储所有客户的名称。我们将命名该表Tenants而不是客户,以清楚地表明,该表是我们要存储有关我们多租户应用程序租户的信息的地方。以下是Tenants表的实体框架模型:

[Index(nameof(Tenant.Domain), IsUnique = true)]
public class Tenant
{
    [Key]
    public int Id { get; set; }

    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [Required]
    [StringLength(100)]
    public string Domain { get; set; }
}

Id列将是Tenants表的主要键。 Name列将具有客户名称。 Domain列将具有该租户的域名。每个租户都将在我们的应用中拥有自己的领域。例如,如果租户的名称是最佳保险,他们将使用https://best-insurance.com URL访问其网站。如果房客的名字是不错的印刷,他们将使用https://nice-printing.com URL访问其网站。 Tenants表的Domain列将分别为每个租户具有“ Best-Insurance.com”和“ Nice-Printing.com”值。 Domain列将在其上具有唯一的索引,因为我们将通过其域名定位每个租户。

我们所有的桌子都将拥有一个称为TenantId的列,该列将说明属于租户的行。这些表的主要键将是复合键,由TenantIdId列组成。 Id列将是一个身份列,它将在数据库中添加的每一行自动插入。 TenantId列除了成为主要键的一部分外,还将是引用Tenants表的外键。由于所有表都将具有TenantIdId列,因此为它们创建基本摘要类很有意义:

public abstract class TenantModel
{
    public int TenantId { get; set; }
    public Tenant Tenant { get; set; }

    public int Id { get; set; }
}

接下来,我们必须告诉实体框架,我们要使从TenantModel类继承的所有模型类的主要键组成的复合键由TenantIdId列组成,并使Id随时随我们添加了一个新的行。为此,我们必须覆盖DbContext类的OnModelCreating方法,并使用以下代码:

public class AppDbContext : DbContext
{
    public DbSet<Tenant> Tenants { get; set; }

    private void SetupTenantModels(ModelBuilder modelBuilder)
    {
        var tenantModels = modelBuilder
            .Model
            .GetEntityTypes()
            .Where(e => typeof(TenantModel).IsAssignableFrom(e.ClrType));

        foreach (var model in tenantModels)
        {
            // Set primary key to a composite key consisted of TenantId and Id columns.
            modelBuilder.Entity(model.ClrType)
                .HasKey(nameof(TenantModel.TenantId), nameof(TenantModel.Id));

            // Make the Id column Identity that auto-increments with every row.
            modelBuilder.Entity(model.ClrType)
                .Property(nameof(TenantModel.Id))
                .ValueGeneratedOnAdd();
        }
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        SetupTenantModels(modelBuilder);
    }
}

现在,我们可以添加一个实际使用TenantModel类的表。我们的新桌子将被称为产品。产品表将具有来自所有租户的数据,但是数据将通过TenantId列隔离。以下是产品表的产品模型的代码:

public class Product : TenantModel
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [StringLength(300)]
    public string Description { get; set; }
}

Product类非常简单。它从TenantModel类继承,并在其中添加了名称和描述属性。我们添加到AppDbContext类中的代码将自动使产品表复合键的主要键由TenantId和ID列组成,该列是从TenantModel类继承的,它也将使TenantId列作为外国键引用房客表。每当我们创建一个从TenantModel继承的新类时,我们的代码都会确保其具有正确的多租户应用程序的主要键。

隔离租户数据

我们编写了代码,该代码将在数据库中的所有表中添加TenantId列。接下来,我们必须编写代码,该代码在添加或更新这些表中的行时实际上将设置TenantId列值。每次我们要在数据库中添加或更新数据时,我们都必须使用正确的租户ID来做到这一点,这提出了我们将如何确定网站中每个请求使用的租户ID的问题。我们可以从网站URL提取域,并使用该域来查询Domain列的Tenants表。当我们找到与相关域匹配的Tenants表行时,我们可以使用该行的ID。让我们创建一个将执行我们需要的ASP.NET核心过滤器。

public class TenantFilter : IActionFilter
{
    private readonly AppDbContext _dbContext;
    private readonly IHostEnvironment _environment;
    private readonly ITenantProviderService _tenantProviderService;

    public TenantFilter(
        AppDbContext dbContext,
        IHostEnvironment environment,
        ITenantProviderService tenantProviderService)
    {
        _dbContext = dbContext;
        _environment = environment;
        _tenantProviderService = tenantProviderService;
    }

    private string GetCallingDomain(HttpRequest request)
    {
        var callingUrl = $"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}";
        var uri = new Uri(callingUrl);

        return _environment.IsDevelopment()
            ? $"{uri.Host}:{uri.Port}"
            : uri.Host;
    }

    public void OnActionExecuting(ActionExecutingContext context)
    {
        var domain = GetCallingDomain(context.HttpContext.Request);

        var tenant = _dbContext
            .Tenants
            .SingleOrDefault(t => t.Domain == domain);

        _tenantProviderService.TenantId = tenant.Id;
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        // Do nothing here.
    }
}

我们在第42行的代码段中设置的TenantProviderService.TenantId属性是一个简单的属性,带有Getter和setter。我们只能将TenantProviderService类注册为范围的服务,这意味着一旦TenantId属性设置在我们的TenantFilter类中,它将在服务HTTP请求和响应时可用。在以下代码段中,我们在全球登记TenantFilter ASP.NET核心过滤器,以在每个请求的TenantProviderService类上设置TenantId属性,并将TenantProviderService类注册为范围的服务,以使TenantId属性保持HTTP请求和HTTP请求的服务响应一旦我们将其设置在TenantFilter类中。

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Add services to the container.
        builder.Services.AddControllersWithViews(options =>
        {
            options.Filters.Add(typeof(TenantFilter));
        });

        builder.Services.AddScoped<ITenantProviderService, TenantProviderService>();

        // Irrelevant code omitted for brevity
    }
}

现在,TenantProviderService.TenantId属性将为每个请求提供正确的房客ID。现在,在数据库中添加,编辑和删除记录时,我们必须使用该租户ID。我们可以通过覆盖SaveChangesSaveChangesAsync实体框架DbContext方法并将修改后实体的TenantId属性设置为TenantProviderService.TenantId属性的值。
来做到这一点。

public class AppDbContext : DbContext
{
    // Irrelevant code omitted for brevity

    private void SetTenantId()
    {
        foreach (var entry in ChangeTracker.Entries<TenantModel>())
        {
            entry.Property(e => e.TenantId).CurrentValue = _tenantProviderService.TenantId;
        }
    }

    public override int SaveChanges()
    {
        SetTenantId();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SetTenantId();
        return base.SaveChangesAsync(cancellationToken);
    }
}

在上面的代码代码段的SetTenantId方法中,我们正在通过EF变更跟踪器中的所有实体进行迭代,该实体从TenantModel类中继承,并将这些实体的TenantId值设置为TenantProviderService Service的TenantId的值。然后,我们从覆盖的SaveChangesSaveChangesAsync方法调用SetTenantId方法。

我们要做的最后一件事是当我们从数据库中读取数据时,通过当前租户ID过滤所有数据。每当用户需要查看某些数据时,我们只需要显示属于其网站浏览的租户(客户)的记录。我们可以通过添加全球实体框架核心过滤器来做到这一点。

public class AppDbContext : DbContext
{
    private void FilterByTenantId(ModelBuilder modelBuilder, IMutableEntityType model)
    {
        Expression<Func<TenantModel, bool>> filterExpression = t => t.TenantId == _tenantProviderService.TenantId;

        var newParam = Expression.Parameter(model.ClrType);
        var newBody = ReplacingExpressionVisitor.Replace(filterExpression.Parameters.Single(), newParam, filterExpression.Body);

        LambdaExpression lambdaExpression = Expression.Lambda(newBody, newParam);

        modelBuilder.Entity(model.ClrType)
            .HasQueryFilter(lambdaExpression);
    }

    private void SetupTenantModels(ModelBuilder modelBuilder)
    {
        var tenantModels = modelBuilder
            .Model
            .GetEntityTypes()
            .Where(e => typeof(TenantModel).IsAssignableFrom(e.ClrType));

        foreach (var model in tenantModels)
        {
            // Set primary key to a composite key consisted of TenantId and Id columns.
            modelBuilder.Entity(model.ClrType)
                .HasKey(nameof(TenantModel.TenantId), nameof(TenantModel.Id));

            // Make the Id column Identity that auto-increments with every row.
            modelBuilder.Entity(model.ClrType)
                .Property(nameof(TenantModel.Id))
                .ValueGeneratedOnAdd();

            // Globally filter all queries by TenantId
            FilterByTenantId(modelBuilder, model);
        }
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        SetupTenantModels(modelBuilder);
    }
}

在上面的代码段中,我们从SetupTenantModels方法调用了新的FilterByTenantId方法。我们正在定义一个过滤器表达式,该表达式仅保留TenantId列等于TenantProviderService.TenantId列的值的行。我们正在为从TenantModel基类继承的类创建的每个表格注册该过滤器表达式。使用表达式树和表达式访问者的代码只是为了以实体框架喜欢的方式调整过滤器表达式。

结论

如果您正在开发SaaS解决方案,则使用多租户体系结构非常有意义,因为它使维护和添加新功能非常容易为新的和现有的客户提供。有不同的方法来实现此体系结构。我们在本文中使用的最常见方法是为所有租户(客户)使用一个数据库和一个网站。要使用一个数据库和一个用于所有租户的网站实现多租户体系结构,您必须执行以下操作:

  1. 配置所有表格的主要键,这些键将租户数据作为复合密钥,由TenantIdId列组成。 ID列应该是一个身份列,每次添加新行时都会增加。 TenantId列也应该是引用租户表的外国密钥。
  2. 为每个请求确定TenantId。在ASP.NET核心应用程序中,我们可以创建一个全局过滤器,该过滤器将根据请求URL的域中从数据库中获取租户ID。
  3. 添加,编辑和删除数据时,将TenantId列值设置为我们从步骤2获得的房客ID值。
  4. 从数据库中读取数据时,通过我们从步骤2获得的房客ID值过滤数据。

请注意,我们在本文中使用的TenantModel基类将不适用于多对多数据库表关系。在本文中,我们没有涵盖为该类型的关系配置TenantId列,以使其简短易懂。但是,如果您有兴趣为多对多关系配置TenantId列,请考虑阅读我们有关实体框架核心中多一对多关系的文章。它不能涵盖多租期,但可以是一个很好的起点。
Many-To-Many Relationships in Entity Framework Core