本次教程所涉及到的原始碼已上傳至Github,如果你不需要繼續閱讀下面的內容,你可以直接點選此連結獲取原始碼內容。github.com/ramostear/u…
1. 概述
筆者從2014年開始接觸SaaS(Software as a Service),即多租戶(或多承租)軟體應用平臺;並一直從事相關領域的架構設計及研發工作。機緣巧合,在筆者本科畢業設計時完成了一個基於SaaS的高效財務管理平臺的課題研究,從中收穫頗多。最早接觸SaaS時,國內相關資源匱乏,唯一有的參照資料是《網際網路時代的軟體革命:SaaS架構設計》(葉偉等著)一書。最後課題的實現是基於OSGI(Open Service Gateway Initiative)Java動態模組化系統規範來實現的。
時至今日,五年的時間過去了,軟體開發的技術發生了巨大的改變,筆者所實現SaaS平臺的技術棧也更新了好幾波,真是印證了那就話:“山重水盡疑無路,柳暗花明又一村”。基於之前走過的許多彎路和踩過的坑,以及近段時間有許多網友問我如何使用Spring Boot實現多租戶系統,決定寫一篇文章聊一聊關於SaaS的硬核技術。
說起SaaS,它只是一種軟體架構,並沒有多少神祕的東西,也不是什麼很難的系統,我個人的感覺,SaaS平臺的難度在於商業上的運營,而非技術上的實現。就技術上來說,SaaS是這樣一種架構模式:它讓多個不同環境的使用者使用同一套應用程式,且保證使用者之間的資料相互隔離。現在想想看,這也有點共享經濟的味道在裡面。
筆者在這裡就不再深入聊SaaS軟體成熟度模型和資料隔離方案對比的事情了。今天要聊的是使用Spring Boot快速構建獨立資料庫/共享資料庫獨立Schema的多租戶系統。我將提供一個SaaS系統最核心的技術實現,而其他的部分有興趣的朋友可以在此基礎上自行擴充套件。
2. 嘗試瞭解多租戶的應用場景
假設我們需要開發一個應用程式,並且希望將同一個應用程式銷售給N家客戶使用。在常規情況下,我們需要為此建立N個Web伺服器(Tomcat),N個資料庫(DB),併為N個客戶部署相同的應用程式N次。現在,如果我們的應用程式進行了升級或者做了其他任何的改動,那麼我們就需要更新N個應用程式同時還需要維護N臺伺服器。接下來,如果業務開始增長,客戶由原來的N個變成了現在的N+M個,我們將面臨N個應用程式和M個應用程式版本維護,裝置維護以及成本控制的問題。運維幾乎要哭死在機房了...
為了解決上述的問題,我們可以開發多租戶應用程式,我們可以根據當前使用者是誰,從而選擇對應的資料庫。例如,當請求來自A公司的使用者時,應用程式就連線A公司的資料庫,當請求來自B公司的使用者時,自動將資料庫切換到B公司資料庫,以此類推。從理論上將沒有什麼問題,但我們如果考慮將現有的應用程式改造成SaaS模式,我們將遇到第一個問題:如果識別請求來自哪一個租戶?如何自動切換資料來源?
3. 維護、識別和路由租戶資料來源
我們可以提供一個獨立的庫來存放租戶資訊,如資料庫名稱、連結地址、使用者名稱、密碼等,這可以統一的解決租戶資訊維護的問題。租戶的識別和路由有很多種方法可以解決,下面列舉幾個常用的方式:
- 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動態配置資料來源的能力,為當前請求設定資料來源,最後之前使用者的請求。這樣我們就只需要在應用程式中維護一份資料來源配置資訊(租戶資料庫配置庫),其餘的資料來源動態查詢配置。接下來,我們將快速的演示這一功能。
4. 專案構建
我們將使用Spring Boot 2.1.5版本來實現這一演示專案,首先你需要在Maven配置檔案中加入如下的一些配置:
<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);
}
}
複製程式碼
最後,讓我們看看整個專案的結構:
5. 實現租戶資料來源查詢模組
我們將定義一個實體類存放租戶資料來源資訊,它包含了租戶名,資料庫連線地址,使用者名稱和密碼等資訊,其程式碼如下:
@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;
/**
* @author : Created by Tan Chaohong (alias:ramostear)
* @create-time 2019/5/25 0025-8:26
* @modify by :
* @since:
*/
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;
}
}
複製程式碼
在改配置類中,我們主要提供包掃描路徑,實體管理工程,事務管理器和資料來源配置引數的配置。
6. 實現租戶業務模組
在此小節中,租戶業務模組我們僅提供一個使用者登入的場景來演示SaaS的功能。其實體層、業務層和持久化層根普通的Spring Boot Web專案沒有什麼區別,你甚至感覺不到它是一個SaaS應用程式的程式碼。
首先,建立一個使用者實體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>{
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生成器。
7. 配置攔截器
我們需要提供一個租戶資訊的攔截器,用以獲取租戶識別符號,其原始碼和配置攔截器的原始碼如下:
/**
* @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是系統的登入路徑,我們需要將其排除在攔截器攔截的範圍之外,否則我們永遠無法進行登入
8. 維護租戶標識資訊
在這裡,我們使用ThreadLocal來存放租戶標識資訊,為動態設定資料來源提供資料支援,該類提供了設定租戶標識、獲取租戶標識以及清除租戶標識三個靜態方法。其原始碼如下:
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();
}
}
複製程式碼
此類時實現動態資料來源設定的關鍵
9. 動態資料來源切換
要實現動態資料來源切換,我們需要藉助兩個類來完成,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;
/**
* @author : Created by Tan Chaohong (alias:ramostear)
* @create-time 2019/5/26 0026-22:38
* @modify by :
* @since:
*/
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {
/**
* 預設的租戶ID
*/
private static final String DEFAULT_TENANT = "tenant_1";
/**
* 解析當前租戶的ID
* @return
*/
@Override
public String resolveCurrentTenantIdentifier() {
//通過租戶上下文獲取租戶ID,此ID是使用者登入時在header中進行設定的
String tenant = TenantContextHolder.getTenant();
//如果上下文中沒有找到該租戶ID,則使用預設的租戶ID,或者直接報異常資訊
return StringUtils.isNotBlank(tenant)?tenant:DEFAULT_TENANT;
}
@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<MasterTenant> 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在執行資料庫操作命令前,應該設定什麼樣的資料庫連線資訊,以及使用者名稱和密碼等資訊。
10. 應用測試
最後,我們通過一個簡單的登入案例來測試本次課程中的SaaS應用程式,為此,需要提供一個Controller用於處理使用者登入邏輯。在本案例中,沒有嚴格的對使用者密碼進行加密,而是使用明文進行比對,也沒有提供任何的許可權認證框架,知識單純的驗證SaaS的基本特性是否具備。登入控制器程式碼如下:
/**
* @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應用平臺的最核心技術手段,並不是一個完整可用的專案程式碼,如使用者的認證、授權等並未出現在本文中。額外的業務模組感興趣的朋友可以在此設計基礎上自行擴充套件,如對其中的程式碼有任何的疑問,歡迎大家在下方給我留言。
原文:www.ramostear.com/articles/sp…
作者:譚朝紅