假设我们需要开发一个应用程序,并且希望将同一个应用程序销售给N家客户使用。
一、多租户应用场景
1.1 传统多客户部署的问题
假设我们需要开发一个应用程序,并且希望将同一个应用程序销售给N家客户使用。
传统部署方式的问题
在常规情况下,我们需要:
- 创建N个Web服务器(Tomcat)
- 创建N个数据库(DB)
- 为N个客户部署相同的应用程序N次
维护成本问题
- 升级困难:如果应用程序进行了升级或者做了其他改动,需要更新N个应用程序
- 服务器维护:需要同时维护N台服务器
- 版本管理:如果业务增长,客户由N个变成N+M个,将面临N个应用程序和M个应用程序版本的维护问题
- 成本控制:设备维护成本、人力成本急剧增加
- 运维压力:运维人员需要管理大量独立的部署实例
1.2 多租户SaaS解决方案
为了解决上述问题,我们可以开发多租户应用程序(Multi-Tenant Application)。
核心思想
- 根据当前用户所属的租户,动态选择对应的数据库
- 例如:当请求来自A公司的用户时,应用程序连接A公司的数据库
- 当请求来自B公司的用户时,自动将数据库切换到B公司数据库
优势
- 只需要部署一套应用程序
- 升级和维护只需要操作一次
- 大大降低运维成本和复杂度
1.3 多租户实现的关键问题
从理论上讲没有什么问题,但如果考虑将现有的应用程序改造成SaaS模式,我们将遇到以下关键问题:
- 如何识别请求来自哪一个租户?
- 如何自动切换数据源?
- 如何维护租户信息?
- 如何保证数据隔离?
接下来我们将详细讲解这些问题的解决方案。
二、维护、识别和路由租户数据源
2.1 租户信息维护
我们可以提供一个独立的库来存放租户信息,包括:
- 数据库名称
- 数据库连接地址
- 用户名、密码
- 其他配置信息
这样可以统一解决租户信息维护的问题,便于管理和扩展。
2.2 租户识别方式
租户的识别和路由有很多种方法,下面列举几种常用的方式:
方式一:域名识别(推荐)
- 实现方式:为每一个租户提供一个唯一的二级域名
- 示例:
tenantone.example.com、tenanttwo.example.com - 识别关键:
tenantone和tenanttwo就是我们识别租户的关键信息 - 优点:直观、易于理解,每个租户有独立的访问地址
- 缺点:需要配置DNS,域名管理相对复杂
方式二:请求参数识别
- 实现方式:将租户信息作为请求参数传递给服务端
1
- 示例:`saas.example.com?tenantId=tenant1`、`saas.example.com?tenantId=tenant2` - 识别关键:参数
tenantId就是应用程序识别租户的关键信息 - 优点:实现简单,无需额外配置
- 缺点:URL中暴露租户信息,安全性较低
方式三:请求头识别(推荐)
- 实现方式:在请求头(Header)中设置租户信息
- 技术方案:使用JWT等技术,服务端通过解析Header中相关参数获得租户信息
- 示例:
X-Tenant-Id: tenant1 - 优点:安全性高,不暴露在URL中,支持RESTful API
- 缺点:需要前端配合设置请求头
方式四:Session识别
- 实现方式:在用户成功登录系统后,将租户信息保存在Session中
- 使用方式:在需要的时候从Session取出租户信息
- 优点:实现简单,适合传统Web应用
- 缺点:不适合前后端分离架构,不支持无状态服务
2.3 动态数据源配置
传统方式的问题
在启动Spring Boot应用程序之前,就需要为其提供有关数据源的配置信息。按照需求,有N个客户需要使用我们的应用程序,就需要提前配置好N个数据源(多数据源)。
1 | |
- 配置文件会变得非常庞大
- 新增租户需要修改配置并重启应用
解决方案:动态数据源
我们需要借助Hibernate 5提供的动态数据源特性,让应用程序具备动态配置客户端数据源的能力。
实现流程
- 存储租户信息:当用户请求系统资源时,将用户提供的租户信息(tenantId)存放在
ThreadLocal中 - 获取租户信息:从
ThreadLocal中获取租户信息 - 查询配置:根据租户信息查询租户配置库,获取当前租户的数据源配置信息
- 动态设置数据源:借助Hibernate动态配置数据源的能力,为当前请求设置数据源
- 执行请求:使用设置好的数据源执行用户的请求
- 只需要在应用程序中维护一份数据源配置信息(租户数据库配置库)
- 其余的数据源动态查询配置
- 新增租户无需修改配置和重启应用
- 支持租户数量的动态扩展
接下来,我们将快速演示这一功能的实现。
三、 项目构建
我们将使用Spring Boot 2.1.5版本来实现这一演示项目,首先你需要在Maven配置文件中加入如下的一些配置:
1 | |
spring:
freemarker:
cache:false
template-loader-path:
- classpath:/templates/
prefix:
suffix:.html
resources:
static-locations:
- classpath:/static/
devtools:
restart:
enabled:true
jpa:
database:MySQL
show-sql:true
generate-ddl:false
hibernate:
ddl-auto:none
una:
master:
datasource:
url:jdbc:MySQL://localhost:3306/master_tenant?useSSL=false
username:root
password:root
driverClassName:com.MySQL.jdbc.Driver
maxPoolSize:10
idleTimeout:300000
minIdle:10
poolName:master-database-connection-pool
logging:
level:
root:warn
org:
springframework:
Web:debug
hibernate:debug
1 | |
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
publicclass UnaSaasApplication {
public static void main(String[] args) {
SpringApplication.run(UnaSaasApplication.class, args);
}
}
1 | |
@Data
@Entity
@Table(name = “MASTER_TENANT”)
@NoArgsConstructor
@AllArgsConstructor
@Builder
publicclass MasterTenant implements Serializable{
@Id
@Column(name="ID")
private String id;
@Column(name = "TENANT")
@NotEmpty(message = "Tenant identifier must be provided")
private String tenant;
@Column(name = "URL")
@Size(max = 256)
@NotEmpty(message = "Tenant jdbc url must be provided")
private String url;
@Column(name = "USERNAME")
@Size(min = 4,max = 30,message = "db username length must between 4 and 30")
@NotEmpty(message = "Tenant db username must be provided")
private String username;
@Column(name = "PASSWORD")
@Size(min = 4,max = 30)
@NotEmpty(message = "Tenant db password must be provided")
private String password;
@Version
privateint version = 0;
1 | |
持久层我们将继承JpaRepository接口,快速实现对数据源的CURD操作,同时提供了一个通过租户名查找租户数据源的接口,其代码如下:
package com.ramostear.una.saas.master.repository;
import com.ramostear.una.saas.master.model.MasterTenant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
@author : Created by Tan Chaohong (alias:ramostear)
@create-time 2019/5/25 0025-8:22
@modify by :
@since:
*/
@Repository
publicinterface MasterTenantRepository extends JpaRepository<MasterTenant,String>{@Query(“select p from MasterTenant p where p.tenant = :tenant”)
MasterTenant findByTenant(@Param(“tenant”) String tenant);
}
业务层提供通过租户名获取租户数据源信息的服务(其余的服务各位可自行添加):
1 | |
}
接下来是配置自定义的数据源,其源码如下:
package com.ramostear.una.saas.master.config;
import com.ramostear.una.saas.master.config.properties.MasterDatabaseProperties;
import com.ramostear.una.saas.master.repository.MasterTenantRepository;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.cfg.Environment;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.util.Properties;
/**
@create-time 2019/5/25 0025-8:31
1
*/@EnableTransactionManagement
@EnableJpaRepositories(basePackages = {“com.ramostear.una.saas.master.model”,”com.ramostear.una.saas.master.repository”},
entityManagerFactoryRef = “masterEntityManagerFactory”,
transactionManagerRef = “masterTransactionManager”)
@Slf4j
publicclass MasterDatabaseConfig {@Autowired
private MasterDatabaseProperties masterDatabaseProperties;@Bean(name = “masterDatasource”)
public DataSource masterDatasource(){
log.info(“Setting up masterDatasource with :{}”,masterDatabaseProperties.toString());
HikariDataSource datasource = new HikariDataSource();
datasource.setUsername(masterDatabaseProperties.getUsername());
datasource.setPassword(masterDatabaseProperties.getPassword());
datasource.setJdbcUrl(masterDatabaseProperties.getUrl());
datasource.setDriverClassName(masterDatabaseProperties.getDriverClassName());
datasource.setPoolName(masterDatabaseProperties.getPoolName());
datasource.setMaximumPoolSize(masterDatabaseProperties.getMaxPoolSize());
datasource.setMinimumIdle(masterDatabaseProperties.getMinIdle());
datasource.setConnectionTimeout(masterDatabaseProperties.getConnectionTimeout());
datasource.setIdleTimeout(masterDatabaseProperties.getIdleTimeout());
log.info(“Setup of masterDatasource successfully.”);
return datasource;
1 | |
@Primary
@Bean(name = "masterEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean masterEntityManagerFactory(){
LocalContainerEntityManagerFactoryBean lb = new LocalContainerEntityManagerFactoryBean();
lb.setDataSource(masterDatasource());
lb.setPackagesToScan(
new String[]{MasterTenant.class.getPackage().getName(), MasterTenantRepository.class.getPackage().getName()}
1 | |
//Setting a name for the persistence unit as Spring sets it as ‘default’ if not defined.
lb.setPersistenceUnitName(“master-database-persistence-unit”);
//Setting Hibernate as the JPA provider.
JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
lb.setJpaVendorAdapter(vendorAdapter);
//Setting the hibernate properties
lb.setJpaProperties(hibernateProperties());
log.info(“Setup of masterEntityManagerFactory successfully.”);
return lb;
1 | |
@Bean(name = "masterTransactionManager")
public JpaTransactionManager masterTransactionManager(@Qualifier("masterEntityManagerFactory")EntityManagerFactory emf){
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(emf);
log.info(“Setup of masterTransactionManager successfully.”);
return transactionManager;
1 | |
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslationPostProcessor(){
returnnew PersistenceExceptionTranslationPostProcessor();
1 | |
private Properties hibernateProperties(){
Properties properties = new Properties();
properties.put(Environment.DIALECT,”org.hibernate.dialect.MySQL5Dialect”);
properties.put(Environment.SHOW_SQL,true);
properties.put(Environment.FORMAT_SQL,true);
properties.put(Environment.HBM2DDL_AUTO,”update”);
return properties;
1 | |
在改配置类中,我们主要提供包扫描路径,实体管理工程,事务管理器和数据源配置参数的配置。
五、 实现租户业务模块
在此小节中,租户业务模块我们仅提供一个用户登录的场景来演示SaaS的功能。其实体层、业务层和持久化层根普通的Spring Boot Web项目没有什么区别,你甚至感觉不到它是一个SaaS应用程序的代码。
首先,创建一个用户实体User,其源码如下:
@Table(name = “USER”)
@Data
publicclass User implements Serializable {
privatestaticfinallong serialVersionUID = -156890917814957041L;
@Id
@Column(name = "ID")
@Size(min = 6,max = 22,message = "User password must be provided and length between 6 and 22.")
}
1 | |
publicinterface UserRepository extends JpaRepository<User,String>,JpaSpecificationExecutor
User findByUsername(String username);
1 | |
@Service(“userService”)
publicclass UserServiceImpl implements UserService{
private UserRepository userRepository;
privatestatic TwitterIdentifier identifier = new TwitterIdentifier();
public void save(User user) {
user.setId(identifier.generalIdentifier());
user.setTenant(TenantContextHolder.getTenant());
userRepository.save(user);
1 | |
public User findById(String userId) {
Optional
if(optional.isPresent()){
return optional.get();
}else{
returnnull;
1 | |
public User findByUsername(String username) {
System.out.println(TenantContextHolder.getTenant());
return userRepository.findByUsername(username);
1 | |
在这里,我们采用了Twitter的雪花算法来实现了一个ID生成器。
六、 配置拦截器
我们需要提供一个租户信息的拦截器,用以获取租户标识符,其源代码和配置拦截器的源代码如下:
/**
@create-time 2019/5/26 0026-23:17
*/
publicclass TenantInterceptor implements HandlerInterceptor{public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenant = request.getParameter(“tenant”);
if(StringUtils.isBlank(tenant)){
response.sendRedirect(“/login.html”);
returnfalse;
TenantContextHolder.setTenant(tenant);
returntrue;
}
publicclass InterceptorConfig extends WebMvcConfigurationSupport {
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TenantInterceptor()).addPathPatterns(“/“).excludePathPatterns(“/login.html”);
super.addInterceptors(registry);
}
1 | |
publicclass TenantContextHolder {
privatestaticfinal ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenant(String tenant){
CONTEXT.set(tenant);
1 | |
public static String getTenant(){
return CONTEXT.get();
1 | |
public static void clear(){
CONTEXT.remove();
1 | |
此类时实现动态数据源设置的关键
八、 动态数据源切换
要实现动态数据源切换,我们需要借助两个类来完成,CurrentTenantIdentifierResolver和AbstractDataSourceBasedMultiTenantConnectionProviderImpl。从它们的命名上就可以看出,一个负责解析租户标识,一个负责提供租户标识对应的租户数据源信息。首先,我们需要实现CurrentTenantIdentifierResolver接口中的resolveCurrentTenantIdentifier()和validateExistingCurrentSessions()方法,完成租户标识的解析功能。实现类的源码如下:
package com.ramostear.una.saas.tenant.config;
import com.ramostear.una.saas.context.TenantContextHolder;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
/
@create-time 2019/5/26 0026-22:38
*/
publicclass CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {/**
- 默认的租户ID
*/
privatestaticfinal String DEFAULT_TENANT = “tenant_1”;
/**
- 解析当前租户的ID
- @return
*/
public String resolveCurrentTenantIdentifier() {
- 默认的租户ID
//通过租户上下文获取租户ID,此ID是用户登录时在header中进行设置的
String tenant = TenantContextHolder.getTenant();
//如果上下文中没有找到该租户ID,则使用默认的租户ID,或者直接报异常信息
return StringUtils.isNotBlank(tenant)?tenant:DEFAULT_TENANT;
}
public boolean validateExistingCurrentSessions() {
}
1 | |
publicclass DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl{
privatestaticfinallong serialVersionUID = -7522287771874314380L;
private MasterTenantRepository masterTenantRepository;
private Map<String,DataSource> dataSources = new TreeMap<>();
protected DataSource selectAnyDataSource() {
if(dataSources.isEmpty()){
List
tenants.forEach(masterTenant->{
dataSources.put(masterTenant.getTenant(), DataSourceUtils.wrapperDataSource(masterTenant));
});
return dataSources.values().iterator().next();
1 | |
protected DataSource selectDataSource(String tenant) {
if(!dataSources.containsKey(tenant)){
dataSources.put(masterTenant.getTenant(),DataSourceUtils.wrapperDataSource(masterTenant));
});
return dataSources.get(tenant);
1 | |
在该类中,通过查询租户数据源库,动态获得租户数据源信息,为租户业务模块的数据源配置提供数据数据支持。
最后,我们还需要提供租户业务模块数据源配置,这是整个项目核心的地方,其代码如下:
@ComponentScan(basePackages = {
“com.ramostear.una.saas.tenant.model”,
“com.ramostear.una.saas.tenant.repository”
})
@EnableJpaRepositories(basePackages = {
“com.ramostear.una.saas.tenant.repository”,
“com.ramostear.una.saas.tenant.service”
},entityManagerFactoryRef = “tenantEntityManagerFactory”
,transactionManagerRef = “tenantTransactionManager”)
publicclass TenantDataSourceConfig {
@Bean("jpaVendorAdapter")
public JpaVendorAdapter jpaVendorAdapter(){
returnnew HibernateJpaVendorAdapter();
}
@Bean(name = “tenantTransactionManager”)
public JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory){
transactionManager.setEntityManagerFactory(entityManagerFactory);
}
@Bean(name = "datasourceBasedMultiTenantConnectionProvider")
@ConditionalOnBean(name = "masterEntityManagerFactory")
public MultiTenantConnectionProvider multiTenantConnectionProvider(){
returnnew DataSourceBasedMultiTenantConnectionProviderImpl();
}
@Bean(name = “currentTenantIdentifierResolver”)
public CurrentTenantIdentifierResolver currentTenantIdentifierResolver(){
returnnew CurrentTenantIdentifierResolverImpl();
}
@Bean(name = "tenantEntityManagerFactory")
@ConditionalOnBean(name = "datasourceBasedMultiTenantConnectionProvider")
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
@Qualifier(“datasourceBasedMultiTenantConnectionProvider”)MultiTenantConnectionProvider connectionProvider,
@Qualifier(“currentTenantIdentifierResolver”)CurrentTenantIdentifierResolver tenantIdentifierResolver
){
LocalContainerEntityManagerFactoryBean localBean = new LocalContainerEntityManagerFactoryBean();
localBean.setPackagesToScan(
new String[]{
User.class.getPackage().getName(),
UserRepository.class.getPackage().getName(),
UserService.class.getPackage().getName()
}
);
localBean.setJpaVendorAdapter(jpaVendorAdapter());
localBean.setPersistenceUnitName(“tenant-database-persistence-unit”);
Map<String,Object> properties = new HashMap<>();
properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER,connectionProvider);
properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER,tenantIdentifierResolver);
localBean.setJpaPropertyMap(properties);
return localBean;
}
1 | |
/**
@create-time 2019/5/27 0027-0:18
1
*/@Controller
publicclass LoginController {private UserService userService;
@GetMapping(“/login.html”)
public String login(){
return”/login”;
1 | |
@PostMapping("/login")
public String login(@RequestParam(name = "username") String username, @RequestParam(name = "password")String password, ModelMap model){
System.out.println(“tenant:”+TenantContextHolder.getTenant());
User user = userService.findByUsername(username);
if(user != null){
if(user.getPassword().equals(password)){
model.put(“user”,user);
return”/index”;
1 | |
在启动项目之前,我们需要为主数据源创建对应的数据库和数据表,用于存放租户数据源信息,同时还需要提供一个租户业务模块数据库和数据表,用来存放租户业务数据。一切准备就绪后,启动项目,在浏览器中输入:http://localhost:8080/login.html
在登录窗口中输入对应的租户名,用户名和密码,测试是否能够正常到达主页。可以多增加几个租户和用户,测试用户是否正常切换到对应的租户下。
注:特别申明一下,本篇文章来源于网络,觉得写的很好,便整理分享给大家!
本文标题: SpringBoot入门篇
本文作者: 狂欢马克思
发布时间: 2022年01月06日 00:00
最后更新: 2026年09月16日 05:40
原始链接: https://haoxiang.eu.org/66e04423/
版权声明: 本文著作权归作者所有,均采用CC BY-NC-SA 4.0许可协议,转载请注明出处!

