ASP.NET Core - Aplicações Multi-Tenant - II


Hoje veremos como criar uma aplicação Multi-Tenant em simples usando a ASP .NET Core 5.

Continuando a primeira parte do artigo vamos agora criar uma aplicação Multi-Tenant bem simples usando  ASP .NET Core 5.

Usando a resolução do Inquilino ou Tenant Resolution

Em uma aplicação Multi-Tenant precisamos ser capazes de identificar em qual locatário uma solicitação está sendo executada, mas antes de ficarmos muito animados, precisamos decidir quais dados exigimos para poder procurar um locatário. Na verdade, precisamos apenas de uma informação neste estágio, o identificador do locatário.

Assim na requisição HTTP, precisaremos decidir em qual contexto de locatário executar o request e isso afeta coisas como qual banco de dados acessar ou qual configuração usar.

Vamos usar aqui a resolução do inquilino que é a técnica pela qual você pode corresponder uma solicitação a um inquilino.

Existem várias maneiras de fazer isso; seu aplicativo pode armazenar dados de identificação do locatário de uma das seguintes maneiras:

Neste exemplo, vamos usar a última estratégia, Headers do Request, ou seja, os dados de identificação do locatário serão passados no cabeçalho do request e lidos a partir daí pelo aplicativo.

Assim vamos usar o identificador para corresponder a um locatário com base em nossa estratégia de resolução.

Criando a aplicação ASP .NET Core MVC

Abra o VS 2019 e clique em Create New Project selecionando o template ASP .NET Core Web App(Mode-View-Controller)

A seguir selecione as configurações conforme a figura abaixo:

Após criar o projeto inclua as referências as pacotes :

Criando o banco de dados , as tabelas e incluindo dados

Para manter as coisas simples, vamos usar um banco de dados com um design simples.  Vamos criar um banco de dados no SQL Server chamado DemoTenant e neste banco de dados vamos criar as tabelas :

A seguir temos o script usado para criar essas tabelas:

USE DemoTenant
GO

CREATE TABLE dbo.Tenant(
    Id uniqueidentifier NOT NULL,
    ApiKey uniqueidentifier NOT NULL,
    TenantName nvarchar(200) NOT NULL,
    IsActive bit NOT NULL

CONSTRAINT PK_Tenant
    PRIMARY KEY
    CLUSTERED (Id ASC)
    )
GO

CREATE TABLE dbo.Customer(
    Id uniqueidentifier NOT NULL, 
    TenantId uniqueidentifier NOT NULL, 
    CustomerName nvarchar(50) NOT NULL, 
    IsActive bit NOT NULL

CONSTRAINT PK_Customer
    PRIMARY KEY
    CLUSTERED (Id  ASC),
    CONSTRAINT FK_Customer_Tenant
    FOREIGN KEY (TenantId)
    REFERENCES dbo.Tenant(Id)
    )

GO

1- Estrutura da tabela Customer

2- Estrutura da tabela Tenant

A seguir temos o script usado para incluir dados em ambas as tabelas :

Use DemoTenant
GO

INSERT INTO dbo.Tenant(Id, ApiKey,TenantName,IsActive)
    VALUES('f3cfe18a-369b-4630-89b0-ed121d083c59',
       'e71c77c8-e18a-4114-b659-006af444c2dc',
           'Tenant A', 1)

INSERT INTO dbo.Tenant(Id, ApiKey,TenantName,IsActive)
    VALUES('47df0eb1-aef2-4208-8d03-ac88ae6c8330',
           'a8ac37d4-715f-41e9-ae36-292cc6615e73',
           'Tenant B', 2)

INSERT INTO dbo.Tenant(Id, ApiKey,TenantName, IsActive)
    VALUES('a9121c5e-a37e-4c77-bd11-0cddaae46d4b',
        'cab37f08-10aa-473d-9f26-16d9d4a958e5',
        'Tenant C', 3)
GO

INSERT dbo.Customer(Id,TenantId, CustomerName, IsActive)
    VALUES ('169d0df3-688a-4864-8b11-078f2d1b1c24',
            'f3cfe18a-369b-4630-89b0-ed121d083c59',
            'Customer 1', 1)
GO

INSERT dbo.Customer(Id,TenantId, CustomerName, IsActive)
    VALUES ('8b9b5299-f718-4eeb-93ba-f1acc62356ab',
            '47df0eb1-aef2-4208-8d03-ac88ae6c8330',
            'Customer 2', 1)
