Compartir a través de


Multiempresa

Muchas aplicaciones de línea de negocio están diseñadas para trabajar con varios clientes. Es importante proteger los datos para que los datos del cliente no se "filtren" ni vean otros clientes y competidores potenciales. Estas aplicaciones se clasifican como "multiinquilino" porque cada cliente se considera un inquilino de la aplicación con su propio conjunto de datos.

Advertencia

En este artículo se usa una base de datos local que no requiere que el usuario se autentique. Las aplicaciones de producción deben usar el flujo de autenticación más seguro disponible. Para obtener más información sobre la autenticación para aplicaciones de prueba y producción implementadas, consulta Flujos de autenticación seguros.

Importante

En este documento se proporcionan ejemplos y soluciones "tal como están". Estos no están diseñados para ser "procedimientos recomendados", sino "prácticas de trabajo" para su consideración.

Sugerencia

Puede ver el código fuente de este ejemplo en GitHub

Compatibilidad con multiinquilino

Hay muchos enfoques para implementar el multiinquilino en las aplicaciones. Un enfoque común (que a veces es un requisito) es mantener los datos de cada cliente en una base de datos independiente. El esquema es el mismo, pero los datos son específicos del cliente. Otro enfoque consiste en crear particiones de los datos en una base de datos existente por parte del cliente. Esto se puede hacer mediante una columna de una tabla o tener una tabla en varios esquemas con un esquema para cada inquilino.

Enfoque ¿Columna para inquilino? ¿Esquema por inquilino? ¿Varias bases de datos? Compatibilidad con EF Core
Discriminador (columna) No No Filtro de consulta global
Base de datos por inquilino No No Configuración
Esquema por inquilino No No No compatible

Para el enfoque de base de datos por inquilino, cambiar a la base de datos correcta es tan sencillo como proporcionar la cadena de conexión correcta. Cuando los datos se almacenan en una base de datos única, puede usarse un filtro de consulta global para filtrar automáticamente las filas por la columna id. de inquilino, lo que garantiza que los desarrolladores no escriban código accidentalmente que pueda acceder a los datos de otros clientes.

Estos ejemplos deben funcionar bien en la mayoría de los modelos de aplicaciones, incluidas las aplicaciones de consola, WPF, WinForms y ASP.NET Core. Las aplicaciones Blazor Server requieren una consideración especial.

Aplicaciones Blazor Server y la vida útil de la fábrica

El patrón recomendado para usar Entity Framework Core en aplicaciones Blazor es registrar DbContextFactory y, a continuación, llamarlo para crear una nueva instancia de cada operación DbContext. De forma predeterminada, el generador es un singleton, por lo que solo existe una copia para todos los usuarios de la aplicación. Esto suele ser correcto porque, aunque el generador se comparte, las instancias individuales DbContext no lo son.

Sin embargo, en el caso de los multiinquilino, la cadena de conexión puede cambiar por usuario. Dado que el generador almacena en caché la configuración con la misma duración, esto significa que todos los usuarios deben compartir la misma configuración. Por lo tanto, la duración debe cambiarse a Scoped.

Este problema no se produce en las aplicaciones WebAssembly de Blazor porque singleton está en el ámbito del usuario. Por otro lado, las aplicaciones Blazor Server presentan un desafío único. Aunque la aplicación es una aplicación web, se "mantiene activa" mediante la comunicación en tiempo real mediante SignalR. Se crea una sesión por usuario y dura más allá de la solicitud inicial. Se debe proporcionar una nueva fábrica por usuario para permitir la nueva configuración. La duración de esta factoría especial está limitada y se crea una nueva instancia por sesión de usuario.

Una solución de ejemplo (base de datos única)

Una posible solución consiste en crear un servicio simple de ITenantService que controle la configuración del inquilino actual del usuario. Proporciona devoluciones de llamada para que el código se notifique cuando cambia el inquilino. La implementación (con las devoluciones de llamada omitidas para mayor claridad) podría tener este aspecto:

namespace Common
{
    public interface ITenantService
    {
        string Tenant { get; }

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

A continuación, DbContext puede administrar el multiinquilino. El enfoque depende de la estrategia de base de datos. Si va a almacenar todos los inquilinos en una base de datos única, es probable que vaya a usar un filtro de consulta. ITenantService se pasa al constructor a través de la inserción de dependencias y se usa para resolver y almacenar el identificador del inquilino.

public ContactContext(
    DbContextOptions<ContactContext> opts,
    ITenantService service)
    : base(opts) => _tenant = service.Tenant;

El método OnModelCreating se invalida para especificar el filtro de consulta:

protected override void OnModelCreating(ModelBuilder modelBuilder)
    => modelBuilder.Entity<MultitenantContact>()
        .HasQueryFilter(mt => mt.Tenant == _tenant);

Esto garantiza que todas las consultas se filtren al inquilino en cada solicitud. No es necesario filtrar en el código de aplicación porque el filtro global se aplicará automáticamente.

El proveedor de inquilinos y DbContextFactory se configuran en el inicio de la aplicación como este, con Sqlite como ejemplo:

builder.Services.AddDbContextFactory<ContactContext>(
    opt => opt.UseSqlite("Data Source=singledb.sqlite"), ServiceLifetime.Scoped);

Tenga en cuenta que la vida útil del servicio está configurada con ServiceLifetime.Scoped. Esto le permite tomar una dependencia en el proveedor de inquilinos.

Nota:

Las dependencias siempre deben fluir hacia singleton. Esto significa que un servicio Scoped puede depender de otro servicio Scoped o de un servicio Singleton pero un servicio Singleton solo puede depender de otros servicios Singleton: Transient => Scoped => Singleton.

Varios esquemas

Advertencia

Este escenario no es compatible directamente con EF Core y no es una solución recomendada.

En un enfoque diferente, la misma base de datos puede controlar tenant1 y tenant2 mediante esquemas de tabla.

  • Inquilino1 - tenant1.CustomerData
  • Inuilino2 - tenant2.CustomerData

Si no usa EF Core para controlar las actualizaciones de base de datos con migraciones y ya tiene tablas de varios esquemas, puede invalidar el esquema en un DbContext en OnModelCreating de este modo (el esquema de la tabla CustomerData se establece en el inquilino):

protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.Entity<CustomerData>().ToTable(nameof(CustomerData), tenant);

Varias bases de datos y cadenas de conexión

La versión de varias bases de datos se implementa pasando una cadena de conexión diferente para cada inquilino. Esto se puede configurar al iniciar resolviendo el proveedor de servicios y usándolo para compilar la cadena de conexión. Se agrega una cadena de conexión por inquilino al archivo de configuración de appsettings.json.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "TenantA": "Data Source=tenantacontacts.sqlite",
    "TenantB": "Data Source=tenantbcontacts.sqlite"
  },
  "AllowedHosts": "*"
}

Tanto el servicio como la configuración se insertan en el DbContext:

public ContactContext(
    DbContextOptions<ContactContext> opts,
    IConfiguration config,
    ITenantService service)
    : base(opts)
{
    _tenantService = service;
    _configuration = config;
}

A continuación, el inquilino se usa para buscar la cadena de conexión en OnConfiguring:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var tenant = _tenantService.Tenant;
    var connectionStr = _configuration.GetConnectionString(tenant);
    optionsBuilder.UseSqlite(connectionStr);
}

Esto funciona bien para la mayoría de los escenarios a menos que el usuario pueda cambiar de inquilino durante la misma sesión.

Cambio de inquilinos

En la configuración anterior de varias bases de datos, las opciones se almacenan en caché en el nivel de Scoped. Esto significa que si el usuario cambia el inquilino, las opciones no se vuelven a evaluar y, por tanto, el cambio del inquilino no se refleja en las consultas.

La solución fácil para esto cuando el inquilino puede cambiar consiste en establecer la duración en Transient.. Esto garantiza que el inquilino se vuelva a evaluar junto con la cadena de conexión cada vez que se solicite un DbContext. El usuario puede cambiar los inquilinos con tanta frecuencia como les guste. La tabla siguiente le ayuda a elegir qué duración tiene más sentido para la fábrica.

Escenario Base de datos única Varias bases de datos
El usuario permanece en un solo inquilino Scoped Scoped
El usuario puede cambiar de inquilino Scoped Transient

El valor predeterminado de Singleton sigue teniendo sentido si la base de datos no toma dependencias de ámbito de usuario.

Notas sobre el rendimiento

EF Core se diseñó para que las instancias de DbContext se puedan crear instancias rápidamente con la menor sobrecarga posible. Por ese motivo, la creación de una nueva DbContext por operación suele ser correcta. Si este enfoque afecta al rendimiento de la aplicación, considere la posibilidad de usar la agrupación de DbContext.

Conclusión

Esta es una guía de trabajo para implementar multiinquilino en aplicaciones de EF Core. Si tiene más ejemplos o escenarios o desea proporcionar comentarios, abra un problema y haga referencia a este documento.