Banco de Dados

4 dez, 2019

MULTI TENANCY: a capacidade de uma aplicação de separar os dados

Publicidade

Hoje vamos falar um pouco de multi tenancy, que nada mais é do que a capacidade de uma aplicação de separar os dados por usuário, ou por qualquer outra definição de “tenant” dentro da plataforma.

Um “tenant” pode ser um usuário, uma empresa ou qualquer coisa que diferencie onde os dados devem ser armazenados. Os casos mais comuns são por usuário ou por empresa, e assim os dados ficam segregados garantindo a confiabilidade e o isolamento.

A aplicabilidade de multi tenancy é comum em aplicações SaaS por exemplo, onde você possui uma plataforma de ecommerce com várias lojas utilizando o mesmo SaaS. Desta forma você consegue segregar os dados de cada loja dentro da sua aplicação. Isso se aplica a ecommerce ou a qualquer outro SaaS onde exista a necessidade de segregação de dados.

Os tipos de multi tenancy são:

  • Banco de dados: Cada tenant tem seu próprio banco de dados isolado dos outros tenants;
  • Banco de dados compartilhado, separado por schema: Cada tenant tem um schema dentro de um banco de dados compartilhado entre os tenants;
  • Banco de dados compartilhado e schema compartilhado: Tanto o banco de dados quanto o schema são compartilhados, todas as tabelas possuem uma coluna que irá identificar o tenant.

Agora partiremos para a implementação do tenant, e a primeira coisa a fazer é criar uma classe de configuração para que você consiga definir todos os seus datasources separados por tenant. A sua configuração deve ficar assim:

spring:
  datasources:
    -
      name: db1
      url: jdbc:mysql://localhost:3306/db1
      username: admin
      password: admin
      driver-class-name: com.mysql.jdbc.Driver
    -
      name: db2
      url: jdbc:mysql://localhost:3306/db2
      username: admin
      password: admin
      driver-class-name: com.mysql.jdbc.Driver
 
flyway:
  baseline-on-migrate: true
A seguir é preciso criar as classes que irão receber a configuração do yaml para que possamos definir os nossos datasources. Vamos precisar de duas classes, onde a primeira conterá uma lista de datasources e a segunda os detalhes desse datasource como mostra o exemplo abaixo:
package br.com.tenant.example.system.properties;
 
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
 
import java.util.ArrayList;
import java.util.List;
 
@Data
@Component
@ConfigurationProperties(prefix = "spring")
public class DataSourceProperties {
    private List datasources = new ArrayList();
}

 

package br.com.tenant.example.system.properties;
 
import lombok.Data;
 
@Data
public class DataSourceProperty {
 
   private String name;
   private String url;
   private String username;
   private String password;
   private String driverClassName;
}

Agora que você já consegue colocar todos os data sources em uma lista dentro da classe DataSourceProperties, o próximo passo é criar uma classe onde o “tenant” será armazenado no ThreadLocal. A sua classe deve se parecer com a classe abaixo:

package br.com.tenant.example.system;
 
public class TenantLocalStorage {
 
    private static ThreadLocal<String> tenant = new ThreadLocal<>();
 
    public static void setTenantName(String tenantName) {
        tenant.set(tenantName);
    }
 
    public static String getTenantName() {
        return tenant.get();
    }
}

Após definir a classe TenantLocalStorage você deve criar uma classe que extenda AbstractRoutingDataSource do Spring para que possa ser definido qual datasource deve ser utilizado no momento da comunicação com o banco de dados. A classe deve ser igual a classe abaixo:

package br.com.tenant.example.system;
 
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
 
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
 
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantLocalStorage.getTenantName();
    }
}

Feito isso é hora de você criar a classe de configuração do Spring Data, ela deve ficar igual a classe abaixo:

package br.com.tenant.example.system;
 
import br.com.tenant.example.system.properties.DataSourceProperties;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.annotation.EnableTransactionManagement;
 
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
 
@Configuration
@EnableTransactionManagement
@EntityScan(basePackages = {"br.com.tenant"})
@EnableJpaRepositories(basePackages = {"br.com.tenant"})
public class DomainConfiguration {
 
