多租户是一种方法,应用程序实例由不同的客户使用,从而降低软件开发和部署成本,与单一租户解决方案相比,在这种解决方案中,需要触及多个部分以提供新客户端或更新现有租户。
实施这种架构有多种众所周知的策略,从高度孤立(如单租户)到共享的一切。
在这篇文章中,我将回顾使用Spring Boot,JPA,Hibernate和Postgres来检查多个数据库和一个API服务的多租户解决方案。
pom.xml
需要相应更新。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
另一种方法是在同一台服务器上创建数据库,但在保持相同模式的同时对其进行不同的命名。
现在数据库设置可以区分他们更新数据库中的一行,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
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工具生成,然后选择Actuator,Web和JPA依赖项,如下所示:
使用Spring Boot,Postgres和Docker在集成测试中也介绍了从数据库模式生成JPA实体,因此我只需将com.mushsoft.dvdrental.model
它的Bitbucket随附的源代码src/main/java
文件复制到文件夹即可。
由于演示应用程序将支持多租户,因此需要手动配置持久层,与所有Spring应用程序类似。它将由定义和配置组成:
为了实现这一点,我们首先从Spring Boot应用程序入口点开始排除一些Spring Boot AutoConfiguration行为,这意味着应用程序需要显式配置数据源,Hibernate和JPA相关的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衍生的注释类。
注意:排除前面提到的自动配置行为也可以使用application.yml中的以下设置来完成:
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
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
...
简单的JPA,Hibernate和数据源配置属性。没有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注释的结果,JpaProperties和MultiTenantDvdRentalProperties实例是如何被注入的。
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
}
}
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与其数据源进行映射。
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,因为它将从下面详细介绍的MultiTenantConnectionProvider和CurrentTenantIdentifierResolver实现中检索。
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 }
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实现,在这种情况下,Hibernate的SessionFactory将AutodetectDataSource属性设置为false,这是多租户使用本文讨论的方法的要求。
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注释的类方法的执行被包装在数据库事务中,而无需手动处理连接或事务。
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请求关联。
cd <path to service>/springboot-hibernate-multitenancy/
mvn spring-boot:run
向DemoResource类中/demo
实现的资源发送请求,在头中传递租户信息:X-TENANT-ID
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]
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
因为每个请求的标头中都会传递不同的承租人。另外值得一提的是,DemoResource和ActorDao实例的实例ID 相同,这意味着即使多租户已完成,它们仍然是使用正确数据源的单例实例。