使用Spring Boot,JPA,Hibernate和Postgres的多租户应用程序

 

1.使用SPRING BOOT,JPA,HIBERNATE和POSTGRES的多租户应用程序

多租户是一种方法,应用程序实例由不同的客户使用,从而降低软件开发和部署成本,与单一租户解决方案相比,在这种解决方案中,需要触及多个部分以提供新客户端或更新现有租户。

实施这种架构有多种众所周知的策略,从高度孤立(如单租户)到共享的一切。

在这篇文章中,我将回顾使用Spring BootJPAHibernatePostgres来检查多个数据库和一个API服务的多租户解决方案

2.需求

  • Java 8或Java 7.对于Java 7,内部的java.version属性pom.xml需要相应更新。
  • Maven 3.3.x
  • 熟悉Spring框架。
  • Postgres服务器或Docker主机。

3.设置POSTGRES DVD租用数据库

asimio / db_dvdrental 集成测试中使用Spring Boot,Postgres和Docker创建的Docker映像将用于启动两个容器,每个容器映射到不同的Docker主机端口:

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

另一种方法是在同一台服务器上创建数据库,但在保持相同模式的同时对其进行不同的命名。

4.区分租户

现在数据库设置可以区分他们更新数据库中的一行,5532因此可以根据租户信息清楚地使用哪一个数据库

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.创建弹簧引导程序

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.mushsoft.demo.api -d artifactId=springboot-hibernate-multitenancy -d version=0-SNAPSHOT | tar -xzvf -

这个命令将在一个文件夹中创建一个Maven项目,该文件夹springboot-hibernate-multitenancy中随附的源代码中使用的大多数依赖项都被命名

或者,也可以使用Spring Initializr工具生成,然后选择ActuatorWebJPA依赖项,如下所示:

6. JPA实体

使用Spring Boot,Postgres和Docker集成测试中也介绍了从数据库模式生成JPA实体,因此我只需将com.mushsoft.dvdrental.model它的Bitbucket随附的源代码src/main/java文件复制文件夹即可。

7.配置持久层

由于演示应用程序将支持多租户,因此需要手动配置持久层,与所有Spring应用程序类似它将由定义和配置组成:

  • Hibernate,JPA和数据源属性。
  • 数据源bean。
  • 实体管理器工厂bean。
  • 事务管理器bean。
  • Spring Data JPA和事务支持(通过@Transactional注释)配置。

为了实现这一点,我们首先从Spring Boot应用程序入口点开始排除一些Spring Boot AutoConfiguration行为,这意味着应用程序需要显式配置数据源HibernateJPA相关的bean:

Application.java

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

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

com.mushsoft.demo.config并且com.mushsoft.demo.rest包将被扫描以查找@Component衍生的注释类。

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
7.1 HIBERNATE,JPA和数据库属性

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
...

简单的JPAHibernate数据源配置属性。没有DDL将产生或执行,因为数据库架构已经到位数据源的前缀为multitenancy.dvdrental读入的Java类的属性感谢YAML支持加入到春天,但更多关于这个未来。

MultiTenantJpaConfiguration.java

 1 package com.mushsoft.demo.config.dvdrental;
 2 ...
 3 @Configuration
 4 @EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
 5 @ImportResource(locations = { "classpath:applicationContent.xml" })
 6 @EnableTransactionManagement
 7 public class MultiTenantJpaConfiguration {
 8 
 9   @Autowired
10   private JpaProperties jpaProperties;
11 
12   @Autowired
13   private MultiTenantDvdRentalProperties multiTenantDvdRentalProperties;
14 ...
15 }

这是所有与JPA相关的bean都被实例化Java@Configuration指定这个类将提供定义Bean的@Bean注解方法,这些方法将由Spring容器管理。

另请注意作为第4行中@EnableConfigurationProperties注释的结果,JpaPropertiesMultiTenantDvdRentalProperties实例是如何被注入的

JpaProperties由设置弹簧引导,它将包括前缀配置属性spring.jpa所定义的前面

MultiTenantDvdRentalProperties是一个简单的Java类,如下所示,为此演示创建,并将包含前缀为的属性multitenancy.dvdrental,它基本上是租户信息和数据源数据,用于建立与数据库的连接。

MultiTenantDvdRentalProperties.java

package com.mushsoft.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数据库BEAN

MultiTenantJpaConfiguration.java 继续:

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

这是一个bean,它使用前面描述的MultiTenantDvdRentalProperties类的注入实例将每个租户id与其数据源进行映射

7.3实体经理工厂BEAN

MultiTenantJpaConfiguration.java 继续:

 1 package com.mushsoft.demo.config.dvdrental;
 2 ...
 3 @Configuration
 4 @EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
 5 @ImportResource(locations = { "classpath:applicationContent.xml" })
 6 @EnableTransactionManagement
 7 public class MultiTenantJpaConfiguration {
 8 ...
 9   @Bean
10   public MultiTenantConnectionProvider multiTenantConnectionProvider() {
11     // Autowires dataSourcesDvdRental
12     return new DvdRentalDataSourceMultiTenantConnectionProviderImpl();
13   }
14 
15   @Bean
16   public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
17     return new TenantDvdRentalIdentifierResolverImpl();
18   }
19 
20   @Bean
21   public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(MultiTenantConnectionProvider multiTenantConnectionProvider,
22     CurrentTenantIdentifierResolver currentTenantIdentifierResolver) {
23 
24     Map<String, Object> hibernateProps = new LinkedHashMap<>();
25     hibernateProps.putAll(this.jpaProperties.getProperties());
26     hibernateProps.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
27     hibernateProps.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
28     hibernateProps.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver);
29 
30     // No dataSource is set to resulting entityManagerFactoryBean
31     LocalContainerEntityManagerFactoryBean result = new LocalContainerEntityManagerFactoryBean();
32     result.setPackagesToScan(new String[] { Actor.class.getPackage().getName() });
33     result.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
34     result.setJpaPropertyMap(hibernateProps);
35 
36     return result;
37   }
38 ...
39 }

为了让entityManagerFactory bean可以感知多租户,它的配置属性需要包含多租户策略,多租户连接提供程序和租户标识符解析器实现,这些都是在26到28行以及JPA中配置的application.yml中定义在这里解释的属性

至于多租户策略,Hibernate支持:

战略 实施细节
数据库 每个租户都有一个数据库。
SCHEMA 每个租户的架构。
DISCRIMINATOR 用于指定不同租户的一个或多个表列。Hibernate 5中添加

需求不是将数据源设置为entityManagerFactory bean,因为它将从下面详细介绍MultiTenantConnectionProviderCurrentTenantIdentifierResolver实现中检索

DvdRentalDataSourceMultiTenantConnectionProviderImpl.java

 1 package com.mushsoft.demo.config.dvdrental;
 2 ...
 3 public class DvdRentalDataSourceMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
 4 ...
 5   @Autowired
 6   private Map<String, DataSource> dataSourcesDvdRental;
 7 
 8   @Override
 9   protected DataSource selectAnyDataSource() {
10     return this.dataSourcesDvdRental.values().iterator().next();
11   }
12 
13   @Override
14   protected DataSource selectDataSource(String tenantIdentifier) {
15     return this.dataSourcesDvdRental.get(tenantIdentifier);
16   }
17 ...
18 }

MultiTenantConnectionProvider实现使用此处讨论数据源Map从租户标识符中查找预期的数据源,该标识符是从CurrentTenantIdentifierResolver实现中接下来查看的。

TenantDvdRentalIdentifierResolverImpl.java

 1 package com.mushsoft.demo.config.dvdrental;
 2 ...
 3 public class TenantDvdRentalIdentifierResolverImpl implements CurrentTenantIdentifierResolver {
 4 
 5   private static String DEFAULT_TENANT_ID = "tenant_1";
 6 
 7   @Override
 8   public String resolveCurrentTenantIdentifier() {
 9     String currentTenantId = DvdRentalTenantContext.getTenantId();
10     return (currentTenantId != null) ? currentTenantId : DEFAULT_TENANT_ID;
11   }
12 ...
13 }

用于此演示CurrentTenantIdentifierResolver实现是一种简单的将租户选择委托给DvdRentalTenantContext静态方法的方法,该方法使用ThreadLocal引用来存储和检索租户数据。

这种方法的一个优点是,不需要使用请求URL或HTTP Header来解析租户标识符,而是可以在不需要启动servlet容器的情况下测试Repository层。

DvdRentalTenantContext.java

 1 package com.mushsoft.demo.config.dvdrental;
 2 ...
 3 public class DvdRentalTenantContext {
 4 
 5   private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
 6 
 7   public static void setTenantId(String tenantId) {
 8     CONTEXT.set(tenantId);
 9   }
10 
11   public static String getTenantId() {
12     return CONTEXT.get();
13   }
14 
15   public static void clear() {
16     CONTEXT.remove();
17   }
18 }
7.4交易经理BEAN