GO

INSERT dbo.Customer(Id,TenantId, CustomerName, IsActive)
    VALUES ('7fc8a49f-1a15-46fb-b30b-d8b9c479e7cc',
            'a9121c5e-a37e-4c77-bd11-0cddaae46d4b',
            'Customer 3', 10)
GO

INSERT dbo.Customer(Id,TenantId, CustomerName, IsActive)
    VALUES ('e6cb4033-d215-4a5a-9e61-6af1e69a074c',
            'f3cfe18a-369b-4630-89b0-ed121d083c59',
            'Customer 4', 1)
GO

INSERT dbo.Customer(Id, TenantId, CustomerName, IsActive)
    VALUES ('a5f7ab05-9862-409f-8b29-5d7406cd15f3',
            'a9121c5e-a37e-4c77-bd11-0cddaae46d4b',
            'Customer 5', 1)
GO

 

Ao final teremos o seguinte resultado:

1- Dados da tabela Tenant

2- Dados da tabela Customer

Note que temos um relacionamento de um-para-muitos entre a tabela Tenant e a tabela Customer:

No arquivo appsettings.json vamos definir a seção ConnectionStrings contendo a string de conexão com o banco de dados DemoTenant:

{
  "ConnectionStrings": {
    "TenantDbConnection": "Data Source=\\sqlexpress;Initial Catalog=DemoTenant;
      Integrated Security=Tru
e"
  },

  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Criando as entidades e os repositórios

Na pasta Models do projeto vamos criar as entidades Customer e Tenant que representam o domínio da aplicação com o código a seguir:

1-Customer

public class Customer
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
    public string CustomerName { get; set; }
    public bool IsActive { get; set; }
}

2-Tenant

public class Tenant
{
   public Guid Id { get; set; }
   public Guid ApiIKey { get; set; }
   public string TenantName { get; set; }
   public bool IsActive { get; set; }
}

A seguir vamos criar a pasta Repositories no projeto e nesta pasta criar a classe CustomerRepository:

using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Threading.Tasks;
using TenantWebApp.Models;

namespace TenantWebApp.Repositories
{
    public class CustomerRepository
    {
        private readonly IConfiguration _configuration;

        public CustomerRepository(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        public async Task<List<Customer>> GetAllCustomers(string tenantId)
        {
            try
            {
                List<Customer> customers = new List<Customer>();

                using (var connection = new SqlConnection(_configuration
                                        ["ConnectionStrings:TenantDbConnection"]))
                {
                    await connection.OpenAsync();
                    using (var command = new SqlCommand(
                           "SELECT * FROM dbo.Customer Where TenantId = @TenantId", connection))
                    {
                        SqlParameter param = new SqlParameter();
                        param.ParameterName = "@TenantId";
                        param.Value = tenantId;
                        command.Parameters.Add(param);

                        var reader = command.ExecuteReader();
                        while (reader.Read())
                        {
                            Customer customer = new Customer();
                            customer.Id = Guid.Parse(reader["Id"].ToString());
                            customer.TenantId = Guid.Parse(reader["TenantId"].ToString());
                            customer.CustomerName = reader["CustomerName"].ToString();
                            customer.IsActive = bool.Parse(reader["IsActive"].ToString());
                            customers.Add(customer);
                        }

                        if (!reader.IsClosed) await reader.CloseAsync();
                    }

                    if (connection.State != ConnectionState.Closed) await
                                         connection.CloseAsync();
                    return customers;
                }
            }
            catch
            {
                return null;
            }
        }
    }
}

Neste código injetamos uma instância de IConfiguration e no método GetAllCustomers() estamos passando o Id do Tenant e acessando a tabela Customer para retornar todos os Customers para este Tenant.

A seguir vamos criar a a classe TenantRepository:

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using System;
using System.Data;
using System.Data.SqlClient;
using System.Threading.Tasks;

namespace TenantWebApp.Repositories
{
    public class TenantRepository
    {
        private readonly IHttpContextAccessor _httpContextAccessor;
        private ISession _session => _httpContextAccessor.HttpContext.Session;
        private readonly IConfiguration _configuration;

        public TenantRepository(IConfiguration configuration,
                     IHttpContextAccessor httpContextAccessor)
        {
            _configuration = configuration;
            _httpContextAccessor = httpContextAccessor;
        }

