Multi-tenant applications using Spring Boot, JPA, Hibernate and Postgres

Revision #1 on May 9th, 2018: Save operations were failing, added support for Actor instances to be updated via a PATCH endpoint. Updated Transaction Manager section.
Revision #2 on Mar 25th, 2019: Excluded DataSourceTransactionManagerAutoConfiguration from auto-configuration.

 Table of Contents

1. MULTI-TENANT APPLICATIONS USING SPRING BOOT, JPA, HIBERNATE AND POSTGRES

Multitenancy is an approach in which an instance of an application is used by different customers and thus dropping software development and deployment costs when compared to a single-tenant solution where multiple parts would need to be touched in order to provision new clients or update existing tenants.

There are multiple well known strategies to implement this architecture, ranging from highly isolated (like single-tenant) to everything shared.

Multi-tenancy Degrees

In this post I’ll review configuring and implementing a multitenancy solution with multiple databases and one API service using Spring Boot, JPA, Hibernate and Postgres.

2. REQUIREMENTS

  • Java 7+.
  • Maven 3.2+.
  • Familiarity with Spring Framework.
  • A Postgres server or Docker host.

3. SETUP POSTGRES DVD RENTAL DATABASES

asimio/db_dvdrental Docker image, created in Integration Testing using Spring Boot, Postgres and Docker, is going to be used to start two containers, one for each tenant, mapped to different Docker host ports:

docker run -d -p 5432:5432 -e DB_NAME=db_dvdrental -e DB_USER=user_dvdrental -e DB_PASSWD=changeit asimio/db_dvdrental:latest
83c9ac6f53b4995cb38796b70593585fbab8cc7ad15bcc580d28f773d9621055
docker run -d -p 5532:5432 -e DB_NAME=db_dvdrental -e DB_USER=user_dvdrental -e DB_PASSWD=changeit asimio/db_dvdrental:latest
004bf55f9576361bb3a674e31bcb4d6f20ca7c875fe91e146289ec8aaf7abe27

Another approach would be to create the databases in the same server, but naming them differently while keeping the same schema.

4. DIFFERENTIATING THE TENANTS

Now that the DBs are setup lets differentiate them updating a row in the DB listening on 5532 so it would be clear which one is used depending on tenant information:

psql -h 172.16.69.133 -p 5532 -U user_dvdrental -d db_dvdrental
psql (9.4.4, server 9.5.3)
WARNING: psql major version 9.4, server major version 9.5.
         Some psql features might not work.
Type "help" for help.

db_dvdrental=> select * from Actor where actor_id = 1;
 actor_id | first_name | last_name |      last_update
----------+------------+-----------+------------------------
        1 | Penelope   | Guiness   | 2013-05-26 14:47:57.62
(1 row)

db_dvdrental=> update actor set first_name = 'Orlando', last_name = 'Otero' where actor_id = 1;
UPDATE 1

db_dvdrental=> \q

5. CREATE THE SPRING BOOT APP

curl "https://start.spring.io/starter.tgz" -d bootVersion=1.4.3.RELEASE -d dependencies=actuator,web,data-jpa -d language=java -d type=maven-project -d baseDir=springboot-hibernate-multitenancy -d groupId=com.asimio.demo.api -d artifactId=springboot-hibernate-multitenancy -d version=0-SNAPSHOT | tar -xzvf -

This command will create a Maven project in a folder named springboot-hibernate-multitenancywith most of the dependencies used in the accompanying source code.

Alternatively, it can also be generated using Spring Initializr tool then selecting Actuator, Web and JPA dependencies as shown below:

Spring Initializr - Generate Spring Boot App - Actuator, Web, JPASpring Initializr - Generate Spring Boot App - Actuator, Web, JPA

6. THE JPA ENTITIES

Generating the JPA entities from a database schema was also covered in Integration Testing using Spring Boot, Postgres and Docker so I’ll just copy com.asimio.dvdrental.model package from its Bitbucket accompanying source code to src/main/java folder.

7. CONFIGURE THE PERSISTENCE LAYER

Since the demo application is going to support multitenancy, the persistence layer needs to be manually configured similarly to any Spring application. It will consist of defining and configuring:

  • Hibernate, JPA and datasources properties.
  • Datasources beans.
  • Entity manager factory bean.
  • Transaction manager bean.
  • Spring Data JPA and transaction support (through the @Transactional annotation) configuration.

To accomplish so, let’s start with the Spring Boot application entry point to exclude some of the beans AutoConfiguration behavior, you would need to explicitly configure the data sources, Hibernate and JPA-related beans:

Application.java:

package com.asimio.demo.main;
...
@SpringBootApplication(
  exclude = {
    DataSourceAutoConfiguration.class,
    HibernateJpaAutoConfiguration.class,
    DataSourceTransactionManagerAutoConfiguration.class
  },
  scanBasePackages = { "com.asimio.demo.config", "com.asimio.demo.rest" }
)
public class Application {

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

com.asimio.demo.config and com.asimio.demo.rest packages will be scanned for @Component-derived annotated classes.

 Note: Excluding AutoConfiguration behavior mentioned earlier could also be accomplished using the following settings in application.yml:

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
      - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration

7.1 HIBERNATE, JPA AND DATASOURCES PROPERTIES

application.yml:

...
spring:
  jpa:
    database: POSTGRESQL
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    generate-ddl: false
    hibernate:
      ddl-auto: none
...
multitenancy:
  dvdrental:
    dataSources:
      -
        tenantId: tenant_1
        url: jdbc:postgresql://172.16.69.133:5432/db_dvdrental
        username: user_dvdrental
        password: changeit
        driverClassName: org.postgresql.Driver
      -
        tenantId: tenant_2
        url: jdbc:postgresql://172.16.69.133:5532/db_dvdrental
        username: user_dvdrental
        password: changeit
        driverClassName: org.postgresql.Driver
...

 Note: You would have to replace 172.16.69.133 with your Docker host IP where both Postgres containers are running.

Simple JPA, Hibernate and the datasources configuration properties. No DDL will be generated or executed since the databases schema are already in place. The datasources are prefixed with multitenancy.dvdrental and read into a Java class attributes thanks to yaml support added to Spring but more on this next.

MultiTenantJpaConfiguration.java:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.asimio.demo.config.dvdrental;
...
@Configuration
@EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
@EnableJpaRepositories(basePackages = { "com.asimio.dvdrental.dao" }, transactionManagerRef = "txManager")
@EnableTransactionManagement
public class MultiTenantJpaConfiguration {

  @Autowired
  private JpaProperties jpaProperties;

  @Autowired
  private MultiTenantDvdRentalProperties multiTenantDvdRentalProperties;
...
}

This is the Java class where all the JPA-related beans are instantiated. @Configuration specifies this class is going to provide @Bean-annotated methods defining beans that will be managed by the Spring container.

Also notice how JpaProperties and MultiTenantDvdRentalProperties instances are injected as a result of @EnableConfigurationProperties annotation in line 4.

JpaProperties is provided by Spring Boot and it will include configuration properties prefixed with spring.jpa as defined earlier.

MultiTenantDvdRentalProperties is a simple Java class as shown below, created for this demo and will include the properties prefixed with multitenancy.dvdrental, which is basically tenant information and datasource data to establish connections to the DB.

MultiTenantDvdRentalProperties.java:

package com.asimio.demo.config.dvdrental;
...
@Configuration
@ConfigurationProperties(prefix = "multitenancy.dvdrental")
public class MultiTenantDvdRentalProperties {

  private List<DataSourceProperties> dataSourcesProps;
  // Getters and Setters

  public static class DataSourceProperties extends org.springframework.boot.autoconfigure.jdbc.DataSourceProperties {

    private String tenantId;
    // Getters and Setters
  }
}

7.2 THE DATASOURCES BEAN

MultiTenantJpaConfiguration.java continued:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.asimio.demo.config.dvdrental;
...
@Configuration
@EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
@EnableJpaRepositories(basePackages = { "com.asimio.dvdrental.dao" }, transactionManagerRef = "txManager")
@EnableTransactionManagement
public class MultiTenantJpaConfiguration {
...
  @Bean(name = "dataSourcesDvdRental" )
  public Map<String, DataSource> dataSourcesDvdRental() {
    Map<String, DataSource> result = new HashMap<>();
    for (DataSourceProperties dsProperties : this.multiTenantDvdRentalProperties.getDataSources()) {
      DataSourceBuilder factory = DataSourceBuilder
        .create()
        .url(dsProperties.getUrl())
        .username(dsProperties.getUsername())
        .password(dsProperties.getPassword())
        .driverClassName(dsProperties.getDriverClassName());
      result.put(dsProperties.getTenantId(), factory.build());
    }
    return result;
  }
...
}

This is a bean that maps each tenant id with its datasource using an injected instance of the previously described MultiTenantDvdRentalProperties class.

7.3 THE ENTITY MANAGER FACTORY BEAN

MultiTenantJpaConfiguration.java continued:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.asimio.demo.config.dvdrental;
...
@Configuration
@EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
@EnableJpaRepositories(basePackages = { "com.asimio.dvdrental.dao" }, transactionManagerRef = "txManager")
@EnableTransactionManagement
public class MultiTenantJpaConfiguration {
...
  @Bean
  public MultiTenantConnectionProvider multiTenantConnectionProvider() {
    // Autowires dataSourcesDvdRental
    return new DvdRentalDataSourceMultiTenantConnectionProviderImpl();
  }

  @Bean
  public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
    return new TenantDvdRentalIdentifierResolverImpl();
  }

  @Bean
  public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(MultiTenantConnectionProvider multiTenantConnectionProvider,
    CurrentTenantIdentifierResolver currentTenantIdentifierResolver) {

    Map<String, Object> hibernateProps = new LinkedHashMap<>();
    hibernateProps.putAll(this.jpaProperties.getProperties());
    hibernateProps.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
    hibernateProps.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
    hibernateProps.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver);

    // No dataSource is set to resulting entityManagerFactoryBean
    LocalContainerEntityManagerFactoryBean result = new LocalContainerEntityManagerFactoryBean();
    result.setPackagesToScan(new String[] { Actor.class.getPackage().getName() });
    result.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
    result.setJpaPropertyMap(hibernateProps);

    return result;
  }
...
}

To make the entityManagerFactory bean multi-tenant-aware, its configuration properties need to include a multi-tenant strategy, a multi-tenant connection provider and a tenant identifier resolver implementations, that’s what have been configured in lines 26 through 28 along with the JPA properties defined in application.yml and explained here.

As for the multitenancy strategy, Hibernate supports:

Strategy Implementation details
DATABASE A database per tenant.
SCHEMA A schema per tenant.
DISCRIMINATOR One or multiple table columns used to specify different tenants. Added in Hibernate 5

A requirement is not to set the datasource to the entityManagerFactory bean because it will be retrieved from the MultiTenantConnectionProvider and CurrentTenantIdentifierResolver implementations detailed next.

DvdRentalDataSourceMultiTenantConnectionProviderImpl.java:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.asimio.demo.config.dvdrental;
...
public class DvdRentalDataSourceMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
...
  @Autowired
  private Map<String, DataSource> dataSourcesDvdRental;

  @Override
  protected DataSource selectAnyDataSource() {
    return this.dataSourcesDvdRental.values().iterator().next();
  }

  @Override
  protected DataSource selectDataSource(String tenantIdentifier) {
    return this.dataSourcesDvdRental.get(tenantIdentifier);
  }
...
}

This MultiTenantConnectionProvider implementation uses the datasource Map discussed here to locate the expected datasource from the tenant identifier, which is retrieved from a CurrentTenantIdentifierResolver implementation reviewed next.

TenantDvdRentalIdentifierResolverImpl.java:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.asimio.demo.config.dvdrental;
...
public class TenantDvdRentalIdentifierResolverImpl implements CurrentTenantIdentifierResolver {

  private static String DEFAULT_TENANT_ID = "tenant_1";

  @Override
  public String resolveCurrentTenantIdentifier() {
    String currentTenantId = DvdRentalTenantContext.getTenantId();
    return (currentTenantId != null) ? currentTenantId : DEFAULT_TENANT_ID;
  }
...
}

The CurrentTenantIdentifierResolver implementation used for this demo is a simple one delegating tenant selection to DvdRentalTenantContext static methods, which uses a ThreadLocal reference to store and retrieve tenant data.

One advantage of this approach instead of resolving tenant identifier using a request URL or HTTP Header is that the Repository layer could be tested without the need to start a servlet container.

DvdRentalTenantContext.java:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.asimio.demo.config.dvdrental;
...
public class DvdRentalTenantContext {

  private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

  public static void setTenantId(String tenantId) {
    CONTEXT.set(tenantId);
  }

  public static String getTenantId() {
    return CONTEXT.get();
  }

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

7.4 THE TRANSACTION MANAGER BEAN

MultiTenantJpaConfiguration.java continued:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.asimio.demo.config.dvdrental;
...
@Configuration
@EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
@EnableJpaRepositories(basePackages = { "com.asimio.dvdrental.dao" }, transactionManagerRef = "txManager")
@EnableTransactionManagement
public class MultiTenantJpaConfiguration {
...
  @Bean
  public EntityManagerFactory entityManagerFactory(LocalContainerEntityManagerFactoryBean entityManagerFactoryBean) {
    return entityManagerFactoryBean.getObject();
  }

  @Bean
  public PlatformTransactionManager txManager(EntityManagerFactory entityManagerFactory) {
    JpaTransactionManager transactionManager = new JpaTransactionManager();
    transactionManager.setEntityManagerFactory(entityManagerFactory);
    return transactionManager;
  }
...
}

Once again, this is the Java class I have been reviewing where all the JPA-related beans are instantiated. 
The important things to notice here is that the txManager bean needs to unwrap the EntityManagerFactory implementation, Hibernate’s SessionFactory in this case, to set the AutodetectDataSource attribute to false, this is a requirement for multitenancy to work using the approach discussed in this post. 
This is not true, in fact unwrapping the SessionFactory and setting it to the HibernateTransactionManager instance was causing a PATCH endpoint in DemoResource.javato fail because there wasn’t a transaction to commit to update an Actor entity.

7.5 CONFIGURE SPRING DATA JPA AND ANNOTATION-DRIVEN TRANSACTIONS

package com.asimio.demo.config.dvdrental;

@Configuration
...
// @ImportResource(locations = { "classpath:applicationContent.xml" }) // or @EnableJpaRepositories
@EnableJpaRepositories(basePackages = { "com.asimio.dvdrental.dao" }, transactionManagerRef = "txManager")
@EnableTransactionManagement
public class MultiTenantJpaConfiguration {
...
}

or

applicationContent.xml:

...
<jpa:repositories base-package="com.asimio.dvdrental.dao" transaction-manager-ref="txManager" />
<tx:annotation-driven transaction-manager="txManager" proxy-target-class="true" />
...

Imported using @ImportResource.

com.asimio.dvdrental.dao package includes the interfaces upon which Spring JPA Datainstantiates the Repository (or Dao) beans.

package com.asimio.dvdrental.dao;
...
public interface ActorDao extends JpaRepository<Actor, Integer> {
}

@EnableTransactionManagement or tx:annotation-driven allows the execution of class methods annotated with @Transactional to be wrapped in a DB transaction without the need to manually handling the connection or transaction.

8. THE REST LAYER

The REST layer is going to implement a Demo REST resource to demonstrate the multitenancy approach described in this post. It will consist of the REST resource, a Spring interceptor to select and set the tenant identifier and the configuration to associate the interceptor with the RESTresource.

DemoResource.java:

package com.asimio.demo.rest;
...
@RestController
@RequestMapping(value = "/demo")
@Transactional
public class DemoResource {

  @Autowired
  private ActorDao actorDao;

  @GetMapping(value = "/{id}")
  public String getActor(@PathVariable("id") String id) {
    Actor actor = this.actorDao.getOne(Integer.valueOf(id));
    return String.format("[actor: %s %s], [DemoResource instance: %s], [ActorDao instance: %s]",
      actor.getFirstName(), actor.getLastName(), this, this.actorDao);
  }
...
}

To keep this post and sample code simple I decided to inject the Repository dependency in the REST-related class, in a more serious or complex application, I would suggest to implement a Service class where one or more Repository / Dao dependencies would be used along with an Object Mapper / Converter to prevent leaking the application’s internal model to the resource layer.

DvdRentalMultiTenantInterceptor.java:

package com.asimio.demo.rest;
...
public class DvdRentalMultiTenantInterceptor extends HandlerInterceptorAdapter {

  private static final String TENANT_HEADER_NAME = "X-TENANT-ID";

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String tenantId = request.getHeader(TENANT_HEADER_NAME);
    DvdRentalTenantContext.setTenantId(tenantId);
    return true;
  }

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

This Spring interceptor is going to use a ThreadLocal-based implementation included in DvdRentalTenantContext to set the tenant information passed through an HTTP Header. Another option would be to pass the tenant identifier in the URL or through a BEARER token. Although an interceptor was used for this post, a servlet filter could have been implemented and configured instead.

WebMvcConfiguration.java:

package com.asimio.demo.rest;
...
@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter {

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

This configuration is automatically done by Spring Boot but needed to be explicitly configured to associate the DvdRentalMultiTenantInterceptor interceptor with the REST requests.

9. RUNNING THE DEMO SERVICE

cd <path to service>/springboot-hibernate-multitenancy/
mvn spring-boot:run

Sending a request to the /demo/1 endpoint implemented in the DemoResource class, passing tenant information in X-TENANT-ID header:

9.1 TENANT 1

curl -H "X-TENANT-ID: tenant_1" "http://localhost:8800/demo/1"
[actor: Penelope Guiness], [DemoResource instance: com.asimio.demo.rest.DemoResource@6b2e9db2], [ActorDao instance: org.springframework.data.jpa.repository.support.SimpleJpaRepository@7e970e0c]

9.2 TENANT 2

curl -H "X-TENANT-ID: tenant_2" "http://localhost:8800/demo/1"
[actor: Orlando Otero], [DemoResource instance: com.asimio.demo.rest.DemoResource@6b2e9db2], [ActorDao instance: org.springframework.data.jpa.repository.support.SimpleJpaRepository@7e970e0c]

Notice how the actor section in the response varies as a different tenant is passed in the X-TENANT-ID header for each request. Also worth mentioning is that the instance ids for the DemoResource and ActorDao instances are the same, meaning that even though multitenancy has been accomplished, they are still singleton instances for which the correct datasource is used.

In the next post I’ll cover Dynamic Configuration using Spring Cloud Bus and will look back at this entry to demo how updating the datasources list will automatically refresh the persistence layer with a new tenant. Stay tuned, go ahead and sign up to the newsletter to receive updates from this blog when content like this is available.

I hope you find this post informational and useful, thanks for reading and feedback is always appreciated.

10. SOURCE CODE

Accompanying source code for this blog post can be found at:

11. REFERENCES

发布了636 篇原创文章 · 获赞 58 · 访问量 47万+

猜你喜欢

转载自blog.csdn.net/wxb880114/article/details/104235523