Multi-tenancy com Hibernate e Spring Boot

Por Gaspar Barancelli Junior em 04 de abril de 2024

O que é Multi-tenancy?

O termo multi-tenancy é aplicado ao desenvolvimento de software para indicar uma arquitetura na qual uma única instância em execução da aplicação atende simultaneamente a vários clientes. Essa arquitetura é muito comum em soluções Saas (Software as a Service) para isolar os dados do cliente.

Abordagens

Existem três abordagens para isolar os dados nessa arquitetura.

DATABASE (Banco de dados separado)

Cada cliente possui uma instância do banco de dados separada fisicamente.

1
SCHEMA (Esquema separado)

Em uma única instância do banco de dados cada cliente possui um schema.

2
DISCRIMINATOR (Dados particionados)

Todos os dados são mantidos no mesmo schema em uma única instância do banco de dados, mas cada tabela do banco possui uma coluna para identificação do cliente.

3

Criando aplicação Spring Boot

O primeiro passo é criar nosso projeto Spring Boot, para isso acesse o seguinte endereço https://start.spring.io/ e adicione as seguintes dependências.

  • Spring Web

  • Spring Data JPA

  • h2 Database

4

Após adicionar as dependências clique em GENERATE e faça o download do seu projeto, na sequencia descompacte o mesmo e abra em sua IDE favorita.

Configurando a conexão com o banco de dados H2

Adicione as configuração abaixo no arquivo application.properties.

spring.datasource.url=jdbc:h2:file:./data/blog
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.initialization-mode=always

Para mais detalhes de como configurar o banco de dados H2 em projetos Spring Boot acesse esse post.

Configurando a abordagem de SCHEMA no Projeto

Segundo a documentação do Hibernate ao optarmos pela abordagem de Schema, devemos implementar a interface MultiTenantConnectionProvider, pois ela é responsável por realizar as alterações nas conexões.

import org.hibernate.HibernateException;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.stereotype.Component;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

@Component
public class MultiTenantConnectionProviderImpl implements MultiTenantConnectionProvider {

    private final DataSource dataSource;

    public MultiTenantConnectionProviderImpl(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Connection getAnyConnection() throws SQLException {
        return dataSource.getConnection();
    }

    @Override
    public void releaseAnyConnection(Connection connection) throws SQLException {
        connection.close();
    }

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        final Connection connection = getAnyConnection();
        try {
            connection.createStatement().execute("USE " + tenantIdentifier);
        } catch (SQLException e) {
            throw new HibernateException("Não foi possivel alterar para o schema [" + tenantIdentifier + "]", e);
        }
        return connection;
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
        try (connection) {
            connection.createStatement().execute("USE " + TenantContext.DEFAULT_TENANT);
        } catch (SQLException e) {
            throw new HibernateException("Não foi se conectar ao schema padrão", e);
        }
    }

    @Override
    public boolean isUnwrappableAs(Class unwrapType) {
        return false;
    }

    @Override
    public <T> T unwrap(Class<T> unwrapType) {
        return null;
    }

    @Override
    public boolean supportsAggressiveRelease() {
        return true;
    }

}

Também devemos implementar a interface CurrentTenantIdentifierResolver, pois é através dela que o Hibernate vai ser capaz de obter o Tenant atual.

import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;

@Component
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {

    @Override
    public String resolveCurrentTenantIdentifier() {
        return TenantContext.getCurrentTenant();
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }

}

Agora vamos criar uma classe responsável por interceptar todas as requisições HTTP feitas pelos usuários, obtendo o parâmetro schema recebido no header da requisição e alterando o Tenant do usuário, mas caso o usuário não informe o schema no header da requisição, o sistema utilizara o schema padrão.

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;

@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        Optional.ofNullable(req.getHeader("schema"))
                .map(String::toUpperCase)
                .ifPresent(TenantContext::setCurrentTenant);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        TenantContext.clear();
    }

}

As implementações acima fizeram uso do objeto TenantContext, este objeto é responsável por armazenar o Tenant utilizado pelo usuário na thread local utilizada na requisição.

public class TenantContext {

    final public static String DEFAULT_TENANT = "CLIENTE_1";

    private static final ThreadLocal<String> currentTenant = ThreadLocal.withInitial(() -> DEFAULT_TENANT);

    public static void setCurrentTenant(String tenant) {
        currentTenant.set(tenant);
    }

    public static String getCurrentTenant() {
        return currentTenant.get();
    }

    public static void clear() {
        currentTenant.remove();
    }

}

A classe a seguir é responsável por configurar o EntityManager, é nesta implementação que definimos a utilização do SCHEMA como abordagem do multi-tenancy e também passamos para o Hibernate as nossas implementações de MultiTenantConnectionProvider e CurrentTenantIdentifierResolver criadas anteriormente.

import org.hibernate.MultiTenancyStrategy;
import org.hibernate.cfg.Environment;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class HibernateConfig {

    private final JpaProperties jpaProperties;

    public HibernateConfig(JpaProperties jpaProperties) {
        this.jpaProperties = jpaProperties;
    }

    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        return new HibernateJpaVendorAdapter();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            DataSource dataSource,
            MultiTenantConnectionProvider multiTenantConnectionProviderImpl,
            CurrentTenantIdentifierResolver currentTenantIdentifierResolverImpl) {
        Map<String, Object> properties = new HashMap<>(jpaProperties.getProperties());
        properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
        properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProviderImpl);
        properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolverImpl);

        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.gasparbarancelli.multitenant.*");
        em.setJpaVendorAdapter(jpaVendorAdapter());
        em.setJpaPropertyMap(properties);
        return em;
    }
}

Por fim, devemos registrar o nosso interceptor utilizado para definir o Tenant do usuário conforme valor recebido pelo parâmetro schema nos headers da requisição.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final TenantInterceptor tenantInterceptor;

    public WebMvcConfig(TenantInterceptor tenantInterceptor) {
        this.tenantInterceptor = tenantInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tenantInterceptor);
    }

}

Testando a implementação

Crie um arquivo chamado data.sql dentro da pasta resources e adicione o seguinte conteúdo.

CREATE SCHEMA IF NOT EXISTS CLIENTE_1;
CREATE SCHEMA IF NOT EXISTS CLIENTE_2;

CREATE TABLE IF NOT EXISTS CLIENTE_1.PERSON (
    ID BIGINT AUTO_INCREMENT NOT NULL,
    NAME VARCHAR(50) NOT NULL
);

CREATE TABLE IF NOT EXISTS CLIENTE_2.PERSON (
    ID BIGINT AUTO_INCREMENT NOT NULL,
    NAME VARCHAR(50) NOT NULL
);

O script acima é executado sempre que a aplicação for inicializada, ele é responsável por criar os schemas CLIENTE_1 e CLIENTE_2 no banco de dados, e também cria a tabela PERSON em cada um dos schemas.

Segue o mapeamento da entidade Person e também de uma classe implementando JpaRepository que nos fornece inúmeros métodos para manipulação da entidade ao banco de dados.

import javax.persistence.*;
import java.util.Objects;

@Entity
@Table(name = "PERSON")
public class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private Long id;

    @Column(name = "NAME", nullable = false, length = 50)
    private String name;

    @Deprecated
    public Person() {
    }

    public Person(String name) {
        setName(name);
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Person setName(String name) {
        this.name = Objects.requireNonNull(name, "name must not be null");
        return this;
    }

    @Override
    public String toString() {
        return "Person{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }

}
import org.springframework.data.jpa.repository.JpaRepository;

public interface PersonRepository extends JpaRepository<Person, Long> {
}

O próximo passo é disponibilizarmos uma API para que nossos usuários manipulem a entidade Person no banco de dados.

import org.springframework.web.bind.annotation.*;

import javax.persistence.NoResultException;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("person")
public class PersonController {

    private final PersonRepository repository;

    public PersonController(PersonRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    public Person persist(@RequestParam("name") String name) {
        return repository.save(new Person(name));
    }

    @PutMapping("{id}")
    public Person update(@PathVariable("id") Long id, @RequestParam("name") String name) {
        var person = repository.findById(id)
                .map(it -> it.setName(name))
                .orElseThrow(NoResultException::new);
        return repository.save(person);
    }

    @GetMapping
    public List<Person> get() {
        return repository.findAll();
    }

    @GetMapping("{id}")
    public Optional<Person> get(@PathVariable("id") Long id) {
        return repository.findById(id);
    }

    @DeleteMapping("{id}")
    public void delete(@PathVariable("id") Long id) {
        repository.deleteById(id);
    }

}

Por fim, segue alguns prints de testes utilizando o Postman. Observe o valor do parâmetro schema no header das requisições.

5

Inserindo uma pessoa no schema CLIENTE_1

6

Listando todas as pessoas do schema CLIENTE_2

7

Listando todas as pessoas do schema CLIENTE_1

8

Inserindo uma pessoa no schema CLIENTE_2

9

Inserindo outra pessoa no schema CLIENTE_2

10

Listando todas as pessoas do schema CLIENTE_2

O código fonte dessa aplicação esta no repositório hospedado no GitHub.

// Compartilhe esse Post