        public async Task<string> GetTenantId(Guid apiKey)
        {
            string tenantId = null;
            try
            {
                using (var connection = new SqlConnection(_configuration                     
                                ["ConnectionStrings:TenantDbConnectio
n"]))
                {
                    await connection.OpenAsync();
                    using (var command = new SqlCommand(
                             "SELECT Id FROM Tenant WHERE ApiKey = @apiKey", connection))
                    {
                        command.Parameters.AddWithValue("@apiKey", apiKey);
                        var reader = await command.ExecuteReaderAsync();
                        if (reader.Read())
                        {
                            tenantId = reader["Id"].ToString();
                        }

                        if (!reader.IsClosed) await reader.CloseAsync();
                        if (connection.State != ConnectionState.Closed)
                                            await connection.CloseAsync();

                        return tenantId;
                    }
                }
            }
            catch
            {
                return null;
            }
        }

        public async Task<string> GetTenantId()
        {
            return await Task.FromResult(_session.GetString("TenantId"));
        }

        public async Task<string> GetAllTenantsAndCustomers()
        {
            string result = null;

         try
         {
            using (var connection = new SqlConnection(_configuration["ConnectionStrings:TenantDbConnection"]))
            {
                    await connection.OpenAsync();

                    using (var command = new SqlCommand("SELECT Tenant.Id, Tenant.ApiKey, 
                      Tenant.TenantName, Customer.CustomerName FROM Tenant LEFT JOIN Customer ON
                      Tenant.Id = Customer.TenantId  FOR JSON AUTO, INCLUDE_NULL_VALUES",
                      connection))

                     {
                        var reader = await command.ExecuteReaderAsync();
                        if (reader.Read())
                        {
                            result = reader[0].ToString();
                        }

                        if (!reader.IsClosed) await reader.CloseAsync();
                        if (connection.State != ConnectionState.Closed) await
                                     connection.CloseAsync();

                        return FormatJsonData(result);
                    }
                }
            }
            catch
            {
                return null;
            }
        }
        private string FormatJsonData(string json)
        {
            dynamic data = JsonConvert.DeserializeObject(json);
            return JsonConvert.SerializeObject(data, Newtonsoft.Json.Formatting.Indented);
        }
    }
}

Neste código temos os métodos :

Criando o Middleware e o método de extensão

Vamos criar a pasta Middlewares no projeto e nesta pasta vamos criar um middleware customizado definido na classe TenantSecurityMiddleware para ler a API key do cabeçalho do request e a seguir vamos usar este valor para recuperar o ID do locatário do banco de dados. Por último, o ID do inquilino recuperado é armazenado na sessão.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using System;
using System.Linq;
using System.Threading.Tasks;
using TenantWebApp.Repositories;

namespace TenantWebApp.Middlewares
{
    public class TenantSecurityMiddleware
    {
        private readonly RequestDelegate next;

        public TenantSecurityMiddleware(RequestDelegate next)
        {
            this.next = next;
        }

        public async Task Invoke(HttpContext context, IConfiguration configuration,
                   IHttpContextAccessor httpContextAccessor)
        {
            string tenantIdentifier = context.Session.GetString("TenantId");
            if (string.IsNullOrEmpty(tenantIdentifier))
            {
                var apiKey = context.Request.Headers["x-api-Key"].FirstOrDefault();
                if (string.IsNullOrEmpty(apiKey))
                {
                    return;
                }

                Guid apiKeyGuid;
                if (!Guid.TryParse(apiKey, out apiKeyGuid))
                {
                    return;
                }

                TenantRepository _tenentRepository = new TenantRepository(configuration,
                 httpContextAccessor);
                string tenantId = await _tenentRepository.GetTenantId(apiKeyGuid);

                context.Session.SetString("TenantId", tenantId);
            }
            await next.Invoke(context);
        }
    }
}

Para adicionar o middleware no pipeline de processamento do request vamos criar um método de extensão para IApplicationBuilder criando nesta mesma pasta a classe TenantSecurityMiddlewareExtension onde vamos definir o método UsetTenant:

using Microsoft.AspNetCore.Builder;

namespace TenantWebApp.Middlewares
{
    public static class TenantSecurityMiddlewareExtension
    {
        public static IApplicationBuilder UseTenant(this IApplicationBuilder app)
        {
            app.UseMiddleware<TenantSecurityMiddleware>();
            return app;
        }
    }
}

A seguir precisamos habilitar o pipeline para Session e para o middleware que criamos definindo o código no método Configure da classe Startup:

  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseAuthorization();

            app.UseSession();
            app.UseTenant();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
    }

Concluindo as configurações vamos registrar no método ConfigureServices os serviços para os repositórios , Session e HttpContextAcessor:

 public void ConfigureServices(IServiceCollection services)
 {
            services.AddControllersWithViews();
            services.AddScoped<TenantRepository, TenantRepository>();
            services.AddScoped<CustomerRepository, CustomerRepository>();
            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            services.AddSession(options =>
            {
                options.IdleTimeout = TimeSpan.FromMinutes(10);
            });

  }

Criando os controladores

Na pasta Controllers do projeto vamos criar primeiro o controlador CustomersController:

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;
using TenantWebApp.Models;
using TenantWebApp.Repositories;

namespace TenantWebApp.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CustomersController : ControllerBase
    {
        private readonly CustomerRepository _customerRepository;
        private readonly TenantRepository _tenantRepository;
        public CustomersController(CustomerRepository customerRepository,

        TenantRepository tenantRepository)
        {
            _customerRepository = customerRepository;
            _tenantRepository = tenantRepository;

        }

        [HttpGet]
        public async Task<List<Customer>> GetAll()
        {
            string tenantId = await _tenantRepository.GetTenantId();
            return await _customerRepository.GetAllCustomers(tenantId);
        }
    }
}

Neste código injetamos os repositórios no construtor e no método GetAll() vamos retornar todos os Customers pelo Id do Tenant:

Assim abrindo o Postman definimos a seguinte requisição:



Observe que obtemos o respectivo Customer relacionado.

Agora vamos criar o controlador TenantController:

using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using TenantWebApp.Repositories;

namespace TenantWebApp.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TenantController : ControllerBase
    {
        private readonly TenantRepository _tenantRepository;

        public TenantController(CustomerRepository customerRepository, TenantRepository tenantRepository)
        {
            _tenantRepository = tenantRepository;
        }

        [HttpGet]
        public async Task<string> Get()
        {
            return await _tenantRepository.GetAllTenantsAndCustomers();
        }
    }
}

Aqui vamos retornar todos os Tenants e Customers relacionados.

Para isso abra o Postman e defina a seguinte requisição:

Vemos a exibição dos Tenants e Customers:

[
  {
    "Id": "A9121C5E-A37E-4C77-BD11-0CDDAAE46D4B",
    "ApiKey": "CAB37F08-10AA-473D-9F26-16D9D4A958E5",
    "TenantName": "Tenant C",
    "Customer": [
      {
        "CustomerName": "Customer 5"
      },
      {
        "CustomerName": "Customer 3"
      }
    ]
  },
  {
    "Id": "47DF0EB1-AEF2-4208-8D03-AC88AE6C8330",
    "ApiKey": "A8AC37D4-715F-41E9-AE36-292CC6615E73",
    "TenantName": "Tenant B",
    "Customer": [
      {
        "CustomerName": "Customer 2"
      }
    ]
  },
  {
    "Id": "F3CFE18A-369B-4630-89B0-ED121D083C59",
    "ApiKey": "E71C77C8-E18A-4114-B659-006AF444C2DC",
    "TenantName": "Tenant A",
    "Customer": [
      {
        "CustomerName": "Customer 1"
      },
      {
        "CustomerName": "Customer 4"
      }
    ]
  }
]

Temos assim um vislumbre de uma aplicação Multi-Tenant criada com a ASP .NET Core 5.

Dessa forma em um aplicativo Multi-Tenant à medida que o grau de multilocação aumenta, os custos do cliente diminuem. Em essência, um aplicativo multilocatário fornece mais valor ao longo do tempo.

Mesmo com todos os benefícios que eles oferecem, a flexibilidade e a segurança dos aplicativos multilocatários ainda é um desafio. Um aplicativo multilocatário pode não ser uma boa escolha se seu aplicativo precisar de muita personalização. Apesar dos desafios e desvantagens, a arquitetura Multi-Tenant é altamente desejável por causa de sua eficiência de custo e do valor comercial que fornece.

Pegue o código do projeto aqui :  TenantWebApp.zip

"Amo ao SENHOR, porque ele ouviu a minha voz e a minha súplica.
Porque inclinou a mim os seus ouvidos; portanto, o invocarei enquanto viver."
Salmos 116:1,2

Referências:


José Carlos Macoratti