如何使用MyBatis的plugin插件實現多租戶的數據過濾?

如何實現多租戶數據隔離

在中臺服務或者saas服務中,當多租戶入駐時,如何保證不同租戶的數據隔離性呢?通常的解決方法有三種,分別如下:

  1. 一個租戶一個獨立數據庫,這種方案的用戶數據隔離級別最高,安全性最好,但成本也高。
  2. 所有租戶共享數據庫,但一個租戶一個數據庫表。這種方案爲安全性要求較高的租戶提供了一定程度的邏輯數據隔離,並不是完全隔離,每個數據庫可以支持更多的租戶數量。
  3. 所有租戶共享數據庫,共享同一個數據庫表,不同的租戶數據通過租戶的標識區分。這種方案共享程度最高、隔離級別最低。

通常爲了降低成本,一般會選擇第三種方案。這時,應該如何快速的實現多租戶的數據隔離呢?在每一個查詢語句中都添加上不同租戶的標識語句麼?基於mybatis提供的plugin插件,可以實現多租戶過濾語句的橫切邏輯,類似於AOP,讓我們的業務代碼從數據隔離的邏輯中抽離出來,專注於業務開發。

基於MyBatis插件plugin的實現

在MyBatis 中,通過其plugin插件機制,可以實現類似於AOP的橫切邏輯編程,允許你在映射語句執行過程中的某一點進行攔截調用。定義了Interceptor 接口,實現指定方法的攔截,官網示例代碼如下:

// ExamplePlugin.java
@Intercepts({@Signature(
  type= Executor.class,
  method = "update",
  args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
  private Properties properties = new Properties();
  public Object intercept(Invocation invocation) throws Throwable {
    // implement pre processing if need
    // 攔截執行方法之前的邏輯處理

    Object returnObject = invocation.proceed();

    // implement post processing if need
     // 攔截執行方法之後的邏輯處理
    return returnObject;
  }
  public void setProperties(Properties properties) {
    this.properties = properties;
  }
}

實現多租戶的攔截過程中,通過對query操作進行攔截,實現了多租戶過濾的如下功能:

  1. 可以動態設置多租戶查詢的開關,支持單個或者多個查詢值的查詢。
  2. 可以自定義多租戶過濾的數據庫字段,自定義查詢數據庫的別名設置,在多個JOIN關聯查詢中設置過濾的查詢條件。
  3. 可以自定義多租戶過濾查詢的查詢條件,例如,單個查詢值的相等條件過濾,多個查詢值的IN條件過濾

其大致的流程如下:

MultiTenancyQueryInterceptor多租戶過濾器

在實現中,定義MultiTenancyQueryInterceptor實現Interceptor實現如上流程的邏輯。其源碼如下:

@Intercepts({
         // 攔截query查詢語句
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class MultiTenancyQueryInterceptor implements Interceptor {

    private static final String WHERE_CONDITION = " where ";
    private static final String AND_CONDITION = " and ";
    /**
     * 條件生成Factory
     */
    private final ConditionFactory conditionFactory;
    /**
     * 多組合屬性
     */
    private final MultiTenancyProperties multiTenancyProperties;

    public MultiTenancyQueryInterceptor() {
        this.conditionFactory = new DefaultConditionFactory();
        this.multiTenancyProperties = new MultiTenancyProperties();
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        Object[] args = invocation.getArgs();
        Object parameter = args[1];
        // 判斷是否需要進行多租戶過濾
        if (!this.isMatchMultiTenancy(parameter)) {

            log.info("parameter is not match multi tenancy query!");
            return invocation.proceed();
        }
        // 如果多租戶過濾時,多租戶過濾數據庫字段爲空或者查詢值爲空則拋出異常
        MultiTenancyQuery multiTenancyQuery = (MultiTenancyQuery) parameter;
        if (StringUtils.isBlank(this.multiTenancyProperties.getMultiTenancyQueryColumn())
                || Objects.isNull(multiTenancyQuery.getMultiTenancyQueryValue())) {

            log.error("property {} or parameter {} is invalid!", JSON.toJSONString(this.multiTenancyProperties), JSON.toJSONString(parameter));
            throw new RuntimeException("property or parameter is invalid!");
        }
        MappedStatement mappedStatement = (MappedStatement) args[0];
        BoundSql boundSql = mappedStatement.getBoundSql(parameter);
        String originSql = boundSql.getSql();
        if (!this.matchPreTableName(originSql, multiTenancyQuery.getPreTableName())) {

            log.info("pre table name {} is not matched sql {}!", multiTenancyQuery.getPreTableName(), originSql);
            return invocation.proceed();
        }
        // 默認使用In 條件
        ConditionFactory.ConditionTypeEnum conditionTypeEnum = ConditionFactory.ConditionTypeEnum.IN;
        String conditionType;
        if (StringUtils.isNotBlank(conditionType = this.multiTenancyProperties.getConditionType())) {
            try {
                conditionTypeEnum = ConditionFactory.ConditionTypeEnum.valueOf(conditionType.toUpperCase());
            } catch (Exception e) {
                log.warn("invalid condition type {}!", conditionType);
            }
        }
        // 根據配置的查詢條件規格生成過濾查詢語句
        String multiTenancyQueryCondition = this.conditionFactory.buildCondition(
                conditionTypeEnum, this.multiTenancyProperties.getMultiTenancyQueryColumn(), multiTenancyQuery);
        String newSql = this.appendWhereCondition(originSql, multiTenancyQueryCondition);
        // 使用反射替換BoundSql的sql語句
        Reflections.setFieldValue(boundSql, "sql", newSql);
        // 把新的查詢放到statement裏
        MappedStatement newMs = copyFromMappedStatement(mappedStatement, parameterObject -> boundSql);
        args[0] = newMs;
        // 執行帶有過濾查詢的語句
        return invocation.proceed();
    }


   /**
     * 追加查詢過濾查詢條件
     */
    private String appendWhereCondition(String originSql, String condition) {

        if (StringUtils.isBlank(originSql) || StringUtils.isBlank(condition)) {
            return originSql;
        }
        String[] sqlSplit = originSql.toLowerCase().split(WHERE_CONDITION.trim());
        // 沒有查詢條件
        if (this.noWhereCondition(sqlSplit)) {
            return originSql + WHERE_CONDITION + condition;
        }
        // 包含查詢條件,添加到第一個查詢條件的位置
        else {
            String sqlBeforeWhere = sqlSplit[0];
            String sqlAfterWhere = sqlSplit[1];
            return sqlBeforeWhere + WHERE_CONDITION + condition + AND_CONDITION + sqlAfterWhere;
        }
    }

   /**
     * 沒有查詢條件
     */
    private boolean noWhereCondition(String[] sqlSplit) {
        return ArrayUtils.isNotEmpty(sqlSplit) && 1 == sqlSplit.length;
    }

    private boolean matchPreTableName(String sql, String preTableName) {

        if (StringUtils.isBlank(preTableName)) {
            return true;
        } else {
            return StringUtils.containsIgnoreCase(sql, preTableName);
        }
    }

    private boolean isMatchMultiTenancy(Object parameter) {

        return Objects.nonNull(parameter)
                && parameter instanceof MultiTenancyQuery
                && ((MultiTenancyQuery) parameter).isFiltered();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

        Object multiTenancyQueryColumn;
        if (Objects.nonNull(multiTenancyQueryColumn = properties.get(MULTI_TENANCY_QUERY_COLUMN_PROPERTY))) {
            multiTenancyProperties.setMultiTenancyQueryColumn(multiTenancyQueryColumn.toString());
        }
        Object conditionType;
        if (Objects.nonNull(conditionType = properties.get(CONDITION_TYPE_PROPERTY))) {
            multiTenancyProperties.setConditionType(conditionType.toString());
        }
    }

    private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
            builder.keyProperty(ms.getKeyProperties()[0]);
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }
}

在使用時,定義多租戶插件,設置過濾的數據庫字段;在查詢時,設置過濾的查詢值,數據庫表的前綴別名,過濾開關。以商品查詢爲例,可以根據商品code進行查詢,定義根據company_id進行數據過濾,定義數據庫的前綴別名爲g,示例如下:

/**
 * mybatis插件配置
 **/    
//MybatisPlusConfig.class    
    @Bean
    public MultiTenancyQueryInterceptor platformQueryInterceptor() {
        MultiTenancyQueryInterceptor platformQueryInterceptor = new MultiTenancyQueryInterceptor();
        Properties properties = new Properties();
        // 需要過濾的數據庫字段
        properties.setProperty(MULTI_TENANCY_QUERY_COLUMN_PROPERTY, "company_id");
        platformQueryInterceptor.setProperties(properties);
        return platformQueryInterceptor;
    }

/**
 * MultiTenancyQuery 查詢參數設置
 **/  
    @Test
    public void successToMultiTenancyQuery() {

        GoodsQuery goodsQuery = new GoodsQuery();
        // 設置商品code查詢參數
        goodsQuery.setCode("phone");
        goodsQuery.setFiltered(true);
        // 設置company_id對應的查詢值
        goodsQuery.setMultiTenancyQueryValue(Lists.list(1, 2));
        // 設置sql查詢的數據庫前綴名
        goodsQuery.setPreTableName("g");
        List<Goods> goodsList = goodsService.findList(goodsQuery);
        Assert.assertNotNull(goodsList);
    }


     // sql 語句
    <select id="findList" resultType="goods">

        SELECT
        g.id AS 'id',
        g.name AS 'name',
        g.code AS 'code',
        g.size AS 'size',
        g.weight AS 'weight',
        g.description AS 'description',
        g.type AS 'type',
        g.state AS 'state'
        FROM boutique_goods g
        <where>
            <!-- 只定義了code查詢條件 -->
            <if test="null!=code and ''!=code">
                g.code=#{code}
            </if>
        </where>
    </select>

 測試運行結果,包含了多租戶g.company_id in(1,2)的過濾查詢條件,並且能夠查詢結果大小爲1,結果如圖:

解析查詢參數MultiTenancyQuery與配置參數MultiTenancyProperties

定義多租戶查詢條件定義MultiTenancyQuery,可以設置執行過濾操作的開關,過濾查詢值,以及數據庫查詢別名,其源碼如下:

public class MultiTenancyQuery implements Serializable {
    private static final long serialVersionUID = -5841093611020112607L;
    /**
     * 多租戶過濾值
     */
    protected Object multiTenancyQueryValue;
    /**
     * 可以過濾的
     */
    protected boolean isFiltered;
    /**
     * 數據庫表前綴名
     */
    protected String preTableName;

    public MultiTenancyQuery() {
        // 默認不執行多租戶過濾
        this.isFiltered = false;
    }
}

定義MultiTenancyProperties,設置多租戶中過濾的數據庫字段,已經查詢條件的設置,現在實現了相等條件和IN條件的查詢條件實現方式,其源碼如下:

public class MultiTenancyProperties implements Serializable {

    private static final long serialVersionUID = -1982635513027523884L;

    public static final String MULTI_TENANCY_QUERY_COLUMN_PROPERTY = "multiTenancyQueryColumn";
    public static final String CONDITION_TYPE_PROPERTY = "conditionType";
    /**
     * 租戶的字段名稱
     */
    private String multiTenancyQueryColumn;
    /**
     * 租戶字段查詢條件
     * {@link ConditionFactory.ConditionTypeEnum}
     */
    private String conditionType;

    public MultiTenancyProperties() {
        // 默認使用IN 條件,例如 id in(1,2,3)
        this.conditionType = ConditionFactory.ConditionTypeEnum.IN.name();
    }
}

 解析查詢語句的生成規格ConditionFactory以及條件語句的追加邏輯

定義ConditionFactory接口實現查詢sql查詢語句的生成,其默認實現類DefaultConditionFactory實現了相等條件和IN條件的查詢語句語法,其源碼如下:

public class DefaultConditionFactory implements ConditionFactory {

    private static final String EQUAL_CONDITION = "=";
    private static final String IN_CONDITION = " in ";
    private final DBColumnValueFactory columnValueFactory;

    public DefaultConditionFactory() {
        this.columnValueFactory = new DefaultDBColumnValueFactory();
    }


    @Override
    public String buildCondition(ConditionTypeEnum conditionType, String multiTenancyQueryColumn, MultiTenancyQuery multiTenancyQuery) {

        StringBuilder stringBuilder = new StringBuilder();
        String columnValue = this.columnValueFactory.buildColumnValue(multiTenancyQuery.getMultiTenancyQueryValue());
        // 根據條件類型設置查詢條件
        switch (conditionType) {
            // IN條件
            case IN:
                stringBuilder
                        .append(multiTenancyQueryColumn)
                        .append(IN_CONDITION)
                        .append("(")
                        .append(columnValue)
                        .append(")");
                break;
            // 相等條件
            case EQUAL:
            default:
                stringBuilder
                        .append(multiTenancyQueryColumn)
                        .append(EQUAL_CONDITION)
                        .append(columnValue);
                break;
        }
        // 設置數據庫表別名
        String preTableName;
        if (StringUtils.isNotBlank(preTableName = multiTenancyQuery.getPreTableName())) {
            stringBuilder.insert(0, ".")
                    .insert(0, preTableName);
        }
        return stringBuilder.toString();
    }
}

在原生的sql查詢語句新增自定義的查詢條件方法,是根據是否存在where查詢條件字段進行動態的拼接。如果沒有查詢條件則直接添加,反之,則添加到第一個查詢條件的位置。其源碼如下:

// MultiTenancyQueryInterceptor.class 
private String appendWhereCondition(String originSql, String condition) {

        if (StringUtils.isBlank(originSql) || StringUtils.isBlank(condition)) {
            return originSql;
        }
        String[] sqlSplit = originSql.toLowerCase().split(WHERE_CONDITION.trim());
        // 沒有查詢條件
        if (this.noWhereCondition(sqlSplit)) {
            return originSql + WHERE_CONDITION + condition;
        }
        // 包含查詢條件,添加到第一個查詢條件的位置
        else {
            String sqlBeforeWhere = sqlSplit[0];
            String sqlAfterWhere = sqlSplit[1];
            return sqlBeforeWhere + WHERE_CONDITION + condition + AND_CONDITION + sqlAfterWhere;
        }
    }

多租戶攔截器全部源碼可以從多租戶數據攔截器插件下載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章