­

SaaS 系統架構,Spring Boot 動態數據源實現!

這段時候在準備從零開始做一套SaaS系統,之前的經驗都是開發單資料庫系統並沒有接觸過SaaS系統,所以接到這個任務的時候也有也些頭疼,不過辦法部比困難多,難得的機會。

在網上找了很多關於SaaS的資料,看完後使我受益匪淺,寫文章之前也一直在關注SaaS系統的開發,通過幾天的探索也有一些方向。

多租戶系統首先要解決的問題就是如何組織租戶的數據問題,通常情況有三種解決方案:

按數據的隔離級別依次為:

  1. 一個租戶一個資料庫實例(資料庫級)
  2. 一個租戶一個Schema (Schema)
  3. 每個租戶都存儲在一個資料庫 (行級)

以上三種數據組織方案網上都有一些介紹,就不多啰嗦了。理解三種隔離模式後,起初覺得還是蠻簡單的真正開始實施的時候困難不少。

租戶標識介面

定義一個TenantInfo來標識租戶資訊,關於獲取當前租戶的方式,後面會再提到。

public interface TenantInfo {

    /**
     * 獲取租戶id
     * @return
     */
    Long getId();


    /**
     * 租戶數據模式
     * @return
     */
    Integer getSchema();


    /**
     * 租戶資料庫資訊
     * @return
     */
    TenantDatabase getDatabase();

    /**
     * 獲取當前租戶資訊
     * @return
     */
    static Optional<TenantInfo> current(){
        return Optional.ofNullable(
                TenantInfoHolder.get()
        );
    }
}

DataSource 路由

以前開發的系統基本都是一個DataSource,但是切換為多租戶後我暫時分了兩種數據源:

  • 租戶數據源(TenantDataSource)
  • 系統數據源(SystemDataSource)

起初我的設想是使用Schema級但是由於是使用的Mysql中的SchemaDatabase是差不多的概念,所以後來的實現是基於資料庫級的。使用資料庫級的因為是系統是基於企業級用戶的,數據都比較重要,企業客戶很看重數據安全性方面的問題。

下面來一步步的解決動態數據源的問題。

DataSource 枚舉


public enum DataSourceType {
    /**
     * 系統數據源
     */
    SYSTEM,
    /**
     * 多租戶數據源
     */
    TENANT,
}

DataSource 註解

定義DataSourceType枚舉後,然後定義一個DataSource註解,名稱可以隨意,一時沒想到好名稱,大家看的時候不要跟javax.sql.DataSource類混淆了:

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DataSource {


    /**
     * 數據源key
     * @return
     */
    com.csbaic.datasource.core.DataSourceType value() default com.csbaic.datasource.core.DataSourceType.SYSTEM;


}

處理 SpringBoot 自動裝配的 DataSource

如果你熟悉SpringBoot,應該知道有一個DataSourceAutoConfiguration配置會自動創建一個javax.sql.DataSource,由於在多租戶環境下隨時都有可能要切換數據源,所以需要將自動裝配的javax.sql.DataSource替換掉:

@Slf4j
public class DataSourceBeanPostProcessor implements BeanPostProcessor {


    @Autowired
    private  ObjectProvider<RoutingDataSourceProperties> dataSourceProperties;

    @Autowired
    private  ObjectProvider<TenantDataSourceFactory> factory;



    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {

        if(bean instanceof DataSource){
            log.debug("process DataSource: {}", bean.getClass().getName());
            return new RoutingDataSource((DataSource) bean, factory, dataSourceProperties);
        }


        return bean;
    }
}

基於BeanPostProcessor的處理,將自動裝配的數據源替換成RoutingDataSource,關於RoutingDataSource後面會再提到。這樣可將自動裝配的數據源直接作為系統數據源其他需要使用數據源的地方不用特殊處理,也不需要在每個服務中排除DataSourceAutoConfiguration的自動裝配。

使用 ThreadLocal 保存數據源類型

數據源的切換是根據前面提到的數據源類型枚舉DataSourceType來的,當需要切換不到的數據源時將對應的數據源類型設置進ThreadLocal中:


public class DataSourceHolder {

    private static final ThreadLocal<Stack<DataSourceType>> datasources = new ThreadLocal<>();

    /**
     * 獲取當前執行緒數據源
     * @return
     */
    public static DataSourceType get(){
        Stack<DataSourceType> stack = datasources.get();
        return stack != null ? stack.peek() : null;
    }


    /**
     * 設置當前執行緒數據源
     * @param type
     */
    public static void push(DataSourceType type){
        Stack<DataSourceType> stack = datasources.get();
        if(stack == null){
            stack = new Stack<>();
            datasources.set(stack);
        }

        stack.push(type);
    }


