saas软件开发框架,具有实体框架的多租户SaaS架构

SaaS 解决方案非常常见。 它们是软件产品的首选模型,原因有很多,例如维护成本低、成本效益高、客户入门容易。 然而,您为 SaaS 解决方案选择的软件架构可能决定您在业务中的成功。 但在我们深入研究使用 Entity Framework 和 .NET Core 创建多租户应用程序的代码之前,让我们先了解一下多租户架构是什么。 为此,我们必须首先了解什么是单租户架构。

什么是单租户架构

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

saas软件开发框架,具有实体框架的多租户SaaS架构

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

单租户架构一点也不差。 它只是有它的优点和缺点。 对于很多解决方案来说,它可能不是最好的架构,但有时单租户架构会带来很多好处。 这完全取决于您的业务性质。

什么是多租户架构

实现多租户架构的方法有多种,但最常见的方法(也是我们将在本文中创建的方法)是所有客户都使用相同编译的应用程序、数据库、托管等的类型。所有客户数据 存储在同一个数据库中,所有客户共享同一个应用程序。 这几乎就像他们在您的网站中租用空间一样,这就是使用术语“租户”的原因,因此客户或客户通常在多租户应用程序中被称为租户。 如果您有数千个客户,并且需要推送解决方案的更新,则只需对一个数据库和一个网站执行此操作,所有客户都会获得更新。 下图描述了多租户架构:

saas软件开发框架,具有实体框架的多租户SaaS架构

设计数据库

如果我们要将所有客户的数据放在同一个数据库中,我们必须以某种方式能够区分哪些记录属于哪些客户。 有不同的方法可以做到这一点。 例如,我们可以为每个客户创建一个新的数据库模式。 我们可以为每个模式使用客户的名称,因此如果“Nice Printing”和“Best Insurance”是我们的客户,则“Nice Printing”客户的所有表都可以放在“nice_printing”模式中,并且“Nice Printing”客户的所有表都可以放在“nice_printing”模式中 “Best Insurance”客户可以放置在“best_insurance”模式中。 只要客户数量较少且易于管理,这种方法就有效。 如果有数千名客户,这种方法可能会导致维护问题。

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

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

[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 表的主键。 名称列将包含客户的姓名。 域列将包含该租户的域。 每个租户都将在我们的应用程序中拥有自己的域。 例如,如果租户的名称是 Best Insurance,他们将使用
https://best-insurance.com URL 访问其网站。 如果租户的名称是 Nice Printing,他们将使用 https://nice-printing.com URL 访问其站点。 租户表的域列将分别为每个租户提供“best-insurance.com”和“nice-printing.com”值。 域列将有一个唯一索引,因为我们将通过域来查找每个租户。

我们所有的表都将有一个名为 TenantId 的列,它将告诉哪些行属于哪些租户。 这些表的主键将是由 TenantId 和 Id 列组成的复合键。 Id 列将成为一个 Identity 列,它将随着我们在数据库中添加的每一行而自动递增。 TenantId 列除了是主键的一部分之外,还将成为引用 Tenants 表的外键。 由于所有表都将具有 TenantId 和 Id 列,因此为它们创建一个抽象基类非常有意义:

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

    public int Id { get; set; }
}

接下来,我们必须告诉实体框架,我们希望将从 TenantModel 类继承的所有模型类的主键设为由 TenantId 和 Id 列组成的复合键,并将 Id 设为 Identity 列,每当我们添加一个列时该列就会自动递增 新行。 为此,我们必须重写 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 类的表。 我们的新表将被称为“产品”。 Products 表将包含来自所有租户的数据,但数据将按 TenantId 列分隔。 以下是Products表的Product模型的代码:

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

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

Product 类非常简单。 它继承自 TenantModel 类,并向其中添加 Name 和 Description 属性。 我们添加到 AppDbContext 类中的代码将自动使 Products 表复合键的主键由从 TenantModel 类继承的 TenantId 和 Id 列组成,并且还将使 TenantId 列成为引用 Tenants 表的外键。 每次我们创建一个继承自 TenantModel 的新类时,我们的代码都会确保它具有多租户应用程序的正确主键。

隔离租户数据

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

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 类注册为 Scoped 服务,这意味着一旦在 TenantFilter 类中设置 TenantId 属性,该属性在提供 HTTP 请求和响应时都将可用。 在下面的代码片段中,我们全局注册 TenantFilter ASP.NET Core 过滤器,以便为每个请求设置 TenantProviderService 类上的 TenantId 属性,并将 TenantProviderService 类注册为作用域服务,以保留 TenantId 属性用于服务 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。 我们可以通过重写 SaveChanges 和 SaveChangesAsync 实体框架 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 服务的 TenantId 值。 然后,我们从重写的 SaveChanges 和 SaveChangesAsync 方法中调用 SetTenantId 方法。

我们要做的最后一件事是,当我们从数据库读取数据时,通过当前租户ID过滤所有数据。 每当用户需要查看一些数据时,我们只能显示属于他们正在浏览的网站的租户(客户)的记录。 我们可以通过添加全局 Entity Framework Core 过滤器来做到这一点。

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 解决方案,那么使用多租户架构非常有意义,因为它使新客户和现有客户的维护和添加新功能变得非常容易。 有多种不同的方法来实现此架构。 最常见的方法(也是我们在本文中使用的方法)是为所有租户(客户)使用一个数据库和一个网站。 要实现所有租户一库一网站的多租户架构,您必须执行以下操作:

将所有包含租户数据的表的主键配置为由 TenantId 和 Id 列组成的复合键。 Id 列应该是一个标识列,每次添加新行时它都会递增。 TenantId 列也应该是引用 Tenants 表的外键。

确定每个请求的 TenantId。 在 ASP.NET Core 应用程序中,我们可以创建一个全局过滤器,它将根据请求 URL 的域从数据库中获取租户 ID。

添加、编辑和删除数据时,将 TenantId 列值设置为我们在步骤 2 中获取的租户 ID 值。

从数据库读取数据时,通过我们在步骤 2 中获取的租户 ID 值过滤数据。

请注意,我们在本文中使用的 TenantModel 基类不适用于多对多数据库表关系。 为了使其简短易懂,我们在本文中没有介绍为该类型的关系配置 TenantId 列。 但是,如果您有兴趣为多对多关系配置 TenantId 列,请考虑阅读我们有关 Entity Framework Core 中多对多关系的文章。 它不涵盖多租户,但它可以是一个很好的起点。

版权声明:【saas软件开发框架,具有实体框架的多租户SaaS架构】版权归原作者所有,本文由作者:【隔壁老王】用户自发贡献上传,该文观点仅代表作者本人,本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任,如发现本站有涉嫌抄袭侵权/违法违规的内容,请发送邮件至举报,一经查实,本站将立刻删除,如若转载,请注明出处:https://www.intostarry.com/jrzy/1433.html

(0)
上一篇 2023年12月14日 21:47:43
下一篇 2023年12月16日

相关推荐

发表回复

登录后才能评论