Criando aplicação web para vários clientes em bancos separados com Laravel

É muito comum ao criar uma aplicação web que tenhamos a intenção de liberá-la como um serviço, ou seja, o cliente irá pagar um valor periódico (mensal, semestral, etc.) pelos serviços que o software disponibiliza e não pelo software em si, isso é comumente conhecido como SaaS (Software as a service). Porém, com isso vem o desafio de gerenciar e distribuir a aplicação para esses clientes.

Um conceito muito conhecido nessa situação é o Multi Tenancy (ou Multi Tenant), ou seja, a aplicação é alugada por vários clientes e cada cliente é um inquilino (tenant). Basicamente, há duas formas de aplicar o Multi Tenancy, com banco de dados compartilhado (Single Database) e com banco de dados separado (Multi Database), cada uma possui suas vantagens e desvantagens.

Neste artigo veremos como implementar a forma com banco de dados separado. Para isso, iremos criar uma aplicação com Laravel em que cada inquilino irá acessar a aplicação através de um subdomínio próprio e através desse subdomínio iremos fazer a conexão no banco de dados específico dele. Além disso, iremos criar comandos para facilitar a criação de um novo inquilino e também para atualizar a estrutura do banco de dados.

Animação 1 – Resultado final

Código fonte disponível em: https://github.com/rafaelcouto/criando-aplicacao-web-para-varios-clientes-em-bancos-separados-com-laravel

Diferenças entre as formas de Multi Tenancy

Banco de dados compartilhado (Single Database): Utilizamos o mesmo banco de dados para todos os inquilinos e separamos os dados através de uma coluna com o código do inquilino.

  • Vantagens
    • Criação de novo inquilino muito mais simples, pois basta adicionar um registro na tabela;
    • Facilidade de manutenção, pois há apenas uma estrutura de banco para atualizar.
  • Desvantagens
    • Preocupação em filtrar o inquilino em cada query, pois se houver uma falha pode haver vazamento de dados de um inquilino para outro;
    • Preocupação de desempenho das querys, pois um inquilino pode ter poucos registros e outro pode ter milhões;
    • Dificuldade em escalar, pois se o banco de dados começar a crescer muito, a única saída seria aumentar os recursos do servidor.

Banco de dados separado (Multi Database): Cada inquilino tem o seu próprio banco de dados. Nesse caso pode ser apenas em schemas separados ou até mesmo com banco de dados em servidores diferentes.

  • Vantagens
    • Os dados do inquilino ficam isolados dos outros, sem a necessidade de termos que nos preocupar em filtrá-lo a cada query;
    • Mais fácil para escalar, pois cada banco de dados pode estar em um servidor completamente diferente;
    • Se o inquilino quiser parar de usar a aplicação ou mudar de servidor fica muito mais fácil entregar os dados para ele.
  • Desvantagens
    • Atualização de estrutura e backup pode ser demorado, pois precisa ser aplicados em todos os bancos de dados e se ocorrer erro em algum deles, precisará ser analisado individualmente;
    • Necessidade de criar uma estrutura para criação de novos inquilinos, pois sempre que criar um inquilino novo será preciso criar um banco de dados.

Preparando o ambiente

Para conseguirmos executar os códigos deste artigo, iremos precisar dos seguintes recursos.

  • PHP 7.3+ (requerido pelo Laravel 8);
  • Composer;
  • Apache (pode ser nginx também, porém será necessário adaptar algumas coisas);
  • MySQL ou MariaDB.

Banco de dados

Neste artigo vamos utilizar o MySQL, porém poderíamos utilizar qualquer outro banco de dados que o Laravel dê suporte. Só é interessante que todos os inquilinos utilizem o mesmo banco de dados para não ficar mais complexo.

Precisamos então criar um banco de dados principal (main) onde teremos centralizado os inquilinos (tenants) da aplicação e os dados de acesso ao banco de dados de cada um.

create schema main collate utf8mb4_general_ci;

create table main.tenants
(
	id VARCHAR(40) not null primary key,
	name VARCHAR(80) not null,
	host VARCHAR(255) not null,
	port VARCHAR(5) not null,
	database_name VARCHAR(60) not null,
	username VARCHAR(60) not null,
	password VARCHAR(250) not null,
    created_at timestamp not null,
    updated_at timestamp
);

É nesta tabela que iremos gerenciar os inquilinos que estão utilizando a aplicação. Podemos, por exemplo, criar uma coluna de status para definir se ele está liberado para utilizar o sistema ou não. O ideal é termos uma outra aplicação somente para gerenciamento dos inquilinos, porém com o artigo é para fins didáticos vamos fazer em uma única aplicação para ficar mais simples.

Laravel

Vamos então iniciar um novo projeto Laravel utilizando o Composer. Enquanto escrevo este artigo, a versão 8 é a mais recente, portanto é a que iremos utilizar.

composer create-project laravel/laravel app "8.*"

Existem diversas bibliotecas para facilitar a criação de aplicações Multi Tenancy, porém não é muito difícil implementar o conceito com o Laravel sem a necessidade de uma biblioteca adicional, visto que ele possui todos os recursos necessários e dessa forma podemos personalizar de acordo com nossa necessidade.

Apache

Para que possamos testar a aplicação localmente, precisamos criar um Virtual Host (ou Server Block no caso do nginx), assim poderemos acessar a aplicação através do endereço http://app.test e também criar os subdomínios (http://tenant1.app.test, por exemplo).

Se você estiver em ambiente Windows, recomendo a utilização do Laragon, pois assim que você criar o projeto Laravel, ele irá criar o Virtual Host automaticamente. Caso você esteja em ambiente Linux ou WSL (Windows Subsystem for Linux), basta ter o Apache instalado e então criar o arquivo de configuração.

sudo nano /etc/apache2/sites-available/app.conf
<VirtualHost *:80>

    ServerName app.test
    ServerAlias *.app.test

    ErrorLog ${APACHE_LOG_DIR}/app.error.log
    CustomLog ${APACHE_LOG_DIR}/app.access.log combined

    DocumentRoot /home/rafael/projetos/app/public

    <Directory /home/rafael/projetos/app/public>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

</VirtualHost>
  • Linha 3: ServerName é o identificador do endereço da aplicação. É através dessa diretiva que o Apache vai saber que deve utilizar essas configurações;
  • Linha 4: ServerAlias permite definir endereços alternativos para o endereço base, no caso utilizamos o wildcard (*), portanto essas configurações irão servir também para qualquer subdomínio, por exemplo, tenant1.app.test, tenant2.app.test, etc;
  • Linha 9: DocumentRoot indica o caminho dos arquivos que serão visíveis na web. Aqui você deve substituir pelo caminho da pasta public da aplicação;
  • Linha 11: Definimos algumas configurações adicionais para o diretório. Aqui você também deve substituir pelo caminho da pasta public da aplicação;

Basta então ativar a configuração e reiniciar o Apache.

sudo a2ensite app.conf
sudo service apache2 restart

Em um ambiente de produção isso seria o mínimo suficiente, pois o domínio da aplicação iria apontar para o servidor e o Apache faria o direcionamento para a pasta da aplicação. Porém, para que possamos testar localmente, precisamos fazer o redirecionamento no arquivo de hosts do sistema operacional. Em ambiente Linux esse arquivo fica em /etc/hosts e no caso de Windows fica em C:\Windows\System32\drivers\etc\hosts. Basta editar esse arquivo e adicionar o redirecionamento. No caso vamos adicionar apenas o domínio da aplicação e o subdomínio de tenant1 e tenant2 para conseguirmos fazer os testes.

127.0.0.1 app.test
127.0.0.1 tenant1.app.test
127.0.0.1 tenant2.app.test

Caso você esteja usando WSL, recomendo fazer da seguinte forma.

127.0.0.1 app.test
::1 app.test

127.0.0.1 tenant1.app.test
::1 tenant1.app.test

127.0.0.1 tenant2.app.test
::1 tenant2.app.test

Pronto, agora se acessarmos o endereço http://app.test, http://tenant1.app.test ou http://tenant2.app.test deveríamos ver a página inicial do Laravel, assim como na Animação 2.

Animação 2 – Acessando aplicação localmente

Configurando a aplicação

A conexão padrão da aplicação será sempre no banco de dados principal, pois a ideia é alternar a conexão para o banco de dados do inquilino em tempo de execução. Portanto, vamos editar o arquivo config/database.php e em connections[] vamos alterar o nome da chave mysql para main e também apagar as outras conexões que o Laravel traz por padrão.