MultiTenantJpaConfiguration.java 继续:

 1 package com.mushsoft.demo.config.dvdrental;
 2 ...
 3 @Configuration
 4 @EnableConfigurationProperties({ MultiTenantDvdRentalProperties.class, JpaProperties.class })
 5 @ImportResource(locations = { "classpath:applicationContent.xml" })
 6 @EnableTransactionManagement
 7 public class MultiTenantJpaConfiguration {
 8 ...
 9   @Bean
10   public EntityManagerFactory entityManagerFactory(LocalContainerEntityManagerFactoryBean entityManagerFactoryBean) {
11     return entityManagerFactoryBean.getObject();
12   }
13 
14   @Bean
15   public PlatformTransactionManager txManager(EntityManagerFactory entityManagerFactory) {
16     SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
17     HibernateTransactionManager result = new HibernateTransactionManager();
18     result.setAutodetectDataSource(false);
19     result.setSessionFactory(sessionFactory);
20     return result;
21   }
22 ...
23 }

再次,这是我一直在审查所有与JPA相关的bean实例化Java这里需要注意的重要的事情是,txManager bean需要解包EntityManagerFactory实现,在这种情况下HibernateSessionFactoryAutodetectDataSource属性设置为false,这是多租户使用本文讨论的方法的要求。

7.5配置弹簧数据JPA和注释驱动的事务

applicationContent.xml

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

通过MultiTenantJpaConfiguration类中找到的@ImportResource注释导入package包含Spring JPA Data实例化Repository(或Dao)bean的接口。com.mushsoft.dvdrental.dao

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

tx:注解驱动允许使用@Transactional注释的类方法的执行被包装在数据库事务中,而无需手动处理连接或事务。

8.休息层

REST层将实现一个Demo REST资源来演示本文描述的多租户方法。它将由REST资源,Spring拦截器组成,用于选择和设置租户标识符以及将拦截器与REST资源相关联的配置。

DemoResource.java

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

  @Autowired
  private ActorDao actorDao;

  @RequestMapping(method = RequestMethod.GET)
  public String getDemo() {
    Actor actor = this.actorDao.getOne(1);
    return String.format("[actor: %s %s], [DemoResource instance: %s], [ActorDao instance: %s]", actor.getFirstName(),
      actor.getLastName(), this, this.actorDao);
  }
...
}

为了保持这篇文章和示例代码的简单性,我决定将Repository依赖项注入到REST相关类中,在一个更严重或复杂的应用程序中,我会建议实现一个Service类,其中将使用一个或多个Dao依赖关系以及对象映射器/转换器,以防止模型泄漏到资源层。

DvdRentalMultiTenantInterceptor.java

package com.mushsoft.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();
  }
...
}

这个Spring拦截器将使用包含在DvdRentalTenantContext中的基于ThreadLocal的实现来设置通过HTTP Header传递的租户信息。另一个选择是在URL中传递租户标识符或通过BEARER标记。尽管这篇文章使用了拦截器,但servlet过滤器可能已经被实现并被配置

WebMvcConfiguration.java

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

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

此配置由Spring Boot自动完成,但需要明确配置为将DvdRentalMultiTenantInterceptor拦截器与REST请求关联

9.运行演示服务

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

DemoResource类中/demo实现的资源发送请求头中传递租户信息X-TENANT-ID

9.1租客1
curl -v -H "X-TENANT-ID: tenant_1" "http://localhost:8800/demo"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8800 (#0)
> GET /demo HTTP/1.1
> Host: localhost:8800
> User-Agent: curl/7.51.0
> Accept: */*
> X-TENANT-ID: tenant_1
>
< HTTP/1.1 200
< X-Application-Context: application:8800
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 193
< Date: Sat, 07 Jan 2017 04:43:47 GMT
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
[actor: Penelope Guiness], [DemoResource instance: com.mushsoft.demo.rest.DemoResource@6b2e9db2], [ActorDao instance: org.springframework.data.jpa.repository.support.SimpleJpaRepository@7e970e0c]
9.2租户2
curl -v -H "X-TENANT-ID: tenant_2" "http://localhost:8800/demo"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8800 (#0)
> GET /demo HTTP/1.1
> Host: localhost:8800
> User-Agent: curl/7.51.0
> Accept: */*
> X-TENANT-ID: tenant_2
>
< HTTP/1.1 200
< X-Application-Context: application:8800
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 190
< Date: Sat, 07 Jan 2017 04:39:18 GMT
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
[actor: Orlando Otero], [DemoResource instance: com.mushsoft.demo.rest.DemoResource@6b2e9db2], [ActorDao instance: org.springframework.data.jpa.repository.support.SimpleJpaRepository@7e970e0c]

请注意响应中actor部分如何变化,X-TENANT-ID因为每个请求标头中都会传递不同的承租人另外值得一提的是,DemoResourceActorDao实例的实例ID 相同,这意味着即使多租户已完成,它们仍然是使用正确数据源的单例实例

10.参考文献

赞赏


微信赞赏

支付宝赞赏