构建一个SAAS应用,需要解决多租户数据隔离的问题,一般来说有3种方案:独立数据库、共享数据库独立SCHEMA、共享数据库共享SCHEMA,以下内容都是基于共享数据库共享SCHEMA这种方案进行讨论。
共享数据库共享SCHEMA这种方式,一般是为每一张表增加一个租户ID(TENANT_ID)的字段来实现数据隔离,那么在写业务逻辑的时候需要时刻关注租户数据的隔离,业务逻辑和数据隔离逻辑耦合在一起,增加了业务逻辑的复杂度,不利于业务逻辑的扩展。
那么比较好的实现方案是:在数据库操作层,用AOP方式拦截所有数据库操作,统一对数据库的增、删、改、查语句进行租户ID字段的增强逻辑,实现租户数据隔离。下面会使用BeetSql这款数据库工具来实现该方案。有关BeetSql详细信息请访问官网http://ibeetl.com。
BeetSql提供Interceptor接口,用来对执行的sql语句做各种扩展。before方法会在执行sql语句前被调用,after方法会在执行sql语句后被调用,exception方法会在执行sql语句抛出异常的时候被调用。那么我们要做的就是在before方法中去改写sql语句,并设置相关参数,来看一下具体代码:
public class TenantInterceptor implements Interceptor {
public static final String PROCESS = "PROCESS";
@Override
public void before(InterceptorContext ctx) {
// 如果当前线程变量设置了不处理,不进行租户过滤,默认不设置进行处理 ThreadLocalContext.put(TenantInterceptor.PROCESS,false);
Object processObj = null;
try {
processObj = ThreadLocalContext.get(PROCESS);
if (processObj == null || (Boolean) processObj) {
TenantHandler handler = TenantHandlerFactory.createHandler(ctx);
handler.handle();
}
} finally {
if (processObj != null) {
ThreadLocalContext.remove(PROCESS);
}
}
}
@Override
public void after(InterceptorContext ctx) {
}
@Override
public void exception(InterceptorContext ctx, Exception ex) {
}
}
TenantInterceptor代码逻辑比较简单,首先会读取线程本地变量是否需要对当前执行的方法进行租户数据隔离增强,如果设置了false,就不执行增强操作,否则会调用TenantHandlerFactory工厂类生成TenantHandler,并调用handle方法,那么来看看TenantHandler和TenantHandlerFactory的代码;
public interface TenantHandler {
String COLUMN = "TENANT_ID";
void handle();
}
public class DefaultHandler implements TenantHandler {
private SQLStatement sqlStatement;
private SQLManager sqlManager;
private InterceptorContext ic;
public DefaultHandler(SQLStatement sqlStatement, SQLManager sqlManager,
InterceptorContext ic) {
this.sqlStatement = sqlStatement;
this.sqlManager = sqlManager;
this.ic = ic;
}
@Override
public void handle() {
if (SaasSessionHelper.getTenantId() != null) {
SQLASTVisitor visitor = VisitorFactory.createVisitor(sqlManager);
sqlStatement.accept(visitor);
String sql = SQLUtils.toSQLString(sqlStatement, sqlManager
.getDbStyle().getName());
ic.setSql(sql);
}
}
}
public class TenantHandlerFactory {
public static TenantHandler createHandler(InterceptorContext ic) {
String sql = ic.getSql();
SQLManager sqlManager = SpringContextHolder.getBean(SQLManager.class);
List stmtList = SQLUtils.parseStatements(sql, sqlManager
.getDbStyle().getName());
if (stmtList.size() != 1) {
throw new RuntimeException("parse sql error:" + sql);
}
SQLStatement stmt = stmtList.get(0);
if (stmt instanceof SQLInsertStatement) {
TenantHandler handler = new DefaultHandler(stmt, sqlManager, ic);
return handler;
}
if (stmt instanceof SQLUpdateStatement) {
TenantHandler handler = new DefaultHandler(stmt, sqlManager, ic);
return handler;
}
if (stmt instanceof SQLDeleteStatement) {
TenantHandler handler = new DefaultHandler(stmt, sqlManager, ic);
return handler;
}
if (stmt instanceof SQLSelectStatement) {
TenantHandler handler = new DefaultHandler(stmt, sqlManager, ic);
return handler;
}
throw new RuntimeException("unsupport sql:" + sql);
}
}
TenantHandler中定义了handle方法和租户ID字段名;
DefaultHandler实现了TenantHandler接口,handle方法会先判断当前上下文环境是否有租户ID的值,不为null才进行处理;处理逻辑是用VisitorFactory工厂类生成访问者SQLASTVisitor,让具体的sqlStatement接收访问者的访问,生成增强后的sql语句,设置到BeetSql执行上下文,这里用了Druid里的sql解析器,该解析器用访问者模式去解析sql语句;
TenantHandlerFactory中对增、删、改、查语句都生成DefaultHandler去处理,如果有不同的增强需求可以另外扩展TenantHandler,生成具体的Handler去处理。
下面来看看访问者相关的代码:
public class VisitorFactory {
public static SQLASTVisitor createVisitor(SQLManager sqlManager){
String dbType = sqlManager.getDbStyle().getName();
if(JdbcConstants.ORACLE.equals(dbType)){
return new OracleTenantVisitor(sqlManager);
}
if(JdbcConstants.MYSQL.equals(dbType)){
return new MySqlTenantVisitor(sqlManager);
}
if(JdbcConstants.DB2.equals(dbType)){
throw new RuntimeException("not implemented yet");
}
if(JdbcConstants.SQL_SERVER.equals(dbType)){
throw new RuntimeException("not implemented yet");
}
throw new RuntimeException("unsupport dbType:"+dbType);
}
}
访问者工厂类VisitorFactory会根据数据库类型生成不同的访问者类去增加sql,这里只实现了oracle和mysql这2种,具体来看MySqlTenantVisitor的代码:
public class MySqlTenantVisitor extends MySqlTenantVisitorAdapter{
public MySqlTenantVisitor(SQLManager sqlManager) {
super(sqlManager);
}
protected String processTableName(String tableName){
String s = tableName;
if(s.startsWith("`")){
s = s.substring(1);
}
if(s.endsWith("`")){
s = s.substring(0,s.length()-1);
}
return s;
}
@Override
public boolean visit(MySqlSelectQueryBlock x) {
SQLTableSource ts = x.getFrom();
if (ts instanceof SQLExprTableSource) {
SQLExprTableSource expr = (SQLExprTableSource) ts;
String tableName = expr.getExpr().toString().toUpperCase();
TableDesc td = sqlManager.getMetaDataManager().getTable(processTableName(tableName));
if (td.containCol(TenantHandler.COLUMN)) {
addTenantCondition(x);
}
}
visit((SQLSelectQueryBlock) x);
return true;
}
@Override
public boolean visit(SQLJoinTableSource x) {
super.visit(x);
SQLTableSource lts = x.getLeft();
if (lts instanceof SQLExprTableSource) {
SQLExprTableSource expr = (SQLExprTableSource) lts;
String tableName = expr.getExpr().toString().toUpperCase();
TableDesc td = sqlManager.getMetaDataManager().getTable(processTableName(tableName));
if (td.containCol(TenantHandler.COLUMN)) {
SQLSubqueryTableSource sub = createTableSource(lts);
x.setLeft(sub);
}
}
SQLTableSource rts = x.getRight();
if (rts instanceof SQLExprTableSource) {
SQLExprTableSource expr = (SQLExprTableSource) rts;
String tableName = expr.getExpr().toString().toUpperCase();
TableDesc td = sqlManager.getMetaDataManager().getTable(processTableName(tableName));
if (td.containCol(TenantHandler.COLUMN)) {
SQLSubqueryTableSource sub = createTableSource(rts);
x.setRight(sub);
}
}
return false;
}
@Override
public boolean visit(MySqlDeleteStatement x) {
x.setWhere(buildTenantCondition(x.getWhere()));
return visit((SQLDeleteStatement) x);
}
@Override
public boolean visit(MySqlUpdateStatement x) {
x.setWhere(buildTenantCondition(x.getWhere()));
return visit((SQLUpdateStatement) x);
}
@Override
public boolean visit(MySqlInsertStatement x) {
addInsertStatement(x);
return visit((SQLInsertStatement) x);
}
}
public class MySqlTenantVisitorAdapter extends TenantVisitor implements MySqlASTVisitor{
//代码省略,做适配
}
public class TenantVisitor extends SQLASTVisitorAdapter implements
SQLASTVisitor {
protected SQLManager sqlManager;
public TenantVisitor(SQLManager sqlManager) {
this.sqlManager = sqlManager;
}
@Override
public boolean visit(SQLSelectQueryBlock x) {
if (x.getFrom() == null) {
return false;
}
if (x.getFrom() instanceof SQLSubqueryTableSource) {
accept(x.getFrom());
return false;
}
accept(x.getFrom());
return false;
}
@Override
public boolean visit(SQLExprTableSource x) {
SQLExpr expr = x.getExpr();
if (expr instanceof SQLName) {
return false;
}
accept(x.getExpr());
return false;
}
@Override
public boolean visit(SQLJoinTableSource x) {
accept(x.getLeft());
accept(x.getRight());
accept(x.getCondition());
return false;
}
@Override
public boolean visit(SQLSubqueryTableSource x) {
accept(x.getSelect());
return false;
}
@Override
public boolean visit(SQLDeleteStatement x) {
accept(x.getWhere());
return false;
}
@Override
public boolean visit(SQLInsertStatement x) {
accept(x.getQuery());
return false;
}
@Override
public boolean visit(SQLUpdateStatement x) {
accept(x.getWhere());
List items = x.getItems();
for (SQLUpdateSetItem item : items) {
accept(item);
}
return false;
}
protected void accept(SQLObject x) {
if (x != null) {
x.accept(this);
}
}
protected SQLExpr buildTenantCondition(SQLExpr where) {
Long tenantId = SaasSessionHelper.getTenantId();
if(tenantId==null){
return where;
}
SQLExpr newCondition = SQLUtils.buildCondition(
SQLBinaryOperator.BooleanAnd, new SQLBinaryOpExpr(
new SQLIdentifierExpr(TenantHandler.COLUMN), //
SQLBinaryOperator.Equality, //
new SQLNumberExpr(tenantId), //
sqlManager.getDbStyle().getName()), false, where);
return newCondition;
}
protected void addTenantCondition(SQLSelectQueryBlock queryBlock) {
Long tenantId = SaasSessionHelper.getTenantId();
if (tenantId != null) {
SQLExpr newCondition = SQLUtils.buildCondition(
SQLBinaryOperator.BooleanAnd, new SQLBinaryOpExpr(
new SQLIdentifierExpr(TenantHandler.COLUMN), //
SQLBinaryOperator.Equality, //
new SQLNumberExpr(tenantId), //
sqlManager.getDbStyle().getName()), false,
queryBlock.getWhere());
queryBlock.setWhere(newCondition);
}
}
protected SQLSubqueryTableSource createTableSource(SQLTableSource ts) {
SQLSelectQueryBlock newQueryBlock = new SQLSelectQueryBlock();
newQueryBlock.addSelectItem(new SQLSelectItem(new SQLAllColumnExpr()));
newQueryBlock.setFrom(ts);
addTenantCondition(newQueryBlock);
SQLSubqueryTableSource sub = new SQLSubqueryTableSource(new SQLSelect(
newQueryBlock), ts.getAlias());
return sub;
}
protected void addInsertStatement(SQLInsertStatement stmt) {
Long tenantId = SaasSessionHelper.getTenantId();
if(tenantId==null){
throw new RuntimeException("tenant id is required");
}
stmt.getColumns().add(new SQLIdentifierExpr(TenantHandler.COLUMN));
stmt.getValues().getValues()
.add(new SQLNumberExpr(tenantId));
}
}
TenantVisitor在标准的sql层面对访问者逻辑进行了抽象,MySqlTenantVisitor对mysql层面的逻辑进行处理,总的逻辑是对sql语句中出现的表元素进行增强,增加租户过滤条件。下面举个例子:
SELECT * FROM T1 t1,T2 t2 WHERE t1.ID=t2.FID AND t1.ID=XXX
以上sql语句会被增强为
SELECT * FROM (SELECT * FROM T1 t1 WHERE TENANT_ID=XX) t1,(SELECT * FROM T2 t2 WHERE TENANT_ID=XX) t2 WHERE t1.ID=t2.FID AND t1.ID=XXX
对于增、删、改也是以类似的方式进行处理,对于其他类型的数据库,处理方式也类似,这里不再赘述。
通过上述实现方式,解耦了业务逻辑和租户数据隔离逻辑,对于SAAS应用来说,可以专注于业务逻辑的实现,而不必关注租户数据隔离,提高了系统的可扩展性。