Multilocação

Muitos aplicativos de linha de negócios foram projetados para funcionar com vários clientes. É importante proteger os dados para que os dados do cliente não sejam "vazados" ou vistos por outros clientes e potenciais concorrentes. Esses aplicativos são classificados como "multilocatários" porque cada cliente é considerado um locatário do aplicativo com seu próprio conjunto de dados.

Importante

Este documento fornece exemplos e soluções "como estão". Elas não se destinam a ser "melhor prática", mas sim "práticas de trabalho" para sua consideração.

Dica

Você pode exibir o código-fonte deste exemplo no GitHub

Suporte à multilocação

Há muitas abordagens para implementar a multilocação em aplicativos. Uma abordagem comum (que às vezes é um requisito) é manter dados de cada cliente em um banco de dados separado. O esquema é o mesmo, mas os dados são específicos do cliente. Outra abordagem é particionar os dados em um banco de dados existente por cliente. Isso pode ser feito usando uma coluna em uma tabela ou tendo uma tabela em vários esquemas com um esquema para cada locatário.

Abordagem Coluna para locatário? Esquema por locatário? Vários bancos de dados? Suporte do EF Core
Discriminador (coluna) Sim No Não Filtros de consulta global
Banco de dados por locatário Não Não Sim Configuração
Esquema por locatário Não Sim Não Sem suporte

Para a abordagem de banco de dados por locatário, alternar para o banco de dados correto é tão simples quanto fornecer a cadeia de conexão correta. Quando os dados são armazenados em um único banco de dados, um filtro de consulta global pode ser usado para filtrar automaticamente linhas pela coluna ID do locatário, garantindo que os desenvolvedores não escrevam acidentalmente código que possa acessar dados de outros clientes.

Esses exemplos devem funcionar bem na maioria dos modelos de aplicativos, incluindo console, WPF, WinForms e aplicativos ASP.NET Core. Os aplicativos Blazor Server exigem uma consideração especial.

Aplicativos do Blazor Server e a vida útil do alocador

O padrão recomendado para usar o Entity Framework Core em aplicativos Blazor é registrar o DbContextFactorye chamá-lo para criar uma nova instância de DbContext a cada operação. Por padrão, o alocador é um singleton, portanto, há apenas uma cópia para todos os usuários do aplicativo. Isso geralmente é bom porque, embora o alocador seja compartilhado, as instâncias individuais DbContext não são.

No entanto, no caso de multilocação, a cadeia de conexão pode ser alterada por usuário. Como o alocador armazena em cache a configuração com o mesmo tempo de vida, isso significa que todos os usuários devem compartilhar a mesma configuração. Portanto, o tempo de vida deve ser alterado para Scoped.

Esse problema não ocorre em aplicativos Blazor WebAssembly porque o singleton tem escopo para o usuário. Os aplicativos Blazor Server, por outro lado, apresentam um desafio único. Embora o aplicativo seja um aplicativo Web, ele é "mantido vivo" pela comunicação em tempo real usando o SignalR. Uma sessão é criada por usuário e dura além da solicitação inicial. Um novo alocador deve ser fornecido por usuário para permitir novas configurações. O tempo de vida deste alocador especial tem escopo definido e uma nova instância é criada por sessão de usuário.

Uma solução de exemplo (banco de dados individual)

Uma solução possível é criar um serviço ITenantService simples que lide com a configuração do locatário atual do usuário. Ele fornece retornos de chamada para que o código seja notificado quando o locatário for alterado. A implementação (com os retornos de chamada omitidos para maior clareza) pode ter esta aparência:

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

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

O DbContext pode, desse modo, gerenciar a multilocação. A abordagem depende de sua estratégia de banco de dados. Se você estiver armazenando todos os locatários em um único banco de dados, provavelmente usará um filtro de consulta. O ITenantService é passado para o construtor por meio de injeção de dependência e usado para resolver e armazenar o identificador de locatário.

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

O método OnModelCreating é substituído para especificar o filtro de consulta:

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

Isso garante que cada consulta seja filtrada para o locatário em cada solicitação. Não é necessário filtrar o código do aplicativo porque o filtro global será aplicado automaticamente.

O provedor de locatário e DbContextFactory são configurados na inicialização do aplicativo assim, usando o Sqlite como exemplo:

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

Observe que o tempo de vida do serviço está configurado com ServiceLifetime.Scoped. Isso permite que ele assuma uma dependência no provedor do locatário.

Observação

As dependências sempre devem fluir para o singleton. Isso significa que um serviço Scoped pode depender de outro serviço Scoped ou de um serviço Singleton, mas um serviço Singleton só pode depender de outros serviços Singleton: Transient => Scoped => Singleton.

Vários esquemas

Aviso

O EF Core não dá suporte direto a este cenário e não é uma solução recomendada.

Em uma abordagem diferente, o mesmo banco de dados pode manipular tenant1 e tenant2 usando esquemas de tabela.

  • Tenant1 - tenant1.CustomerData
  • Tenant2 - tenant2.CustomerData

Se você não estiver usando o EF Core para lidar com atualizações de banco de dados com migrações e já tiver tabelas de vários esquemas, poderá substituir o esquema de um DbContext em OnModelCreating de uma maneira semelhante a esta (o esquema da tabela CustomerData está definido como o locatário):

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

Vários bancos de dados e cadeias de conexão

A versão de vários bancos de dados é implementada passando uma cadeia de conexão diferente para cada locatário. Isso pode ser configurado na inicialização resolvendo o provedor do serviço e usando-o para criar a cadeia de conexão. Uma cadeia de conexão por seção de locatário é adicionada ao arquivo de configuração appsettings.json.

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

O serviço e a configuração são injetados no DbContext:

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

Em seguida, o locatário é usado para pesquisar a cadeia de conexão em OnConfiguring:

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

Isso funciona bem para a maioria dos cenários, a menos que o usuário possa trocar de locatário durante a mesma sessão.

Alternar locatários

Na configuração anterior para vários bancos de dados, as opções são armazenadas em cache no nível Scoped. Isso significa que, se o usuário alterar o locatário, as opções não serão reavaliadas e, portanto, a alteração do locatário não será refletida nas consultas.

A solução fácil para isso quando o locatário pode alterar é definir o tempo de vida para Transient.. Isso garante que o locatário seja reavaliado junto com a cadeia de conexão sempre que um DbContext for solicitado. O usuário pode alternar os locatários com a frequência que quiser. A tabela a seguir ajuda você a escolher qual tempo de vida faz mais sentido para o seu alocador.

Cenário Banco de dados individual Vários bancos de dados
O usuário permanece em um único locatário Scoped Scoped
O usuário pode alternar locatários Scoped Transient

O padrão de Singleton ainda fará sentido se o banco de dados não assumir dependências com escopo de usuário.

Observações sobre desempenho

O EF Core foi projetado para que as instâncias DbContext possam ser instanciadas rapidamente com o mínimo de sobrecarga possível. Por esse motivo, a criação de um novo DbContext por operação deve, no geral, funcionar bem. Se essa abordagem estiver afetando o desempenho do aplicativo, considere usar um pool do DbContext.

Conclusão

Essa é uma orientação de trabalho para implementar multilocação em aplicativos EF Core. Se você tiver mais exemplos ou cenários ou quiser fornecer comentários, abra um problema e faça referência a este documento.