    @Bean
    @Primary
    public DataSource dataSource(DataSourceProperties dataSourceProperties) {
        AbstractRoutingDataSource tenantRoutingDataSource = new TenantRo<span                data-mce-type="bookmark"                id="mce_SELREST_start"              data-mce-style="overflow:hidden;line-height:0"              style="overflow:hidden;line-height:0"           ></span>utingDataSource();
        Map<Object, Object> targetDataSources = new HashMap<>();
        dataSourceProperties.getDatasources().forEach(dataSourceProperty -> {
            DataSource dataSource = DataSourceBuilder.create()
              .url(dataSourceProperty.getUrl())
              .username(dataSourceProperty.getUsername())
              .password(dataSourceProperty.getPassword())
              .driverClassName(dataSourceProperty.getDriverClassName())
              .build();
           if (dataSourceProperty.getName().equalsIgnoreCase("db1")) {
              tenantRoutingDataSource.setDefaultTargetDataSource(dataSource);
           }
           targetDataSources.put(dataSourceProperty.getName(), dataSource);
        });
        tenantRoutingDataSource.setTargetDataSources(targetDataSources);
        tenantRoutingDataSource.afterPropertiesSet();
        return tenantRoutingDataSource;
    }
}

Você pode ver que essa classe itera a lista de datasources e vai adicionando cada datasource a classe TenantRoutingDataSource, assim baseado no que está setado no TenantLocalStorage é possível trocar automaticamente entre esses datasources.

Por fim, o último passo é definir um filtro para que a cada requisição seja possível identificar o tenant e setar ele no TenantLocalStorage, o filtro deve ser igual ao definido abaixo:

package br.com.tenant.example.system;
 
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
 
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
 
/**
* @author Felipe Adorno felipeadsc@gmail.com
*/
class TenantFilter extends GenericFilterBean {
 
    @Override
    public void doFilter(ServletRequest servletRequest,
         ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String tenantId = httpServletRequest.getHeader("tenantId");
        if(StringUtils.isEmpty(tenantId)) {
            TenantLocalStorage.setTenantName("db1");
        } else {
            TenantLocalStorage.setTenantName(tenantId);
        }
        filterChain.doFilter(servletRequest, servletResponse);
   }
}

E para que o filtro seja configurado quando a sua aplicação rodar, basta criar a seguinte classe de configuração:

package br.com.tenant.example.system;
 
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class TenantConfiguration {
 
    @Bean
    public FilterRegistrationBean dawsonApiFilter() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new TenantFilter());
        return registration;
    }
}

Para facilitar ainda mais você pode usar o Flyway para gerenciar as criações do seus schemas, para isso basta criar a seguinte classe:

package br.com.tenant.example.system;
 
import br.com.tenant.example.system.properties.DataSourceProperties;
import lombok.AllArgsConstructor;
import org.flywaydb.core.Flyway;
import org.springframework.context.annotation.Configuration;
 
import javax.annotation.PostConstruct;
 
@Configuration
@AllArgsConstructor
public class FlywaySchemaInitializer {
 
    private final TenantRoutingDataSource dataSource;
    private final DataSourceProperties dataSourceProperties;
 
    @PostConstruct
    public void migrateFlyway() {
        dataSourceProperties.getDatasources().forEach(dataSourceProperty -> {
            if(!dataSourceProperty.getName().contains("db1")) {
                TenantLocalStorage.setTenantName(dataSourceProperty.getName());
                Flyway flyway = new Flyway();
                flyway.setDataSource(dataSource);
                flyway.migrate();
            }
        });
        TenantLocalStorage.setTenantName("db1");
    }
}

Com isso você precisa apenas criar um script sql que crie o seu schema algo como “create schema foo” e o Flyway se encarrega de criar e aplicar todos os migrations.

Para fazer os testes basta enviar requisições para o endpoint com o header tenantId com o valor db1 ou db2 e assim os dados são roteados entre os bancos de dados baseado nesse header, veja o exemplo abaixo:

curl -H "tenantId: db1" -H "Content-Type: application/json" -X POST -d "{\"name\" : \"Product db1\", \"description\" : \"description of product in db1\"}" http://localhost:8080/v1/products
{"id":1,"name":"Product db1","description":"description of product in db1"}
 
curl -H "tenantId: db2" -H "Content-Type: application/json" -X POST -d "{\"name\" : \"Product db2\", \"description\" : \"description of product in db2\"}" http://localhost:8080/v1/products
{"id":1,"name":"Product db2","description":"description of product in db2"}

 

curl -H "tenantID: db1" -X GET http://localhost:8080/v1/products
{"content":[{"id":1,"name":"Product db1","description":"description of product in db1"}],"last":true,"totalElements":1,"totalPages":1,"sort":null,"first":true,"numberOfElements":1,"size":20,"number":0}
 
curl -H "tenantID: db2" -X GET http://localhost:8080/v1/products
{"content":[{"id":1,"name":"Product db2","description":"description of product in db2"}],"last":true,"totalElements":1,"totalPages":1,"sort":null,"first":true,"numberOfElements":1,"size":20,"number":0}

O exemplo completo você pode ver aqui.

Até a próxima!