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.

2 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()

Deixe uma resposta

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