Multi-tenancy com Hibernate e Spring Boot
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.

SCHEMA (Esquema separado)
Em uma única instância do banco de dados cada cliente possui um schema.

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.

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

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
dentro da pasta resources e adicione o seguinte conteúdo.data.sql
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
e CLIENTE_1
no banco de dados, e também cria a tabela PERSON em cada um dos schemas.CLIENTE_2
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.

Inserindo uma pessoa no schema CLIENTE_1

Listando todas as pessoas do schema CLIENTE_2

Listando todas as pessoas do schema CLIENTE_1

Inserindo uma pessoa no schema CLIENTE_2

Inserindo outra pessoa no schema CLIENTE_2

Listando todas as pessoas do schema CLIENTE_2
O código fonte dessa aplicação esta no repositório hospedado no GitHub.