Spring Boot 構建多租戶SaaS平台核心技術指南

  • 2020 年 3 月 11 日
  • 筆記

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;      @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 {        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;      @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()}          );              lb.setPersistenceUnitName("master-database-persistence-unit");              JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();          lb.setJpaVendorAdapter(vendorAdapter);              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. 配置攔截器

我們需要提供一個租戶資訊的攔截器,用以獲取租戶標識符,其源程式碼和配置攔截器的源程式碼如下:

@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;      public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {          private static final String DEFAULT_TENANT = "tenant_1";          @Override      public String resolveCurrentTenantIdentifier() {            String tenant = TenantContextHolder.getTenant();            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的基本特性是否具備。登錄控制器程式碼如下:

@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應用平台的最核心技術手段,並不是一個完整可用的項目程式碼,如用戶的認證、授權等並未出現在本文中。額外的業務模組感興趣的朋友可以在此設計基礎上自行擴展.

END

Tags: