DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on

ASP.NET Core 6: Multi-tenant Multi-Database (Parte 3)

Introducción

Continuando con la última parte de esta serie de posts en asp.net core, hoy veremos como seguir el approach Multi-Tenant con Multi-Database (Tenant Per Database).

De igual forma, exploraremos las ventajas y desventajas de utilizar múltiples bases de datos para múltiples tenants y veremos que opción es mejor según el escenario.

Te vuelvo a recordar que esta serie de posts viene con muchos code snippets, es mejor que lo leas siguiendo el repositorio en Github con el ejemplo final.

Esta serie de posts se dividen en 3 partes:

Tipos de multi-tenancy con multi-database

Ahora toca ver las modalidades de hacer multi-tenancy con varias bases de datos. Este puede ser o será el approach más usado en la mayoría de los casos, pero como en todo, siempre hay que ver sus ventajas y desventajas y verificar si es lo mejor para la solución que estemos creando.

Database Per Tenant

En esta modalidad, cada Tenant cuenta con su propia base de datos. Este approach lo consideraría como el mejor y el más usado. veamos por qué.

Untitled

Seguridad

  • ✔️ Alto nivel de aislamiento, permite distribuir las bases de datos en distintos servidores.
  • ❌ Al contar con más servidores y más bases de datos, también hay que administrarlos y mantenerlos seguros.

Mantenibilidad

  • ✔️ El mantenimiento se lleva acabo por tenant y puede ser personalizado según la carga de la BD
  • ✔️ Fácilmente se puede restaurar/reubicar/limpiar la información de cada tenant
  • ✔️ No hay complejidad en los Queries
  • ❌ Agregar Tenants nuevos requiere de más trabajo, por ser una base de datos totalmente aparte
    • Solución: Automatizar el proceso de creación de tenants
  • ❌ Con forme vayan creciendo los tenants, existirán muchas bases de datos que mantener y administrar

Escalabilidad

  • ✔️ Scale-out y Scale-up son opciones viables — Los tenants pueden ser distribuidos en múltiples servidores
  • ✔️ Elegir un balance entre costo (alta densidad / menos servidores) y eficiencia (baja densidad / más servidores)
  • ✔️ El efecto "noisy neighbor" no nos afecta (tanto).

Multiple Databases, Multiple Tenants (Shared Schema)

Esta modalidad es un hibrido entre Table Based del post pasado y Database Per Tenant. Existen múltiples bases de datos y dentro de cada base de datos puede existir 1 o más tenants.

Untitled 1

Seguridad

  • ✔️ Existe un aislamiento parcial al utilizar múltiples bases de datos
  • ❌ Hay tenants que siguen compartiendo el esquema
    • De igual forma el RLS funciona como mitigación

Mantenibilidad

  • ✔️ La opción de elegir si queremos tener muchas bases de datos (densidad de tenants baja) o pocas bases de datos (densidad de tenants alta)
  • ✔️ Posibilidad de reubicar la información de un tenant (aunque más difícil que el approach tenant per database)
  • ❌ Genera más mantenimiento que si usaramos el puro approach Table based

Escalabilidad

  • ✔️ Scale-out y Scale-up son opciones viables — Los tenants pueden ser distribuidos en múltiples servidores
  • ✔️ Elegir un balance entre costo (alta densidad / menos servidores) y eficiencia (baja densidad / más servidores)

¿ Por qué Database Per Tenant?

Así como en el post anterior comenté porque he usado el modo table-based explicaré porque también uso un tenant por base de datos.

La configuración desde ASP.NET Core para la selección de que base de datos usar es relativamente sencilla y esta modalidad la utilizo para aplicaciones Monoliticas. En las que estoy seguro que tendremos muchas tablas y el crecimiento de tenants puede ser indefinido.

El aislamiento es la mejor parte, porque sabemos que cada cliente tiene su información totalmente separada que la de otros clientes. Restaurar la información de un cliente sin afectar a otros es buena razón para irnos por este approach.

El mantenimiento será brutal, pero con tanta información, siempre será una tarea de dedicación y cuidado (sin importar que esquema multitenant usemos).

SingleTenant DbContext

Para continuar con este ejemplo, estoy haciéndolo en un proyecto aparte (Que encontrarás en el repositorio de GitHub) y me estoy basando totalmente en el contenido del Post 1.

Necesitaremos ahora, un contexto llamado SingleTenantDbContext (muy similar al del Post 2)

public class SingleTenantDbContext : DbContext
{
    private readonly MultiTenants.Fx.Tenant _tenant;

    public SingleTenantDbContext(
        DbContextOptions<SingleTenantDbContext> options,
        ITenantAccessor<MultiTenants.Fx.Tenant> tenantAccessor) : base(options)
    {
        _tenant = tenantAccessor.Tenant ?? throw new ArgumentNullException(nameof(MultiTenants.Fx.Tenant));
    }

    public DbSet<Product> Products { get; set; }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = DateTime.UtcNow;
                    break;
                case EntityState.Modified:
                    entry.Entity.ModifiedAt = DateTime.UtcNow;
                    break;
            }
        }

        return base.SaveChangesAsync(cancellationToken);
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);

        optionsBuilder.UseSqlServer(_tenant.Items["ConnectionString"]?.ToString());
    }
}
Enter fullscreen mode Exit fullscreen mode

El contenido de SaveChangesAsync realmente no es necesario, pero lo quise dejar como en el Post 2, lo importante ocurre en el constructor y en el OnConfiguring.

OnConfiguring configurará el contexto como lo hacemos usualmente al registrarlo como dependencia, pero como el Connection String será dinámico, lo leeremos del Tenant que nos regresa el contenedor de dependencias (todo esto del Post 1).

AuditableEntity sigue siendo como en el Post 2, pero sin la propiedad TenantId (ya que no necesitamos una columna con ese dato).

En esta ocasión, agregamos algo nuevo llamado ITenantAccessor y nos ayuda a acceder al Tenant actual de una forma más elegante y no desde el HttpContext como lo hicimos en el Post 2.

public interface ITenantAccessor<T> where T : Tenant
{
    public T? Tenant { get; init; }
}
Enter fullscreen mode Exit fullscreen mode
public class TenantAccessor : ITenantAccessor<Tenant>
{
    public TenantAccessor(IHttpContextAccessor contextAccessor, IConfiguration config, IWebHostEnvironment env)
    {
        Tenant = contextAccessor.HttpContext?.GetTenant();

        if (Tenant is null && env.IsDevelopment())
        {
            // Nota 👀:
            // Si estamos en modo desarrollo y no hay Tenant, 
            // probablemente es alguna inicialización o creación de migración
            // en modo desarrollo
            Tenant = new Tenant(-1, "TBD");
            Tenant.Items["ConnectionString"] = config.GetConnectionString("SingleTenant");
        }
    }

    public Tenant? Tenant { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Aquí sucede un truco y si ustedes tienen una mejor solución, háganmelo saber 😅.

Lo que sucede al ejecutar el comando dotnet ef migrations add se compila la solución y se construye el contexto para ver los cambios que existen en el modelo y cuando ejecutamos dotnet ef database update consulta la base de datos en físico (la existente en nuestro servidor local) y revisa que migraciones han sido aplicadas.

Por esta razón, necesitamos darle una cadena de conexión de "prueba" o "desarrollo" y se conecte, pero en producción, esto no será así.

{
  "ConnectionStrings": {
    "TenantAdmin": "Server=(localdb)\\mssqllocaldb;Database=MultiTenant_AdminV2;Trusted_Connection=True;MultipleActiveResultSets=true",
    "SingleTenant": "Server=(localdb)\\mssqllocaldb;Database=MultiTenantMultiDb;Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}
Enter fullscreen mode Exit fullscreen mode

El contexto del Post 1 (TenantAdminDbContext) se mantiene igual, solo el Entity Tenant le agregamos una columna ConnectionString.

public class Tenant
{
    public int TenantId { get; set; }
    public string? Name { get; set; }
    public string? Identifier { get; set; }
    public string? ConnectionString { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Y en el DbContextTenantStore que también teníamos del Post 1, queda así.

public async Task<Tenant> GetTenantAsync(string identifier)
{
    var cacheKey = $"Cache_{identifier}";
    var tenant = _cache.Get<Tenant>(cacheKey);

    if (tenant is null)
    {
        var entity = await _context.Tenants
            .FirstOrDefaultAsync(q => q.Identifier == identifier)
                ?? throw new ArgumentNullException($"identifier no es un tenant válido");

        tenant = new Tenant(entity.TenantId, entity.Identifier);

        tenant.Items["Name"] = entity.Name;
        tenant.Items["ConnectionString"] = entity.ConnectionString; // UPDATE ⚠️

        _cache.Set(cacheKey, tenant);
    }

    return tenant;
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Nota: Es importante mencionar que la tabla Tenants de esta base de datos es muy importante tenerla bajo llave, ya que cualquier fuga de información de esta sería muy peligroso porque contiene toda la información de las bases de datos.

Como estamos en .NET 6 (al día de hoy en RC1) necesitamos los siguientes paquetes antes de continuar.

<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0-rc.1.21452.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.0-rc.1.21452.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.0-rc.1.21452.10">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Enter fullscreen mode Exit fullscreen mode

👀 Nota: Se puede utilizar cualquier versión reciente de asp.net core sin problema

Integración con ASP.NET

Aquí hay una diferencia en como configuraremos nuestra base de datos en el Program.cs

builder.Services.AddDbContext<TenantAdminDbContext>(options =>
        options.UseSqlServer(builder.Configuration.GetConnectionString("TenantAdmin")));
// NEW!
builder.Services.AddDbContext<SingleTenantDbContext>();
Enter fullscreen mode Exit fullscreen mode

Aquí no estamos especificando que cadena de conexión utilizará ni que proveedor. Esto se hace en el contexto mismo y con Dependency Injection lo hacemos dinámico según el tenant.

Cool ¿verdad?

👀 Nota : Para tener una mejor idea del proyecto completo, revisa mi GitHub

Teniendo ya todo configurado, hay que crear las bases de datos de cada contexto.

dotnet ef migrations add FirstMigration --context SingleTenantDbContext -o Persistence/Migrations/SingleTenant
dotnet ef migrations add FirstMigration --context TenantAdminDbContext -o Persistence/Migrations/TenantAdmin

dotnet ef database update --context SingleTenantDbContext
dotnet ef database update --context TenantAdminDbContext 
Enter fullscreen mode Exit fullscreen mode

Esto creará 2 migraciones (y creará 2 bases de datos)

Untitled 2

Y según los Connection Strings de appsettings.json, nos creará estas 2 bases de datos.

Untitled 3

MultiTenantMultiDb ya la podríamos usar, pero lo que hice es crear 2 bases de datos aparte copiadas de esta misma (con script o bacpac, hazlo a tu modo).

Untitled 4

Finalizando

Para por fin hacer pruebas, tendremos esta información en nuestra tabla de Tenants

Untitled 5

Las cadenas de conexión las debes de poner según lo que estés usando (SQL Server Express, SQL Lite, etc)

Y en las BDs individuales, agregamos los productos que queramos para probar.

Untitled 6

Untitled 7

Como en el Post 2, crearemos una vista sencilla para mostrar los productos.

@page
@model MultiTenantMultiDatabase.Pages.Products.IndexModel
@{
}

<h1>Productos</h1>

<table class="table">
    <thead>
        <tr>
            <th>Product Id</th>
            <th>Description</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var product in Model.Products)
        {
            <tr>
                <td>@product.ProductId</td>
                <td>@product.Description</td>
            </tr>
        }
    </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode
public class IndexModel : PageModel
    {
        private readonly SingleTenantDbContext _context;

        public IndexModel(SingleTenantDbContext context)
        {
            _context = context;
        }

        public ICollection<Product> Products { get; set; }

        public void OnGet()
        {
            Products = _context.Products.ToList();
        }
    }
Enter fullscreen mode Exit fullscreen mode

Como ven, al Entity Products no se le agrega ningún filtro ya que el DbContext decidirá automáticamente a que base de datos conectarse.

Tenant 1

Tenant 2

Conclusión

De esta forma configuramos la infraestructura de nuestra aplicación para que los Developers simplemente se preocupen en las funcionalidades a desarrollar y todo esto sea transparente para ellos.

Cuéntame ¿Qué opinas de estas propuestas para crear aplicaciones Multi-Tenant?

Espero les sea de utilidad, ya que en los últimos proyectos que he diseñado, me he basado en estas dos modalidades habladas en esta serie de posts y me ha funcionado bastante bien ya en proyectos grandes en producción.

Code4Fun 👍🏽.

Referencias

Multi-Tenancy with SQL Server, Part 2: Database Design Approaches

Discussion (0)