一.什么是SAAS系統
SAAS全稱 Software as a Service,軟件即服務。本人接觸SAAS也在近兩年;在我的理解,SAAS不是特指某種系統,它是提供某類產品的系統服務平台,讓第三方公司可以直接在平台上租用一個相對獨立的系統在線使用,比如OA,ERP等各類管理系統。SAAS概念出來之前,公司想要一個管理系統,只能自己招團隊開發,或者請外包公司做一個,但是由於中小型企業本身開發團隊能力限制,而且購買系統成本又太高,加上本身的功能需求並不高,所以這種形式就導致廠商很難抉擇;
SAAS的出現就是解決這一痛點,做一套基本符合中小型企業需求的平台,讓企業通過租用系統的方式來使用系統;SAAS廠商通過數據隔離的方式,讓每個租用系統的企業都只能看到自己的數據;這樣廠商不需要開發和維護團隊,也不需要服務器等硬件資源,直接通過在線的方式就可以使用系統;
SAAS系統的優點是“開箱即用”,因為SAAS廠商做的系統符合大部分企業的需求,只需要租用開通賬號即可馬上使用;SAAS系統的缺點則是定制化服務比較困難,所以SAAS系統的使用者一般是中小型企業;
二. 租戶之間數據隔離方式
SAAS系統直接數據隔離主要分為三種
隔離方式 | 優點 | 缺點 |
單數據庫 | 成本最低 | 基於代碼層面隔離,不適合租戶數量多的廠商,且定制服務開發難度大 |
租戶獨立數據庫 | 租戶數據隔離徹底,定制服務相對方便 | 部署成本略高 |
租戶獨立數據庫+獨立應用 | 隔離程度最高,適合定制服務開發 | 部署成本高,維護成本高 |
三. 代碼實現
本人之前做的SAAS是屬於單數據庫類型,公司考慮成本問題,但是開發起來難度比其他兩種大;這次我將使用獨立數據庫的方式實現多租戶功能
系統庫主要存儲系統開租戶信息和人員信息,負責登入,授權,權限登管理業務應用,配置由系統庫基礎配置+租戶庫定制配置組成。
租戶在進入應用時就全局緩存數據源,對應的租戶庫基礎信息都會在系統和租戶庫同步保證業務所有數據來自於一個數據源。
3.1 數據庫設計
數據庫分為系統庫和租戶數據庫;
平台系統庫,主要用於保存租戶的信息,包含用戶,租戶的數據庫連接信息等,同時平台的管理后台相關數據也將放在此庫中;
---------------------------------------------------------------------------------------------------------------------
其他三張表則是租戶對應的數據庫,便於測試我創建了三張表,表結構都是一致的
---------------------------------------------------------------------------------------------------------------------
在系統庫中配置如下(主要測試可行性,密碼等隱私字段沒有做加密處理)
3.2 項目目錄結構
項目采用架構 springboot+mybaitsplus+mysql+springsecurity+jwt,數據庫連接池使用alibaba的druid。
├── com.lwj.demo ├── common -- 工具類、枚舉、常量等 ├── config -- 配置相關 ├── exception -- 自定義異常和全局異常處理 ├── security -- springsecurity相關,用於處理登錄和接口權限校驗
├── starter -- spring容器初始化完成事件,用於初始化租戶的數據源 ├── tenant -- 租戶相關接口業務 ├── system -- 系統級別相關接口業務 ├── config -- 實現多數據源的核心代碼
3.3 動態數據源如何實現
我們都知道,java提供一個DataSource接口去定義獲取數據庫連接的標准。
spring的org.springframework.jdbc.datasource中的AbstractDataSource抽象類就實現的該接口。我們用springboot時,默認不需要關注數據庫源(DataSource),因為springboot有默認的數據源配置。
如果我們需要自定義數據源時,將我們自己定義數據源對象放入SqlSessionFactory中即可。
spring提供一個AbstractRoutingDataSource的抽象類讓我們可以去選擇不同的數據源,我們實現多數據源的時候就可以使用該抽象類類;通過源碼我們不難看出,該抽象類有兩個map保存我們設置的所有DataSource對象,其中 targetDataSources 是我們初始化的時候設置的所有數據源對象集合;而resolvedDataSources是解析處理之后的數據源集合。resolvedDefaultDataSource則是默認的數據源,在沒有找到其他數據源時使用默認數據源;
AbstractRoutingDataSource實現了InitializingBean接口,在spring容器創建bean對象時,會調用接口的afterPropertiesSet方法。這個方法默認情況下將在所有的屬性被初始化后調用,在init前調用。
所以我們可以看到AbstractRoutingDataSource在afterPropertiesSet方法中就是對targetDataSource進行處理,把里面的key和value它想要的值。
determineTargetDataSource方法則是實現切換數據源的邏輯代碼,在獲取數據庫連接時,該方法會被調用(具體可以看getConnection方法);
可以看到,就是拿當前的key去resolvedDataSources中獲取對應的數據源。其中lookupKey就是通過下面的抽象方法實現的,所以要想實現動態數據源,就得繼承該抽象類,然后重寫determineCurrentLookupKey就行了;當key找不到對應的數據源,並且lenientFallback是ture(是否對默認數據源應用寬限回退)時數據源等於默認數據源;簡單理解就是,如果找不到對應的數據源是否使用默認的數據源;
不過發現AbstractRoutingDataSource雖然可以實現動態數據源的切換,但是resolvedDataSources只會再初始化時加載一次。所以我們如果想在運行的時候加入新的數據源,需要重新設置數據源,再調用afterPropertiesSet方法。
如果數據源多的話,畢竟影響系統運行;所以我直接對AbstractRoutingDataSource源碼進行修改,繼承AbstractDataSource類;方便直接添加數據源,不用重新加載一邊;
3.4 加入到系統中
主要邏輯為,使用TOKEN保存用戶登錄的信息(或者把登錄信息存入redis,和token對應起來),使用AOP攔截請求,解析token之后存入ThreadLocal(線程私有內存)中,自定義的AbstractDataSource對象獲取租戶id對應的DataSource對象,拿到相應的數據庫連接,然后處理對應的業務。流程圖如下所示:
具體代碼:
首先寫連接池配置,因為我們是動態的數據源,所以我這里只寫一份,然后其他連接池直接復用配置信息即可
對應的配置類bean,通過該基本配置類去生成連接池對象;
@Data @Slf4j @ConfigurationProperties(prefix = "spring.datasource.druid") public class DynamicDatabaseProperties { private String driverClassName; private String url; private String username; private String password; private int initialSize; private int maxActive; private int maxWait; private int minIdle; private boolean poolPreparedStatements; private int maxPoolPreparedStatementPerConnectionSize; private String validationQuery; private boolean testOnBorrow; private boolean testOnReturn; private boolean testWhileIdle; private int timeBetweenEvictionRunsMillis; private String filters; private int minEvictableIdleTimeMillis; /** * 獲取租戶的數據源默認配置 * * @param tenantDataInfo 租戶信息 * @param isSystem 是否是系統數據源 * @return com.alibaba.druid.pool.DruidDataSource */ public DruidDataSource getBaseDataSource(TenantData tenantDataInfo, boolean isSystem) { DruidDataSource dds = new DruidDataSource(); if (isSystem) { dds.setUrl(url); dds.setUsername(username); dds.setPassword(password); }else { dds.setUrl(tenantDataInfo.getUrl()); dds.setUsername(tenantDataInfo.getUsername()); dds.setPassword(tenantDataInfo.getPassword()); } dds.setDriverClassName(driverClassName); dds.setInitialSize(initialSize); dds.setMaxActive(maxActive); dds.setMaxWait(maxWait); dds.setMinIdle(minIdle); dds.setPoolPreparedStatements(poolPreparedStatements); dds.setMaxPoolPreparedStatementPerConnectionSize(maxPoolPreparedStatementPerConnectionSize); dds.setValidationQuery(validationQuery); dds.setTestOnBorrow(testOnBorrow); dds.setTestOnReturn(testOnReturn); dds.setTestWhileIdle(testWhileIdle); dds.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis); try { dds.setFilters(filters); } catch (SQLException e) { log.error(e.getMessage(), e); } dds.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis); return dds; } /** * 用戶項目啟動時候初始化所有數據源 * * @param tenantDataInfoList 所有租戶信息 * @param resolvedDataSources 數據源對象Map集合 * @return java.util.Map<java.lang.Object, java.lang.Object> */ public Map<Object, Object> initDataSource(List<TenantData> tenantDataInfoList, Map<Object, DataSource> resolvedDataSources) { Map<Object, Object> targetDataSources = new HashMap<>(resolvedDataSources); for (TenantData tenantDataInfo : tenantDataInfoList) { targetDataSources.put(tenantDataInfo.getTenantId(), getBaseDataSource(tenantDataInfo, false)); } return targetDataSources; } }
配置加載自定義數據源
@Configuration @EnableConfigurationProperties(value = DynamicDatabaseProperties.class) public class DynamicDatabaseConfiguration { private final DynamicDatabaseProperties dynamicDatabaseProperties; public DynamicDatabaseConfiguration(DynamicDatabaseProperties dynamicDatabaseProperties) { this.dynamicDatabaseProperties = dynamicDatabaseProperties; } /** * 設置數據源,默認一個系統數據源 * * @return javax.sql.DataSource */ @Bean @Primary public DataSource multipleDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = CollectionUtils.newHashMap(1); targetDataSources.put(SystemConstant.ROOT_PARENT_ID, systemDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(systemDataSource()); return dynamicDataSource; } /** * 系統數據源,也是默認數據源。連接的是系統數據庫,系統數據源必須先加載,才能獲取租戶的連接信息 * * @return javax.sql.DataSource */ @Bean @Primary public DataSource systemDataSource() { return dynamicDatabaseProperties.getBaseDataSource(null, true); } /** * mybatis的會話工廠,這改成使用自定義動態數據源,覆蓋默認的的SqlSessionFactory * * @return org.apache.ibatis.session.SqlSessionFactory */ @Bean("sqlSessionFactory") public SqlSessionFactory sqlSessionFactory() throws Exception { MybatisSqlSessionFactoryBean sqlSessionFactory = new MybatisSqlSessionFactoryBean(); sqlSessionFactory.setDataSource(multipleDataSource()); MybatisConfiguration configuration = new MybatisConfiguration(); configuration.setJdbcTypeForNull(JdbcType.NULL); configuration.setMapUnderscoreToCamelCase(true); configuration.setCacheEnabled(false); sqlSessionFactory.setConfiguration(configuration); return sqlSessionFactory.getObject(); } @Bean(name = "multipleTransactionManager") @Primary public DataSourceTransactionManager multipleTransactionManager(@Qualifier("multipleDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }
自定義的動態數據源類關鍵代碼
public class DynamicDataSource extends AbstractDataSource implements InitializingBean { @Nullable private Map<Object, Object> targetDataSources; @Nullable private Object defaultTargetDataSource; private boolean lenientFallback = true; private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup(); @Nullable private Map<Object, DataSource> resolvedDataSources; @Nullable private DataSource resolvedDefaultDataSource;/** * 直接加入數據源 * * @param key 數據源的key * @param value 數據源 */ public synchronized void addDataSources(@NonNull Object key, @NonNull DataSource value) { assert this.resolvedDataSources != null; DataSource dataSource = this.resolveSpecifiedDataSource(value); this.resolvedDataSources.put(key, dataSource); } @Override public void afterPropertiesSet() { if (this.targetDataSources == null) { throw new IllegalArgumentException("Property 'targetDataSources' is required"); } else { this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size()); this.targetDataSources.forEach((key, value) -> { Object lookupKey = this.resolveSpecifiedLookupKey(key); DataSource dataSource = this.resolveSpecifiedDataSource(value); this.resolvedDataSources.put(lookupKey, dataSource); }); if (this.defaultTargetDataSource != null) { this.resolvedDefaultDataSource = this.resolveSpecifiedDataSource(this.defaultTargetDataSource); } } }protected DataSource resolveSpecifiedDataSource(Object dataSource) throws IllegalArgumentException { if (dataSource instanceof DataSource) { return (DataSource) dataSource; } else if (dataSource instanceof String) { return this.dataSourceLookup.getDataSource((String) dataSource); } else { throw new IllegalArgumentException("Illegal data source value - only [javax.sql.DataSource] and String supported: " + dataSource); } } @Override public Connection getConnection() throws SQLException { return this.determineTargetDataSource().getConnection(); } @Override public Connection getConnection(String username, String password) throws SQLException { return this.determineTargetDataSource().getConnection(username, password); }protected DataSource determineTargetDataSource() { Assert.notNull(this.resolvedDataSources, "DataSource router not initialized"); Object lookupKey = this.determineCurrentLookupKey(); DataSource dataSource = this.resolvedDataSources.get(lookupKey); if (dataSource == null && (this.lenientFallback || lookupKey == null)) { dataSource = this.resolvedDefaultDataSource; } // try { // DruidDataSource druidDataSource = (DruidDataSource) dataSource; // String url = druidDataSource.getConnection().getMetaData().getURL(); // System.out.println("連接地址:" + url); // } catch (SQLException throwables) { // throwables.printStackTrace(); // } if (dataSource == null) { throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]"); } else { return dataSource; } } protected Object determineCurrentLookupKey() { // 返回要使用的數據源的key LoginInfo tenant = LoginInfoHolder.getTenant(); if (tenant == null) { tenant = new LoginInfo(); } return tenant.getTenantId(); } }
存放當前請求的用戶信息對象,保存在ThreadLocal中
public class LoginInfoHolder { private static final ThreadLocal<LoginInfo> CONTEXT = new ThreadLocal<>(); public static void setTenant(LoginInfo loginInfo) { CONTEXT.set(loginInfo); } public static LoginInfo getTenant() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }
AOP的切面攔截,攔截所有請求,解析token,存入LoginInfoHolder 對象中。這里還加入一個自定義的注解,實現直接對類或者單個接口設置使用的數據源;
@Slf4j @Aspect @Order(1) @Component public class DataSourceAspect { @Pointcut("execution (* com.lwj.demo..controller.*.*(..))") public void dataPointCut() { } @Before("dataPointCut()") public void before(JoinPoint joinPoint) { //獲取請求對象 ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (requestAttributes == null) { return; } HttpServletRequest request = requestAttributes.getRequest(); // 從token中解析用戶信息 String token = request.getHeader(JwtUtil.TOKEN_HEADER); LoginInfo loginInfo = LoginInfo.getLoginInfoByToken(token); if (loginInfo == null) { loginInfo = new LoginInfo(); } MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); MyDataSource myDataSource = null; //優先判斷方法上的注解 if (method.isAnnotationPresent(MyDataSource.class)) { myDataSource = method.getAnnotation(MyDataSource.class); } else if (method.getDeclaringClass().isAnnotationPresent(MyDataSource.class)) { //其次判斷類上的注解 myDataSource = method.getDeclaringClass().getAnnotation(MyDataSource.class); } if (myDataSource != null) { DataSourceType dataSourceType = myDataSource.type(); log.info("this is datasource: " + dataSourceType); if (dataSourceType.equals(DataSourceType.TENANT)) { loginInfo.setTenantId(myDataSource.value()); } } LoginInfoHolder.setTenant(loginInfo); } @After("dataPointCut()") public void after(JoinPoint joinPoint) { LoginInfoHolder.clear(); } }
最后定義ContextRefreshedEvent 事件,會在Spring容器初始化完成會觸發該事件,對所有租戶進行數據源初始化
@Component @Slf4j public class InitDataSourceConfiguration implements ApplicationListener<ContextRefreshedEvent> { private final TenantDataInfoService tenantDataInfoService; private final DynamicDatabaseProperties dynamicDatabaseProperties; @Qualifier("multipleDataSource") @Autowired private DataSource dataSource; public InitDataSourceConfiguration(TenantDataInfoService tenantDataInfoService, DynamicDatabaseProperties dynamicDatabaseProperties) { this.tenantDataInfoService = tenantDataInfoService; this.dynamicDatabaseProperties = dynamicDatabaseProperties; } @Override public void onApplicationEvent(ContextRefreshedEvent event) { if (event.getApplicationContext().getParent() == null) { log.info("----------初始化動態數據源------------"); List<TenantData> tenantData = tenantDataInfoService.listAllTenantDataInfo(); log.info("數據源列表:{}", tenantData); DynamicDataSource dynamicDataSource = (DynamicDataSource) dataSource; Map<Object, DataSource> resolvedDataSources = dynamicDataSource.getResolvedDataSources(); Map<Object, Object> targetDataSources = dynamicDatabaseProperties.initDataSource(tenantData, resolvedDataSources); Set<Map.Entry<Object, Object>> entries = targetDataSources.entrySet(); for (Map.Entry entry : entries) { try { DruidDataSource druidDataSource = (DruidDataSource) entry.getValue(); String url = druidDataSource.getConnection().getMetaData().getURL(); log.debug("連接地址:{}:{}", entry.getKey(), url); } catch (SQLException throwables) { throwables.printStackTrace(); } } dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.afterPropertiesSet(); } } }
對於在啟動中新增數據源,可以用接口實現,這邊我給一個簡單的例子
public boolean addTenantDataInfo(AddDataSourceReqVo reqVo) { Tenant tenant = tenantService.getTenantById(reqVo.getTenantId()); if (tenant == null) { throw new BaseException("租戶不存在"); } TenantData tenantData = new TenantData().setTenantId(reqVo.getTenantId()) .setUrl(reqVo.getUrl()) .setUsername(reqVo.getUsername()) .setPassword(reqVo.getPassword()); boolean save = this.save(tenantData); if (save) { DynamicDataSource dynamicDataSource = (DynamicDataSource) dataSource; dynamicDataSource.addDataSources(tenant.getId(), dynamicDatabaseProperties.getBaseDataSource(tenantData, false)); } return save; }
以上就是具體的實現流程了,接下來測試一下;加入一個簡單測試接口,獲取test表的信息
租戶對應的數據庫,tenant_one,tenant_tow,tenant_three;創建一張test表,信息分別如下
---------------------------------------------------------------------------------------------------------------------
PostMan分別登錄以下用戶,租戶對應 1、2、3;獲取token,然后再調用測試接口;
登錄接口,獲取token
訪問測試接口,結果如下:
---------------------------------------------------------------------------------------------------------------------
---------------------------------------------------------------------------------------------------------------------
可以看到,接口和參數都一樣,但是不同的token,返回的結果是對應租戶的數據;
整個項目流程還是比較簡單的,我這邊只是給出一個解決方案,如果大家有更好的方案可以留言交流。
項目源碼:https://github.com/TomHusky/dynamic-data-source.git