ASP.NET Core 6: Multi-tenant Single Database (Parte 2)
Isaac Ojeda
Posted on September 13, 2021
Introducción
Seguimos con la continuación de esta serie de posts sobre aplicaciones multi-tenant con asp.net core.
En este post veremos como implementar una aplicación multi-tenant con una base de datos que contenga todos los tenants.
Veremos las ventajas y desventajas y la razón de porque podríamos utilizar este approach.
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:
- ASP.NET Core 6: Creando una app Multi-tenant (Parte 1)
- ASP.NET Core 6: Multi-tenant Single Database (Parte 2) (este post)
- ASP.NET Core 6: Multi-tenant Multi-Database (Parte 3)
Tipos de multi-tenancy single database
Como vimos en el post pasado, podemos tener distintas arquitecturas al crear aplicaciones SaaS. En este caso vamos a hablar sobre como tener múltiples tenants en una misma base de datos.
Cabe mencionar que es importante analizar si nos conviene esta modalidad ya que tiene sus desventajas pero en ciertos escenarios, sus ventajas.
Siempre debemos de analizar que necesitamos, porque al guardar información de nuestros usuarios, debemos considerar siempre la seguridad, mantenibilidad y escalabilidad.
Una base de datos lenta hace un sistema lento. Una base de datos insegura, filtra información. Una base de datos inmantenible, no dará soluciones a futuro.
Schema-based.
Una sola base de datos para todos los tenants y cada tenant tiene su propio esquema en la base de datos.
Seguridad
- ✔️ La información entre tenants se mantiene aislada, ya que cada uno tendrá su propio esquema.
- ✔️ Filtrar información entre tenants tiene un riesgo bajo, ya que no necesitas de un WHERE para limitar la información entre Tenants.
- ❌ Aun así, puedes hacer queries al esquema equivocado y consultar un tenant totalmente diferente.
Mantenibilidad
- ✔️ Manejar una base de datos sigue siendo una ventaja al darle mantenimiento.
- ✔️ También, al tener esquemas diferentes podemos tener un scope de cada tenant.
- ❌ Actualizar los esquemas será doloroso, más si se tienen muchos tenants
- ❌ No se puede restaurar un solo tenant, ya que sigue siendo una sola base de datos
- ❌ Agregar nuevos tenants involucra agregar un esquema totalmente nuevo, una BD con muchas tablas será problema
Escalabilidad
- ✔️ La información está particionada en tablas pequeñas
- ✔️ Optimizar tablas se podría hacer por tenant
- ❌ Al ser una sola base de datos y un servidor, se limita la escalabilidad a un solo hardware
- ❌ Riesgo de "noisy neighbors" — Un tenant puede impactar el rendimiento de otros tenants
Table-based.
De igual forma, una base de datos para todos los tenants y todos comparten el mismo esquema. La forma de aislar los tenants se realiza por medio de un Identificador en todas las tablas.
Personalmente prefiero esta modalidad si usamos Single database, y en este post haremos un ejemplo usando esta modalidad.
Seguridad
- ❌ Existe un riesgo alto de exponer información entre tenants pero existen varias mitigaciones para eliminar este riesgo.
- ✔️ El ejemplo que haremos en este post, será configurar Entity Framework para que haga Queries de forma automática según el tenant actual, reduciendo a 0 el riesgo de filtración a nivel aplicación.
- ❌ No existe aislamiento entre tenants
Mantenibilidad
- ✔️ Fácil de mantener por ser una sola base de datos con un solo esquema
- ✔️ Fácil de recuperar en caso de un desastre o caídas prolongadas
- ✔️ Agregar tenants nuevos es fácil, no hay que hacer ninguna modificación al esquema.
- ❌ Queries pueden ser riesgosos de modificar Tenants no deseados
- Mitigación: Row-Level Security puede ser usado para controlar el acceso de los rows en las tablas
- ❌ Si usamos RLS, se debe de actualizar en cada tabla nueva que agregamos
- ❌ Sería difícil restaurar información de un solo tenant.
Escalabilidad
- ❌ Solo se podría hacer scale-up (agregar más hardware) y no distribuirlo de forma horizontal (scale-out)
- ❌ Mismo problema de "Noisy neighbors"
- ❌ Si la base de datos crece (cada tenant tiene mucha información) las actividades de mantenimiento tardarán más y potencialmente afectará a otros tenants
¿Por qué la modalidad table-based?
Realmente por experiencias que he tenido en microservicios, me gusta esta modalidad.
Sí debemos de tener en cuenta los factores mencionados arriba, una base de datos grande y con mucha información por tenant, definitivamente ninguna de estas será la solución.
Para estos casos, el siguiente post con multi-database será una solución muy buena. Pero para bases de datos relativamente pequeñas, sí es una buena opción.
Un microservicio usualmente tiene esquemas pequeños, eso lo hace fácil de manejar. Podría ser que las tablas sí crezcan mucho, pero al ser pocas tablas de manejar, podría ser viable alguna de estas dos opciones.
MultiTenant DbContext (una DB para todos 🧙🏽♂️)
Para continuar con este ejemplo en ASP.NET Core, tenemos que seguir utilizando la solución del ejemplo anterior (la dejamos preparada para lo mismo).
Para comenzar, crearemos un nuevo DbContext
en la solución con una tabla de ejemplo Product
.
public class Product : AuditableEntity
{
public int ProductId { get; set; }
public string Description { get; set; }
}
La clase padre AuditableEntity
nos servirá para obligar que todos los Entities tengan ciertas propiedades y eso nos ayudará a crear un aislamiento y manejo de nuestros Entities.
public class AuditableEntity
{
public int TenantId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
}
Aquí simplemente estamos agregando el campo TenantId
(es obligatorio para todos los entities) y además unas propiedades adicionales para control de ellas.
Aquí podemos agregar Ids de usuarios que crearon/modificaron los registros para que realmente sea un Entity Auditable.
using Microsoft.EntityFrameworkCore;
using Z.EntityFramework.Plus;
public class MultiTenantDbContext : DbContext
{
private readonly int _tenantId;
public MultiTenantDbContext(
DbContextOptions<MultiTenantDbContext> options,
IHttpContextAccessor contextAccessor) : base(options)
{
var currentTenant = contextAccessor.HttpContext?.GetTenant();
_tenantId = currentTenant?.Id ?? 0;
this.Filter<AuditableEntity>(f => f.Where(q => q.TenantId == _tenantId));
}
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.TenantId = _tenantId;
entry.Entity.CreatedAt = DateTime.UtcNow;
break;
case EntityState.Modified:
entry.Entity.ModifiedAt = DateTime.UtcNow;
break;
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
Aquí se complicaron un poco las cosas. Pero antes de explicarlo, necesitamos una librería que nos ayude con este truco de EF Core.
<PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="6.0.0-preview.7.21378.4-4" />
Lo que está sucediendo aquí realmente es sencillo:
Primero que nada, utilizando IHttpContextAccessor
estamos accediendo al HttpContext
de la solicitud actual y a su vez nos da el Tenant que se quiere acceder.
Si no existe ningún HttpContext
(puede ser alguna inicialización o similar) de entrada no se podrán hacer Queries ya que es requisito saber que Tenant se está accediendo para poder mostrar información.
Tal vez aquí no es lo más optimo, talvez podemos crear un ITenantAccessor
y llenarlo de formas personalizadas (y no siempre de un HttpContext) pero por ahora, el ejemplo será así.
En el mismo constructor de MultiTenantDbContext
se está configurando para que siempre agregue una cláusula WHERE a cualquier query que se haga en el contexto, esto utilizando la librería Z.EntityFramework.Plus.EFCore.
Esta es la parte donde nos aseguramos que exista un aislamiento (al menos uno lógico) y se puedan realizar Queries de forma segura.
ℹ️ Nota: Cabe mencionar, que esta medida de seguridad solo funciona si se usa el
DbContext
directamente, cualquier raw SQL está potencialmente propenso a fugas de información.
El método SaveChangesAsync
simplemente es para actualizar cualquier Entity que se haya creado o modificado. Esto no tiene mucho que ver con la BD multi-tenant, pero es buena idea que las operaciones sean auditables.
Integración con ASP.NET
Para poder crear la base de datos y empezar a hacer pruebas, tenemos que registrar este nuevo DbContext
.
builder.Services.AddDbContext<MultiTenantDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("MultiTenant")));
Teniendo ya las siguientes connection strings.
"ConnectionStrings": {
"TenantAdmin": "Server=(localdb)\\mssqllocaldb;Database=MultiTenant_Admin;Trusted_Connection=True;MultipleActiveResultSets=true",
"MultiTenant": "Server=(localdb)\\mssqllocaldb;Database=MultiTenantSingleDb;Trusted_Connection=True;MultipleActiveResultSets=true"
}
Como en el post anterior, ejecutamos los siguientes comandos para crear la primera migración y crear la base de datos.
dotnet ef database add FirstMigration --context MultiTenantDbContext -o Persistence/Migrations/MultiTenant
dotnet ef database update --context MultiTenantDbContext
Como ya contamos con dos DbContext
en el proyecto, cada comando ef
que hagamos, debemos especificar a que contexto nos estamos refiriendo.
Y nos creará lo siguiente.
Es por eso que desde el post pasado, especificamos el output de la migración.
¿Por qué dos bases de datos?
Es recomendable tener una Base de datos "compartida" o "principal" para que en esta podamos guardar meta-data. Es decir, información que describan nuestros tenants, catálogos compartidos y entre otras cosas.
En multi-database esta base de datos compartida será de vital importancia.
Además, para agregar tenants nuevos, solo tendríamos que agregar un registro a la tabla Tenants dentro del contexto TenantAdminDbContext
.
Finalizando
Para hacer pruebas, agregaremos datos de prueba.
Desde el post anterior estamos utilizando Razor Pages, por lo que haremos una consulta directamente en nuestro PageModel
(en este caso, uno llamado Products.cshtml) y así comprobar que cuando estamos en localhost, solo nos debe de regresar Product 1 y Product 2.
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
public class ProductsModel : PageModel
{
private readonly MultiTenantDbContext _context;
public ProductsModel(MultiTenantDbContext context)
{
_context = context;
}
public List<Product> Products { get; set; }
public async Task OnGet()
{
Products = await _context.Products.ToListAsync();
}
}
Como pueden ver aquí, simplemente estamos enlistando todos los productos de la tabla, aquí se encuentran mezclados entre tenants, pero el Filter base que pusimos hará que estén aislados.
@page
@model MultiTenantSingleDatabase.Pages.ProductsModel
@{
}
<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>
Como resultado.
Si visitamos otro tenant.
Conclusión
Podríamos pensar que esta modalidad de hacer aplicaciones multi-tenants tiene más desventajas que ventajas. Pero si analizamos la situación correctamente, podría ser nuestro proyecto candidato para hacer un multi-tenancy con single database y funcionar perfectamente.
Cuéntame ¿Cómo ves está opción? yo soy totalmente C# Developer y manejar todo desde C# siempre es lo que hago, crear Raw SQL queries es algo que no hago muy seguido.
Y recuerda,
Code4Fun 👍🏽.
Update 1 ℹ️
En lugar de utilizar el NuGet Package de Z.EntityFramework (que para otras cosas también es útil) no sabía que EF Core ya cuenta con una funcionalidad así incluida pero al parecer se tendría que poner el "Query Filter" por cada Entity y no de su clase base como lo hacemos originalmente, igual es bueno saberlo 👍🏽.
Dale un vistazo -> Global Query Filters
Referencias
Multi-tenant Application Database Design
Multi-Tenancy with SQL Server, Part 2: Database Design Approaches
Posted on September 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.