'connections' => [
    'main' => [
        'driver' => 'mysql',
        'url' => env('DATABASE_URL'),
        'host' => env('DB_HOST', '127.0.0.1'),
        'port' => env('DB_PORT', '3306'),
        'database' => env('DB_DATABASE', 'forge'),
        'username' => env('DB_USERNAME', 'forge'),
        'password' => env('DB_PASSWORD', ''),
        'unix_socket' => env('DB_SOCKET', ''),
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix' => '',
        'prefix_indexes' => true,
        'strict' => true,
        'engine' => null,
        'options' => extension_loaded('pdo_mysql') ? array_filter([
            PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
        ]) : [],
    ],
],

Vamos editar o arquivo config/app.php e adicionar uma configuração para o domínio que iremos precisar mais a frente no artigo para descobrir o inquilino pelo endereço.

'domain' => env('APP_DOMAIN'),

Precisamos então editar (ou adicionar) o arquivo .env na raiz da aplicação para informar os dados de acesso para o banco de dados principal e também o domínio da aplicação.

APP_DOMAIN=app.test

DB_CONNECTION=main
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=main
DB_USERNAME=root
DB_PASSWORD=root

Model

Vamos então criar um Model para representar um inquilino (tenant).

php artisan make:model Tenant
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tenant extends Model
{
    protected $connection = 'main';
    
    protected $fillable = [
        'id',
        'name',
        'host',
        'port',
        'database_name',
        'username',
        'password'
    ];
}

O inquilino sempre fará parte do banco de dados principal, portanto informamos main na propriedade $connection para que use a conexão main definida anteriormente. Também informamos os atributos que são preenchíveis (fillable) para que fique mais fácil criá-lo, como veremos mais para frente no artigo.

Vamos criar também um Model para representar a empresa (company) do inquilino. Criaremos apenas para ter uma tabela no banco de dados do inquilino, assim poderemos verificar se estamos conectando no banco de dados correto.

php artisan make:model Company
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Company extends Model
{
    protected $connection = 'tenant';
}

A empresa fará parte do banco de dados do inquilino, portanto informamos tenant na propriedade $connection para que use a conexão tenant que veremos em seguida. Isso deverá ser feito para qualquer Model que represente uma tabela do banco de dados do inquilino.

Classe utilitária

Precisamos também criar uma classe utilitária que será responsável por fazer a conexão com o banco de dados do inquilino em tempo de execução.

<?php

namespace App\Util;

use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
use App\Models\Tenant;

class TenantConnector
{
    public static function connect(Tenant $tenant)
    {
        DB::purge('tenant');

        $config = Config::get('database.connections.main');
        $config['host'] = $tenant->host;
        $config['port'] = $tenant->port;
        $config['database'] = $tenant->database_name;
        $config['username'] = $tenant->username;
        $config['password'] = Crypt::decrypt($tenant->password);

        Config::set("database.connections.tenant", $config);
    }

}
  • Linha 14: Caso já tenha uma conexão tenant, “destruímos” ela. Isso é necessário caso troquemos para mais de um inquilino na mesma requisição, caso que irá acontecer quando criarmos o comando para fazer as alterações de estrutura no banco de dados que veremos mais para frente;
  • Linha 16 a 21: Como o banco de dados principal e o banco de dados do inquilino vão ser MySQL, copiamos a conexão main e então atualizamos com os dados de acesso do inquilino;
  • Linha 23: Definimos a conexão tenant. Isso seria o equivalente a entrar no arquivo config/database.php e adicionar uma chave tenant em connections[] com as configurações de acesso ao banco de dados dele.

Middleware

O próximo passo é a criação de um Middleware, pois para cada requisição à aplicação será necessário descobrir quem é o inquilino e então fazer a conexão no banco de dados dele.

php artisan make:middleware TenantHandler
<?php

namespace App\Http\Middleware;

use App\Models\Tenant;
use App\Util\TenantConnector;
use Closure;
use Illuminate\Http\Request;

class TenantHandler
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = Tenant::findOrFail($request->tenant);

        TenantConnector::connect($tenant);
        
        return $next($request);
    }
}

Selecionamos o inquilino através do id, que será definido na propriedade $request->tenant quando criarmos a rota mais adiante, e então fazemos a conexão no banco de dados dele através do TenantConnector que criamos anteriormente.

Precisamos então declarar o Middleware na propriedade $routeMiddleware do arquivo app/Http/Kernel.php para que possamos utilizá-lo na criação da rota.

protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
        'tenant' => \App\Http\Middleware\TenantHandler::class,
    ];

Caso você queira aplicar o Middleware em todas as rotas da aplicação, basta declará-la na propriedade $middleware ou então no $middlewareGroups para as rotas de web e/ou api.

Rota

Por fim, vamos criar uma rota para testar a aplicação.

<?php

use Illuminate\Support\Facades\Route;
use App\Models\Company;

Route::group(['domain' => '{tenant}.' . config('app.domain'), 'middleware' => 'tenant'], function () {

    Route::get('/', function () {
        $company = Company::firstOrFail();
        return view('tenant', ['company' => $company]);
    });

});
  • Linha 6: Criamos um agrupamento de rota, ou seja, as configurações que passarmos aqui serão aplicadas em todas as rotas dentro deste grupo. Através do recurso de Subdomain Routing definimos que esse grupo de rota será apenas para o subdomínio informado, que no caso seria *.app.test. Além disso capturamos o subdomínio através do {tenant}, dessa forma o Laravel irá armazenar o valor do subdomínio na Request, permitindo acessarmos através do $request->tenant como vimos no Middleware. Por fim, aplicamos o Middleware para que faça a conexão no banco de dados do inquilino;
  • Linha 8 a 11: A partir daqui você pode desenvolver a aplicação normalmente, pois ela já está conectada no banco de dados do inquilino. Neste caso selecionamos a primeira empresa e mostramos os dados em uma View.

Adicionando um novo inquilino

Construímos a aplicação para suportar vários inquilinos, porém do jeito que está, sempre que aparecer um novo inquilino teríamos que criar um banco de dados manualmente para ele. Podemos então criar um Command para automatizar esse processo.

Antes de criar o Command, vamos adicionar uma Migration para criar a tabela de empresa (company).

php artisan make:migration create_companies_table
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCompaniesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('companies', function (Blueprint $table) {
            $table->id();
            $table->string('name', 80);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('companies');
    }
}

Criamos apenas uma tabela com o nome da empresa para identificarmos o inquilino em seu banco de dados.

Agora sim, vamos criar o Command.

php artisan make:command CreateTenant
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Crypt;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use App\Models\Company;
use App\Models\Tenant;
use App\Util\TenantConnector;

class CreateTenant extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'create-tenant {name} {--host=127.0.0.1} {--port=3306} {--username=root}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Create a new Tenant';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $tenant = $this->getTenant();

        $this->info('Creating database');
        $this->createDatabase($tenant);

        $tenant->save();

        $this->info('Connecting in tenant database');
        TenantConnector::connect($tenant);

        $this->info('Starting migrations');
        $this->call('migrate', ['--database' => 'tenant']);

        $this->info('Creating tenant company');
        $this->createCompany($tenant);
    }

    /**
     * @return Tenant
     */
    private function getTenant()
    {
        $data = $this->options();
        $data['name'] = $this->argument('name');
        $data['id'] = $data['database_name'] = Str::slug($data['name'], '');
        $data['password'] = Crypt::encrypt($this->secret('Input the database password'));

        $tenant = new Tenant();
        $tenant->fill($data);

        return $tenant;
    }

    /**
     * @return void
     */
    private function createDatabase(Tenant $tenant)
    {
        $process = new Process([
            'mysql', 
            "-h{$tenant->host}",
            "-P{$tenant->port}",
            "-u{$tenant->username}",
            "-p" . Crypt::decrypt($tenant->password),
            "-e CREATE DATABASE IF NOT EXISTS {$tenant->database_name} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
        ]);

        $process->run();

        if (!$process->isSuccessful()) {
            throw new ProcessFailedException($process);
        }
    }

    /**
     * @return void
     */
    private function createCompany(Tenant $tenant)
    {
        $company = new Company();
        $company->name = $tenant->name;
        $company->save();
    }

}
  • Linha 21: Declaramos a estrutura do comando sendo o único argumento obrigatório o nome do inquilino (name), as informações de acesso ao banco de dados (host, port e username) deixamos como opções com valor padrão. Portanto, para criar um inquilino no banco de dados local podemos utilizar php artisan create-tenant "Tenant 1", porém poderíamos também fazer da seguinte forma para criar em um banco de dados remoto php artisan create-tenant "Tenant 1" --host=167.71.253.102 --username=rafael;
  • Linha 45 a 62: Obtemos o inquilino (getTenant()) com base nos argumentos e opções informados. Criamos o banco de dados (createDabase()) e se der tudo certo salvamos o inquilino no banco de dados principal. Conectamos no banco de dados criado através do TenantConnector e rodamos as Migrations. Por fim, criamos a empresa do inquilino (createCompany());
  • Linha 67 a 78: Tanto o id quanto o nome do banco de dados vão ser um slug do nome do inquilino, ou seja, se informado o nome Tenant 1, o slug será tenant1. Para maior segurança, solicitamos a senha através do método secret() e então criptografamos ela. Por fim, criamos o Model do inquilino, preenchemos as informações através do fill() e retornamos ele;
  • Linha 83 a 99: Para criar o banco de dados, precisamos de acesso ao mysql em linha de comando. Dessa forma montamos o comando através do componente Process, que vai gerar o comando (por exemplo, 'mysql' '-h127.0.0.1' '-P3306' '-uroot' '-proot' '-e CREATE DATABASE IF NOT EXISTS tenant1 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;') e então executá-lo. Lembrando que se o banco de dados for externo à aplicação ele deverá permitir o acesso externo.

Pronto, agora basta rodarmos o comando para criar os dois inquilinos de acordo com os subdomínios que definimos, assim como na Animação 3.

php artisan create-tenant "Tenant 1"
php artisan create-tenant "Tenant 2"
Animação 3 – Adicionando novos inquilinos

Alterando estrutura do banco de dados

Precisamos também criar um Command para fazer as alterações de estrutura, pois se rodarmos php artisan migrate as Migrations serão rodadas no banco de dados principal e não nos bancos de dados dos inquilinos.

php artisan make:command MigrateTenant
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Util\TenantConnector;
use App\Models\Tenant;

class MigrateTenant extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'migrate-tenant {--rollback} {--tenant=}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Execute migration for all tenants';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }   

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $tenants = $this->getTenants();

        foreach ($tenants as $tenant) {

            $this->comment("\n" . $tenant->name);

            try {
                TenantConnector::connect($tenant);
                $this->call($this->getMigrateCommand(), ['--database' => 'tenant']);
            } catch (\Exception $e) {
                $this->error($e->getMessage());
            }
        }
    }

    /**
     * @return Tenant[]
     */
    private function getTenants() {

        $query = Tenant::query();

        if ($this->option('tenant')) {
            $query->where('id', $this->option('tenant'));
        }

        return $query->get();
    }

    /**
     * @return string
     */
    private function getMigrateCommand() {
        
        $command = 'migrate';

        if ($this->option('rollback')) {
            $command = ':rollback';
        }

        return $command;
    }

}
  • Linha 16: A ideia é rodarmos php artisan migrate-tenant e executar as Migrations para todos os inquilinos. Podemos também rodar apenas para um inquilino específico passando o id na opção --tenant. E também vamos ter a opção de --rollback, assim como no comando de migrate padrão;
  • Linha 40 a 55: Obtemos os inquilinos, passamos por cada um deles, conectamos no banco de dados dele e então chamamos o comando php artisan migrate --database=tenant, assim as Migrations serão rodadas de acordo com a conexão tenant;
  • Linha 60 a 69: Se houver a opção --tenant no comando, filtramos apenas o inquilino especifico através do id;
  • Linha 74 a 83: Se houver a opção --rollback, montamos o comando como migrate:rollback, para que assim faça o rollback da alterações.

Para testarmos, vamos criar uma tabela de clientes (clients) no banco de dados do inquilino.

php artisan make:migration create_clients_table
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateClientsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('clients', function (Blueprint $table) {
            $table->id();
            $table->string('name', 80);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('clients');
    }
}

E então vamos utilizar o Command que acabamos de criar para executar as Migrations para todos os inquilinos, assim como na Animação 4.

php artisan migrate-tenant
Animação 4 – Alterando estrutura dos bancos de dados

Configurando o servidor da aplicação

Até então fizemos tudo localmente, porém como faremos para que o subdomínio funcione com a aplicação em produção? Da maneira que está, sempre que adicionarmos um novo inquilino, precisamos ir no gerenciador de DNS do domínio da aplicação e criar um registro tipo A apontando para o servidor da aplicação. E se utilizarmos SSL (https://), teríamos ainda que gerar um certificado para esse subdomínio. Mas muita calma, podemos fazer de uma maneira automatizada, utilizando o wildcard (*).

O primeiro passo é entrarmos no gerenciador de DNS do domínio da aplicação para fazer o apontamento do subdomínio para o IP do servidor da aplicação, assim como na Imagem 1. No meu caso, o domínio está sendo gerenciado pela Digital Ocean, porém o conceito é o mesmo para qualquer outro gerenciador de DNS.

Imagem 1 – Configuração de DNS

Neste caso, o endereço base da aplicação será tenants.rafaelcouto.com.br, então utilizamos o wildcard (*) para que qualquer subdomínio seja direcionado para o servidor da aplicação, como tenant1.tenants.rafaelcouto.com.br, tenant2.tenants.rafaelcouto.com.br, etc.

Basta então criarmos um Virtual Host no Apache (da mesma forma que fizemos localmente) e habilitá-lo.

<VirtualHost *:80>
    ServerName tenants.rafaelcouto.com.br
    ServerAlias *.tenants.rafaelcouto.com.br
    ErrorLog ${APACHE_LOG_DIR}/tenants.error.log
    CustomLog ${APACHE_LOG_DIR}/tenants.access.log combined
    DocumentRoot /home/rafael/app/public

    <Directory /home/rafael/app/public>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>
sudo a2ensite tenants.conf
sudo service apache2 restart

Isso já seria o suficiente para um ambiente sem SSL (http://). Para habilitar o SSL, podemos utilizar o Certbot. Não vamos entrar muito em detalhes, pois não é o foco deste artigo, mas caso queira saber mais pode ver este artigo.

Neste caso, como o domínio possui o wildcard, precisamos fazer da seguinte maneira.

sudo certbot --server https://acme-v02.api.letsencrypt.org/directory -d *.tenants.rafaelcouto.com.br --manual --preferred-challenges dns-01 certonly

O Certbot irá pedir para criarmos um registro do tipo TXT no gerenciador de DNS com um nome e um valor. Precisamos então entrar no gerenciador de DNS e adicionar esse registro.

Imagem 2 – Adição de registro do tipo TXT no gerenciador de DNS

Após isso basta continuarmos e então o Certbot irá gerar o certificado para nós. Basta então criarmos um novo arquivo de configuração para SSL, informar o certificado e habilitá-lo.

<IfModule mod_ssl.c>
<VirtualHost *:443>

    ServerName tenants.rafaelcouto.com.br
    ServerAlias *.tenants.rafaelcouto.com.br
    ErrorLog ${APACHE_LOG_DIR}/tenants.error.log
    CustomLog ${APACHE_LOG_DIR}/tenants.access.log combined
    DocumentRoot /home/rafael/app/public

    <Directory /home/rafael/app/public>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    SSLCertificateFile /etc/letsencrypt/live/tenants.rafaelcouto.com.br/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/tenants.rafaelcouto.com.br/privkey.pem
    Include /etc/letsencrypt/options-ssl-apache.conf

</VirtualHost>
</IfModule>
sudo a2ensite tenants-le-ssl.conf
sudo service apache2 restart

E pronto! Agora é só acessarmos https://tenant1.tenants.rafaelcouto.com.br e https://tenant2.tenants.rafaelcouto.com.br para verificar se deu certo.

Animação 5 – Testando aplicação em produção

Conclusão

Neste artigo vimos uma das formas de criar uma aplicação Multi Tenancy utilizando Laravel, apenas com os recursos do framework, sem a necessidade de uma biblioteca adicional.

Para uma aplicação de pequeno porte, talvez a forma com banco de dados compartilhado (Single Database) seja mais apropriada. Porém, se for uma aplicação de pequeno porte que tende a crescer ou então uma aplicação de médio ou grade porte, acredito que essa forma com bancos de dados separados (Multi Database) seja a melhor forma, pois podemos escalar melhor a aplicação.

Separamos os inquilinos através do subdomínio, porém essa não é a única estratégia. Poderíamos ter um único endereço para a aplicação, porém ao fazer login o usuário informa a empresa dele além do usuário e senha, dessa forma poderíamos adicionar o identificador do tenant na sessão dele e adaptar o middleware para testar a sessão ao invés do subdomínio. No caso de um SPA (Single Page Application), poderíamos enviar o identificador do tenant no header a cada requisição também.

Enfim, espero que este artigo tenha sido útil de alguma forma. Até a próxima.

Código fonte disponível em: https://github.com/rafaelcouto/criando-aplicacao-web-para-varios-clientes-em-bancos-separados-com-laravel

Referências