Construa aplicações Multi-Tenant com ASP.NET Core

4 jan

Fala Galera,

Hoje vamos falar sobre aplicações Multi-Tenant, para muitos esse termo pode ser desconhecido mas provavelmente alguém já ouviu falar sobre Software as a Service (SaaS). O termo SaaS é uma realidade há muito tempo porém se tornou popular como o crescimento de serviços na nuvem como Azure ou AWS.

O conceito de software as a Service (SaaS) costuma a andar junto como o conceito de Multi Tenant mas isso não significa que toda aplicação Multi-Tenant será uma aplicação SaaS, contudo é o mais comum a ser feito.

Pela forma como as aplicações Multi Tenant são estruturadas é possivel adicionar um Tenant (Usuários), criar uma base de dados isolada, subdominio isolado e toda a infratuestrutura necessária para que a aplicação funcione perfeitamente.

A ideia principal de uma aplicação Multi Tenant é garantir o isolamento de implementação, de dados e de customização, para isso temos que atender alguns requisitos como:

  1. Isolamento dos dados: um usuário não pode ter acesso aos dados dos demais ou seja, cada usuário deve sua propria base de dados
  2. Ser extensível: Uma aplicação multi-tenant deve permitir que os usuários consiga fazer customizações
  3. Solução escalavél: A aplicação deve ser robusta a ponto de atender milhares de requisições por Tenant

Fazer uma implementação deste tipo não é algo fácil, mas existem framework que podem ajudar nesta tarefa árdua como o SaaSKit

Neste artigo vamos criar uma aplicação Multi Tenant, usando SaaSKit com customização de temas por Tenant

Criação do Projeto

Para começar vamos criar um projeto ASP.NET Core MVC  e adicionar a referencia do SaasKit que se encontra disponível no NuGet. Abra o Package Manager Console e digite o comando abaixo:

  • Install-Package SaaSKit.Multitenancy

Neste exemplo estou usando MVC mas o SaaSKit funciona em qualquer aplicação .Net Core.

Tenant Identification

O primeiro passo de uma aplicação Multi-Tenant é a identificação do Tenant. A identificação do Tenant pode ser baseada no HttpRequest extraindo o hostname ou até uma cabeçalho header costumizado.

Vamos criar nossa classe de identificação de Tenant, chamada AppTenant e adicione o código abaixo.

public class AppTenant
{
     public string AppName { get; set; }
     public string[] Hostnames { get; set; }
     public string Theme { get;set;}
}

Com a nossa classe criada, temos que implementar como o SaaSKit irá resolver nosso Tenant através de um HttpRequest. Nós faremos isso criando um Tenant Resolver. Crie uma classe chamada AppTenantResolver e adicione o código abaixo:

public class AppTenantResolver : MemoryCacheTenantResolver<AppTenant>
  {
      private IEnumerable<AppTenant> Tenants { get; set; }

      public AppTenantResolver(IMemoryCache cache, ILoggerFactory loggerFactory, IOptions<MultitenancyOptions> options)
          : base(cache, loggerFactory)
      {
          this.Tenants = options.Value.Tenants;
      }
      protected override Task<TenantContext<AppTenant>> ResolveAsync(HttpContext context)
      {
          TenantContext<AppTenant> tenantContext = null;

          var tenant = this.Tenants.FirstOrDefault(t =>
              t.Hostnames.Any(h => h.Equals(context.Request.Host.Value.ToLower())));

          if (tenant != null)
          {
              tenantContext = new TenantContext<AppTenant>(tenant);
          }

          return Task.FromResult(tenantContext);
      }

      protected override string GetContextIdentifier(HttpContext context)
      {
          return context.Request.Host.Value.ToLower();
      }

      protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<AppTenant> context)
      {
          return context.Tenant.Hostnames;
      }

  }

Com o resolver criado, crie uma nova classe chamada MultitenancyOptions, ela será a responsável por guardar as informações de todos os nossos Tenant. Adicione o código abaixo na classe:

public class MultitenancyOptions
{
    public Collection<AppTenant> Tenants { get; set; }
}

Configuração dos Tenants:

Neste ponto, devemos configurar os Tenants, para isso precisamos alterar o Startup e adicionar os serviços de MultiTenant. Abre a classe Startup e adicione o código abaixo:

public class Startup
   {
       public Startup(IHostingEnvironment env)
       {
           var builder = new ConfigurationBuilder()
               .SetBasePath(env.ContentRootPath)
               .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
               .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
               .AddEnvironmentVariables();

           Configuration = builder.Build();
       }

       public IConfigurationRoot Configuration { get; }

       // This method gets called by the runtime. Use this method to add services to the container.
       public void ConfigureServices(IServiceCollection services)
       {
           services.AddMultitenancy<AppTenant, AppTenantResolver>();
           services.AddEntityFrameworkSqlite().AddDbContext<SqliteApplicationDbContext>();

           services.AddOptions();

           services.Configure<RazorViewEngineOptions>(options =>
           {
               options.ViewLocationExpanders.Add(new TenantViewLocationExpander());
           });

           services.Configure<MultitenancyOptions>(Configuration.GetSection("Multitenancy"));

           // Add framework services.
           services.AddMvc();
       }

       // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
       public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
       {
           loggerFactory.AddConsole(Configuration.GetSection("Logging"));
           loggerFactory.AddDebug();

           if (env.IsDevelopment())
           {
               app.UseDeveloperExceptionPage();
               app.UseBrowserLink();
               app.UseDatabaseErrorPage();
           }
           else
           {
               app.UseExceptionHandler("/Home/Error");
           }

           app.UseStaticFiles();

           app.UseMultitenancy<AppTenant>();

           app.UseMvc(routes =>
           {
               routes.MapRoute(
                   name: "default",
                   template: "{controller=Home}/{action=Index}/{id?}");
           });
       }
   }

Agora devemos registrar nossos Tenants, iremos configurar somente dois Tenants para efeito de teste, mas você pode configurar quanto quiser. Vamos abrir o arquivo appsettings.json e adicionar as seguintes configurações:

{
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  },
  "Multitenancy": {
    "Tenants": [
      {
        "AppName": "Tenant 1",
        "Hostnames": [
          "localhost:60000",
          "localhost:60001"
        ],
        "Theme": "Light"
      },
      {
        "AppName": "Tenant 2",
        "Hostnames": [
          "localhost:60002",
          "localhost:44015"
        ],
        "Theme": "Dark"
      }
    ]
  }
}

No arquivo appsettings.json estamos registrando dois tenants note o nó Hostnames e o nó Theme são configurações para cada tenant. Neste JSON podemos configurar quantas informações quisermos podemos colocar connectionstring, informações de autenticação e etc.

Criando o Multi Theme Application

Agora que está configurado nosso Tenants, vamos dar a opção de temas para eles. Vamos adicionar na raiz do Projeto uma pasta chamada Theme. Dentro desta pasta devemos ter mais duas pasta uma chamada Light e outro chamada Dark. Nas pastas Light e Dark devemos ter mais duas pastas uma chamada Shared e outra chamada Home.

A estrutura deve ser igual a da imagem abaixo:

multitenant

Crie os arquivos conforme mostrado na figura acima.

Vamos abrir o arquivo _Layout.cshtml dentro da pasta Shared em Dark, adicione o codigo abaixo:

@inject AppTenant Tenant
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - @Tenant?.AppName</title>

    <environment names="Development">
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
        <link rel="stylesheet" href="~/css/site.css" />
        <link rel="stylesheet" href="~/css/themes/darkly.css" />
    </environment>
    <environment names="Staging,Production">
        <link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.5/css/bootstrap.min.css"
              asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
              asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" />

        <link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
        <link rel="stylesheet" href="~/css/themes/darkly.css" />
    </environment>
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a asp-controller="Home" asp-action="Index" class="navbar-brand">@Tenant?.AppName</a>
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a asp-controller="Home" asp-action="Index">Home</a></li>
                    <li><a asp-controller="Home" asp-action="About">About</a></li>
                    <li><a asp-controller="Home" asp-action="Contact">Contact</a></li>
                </ul>
            </div>
        </div>
    </div>
    <div class="container body-content">
        @RenderBody()
        <hr />
    </div>

    <environment names="Development">
        <script src="~/lib/jquery/dist/jquery.js"></script>
        <script src="~/lib/bootstrap/dist/js/bootstrap.js"></script>
        <script src="~/js/site.js" asp-append-version="true"></script>
    </environment>
    <environment names="Staging,Production">
        <script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-2.1.4.min.js"
                asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
                asp-fallback-test="window.jQuery">
        </script>
        <script src="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.5/bootstrap.min.js"
                asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js"
                asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal">
        </script>
        <script src="~/js/site.min.js" asp-append-version="true"></script>
    </environment>

    @RenderSection("scripts", required: false)
</body>
</html>

 

Vamos abrir o arquivo _Layout.cshtml dentro da pasta Shared em Light, adicione o codigo abaixo:

@inject AppTenant Tenant
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - @Tenant?.AppName</title>

    <environment names="Development">
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
        <link rel="stylesheet" href="~/css/site.css" />
        <link rel="stylesheet" href="~/css/themes/cerulean.css" />
    </environment>
    <environment names="Staging,Production">
        <link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.5/css/bootstrap.min.css"
              asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
              asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" />

        <link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
        <link rel="stylesheet" href="~/css/themes/cerulean.css" />
    </environment>
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a asp-controller="Home" asp-action="Index" class="navbar-brand">@Tenant?.AppName</a>
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a asp-controller="Home" asp-action="Index">Home</a></li>
                    <li><a asp-controller="Home" asp-action="About">About</a></li>
                    <li><a asp-controller="Home" asp-action="Contact">Contact</a></li>
                </ul>
            </div>
        </div>
    </div>
    <div class="container body-content">
        @RenderBody()
        <hr />
    </div>

    <environment names="Development">
        <script src="~/lib/jquery/dist/jquery.js"></script>
        <script src="~/lib/bootstrap/dist/js/bootstrap.js"></script>
        <script src="~/js/site.js" asp-append-version="true"></script>
    </environment>
    <environment names="Staging,Production">
        <script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-2.1.4.min.js"
                asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
                asp-fallback-test="window.jQuery">
        </script>
        <script src="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.5/bootstrap.min.js"
                asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js"
                asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal">
        </script>
        <script src="~/js/site.min.js" asp-append-version="true"></script>
    </environment>

    @RenderSection("scripts", required: false)
</body>
</html>

Com os arquivos _Layout configurado vamos configurar os arquivos Index.cshtml.

Na pasta Home em Dark coloque o código abaixo:

@{
    ViewData["Title"] = "Home Page";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>

<p>Tema Escuro</p>

Na pasta Home em Light coloque o código abaixo:

@{
    ViewData["Title"] = "Home Page";
}

<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>
<h1>Tema Light</h1>

Por último, vamos abri o arquivo _ViewImports.cshtml e adicionar o seguinte código:

@using MultiTenantCore @* Adicione o namespace do seu projeto *@
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.Http;
@using SaasKit.Multitenancy;
@addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"

E depois abrir o arquivo _ViewStart.cshtml e adicionar o seguinte código:

@{
    Layout = "_Layout";
}

Estamos com o multitema configurado para os nossos Tenants. Vamos agora implementar o View Resolver da nossa aplicação MultiTenant.

Implementação do View Resolver

Com o multitema configurado, devemos agora implementar um ViewResolver, ele será o responsável por guiar nosso Tenants para o tema correto. Crie uma classe chamada TenantViewLocationExpander e adicione o seguinte código:

public class TenantViewLocationExpander : IViewLocationExpander
   {
       private const string THEME_KEY = "theme", TENANT_KEY = "tenant";

       public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
       {
           string theme = null;
           if (context.Values.TryGetValue(THEME_KEY, out theme))
           {
               IEnumerable<string> themeLocations = new[]
               {
                   $"/Theme/{theme}/{{1}}/{{0}}.cshtml",
                   $"/Theme/{theme}/Shared/{{0}}.cshtml"
               };

               string tenant;
               if (context.Values.TryGetValue(TENANT_KEY, out tenant))
               {
                   themeLocations = ExpandTenantLocations(tenant, themeLocations);
               }

               viewLocations = themeLocations.Concat(viewLocations);
           }


           return viewLocations;
       }

       public void PopulateValues(ViewLocationExpanderContext context)
       {
           context.Values[THEME_KEY] = context.ActionContext.HttpContext.GetTenant<AppTenant>()?.Theme;

           context.Values[TENANT_KEY] = context.ActionContext.HttpContext.GetTenant<AppTenant>()?.AppName.Replace(" ", "-");
       }

       private IEnumerable<string> ExpandTenantLocations(string tenant, IEnumerable<string> defaultLocations)
       {
           foreach (var location in defaultLocations)
           {
               yield return location.Replace("{0}", $"{{0}}");
               yield return location;
           }
       }
   }

Nossa aplicação Multi Tenant está pronta, basta agora testar.

Testando o Multi Tenant

Execute a aplicação e para cada Tenant configurado, devemos ver a seguinte imagem:

Caso seja o Primeiro Tenant:

multitenant01

Caso seja o Segundo Tenant:

multitenant02

Nossa aplicação multi tema está funcionando conforme o esperado. SaasKit torna fácil fazer uma aplicação multi tentant pois ele resolve o tenant em cada request e tornando-os e injetando o tenant correto na sua aplicação.

Quer saber mais sobre o SaaSKit, clique aqui.

O código deste exemplo está em meu GitHub através deste link

Abs e até a próxima.

6 Replies to “Construa aplicações Multi-Tenant com ASP.NET Core

  1. Bom dia!
    Rafael estou com um problema, que está me retornando isso.
    O que você acha que pode ser ?
    Obrigado pelo tutorial
    CadccV7.AppTenantResolver.ResolveAsync(HttpContext context) in AppTenantResolver .cs+var tenant = this.Tenants.FirstOrDefault(t =>

    System.ArgumentNullException
    HResult=0x80004003
    Message=Value cannot be null.
    Source=System.Linq
    StackTrace:
    at System.Linq.Enumerable.TryGetFirst[TSource](IEnumerable`1 source, Func`2 predicate, Boolean& found)
    at System.Linq.Enumerable.FirstOrDefault[TSource](IEnumerable`1 source, Func`2 predicate)
    at CadccV7.AppTenantResolver.ResolveAsync(HttpContext context) in C:\Users\Genera\source\repos\CadccV7\CadccV7\AppTenantResolver .cs:line 27
    at SaasKit.Multitenancy.MemoryCacheTenantResolver`1.<SaasKit-Multitenancy-ITenantResolver-ResolveAsync>d__10.MoveNext()

  2. Olá rbento,
    Existe alguma forma de forçar passar novamente no resolver no momento que eu solicitar? seria tipo forçar expirar o cache ao fazer algum tipo de sinalização, entende? Preciso disso para que quando eu fazer alguma alteração nas configurações dos tenants (que está no banco de dados) seja refletido no mesmo momento.

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *