时至今日,五年的时间过去了,软件开发的技术发生了巨大的改变,笔者所实现SaaS平台的技术栈也更新了好几波,真是印证了那就话:“山重水尽疑无路,柳暗花明又一村”。基于之前走过的许多弯路和踩过的坑,以及近段时间有许多网友问我如何使用Spring Boot实现多租户系统,决定写一篇文章聊一聊关于SaaS的硬核技术。
说起SaaS,它只是一种软件架构,并没有多少神秘的东西,也不是什么很难的系统,我个人的感觉,SaaS平台的难度在于商业上的运营,而非技术上的实现。就技术上来说,SaaS是这样一种架构模式:它让多个不同环境的用户使用同一套应用程序,且保证用户之间的数据相互隔离。现在想想看,这也有点共享经济的味道在里面。
笔者在这里就不再深入聊SaaS软件成熟度模型和数据隔离方案对比的事情了。今天要聊的是使用Spring Boot快速构建独立数据库/共享数据库独立Schema的多租户系统。我将提供一个SaaS系统最核心的技术实现,而其他的部分有兴趣的朋友可以在此基础上自行扩展。
为了解决上述的问题,我们可以开发多租户应用程序,我们可以根据当前用户是谁,从而选择对应的数据库。例如,当请求来自A公司的用户时,应用程序就连接A公司的数据库,当请求来自B公司的用户时,自动将数据库切换到B公司数据库,以此类推。从理论上将没有什么问题,但我们如果考虑将现有的应用程序改造成SaaS模式,我们将遇到第一个问题:如果识别请求来自哪一个租户?如何自动切换数据源?
1.可以通过域名的方式来识别租户:我们可以为每一个租户提供一个唯一的二级域名,通过二级域名就可以达到识别租户的能力,如tenantone.example.com,tenant.example.com;tenantone和tenant就是我们识别租户的关键信息。 2.可以将租户信息作为请求参数传递给服务端,为服务端识别租户提供支持,如saas.example.com?tenantId=tenant1,saas.example.com?tenantId=tenant2。其中的参数tenantId就是应用程序识别租户的关键信息。 3.可以在请求头(Header)中设置租户信息,例如JWT等技术,服务端通过解析Header中相关参数以获得租户信息。 4.在用户成功登录系统后,将租户信息保存在Session中,在需要的时候从Session取出租户信息。 解决了上述问题后,我们再来看看如何获取客户端传入的租户信息,以及在我们的业务代码中如何使用租户信息(最关键的是DataSources的问题)。
我们都知道,在启动Spring Boot应用程序之前,就需要为其提供有关数据源的配置信息(有使用到数据库的情况下),按照一开始的需求,有N个客户需要使用我们的应用程序,我们就需要提前配置好N个数据源(多数据源),如果N<50,我认为我还能忍受,如果更多,这样显然是无法接受的。为了解决这一问题,我们需要借助Hibernate 5 提供的动态数据源特性,让我们的应用程序具备动态配置客户端数据源的能力。简单来说,当用户请求系统资源时,我们将用户提供的租户信息(tenantId)存放在ThreadLoacal中,紧接着获取TheadLocal中的租户信息,并根据此信息查询单独的租户库,获取当前租户的数据配置信息,然后借助Hibernate动态配置数据源的能力,为当前请求设置数据源,最后之前用户的请求。这样我们就只需要在应用程序中维护一份数据源配置信息(租户数据库配置库),其余的数据源动态查询配置。接下来,我们将快速的演示这一功能。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
然后提供一个可用的配置文件,并加入如下的内容:
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
由于采用Freemarker作为视图渲染引擎,所以需要提供Freemarker的相关技术
una:master:datasource配置项就是上面说的统一存放租户信息的数据源配置信息,你可以理解为主库。
接下来,我们需要关闭Spring Boot自动配置数据源的功能,在项目主类上添加如下的设置:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class UnaSaasApplication {
public static void main(String[] args) {
SpringApplication.run(UnaSaasApplication.class, args);
}
}
最后,让我们看看整个项目的结构:
@Data
@Entity
@Table(name = "MASTER_TENANT")
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class 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
private int version = 0;
}
持久层我们将继承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 public interface MasterTenantRepository extends JpaRepository<MasterTenant,String>{
@Query("select p from MasterTenant p where p.tenant = :tenant") MasterTenant findByTenant(@Param("tenant") String tenant); } 业务层提供通过租户名获取租户数据源信息的服务(其余的服务各位可自行添加):
package com.ramostear.una.saas.master.service;
import com.ramostear.una.saas.master.model.MasterTenant;
/**
public interface MasterTenantService { /** * Using custom tenant name query * @param tenant tenant name * @return masterTenant */ MasterTenant findByTenant(String tenant); } 最后,我们需要关注的重点是配置主数据源(Spring Boot需要为其提供一个默认的数据源)。在配置之前,我们需要获取配置项,可以通过@ConfigurationProperties("una.master.datasource")获取配置文件中的相关配置信息:
@Getter @Setter @Configuration @ConfigurationProperties("una.master.datasource") public class MasterDatabaseProperties {
private String url;
private String password;
private String username;
private String driverClassName;
private long connectionTimeout;
private int maxPoolSize;
private long idleTimeout;
private int minIdle;
private String poolName;
@Override
public String toString(){
StringBuilder builder = new StringBuilder();
builder.append("MasterDatabaseProperties [ url=")
.append(url)
.append(", username=")
.append(username)
.append(", password=")
.append(password)
.append(", driverClassName=")
.append(driverClassName)
.append(", connectionTimeout=")
.append(connectionTimeout)
.append(", maxPoolSize=")
.append(maxPoolSize)
.append(", idleTimeout=")
.append(idleTimeout)
.append(", minIdle=")
.append(minIdle)
.append(", poolName=")
.append(poolName)
.append("]");
return builder.toString();
}
} 接下来是配置自定义的数据源,其源码如下:
package com.ramostear.una.saas.master.config;
import com.ramostear.una.saas.master.config.properties.MasterDatabaseProperties; import com.ramostear.una.saas.master.model.MasterTenant; 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;
/**
@author : Created by Tan Chaohong (alias:ramostear)
@create-time 2019/5/25 0025-8:31
@modify by :
@since: */ @Configuration @EnableTransactionManagement @EnableJpaRepositories(basePackages = {"com.ramostear.una.saas.master.model","com.ramostear.una.saas.master.repository"}, entityManagerFactoryRef = "masterEntityManagerFactory", transactionManagerRef = "masterTransactionManager") @Slf4j public class 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; } @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()} );
//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;
} @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; }
@Bean public PersistenceExceptionTranslationPostProcessor exceptionTranslationPostProcessor(){ return new PersistenceExceptionTranslationPostProcessor(); } 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; } }
在改配置类中,我们主要提供包扫描路径,实体管理工程,事务管理器和数据源配置参数的配置。
首先,创建一个用户实体User,其源码如下:
@Entity @Table(name = "USER") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class User implements Serializable { private static final long serialVersionUID = -156890917814957041L;
@Id
@Column(name = "ID")
private String id;
@Column(name = "USERNAME")
private String username;
@Column(name = "PASSWORD")
@Size(min = 6,max = 22,message = "User password must be provided and length between 6 and 22.")
private String password;
@Column(name = "TENANT")
private String tenant;
}
业务层提供了一个根据用户名检索用户信息的服务,它将调用持久层的方法根据用户名对租户的用户表进行检索,如果找到满足条件的用户记录,则返回用户信息,如果没有找到,则返回null;持久层和业务层的源码分别如下:
@Repository public interface UserRepository extends JpaRepository<User,String>,JpaSpecificationExecutor{
User findByUsername(String username);
} @Service("userService") public class UserServiceImpl implements UserService{
@Autowired
private UserRepository userRepository;
private static TwitterIdentifier identifier = new TwitterIdentifier();
@Override
public void save(User user) {
user.setId(identifier.generalIdentifier());
user.setTenant(TenantContextHolder.getTenant());
userRepository.save(user);
}
@Override
public User findById(String userId) {
Optional<User> optional = userRepository.findById(userId);
if(optional.isPresent()){
return optional.get();
}else{
return null;
}
}
@Override
public User findByUsername(String username) {
System.out.println(TenantContextHolder.getTenant());
return userRepository.findByUsername(username);
}
在这里,我们采用了Twitter的雪花算法来实现了一个ID生成器。
/**
@author : Created by Tan Chaohong (alias:ramostear)
@create-time 2019/5/26 0026-23:17
@modify by :
@since: */ @Slf4j public class TenantInterceptor implements HandlerInterceptor{
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String tenant = request.getParameter("tenant"); if(StringUtils.isBlank(tenant)){ response.sendRedirect("/login.html"); return false; }else{ TenantContextHolder.setTenant(tenant); return true; } } } @Configuration public class InterceptorConfig extends WebMvcConfigurationSupport {
@Override protected void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TenantInterceptor()).addPathPatterns("/**").excludePathPatterns("/login.html"); super.addInterceptors(registry); } } /login.html是系统的登录路径,我们需要将其排除在拦截器拦截的范围之外,否则我们永远无法进行登录
public class TenantContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenant(String tenant){
CONTEXT.set(tenant);
}
public static String getTenant(){
return CONTEXT.get();
}
public static void clear(){
CONTEXT.remove();
}
} 此类时实现动态数据源设置的关键
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;
/**
@author : Created by Tan Chaohong (alias:ramostear)
@create-time 2019/5/26 0026-22:38
@modify by :
@since: */ public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {
/**
/**
@Override public boolean validateExistingCurrentSessions() { return true; } }
此类的逻辑非常简单,就是从ThreadLocal中获取当前设置的租户标识符
有了租户标识符解析类之后,我们需要扩展租户数据源提供类,实现从数据库动态查询租户数据源信息,其源码如下:
@Slf4j @Configuration public class DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl{
private static final long serialVersionUID = -7522287771874314380L;
@Autowired
private MasterTenantRepository masterTenantRepository;
private Map<String,DataSource> dataSources = new TreeMap<>();
@Override
protected DataSource selectAnyDataSource() {
if(dataSources.isEmpty()){
List<MasterTenant> tenants = masterTenantRepository.findAll();
tenants.forEach(masterTenant->{
dataSources.put(masterTenant.getTenant(), DataSourceUtils.wrapperDataSource(masterTenant));
});
}
return dataSources.values().iterator().next();
}
@Override protected DataSource selectDataSource(String tenant) { if(!dataSources.containsKey(tenant)){ List tenants = masterTenantRepository.findAll(); tenants.forEach(masterTenant->{ dataSources.put(masterTenant.getTenant(),DataSourceUtils.wrapperDataSource(masterTenant)); }); } return dataSources.get(tenant); } } 在该类中,通过查询租户数据源库,动态获得租户数据源信息,为租户业务模块的数据源配置提供数据数据支持。
最后,我们还需要提供租户业务模块数据源配置,这是整个项目核心的地方,其代码如下:
@Slf4j @Configuration @EnableTransactionManagement @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") public class TenantDataSourceConfig {
@Bean("jpaVendorAdapter")
public JpaVendorAdapter jpaVendorAdapter(){
return new HibernateJpaVendorAdapter();
}
@Bean(name = "tenantTransactionManager")
public JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory){
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(entityManagerFactory);
return transactionManager;
}
@Bean(name = "datasourceBasedMultiTenantConnectionProvider")
@ConditionalOnBean(name = "masterEntityManagerFactory")
public MultiTenantConnectionProvider multiTenantConnectionProvider(){
return new DataSourceBasedMultiTenantConnectionProviderImpl();
}
@Bean(name = "currentTenantIdentifierResolver")
public CurrentTenantIdentifierResolver currentTenantIdentifierResolver(){
return new 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);
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");
localBean.setJpaPropertyMap(properties);
return localBean;
}
} 在改配置文件中,大部分内容与主数据源的配置相同,唯一的区别是租户标识解析器与租户数据源补给源的设置,它将告诉Hibernate在执行数据库操作命令前,应该设置什么样的数据库连接信息,以及用户名和密码等信息。
/**
@author : Created by Tan Chaohong (alias:ramostear)
@create-time 2019/5/27 0027-0:18
@modify by :
@since: */ @Controller public class LoginController {
@Autowired private UserService userService;
@GetMapping("/login.html") public String login(){ return "/login"; }
@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"; }else{ return "/login"; } }else{ return "/login"; } } } 在启动项目之前,我们需要为主数据源创建对应的数据库和数据表,用于存放租户数据源信息,同时还需要提供一个租户业务模块数据库和数据表,用来存放租户业务数据。一切准备就绪后,启动项目,在浏览器中输入:http://localhost:8080/login.html
在登录窗口中输入对应的租户名,用户名和密码,测试是否能够正常到达主页。可以多增加几个租户和用户,测试用户是否正常切换到对应的租户下。
总结 在这里,我分享了使用Spring Boot+JPA快速实现多租户应用程序的方法,此方法只涉及了实现SaaS应用平台的最核心技术手段,并不是一个完整可用的项目代码,如用户的认证、授权等并未出现在本文中。额外的业务模块感兴趣的朋友可以在此设计基础上自行扩展,如对其中的代码有任何的疑问,欢迎大家在下方给我留言。
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。