如何實現多租戶數據隔離
在中臺服務或者saas服務中,當多租戶入駐時,如何保證不同租戶的數據隔離性呢?通常的解決方法有三種,分別如下:
- 一個租戶一個獨立數據庫,這種方案的用戶數據隔離級別最高,安全性最好,但成本也高。
- 所有租戶共享數據庫,但一個租戶一個數據庫表。這種方案爲安全性要求較高的租戶提供了一定程度的邏輯數據隔離,並不是完全隔離,每個數據庫可以支持更多的租戶數量。
- 所有租戶共享數據庫,共享同一個數據庫表,不同的租戶數據通過租戶的標識區分。這種方案共享程度最高、隔離級別最低。
通常爲了降低成本,一般會選擇第三種方案。這時,應該如何快速的實現多租戶的數據隔離呢?在每一個查詢語句中都添加上不同租戶的標識語句麼?基於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操作進行攔截,實現了多租戶過濾的如下功能:
- 可以動態設置多租戶查詢的開關,支持單個或者多個查詢值的查詢。
- 可以自定義多租戶過濾的數據庫字段,自定義查詢數據庫的別名設置,在多個JOIN關聯查詢中設置過濾的查詢條件。
- 可以自定義多租戶過濾查詢的查詢條件,例如,單個查詢值的相等條件過濾,多個查詢值的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;
}
}
多租戶攔截器全部源碼可以從多租戶數據攔截器插件下載。