    /**
     * 移除數據源配置
     */
    public static void remove(){
        Stack<DataSourceType> stack = datasources.get();
        if(stack == null){
            return;
        }

        stack.pop();

        if(stack.isEmpty()){
            datasources.remove();
        }
    }

}

DataSourceHolder.datasources是使用的Stack而不是直接持有DataSource這樣會稍微靈活一點,試想一下從方法A中調用方法B,A,B方法中各自要操作不同的數據源,當方法B執行完成後,回到方法A中,如果是在ThreadLocal直接持有DataSource的話,A方法繼續操作就會對數據源產生不確定性。

AOP 切換數據源

要是在每個類方法都需要手機切換數據源,那也太不方便了,得益於AOP編程可以在調用需要切換數據源的方法的時候做一些手腳:


@Slf4j
@Aspect
public class DataSourceAspect {


    @Pointcut(value = "(@within(com.csbaic.datasource.annotation.DataSource) || @annotation(com.csbaic.datasource.annotation.DataSource)) && within(com.csbaic..*)")
    public void dataPointCut(){

    }

    @Before("dataPointCut()")
    public void before(JoinPoint joinPoint){
        Class<?> aClass = joinPoint.getTarget().getClass();
        // 獲取類級別註解
        DataSource classAnnotation = aClass.getAnnotation(DataSource.class);
        if (classAnnotation != null){
            com.csbaic.datasource.core.DataSourceType dataSource = classAnnotation.value();
            log.info("this is datasource: "+ dataSource);
            DataSourceHolder.push(dataSource);
        }else {
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            Method method = methodSignature.getMethod();
            DataSource methodAnnotation = method.getAnnotation(DataSource.class);
            if (methodAnnotation != null){
                com.csbaic.datasource.core.DataSourceType dataSource = methodAnnotation.value();
                log.info("this is dataSource: "+ dataSource);
                DataSourceHolder.push(dataSource);
            }
        }
    }
    @After("dataPointCut()")
    public void after(JoinPoint joinPoint){
        log.info("執行完畢!");
        DataSourceHolder.remove();
    }
}

DataSourceAspect很簡單在有com.csbaic.datasource.annotation.DataSource註解的方法或者類中切換、還原使用DataSourceHolder類切換數據源。

動態獲取、構造數據源

前面說了那麼多都是在為獲取、構建數據源做準備工作,一但數據源切換成功,業務服務獲取數據時就會使用javax.sql.DataSource獲取資料庫連接,這裡就要說到RoutingDataSource了:


@Slf4j
public class RoutingDataSource extends AbstractDataSource {


    /**
     * 已保存的DataSource
     */
    private final DataSource systemDataSource;

    /**
     * 租戶數據源工廠
     */
    private final ObjectProvider<TenantDataSourceFactory> factory;



    /**
     * 解析數據源
     * @return
     */
    protected DataSource resolveDataSource(){

        DataSourceType type =  DataSourceHolder.get();

        RoutingDataSourceProperties pros = properties.getIfAvailable();
        TenantDataSourceFactory tenantDataSourceFactory = factory.getIfAvailable();




        if(tenantDataSourceFactory == null){
            throw new DataSourceLookupFailureException("租戶數據源不正確");
        }

        if(pros == null){
            throw new DataSourceLookupFailureException("數據源屬性不正確");
        }

        if(type == null){
            log.warn("沒有顯示的設置數據源,使用默認數據源:{}", pros.getDefaultType());
            type = pros.getDefaultType();
        }



        log.warn("數據源類型:{}", type);
        if(type == DataSourceType.SYSTEM){
            return systemDataSource;
        }else if(type == DataSourceType.TENANT){
            return tenantDataSourceFactory.create();
        }

        throw new DataSourceLookupFailureException("解析數據源失敗");
    }
}

resolveDataSource方法中,首先獲取數據源類型:

 DataSourceType type =  DataSourceHolder.get();

然後根據數據源類型獲取數據源:


    if(type == DataSourceType.SYSTEM){
        return systemDataSource;
    }else if(type == DataSourceType.TENANT){
        return tenantDataSourceFactory.create();
    }

系統類型的數據源較簡單直接返回,在租戶類型的數據時就要作額外的操作,如果是資料庫級的隔離模式就需要為每個租戶創建數據源,這裡封裝了一個TenantDataSourceFactory來構建租戶數據源:

public interface TenantDataSourceFactory {


    /**
     * 構建一個數據源
     * @return
     */
    DataSource create();


    /**
     * 構建一個數據源
     * @return
     */
    DataSource create(TenantInfo info);
}

實現方面大致就是從系統數據源中獲取租戶的數據源配置資訊,然後構造一個javax.sql.DataSource

注意:租戶數據源一定要快取起來,每次都構建太浪費。。。

小結

經過上面的一系統配置後,相信切換數據已經可以實現了。業務程式碼不關心使用的數據源,後續切換成隔離模式也比較方便。但是呢,總覺得只支援一種隔離模式又不太好,隔離模式更高的模式也可以作為收費項的麻。。。

使用 Mybatis Plus 實現行級隔離模式

上前提到動態數據源都是基於資料庫級的,一個租戶一個資料庫消耗還是很大的,難達到SaaS的規模效應,一但租戶增多資料庫管理、運維都是成本。

比如有些試用用戶不一定用購買只是想試用,直接開個資料庫也麻煩,況且前期開發也麻煩的很,數據備份、還原、欄位修改都要花時間和人力的,所以能不能同時支援多種數據隔離模式呢?答案是肯定的,利益於Mybatis Plus可的多租戶 SQL 解析器以輕鬆實現,詳細文檔可參考:

多租戶 SQL 解析器://mp.baomidou.com/guide/tenant.html

只需要配置TenantSqlParserTenantHandler就可以實現行級的數據隔離模式:

public class RowTenantHandler implements TenantHandler {


    @Override
    public Expression getTenantId(boolean where) {
        TenantInfo tenantInfo = TenantInfo.current().orElse(null);
        if(tenantInfo == null){
            throw new IllegalStateException("No tenant");
        }

        return new LongValue(tenantInfo.getId());
    }

    @Override
    public String getTenantIdColumn() {
        return TenantConts.TENANT_COLUMN_NAME;
    }

    @Override
    public boolean doTableFilter(String tableName) {
        TenantInfo tenantInfo = TenantInfo.current().orElse(null);

        //忽略系統表或者沒有解析到租戶id,直接過濾
        return tenantInfo == null || tableName.startsWith(SystemInfo.SYS_TABLE_PREFIX);
    }
}

回想一下上面使用的TenantDataSourceFactory介面,對於行級的隔離模式,構造不同的數據源就可以了。

如何解析當前租戶資訊?

多租戶環境下,對於每一個http請求可能是對系統數據或者租戶數據的操作,如何區分租戶也是個問題。

以下列舉幾種解析租戶的方式:

  • 系統為每個用戶生成一個二級域名如:tenant-{id}.csbaic.com業務系統使用HostOriginX-Forwarded-Host等請求頭按指定的模式解析租戶
  • 前端攜帶租戶id參數如:www.csbaic.com?tenantId=xxx
  • 根據請求uri路徑獲取如:www.csbaic.com/api/{tenantId}
  • 解析前端傳遞的token,獲取租戶資訊
  • 租戶自定義域名解析,有些功能租戶可以綁定自己的域名

解析方式現在大概只知道這些,如果有好的方案歡迎大家補充。為了以為擴展方便定義一個TenantResolver介面:


/**
 * 解析租戶
 */
public interface TenantResolver {


    /**
     * 從請求中解析租戶資訊
     * @param request 當前請求
     * @return
     */
    Long resolve(HttpServletRequest request);
}

然後可以將所有的解析方式都聚合起來統一處理:


    /**
     *
     * @param domainMapper
     * @return
     */
    @Bean
    public TenantResolver tenantConsoleTenantResolver(TenantDomainMapper domainMapper, ITokenService tokenService){
        return new CompositeTenantResolver(
                new SysDomainTenantResolver(),
                new RequestHeaderTenantResolver(),
                new RequestQueryTenantResolver(),
                new TokenTenantResolver(tokenService),
                new CustomDomainTenantResolver(domainMapper)
        );
    }

最後再定義一個Filter來調用解析器,解析租戶:

public class UaaTenantServiceFilter implements Filter {


    private final TenantInfoService tenantInfoService;


    public UaaTenantServiceFilter(TenantInfoService tenantInfoService) {
        this.tenantInfoService = tenantInfoService;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        //從request解析租戶資訊
        try{
            TenantInfo tenantInfo = tenantInfoService.getTenantInfo((HttpServletRequest) request);
            TenantInfoHolder.set(tenantInfo);
            chain.doFilter(request,response);
        }finally {
            TenantInfoHolder.remove();
        }


    }
}

TenantInfoService是獲取租戶資訊的介面,內部還是通過TenantResolver來解析租戶Id,然後通過id從系統資料庫獲取當前租戶的資訊。

總結

解決完動態數據源、租戶資訊獲取兩個問題後,只是一小步,後續還有很多問題需要處理如:系統許可權和租戶許可權、統一登陸和鑒權、數據統計等等。。。,相信這些問題都會解決的,後續再來分享。

推薦閱讀

學習資料分享

12 套 微服務、Spring Boot、Spring Cloud 核心技術資料,這是部分資料目錄:

  • Spring Security 認證與授權
  • Spring Boot 項目實戰(中小型互聯網公司後台服務架構與運維架構)
  • Spring Boot 項目實戰(企業許可權管理項目))
  • Spring Cloud 微服務架構項目實戰(分散式事務解決方案)

公眾號後台回復arch028獲取資料::

Tags: