普通视图

Received before yesterday

DataPermissionInterceptor源码解读

2025年3月31日 00:00

一、概述

DataPermissionInterceptor是MyBatis-Plus中的一个拦截器插件类,位于mybatis-plus-jsqlparser-support模块的com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor,用于实现数据权限功能,它将查询、删除和修改的SQL进行拦截并获得要执行的SQL,并解析出SQL中的表和原有条件,通过一个DataPermissionHandler接口来回调获取每个表的数据权限条件,再和原有的条件拼接在一起形成新的SQL,执行重写后的新SQL,从而实现数据权限功能。因为添加操作无需数据权限控制,因此不处理添加的情况。

本类的实现较为简单,因为对于数据权限来说,对于比较复杂的查询SQL的解析逻辑基本已经由父类完成,具体见:BaseMultiTableInnerInterceptor源码解读,本类作为子类将查询SQL调用父类进行解析重写即可,对于删除和更新的SQL仅仅针对delete和update本身的where条件进行处理,而且是单表操作,因此对于删除和更新来说,只是将表原有条件和数据权限条件做简单的拼接即可。

本文基于MyBatis-Plus的3.5.9版本的源码,并fork了代码: https://github.com/changelzj/mybatis-plus/tree/lzj-3.5.9

public class DataPermissionInterceptor extends BaseMultiTableInnerInterceptor implements InnerInterceptor {    private DataPermissionHandler dataPermissionHandler;    @SuppressWarnings("RedundantThrows")    @Override    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {...}    @Override    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {...}    @Override    protected void processSelect(Select select, int index, String sql, Object obj) {...}    protected void setWhere(PlainSelect plainSelect, String whereSegment) {...}    @Override    protected void processUpdate(Update update, int index, String sql, Object obj) {...}    @Override    protected void processDelete(Delete delete, int index, String sql, Object obj) {...}    protected Expression getUpdateOrDeleteExpression(final Table table, final Expression where, final String whereSegment) {...}    @Override    public Expression buildTableExpression(final Table table, final Expression where, final String whereSegment) {...}}

二、源码解读

2.1 beforeQuery

该方法从InnerInterceptor接口继承而来,是解析查询SQL的起点,MyBatis-Plus执行时就是对实现InnerInterceptor接口的类中的对应方法进行回调的,会传入要执行的SQL并接收重写后的SQL来实现对SQL的修改,在查询SQL执行前进行拦截并调用beforeQuery()beforeQuery()中再去调用parserSingle()

parserSingle()是从父类BaseMultiTableInnerInterceptor自JsqlParserSupport抽象类间接继承而来的,JsqlParserSupport类的功能非常简单,作用是判断SQL是增删改查的哪一种类型,然后分别调用对应的方法开始解析。

当调用parserSingle()并传入SQL时,会在JsqlParserSupport的processParser()方法中先判断是哪一种Statement,然后分别强转为具体的Select、Update、Delete、Insert对象,再调用该类间接继承并重写的processSelect()方法并传入Select对象。

processSelect()方法会再调用父类的processSelectBody()对查询SQL进行解析,对于解析到的每张表和已有条件,再去调用父类的builderExpression()进而再调用buildTableExpression()获取当前表对应的数据权限过滤条件再和已有条件进行拼接。

@SuppressWarnings("RedundantThrows")@Overridepublic void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {    if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {        return;    }    PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);    mpBs.sql(parserSingle(mpBs.sql(), ms.getId()));}

2.2 beforePrepare

该方法和beforeQuery()一样,也是从InnerInterceptor接口中继承而来,因为添加修改和删除SQL都要预编译,因此该方法可作为解析删除和修改SQL的起点,不同的是beforePrepare()调用的是JsqlParserSupport中继承来的parserMulti(),因为查询语句只能一次执行一条,但是增删改语句可以用分号间隔一次执行多条,故需调用parserMulti()将多个语句循环拆开,然后判断并分别强转为具体的Select、Update、Delete、Insert对象,再分别调用该类间接继承并重写的processDelete()processUpdate()方法并分别传入Delete,Update对象,然后直接解析出要删除和更新数据的表和已有删除更新条件,调用父类的andExpression()进而在调用buildTableExpression()来拼接数据权限过滤条件。

@Overridepublic void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {    PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);    MappedStatement ms = mpSh.mappedStatement();    SqlCommandType sct = ms.getSqlCommandType();    if (sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {        if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {            return;        }        PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();        mpBs.sql(parserMulti(mpBs.sql(), ms.getId()));    }}

2.3 processSelect

开始一个对查询SQL的解析,当前版本走的是if (dataPermissionHandler instanceof MultiDataPermissionHandler)的新版本的逻辑,先调用processSelectBody()进行解析,对于WITH中的结构,又在调用processSelectBody()后单独组织了一段针对WITH中的查询的解析逻辑。旧版本应该是直接获取where后面的条件直接传递给dataPermissionHandler,在dataPermissionHandler中对where进行追加,而新版本代码是将解析到的表传到dataPermissionHandler,传入的是表名返回表的数据权限条件

@Overrideprotected void processSelect(Select select, int index, String sql, Object obj) {    if (dataPermissionHandler == null) {        return;    }    if (dataPermissionHandler instanceof MultiDataPermissionHandler) {        // 参照 com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor.processSelect 做的修改        final String whereSegment = (String) obj;        processSelectBody(select, whereSegment);        List<WithItem> withItemsList = select.getWithItemsList();        if (!CollectionUtils.isEmpty(withItemsList)) {            withItemsList.forEach(withItem -> processSelectBody(withItem, whereSegment));        }    } else {        // 兼容原来的旧版 DataPermissionHandler 场景        if (select instanceof PlainSelect) {            this.setWhere((PlainSelect) select, (String) obj);        } else if (select instanceof SetOperationList) {            SetOperationList setOperationList = (SetOperationList) select;            List<Select> selectBodyList = setOperationList.getSelects();            selectBodyList.forEach(s -> this.setWhere((PlainSelect) s, (String) obj));        }    }}

2.4 setWhere

这段代码应该是为旧版本用的,没有走到

/** * 设置 where 条件 * * @param plainSelect  查询对象 * @param whereSegment 查询条件片段 */protected void setWhere(PlainSelect plainSelect, String whereSegment) {    if (dataPermissionHandler == null) {        return;    }    // 兼容旧版的数据权限处理    final Expression sqlSegment = dataPermissionHandler.getSqlSegment(plainSelect.getWhere(), whereSegment);    if (null != sqlSegment) {        plainSelect.setWhere(sqlSegment);    }}

2.5 processUpdate

/** * update 语句处理 */@Overrideprotected void processUpdate(Update update, int index, String sql, Object obj) {    final Expression sqlSegment = getUpdateOrDeleteExpression(update.getTable(), update.getWhere(), (String) obj);    if (null != sqlSegment) {        update.setWhere(sqlSegment);    }}

2.6 processDelete

/** * delete 语句处理 */@Overrideprotected void processDelete(Delete delete, int index, String sql, Object obj) {    final Expression sqlSegment = getUpdateOrDeleteExpression(delete.getTable(), delete.getWhere(), (String) obj);    if (null != sqlSegment) {        delete.setWhere(sqlSegment);    }}

2.7 getUpdateOrDeleteExpression

针对更新和删除的SQL,不同于查询,当更新后的值是子查询或更新删除条件的值是一个子查询的时候,不会为这个子查询中的表追加条件,仅把针对整个update或delete语句的条件本身和要追加的数据权限过滤条件进行AND和OR拼接,因此会直接把表名和WHERE条件调用父类的andExpression(table, where, whereSegment)进行拼接,方法的返回值即为拼接后的结果,直接返回。

protected Expression getUpdateOrDeleteExpression(final Table table, final Expression where, final String whereSegment) {    if (dataPermissionHandler == null) {        return null;    }    if (dataPermissionHandler instanceof MultiDataPermissionHandler) {        return andExpression(table, where, whereSegment);    } else {        // 兼容旧版的数据权限处理        return dataPermissionHandler.getSqlSegment(where, whereSegment);    }}

2.8 buildTableExpression

传入表名,返回表要追加的数据权限过滤条件,具体哪个表需要怎样的数据权限条件,会通过回调dataPermissionHandler.getSqlSegment()让DataPermissionHandler的实现类根据具体业务来确定

@Overridepublic Expression buildTableExpression(final Table table, final Expression where, final String whereSegment) {    if (dataPermissionHandler == null) {        return null;    }    // 只有新版数据权限处理器才会执行到这里    final MultiDataPermissionHandler handler = (MultiDataPermissionHandler) dataPermissionHandler;    return handler.getSqlSegment(table, where, whereSegment);}

TenantLineInnerInterceptor源码解读

2025年3月31日 00:00

一、引言

TenantLineInnerInterceptor是MyBatis-Plus中的一个拦截器类,位于com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor,通过MyBatis-Plus的插件机制调用,用于实现表级的多租户功能。

本文基于MyBatis-Plus的3.5.9版本的源码,并fork了代码: https://github.com/changelzj/mybatis-plus/tree/lzj-3.5.9

public class TenantLineInnerInterceptor extends BaseMultiTableInnerInterceptor implements InnerInterceptor {    private TenantLineHandler tenantLineHandler;    @Override    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {...}    @Override    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {...}    @Override    protected void processSelect(Select select, int index, String sql, Object obj) {...}    @Override    protected void processInsert(Insert insert, int index, String sql, Object obj) {...}    @Override    protected void processUpdate(Update update, int index, String sql, Object obj) {...}    @Override    protected void processDelete(Delete delete, int index, String sql, Object obj) {...}    protected void processInsertSelect(Select selectBody, final String whereSegment) {...}    protected void appendSelectItem(List<SelectItem<?>> selectItems) {...}    protected Column getAliasColumn(Table table) {...}    @Override    public void setProperties(Properties properties) {...}    @Override    public Expression buildTableExpression(final Table table, final Expression where, final String whereSegment) {...}}

多租户和数据权限DataPermissionInterceptor的实现原理是类似的,租户本质上也是一种特殊的数据权限,不同于数据权限的是对于涉及租户的表的增、删、改、查四种操作,都需要对SQL语句进行处理,实现原理是执行SQL前进行拦截,并获取要执行的SQL,然后解析SQL语句中的表,遇到需要租户隔离的表就要进行处理,对于查询、删除和更新的场景,就在现有的SQL条件中追加一个tenant_id = ?的条件,获取当前操作的用户或要执行的某种任务所属的租户ID赋值给tenant_id,对于添加操作,则是将tenant_id字段加入到INSERT列表中并赋值。

TenantLineInnerInterceptor类也像数据权限插件一样继承了用于解析和追加条件的BaseMultiTableInnerInterceptor类,但是BaseMultiTableInnerInterceptor主要是提供了对查询SQL的解析重写能力供插件类使用,本类对于添加数据的场景采用自己实现的解析和重写INSERT SQL的逻辑。

TenantLineInnerInterceptor需要一个TenantLineHandler类型的租户处理器,TenantLineHandler是一个接口,用于给TenantLineInnerInterceptor判断某个表是否需要租户隔离,以及获取租户ID值表达式、租户字段名以及要执行的SQL的列中如果已经包含租户ID字段是否继续,我们使用MyBatis-Plus的租户插件时,需要实现这个接口并在回调方法中将这些信息封装好后返回。

com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler

public interface TenantLineHandler {    /**     * 获取租户 ID 值表达式,只支持单个 ID 值     * <p>     *     * @return 租户 ID 值表达式     */    Expression getTenantId();    /**     * 获取租户字段名     * <p>     * 默认字段名叫: tenant_id     *     * @return 租户字段名     */    default String getTenantIdColumn() {        return "tenant_id";    }    /**     * 根据表名判断是否忽略拼接多租户条件     * <p>     * 默认都要进行解析并拼接多租户条件     *     * @param tableName 表名     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件     */    default boolean ignoreTable(String tableName) {        return false;    }    /**     * 忽略插入租户字段逻辑     *     * @param columns        插入字段     * @param tenantIdColumn 租户 ID 字段     * @return     */    default boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {        return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));    }}

二、主要源码解读

本文指定租户ID为1001,对各种结构的INSERT SQL解析重写过程进行解读

TenantLineHandler handler = new TenantLineHandler() {    @Override    public Expression getTenantId() {        return new LongValue(1001);    }};

2.1 beforeQuery/beforePrepare

逻辑和DataPermissionInterceptor中的实现基本一致,唯一不同的是,租户的实现需要对INSERT类型的SQL进行解析重写。

@Overridepublic void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {    if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {        return;    }    PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);    mpBs.sql(parserSingle(mpBs.sql(), null));}
@Overridepublic void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {    PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);    MappedStatement ms = mpSh.mappedStatement();    SqlCommandType sct = ms.getSqlCommandType();    if (sct == SqlCommandType.INSERT || sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {        if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {            return;        }        PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();        mpBs.sql(parserMulti(mpBs.sql(), null));    }}

2.2 processSelect

对SELECT语句的解析和重写,已经在父类BaseMultiTableInnerInterceptor中实现

@Overrideprotected void processSelect(Select select, int index, String sql, Object obj) {    final String whereSegment = (String) obj;    processSelectBody(select, whereSegment);    List<WithItem> withItemsList = select.getWithItemsList();    if (!CollectionUtils.isEmpty(withItemsList)) {        withItemsList.forEach(withItem -> processSelectBody(withItem, whereSegment));    }}

2.3 processInsert

该方法是本类中一个很重要的方法,用于对INSERT语句进行解析和重写以实现租户隔离。

@Overrideprotected void processInsert(Insert insert, int index, String sql, Object obj) {    if (tenantLineHandler.ignoreTable(insert.getTable().getName())) {        // 过滤退出执行        return;    }    List<Column> columns = insert.getColumns();    if (CollectionUtils.isEmpty(columns)) {        // 针对不给列名的insert 不处理        return;    }    String tenantIdColumn = tenantLineHandler.getTenantIdColumn();    if (tenantLineHandler.ignoreInsert(columns, tenantIdColumn)) {        // 针对已给出租户列的insert 不处理        return;    }    columns.add(new Column(tenantIdColumn));    Expression tenantId = tenantLineHandler.getTenantId();    // fixed gitee pulls/141 duplicate update    List<UpdateSet> duplicateUpdateColumns = insert.getDuplicateUpdateSets();    if (CollectionUtils.isNotEmpty(duplicateUpdateColumns)) {        EqualsTo equalsTo = new EqualsTo();        equalsTo.setLeftExpression(new StringValue(tenantIdColumn));        equalsTo.setRightExpression(tenantId);        duplicateUpdateColumns.add(new UpdateSet(new Column(tenantIdColumn), tenantId));    }    Select select = insert.getSelect();    if (select instanceof PlainSelect) { //fix github issue 4998  修复升级到4.5版本的问题        this.processInsertSelect(select, (String) obj);    } else if (insert.getValues() != null) {        // fixed github pull/295        Values values = insert.getValues();        ExpressionList<Expression> expressions = (ExpressionList<Expression>) values.getExpressions();        if (expressions instanceof ParenthesedExpressionList) {            expressions.addExpression(tenantId);        } else {            if (CollectionUtils.isNotEmpty(expressions)) {//fix github issue 4998 jsqlparse 4.5 批量insert ItemsList不是MultiExpressionList 了,需要特殊处理                int len = expressions.size();                for (int i = 0; i < len; i++) {                    Expression expression = expressions.get(i);                    if (expression instanceof Parenthesis) {                        ExpressionList rowConstructor = new RowConstructor<>()                            .withExpressions(new ExpressionList<>(((Parenthesis) expression).getExpression(), tenantId));                        expressions.set(i, rowConstructor);                    } else if (expression instanceof ParenthesedExpressionList) {                        ((ParenthesedExpressionList) expression).addExpression(tenantId);                    } else {                        expressions.add(tenantId);                    }                }            } else {                expressions.add(tenantId);            }        }    } else {        throw ExceptionUtils.mpe("Failed to process multiple-table update, please exclude the tableName or statementId");    }}

首先判断if (CollectionUtils.isEmpty(columns)):如SQL没有指明要更新的列,则不处理

然后判断if (tenantLineHandler.ignoreInsert(columns, tenantIdColumn)),如要执行的SQL中已经包含租户ID字段,则可能是已经明确指定了具体的租户ID,同样不处理

然后调用tenantLineHandlergetTenantIdColumn()获取租户列的字段名,先把租户的字段名添加到INSERT INTO后面原有的字段名的最后

之后针对不同结构的SQL,会分别走到不同的分支,针对几种常见的INSERT SQL,分别进行解读:

2.3.1 最常见的新增SQL语句

insert into t_user (name, age) values ('liming', 15)

首先会尝试获取INSERT语句中的查询结构Select select = insert.getSelect(),并判断是否带有查询结构,这种情况是不带查询结构的,会走到else if (insert.getValues() != null)这个分支,然后insert.getValues()获取代表一组值的对象values

紧接着获取values的结构ExpressionList<Expression> expressions = (ExpressionList<Expression>) values.getExpressions()得到('liming', 15)

然后,通过if (expressions instanceof ParenthesedExpressionList)判断是否为带着括号的Expression结构,很显然是,通过expressions.addExpression(tenantId);将租户ID的值追加到('liming', 15)的最后,得到SQL:

INSERT INTO t_user (name, age, tenant_id) VALUES ('liming', 15, 1001)

2.3.2 批量新增数据的SQL语句

insert into t_user (name, age) values ('liming', 15), ('zhaoying', 16)

与2.3.1不同的是,这种SQL在通过if (expressions instanceof ParenthesedExpressionList)判断是否为带着括号的Expression结构时结果为false,因为这种SQL的VALUES部分结构是('liming', 15), ('zhaoying', 16)显然不符合,因此会走到else分支,分别取出其中每个元素(...),再去判断每个元素是否为带着括号的Expression结构,显然每个(...)都符合,因此对每个(...)中最后一个值后面再追加上租户ID即可,相当于将大的拆散分别处理,最终得到SQL:

INSERT INTO t_user (name, age, tenant_id) VALUES ('liming', 15, 1001), ('zhaoying', 16, 1001)

2.3.3 ON DUPLICATE KEY UPDATE的SQL

INSERT INTO table_name (col1, col2) VALUES (val1, val2) ON DUPLICATE KEY UPDATE col1 = val3, col2 = col4 + 1;

这种SQL,在if (CollectionUtils.isNotEmpty(duplicateUpdateColumns))处为true,属于添加发生冲突时对冲突的字段进行更新的SQL结构,会先进入这个if分支处理ON DUPLICATE的部分,意思是如果insert.getDuplicateUpdateSets()不为空,则会先将tenant_id = 1001追加到ON DUPLICATE KEY UPDATE后面,再后面的VALUES (val1, val2, 1001)的结构和2.3.1处理方式相同

INSERT INTO table_name (col1, col2, tenant_id) VALUES (val1, val2, 1001) ON DUPLICATE KEY UPDATE col1 = val3, col2 = col4 + 1, tenant_id = 1001

2.3.4 INSERT SELECT的SQL

INSERT INTO table_name (col1, col2) SELECT col1, col2 FROM another_table 

与2.3.1情况相反,这种情况是带查询结构的,这种SQL要添加的值在一个查询结果集中,该方法在获取查询结构Select select = insert.getSelect()并判断是否带有查询结构时,就会走到if (select instanceof PlainSelect)中,调用processInsertSelect()方法并将SQL上获取到的Select结构传入,对SQL中的查询结构进行处理,processInsertSelect方法解读详见2.6,最终得到SQL:

INSERT INTO table_name (col1, col2, tenant_id) SELECT col1, col2, tenant_id FROM another_table WHERE tenant_id = 1001

2.3.5 SELECT INTO的结构

SELECT col1,col2  INTO table_name2 FROM table_name1

这种会被当成select语句进行处理

2.4 processUpdate

该方法用于解析重写update语句,针对租户的processUpdate方法和数据权限的实现类似但也有区别

/** * update 语句处理 */@Overrideprotected void processUpdate(Update update, int index, String sql, Object obj) {    final Table table = update.getTable();    if (tenantLineHandler.ignoreTable(table.getName())) {        // 过滤退出执行        return;    }    List<UpdateSet> sets = update.getUpdateSets();    if (!CollectionUtils.isEmpty(sets)) {        sets.forEach(us -> us.getValues().forEach(ex -> {            if (ex instanceof Select) {                processSelectBody(((Select) ex), (String) obj);            }        }));    }    update.setWhere(this.andExpression(table, update.getWhere(), (String) obj));}

用于解析和重写update语句的租户逻辑,对于常规的update语句处理较为简单,直接在where后面追加租户过滤条件:update.setWhere(this.andExpression(table, update.getWhere(), (String) obj)),例如:

UPDATE user SET username = 5 WHERE id = 1 

重写后:

UPDATE user SET username = 5 WHERE id = 1 AND tenant_id = 1001

和数据权限拦截器插件的实现不同的是,多租户对于update语句更新后的值是子查询的情况进行了额外处理,对子查询SQL也进行了解析和重写,通过sets.forEach(us -> us.getValues().forEach(ex -> {获取所有要更新的值并遍历,如果某个值属于子查询结构(ex instanceof Select)则处理子查询,例如:

UPDATE user SET username = (SELECT name FROM employee WHERE emp_no = 'UA001') WHERE id = 1 

重写后:

UPDATE user SET username = (SELECT name FROM employee WHERE emp_no = 'UA001' AND tenant_id = 1001) WHERE id = 1 AND tenant_id = 1001

2.5 processDelete

删除语句,处理较为简单,处理方式类似简单的update语句,直接追加过滤条件在where后面即可

/** * delete 语句处理 */@Overrideprotected void processDelete(Delete delete, int index, String sql, Object obj) {    if (tenantLineHandler.ignoreTable(delete.getTable().getName())) {        // 过滤退出执行        return;    }    delete.setWhere(this.andExpression(delete.getTable(), delete.getWhere(), (String) obj));}

2.6 processInsertSelect

该方法用于对INSERT...SELECT...结构后面的SELECT部分进行处理

/** * 处理 insert into select * <p> * 进入这里表示需要 insert 的表启用了多租户,则 select 的表都启动了 * * @param selectBody SelectBody */protected void processInsertSelect(Select selectBody, final String whereSegment) {    if(selectBody instanceof PlainSelect){        PlainSelect plainSelect = (PlainSelect) selectBody;        FromItem fromItem = plainSelect.getFromItem();        if (fromItem instanceof Table) {            // fixed gitee pulls/141 duplicate update            processPlainSelect(plainSelect, whereSegment);            appendSelectItem(plainSelect.getSelectItems());        } else if (fromItem instanceof Select) {            Select subSelect = (Select) fromItem;            appendSelectItem(plainSelect.getSelectItems());            processInsertSelect(subSelect, whereSegment);        }    } else if(selectBody instanceof ParenthesedSelect){        ParenthesedSelect parenthesedSelect = (ParenthesedSelect) selectBody;        processInsertSelect(parenthesedSelect.getSelect(), whereSegment);    }}

解读:

1.表:if (fromItem instanceof Table)针对的是SELECT部分查询的是表的情况

INSERT INTO table_name (col1, col2) SELECT col1, col2 FROM another_table

直接调用父类processPlainSelect对表where条件追加租户过滤条件,再将租户ID字段名添加到查询字段名列表中即可,得到如下SQL:

INSERT INTO table_name (col1, col2, tenant_id) SELECT col1, col2, tenant_id FROM another_table WHERE tenant_id = 1001

2.子查询:else if (fromItem instanceof Select)针对的是SELECT部分查询的是子查询的情况

INSERT INTO table_name (col1, col2) SELECT col1, col2 FROM (select col1, col2 from  another_table) t

appendSelectItem()将租户ID字段名添加到查询字段名列表中,然后获取子查询再递归调用当前processInsertSelect方法,如果子查询中查询的是表,则将租户ID字段名添加到子查询的字段名列表中然后追加租户过滤条件在子查询的where条件上,如果子查询中的查询来源还是子查询,则继续递归解析,最终会得到如下SQL:

INSERT INTO table_name (col1, col2, tenant_id) SELECT col1, col2, tenant_id FROM (    SELECT col1, col2, tenant_id FROM another_table WHERE tenant_id = 1001) t

2.7 appendSelectItem

该方法配合processInsertSelect使用,用于将租户ID字段名插入到select后的字段名列表中,使得结果集可以直接作为要添加的值进行批量insert,如果select的字段是模糊的select *表示的,则不处理,直接跳过

/** * 追加 SelectItem * * @param selectItems SelectItem */protected void appendSelectItem(List<SelectItem<?>> selectItems) {    if (CollectionUtils.isEmpty(selectItems)) {        return;    }    if (selectItems.size() == 1) {        SelectItem item = selectItems.get(0);        Expression expression = item.getExpression();        if (expression instanceof AllColumns) {            return;        }    }    selectItems.add(new SelectItem<>(new Column(tenantLineHandler.getTenantIdColumn())));}

结束语

该类是MyBatis-Plus的多租户插件实现源码,基本上和数据权限插件的实现逻辑类似,本质上讲租户也是一种特殊的数据权限,根据租户的业务逻辑,本类针对INSERT SQL的解析和重写进行了实现,并对UPDATE SQL做了和数据权限插件不一样的处理:针对更新后的值是子查询的情况也对子查询SQL进行了租户隔离。

BaseMultiTableInnerInterceptor源码解读

2025年3月7日 00:00

一、概述

BaseMultiTableInnerInterceptor是MyBatis-Plus中的一个抽象类,位于mybatis-plus-jsqlparser-4.9模块中com.baomidou.mybatisplus.extension.plugins.inner包下,提供解析和重写SQL功能,MyBatis-Plus的数据权限(TenantLineInnerInterceptor)插件和多租户(DataPermissionInterceptor)插件均继承了BaseMultiTableInnerInterceptor类来实现对应的功能。

本文基于MyBatis-Plus的3.5.9版本的源码,并fork了代码: https://github.com/changelzj/mybatis-plus/tree/lzj-3.5.9

public abstract class BaseMultiTableInnerInterceptor extends JsqlParserSupport implements InnerInterceptor {    protected void processSelectBody(Select selectBody, final String whereSegment) {...}    protected Expression andExpression(Table table, Expression where, final String whereSegment) {...}    protected void processPlainSelect(final PlainSelect plainSelect, final String whereSegment) {...}    private List<Table> processFromItem(FromItem fromItem, final String whereSegment) {...}    protected void processWhereSubSelect(Expression where, final String whereSegment) {...}    protected void processSelectItem(SelectItem selectItem, final String whereSegment) {...}    protected void processFunction(Function function, final String whereSegment) {...}    protected void processOtherFromItem(FromItem fromItem, final String whereSegment) {...}    private List<Table> processSubJoin(ParenthesedFromItem subJoin, final String whereSegment) {...}    private List<Table> processJoins(List<Table> mainTables, List<Join> joins, final String whereSegment) {...}    protected Expression builderExpression(Expression currentExpression, List<Table> tables, final String whereSegment) {...}    public abstract Expression buildTableExpression(final Table table, final Expression where, final String whereSegment);}

二、执行流程

BaseMultiTableInnerInterceptor实现了InnerInterceptor接口中的beforeQuery(),beforePrepare()方法,实际上是子类去间接实现的,MyBatis-Plus就是对实现这个接口的类进行回调,在查询SQL即将执行时调用beforeQuery(),在增删改SQL即将执行前调用beforePrepare()beforeQuery()中再去调用parserSingle()beforePrepare()再去调用parserMulti()

查询语句只能一次执行一条,增删改语句可以用分号间隔一次执行多条。故beforeQuery()调用parserSingle()beforePrepare()调用parserMulti()

@Overridepublic void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {    if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {        return;    }    PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);    mpBs.sql(parserSingle(mpBs.sql(), ms.getId()));}@Overridepublic void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {    PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);    MappedStatement ms = mpSh.mappedStatement();    SqlCommandType sct = ms.getSqlCommandType();    if (sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {        if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {            return;        }        PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();        mpBs.sql(parserMulti(mpBs.sql(), ms.getId()));    }}

parserSingle()parserMulti()BaseMultiTableInnerInterceptorJsqlParserSupport抽象类继承而来的,JsqlParserSupport是MyBatis-Plus基于JsqlParser(JSQLParser详见:SQL解析工具JSQLParser)封装的一个工具类,这个类的功能非常简单,作用是判断SQL是增删改查的哪一种类型,然后分别调用对应的方法开始解析。

public abstract class JsqlParserSupport {    /**     * 日志     */    protected final Log logger = LogFactory.getLog(this.getClass());    public String parserSingle(String sql, Object obj) {        if (logger.isDebugEnabled()) {            logger.debug("original SQL: " + sql);        }        try {            Statement statement = JsqlParserGlobal.parse(sql);            return processParser(statement, 0, sql, obj);        } catch (JSQLParserException e) {            throw ExceptionUtils.mpe("Failed to process, Error SQL: %s", e.getCause(), sql);        }    }    public String parserMulti(String sql, Object obj) {        if (logger.isDebugEnabled()) {            logger.debug("original SQL: " + sql);        }        try {            // fixed github pull/295            StringBuilder sb = new StringBuilder();            Statements statements = JsqlParserGlobal.parseStatements(sql);            int i = 0;            for (Statement statement : statements) {                if (i > 0) {                    sb.append(StringPool.SEMICOLON);                }                sb.append(processParser(statement, i, sql, obj));                i++;            }            return sb.toString();        } catch (JSQLParserException e) {            throw ExceptionUtils.mpe("Failed to process, Error SQL: %s", e.getCause(), sql);        }    }    /**     * 执行 SQL 解析     *     * @param statement JsqlParser Statement     * @return sql     */    protected String processParser(Statement statement, int index, String sql, Object obj) {        if (logger.isDebugEnabled()) {            logger.debug("SQL to parse, SQL: " + sql);        }        if (statement instanceof Insert) {            this.processInsert((Insert) statement, index, sql, obj);        } else if (statement instanceof Select) {            this.processSelect((Select) statement, index, sql, obj);        } else if (statement instanceof Update) {            this.processUpdate((Update) statement, index, sql, obj);        } else if (statement instanceof Delete) {            this.processDelete((Delete) statement, index, sql, obj);        }        sql = statement.toString();        if (logger.isDebugEnabled()) {            logger.debug("parse the finished SQL: " + sql);        }        return sql;    }    /**     * 新增     */    protected void processInsert(Insert insert, int index, String sql, Object obj) {        throw new UnsupportedOperationException();    }    /**     * 删除     */    protected void processDelete(Delete delete, int index, String sql, Object obj) {        throw new UnsupportedOperationException();    }    /**     * 更新     */    protected void processUpdate(Update update, int index, String sql, Object obj) {        throw new UnsupportedOperationException();    }    /**     * 查询     */    protected void processSelect(Select select, int index, String sql, Object obj) {        throw new UnsupportedOperationException();    }}

当调用parserSingle()parserMulti()并传入SQL时,会在processParser()方法中先判断是哪一种Statement,然后分别强转为具体的Select、Update、Delete、Insert对象,再调用子类(例如:DataPermissionInterceptor)间接继承并重写的processSelect()processDelete()processUpdate()方法。

子类中的processSelect()方法会再调用父类BaseMultiTableInnerInterceptor中的processSelectBody()对查询进行解析,processUpdate()processDelete()同理。这样设计的原因可能是由具体的子类根据功能来最终确定解析和重写逻辑,而BaseMultiTableInnerInterceptor只提供解析和重写能力不负责不同场景下的具体逻辑实现。

@Overrideprotected void processSelect(Select select, int index, String sql, Object obj) {    if (dataPermissionHandler == null) {        return;    }    if (dataPermissionHandler instanceof MultiDataPermissionHandler) {        // 参照 com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor.processSelect 做的修改        final String whereSegment = (String) obj;        processSelectBody(select, whereSegment);        List<WithItem> withItemsList = select.getWithItemsList();        if (!CollectionUtils.isEmpty(withItemsList)) {            withItemsList.forEach(withItem -> processSelectBody(withItem, whereSegment));        }    } else {        // 兼容原来的旧版 DataPermissionHandler 场景        if (select instanceof PlainSelect) {            this.setWhere((PlainSelect) select, (String) obj);        } else if (select instanceof SetOperationList) {            SetOperationList setOperationList = (SetOperationList) select;            List<Select> selectBodyList = setOperationList.getSelects();            selectBodyList.forEach(s -> this.setWhere((PlainSelect) s, (String) obj));        }    }}/** * update 语句处理 */@Overrideprotected void processUpdate(Update update, int index, String sql, Object obj) {    final Expression sqlSegment = getUpdateOrDeleteExpression(update.getTable(), update.getWhere(), (String) obj);    if (null != sqlSegment) {        update.setWhere(sqlSegment);    }}/** * delete 语句处理 */@Overrideprotected void processDelete(Delete delete, int index, String sql, Object obj) {    final Expression sqlSegment = getUpdateOrDeleteExpression(delete.getTable(), delete.getWhere(), (String) obj);    if (null != sqlSegment) {        delete.setWhere(sqlSegment);    }}protected Expression getUpdateOrDeleteExpression(final Table table, final Expression where, final String whereSegment) {    if (dataPermissionHandler == null) {        return null;    }    if (dataPermissionHandler instanceof MultiDataPermissionHandler) {        return andExpression(table, where, whereSegment);    } else {        // 兼容旧版的数据权限处理        return dataPermissionHandler.getSqlSegment(where, whereSegment);    }}

三、源码解读

与更新和删除语句的解析相比,对查询语句进行解析和重写的逻辑是更加复杂的,步骤也更多,需要解析到SQL语句的各个部分,分为多个方法,方法间互相配合实现对复杂查询SQL语句的解析和重写

执行的大致流程如下:

如SQL结构复杂,需要先将一个复杂SQL拆分为若干简单SQL,然后依次对每个SQL需要重写条件的地方(select xx,from xx,join xx,where xx)进行表和条件解析然后追加过滤条件,如果遇到子查询需要递归解析子查询直到SQL所有部分都被解析到

3.1 processSelectBody

该方法是解析SELECT语句的入口方法,会先对复杂的SELECT语句进行简化拆分,再分别调用processPlainSelect()来解析每个部分

protected void processSelectBody(Select selectBody, final String whereSegment) {    if (selectBody == null) {        return;    }    if (selectBody instanceof PlainSelect) {        processPlainSelect((PlainSelect) selectBody, whereSegment);    } else if (selectBody instanceof ParenthesedSelect) {        ParenthesedSelect parenthesedSelect = (ParenthesedSelect) selectBody;        processSelectBody(parenthesedSelect.getSelect(), whereSegment);    } else if (selectBody instanceof SetOperationList) {        SetOperationList operationList = (SetOperationList) selectBody;        List<Select> selectBodyList = operationList.getSelects();        if (CollectionUtils.isNotEmpty(selectBodyList)) {            selectBodyList.forEach(body -> processSelectBody(body, whereSegment));        }    }}

解读:

该方法传入一个jsqlparser的Select对象,因为有的SELECT语句结构比较复杂,需要化繁为简进行拆分然后对每个部分分别进行解析,这里MyBatis-Plus考虑了三种情况:

  1. PlainSelect:最标准的SELECT语句格式,直接调用processPlainSelect(PlainSelect plainSelect)方法开始解析即可

  2. ParenthesedSelect:带括号的子查询,先去掉括号,将括号内SELECT语句再次调用processSelectBody(Select select)进行递归解析,直到格式满足PlainSelect

  3. SetOperationList:多个SELECT语句通过UNIONUNION ALL等组合为一个整体的SELECT语句的情况,分别拆开取出每一段SELECT,将每一段SELECT再次调用processSelectBody(Select select)进行递归解析,直到格式满足PlainSelect

还有一种select语句中带有with的情况,要把with中的查询语句提取进行解析,不过不是在这里处理的,而是在子类的processSelect方法中,调用processSelectBody方法之后

3.2 processPlainSelect

该方法用于开启一个对常规形式的SELECT语句的解析

protected void processPlainSelect(final PlainSelect plainSelect, final String whereSegment) {    //#3087 github    List<SelectItem<?>> selectItems = plainSelect.getSelectItems();    if (CollectionUtils.isNotEmpty(selectItems)) {        selectItems.forEach(selectItem -> processSelectItem(selectItem, whereSegment));    }    // 处理 where 中的子查询    Expression where = plainSelect.getWhere();    processWhereSubSelect(where, whereSegment);    // 处理 fromItem    FromItem fromItem = plainSelect.getFromItem();    List<Table> list = processFromItem(fromItem, whereSegment);    List<Table> mainTables = new ArrayList<>(list);    // 处理 join    List<Join> joins = plainSelect.getJoins();    if (CollectionUtils.isNotEmpty(joins)) {        processJoins(mainTables, joins, whereSegment);    }    // 当有 mainTable 时,进行 where 条件追加    if (CollectionUtils.isNotEmpty(mainTables)) {        plainSelect.setWhere(builderExpression(where, mainTables, whereSegment));    }}

解读:

该方法分别对SELECT语句中需要追加条件的部位进行解析,包括SELECT部分的[SelectItem] ,FROM部分的[FromItem],WHERE后面的条件(中的子查询)[Expression],JOIN连接查询的部分[JOIN]

SELECT    [SelectItem] FROM    [FromItem]LEFT/RIGHT/INNER JOIN [JOIN]WHERE    [Expression]

解析完成后会调用plainSelect.setWhere(builderExpression(where, mainTables))对需要最终查出所有数据的驱动表进行WHERE条件重写,详见:3.10 buildTableExpression,到底哪个表是驱动表,会由processJoins方法进行计算确认,具体见:3.7 processJoins

3.3 processSelectItem

该方法用于解析和重写SELECT列表中带有SELECT的语法结构

protected void processSelectItem(SelectItem selectItem, final String whereSegment) {    Expression expression = selectItem.getExpression();    if (expression instanceof Select) {        processSelectBody(((Select) expression), whereSegment);    } else if (expression instanceof Function) {        processFunction((Function) expression, whereSegment);    } else if (expression instanceof ExistsExpression) {        ExistsExpression existsExpression = (ExistsExpression) expression;        processSelectBody((Select) existsExpression.getRightExpression(), whereSegment);    }}

解读:

该方法会对SELECT列表项中的子查询语句,函数参数中的SELECT语句和EXIST结构中的SELECT语句进行解析

SQL举例说明:

SELECT     id,    employee_id,    fun_first_name( (select n from users u where u.id = e.uid) ) as first_name ,    (select last_name from users u where u.id = e.uid) as last_name,    EXISTS(SELECT 1 FROM projects WHERE manager_id = e.employee_id)  AS is_managerFROM     employees e;

解析并处理后得到SQL:

SELECT     id,     employee_id,     fun_first_name((SELECT n FROM users u WHERE u.id = e.uid AND users.scope = 12)) AS first_name,     (SELECT last_name FROM users u WHERE u.id = e.uid AND users.scope = 12) AS last_name,     EXISTS (SELECT 1 FROM projects WHERE manager_id = e.employee_id AND projects.scope = 12) AS is_manager FROM    employees eWHERE     employees.scope = 12

EXISTS (...) as ..不能写成( EXISTS (...) ) as ..,否则不会被解析为Select而是会被解析为Parenthesis,而该方法没有提供Parenthesis的解析,会导致被忽略

3.4 processWhereSubSelect

该方法用于对WHERE后面的SQL语句结构进行解析和追加过滤条件,主要是在分段拆分解析where表达式,代码实现的方式非常精巧,分析起来自然稍微有一点难度,但是远比processJoins()简单的多。

protected void processWhereSubSelect(Expression where, final String whereSegment) {    if (where == null) {        return;    }    if (where instanceof FromItem) {        processOtherFromItem((FromItem) where, whereSegment);        return;    }    if (where.toString().indexOf("SELECT") > 0) {        /* 通过if (where.toString().indexOf("SELECT") > 0)判断当前的where语句中是否含有select关键字        如果有的话说明where条件后的表达式存在子查询,又会马上进入以下逻辑对子查询的表进行解析和追加条件*/        if (where instanceof BinaryExpression) {            // 比较符号 , and , or , 等等            BinaryExpression expression = (BinaryExpression) where;            processWhereSubSelect(expression.getLeftExpression(), whereSegment);            processWhereSubSelect(expression.getRightExpression(), whereSegment);        }        else if (where instanceof InExpression) {            // in            InExpression expression = (InExpression) where;            Expression inExpression = expression.getRightExpression();            // in的是子查询才处理            if (inExpression instanceof Select) {                processSelectBody(((Select) inExpression), whereSegment);            }        }         else if (where instanceof ExistsExpression) {            // exists            ExistsExpression expression = (ExistsExpression) where;            processWhereSubSelect(expression.getRightExpression(), whereSegment);        }         else if (where instanceof NotExpression) {            // not exists , not in ...            // 如果是not的结构,还需要expression.getExpression()后再递归调用processWhereSubSelect()特殊处理            NotExpression expression = (NotExpression) where;            processWhereSubSelect(expression.getExpression(), whereSegment);        }         else if (where instanceof Parenthesis) {            Parenthesis expression = (Parenthesis) where;            processWhereSubSelect(expression.getExpression(), whereSegment);        }    }}

解读:

传进来的参数Expression where是一个JSQLParser的Expression类型,因为WHERE条件中可能解析出很多不同类型的SQL语法结构,这些结构都在processWhereSubSelect方法中一并处理,因此这里用了一个偏底层可以泛指这些结构的Expression对象作为参数,主要需要处理的就是子查询和返回布尔值的各种表达式。

解析时首先判断传进来的Expression是否为FromItem结构(通常就是子查询),如是直接传入processOtherFromItem()处理子查询,否则进一步判断该结构的语句体中是否有where关键字,如有说明存在子查询需要进一步处理,接着就会判断该结构是否为为比较符号(and,or, =, >等)衔接的BinaryExpression结构。

如果是BinaryExpression结构则先拆分为左右两部分,拆成的左右两部分可能有一侧还是BinaryExpression结构,甚至两侧都还是BinaryExpression结构,这样的话就要递归调用processWhereSubSelect()方法将拆分后的结构再次拆分,这样整个表达式便越拆越小,直到某个拆出的结构满足where instanceof FromItem后,再把该结构传入processOtherFromItem()处理子查询。

如果拆出的结构既不是FromItem又不是BinaryExpression,则需要再判断它是否属于in, exists,如是且有子查询结构,则将子查询剔出调用processSelectBody()进行解析子查询。

如果是not的结构,还需要expression.getExpression()后再递归调用processWhereSubSelect()特殊处理,因为not的情况比较特殊,不能一口气把子查询剔干净,实测not exists(select ...)不能拆出(select ...),只能先拆分出exists(select ...),再调用processWhereSubSelect走到else if (where instanceof ExistsExpression)分支后再拆出(select ...)not in同理,因此NotExpression结构不能直接拿到子查询,剔出来的是not后面的结构,要再递归调用processWhereSubSelect(),而不是直接processSelectBody()

案例说明:

SELECT name FROM user u WHERE u.math_score < (SELECT avg(score) FROM math ) OR u.english_score > (SELECT avg(score) FROM english ) AND (SELECT order_num FROM student ) = u.order_num AND u.role_id IN (SELECT id FROM role ) AND EXISTS ( SELECT * FROM customer WHERE id = 6 )AND NOT EXISTS ( SELECT * FROM customer WHERE id = 7 )

在这段SQL中,通过plainSelect.getWhere()得到的where的部分是:u.math_score < (SELECT avg(score) FROM math) OR u.english_score > (SELECT avg(score) FROM english) AND (SELECT order_num FROM student) = u.order_num AND u.role_id IN (SELECT id FROM role) AND EXISTS (SELECT * FROM customer WHERE id = 6) AND NOT EXISTS (SELECT * FROM customer WHERE id = 7),该部分会作为参数传入Expression where中,这段复杂的where表达式中的子查询是采用拆分的方法解析到的,具体解析和追加的步骤如下:

第一次拆分:首先where结构被整个传入,where instanceof FromItem == false且where instanceof BinaryExpression == true,整个where表达式将被processWhereSubSelect(expression.getLeftExpression(), whereSegment)拆分为:

  • expression.getLeftExpression() => u.math_score < (SELECT avg(score) FROM math)
  • expression.getRightExpression() => u.english_score > (SELECT avg(score) FROM english) AND (SELECT order_num FROM student) = u.order_num AND u.role_id IN (SELECT id FROM role) AND EXISTS (SELECT * FROM customer WHERE id = 6) AND NOT EXISTS (SELECT * FROM customer WHERE id = 7)

第二次拆分:执行到processWhereSubSelect(expression.getLeftExpression(), whereSegment)处,将u.math_score < (SELECT avg(score) FROM math)传入processWhereSubSelect递归解析,这次执行仍然满足where instanceof FromItem == false,where instanceof BinaryExpression == true,u.math_score < (SELECT avg(score) FROM math)将被拆分为:

  • expression.getLeftExpression() => u.math_score
  • expression.getRightExpression() => (SELECT avg(score) FROM math)

接下来还会递归执行到processWhereSubSelect(expression.getLeftExpression(), whereSegment)处,将u.math_score传入processWhereSubSelect递归解析,没有满足条件的分支直接跳过,紧接着执行processWhereSubSelect(expression.getRightExpression(), whereSegment),将(SELECT avg(score) FROM math)传入processWhereSubSelect递归解析,这次执行满足where instanceof FromItem的条件,不需要拆分,执行processOtherFromItem(SELECT avg(score) FROM math)进行过滤条件追加,至此,第一步拆分拆出来的bexpression.getLeftExpression()部分解析处理完成,第一段递归随即跳出。

第三次拆分:第一步拆分出来的expression.getRightExpression()开始传入processWhereSubSelect进行递归解析,这部分也满足where instanceof FromItem == false,where instanceof BinaryExpression == true,将被拆分为:

  • expression.getLeftExpression() => u.english_score > (SELECT avg(score) FROM english) AND (SELECT order_num FROM student) = u.order_num AND u.role_id IN (SELECT id FROM role) AND EXISTS (SELECT * FROM customer WHERE id = 6)
  • expression.getRightExpression() => NOT EXISTS (SELECT * FROM customer WHERE id = 7)

同理,取出expression.getLeftExpression()进行第四次拆分:

where instanceof BinaryExpression:

  • expression.getLeftExpression() => u.english_score > (SELECT avg(score) FROM english) AND (SELECT order_num FROM student) = u.order_num AND u.role_id IN (SELECT id FROM role)
  • expression.getRightExpression() => EXISTS (SELECT * FROM customer WHERE id = 6)

第五次拆分:

where instanceof BinaryExpression:

  • expression.getLeftExpression() => u.english_score > (SELECT avg(score) FROM english) AND (SELECT order_num FROM student) = u.order_num
  • expression.getRightExpression() => u.role_id IN (SELECT id FROM role)

第六次拆分:

where instanceof BinaryExpression:

  • expression.getLeftExpression() => u.english_score > (SELECT avg(score) FROM english)
  • expression.getRightExpression() => (SELECT order_num FROM student) = u.order_num

第七次拆分:

where instanceof BinaryExpression:

  • expression.getLeftExpression() => u.english_score
  • expression.getRightExpression() => (SELECT avg(score) FROM english)

至此,第一步拆分出来的bexpression.getLeftExpression()已经拆分到不可拆分的程度,开始递归expression.getRightExpression()部分,并一路反算回去:

处理第七次拆分的RightExpression:

where instanceof BinaryExpression:

  • expression.getLeftExpression() => u.english_score
  • expression.getRightExpression() => (SELECT avg(score) FROM english)

u.english_score不满足任何分支,直接跳过,(SELECT avg(score) FROM english)是子查询,调用processOtherFromItem()处理。

处理第六次拆分的RightExpression:

where instanceof BinaryExpression:

  • expression.getLeftExpression() => (SELECT order_num FROM student)
  • expression.getRightExpression() => u.order_num

(SELECT order_num FROM student)是子查询,调用processOtherFromItem()处理,u.order_num不满足任何分支,直接跳过

处理第五次拆分的RightExpression:

where instanceof InExpression:

  • expression.getLeftExpression() => u.role_id
  • expression.getRightExpression() => (SELECT id FROM role)

u.role_id不满足任何分支,直接跳过,(SELECT id FROM role),通过IN解析子查询,然后调用processOtherFromItem()处理

处理第四次拆分的RightExpression:

where instanceof ExistsExpression:

  • expression.getRightExpression() => (SELECT * FROM customer WHERE id = 6)

EXISTS (SELECT * FROM customer WHERE id = 6)满足where instanceof ExistsExpression的情况,提取出(SELECT * FROM customer WHERE id = 6)子查询,调用processOtherFromItem()处理

处理第三次拆分的RightExpression:

where instanceof NotExpression:

  • expression.getExpression() => EXISTS (SELECT * FROM customer WHERE id = 7)

先调用processWhereSubSelect()NOT EXISTS (SELECT * FROM customer WHERE id = 7)中提取出EXISTS (SELECT * FROM customer WHERE id = 7),再走到where instanceof ExistsExpression分支提取出子查询(SELECT * FROM customer WHERE id = 7)调用processOtherFromItem()处理

至此,WHERE语句中所有需要追加条件的表都解析追加完成了,最终得到SQL如下:

SELECT name FROM user u WHERE (u.math_score < (SELECT avg(score) FROM math WHERE math.scope = 12) OR u.english_score > (SELECT avg(score) FROM english WHERE english.scope = 12) AND (SELECT order_num FROM student WHERE student.scope = 12) = u.order_num AND u.role_id IN (SELECT id FROM role WHERE role.scope = 12) AND EXISTS (SELECT * FROM customer WHERE id = 6 AND customer.scope = 12) AND NOT EXISTS (SELECT * FROM customer WHERE id = 7 AND customer.scope = 12)) AND user.scope = 12

这个方法看似有点复杂,只要编写一个SQL,运行一下,并DEBUG跟着一路调试下来,就会发现一点也不难理解,还能体会到这种实现的精巧之处

3.5 processOtherFromItem

主要就是处理子查询ParenthesedSelect

/** * 处理子查询等 */protected void processOtherFromItem(FromItem fromItem, final String whereSegment) {    // 去除括号//        while (fromItem instanceof ParenthesisFromItem) {//            fromItem = ((ParenthesisFromItem) fromItem).getFromItem();//        }    if (fromItem instanceof ParenthesedSelect) {        Select subSelect = (Select) fromItem;        processSelectBody(subSelect, whereSegment);    } else if (fromItem instanceof ParenthesedFromItem) {        logger.debug("Perform a subQuery, if you do not give us feedback");    }}

3.6 processFunction

处理函数,对参数中的子查询进行处理

/** * 处理函数 * <p>支持: 1. select fun(args..) 2. select fun1(fun2(args..),args..)<p> * <p> fixed gitee pulls/141</p> * * @param function */protected void processFunction(Function function, final String whereSegment) {    ExpressionList<?> parameters = function.getParameters();    if (parameters != null) {        parameters.forEach(expression -> {            if (expression instanceof Select) {                processSelectBody(((Select) expression), whereSegment);            } else if (expression instanceof Function) {                processFunction((Function) expression, whereSegment);            } else if (expression instanceof EqualsTo) {                if (((EqualsTo) expression).getLeftExpression() instanceof Select) {                    processSelectBody(((Select) ((EqualsTo) expression).getLeftExpression()), whereSegment);                }                if (((EqualsTo) expression).getRightExpression() instanceof Select) {                    processSelectBody(((Select) ((EqualsTo) expression).getRightExpression()), whereSegment);                }            }        });    }}

3.7 processJoins

该方法用于解析和重写JOIN连接部分的SQL,将被驱动表(要保留部分数据)的过滤条件追加在ON条件上,并确定最终的驱动表(要保留全部数据)到底是哪一张,该方法实现的功能虽然简单,但逻辑却是该类所有的方法中最复杂的。

/** * 处理 joins * * @param mainTables 哪些表是过滤条件要放在最后的where后面的主表,暂时是from后面的表,但是会根据JOIN类型的不同对主子表进行修改 * @param joins      连接的表及其连接条件 */private List<Table> processJoins(List<Table> mainTables, List<Join> joins, final String whereSegment) {    // join 表达式中最终的主表    Table mainTable = null;    // 当前 join 的左表    Table leftTable = null;    if (mainTables.size() == 1) {        mainTable = mainTables.get(0);        leftTable = mainTable;    }    //对于 on 表达式写在最后的 join,需要记录下前面多个 on 的表名    Deque<List<Table>> onTableDeque = new LinkedList<>();    for (Join join : joins) {        // 处理 on 表达式        FromItem joinItem = join.getRightItem();                List<Table> joinTables = null;        // //join的对象是表,将表存入joinTables        if (joinItem instanceof Table) {            joinTables = new ArrayList<>();            joinTables.add((Table) joinItem);        }         // 可被查询的一个带着括号的语法结构,但是又不是子查询(select ...),一般不会走到这个分支        else if (joinItem instanceof ParenthesedFromItem) {            joinTables = processSubJoin((ParenthesedFromItem) joinItem, whereSegment);        }        if (joinTables != null) {            // 如果是隐式内连接,from和join的表在语法上没有谁是驱动谁是被驱动            if (join.isSimple()) {                mainTables.addAll(joinTables);                continue;            }                        Table joinTable = joinTables.get(0);            List<Table> onTables = null;            // 右连接            if (join.isRight()) {                // 因为取右表所有,驱动表和被驱动表交换                mainTable = joinTable;                mainTables.clear();                if (leftTable != null) {                    // leftTable原本是驱动表,right join的新表后,要作为被驱动表                    onTables = Collections.singletonList(leftTable);                }            }             // 内连接本就是取得两表交集,无论哪个表的条件都加在ON上,过滤条件即为查询条件,不区分谁是驱动谁是被驱动            else if (join.isInner()) {                if (mainTable == null) {                    onTables = Collections.singletonList(joinTable);                } else {                    onTables = Arrays.asList(mainTable, joinTable);                }                mainTable = null;                mainTables.clear();            }             // left join的情况,表的地位不需调整,from后的表是驱动表,on的表是被驱动表            else {                onTables = Collections.singletonList(joinTable);            }            // 将新的驱动表回写mainTables,用于拼接过滤条件在where后            if (mainTable != null && !mainTables.contains(mainTable)) {                mainTables.add(mainTable);            }            // 获取 join 尾缀的 on 表达式列表            Collection<Expression> originOnExpressions = join.getOnExpressions();            // 正常 join on 表达式只有一个,立刻处理            if (originOnExpressions.size() == 1 && onTables != null) {                List<Expression> onExpressions = new LinkedList<>();                onExpressions.add(builderExpression(originOnExpressions.iterator().next(), onTables, whereSegment));                join.setOnExpressions(onExpressions);                /*                      记录下本次JOIN后驱动表是哪个                    RIGHT JOIN:join后的表是驱动表                    INNER JOIN:join后的表作为驱动表                    LEFT JOIN: from后面的是驱动表                */                leftTable = mainTable == null ? joinTable : mainTable;                continue;            }            // 表名压栈,忽略的表压入 null,以便后续不处理            onTableDeque.push(onTables);            // 尾缀多个 on 表达式的时候统一处理            if (originOnExpressions.size() > 1) {                Collection<Expression> onExpressions = new LinkedList<>();                for (Expression originOnExpression : originOnExpressions) {                    List<Table> currentTableList = onTableDeque.poll();                    if (CollectionUtils.isEmpty(currentTableList)) {                        onExpressions.add(originOnExpression);                    } else {                        onExpressions.add(builderExpression(originOnExpression, currentTableList, whereSegment));                    }                }                join.setOnExpressions(onExpressions);            }            leftTable = joinTable;        }         // join的不是表,可能是一个子查询,如是,对子查询中的SQL进行解析和追加条件        else {            processOtherFromItem(joinItem, whereSegment);            leftTable = null;        }    }    return mainTables;}

解读:

这里假设每张表都追加一个scope = 12的过滤条件用于数据权限或多租户等功能,这里用几种类型的SQL测试用例来解读该方法,其中有些形式的SQL写法在开发中基本不会用到,但是还是列举出来一一分析下

3.7.1 隐式INNER JOIN

SELECT u.id, u.name FROM userinfo u, dept d, role r WHERE u.p = 1 AND u.dept_id = d.id AND u.rid = r.id 

jsqlparser解析这种隐式内连接SQL时,会默认将from后面接的第一个表userinfo作为驱动表,传入List<Table> mainTables,剩下的表默认作为非驱动表在List<Join> joins中,在隐式内连接中,因为需要取多表交集,语法上实际是没有谁驱动谁的概念的,只要当前的JOIN满足if (join.isSimple()) == true,则当前JOIN的表也添加到mainTables中,并continue结束当前JOIN条件的解析,实际上隐式内连接的情况下List<Join> joins中的JOIN都满足if (join.isSimple()) == true,最后所有JOIN的表都会被加入mainTables中,最终在where上追加过滤条件,得到SQL如下:

SELECT u.id, u.name FROM userinfo u, dept d, role r WHERE u.p = 1 AND u.dept_id = d.id AND u.rid = r.id AND userinfo.scope = 12 AND dept.scope = 12 AND role.scope = 12

3.7.2 INNER JOIN

SELECT u.id, u.name FROM userinfo u INNER JOIN dept d ON u.dept_id = d.id  INNER JOIN role r ON u.rid = r.id  WHERE u.p = 1

INNER JOIN的情况和隐式内连接的情况类似,都是取多张表的交集,传入List<Table> mainTables中的唯一的元素是userinfo,List<Join> joins中依次是INNER JOIN的两张表dept,role,解析第一个inner join时,userinfo,dept两表都会保存到onTables中,这会将两表各自的scope = 12过滤条件依次追加在当前inner join dept的ON后,解析到第二个inner join的表时,则是把解析到的role表加入到onTables中,同理会将这个表的过滤条件scope = 12追加在当前inner join role的ON后,第三个和更后面的JOIN的规则和第二个是一样的。

因此,和隐式内连接不同的是,INNER JOIN下过滤条件不会加在where上,而是将过滤条件全部加在每个JOIN的ON后面,最终得到SQL:

SELECT u.id, u.name FROM userinfo u INNER JOIN dept d ON u.dept_id = d.id AND userinfo.scope = 12 AND dept.scope = 12 INNER JOIN role r ON u.rid = r.id AND role.scope = 12 WHERE u.p = 1

3.7.3 LEFT JOIN

SELECT u.id, u.name FROM userinfo u LEFT JOIN dept d ON u.dept_id = d.id  LEFT JOIN role r ON u.rid = r.id  WHERE u.p = 1  

LEFT JOIN取的是FROM表的全部数据,是最简单的一种情况,方法开始执行时,参数mainTables中传入userinfo,joins中存放的则是dept,role两张表,局部变量mainTableleftTable均为userinfo,因为LEFT JOIN取的是userinfo表的全部数据,因此mainTables中的userinfo就是驱动表,过滤条件加在WHERE上。LEFT JOIN的dept和role两张表都是被驱动表,过滤条件加在ON上。

SELECT u.id, u.name FROM userinfo u LEFT JOIN dept d ON u.dept_id = d.id AND dept.scope = 12 LEFT JOIN role r ON u.rid = r.id AND role.scope = 12 WHERE u.p = 1 AND userinfo.scope = 12

3.7.4 RIGHT JOIN

SELECT u.id, u.name FROM userinfo u RIGHT JOIN dept d ON u.dept_id = d.id  RIGHT JOIN role r ON u.rid = r.id  WHERE u.p = 1  

RIGHT JOIN取的是JOIN后的表的全部数据,和LEFT JOIN正好相反,方法开始执行时,参数mainTables中传入userinfo,joins中存放的则是dept,role两张表,局部变量mainTableleftTable均为userinfo

循环第一个JOIN,首先交换驱动和非驱动表,mainTable = joinTable将dept赋给mainTable,原先的userinfo放到onTables中并追加过滤条件到ON上,再将dept放进mainTables,交换完成后,本次JOIN的驱动表dept再赋给leftTable记录下来用于下次JOIN解析

第二个JOIN,仍然是右连接,role将作为驱动表取代上次的dept,因此mainTable = joinTable将role赋给mainTable,leftTable依然记录着上次JOIN的驱动表dept,但本次RIGHT JOIN中dept已经变为被驱动表,所以dept放到onTables中追加过滤条件到本次JOIN的ON上,从而缩小上次结果集的范围

更多JOIN以此类推,RIGHT JOIN中,越是最后JOIN的表越“大“,循环结束后,role作为最终的驱动表,在where上追加过滤条件,最终得到SQL:

SELECT u.id, u.name FROM userinfo u RIGHT JOIN dept d ON u.dept_id = d.id AND userinfo.scope = 12 RIGHT JOIN role r ON u.rid = r.id AND dept.scope = 12 WHERE u.p = 1 AND role.scope = 12

3.7.5 先INNER再RIGHT

SELECT u.id, u.name FROM userinfo u INNER JOIN dept d ON u.dept_id = d.id  RIGHT JOIN role r ON u.rid = r.id  WHERE u.p = 1  

这种情况下解析第一个INNER JOIN的逻辑和之前的是一样的,userinfo和dept同时作为驱动表,把过滤条件加在ON上,然后默认驱动表是当前JOIN的dept,并赋值给leftTable,当解析第二个的RIGHT JOIN的role表时,role表成为最终查出全部数据的驱动表,因此为上次赋值给leftTable的dept表追加过滤条件到本次RIGHT JOIN role的ON后,缩小上次JOIN的结果集范围,并最终将role保存到mainTables在where上追加过滤条件,实现查出role的独有加role和上次inner join结果集的共有,得到如下SQL:

SELECT u.id, u.nameFROM userinfo uINNER JOIN dept d ON u.dept_id = d.id AND userinfo.scope = 12 AND dept.scope = 12RIGHT JOIN role r ON u.rid = r.id AND dept.scope = 12WHERE u.p = 1 AND role.scope = 12

3.7.6 先RIGHT再INNER

SELECT u.id, u.name FROM userinfo u RIGHT JOIN dept d ON u.dept_id = d.id  INNER JOIN role r ON u.rid = r.id  WHERE u.p = 1  

第一个RIGHT JOIN和之前的一样,首先交换表,mainTable = joinTable将dept赋给mainTable,原先的userinfo放到onTables中并追加过滤条件到ON上,再将dept放进mainTables,交换完成后,本次JOIN的驱动表dept再赋给leftTable记录下来用于下次JOIN解析,第二次循环的INNER JOIN是要把当前role表和上次的RIGHT JOIN的结果集取交集,因此会将上次的驱动表dept和当前INNER JOIN的表role都加在本次JOIN的ON上做过滤条件拼接就够了,不需要在where拼接任何条件,因此会清空mainTables,得到SQL如下:

SELECT u.id, u.name FROM userinfo u RIGHT JOIN dept d ON u.dept_id = d.id AND userinfo.scope = 12 INNER JOIN role r ON u.rid = r.id AND dept.scope = 12 AND role.scope = 12 WHERE u.p = 1

3.7.7 先INNER再LEFT

SELECT u.id, u.name FROM userinfo u INNER JOIN dept d ON u.dept_id = d.id  LEFT JOIN role r ON u.rid = r.id  WHERE u.p = 1  

这种情况第一次循环先处理INNER JOIN,将userinfo和dept两表的过滤条件加在第一个INNER JOIN的ON上,mainTables没有元素,第二次循环处理LEFT JOIN时,因为要取上次INNER JOIN结果的所有加上次INNER JOIN结果和role表的共有,因此将过滤条件加在LEFT JOIN role的ON上缩小role表的范围即可,得到SQL:

SELECT u.id, u.name FROM userinfo u INNER JOIN dept d ON u.dept_id = d.id AND userinfo.scope = 12 AND dept.scope = 12 LEFT JOIN role r ON u.rid = r.id AND role.scope = 12 WHERE u.p = 1

3.7.8 先LEFT再INNER

SELECT u.id, u.name FROM userinfo u LEFT JOIN dept d ON u.dept_id = d.id  INNER JOIN role r ON u.rid = r.id  WHERE u.p = 1  

解析LEFT JOIN时,取from表的全部,因此驱动表就是userinfo,INNER JOIN时又需要取role和上次LEFT JOIN结果集的交集,因此会将驱动表userinfo和role表的过滤条件加在INNER JOIN的ON上面,得到SQL如下:

SELECT u.id, u.name FROM userinfo u LEFT JOIN dept d ON u.dept_id = d.id AND dept.scope = 12 INNER JOIN role r ON u.rid = r.id AND userinfo.scope = 12 AND role.scope = 12 WHERE u.p = 1

3.7.9 先RIGHT再LEFT

SELECT u.id, u.name FROM userinfo u RIGHT JOIN dept d ON u.dept_id = d.id  LEFT JOIN role r ON u.rid = r.id  WHERE u.p = 1  

解析第一个RIGHT JOIN时,JOIN的表要查出全部数据,是驱动表,因此通过mainTable = joinTable;将dept设置为驱动表,并将dept存入mainTables,userinfo表存入onTables中作为被驱动表,将userinfo的过滤条件追加在ON上。
解析第二个LEFT JOIN时,要取上次JOIN的结果集的全部,role表作为当前的joinTable存入onTables,将过滤条件追加在当前JOIN的ON上,mainTables存的是主导上次结果集的表dept,在本次JOIN结束后,dept表的过滤条件加在最终的WHERE上,得到SQL:

SELECT u.id, u.name FROM userinfo u RIGHT JOIN dept d ON u.dept_id = d.id AND userinfo.scope = 12 LEFT JOIN role r ON u.rid = r.id AND role.scope = 12 WHERE u.p = 1  AND dept.scope = 12

3.7.10 先LEFT再RIGHT

SELECT u.id, u.name FROM userinfo u LEFT JOIN dept d ON u.dept_id = d.id  RIGHT JOIN role r ON u.rid = r.id  WHERE u.p = 1

解析第一个LEFT JOIN时,结果集需要取userinfo表的全部,mainTable, leftTable的值都是userinfo,mainTables中唯一的元素也是userinfo,LEFT JOIN dept直接把JOIN的dept表的过滤条件追加在ON上。
解析第二个RIGHT JOIN role时,最终的结果集要以role表为准了,于是mainTable赋值为role表,将mainTables清空,leftTable不为空的话,存入onTables中,于是userinfo表将在本次JOIN的ON上追加过滤条件,role表将存入到mainTables中在WHERE上追加过滤条件,得到SQL如下:

SELECT u.id, u.name FROM userinfo u LEFT JOIN dept d ON u.dept_id = d.id AND dept.scope = 12 RIGHT JOIN role r ON u.rid = r.id AND userinfo.scope = 12 WHERE u.p = 1 AND role.scope = 12

3.7.11 FROM子查询JOIN表

LEFT JOIN:

SELECT u.id, u.name FROM (SELECT * FROM userinfo  ) u LEFT JOIN dept d ON u.dept_id = d.id  LEFT JOIN role r ON u.rid = r.id  WHERE u.p = 1

这种情况下,from后的是子查询,参数mainTables元素数为0,dept表加入到onTables中在ON上追加过滤条件,但是from后的子查询的过滤条件追加已经在子查询解析重写中完成,因此if (mainTable != null && !mainTables.contains(mainTable))不满足,mainTables中没有要追加条件到where上的表,如第二次还是LEFT JOIN同理,最终得到SQL如下:

SELECT u.id, u.name FROM (SELECT * FROM userinfo WHERE userinfo.scope = 12) u LEFT JOIN dept d ON u.dept_id = d.id AND dept.scope = 12 LEFT JOIN role r ON u.rid = r.id AND role.scope = 12 WHERE u.p = 1

RIGHT JOIN:

SELECT u.id, u.name FROM (SELECT * FROM userinfo  ) u RIGHT JOIN dept d ON u.dept_id = d.id RIGHT JOIN role r ON u.rid = r.id  WHERE u.p = 1  

这种情况,from后的是子查询,参数mainTables元素数为0,leftTable一开始肯定也为null,因此第一个RIGHT JOIN后面没有ON过滤条件,但是第一个JOIN的dept表会被mainTable = joinTable设置为驱动表,onTables没有元素会最终走到leftTable = joinTable将dept设置为leftTable,第二次RIGHT JOIN时就会追加dept的过滤条件在当前的ON上来缩小上次JOIN的结果集,得到SQL如下:

SELECT u.id, u.name FROM (SELECT * FROM userinfo WHERE userinfo.scope = 12) u RIGHT JOIN dept d ON u.dept_id = d.id RIGHT JOIN role r ON u.rid = r.id AND dept.scope = 12 WHERE u.p = 1 AND role.scope = 12

3.7.12 FROM表JOIN子查询

RIGHT JOIN:

SELECT u.id, u.name FROM userinfo u RIGHT JOIN (SELECT * FROM dept  ) d ON u.dept_id = d.id RIGHT JOIN (SELECT * FROM role  ) r ON u.rid = r.id WHERE u.p = 1  

这样的SQL处理起来比较简单,因为JOIN的都是子查询而不是表,因此会执行processOtherFromItem(joinItem, whereSegment)将子查询表追加的条件直接加在子查询语句的where上面,主SQL语句的条件不需要区分驱动表和非驱动表和各个表的过滤条件在ON或WHERE的位置,处理完子查询后,参数List<Table> mainTables会原样返回,FROM后面的表直接在WHERE上拼接过滤条件,最终得到SQL:

SELECT u.id, u.name FROM userinfo u RIGHT JOIN (SELECT * FROM dept WHERE dept.scope = 12) d ON u.dept_id = d.id RIGHT JOIN (SELECT * FROM role WHERE role.scope = 12) r ON u.rid = r.id WHERE u.p = 1 AND userinfo.scope = 12

LEFT JOIN:

SELECT u.id, u.name FROM userinfo u LEFT JOIN (SELECT * FROM dept  ) d ON u.dept_id = d.id LEFT JOIN (SELECT * FROM role  ) r ON u.rid = r.id WHERE u.p = 1  

处理LEFT的情况和RIGHT是一样的,得到的SQL形式也相同:

SELECT u.id, u.name FROM userinfo u LEFT JOIN (SELECT * FROM dept WHERE dept.scope = 12) d ON u.dept_id = d.id LEFT JOIN (SELECT * FROM role WHERE role.scope = 12) r ON u.rid = r.id WHERE u.p = 1 AND userinfo.scope = 12

3.7.13 FROM子查询JOIN子查询

SELECT u.id, u.name FROM (SELECT * FROM userinfo ) u RIGHT JOIN (SELECT * FROM dept ) d ON u.dept_id = d.id RIGHT JOIN (SELECT * FROM role ) r ON u.rid = r.id WHERE u.p = 1

这种情况本质上和FROM表JOIN子查询是一样的

SELECT u.id, u.name FROM (SELECT * FROM userinfo WHERE userinfo.scope = 12) u RIGHT JOIN (SELECT * FROM dept WHERE dept.scope = 12) d ON u.dept_id = d.id RIGHT JOIN (SELECT * FROM role WHERE role.scope = 12) r ON u.rid = r.id WHERE u.p = 1

3.7.14 不支持的情况

processJoins()方法似乎并不是万能的,有几种我遇到的不能支持的极端情况:

1.JOIN表和JOIN子查询混用时,使用了RIGHT会导致丢掉某个表的过滤条件

以下两个是重写过的SQL,都会导致userinfo表的scope条件丢失

SELECT u.id, u.name FROM userinfo u LEFT JOIN (SELECT * FROM dept WHERE dept.scope = 12) d ON u.dept_id = d.id RIGHT JOIN role r ON u.rid = r.id LEFT JOIN (SELECT * FROM job WHERE job.scope = 12) j ON u.jid = j.id WHERE u.p = 1 AND role.scope = 12
SELECT u.id, u.nameFROM userinfo uRIGHT JOIN (SELECT * FROM dept WHERE dept.scope = 12) d ON u.dept_id = d.idRIGHT JOIN role r ON u.rid = r.idWHERE u.p = 1 AND role.scope = 12

2.from子查询后,left和right混用时,会导致表的范围限制出现问题,因为找不到上次结果集范围的基准表是哪个了

例:这是一个重写过的SQL,因为from后的表不存在(因为是子查询),在执行leftTable = mainTable == null ? joinTable时,将left join的dept表错误的作为了驱动表,导致下次right join时以dept表为基准,将dept又追加一次dept.scope = 12,实际应当以(SELECT * FROM userinfo WHERE userinfo.scope = 12)为基准,这样就导致(SELECT * FROM userinfo WHERE userinfo.scope = 12)的记录不全

SELECT u.id, u.name FROM (SELECT * FROM userinfo WHERE userinfo.scope = 12) u LEFT JOIN dept d ON u.dept_id = d.id AND dept.scope = 12 RIGHT JOIN role r ON u.rid = r.id AND dept.scope = 12 WHERE u.p = 1 AND role.scope = 12

3.case表达式中如出现select,默认不处理,可能是因为这里的select条件不影响整体查询结果的范围,没有处理的必要

例:

SELECT     CASE         WHEN id >= 90 THEN (SELECT id FROM system_users WHERE parent_dept_id = 9)         WHEN id >= 80 THEN (SELECT id FROM system_users WHERE parent_dept_id = 6)         WHEN (SELECT id FROM system_users WHERE parent_dept_id = 5) >= 70         THEN (SELECT id FROM system_users WHERE parent_dept_id = 5) ELSE 100     END AS grade FROM system_users WHERE system_users.scope = 12

3.7.15 小结

processJoins()方法针JOIN的表进行解析重写,并对照FROM后面的表根据每次JOIN结果集的范围确定每张表在当前JOIN中的角色,从而调整要追加的条件的位置是在ON上还是WHERE上,做到既要精准的进行条件限制,又不能破坏原有SQL逻辑应当得到的结果集范围

3.8 processSubJoin

sub join的情况,目前还没遇到过,之后再补充,这个分支应该很少走

/** * 处理 sub join * * @param subJoin subJoin * @return Table subJoin 中的主表 */private List<Table> processSubJoin(ParenthesedFromItem subJoin, final String whereSegment) {    List<Table> mainTables = new ArrayList<>();    while (subJoin.getJoins() == null && subJoin.getFromItem() instanceof ParenthesedFromItem) {        subJoin = (ParenthesedFromItem) subJoin.getFromItem();    }    if (subJoin.getJoins() != null) {        List<Table> list = processFromItem(subJoin.getFromItem(), whereSegment);        mainTables.addAll(list);        processJoins(mainTables, subJoin.getJoins(), whereSegment);    }    return mainTables;}

3.9 processFromItem

对FROM后面的结构进行解析,解析出的有表(Table)或子查询(ParenthesedSelect)以及(table1 join table2)等结构,分别处理

private List<Table> processFromItem(FromItem fromItem, final String whereSegment) {    // 处理括号括起来的表达式//        while (fromItem instanceof ParenthesedFromItem) {//            fromItem = ((ParenthesedFromItem) fromItem).getFromItem();//        }    List<Table> mainTables = new ArrayList<>();    // 无 join 时的处理逻辑    if (fromItem instanceof Table) {        Table fromTable = (Table) fromItem;        mainTables.add(fromTable);    } else if (fromItem instanceof ParenthesedFromItem) {        // SubJoin 类型则还需要添加上 where 条件        List<Table> tables = processSubJoin((ParenthesedFromItem) fromItem, whereSegment);        mainTables.addAll(tables);    } else {        // 处理下 fromItem        processOtherFromItem(fromItem, whereSegment);    }    return mainTables;}

3.10 builderExpression

刚方法用于对解析出来的表在已有的条件上追加过滤条件,在FROM后面和ON后面解析出来的表和对应条件都会传到在这个方法,先将传进来的表追加条件并拼接成AND结构,再判断已有条件是使用AND还是OR连接,如果已有的条件是OR连接,则将已有条件用小括号括起来再去AND要追加的条件,如果已有条件就是AND连接的,则把要追加的条件和已有条件直接AND相连即可

/** * 处理条件 */protected Expression builderExpression(Expression currentExpression, List<Table> tables, final String whereSegment) {    // 没有表需要处理直接返回    if (CollectionUtils.isEmpty(tables)) {        return currentExpression;    }    // 构造每张表的条件    List<Expression> expressions = tables.stream()        .map(item -> buildTableExpression(item, currentExpression, whereSegment))        .filter(Objects::nonNull)        .collect(Collectors.toList());    // 没有表需要处理直接返回    if (CollectionUtils.isEmpty(expressions)) {        return currentExpression;    }    // 注入的表达式    Expression injectExpression = expressions.get(0);    // 如果有多表,则用 and 连接    if (expressions.size() > 1) {        for (int i = 1; i < expressions.size(); i++) {            injectExpression = new AndExpression(injectExpression, expressions.get(i));        }    }    if (currentExpression == null) {        return injectExpression;    }    if (currentExpression instanceof OrExpression) {        // 已有条件是个OR结构,要先用括号括起来        return new AndExpression(new Parenthesis(currentExpression), injectExpression);    } else {        // 已有条件是个AND结构,直接拼接在一起        return new AndExpression(currentExpression, injectExpression);    }}

3.11 buildTableExpression

该方法本是BaseMultiTableInnerInterceptor中的一个抽象方法,用于确定对某个表要拼接的过滤条件具体是什么,由子类实现重写,这里先拼接一个scope = 12的过滤条件用于测试

/** * 构建数据库表的查询条件 * * @param table        表对象 * @param where        当前where条件 * @param whereSegment 所属Mapper对象全路径 * @return 需要拼接的新条件(不会覆盖原有的where条件,只会在原有条件上再加条件),为 null 则不加入新的条件 */public Expression buildTableExpression(final Table table, final Expression where, final String whereSegment) {    System.out.println(table);    return new EqualsTo(new Column(table.getName() + StringPool.DOT + "scope"),  new LongValue(12));}

3.12 andExpression

这个方法用于给单个表在已有的条件上追加过滤条件,实现过程类似builderExpression,一般只有删除和更新SQL才会用到这个,因为一次只能删除或更新一张表。

protected Expression andExpression(Table table, Expression where, final String whereSegment) {    //获得where条件表达式    final Expression expression = buildTableExpression(table, where, whereSegment);    if (expression == null) {        return where;    }    if (where != null) {        if (where instanceof OrExpression) {            return new AndExpression(new Parenthesis(where), expression);        } else {            return new AndExpression(where, expression);        }    }    return expression;}

四、结束语

该类主要为其他业务类提供涉及多表复杂查询SQL的解析能力,本类代码实现有很多值得学习和借鉴之处,而且基本严谨的考虑到了所有的情况,解析SQL时,对查询的解析较为复杂,分很多步骤,因为查询语句可以写的很复杂来满足业务的需要,但是对删除和修改的解析就很简单了,因为MyBatis-Plus的插件在追加条件时基本没有对修改后或修改条件的值是子查询或删除条件的值是子查询的情况进行处理,仅仅处理针对update/delete本身的where条件,这一点后面的系列文章也许还会做进一步分析。

繁忙的工作中抽时间阅读并DEBUG贯通该类源码,并大致理解源码的含义再到形成本文大概花了20天左右,感觉对自己的提升还是很大的,学习到了一系列解析SQL语句的实现方案,使用这个类提供的功能时也能心中有数,做到开发时尽可能避免写出该类不支持解析的SQL结构,在遇到一些问题时,也能大致猜到问题出现在哪了。

芋道源码解读之多租户

2025年1月26日 00:00

博主和芋道源码作者及其官方开发团队无任何关联

一、概述

租户(Tenant)是系统中的一个逻辑隔离的单元,代表一个独立使用系统的组织(如企业、高校等),在多租户系统中,不同租户共享相同的应用程序和基础设施,但各自拥有独立的数据、配置、组织架构及用户等。

芋道是一个支持多租户的系统,对多租户功能的组件和框架封装的代码位于yudao-spring-boot-starter-biz-tenant模块中,对于读写数据库和Redis,消息队列中消息的生产消费以及定时任务派发,调用异步方法等都分别实现了租户隔离,实现原理都是利用线程ThreadLocal进行租户标识传递和线程内共享,处理租户业务的线程(例如WebApi的HTTP请求线程,定时任务执行线程,消息消费回调线程)开始执行时首先获取具体场景下的租户ID,存到当前线程的ThreadLocal中,后续基于该线程执行或调用的各种方法中如遇到读写数据库或Redis以及发送消息和调用异步方法的操作时,便能从ThreadLocal获取租户ID再执行进一步操作。

项目通过一个TenantContextHolder类来封装ThreadLocal进而实现不同场景下基于线程的租户隔离,为每个执行带有租户隔离逻辑代码的线程都绑定一个TENANT_ID对象来储存和共享当前场景的租户ID,同时还绑定了一个布尔类型的IGNORE用于标识当前线程即将要执行的代码是否需要处理租户。

只有深入正确理解TenantContextHolder中ThreadLocal的原理,才能真正理解多租户的实现原理

cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder

public class TenantContextHolder {    /**     * 当前租户编号     */    private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();    /**     * 是否忽略租户     */    private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();    /**     * 获得租户编号     *     * @return 租户编号     */    public static Long getTenantId() {        return TENANT_ID.get();    }    /**     * 获得租户编号。如果不存在,则抛出 NullPointerException 异常     *     * @return 租户编号     */    public static Long getRequiredTenantId() {        Long tenantId = getTenantId();        if (tenantId == null) {            throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:"                + DocumentEnum.TENANT.getUrl());        }        return tenantId;    }    public static void setTenantId(Long tenantId) {        TENANT_ID.set(tenantId);    }    public static void setIgnore(Boolean ignore) {        IGNORE.set(ignore);    }    /**     * 当前是否忽略租户     *     * @return 是否忽略     */    public static boolean isIgnore() {        return Boolean.TRUE.equals(IGNORE.get());    }    public static void clear() {        TENANT_ID.remove();        IGNORE.remove();    }}

多租户还需要考虑忽略租户和指定租户的情况:

调用某些方法时,租户应当被忽略,例如超级管理员获取系统全部数据、项目启动后获取全部数据去创建全局静态缓存等,因此该项目也提供了租户忽略的实现方案,对于某段需要忽略租户执行的代码,提供了忽略租户去执行某个代码块的公共方法,对于整个方法需要忽略租户的情况,则通过AOP处理自定义注解的方式,对某个方法标记忽略租户,该方法内执行的代码便不再对多租户的情况进行处理。

调用某些方法时,应当以指定的某个租户ID去执行,而不是采用当前登录用户的租户ID,例如超管新建了一个租户,并为新租户一并创建管理员用户以及基本的角色,菜单和权限等数据时,这些数据的租户ID应该是新建的租户的ID,针对这种情况,项目也实现了一个按照指定租户ID执行某个代码块的公共方法。

二、数据库的租户隔离

2.1 数据库的租户隔离实现方案

数据库中租户的隔离方案有三种:

  • 库隔离:每个租户拥有独立的数据库实例
  • 表隔离:每个租户建属于自己的一套数据表
  • 记录隔离:表中使用租户标识字段(tenant_id)区分不同租户的数据

三种方案对比:

库隔离表隔离记录隔离
隔离性最高较高最低
性能低,受租户数量影响
备份还原难度简单困难
硬件成本
维护成本高,开租户就要建库

芋道采用了记录隔离的方式来实现数据库的租户隔离。

2.2 实现原理和源码解读

租户本质上也是一种特殊的数据权限,不同于数据权限的是对于涉及租户的表的增、删、改、查四种操作,都需要对SQL语句进行处理,实现原理是执行SQL前进行拦截,并获取要执行的SQL,然后解析SQL语句中的表,遇到需要租户隔离的表就要进行处理,对于查询、删除和更新的场景,就在现有的SQL条件中追加一个tenant_id = ?的条件,获取当前操作的用户或要执行的某种任务所属的租户ID赋值给tenant_id,对于添加操作,则是将tenant_id字段加入到INSERT列表中并赋值。

芋道采用MyBatis-Plus的插件拦截机制实现数据库的记录级别的租户隔离,这和数据权限的实现原理是完全一样的,实现租户隔离的插件是TenantLineInnerInterceptor,该类也像数据权限插件一样继承了用于解析和追加条件的BaseMultiTableInnerInterceptor类来实现表的解析和租户ID过滤条件的追加,实现原理具体见:TenantLineInnerInterceptor源码解读

TenantLineInnerInterceptor需要一个TenantLineHandler类型的租户处理器,TenantLineHandler是一个接口,用于给TenantLineInnerInterceptor判断某个表是否需要租户隔离,以及获取租户ID值表达式、租户字段名,我们需要实现这个接口并在回调方法中将这些信息封装好后返回。

com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler

public interface TenantLineHandler {    /**     * 获取租户 ID 值表达式,只支持单个 ID 值     * <p>     *     * @return 租户 ID 值表达式     */    Expression getTenantId();    /**     * 获取租户字段名     * <p>     * 默认字段名叫: tenant_id     *     * @return 租户字段名     */    default String getTenantIdColumn() {        return "tenant_id";    }    /**     * 根据表名判断是否忽略拼接多租户条件     * <p>     * 默认都要进行解析并拼接多租户条件     *     * @param tableName 表名     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件     */    default boolean ignoreTable(String tableName) {        return false;    }    /**     * 忽略插入租户字段逻辑     *     * @param columns        插入字段     * @param tenantIdColumn 租户 ID 字段     * @return     */    default boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {        return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));    }}

TenantDatabaseInterceptor就是芋道项目的租户处理器实现类,创建时构造方法内会读取项目配置文件,将所有需要忽略租户的表保存起来,用于执行ignoreTable()方法时判断当前表是否需要租户隔离。通过getTenantId()返回当前执行mapper方法和数据库交互的线程所绑定的租户ID,直接从TenantContextHolder获取即可。租户标识的字段名统一都叫”tenant_id”,getTenantIdColumn()直接从接口的默认方法中继承不需重写。

cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor

public class TenantDatabaseInterceptor implements TenantLineHandler {    private final Set<String> ignoreTables = new HashSet<>();    public TenantDatabaseInterceptor(TenantProperties properties) {        // 不同 DB 下,大小写的习惯不同,所以需要都添加进去        properties.getIgnoreTables().forEach(table -> {            ignoreTables.add(table.toLowerCase());            ignoreTables.add(table.toUpperCase());        });        // 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错        ignoreTables.add("DUAL");    }    @Override    public Expression getTenantId() {        return new LongValue(TenantContextHolder.getRequiredTenantId());    }    @Override    public boolean ignoreTable(String tableName) {        return TenantContextHolder.isIgnore() // 情况一,全局忽略多租户                || CollUtil.contains(ignoreTables, SqlParserUtils.removeWrapperSymbol(tableName)); // 情况二,忽略多租户的表    }}

最后,将TenantDatabaseInterceptor包装进TenantLineInnerInterceptor,注入MyBatis-Plus插件队列中,根据MyBatis-Plus插件机制在执行SQL前调用

cn.iocoder.yudao.framework.tenant.config.YudaoTenantAutoConfiguration

@AutoConfiguration@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户@EnableConfigurationProperties(TenantProperties.class)public class YudaoTenantAutoConfiguration {    ......    @Bean    public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,                                                                    MybatisPlusInterceptor interceptor) {        TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));        // 添加到 interceptor 中        // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定        MyBatisUtils.addInterceptor(interceptor, inner, 0);        return inner;    }    ......}

三、Redis的租户隔离

Redis和MySQL这样的关系型数据库不同,Redis采用KV键值对存储,因此芋道采用的办法是如果当前Redis读写需要处理租户隔离,则将KEY字符串的最后追加一个冒号和租户ID进行标识,需要注意的是,项目仅仅针对SpringCache方式操作Redis的情况进行了处理,如要使用RedisTemplate,需要自行实现KEY追加租户ID的逻辑。

Redis读写的租户隔离实现还是非常简单的,具体原理和源码解读如下:

1.实现一个TenantRedisCacheManager类继承TimeoutRedisCacheManager来拓展原有类的租户功能,操作Redis时,如需要处理租户,则在键名后面追加租户ID,再将数据保存到Redis或从Redis读出:

cn.iocoder.yudao.framework.tenant.core.redis.TenantRedisCacheManager

public class TenantRedisCacheManager extends TimeoutRedisCacheManager {    private final Set<String> ignoreCaches;    public TenantRedisCacheManager(RedisCacheWriter cacheWriter,                                   RedisCacheConfiguration defaultCacheConfiguration,                                   Set<String> ignoreCaches) {        super(cacheWriter, defaultCacheConfiguration);        this.ignoreCaches = ignoreCaches;    }    @Override    public Cache getCache(String name) {        // 如果开启多租户,则 name 拼接租户后缀        if (!TenantContextHolder.isIgnore()            && TenantContextHolder.getTenantId() != null            && !CollUtil.contains(ignoreCaches, name)) {            name = name + ":" + TenantContextHolder.getTenantId();        }        // 继续基于父方法        return super.getCache(name);    }}

2.将TenantRedisCacheManager注入Spring容器,并添加注解@Primary,只要引用了租户模块,TenantRedisCacheManager就是主Bean,代替TimeoutRedisCacheManager类提供带租户隔离功能的Redis客户端:

cn.iocoder.yudao.framework.tenant.config.YudaoTenantAutoConfiguration

@AutoConfiguration@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户@EnableConfigurationProperties(TenantProperties.class)public class YudaoTenantAutoConfiguration {    ......    @Bean    @Primary // 引入租户时,tenantRedisCacheManager 为主 Bean    public RedisCacheManager tenantRedisCacheManager(RedisTemplate<String, Object> redisTemplate,                                                        RedisCacheConfiguration redisCacheConfiguration,                                                        YudaoCacheProperties yudaoCacheProperties,                                                        TenantProperties tenantProperties) {        // 创建 RedisCacheWriter 对象        RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());        RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,                BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));        // 创建 TenantRedisCacheManager 对象        return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches());    }    ......}

⚠️如果不加@Primary注解,模块yudao-spring-boot-starter-redis中定义的不带租户隔离功能的TimeoutRedisCacheManager就会生效:

cn.iocoder.yudao.framework.redis.core.TimeoutRedisCacheManager

@Beanpublic RedisCacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate,                                           RedisCacheConfiguration redisCacheConfiguration,                                           YudaoCacheProperties yudaoCacheProperties) {    // 创建 RedisCacheWriter 对象    RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());    RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,            BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));    // 创建 TenantRedisCacheManager 对象    return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);}

四、Web请求的租户隔离

Web访问作为整个系统对外提供功能的入口,用户登录系统时需要选择以哪个组织(租户)的成员身份使用系统,每次调用需要租户隔离的接口,都要传入租户ID放进TenantContextHolder并存在于整个http请求线程的生命周期中供各种需要租户的场景读取使用,如果访问的URL在忽略租户列表中,则标记整个线程生命周期忽略租户,如果访问者访问的URL是必须登录才能访问的资源,还需要校验登录用户所属的租户和用户传入的租户是否一致防止越权访问。

Web请求的租户隔离的处理逻辑由TenantContextWebFilter和TenantSecurityWebFilter两个类来实现,TenantContextWebFilter用于维护租户ID在整个http请求线程,TenantSecurityWebFilter则用于鉴权,两个都是原生过滤器,直接注册到Servlet容器中。

TenantContextWebFilter优先执行,用于从请求头获得传过来的租户编号,并存放在TenantContextHolder中,请求完成后,再把租户编号从TenantContextHolder移除,这样在整个访问过程中,涉及到租户隔离的逻辑代码都能从TenantContextHolder中获取当前操作所属的租户ID:

cn.iocoder.yudao.framework.tenant.core.web.TenantContextWebFilter

public class TenantContextWebFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)            throws ServletException, IOException {        // 设置        Long tenantId = WebFrameworkUtils.getTenantId(request);        if (tenantId != null) {            TenantContextHolder.setTenantId(tenantId);        }        try {            chain.doFilter(request, response);        } finally {            // 清理            TenantContextHolder.clear();        }    }}

仅仅将请求的租户ID用于业务操作是不可以的,对于请求的租户是否合规还要进一步检验,先要判断租户ID是不是用户随意传的,如果租户可以随意指定,访问一些接口时就可能发生越权,故对于登录的用户,还需要TenantSecurityWebFilter校验当前登录的用户是否属于传入的租户,如果不一致直接报错。

接下来还要判断访问的URL是否必须进行租户隔离,如果URL不在忽略租户的URL配置中当前请求却没传租户ID就直接报错,如果传递了租户则继续校验租户可用状态是否正常,如正常就放行请求,如URL在忽略列表则直接将整个http线程标记为忽略租户,然后放行:

cn.iocoder.yudao.framework.tenant.core.security.TenantSecurityWebFilter

public class TenantSecurityWebFilter extends ApiRequestFilter {    private final TenantProperties tenantProperties;    private final AntPathMatcher pathMatcher;    private final GlobalExceptionHandler globalExceptionHandler;    private final TenantFrameworkService tenantFrameworkService;    public TenantSecurityWebFilter(TenantProperties tenantProperties,                                   WebProperties webProperties,                                   GlobalExceptionHandler globalExceptionHandler,                                   TenantFrameworkService tenantFrameworkService) {        super(webProperties);        this.tenantProperties = tenantProperties;        this.pathMatcher = new AntPathMatcher();        this.globalExceptionHandler = globalExceptionHandler;        this.tenantFrameworkService = tenantFrameworkService;    }    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)            throws ServletException, IOException {        Long tenantId = TenantContextHolder.getTenantId();        // 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。        LoginUser user = SecurityFrameworkUtils.getLoginUser();        if (user != null) {            // 如果获取不到租户编号,则尝试使用登陆用户的租户编号            if (tenantId == null) {                tenantId = user.getTenantId();                TenantContextHolder.setTenantId(tenantId);            // 如果传递了租户编号,则进行比对租户编号,避免越权问题            } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {                log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",                        user.getTenantId(), user.getId(), user.getUserType(),                        TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());                ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),                        "您无权访问该租户的数据"));                return;            }        }        // 如果非允许忽略租户的 URL,则校验租户是否合法        if (!isIgnoreUrl(request)) {            // 2. 如果请求未带租户的编号,不允许访问。            if (tenantId == null) {                log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());                ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),                        "请求的租户标识未传递,请进行排查"));                return;            }            // 3. 校验租户是合法,例如说被禁用、到期            try {                tenantFrameworkService.validTenant(tenantId);            } catch (Throwable ex) {                CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);                ServletUtils.writeJSON(response, result);                return;            }        } else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错            if (tenantId == null) {                TenantContextHolder.setIgnore(true);            }        }        // 继续过滤        chain.doFilter(request, response);    }    private boolean isIgnoreUrl(HttpServletRequest request) {        // 快速匹配,保证性能        if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) {            return true;        }        // 逐个 Ant 路径匹配        for (String url : tenantProperties.getIgnoreUrls()) {            if (pathMatcher.match(url, request.getRequestURI())) {                return true;            }        }        return false;    }}

注册两个过滤器,并指定优先级:

cn.iocoder.yudao.framework.tenant.config.YudaoTenantAutoConfiguration

@AutoConfiguration@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户@EnableConfigurationProperties(TenantProperties.class)public class YudaoTenantAutoConfiguration {    // ========== WEB ==========    @Bean    public FilterRegistrationBean<TenantContextWebFilter> tenantContextWebFilter() {        FilterRegistrationBean<TenantContextWebFilter> registrationBean = new FilterRegistrationBean<>();        registrationBean.setFilter(new TenantContextWebFilter());        registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER);// -104        return registrationBean;    }    // ========== Security ==========    @Bean    public FilterRegistrationBean<TenantSecurityWebFilter> tenantSecurityWebFilter(TenantProperties tenantProperties,                                                                                    WebProperties webProperties,                                                                                    GlobalExceptionHandler globalExceptionHandler,                                                                                    TenantFrameworkService tenantFrameworkService) {        FilterRegistrationBean<TenantSecurityWebFilter> registrationBean = new FilterRegistrationBean<>();        registrationBean.setFilter(new TenantSecurityWebFilter(tenantProperties, webProperties,                globalExceptionHandler, tenantFrameworkService));        registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER);// -99        return registrationBean;    }}

五、消息队列的租户隔离

对于消息的租户隔离,芋道是分为发送和消费两种场景分别处理的

  • 发送消息:如当前线程绑定了租户,取出当前线程绑定的租户ID,在发送消息时将租户ID设置在“消息头”,和消息内容一并发送到消息中间件。

  • 消费消息:从消息中间件推送过来的消息中,如消息头带着租户ID则先行取出消息头中的租户ID,与消息消费回调线程绑定在一起,再执行消息消费的回调方法。

芋道对采用多种常见消息中间件产品发送和消费消息的场景都支持了租户隔离。

5.1 Redis PubSub/Stream

Redis除了缓存,还是一个轻量级的消息中间件产品,它的Pub/Sub机制和Stream数据结构均能实现消息中间件功能,芋道对这两种方式的消息都提供了多租户的支持。

yudao-spring-boot-starter-redis模块在整合Redis时,将RedisTemplate进行了封装,对发送消息和消费消息都拓展出了前置后置操作功能,租户模块的TenantRedisMessageInterceptor就通过实现RedisMessageInterceptor接口来实现租户隔离。发送消息时从线程中取出绑定的租户ID,添加到“消息头”,发送给Redis,消费时,因为消费消息的方法运行在单独的线程,因此从消息的头取出消息所属的租户ID直接绑定在消息消费的回调线程,供线程中用到租户的场景使用,因为消费线程通常是通过线程池复用的,消费完成后,要把租户ID从消费线程中移除。

cn.iocoder.yudao.framework.tenant.core.mq.redis.TenantRedisMessageInterceptor

public class TenantRedisMessageInterceptor implements RedisMessageInterceptor {    @Override    public void sendMessageBefore(AbstractRedisMessage message) {        Long tenantId = TenantContextHolder.getTenantId();        if (tenantId != null) {            message.addHeader(HEADER_TENANT_ID, tenantId.toString());        }    }    @Override    public void consumeMessageBefore(AbstractRedisMessage message) {        String tenantIdStr = message.getHeader(HEADER_TENANT_ID);        if (StrUtil.isNotEmpty(tenantIdStr)) {            TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr));        }    }    @Override    public void consumeMessageAfter(AbstractRedisMessage message) {        // 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况        TenantContextHolder.clear();    }}

5.2 RocketMQ

RocketMQ支持多租户采用的是Spring的BeanPostProcessor对注入的Bean进行修改,将RocketMQ发送和消费消息相关的两个类:DefaultRocketMQListenerContainer和RocketMQTemplate设置了可以执行后置和前置操作的“Hook”,当发送和消费消息时,同Redis一样的将租户ID和当前线程进行绑定。

cn.iocoder.yudao.framework.tenant.core.mq.rocketmq.TenantRocketMQInitializer

public class TenantRocketMQInitializer implements BeanPostProcessor {    @Override    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {        if (bean instanceof DefaultRocketMQListenerContainer) {            DefaultRocketMQListenerContainer container = (DefaultRocketMQListenerContainer) bean;            initTenantConsumer(container.getConsumer());        } else if (bean instanceof RocketMQTemplate) {            RocketMQTemplate template = (RocketMQTemplate) bean;            initTenantProducer(template.getProducer());        }        return bean;    }    private void initTenantProducer(DefaultMQProducer producer) {        if (producer == null) {            return;        }        DefaultMQProducerImpl producerImpl = producer.getDefaultMQProducerImpl();        if (producerImpl == null) {            return;        }        producerImpl.registerSendMessageHook(new TenantRocketMQSendMessageHook());    }    private void initTenantConsumer(DefaultMQPushConsumer consumer) {        if (consumer == null) {            return;        }        DefaultMQPushConsumerImpl consumerImpl = consumer.getDefaultMQPushConsumerImpl();        if (consumerImpl == null) {            return;        }        consumerImpl.registerConsumeMessageHook(new TenantRocketMQConsumeMessageHook());    }}

cn.iocoder.yudao.framework.tenant.core.mq.rocketmq.TenantRocketMQConsumeMessageHook

public class TenantRocketMQConsumeMessageHook implements ConsumeMessageHook {    @Override    public String hookName() {        return getClass().getSimpleName();    }    @Override    public void consumeMessageBefore(ConsumeMessageContext context) {        // 校验,消息必须是单条,不然设置租户可能不正确        List<MessageExt> messages = context.getMsgList();        Assert.isTrue(messages.size() == 1, "消息条数({})不正确", messages.size());        // 设置租户编号        String tenantId = messages.get(0).getUserProperty(HEADER_TENANT_ID);        if (StrUtil.isNotEmpty(tenantId)) {            TenantContextHolder.setTenantId(Long.parseLong(tenantId));        }    }    @Override    public void consumeMessageAfter(ConsumeMessageContext context) {        TenantContextHolder.clear();    }}

cn.iocoder.yudao.framework.tenant.core.mq.rocketmq.TenantRocketMQSendMessageHook

public class TenantRocketMQSendMessageHook implements SendMessageHook {    @Override    public String hookName() {        return getClass().getSimpleName();    }    @Override    public void sendMessageBefore(SendMessageContext sendMessageContext) {        Long tenantId = TenantContextHolder.getTenantId();        if (tenantId == null) {            return;        }        sendMessageContext.getMessage().putUserProperty(HEADER_TENANT_ID, tenantId.toString());    }    @Override    public void sendMessageAfter(SendMessageContext sendMessageContext) {    }}

5.3 RabbitMQ

RabbitMQ处理方法和RockerMQ类似,将消息发送的RabbitTemplate进行前置操作,设置租户ID到消息头。

cn.iocoder.yudao.framework.tenant.core.mq.rabbitmq.TenantRabbitMQInitializer

public class TenantRabbitMQInitializer implements BeanPostProcessor {    @Override    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {        if (bean instanceof RabbitTemplate) {            RabbitTemplate rabbitTemplate = (RabbitTemplate) bean;            rabbitTemplate.addBeforePublishPostProcessors(new TenantRabbitMQMessagePostProcessor());        }        return bean;    }}

cn.iocoder.yudao.framework.tenant.core.mq.rabbitmq.TenantRabbitMQMessagePostProcessor

public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor {    @Override    public Message postProcessMessage(Message message) throws AmqpException {        Long tenantId = TenantContextHolder.getTenantId();        if (tenantId != null) {            message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId);        }        return message;    }}

5.4 Kafka

针对Kafka的消息发送适配租户,实现了一个TenantKafkaEnvironmentPostProcessor用于当Spring环境(Environment)准备好了之后,将自己实现的TenantKafkaProducerInterceptor类加入到spring.kafka.producer.properties.interceptor.classes变量中,如果变量没有值就直接赋值,如果变量中已经有了别的值就追加上,TenantKafkaProducerInterceptor将在发送消息到Kafka前,将线程中绑定的租户ID取出设置在消息头上,然后发送到Kafka。

cn.iocoder.yudao.framework.tenant.core.mq.kafka.TenantKafkaEnvironmentPostProcessor

@Slf4jpublic class TenantKafkaEnvironmentPostProcessor implements EnvironmentPostProcessor {    private static final String PROPERTY_KEY_INTERCEPTOR_CLASSES = "spring.kafka.producer.properties.interceptor.classes";    @Override    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {        // 添加 TenantKafkaProducerInterceptor 拦截器        try {            String value = environment.getProperty(PROPERTY_KEY_INTERCEPTOR_CLASSES);            if (StrUtil.isEmpty(value)) {                value = TenantKafkaProducerInterceptor.class.getName();            } else {                value += "," + TenantKafkaProducerInterceptor.class.getName();            }            environment.getSystemProperties().put(PROPERTY_KEY_INTERCEPTOR_CLASSES, value);        } catch (NoClassDefFoundError ignore) {            // 如果触发 NoClassDefFoundError 异常,说明 TenantKafkaProducerInterceptor 类不存在,即没引入 kafka-spring 依赖        }    }}

cn.iocoder.yudao.framework.tenant.core.mq.kafka.TenantKafkaProducerInterceptor

public class TenantKafkaProducerInterceptor implements ProducerInterceptor<Object, Object> {    @Override    public ProducerRecord<Object, Object> onSend(ProducerRecord<Object, Object> record) {        Long tenantId = TenantContextHolder.getTenantId();        if (tenantId != null) {            Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); // private 属性,没有 get 方法,智能反射            headers.add(HEADER_TENANT_ID, tenantId.toString().getBytes());        }        return record;    }    @Override    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {    }    @Override    public void close() {    }    @Override    public void configure(Map<String, ?> configs) {    }}

5.5 InvocableHandlerMethod

Spring整合Kafka和RabbitMQ的过程中,很难通过一些常规方式(设置拦截器等)在消息消费回调方法即将执行前获取消息所属租户ID绑定在MQ消费回调线程上,因此作者把Spring整合Kafka和RabbitMQ的源码中的InvocableHandlerMethod类进行了重写并放置在yudao-spring-boot-starter-biz-tenant/src/main/java下代替原有的类来实现这一功能,InvocableHandlerMethod类的invoke()会对收到的消息进一步执行doInvoke()来调用消费方法,所以对invoke()方法实现进行了修改,如果发来的消息中存在租户ID,则将源码修改为return TenantUtils.execute(tenantId, () -> doInvoke(args));将租户ID绑定在线程上。

作者采用这种办法可能是一种无奈之举,权宜之计,因此如果要自行更改spring版本,须先查看新版本的本类实现和当前版本是否有出入,不能随意更改版本。

org.springframework.messaging.handler.invocation.InvocableHandlerMethod

⚠️ 重写的类和spring-messaging-*.jar包中的原类肯定是重复的,JVM加载类时总是先加载到的类优先。打包时,spring-boot会将主启动模块(yudao-server)代码编译为class文件放入BOOT-INF/classes/,runtime依赖的jar包放入BOOT-INF/lib/,启动时,spring-boot自定义的类加载器LaunchedURLClassLoader会先加载BOOT-INF/classes/下的类,后加载BOOT-INF/lib/下jar包中的类,重写的InvocableHandlerMethod类所在的yudao-spring-boot-starter-biz-tenant也是以jar包形式引入主启动模块,因此会和spring-messaging-*.jar一样编译打包为jar包打入BOOT-INF/lib/,jar包中的类是按jar包文件顺序加载,但无法保证打包时jar文件排列顺序,因此打包后如果重写的类不生效,我认为可以把它迁移到主启动模块中,编译打包到BOOT-INF/classes/目录,以得到优先加载。使用IDEA启动项目时,会按照jar包出现在-cp参数列表中的顺序加载,这个顺序也无法保证,如不生效可同样办法处理。

/* * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * *      https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */package org.springframework.messaging.handler.invocation;import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;import org.springframework.core.DefaultParameterNameDiscoverer;import org.springframework.core.MethodParameter;import org.springframework.core.ParameterNameDiscoverer;import org.springframework.core.ResolvableType;import org.springframework.lang.Nullable;import org.springframework.messaging.Message;import org.springframework.messaging.handler.HandlerMethod;import org.springframework.util.ObjectUtils;import java.lang.reflect.InvocationTargetException;import java.lang.reflect.Method;import java.lang.reflect.Type;import java.util.Arrays;import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;/** * Extension of {@link HandlerMethod} that invokes the underlying method with * argument values resolved from the current HTTP request through a list of * {@link HandlerMethodArgumentResolver}. * * 针对 rabbitmq-spring 和 kafka-spring,不存在合适的拓展点,可以实现 Consumer 消费前,读取 Header 中的 tenant-id 设置到 {@link TenantContextHolder} 中 * TODO 芋艿:持续跟进,看看有没新的拓展点 * * @author Rossen Stoyanchev * @author Juergen Hoeller * @since 4.0 */public class InvocableHandlerMethod extends HandlerMethod {    .........    @Nullable    public Object invoke(Message<?> message, Object... providedArgs) throws Exception {        Object[] args = getMethodArgumentValues(message, providedArgs);        if (logger.isTraceEnabled()) {            logger.trace("Arguments: " + Arrays.toString(args));        }        // 注意:如下是本类的改动点!!!        // 情况一:无租户编号的情况        Long tenantId= parseTenantId(message);        if (tenantId == null) {            return doInvoke(args);        }        // 情况二:有租户的情况下        return TenantUtils.execute(tenantId, () -> doInvoke(args));    }    private Long parseTenantId(Message<?> message) {        Object tenantId = message.getHeaders().get(HEADER_TENANT_ID);        if (tenantId == null) {            return null;        }        if (tenantId instanceof Long) {            return (Long) tenantId;        }        if (tenantId instanceof Number) {            return ((Number) tenantId).longValue();        }        if (tenantId instanceof String) {            return Long.parseLong((String) tenantId);        }        if (tenantId instanceof byte[]) {            return Long.parseLong(new String((byte[]) tenantId));        }        throw new IllegalArgumentException("未知的数据类型:" + tenantId);    }    .........}

5.6 集成

对于Redis/RocketMQ/RabbitMQ的实现,直接注入容器即可,对于Kafka的EnvironmentPostProcessor实现,采用配置在spring.factories的方式

cn.iocoder.yudao.framework.tenant.config.YudaoTenantAutoConfiguration

@AutoConfiguration@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户@EnableConfigurationProperties(TenantProperties.class)public class YudaoTenantAutoConfiguration {    .........    @Bean    public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {        return new TenantRedisMessageInterceptor();    }    @Bean    @ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate")    public TenantRabbitMQInitializer tenantRabbitMQInitializer() {        return new TenantRabbitMQInitializer();    }    @Bean    @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")    public TenantRocketMQInitializer tenantRocketMQInitializer() {        return new TenantRocketMQInitializer();    }    .........}

yudao-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring.factories

org.springframework.boot.env.EnvironmentPostProcessor=\  cn.iocoder.yudao.framework.tenant.core.mq.kafka.TenantKafkaEnvironmentPostProcessor

六、定时任务Quartz的租户隔离

芋道中集成了定时任务功能,需要注意的是它的定时任务功能是给超级管理员用的,定义和下发任务功能不对使用系统的租户开放。如果任务的执行需要租户隔离,那么执行定时任务的过程中要以租户为单位进行数据隔离。

定时任务的租户隔离实现原理是自定义一个方法级别的注解@TenantJob,并为加了注解的job方法实现一个环绕通知的TenantJobAspect,当一个任务即将执行时首先查出系统中所有租户的ID到tenantIds,让tenantIds列表通过parallelStream().forEach()实现每次遍历都是不同的线程。每次遍历又去调用TenantUtils.execute()(具体见8.2 TenantUtils)并传入当次遍历到的租户ID和执行joinPoint.proceed();的匿名类,把每次遍历到的租户ID绑定到这次遍历使用的线程上,并用该线程执行加了@TenantJob注解的目标方法,这样当一个任务下发执行时,系统中的每个租户都派发一个绑定了自己租户ID的线程执行一次目标方法,实现一个任务被每个租户在自己的数据范围内各自执行一次,数据相互隔离。

cn.iocoder.yudao.framework.tenant.core.job.TenantJob

@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)public @interface TenantJob {}

cn.iocoder.yudao.framework.tenant.core.job.TenantJobAspect

@Aspect@RequiredArgsConstructor@Slf4jpublic class TenantJobAspect {    private final TenantFrameworkService tenantFrameworkService;    @Around("@annotation(tenantJob)")    public String around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) {        // 获得租户列表        List<Long> tenantIds = tenantFrameworkService.getTenantIds();        if (CollUtil.isEmpty(tenantIds)) {            return null;        }        // 逐个租户,执行 Job        Map<Long, String> results = new ConcurrentHashMap<>();        tenantIds.parallelStream().forEach(tenantId -> {            // TODO 芋艿:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况            TenantUtils.execute(tenantId, () -> {                try {                    Object result = joinPoint.proceed();                    results.put(tenantId, StrUtil.toStringOrEmpty(result));                } catch (Throwable e) {                    log.error("[execute][租户({}) 执行 Job 发生异常", tenantId, e);                    results.put(tenantId, ExceptionUtil.getRootCauseMessage(e));                }            });        });        return JsonUtils.toJsonString(results);    }}

cn.iocoder.yudao.framework.tenant.config.YudaoTenantAutoConfiguration

@AutoConfiguration@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户@EnableConfigurationProperties(TenantProperties.class)public class YudaoTenantAutoConfiguration {    ......    @Bean    public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) {        return new TenantJobAspect(tenantFrameworkService);    }    ......}

七、Spring @Async方法的租户隔离

之前提到,芋道的多租户实现原理都是租户ID绑定在当前线程,但是如果一个绑定了租户ID的线程在一些情况下调用了加了@Async注解的方法异步执行一些逻辑时,如果异步执行的逻辑需要租户隔离,就会导致出错,因为都不在一个线程上了,租户ID无法传递和共享,因此如有异步调用的情况,应当对多租户情况做特殊处理。

对异步方法执行的配置定义在yudao-spring-boot-starter-job模块的YudaoAsyncAutoConfiguration类下,通过设置executor.setTaskDecorator(TtlRunnable::get);实现芋道项目中所有的异步方法执行时,被派发执行异步方法的线程会继承派发它的线程ThreadLocal中的元素,因此一个绑定了租户ID的线程调用了异步方法时,异步方法会直接继承租户ID保存在自己的线程上下文,同样可以执行一些需要租户隔离的业务代码,这是依靠阿里巴巴的TransmittableThreadLocal组件实现的,具体实现原理以后再进一步研究。

cn.iocoder.yudao.framework.quartz.config.YudaoAsyncAutoConfiguration

@AutoConfiguration@EnableAsyncpublic class YudaoAsyncAutoConfiguration {    @Bean    public BeanPostProcessor threadPoolTaskExecutorBeanPostProcessor() {        return new BeanPostProcessor() {            @Override            public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {                if (!(bean instanceof ThreadPoolTaskExecutor)) {                    return bean;                }                // 修改提交的任务,接入 TransmittableThreadLocal                ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean;                executor.setTaskDecorator(TtlRunnable::get);                return executor;            }        };    }}

八、忽略和指定租户

针对系统中一些需要忽略租户或指定租户的场景进行特殊处理

⚠️需要注意的是有时忽略租户对Redis和MQ而言是没有意义的,因为它们不像数据库那样通过表组织存储数据,数据库可以通过在过滤条件中不追加租户ID字段的方式忽略租户,但是Redis和MQ不可以。

8.1 @TenantIgnore

采用自定义注解的形式对一个方法标记忽略租户,定义一个注解并通过AOP类TenantIgnoreAspect为加了注解的方法进行环绕增强,执行前先获取当前线程的忽略标记到oldIgnore进行备份,然后将当前线程标记为忽略租户,目标方法执行完成后,再将执行前的忽略标记恢复到线程上下文,这样是一个比较严谨的设计,因为忽略标记是整个线程的生命周期内共享的,如果方法嵌套调用,且都加了这个注解,仅是简单的将当前方法先设置忽略后取消忽略可能导致外层的方法标记的忽略标识被覆盖。

cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore

/** * 忽略租户,标记指定方法不进行租户的自动过滤 * * 注意,只有 DB 的场景会过滤,其它场景暂时不过滤: * 1、Redis 场景:因为是基于 Key 实现多租户的能力,所以忽略没有意义,不像 DB 是一个 column 实现的 * 2、MQ 场景:有点难以抉择,目前可以通过 Consumer 手动在消费的方法上,添加 @TenantIgnore 进行忽略 * * @author 芋道源码 */@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Inheritedpublic @interface TenantIgnore {}

cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnoreAspect

/** * 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。 * 例如说,一个定时任务,读取所有数据,进行处理。 * 又例如说,读取所有数据,进行缓存。 * * 整体逻辑的实现,和 {@link TenantUtils#executeIgnore(Runnable)} 需要保持一致 * * @author 芋道源码 */@Aspect@Slf4jpublic class TenantIgnoreAspect {    @Around("@annotation(tenantIgnore)")    public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable {        Boolean oldIgnore = TenantContextHolder.isIgnore();        try {            TenantContextHolder.setIgnore(true);            // 执行逻辑            return joinPoint.proceed();        } finally {            TenantContextHolder.setIgnore(oldIgnore);        }    }}

cn.iocoder.yudao.framework.tenant.config.YudaoTenantAutoConfiguration

@AutoConfiguration@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户@EnableConfigurationProperties(TenantProperties.class)public class YudaoTenantAutoConfiguration {    .........    @Bean    public TenantIgnoreAspect tenantIgnoreAspect() {        return new TenantIgnoreAspect();    }    .........}

8.2 TenantUtils

TenantUtils是一个工具类,可以指定租户或忽略租户执行某段代码,原理和TenantIgnoreAspect类似

cn.iocoder.yudao.framework.tenant.core.util.TenantUtils

public class TenantUtils {    /**     * 使用指定租户,执行对应的逻辑     *     * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户     * 当然,执行完成后,还是会恢复回去     *     * @param tenantId 租户编号     * @param runnable 逻辑     */    public static void execute(Long tenantId, Runnable runnable) {        Long oldTenantId = TenantContextHolder.getTenantId();        Boolean oldIgnore = TenantContextHolder.isIgnore();        try {            TenantContextHolder.setTenantId(tenantId);            TenantContextHolder.setIgnore(false);            // 执行逻辑            runnable.run();        } finally {            TenantContextHolder.setTenantId(oldTenantId);            TenantContextHolder.setIgnore(oldIgnore);        }    }    /**     * 使用指定租户,执行对应的逻辑     *     * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户     * 当然,执行完成后,还是会恢复回去     *     * @param tenantId 租户编号     * @param callable 逻辑     */    public static <V> V execute(Long tenantId, Callable<V> callable) {        Long oldTenantId = TenantContextHolder.getTenantId();        Boolean oldIgnore = TenantContextHolder.isIgnore();        try {            TenantContextHolder.setTenantId(tenantId);            TenantContextHolder.setIgnore(false);            // 执行逻辑            return callable.call();        } catch (Exception e) {            throw new RuntimeException(e);        } finally {            TenantContextHolder.setTenantId(oldTenantId);            TenantContextHolder.setIgnore(oldIgnore);        }    }    /**     * 忽略租户,执行对应的逻辑     *     * @param runnable 逻辑     */    public static void executeIgnore(Runnable runnable) {        Boolean oldIgnore = TenantContextHolder.isIgnore();        try {            TenantContextHolder.setIgnore(true);            // 执行逻辑            runnable.run();        } finally {            TenantContextHolder.setIgnore(oldIgnore);        }    }    .........}

结束语

设计一个支持多租户的系统,需要考虑的点很多,首先要考虑访问时对用户连同租户一并进行鉴权,防止越权。其次对于操作数据库、Redis、消息队列等涉及存取信息的操作必然要考虑数据隔离,对于定时任务也要确保在自己租户范围内执行,还要考虑到如执行异步方法要透传租户ID保证租户功能在子线程不失效,在一些场景下还需要忽略和指定租户,对于不同场景下的租户隔离,芋道考虑的还是比较全面,通过阅读租户模块的实现源码,了解到了设计一个多租户系统的大致思路和实现原理。

芋道源码解读之数据权限

2025年1月22日 00:00

博主和芋道源码作者及其官方开发团队无任何关联

一、引言

芋道的数据权限模块代码,涉及的类和方法很多,环环相扣,需要运行项目一步一步debug分析才能看懂。该模块的代码按照功能细分,大致可以分为两部分:

1.数据权限SQL拦截器:根据定义好的数据权限规则来为涉及到的表在更新、查询和删除时重写(追加)SQL条件,使得用户只能访问到权限范围内的数据。

2.数据权限注解处理器:基于Spring AOP实现,通过自定义一个数据权限注解并实现一个注解处理器来为某些方法单独指定数据权限规则。

两个部分需要配合使用。

二、数据权限SQL拦截器

2.4.0-jdk8-SNAPSHOT版本的数据权限功能是基于mybatis-plus的插件机制实现的,具体是对执行修改、删除和查询的SQL进行拦截、解析,然后再根据数据权限规则对需要限制的表重写(追加)查询条件。使用该插件需要实现MultiDataPermissionHandler接口。

2.1 主要涉及类和接口

2.1.1 Class Diagram

2.1.2 mybatis-plus

  • com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor 类,数据权限的入口,执行解析和重写逻辑,并加入到mp插件队列中。具体见:DataPermissionInterceptor源码解读

  • com.baomidou.mybatisplus.extension.plugins.inner.BaseMultiTableInnerInterceptor 抽象类,被DataPermissionInterceptor类继承,继承自JsqlParserSupport。提供SQL深度解析能力,遍历SQL语句中各个需要拼接条件的位置,在调用子类来根据不同的表和字段进行SQL重写。详见:BaseMultiTableInnerInterceptor源码解读

  • com.baomidou.mybatisplus.extension.parser.JsqlParserSupport 抽象类,是mp对jsqlparser的封装,更好的实现SQL的解析。

  • com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor 接口,被DataPermissionInterceptor类实现,由mp调用,在适当时机触发实现类去执行相关方法,进而使实现类执行SQL解析和重写的功能。

  • com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler 接口,用于获取数据权限,由实现类来根据不同的表和字段进行SQL重写。

  • com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler 接口,作用同MultiDataPermissionHandler

2.1.3 yudao

  • cn.iocoder.yudao.framework.datapermission.core.db.DataPermissionRuleHandler 类,间接实现DataPermissionHandler接口,根据mp传来的表名和对应查询条件,寻找匹配的数据权限规则来进行数据权限SQL条件的重写,并将符合的多个数据权限策略各自生成的条件进行拼接,返回给mp权限插件。

  • cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRuleFactory 接口,数据权限工厂,实现类来根据实际场景对所有适用的数据权限类根据实际情况进行一些筛选或修改,实现在一些特殊场景下改变数据权限的范围,效力以及优先级。

  • cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRuleFactoryImpl 类,DataPermissionRuleFactory的实现。

  • cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule 接口,由实现类继承后来实现某种数据权限规则。

  • cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRule 类,yudao项目默认的数据权限,通过实现DataPermissionRule接口实现了部门级别的数据权限规则。

2.2 执行流程源码解读

2.2.1 Sequence Diagram

2.2.2 DataPermissionInterceptor

该类是mybatis-plus数据权限插件的执行入口,是SQL解析和重写功能的起点。

该类在SQL执行前,会对执行的动作进行拦截,并拿到要执行的SQL,递归对SQL语句各处进行扫描,扫描到表和条件时,调用DataPermissionHandler获取当前表的数据权限过滤条件(Expression)对象,再和业务逻辑的查询条件拼在一起,从而实现数据库层面的数据权限控制。

public class DataPermissionInterceptor extends BaseMultiTableInnerInterceptor implements InnerInterceptor {    private DataPermissionHandler dataPermissionHandler;    @SuppressWarnings("RedundantThrows")    @Override    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {        if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {            return;        }        PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);        mpBs.sql(parserSingle(mpBs.sql(), ms.getId()));    }    @Override    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {        PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);        MappedStatement ms = mpSh.mappedStatement();        SqlCommandType sct = ms.getSqlCommandType();        if (sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {            if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {                return;            }            PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();            mpBs.sql(parserMulti(mpBs.sql(), ms.getId()));        }    }    ......    @Override    public Expression buildTableExpression(final Table table, final Expression where, final String whereSegment) {        if (dataPermissionHandler == null) {            return null;        }        // 只有新版数据权限处理器才会执行到这里        final MultiDataPermissionHandler handler = (MultiDataPermissionHandler) dataPermissionHandler;        return handler.getSqlSegment(table, where, whereSegment);    }    }

解读:

  1. beforeQuery()beforePrepare()是从接口InnerInterceptor继承来的方法,由mybatis-plus在SQL查询前或者预编译(增删改)前回调并传入要执行的SQL,从而叫该类对即将执行的SQL进行某些操作。两个方法都会调用mpBs.sql(parserXxxx(......))方法对SQL进行解析重写,beforeQuery()调用的是parserSingle(......),只能处理单条SQL。beforePrepare()调用的是parserMulti(......),可以处理多条SQL,因为jdbc能一次执行用分号间隔的多条增删改SQL语句,就需要parserMulti将每次执行的语句分开,如果确实是一次执行多条的情况,就需要逐个进行解析和重写,再将新的拼接在一起,而查询一次只能执行一条,故采用parserSingle即可。

  2. parserSingle()parserMulti()都是间接继承自抽象类JsqlParserSupport的方法,用于启动多条和单条SQL的递归解析,过程详见:BaseMultiTableInnerInterceptor源码解读,解析获取SQL语句每个部分上的表和对应的条件信息,再调用buildTableExpression()方法,并在方法内再调用handler.getSqlSegment(table, where, whereSegment);,将解析到的表table及条件where和当前执行目标whereSegment传入,向DataPermissionRuleHandler获取当前表的数据权限规则。

  3. 为了更好的对要处理的SQL进行改写,beforeQuery()将mybatis的BoundSql boundSql对象转换为mybatis-plus的MPBoundSql mpBs对象。beforePrepare()将mybatis的StatementHandler sh转换为mybatis-plus的MPStatementHandler mpSh对象后再获取mybatis-plus的MPBoundSql mpBs对象。

  4. 新增不涉及数据权限,因此beforePrepare()方法中不会针对insert的情况进行处理。

2.2.3 DataPermissionRuleHandler

该类是接口DataPermissionHandler的实现,供拦截器DataPermissionInterceptor调用,用于找到某个表在当前业务下适用的所有的数据权限规则,并汇总,然后再返回一个总的数据权限规则对象给拦截器

@RequiredArgsConstructorpublic class DataPermissionRuleHandler implements MultiDataPermissionHandler {    private final DataPermissionRuleFactory ruleFactory;    @Override    public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {        // 获得 Mapper 对应的数据权限的规则        List<DataPermissionRule> rules = ruleFactory.getDataPermissionRule(mappedStatementId);        if (CollUtil.isEmpty(rules)) {            return null;        }        // 生成条件        Expression allExpression = null;        for (DataPermissionRule rule : rules) {            // 判断表名是否匹配            String tableName = MyBatisUtils.getTableName(table);            if (!rule.getTableNames().contains(tableName)) {                continue;            }            // 单条规则的条件            Expression oneExpress = rule.getExpression(tableName, table.getAlias());            if (oneExpress == null) {                continue;            }            // 拼接到 allExpression 中            allExpression = allExpression == null ? oneExpress                    : new AndExpression(allExpression, oneExpress);        }        return allExpression;    }}

解读:

  1. 拦截器DataPermissionInterceptor解析到具体的表时会调用该类的getSqlSegment(Table table, Expression where, String mappedStatementId)方法,传入表table,已有条件where和当前执行的目标mappedStatementId,数据权限这里只会用到参数table,没有用到where和mappedStatementId。

  2. List<DataPermissionRule> rules = ruleFactory.getDataPermissionRule(mappedStatementId);用于从ruleFactory数据权限规则工厂对象获取所有当前业务下生效了的数据权限,如果没有数据权限规则直接返回null,如果有定义好的数据权限规则对象则进行下一步的匹配,参数mappedStatementId在这个版本的源码中并没有实际用到。

  3. for (DataPermissionRule rule : rules)循环遍历当前生效的所有数据权限规则对象List<DataPermissionRule> rules,通过if (!rule.getTableNames().contains(tableName))判断当前表在哪些规则下不需要数据权限进行跳过,没有跳过的都需要进行数据权限条件拼接,如果都跳过了就等于返回null

  4. 最终返回的规则对象是一个总的规则allExpression,如果某个表匹配了多个DataPermissionRule规则,则用AndExpression(allExpression, oneExpress)拼接每个表的规则oneExpress到总的规则allExpression上面,最终allExpression作为当前表的数据权限规则返回。

    如果一个表适用多个数据权限规则,则最终的SQL条件之间是and的关系

2.2.4 DataPermissionRuleFactory

数据权限规则”工厂”,供DataPermissionRuleHandler调用来获取当前业务下适用的数据权限规则,该类会配合数据权限注解处理器来使用,从线程上下文DataPermissionContextHolder中获取加了@DataPermission数据权限注解且是最近一级调用当前mapper执行SQL的那个业务方法上面的@DataPermission注解,根据注解上的数据权限规则进行匹配,返回当前业务方法下具体适用的数据权限规则,而不是简单的把所有定义好了的数据权限规则都返回。

public interface DataPermissionRuleFactory {    /**     * 获得所有数据权限规则数组     *     * @return 数据权限规则数组     */    List<DataPermissionRule> getDataPermissionRules();    /**     * 获得指定 Mapper 的数据权限规则数组     *     * @param mappedStatementId 指定 Mapper 的编号     * @return 数据权限规则数组     */    List<DataPermissionRule> getDataPermissionRule(String mappedStatementId);}
@RequiredArgsConstructorpublic class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory {    /**     * 数据权限规则数组     */    private final List<DataPermissionRule> rules;    @Override    public List<DataPermissionRule> getDataPermissionRules() {        return rules;    }    @Override // mappedStatementId 参数,暂时没有用。以后,可以基于 mappedStatementId + DataPermission 进行缓存    public List<DataPermissionRule> getDataPermissionRule(String mappedStatementId) {        // 1. 无数据权限        if (CollUtil.isEmpty(rules)) {            return Collections.emptyList();        }        // 2. 未配置,则默认开启        DataPermission dataPermission = DataPermissionContextHolder.get();        if (dataPermission == null) {            return rules;        }        // 3. 已配置,但禁用        if (!dataPermission.enable()) {            return Collections.emptyList();        }        // 4. 已配置,只选择部分规则        if (ArrayUtil.isNotEmpty(dataPermission.includeRules())) {            return rules.stream().filter(rule -> ArrayUtil.contains(dataPermission.includeRules(), rule.getClass()))                    .collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询        }        // 5. 已配置,只排除部分规则        if (ArrayUtil.isNotEmpty(dataPermission.excludeRules())) {            return rules.stream().filter(rule -> !ArrayUtil.contains(dataPermission.excludeRules(), rule.getClass()))                    .collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询        }        // 6. 已配置,全部规则        return rules;    }}

解读:

  1. DataPermissionContextHolder.get()从线程上下文获取数据权限注解处理器为当前执行的SQL具体指定的数据权限规则。

2.2.5 DataPermissionRule

DataPermissionRule,数据权限规则接口,用于定义某种数据权限规则,需要通过getTableNames()来声明适用的表,再通过Expression getExpression(String tableName, Alias tableAlias)来定义某个表的数据权限条件

public interface DataPermissionRule {    /**     * 返回需要生效的表名数组     * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据     *     * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得     *     * @return 表名数组     */    Set<String> getTableNames();    /**     * 根据表名和别名,生成对应的 WHERE / OR 过滤条件     *     * @param tableName 表名     * @param tableAlias 别名,可能为空     * @return 过滤条件 Expression 表达式     */    Expression getExpression(String tableName, Alias tableAlias);}

DeptDataPermissionRule,yudao自带的一个默认的数据权限规则实现类,可以针对系统中所有的表实现本人、本部门、本部门及以下、指定部门、无任何权限和无任何限制的6种数据权限。需要使用该规则的模块只需要将需要限制数据权限的表和其中对应的字段注册到这个类中,即可实现根据每个用户的数据权限范围对不同的表进行个人和部门级别的数据权限控制,实现这6种权限。

@AllArgsConstructor@Slf4jpublic class DeptDataPermissionRule implements DataPermissionRule {    /**     * LoginUser 的 Context 缓存 Key     */    protected static final String CONTEXT_KEY = DeptDataPermissionRule.class.getSimpleName();    private static final String DEPT_COLUMN_NAME = "dept_id";    private static final String USER_COLUMN_NAME = "user_id";    static final Expression EXPRESSION_NULL = new NullValue();    private final PermissionApi permissionApi;    /**     * 基于部门的表字段配置     * 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。     *     * key:表名     * value:字段名     */    private final Map<String, String> deptColumns = new HashMap<>();    /**     * 基于用户的表字段配置     * 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。     *     * key:表名     * value:字段名     */    private final Map<String, String> userColumns = new HashMap<>();    /**     * 所有表名,是 {@link #deptColumns} 和 {@link #userColumns} 的合集     */    private final Set<String> TABLE_NAMES = new HashSet<>();    @Override    public Set<String> getTableNames() {        return TABLE_NAMES;    }    @Override    public Expression getExpression(String tableName, Alias tableAlias) {        // 只有有登陆用户的情况下,才进行数据权限的处理        LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();        if (loginUser == null) {            return null;        }        // 只有管理员类型的用户,才进行数据权限的处理        if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) {            return null;        }        // 获得数据权限        DeptDataPermissionRespDTO deptDataPermission = loginUser.getContext(CONTEXT_KEY, DeptDataPermissionRespDTO.class);        // 从上下文中拿不到,则调用逻辑进行获取        if (deptDataPermission == null) {            deptDataPermission = permissionApi.getDeptDataPermission(loginUser.getId());            if (deptDataPermission == null) {                log.error("[getExpression][LoginUser({}) 获取数据权限为 null]", JsonUtils.toJsonString(loginUser));                throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 未返回数据权限",                        loginUser.getId(), tableName, tableAlias.getName()));            }            // 添加到上下文中,避免重复计算            loginUser.setContext(CONTEXT_KEY, deptDataPermission);        }        // 情况一,如果是 ALL 可查看全部,则无需拼接条件        if (deptDataPermission.getAll()) {            return null;        }        // 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限        if (CollUtil.isEmpty(deptDataPermission.getDeptIds())            && Boolean.FALSE.equals(deptDataPermission.getSelf())) {            return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空        }        // 情况三,拼接 Dept 和 User 的条件,最后组合        Expression deptExpression = buildDeptExpression(tableName,tableAlias, deptDataPermission.getDeptIds());        Expression userExpression = buildUserExpression(tableName, tableAlias, deptDataPermission.getSelf(), loginUser.getId());        if (deptExpression == null && userExpression == null) {            // TODO 芋艿:获得不到条件的时候,暂时不抛出异常,而是不返回数据            log.warn("[getExpression][LoginUser({}) Table({}/{}) DeptDataPermission({}) 构建的条件为空]",                    JsonUtils.toJsonString(loginUser), tableName, tableAlias, JsonUtils.toJsonString(deptDataPermission));//            throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 构建的条件为空",//                    loginUser.getId(), tableName, tableAlias.getName()));            return EXPRESSION_NULL;        }        if (deptExpression == null) {            return userExpression;        }        if (userExpression == null) {            return deptExpression;        }        // 目前,如果有指定部门 + 可查看自己,采用 OR 条件。即,WHERE (dept_id IN ? OR user_id = ?)        return new ParenthesedExpressionList(new OrExpression(deptExpression, userExpression));    }    private Expression buildDeptExpression(String tableName, Alias tableAlias, Set<Long> deptIds) {        // 如果不存在配置,则无需作为条件        String columnName = deptColumns.get(tableName);        if (StrUtil.isEmpty(columnName)) {            return null;        }        // 如果为空,则无条件        if (CollUtil.isEmpty(deptIds)) {            return null;        }        // 拼接条件        return new InExpression(MyBatisUtils.buildColumn(tableName, tableAlias, columnName),                // Parenthesis 的目的,是提供 (1,2,3) 的 () 左右括号                new ParenthesedExpressionList(new ExpressionList<LongValue>(CollectionUtils.convertList(deptIds, LongValue::new))));    }    private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) {        // 如果不查看自己,则无需作为条件        if (Boolean.FALSE.equals(self)) {            return null;        }        String columnName = userColumns.get(tableName);        if (StrUtil.isEmpty(columnName)) {            return null;        }        // 拼接条件        return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId));    }    // ==================== 添加配置 ====================    public void addDeptColumn(Class<? extends BaseDO> entityClass) {        addDeptColumn(entityClass, DEPT_COLUMN_NAME);    }    public void addDeptColumn(Class<? extends BaseDO> entityClass, String columnName) {        String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName();       addDeptColumn(tableName, columnName);    }    public void addDeptColumn(String tableName, String columnName) {        deptColumns.put(tableName, columnName);        TABLE_NAMES.add(tableName);    }    public void addUserColumn(Class<? extends BaseDO> entityClass) {        addUserColumn(entityClass, USER_COLUMN_NAME);    }    public void addUserColumn(Class<? extends BaseDO> entityClass, String columnName) {        String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName();        addUserColumn(tableName, columnName);    }    public void addUserColumn(String tableName, String columnName) {        userColumns.put(tableName, columnName);        TABLE_NAMES.add(tableName);    }}

解读:

  1. Map<String, String> deptColumns将需要部门数据权限约束的表的表名和部门ID的字段名通过键值对关联起来,Map<String, String> userColumns则是将需要本人数据权限约束的表的表名和用户ID的字段名通过键值对关联起来,用于之后对不同的表和字段拼接条件。Set<String> TABLE_NAMES则是将适用本类规则的所有表的表名都保存进去,供DataPermissionRuleHandler判断当前解析到的某个表是否匹配本类的数据权限规则。业务模块需要将模块中用到该规则的表名和对应字段名注册到这些集合中。

  2. addDeptColumn(String tableName, String columnName)addUserColumn(String tableName, String columnName)方法以及它们的重载方法,会在该类创建时被各业务模块的配置类DataPermissionConfiguration调用,将每个模块需要用到该规则类的表和对应字段名注册到该类中。

  3. getExpression(String tableName, Alias tableAlias)方法中拼接表的数据权限SQL条件,首先要通过permissionApi.getDeptDataPermission(loginUser.getId())获取当前登录用户的数据权限范围,如果是无限制,直接返回null表示没有规则限制,无论哪个表都查出所有数据。如果既不能查看自己的数据又不能访问任何部门的数据,说明无论哪个表都没有数据权限,直接返回WHERE null = null,执行结果就是空集。剩下的情况就需要根据具体表和字段来返回具体的数据权限条件了,会先后调用buildDeptExpression(String tableName, Alias tableAlias, Set<Long> deptIds)buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId)方法来对当前表拼接部门或本人,或同时拼接部门和本人的数据权限条件,返回对应的Expression对象,找不到表或无权限时返回null,两个方法至少返回一个非空的对象,部门为null则返回本人,本人为null则返回部门,两者都不为null则用OR拼接返回new ParenthesedExpressionList(new OrExpression(deptExpression, userExpression)),既然当前表适用该类定义的规则且当前用户是有权限的,部门和本人的条件便不能同时为null,如出现同时为null的情况则返回new NullValue(),SQL执行会返回空集。

2.2.6 DeptDataPermissionRuleCustomizer

个人及部门级别数据权限的表和字段回调接口,由业务模块实现,将DeptDataPermissionRule对象传入回调方法customize,供各业务模块将需要数据权限控制的表和字段信息注册到DeptDataPermissionRule中。

@FunctionalInterfacepublic interface DeptDataPermissionRuleCustomizer {    /**     * 自定义该权限规则     * 1. 调用 {@link DeptDataPermissionRule#addDeptColumn(Class, String)} 方法,配置基于 dept_id 的过滤规则     * 2. 调用 {@link DeptDataPermissionRule#addUserColumn(Class, String)} 方法,配置基于 user_id 的过滤规则     *     * @param rule 权限规则     */    void customize(DeptDataPermissionRule rule);}

2.2.7 YudaoDeptDataPermissionAutoConfiguration

个人及部门级别数据权限规则的配置类,用于注册DeptDataPermissionRule到Spring容器中,每个模块实现的DeptDataPermissionRuleCustomizer接口实例也会被注入到List<DeptDataPermissionRuleCustomizer> customizers中,遍历调用customize(rule)方法后,所有业务模块配置的本人和部门数据权限相关的表和字段信息就全部注册到了DeptDataPermissionRule对象中。

@AutoConfiguration@ConditionalOnClass(LoginUser.class)@ConditionalOnBean(value = {PermissionApi.class, DeptDataPermissionRuleCustomizer.class})public class YudaoDeptDataPermissionAutoConfiguration {    @Bean    public DeptDataPermissionRule deptDataPermissionRule(PermissionApi permissionApi,                                                         List<DeptDataPermissionRuleCustomizer> customizers) {        // 创建 DeptDataPermissionRule 对象        DeptDataPermissionRule rule = new DeptDataPermissionRule(permissionApi);        // 补全表配置        customizers.forEach(customizer -> customizer.customize(rule));        return rule;    }}

2.2.8 DataPermissionConfiguration

system业务模块的数据权限配置类,位于cn.iocoder.yudao.module.system.framework.datapermission.config.DataPermissionConfiguration下,用于实例化DeptDataPermissionRuleCustomizer的对象将system模块下需要DeptDataPermissionRule规则限制的表的表名和字段名进行注册。

每一个需要使用DeptDataPermissionRule规则的业务模块(biz)都可以通过创建配置类返回DeptDataPermissionRuleCustomizer的方式实现部门和个人级别的数据权限控制。

@Configuration(proxyBeanMethods = false)public class DataPermissionConfiguration {    @Bean    public DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() {        return rule -> {            // dept            rule.addDeptColumn(AdminUserDO.class);            rule.addDeptColumn(DeptDO.class, "id");            // user            rule.addUserColumn(AdminUserDO.class, "id");        };    }}

2.2.9 YudaoDataPermissionAutoConfiguration

数据权限拦截器插件的配置类,将各种规则对象DataPermissionRule注入到DataPermissionRuleFactory规则工厂中,将数据权限拦截器DataPermissionInterceptor注册到mybatis-plus插件队列中。

@AutoConfigurationpublic class YudaoDataPermissionAutoConfiguration {    @Bean    public DataPermissionRuleFactory dataPermissionRuleFactory(List<DataPermissionRule> rules) {        return new DataPermissionRuleFactoryImpl(rules);    }    @Bean    public DataPermissionRuleHandler dataPermissionRuleHandler(MybatisPlusInterceptor interceptor,                                                               DataPermissionRuleFactory ruleFactory) {        // 创建 DataPermissionInterceptor 拦截器        DataPermissionRuleHandler handler = new DataPermissionRuleHandler(ruleFactory);        DataPermissionInterceptor inner = new DataPermissionInterceptor(handler);        // 添加到 interceptor 中        // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定        MyBatisUtils.addInterceptor(interceptor, inner, 0);        return handler;    }    .........}

三、数据权限注解处理器

数据权限SQL拦截器将系统中定义了的全部数据权限规则适用于所有的场景,但是有些业务下的一些方法是不能适用某些数据权限的,例如某人在OA中只有个人数据权限,但是选择审批人时需要能找到他的领导,这时就需要对某些具体的业务方法进行特殊处理。

3.1 主要涉及类和接口

  • cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission 注解,加在类上或方法上,用于为某个具体的业务方法进行具体的数据权限规则控制。

  • cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionAnnotationAdvisor 类,封装一个切点(Pointcut)和通知(Advice)的Advisor接口,用于把DataPermission注解和注解处理器DataPermissionAnnotationInterceptor进行关联。

  • cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionAnnotationInterceptor 类,DataPermission注解的处理器。

  • cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder 类,封装了透传数据权限注解的上下文对象ThreadLocal<LinkedList<DataPermission>> DATA_PERMISSIONS和一些操作它的方法。

3.2 实现原理

在线程上下文中维护一个LinkedList<DataPermission> list,带有DataPermission注解的方法执行前,注解处理器会拦截到并获取注解存入list,执行完成注解处理器还会再将对应注解从list中移除,由于方法执行顺序是栈结构,后进先出,因此维护注解的list也要和方法栈一样后进先出,这样数据权限SQL拦截器在DataPermissionRuleFactoryImpl中获取注解时便能获取到最近一级业务方法上的注解了。

实现比较抽象,伪代码举例说明:

@anno(scope = 1)service1() {    mapper.sql(a);    @anno(scope = 2)    service2() {        mapper.sql(b);        @anno(scope = 3)        service3() {            mapper.sql(c);        }        mapper.sql(d);    }    service4() {        mapper.sql(e);    }}
  1. service1()执行mapper.sql(a);时,list中只有一个@anno(scope = 1)mapper.sql(a);适用@anno(scope = 1),执行到调用service2()时,service2()在方法栈的最外,list也会将@anno(scope = 2)维护在最外面,这样mapper.sql(b);被数据权限拦截器拦截时,从上下文获取到的就是@anno(scope = 2)service2()执行完成从方法栈退出,list也会将最外的@anno(scope = 2)移除,只剩下@anno(scope = 1),然后service1()继续执行调用service4(),因为service4()没有加注解,因此其中的mapper.sql(e);便依然适用@anno(scope = 1)

  2. service2()调用service3()也是一样的道理,由于service3()也加了注解,因此执行到调用service3()时,list最外面就是@anno(scope = 3)mapper.sql(c);适用@anno(scope = 3)service3()执行完毕退出方法栈后@anno(scope = 3)被从list移除,mapper.sql(d);便还适用@anno(scope = 2)

图例说明:

注解处理器根据被拦截的方法的入栈出栈顺序在线程上下文中同步维护了一个栈结构的list来存储从方法上获取的数据权限注解,方法进栈注解也”进栈”,方法出栈注解也”出栈”,注解处理器在业务方法前”抢先一步”获取业务方法上面的数据权限注解维护到线程上下文,mapper执行时数据权限SQL拦截器在规则工厂DataPermissionRuleFactoryImpl中从线程上下文获取到的,永远是”栈”的最顶部的那个注解,注解上设置的权限规则也刚好适用于当前mapper方法要执行的SQL,这一精巧的设计实现了对某个业务方法进行特殊的数据权限控制,而且可以保证加在最近一级方法上的注解优先生效。

3.3 源码解读

3.3.1 @DataPermission

自定义数据权限注解,可以配置数据权限是否开启、适用的数据权限规则有哪些和不适用的数据权限规则有哪些,DataPermissionRuleFactoryImpl从线程上下文获取到该注解后,根据这些属性和匹配逻辑进行进一步的数据权限规则处理。

@Target({ElementType.TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface DataPermission {    /**     * 当前类或方法是否开启数据权限     * 即使不添加 @DataPermission 注解,默认是开启状态     * 可通过设置 enable 为 false 禁用     */    boolean enable() default true;    /**     * 生效的数据权限规则数组,优先级高于 {@link #excludeRules()}     */    Class<? extends DataPermissionRule>[] includeRules() default {};    /**     * 排除的数据权限规则数组,优先级最低     */    Class<? extends DataPermissionRule>[] excludeRules() default {};}

3.3.2 DataPermissionContextHolder

封装了一个LinkedList<DataPermission>到线程上下文ThreadLocal<LinkedList<DataPermission>> DATA_PERMISSIONS中,又封装了一些对LinkedList的进出栈操作。

public class DataPermissionContextHolder {    /**     * 使用 List 的原因,可能存在方法的嵌套调用     */    private static final ThreadLocal<LinkedList<DataPermission>> DATA_PERMISSIONS =            TransmittableThreadLocal.withInitial(LinkedList::new);    /**     * 获得当前的 DataPermission 注解     *     * @return DataPermission 注解     */    public static DataPermission get() {        return DATA_PERMISSIONS.get().peekLast();    }    /**     * 入栈 DataPermission 注解     *     * @param dataPermission DataPermission 注解     */    public static void add(DataPermission dataPermission) {        DATA_PERMISSIONS.get().addLast(dataPermission);    }    /**     * 出栈 DataPermission 注解     *     * @return DataPermission 注解     */    public static DataPermission remove() {        DataPermission dataPermission = DATA_PERMISSIONS.get().removeLast();        // 无元素时,清空 ThreadLocal        if (DATA_PERMISSIONS.get().isEmpty()) {            DATA_PERMISSIONS.remove();        }        return dataPermission;    }    /**     * 获得所有 DataPermission     *     * @return DataPermission 队列     */    public static List<DataPermission> getAll() {        return DATA_PERMISSIONS.get();    }    /**     * 清空上下文     *     * 目前仅仅用于单测     */    public static void clear() {        DATA_PERMISSIONS.remove();    }}

解读:

  1. get()方法,取出最后一个添加进来的元素,用于DataPermissionRuleFactoryImpl从线程上下文获取到当前要执行的SQL适用的数据权限规则,当每个线程首次调用该方法时,ThreadLocal中的LinkedList<DataPermission>对象将被(new)创建。

  2. add(DataPermission dataPermission)方法,添加一个元素在最后面,用于注解处理器DataPermissionAnnotationInterceptor在方法开始前将方法上的注解存到LinkedList。

  3. remove()方法,删除最后一个添加进来的元素,用于注解处理器DataPermissionAnnotationInterceptor在方法执行结束后将当前方法上的注解从LinkedList中移除,当LinkedList里面没有元素时,说明方法栈最外层带有注解的业务方法也已经执行完毕,此时直接DATA_PERMISSIONS.remove()销毁线程上下文中的LinkedList。

3.3.3 DataPermissionAnnotationAdvisor

封装一个切点(Pointcut)和通知(Advice)的Advisor接口,用于指定DataPermission注解的处理器是DataPermissionAnnotationInterceptor

@Getter@EqualsAndHashCode(callSuper = true)public class DataPermissionAnnotationAdvisor extends AbstractPointcutAdvisor {    private final Advice advice;    private final Pointcut pointcut;    public DataPermissionAnnotationAdvisor() {        this.advice = new DataPermissionAnnotationInterceptor();        this.pointcut = this.buildPointcut();    }    protected Pointcut buildPointcut() {        Pointcut classPointcut = new AnnotationMatchingPointcut(DataPermission.class, true);        Pointcut methodPointcut = new AnnotationMatchingPointcut(null, DataPermission.class, true);        return new ComposablePointcut(classPointcut).union(methodPointcut);    }}

解读:

  1. this.advice = new DataPermissionAnnotationInterceptor()指定了切面(通知)是DataPermissionAnnotationInterceptor

  2. this.pointcut = this.buildPointcut()指定了切点是new ComposablePointcut(classPointcut).union(methodPointcut)

  3. Pointcut classPointcut = new AnnotationMatchingPointcut(DataPermission.class, true) 注解加在类上,类(不含父类)中所有方法都将被注解处理器拦截

  4. Pointcut methodPointcut = new AnnotationMatchingPointcut(null, DataPermission.class, true); 只根据方法的注解来匹配且只匹配直接标注该注解的方法

  5. new ComposablePointcut(classPointcut).union(methodPointcut) 无论类上的注解还是只加在方法上的注解,都进行拦截

3.3.4 DataPermissionAnnotationInterceptor

注解处理器,加了注解的(类)方法被执行前后获取注解,保存到线程上下文的LinkedList<DataPermission>中。

@DataPermission // 该注解,用于 {@link DATA_PERMISSION_NULL} 的空对象public class DataPermissionAnnotationInterceptor implements MethodInterceptor {    /**     * DataPermission 空对象,用于方法无 {@link DataPermission} 注解时,使用 DATA_PERMISSION_NULL 进行占位     */    static final DataPermission DATA_PERMISSION_NULL = DataPermissionAnnotationInterceptor.class.getAnnotation(DataPermission.class);    @Getter    private final Map<MethodClassKey, DataPermission> dataPermissionCache = new ConcurrentHashMap<>();    @Override    public Object invoke(MethodInvocation methodInvocation) throws Throwable {        // 入栈        DataPermission dataPermission = this.findAnnotation(methodInvocation);        if (dataPermission != null) {            DataPermissionContextHolder.add(dataPermission);        }        try {            // 执行逻辑            return methodInvocation.proceed();        } finally {            // 出栈            if (dataPermission != null) {                DataPermissionContextHolder.remove();            }        }    }    private DataPermission findAnnotation(MethodInvocation methodInvocation) {        // 1. 从缓存中获取        Method method = methodInvocation.getMethod();        Object targetObject = methodInvocation.getThis();        Class<?> clazz = targetObject != null ? targetObject.getClass() : method.getDeclaringClass();        MethodClassKey methodClassKey = new MethodClassKey(method, clazz);        DataPermission dataPermission = dataPermissionCache.get(methodClassKey);        if (dataPermission != null) {            return dataPermission != DATA_PERMISSION_NULL ? dataPermission : null;        }        // 2.1 从方法中获取        dataPermission = AnnotationUtils.findAnnotation(method, DataPermission.class);        // 2.2 从类上获取        if (dataPermission == null) {            dataPermission = AnnotationUtils.findAnnotation(clazz, DataPermission.class);        }        // 2.3 添加到缓存中        dataPermissionCache.put(methodClassKey, dataPermission != null ? dataPermission : DATA_PERMISSION_NULL);        return dataPermission;    }}

解读:

  1. DataPermissionContextHolder.add(dataPermission) 执行前获取方法上的注解,存入线程上下文中的List(进栈)

  2. methodInvocation.proceed(); 目标方法执行

  3. DataPermissionContextHolder.remove(); 执行完毕后从线程上下文中的List移除(出栈)

3.3.5 YudaoDataPermissionAutoConfiguration

将数据权限注解处理器Advisor加入Spring容器

@AutoConfigurationpublic class YudaoDataPermissionAutoConfiguration {    .........    @Bean    public DataPermissionAnnotationAdvisor dataPermissionAnnotationAdvisor() {        return new DataPermissionAnnotationAdvisor();    }}

四、总结

数据权限是一个比较常用的功能,芋道源码通过mybatis-plus自定义插件在SQL执行前拦截并解析到对应的表,根据数据权限规则对这些表追加过滤条件来实现数据权限控制,对于一些需要单独指定数据权限的业务方法,通过数据权限注解和结合线程上下文对加了注解的方法进行前置和后置的处理,把当前方法适用的数据权限规则传递给数据权限SQL解析器进行额外处理,使得数据权限规则既能全局生效又能局部调整。

芋道源码解读开篇

2025年1月20日 00:00

博主和芋道源码作者及其官方开发团队无任何关联

一、引言

芋道(又名yudao,ruoyi-vue-pro)是一个基于spring-boot框架的单体Java后端开源项目,拥有基于RBAC模型的组织架构管理、CRM、ERP、商城、代码生成、AI等多个功能模块。封装了多租户、数据权限、工作流、OAuth,邮件、短信、定时任务、日志、链路追踪等多种技术和业务组件。其在GitHub上的地址是:https://github.com/YunaiV/ruoyi-vue-pro

因工作中会用到这个开源项目,为了更好的定制和更新功能,所以决定把它的源码核心部分都通读并运行调试一遍,并把过程通过博客记录下来,内容持续更新,边学习,边输出,做知识积累整理。

本文基于2.4.0-jdk8-SNAPSHOT版本的源码,并fork了该版本的代码

后端:https://gitee.com/changelzj/ruoyi-vue-pro/tree/2.4.0/
前端:https://gitee.com/changelzj/yudao-ui-admin-vue3/tree/2.4.0/

二、项目总体结构

项目基于传统的maven构建,大致结构如下,整个项目是多模块结构,分为1个父模块和多个子模块。

ruoyi-vue-pro [yudao]    │    ├── yudao-dependencies    │     └── pom.xml    │    ├── yudao-framework    │     ├── yudao-common    │     │       ├── src    │     │       └── pom.xml    │     ├── yudao-spring-boot-starter-biz-xxxxxxx    │     │       ├── src    │     │       └── pom.xml     │     ├── yudao-spring-boot-starter-xxxxxxx    │     │       ├── src    │     │       └── pom.xml     │     └── pom.xml       │    │── yudao-module-aaa       │     ├── yudao-module-aaa-api    │     │       ├── src    │     │       └── pom.xml           │     ├── yudao-module-aaa-biz    │     │       ├── src    │     │       └── pom.xml      │     └── pom.xml                  │    │── yudao-module-bbb       │     ├── yudao-module-bbb-api    │     │       ├── src    │     │       └── pom.xml           │     ├── yudao-module-bbb-biz    │     │       ├── src    │     │       └── pom.xml      │     └── pom.xml    │            │── yudao-server    │     ├── src    │     └── pom.xml    │    └── pom.xml

三、各模块结构,功能和源码解读

3.1 root

1.最外层的/pom.xml作为root模块的配置,通过<modules/>包含了yudao-framework,yudao-module-xxxxxx,yudao-server,yudao-dependencies等众多模块。

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    ......    <modules>        <module>yudao-dependencies</module>        <module>yudao-framework</module>        <!-- Server 主项目 -->        <module>yudao-server</module>        <!-- 各种 module 拓展 -->        <module>yudao-module-system</module>        <module>yudao-module-infra</module>    </modules>    ......</project>

2.root模块通过引用负责统一依赖版本的模块yudao-dependencies来将依赖的版本号传递给所有子模块,从而统一整个项目的依赖版本

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    ... ...    <dependencyManagement>        <dependencies>            <dependency>                <groupId>cn.iocoder.boot</groupId>                <artifactId>yudao-dependencies</artifactId>                <version>${revision}</version>                <type>pom</type>                <scope>import</scope>            </dependency>        </dependencies>    </dependencyManagement>    ... ...</project>

3.root模块使用<version>${revision}</version>来设置自身的版本号,子模块的<version/>如果也设置为${revision}的话,就继承了root模块的版本号了,子模块的子模块也是一样的道理,这样整个工程所有子孙模块的版本号就都统一起来了,需要升级版本时,只需要在root模块的pom.xml文件中,把<properties/>里面的版本号一改,整个工程所有子孙模块的版本号便全部跟着变了。

例:
/pom.xml

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <groupId>cn.iocoder.boot</groupId>    <artifactId>yudao</artifactId>    <version>${revision}</version>    <packaging>pom</packaging>    ... ...    <properties>        <revision>2.4.0-jdk8-SNAPSHOT</revision>    </properties>    ... ...</project>

yudao-module-system/pom.xml

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <parent>        <groupId>cn.iocoder.boot</groupId>        <artifactId>yudao</artifactId>        <version>${revision}</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <modules>        <module>yudao-module-system-api</module>        <module>yudao-module-system-biz</module>    </modules>    <artifactId>yudao-module-system</artifactId>    <packaging>pom</packaging>    <name>${project.artifactId}</name>    <description>        system 模块下,我们放通用业务,支撑上层的核心业务。        例如说:用户、部门、权限、数据字典等等    </description></project>

yudao-module-system/yudao-module-system-api/pom.xml

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">        <parent>        <groupId>cn.iocoder.boot</groupId>        <artifactId>yudao-module-system</artifactId>        <version>${revision}</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>yudao-module-system-api</artifactId>    <packaging>jar</packaging>    <name>${project.artifactId}</name>    <description>        system 模块 API,暴露给其它模块调用    </description>    ......</project>

4.通过插件org.codehaus.mojo:flatten-maven-plugin来防止项目拆分后版本号无法传递的问题

如果将此项目进行拆分,将业务模块(yudao-module-xxxxx)和框架模块(yudao-framework)等进行切割分离,形成两个或更多个独立的maven工程,就会出现版本号无法通过${revision}传递的问题。

例如A工程的pom.xml通过${revision}将版本同步给A工程的子模块B,新建的另一个独立工程C如果依赖了B会报错找不到B的父工程A,因为C工程会将"${revision}"这个字符串作为A的版本号,而不是取${revision}代表的值,所以如果要把该项目拆分成一个个单独的服务,A工程需要引入org.codehaus.mojo:flatten-maven-plugin插件进行特殊处理,引入这个插件后,执行mvn install命令时,打入本地仓库的坐标版本号就是${revision}代表的值,mvn deploy到远程仓库同理。

导致这个问题出现的原因是拆分后Maven依赖的优先级发生了改变,如果不拆分,在同一个工程下的话,依赖会从工程自身的模块开始查找,找到了就不再访问本地仓库和远程仓库,这时由于是同一个工程下${revision}是能互相访问到的。但是一旦被拆分,被拆分出去的工程无法在工程自身的模块中查找依赖了(因为根本没有),便会用本地或远程仓库寻找依赖,被依赖的模块没有使用flatten-maven-plugin插件的话,其mvn install, mvn deploy到仓库时,版本号就是错的,无法被正确引用。

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    ... ...    <build>        <pluginManagement>            <plugins>                <plugin>                    <groupId>org.apache.maven.plugins</groupId>                    <artifactId>maven-surefire-plugin</artifactId>                    <version>${maven-surefire-plugin.version}</version>                </plugin>                <plugin>                    <groupId>org.apache.maven.plugins</groupId>                    <artifactId>maven-compiler-plugin</artifactId>                    <version>${maven-compiler-plugin.version}</version>                    <configuration>                        <annotationProcessorPaths>                            <path>                                <groupId>org.springframework.boot</groupId>                                <artifactId>spring-boot-configuration-processor</artifactId>                                <version>${spring.boot.version}</version>                            </path>                            <path>                                <groupId>org.projectlombok</groupId>                                <artifactId>lombok</artifactId>                                <version>${lombok.version}</version>                            </path>                            <path>                                <groupId>org.mapstruct</groupId>                                <artifactId>mapstruct-processor</artifactId>                                <version>${mapstruct.version}</version>                            </path>                        </annotationProcessorPaths>                    </configuration>                </plugin>                <plugin>                    <groupId>org.codehaus.mojo</groupId>                    <artifactId>flatten-maven-plugin</artifactId>                </plugin>            </plugins>        </pluginManagement>        <plugins>            <plugin>                <groupId>org.codehaus.mojo</groupId>                <artifactId>flatten-maven-plugin</artifactId>                <version>${flatten-maven-plugin.version}</version>                <configuration>                    <flattenMode>oss</flattenMode>                    <updatePomFile>true</updatePomFile>                </configuration>                <executions>                    <execution>                        <goals>                            <goal>flatten</goal>                        </goals>                        <id>flatten</id>                        <phase>process-resources</phase>                    </execution>                    <execution>                        <goals>                            <goal>clean</goal>                        </goals>                        <id>flatten.clean</id>                        <phase>clean</phase>                    </execution>                </executions>            </plugin>        </plugins>    </build>    ... ...</project>

3.2 yudao-dependencies

这个模块内仅有一个pom.xml文件,该模块的作用仅仅是统一整个项目的依赖版本,因为yudao-dependencies模块没有指定<parent/>,因此不能从父(即root)模块继承${revision},需要在自己的<properties/>里面维护自己的${revision}版本供自己引用,版本号的值一般要与root模块中的版本号要保持一致。

严格来讲,yudao-dependencies模块并不是root模块的子模块,因为如果root模块成了yudao-dependencies的父模块的同时还引用了子模块yudao-dependencies的话,就会导致循环引用,因此yudao-dependencies没有指定<parent/>,只是由root模块通过<modules/>包含进去进行代管,root模块构建时,yudao-dependencies会一并构建,但不能继承root的</properties><version>${revision}</version></dependency></plugin>等。

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <groupId>cn.iocoder.boot</groupId>    <artifactId>yudao-dependencies</artifactId>    <version>${revision}</version>    <packaging>pom</packaging>    ... ...    <properties>        <revision>2.4.0-jdk8-SNAPSHOT</revision>    </properties>    ... ...</project>

yudao-dependencies里面只有一个pom.xml文件,其使用<dependencyManagement/>声明了整个项目所需要的依赖,并被root模块引入,从而统一整个工程的依赖版本。

yudao-dependencies不仅通过引用springframework,spring-boot-dependencies等type为pom的依赖项来继承第三方框架的版本,还规定了项目自身封装的一些框架(yudao-framework)的版本号。

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    ... ...    <dependencyManagement>        <dependencies>            <dependency>                <groupId>org.springframework</groupId>                <artifactId>spring-framework-bom</artifactId> <!-- JDK8 版本独有:保证 Spring Framework 尽量高 -->                <version>${spring.framework.version}</version>                <type>pom</type>                <scope>import</scope>            </dependency>            <dependency>                <groupId>org.springframework.security</groupId>                <artifactId>spring-security-bom</artifactId> <!-- JDK8 版本独有:保证 Spring Security 尽量高 -->                <version>${spring.security.version}</version>                <type>pom</type>                <scope>import</scope>            </dependency>            <dependency>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-dependencies</artifactId>                <version>${spring.boot.version}</version>                <type>pom</type>                <scope>import</scope>            </dependency>            <dependency>                <groupId>cn.iocoder.boot</groupId>                <artifactId>yudao-spring-boot-starter-biz-tenant</artifactId>                <version>${revision}</version>            </dependency>            <dependency>                <groupId>cn.iocoder.boot</groupId>                <artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId>                <version>${revision}</version>            </dependency>            <dependency>                <groupId>cn.iocoder.boot</groupId>                <artifactId>yudao-spring-boot-starter-biz-ip</artifactId>                <version>${revision}</version>            </dependency>            <dependency>                <groupId>cn.iocoder.boot</groupId>                <artifactId>yudao-common</artifactId>                <version>${revision}</version>            </dependency>            ... ...        </dependencies>    </dependencyManagement>    ... ...</project>

同理,需要通过插件org.codehaus.mojo:flatten-maven-plugin来防止项目拆分后版本号无法传递的问题

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">        ... ...    <build>        <plugins>            <!-- 统一 revision 版本 -->            <plugin>                <groupId>org.codehaus.mojo</groupId>                <artifactId>flatten-maven-plugin</artifactId>                <version>${flatten-maven-plugin.version}</version>                <configuration>                    <flattenMode>bom</flattenMode>                    <updatePomFile>true</updatePomFile>                </configuration>                <executions>                    <execution>                        <goals>                            <goal>flatten</goal>                        </goals>                        <id>flatten</id>                        <phase>process-resources</phase>                    </execution>                    <execution>                        <goals>                            <goal>clean</goal>                        </goals>                        <id>flatten.clean</id>                        <phase>clean</phase>                    </execution>                </executions>            </plugin>        </plugins>    </build>    ... ...</project>

3.3 yudao-framework

该模块内主要是需要用到的公共依赖和一些对常用框架和功能组件的封装,大致结构如下

yudao-framework      │      │── yudao-common      │     ├─ src      │     │   └─ main      │     │       └─ java      │     │            └─ cn.iocoder.yudao.framework.common      │     │                  ├─ core      │     │                  ├─ enums      │     │                  ├─ exception      │     │                  ├─ pojo      │     │                  ├─ util      │     │                  └─ validation      │     │      │     └─ pom.xml      │      │── yudao-spring-boot-starter-xxxxxx      │     ├─ src      │     │   └─ main      │     │       ├─ java      │     │       |    ├─ cn.iocoder.yudao.framework.xxxxxx       │     │       |    │     ├─ config      │     │       |    │     ├─ core      │     │       |    │     └─ aaa      │     │       |    │                │     │       |    └─ bbb.ccc.ddd                            │     │       │      │     │       └─ resources      │     │            └─ META-INF.spring      │     │                  └─ org.springframework.boot.autoconfigure.AutoConfiguration.imports      │     │                                     │     └── pom.xml       │      └── pom.xml

yudao-framework下没有其他依赖,只是简单的将所有封装的组件聚合起来

yudao-framework/pom.xml

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <artifactId>yudao</artifactId>        <groupId>cn.iocoder.boot</groupId>        <version>${revision}</version>    </parent>    <packaging>pom</packaging>    <modules>        <module>yudao-common</module>        <module>yudao-spring-boot-starter-mybatis</module>        <module>yudao-spring-boot-starter-redis</module>        <module>yudao-spring-boot-starter-web</module>        <module>yudao-spring-boot-starter-security</module>        <module>yudao-spring-boot-starter-websocket</module>        <module>yudao-spring-boot-starter-monitor</module>        <module>yudao-spring-boot-starter-protection</module>        <module>yudao-spring-boot-starter-job</module>        <module>yudao-spring-boot-starter-mq</module>        <module>yudao-spring-boot-starter-excel</module>        <module>yudao-spring-boot-starter-test</module>        <module>yudao-spring-boot-starter-biz-tenant</module>        <module>yudao-spring-boot-starter-biz-data-permission</module>        <module>yudao-spring-boot-starter-biz-ip</module>    </modules>    <artifactId>yudao-framework</artifactId>    <description>        该包是技术组件,每个子包,代表一个组件。每个组件包括两部分:            1. core 包:是该组件的核心封装            2. config 包:是该组件基于 Spring 的配置        技术组件,也分成两类:            1. 框架组件:和我们熟悉的 MyBatis、Redis 等等的拓展            2. 业务组件:和业务相关的组件的封装,例如说数据字典、操作日志等等。        如果是业务组件,Maven 名字会包含 biz    </description>    <url>https://github.com/YunaiV/ruoyi-vue-pro</url></project>

yudao-common模块封装了一些项目公共的枚举类,异常类,公共的实体类,和一些工具类,在这个项目中通常会被其他组件模块(yudao-spring-boot-starter-xxxx)和业务模块的api模块(yudao-module-xxxxx-api)所引用。

除了yudao-common外其余的都是封装的框架功能模块,模块名格式为yudao-spring-boot-starter-xxxx,分为业务组件和技术组件。技术组件模块名中没有biz,业务组件是有的。业务组件通常会引用业务模块的api模块(yudao-module-xxxxx-api)

例如数据权限yudao-spring-boot-starter-biz-data-permission组件依赖了系统管理业务模块的api:yudao-module-system-api

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <parent>        <artifactId>yudao-framework</artifactId>        <groupId>cn.iocoder.boot</groupId>        <version>${revision}</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId>    <packaging>jar</packaging>    ......    <dependencies>        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-common</artifactId>        </dependency>        .........        <!-- 业务组件 -->        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-module-system-api</artifactId> <!-- 需要使用它,进行数据权限的获取 -->            <version>${revision}</version>        </dependency>        .........</project>

该模块下的包名都以cn.iocoder.yudao.framework开头,后面是组件名称,然后再往下大多又分成config和core两个包,config包下是spring-boot的配置类,与组件本身的配置有关,core包下是组件具体功能的实现代码,需要注意的是config包下的配置类会配合resources/META-INF.spring下的org.springframework.boot.autoconfigure.AutoConfiguration.imports文件使用,配置类的类路径只有配在这个文件中,才会被spring扫描到,然后将组件注入spring容器中,供其他业务模块使用。

framework模块之间也可以引用,例如yudao-spring-boot-starter-biz-data-permission就依赖了yudao-spring-boot-starter-security,yudao-spring-boot-starter-mybatis和yudao-spring-boot-starter-test,但不能互相引用,因为必然导致maven循环引用错误。

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <parent>        <artifactId>yudao-framework</artifactId>        <groupId>cn.iocoder.boot</groupId>        <version>${revision}</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId>    <packaging>jar</packaging>    <name>${project.artifactId}</name>    <description>数据权限</description>    <url>https://github.com/YunaiV/ruoyi-vue-pro</url>    <dependencies>        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-common</artifactId>        </dependency>        <!-- Web 相关 -->        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-spring-boot-starter-security</artifactId>            <optional>true</optional> <!-- 可选,如果使用 DeptDataPermissionRule 必须提供 -->        </dependency>        <!-- DB 相关 -->        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-spring-boot-starter-mybatis</artifactId>        </dependency>        <!-- 业务组件 -->        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-module-system-api</artifactId> <!-- 需要使用它,进行数据权限的获取 -->            <version>${revision}</version>        </dependency>        <!-- Test 测试相关 -->        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-spring-boot-starter-test</artifactId>            <scope>test</scope>        </dependency>    </dependencies></project>

yudao-framework模块下业务组件的实现原理,并解读部分源码:

1.业务组件

序号文章名概述
1芋道源码解读之数据权限biz-data-permission:限制用户可访问的数据范围
2芋道源码解读之多租户 biz-tenant:Web,DB,Redis,MQ等场景的租户隔离

3.4 yudao-module-xxxxx

yudao-module-xxxxx模块是实现具体业务的模块,具体结构如下:
(xxxxx为业务名,aaa,bbb为业务下的具体功能名)

yudao-module-xxxxx    │    │── yudao-module-xxxxx-api    │     ├─ src    │     │   └─ main    │     │       └─ java    │     │            └─ cn.iocoder.yudao.module.xxxxx    │     │                ├─ api    │     │                │   ├─ aaa    │     │                │   │    ├─ dto    │     │                │   │    │   └─ AaaRespDTO.java    │     │                │   │    └─ AaaApi.java                        │     │                │   └─ bbb    │     │                │        ├─ dto    │     │                │        │   └─ BbbRespDTO.java    │     │                │        └─ BbbApi.java    │     │                └─ enums    │     │                    ├─ aaa    │     │                    │    ├─ AaaCccEnum.java       │     │                    │    └─ AaaDdddEnum.java                     │     │                    ├─ bbb    │     │                    │    ├─ BbbCccEnum.java       │     │                    │    └─ BbbDdddEnum.java    │     │                    │              │     │                    ├─ AaaTypeConstants.java        │     │                    └─ BbbTypeConstants.java                    │     │    │     └─ pom.xml    │    │── yudao-module-xxxxx-biz    │     ├─ src    │     │   └─ main    │     │       ├─ java    │     │       |    └─ cn.iocoder.yudao.module.xxxxx     │     │       |        ├─ api    │     │       |        │   ├─ aaa       │     │       |        │   │   └─ AaaApiImpl.java      │     │       |        │   └─ bbb       │     │       |        │       └─ BbbApiImpl.java      │     │       |        │                                     │     │       |        ├─ controller    │     │       |        │   ├─ admin       │     │       |        │   │   ├─ aaa    │     │       |        │   │   │   ├─ vo    │     │       |        │   │   │   │   └─ AaaReqVO.java                        │     │       |        │   │   │   └─ AaaController.java    │     │       |        │   │   └─ bbb    │     │       |        │   │       ├─ vo    │     │       |        │   │       │   └─ BbbReqVO.java                        │     │       |        │   │       └─ BbbController.java        │     │       |        │   │                    │     │       |        │   └─ app       │     │       |        │       └─ aaa    │     │       |        │           ├─ vo    │     │       |        │           │   └─ AaaAppReqVO.java              │     │       |        │           └─ AaaAppController.java    │     │       |        │                  │     │       |        ├─ convert      │     │       |        │   ├─ aaa    │     │       |        │   │   └─ AaaConvert.java              │     │       |        │   └─ bbb    │     │       |        │       └─ BbbConvert.java    │     │       |        │            │     │       |        ├─ framework    │     │       |        ├─ job    │     │       |        ├─ mq    │     │       |        ├─ service    │     │       |        │   ├─ aaa    │     │       |        │   │   ├─ AaaService.java    │     │       |        │   │   └─ AaaServiceImpl.java                        │     │       |        │   └─ bbb    │     │       |        │       ├─ BbbService.java    │     │       |        │       └─ BbbServiceImpl.java    │     │       |        │     │     │       |        │    │     │       |        └─ dal    │     │       |            ├─ dataobject    │     │       |            │   ├─ aaa    │     │       |            │   │   └─ AaaDO.java              │     │       |            │   └─ bbb    │     │       |            │       └─ BbbDO.java                        │     │       |            └─ mysql      │     │       |                ├─ aaa    │     │       |                │   └─ AaaMapper.java              │     │       |                └─ bbb    │     │       |                    └─ BbbMapper.java                                │     │       │      │     │       └─ resource    │     │            └─ mapper    │     │                 ├─ aaa     │     │                 │   └─ AaaMapper.xml              │     │                 └─ bbb    │     │                     └─ BbbMapper.xml                                 │     └── pom.xml     │    └── pom.xml  

整个项目的Controller, Service, Mapper都封装在业务模块里,业务模块是根据具体的业务来建立的。

每个业务模块都由yudao-module-xxxxx-api和yudao-module-xxxxx-biz两个子模块组成。yudao-module-xxxxx-api模块中是开放给其他业务模块或业务组件调用的接口代码和一些公共的枚举和常量,yudao-module-xxxxx-biz模块中是具体业务的实现代码,因为api定义的接口是biz实现的,因此biz模块首先要依赖它自己要实现的api模块。

模块内包名都是固定前缀cn.iocoder.yudao加module再加业务模块名的形式,例如:cn.iocoder.yudao.module.xxxxx,在此基础上根据所属层级建立下一级包名,例如cn.iocoder.yudao.module.xxxxx.controller.admin,cn.iocoder.yudao.module.xxxxx.service,然后根据具体业务功能再建立更深层级的包名和包下的类,例如:cn.iocoder.yudao.module.xxxxx.controller.admin.aaa.vo。


包名解释:

  • yudao-module-xxxxx-api

    • cn.iocoder.yudao.module.xxxxx.api 包存放业务模块需要对外暴漏的接口,以及用于传输数据的DTO对象。

    • cn.iocoder.yudao.module.xxxxx.enums 包存放该业务模块的枚举类和常量类,既供自己使用,也供调用方使用。

  • yudao-module-xxxxx-biz

    • cn.iocoder.yudao.module.xxxxx.api 包存放对api模块定义的接口类的实现(***ApiImpl),实现类为Spring容器管理,被Spring注入到调用者引用的Api接口上,ApiImpl和Controller一样,接收到调用后再调用业务层Service代码。

    • cn.iocoder.yudao.module.xxxxx.controller 分为admin和app两个子包,分别放置管理员接口和会员接口,包中存放Controller类及接收和生成JSON的实体类VO,接收http请求并返回数据。

    • cn.iocoder.yudao.module.xxxxx.service 包下是具体的Service业务接口和实现类。

    • cn.iocoder.yudao.module.xxxxx.dal 包是负责数据库访问的DAO层,分为dataobject和mysql两个包,dataobject包内存放的是DO对象,mysql包内存放的是Mybatis/Mybatis-Plus的Mapper类,Java代码无法实现的复杂SQL,可在resources文件夹内定义”*Mapper.xml”文件实现。

    • cn.iocoder.yudao.module.xxxxx.convert 包功能比较简单,用于存放mapstruct转换器类,用于各种不同类型的实体类对象之间的深拷贝互相转换。

    • cn.iocoder.yudao.module.xxxxx.mq 消息发送接收。

    • cn.iocoder.yudao.module.xxxxx.job 定时任务。

    • cn.iocoder.yudao.module.xxxxx.framework 配合yudao-framework模块封装的框架和功能来实现一些更高级的功能,例如文档生成,数据权限等等。

    • ……


业务模块biz之间是相互独立的,如biz模块间要相互调用,只要互相引用对方的api模块坐标到自己biz的pom.xml即可,这样的模块依赖方式完美遵循依赖倒置原则,如果是biz直接引用biz不但违背依赖倒置原则,而且可能还会导致maven构建时报出循环引用的错误。本项目中后续还会出现业务组件框架模块(yudao-spring-boot-starter-biz-xxxxxxxx)依赖具体业务模块的情况,同样也是需要引用业务模块的api。

例:

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">        <parent>        <groupId>cn.iocoder.boot</groupId>        <artifactId>yudao-module-system</artifactId>        <version>${revision}</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <!-- 业务模块 -->    <artifactId>yudao-module-system-biz</artifactId>    <packaging>jar</packaging>    ... ...    <dependencies>        <dependency>            <groupId>cn.iocoder.boot</groupId>            <!-- 自身业务的api -->            <artifactId>yudao-module-system-api</artifactId>            <version>${revision}</version>        </dependency>        <dependency>            <groupId>cn.iocoder.boot</groupId>            <!-- 要调用的其他业务模块的api -->            <artifactId>yudao-module-infra-api</artifactId>            <version>${revision}</version>    </dependency>    ... ...</project>

3.5 yudao-server

yudao-server是启动项目的模块,里面有spring-boot主启动类cn.iocoder.yudao.server.YudaoServerApplication,缺省的请求处理类cn.iocoder.yudao.server.controller.DefaultController,不同环境的配置文件application-*.yml,还有一个logback的日志配置文件logback-spring.xml。

yudao-server    |    ├─ src    |   └─ main    |        ├─ java    |        |    └─ cn.iocoder.yudao.server    |        |        ├─ controller    |        |        |   └─ DefaultController.java            |        |        └─ YudaoServerApplication.java      |        |    |        └─ resources    |             ├─ application.yaml    |             ├─ application-dev.yaml    |             ├─ application-local.yaml    |             └─ logback-spring.xml    |         └─ pox.xml

yudao-server模块汇聚了所有的业务模块,打包上线的可执行jar包就是这个模块编译而成的,该模块聚合了所有的业务模块的biz模块(yudao-module-***-biz)以及一些需要直接引用的starter,需要启用哪个业务模块就可以按需引入哪个业务模块。

/yudao-server/pom.xml中,引入了项目最核心的两个业务模块:系统管理yudao-module-system-biz和服务保障yudao-module-infra-biz,默认不引入其他业务模块从而加快编译速度,还引入了一些其他的starter,最后通过spring-boot-maven-plugin插件将此模块代码打包为可执行的jar包,从而启动整个项目。

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">       <parent>        <groupId>cn.iocoder.boot</groupId>        <artifactId>yudao</artifactId>        <version>${revision}</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>yudao-server</artifactId>    <packaging>jar</packaging>    <name>${project.artifactId}</name>    <description>        后端 Server 的主项目,通过引入需要 yudao-module-xxx 的依赖,        从而实现提供 RESTful API 给 yudao-ui-admin、yudao-ui-user 等前端项目。        本质上来说,它就是个空壳(容器)!    </description>    <url>https://github.com/YunaiV/ruoyi-vue-pro</url>    <dependencies>        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-module-system-biz</artifactId>            <version>${revision}</version>        </dependency>        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-module-infra-biz</artifactId>            <version>${revision}</version>        </dependency>        <!-- spring boot 配置所需依赖 -->        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-configuration-processor</artifactId>            <optional>true</optional>        </dependency>        <!-- 服务保障相关 -->        <dependency>            <groupId>cn.iocoder.boot</groupId>            <artifactId>yudao-spring-boot-starter-protection</artifactId>        </dependency>    </dependencies>    <build>        <!-- 设置构建的 jar 包名 -->        <finalName>${project.artifactId}</finalName>        <plugins>            <!-- 打包 -->            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>                <version>${spring.boot.version}</version>                <executions>                    <execution>                        <goals>                            <goal>repackage</goal> <!-- 将引入的 jar 打入其中 -->                        </goals>                    </execution>                </executions>            </plugin>        </plugins>    </build></project>

cn.iocoder.yudao.server.YudaoServerApplication是整个项目的主启动类,通过注解@SpringBootApplication(scanBasePackages = {"${yudao.info.base-package}.server", "${yudao.info.base-package}.module"})将cn.iocoder.yudao.module下的包列入Spring扫描范围,用于实例化module模块中的类,并纳入Spring容器管理,这也是业务模块(yudao-module-xxx-xxx)下的子包和类必须放在cn.iocoder.yudao.module包下的原因。

package cn.iocoder.yudao.server;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;/** * 项目的启动类 * @author 芋道源码 */@SuppressWarnings("SpringComponentScan") // 忽略 IDEA 无法识别 ${yudao.info.base-package}@SpringBootApplication(scanBasePackages = {"${yudao.info.base-package}.server", "${yudao.info.base-package}.module"})public class YudaoServerApplication {    public static void main(String[] args) {        SpringApplication.run(YudaoServerApplication.class, args);    }}

controller包下定义了一个缺省的cn.iocoder.yudao.server.controller.DefaultController类,如果被调用的接口所在的模块没有被yudao-server引入,就会被这个类中带着路径通配符的接口方法“兜底”,给出对应的错误提示,这个也是芋道源码中比较精巧的设计之一。

package cn.iocoder.yudao.server.controller;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_IMPLEMENTED;/** * 默认 Controller,解决部分 module 未开启时的 404 提示。 * 例如说,/bpm/** 路径,工作流 * * @author 芋道源码 */@RestControllerpublic class DefaultController {    @RequestMapping("/admin-api/bpm/**")    public CommonResult<Boolean> bpm404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]");    }    @RequestMapping("/admin-api/mp/**")    public CommonResult<Boolean> mp404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]");    }    @RequestMapping(value = {"/admin-api/product/**", // 商品中心            "/admin-api/trade/**", // 交易中心            "/admin-api/promotion/**"})  // 营销中心    public CommonResult<Boolean> mall404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[商城系统 yudao-module-mall - 已禁用][参考 https://doc.iocoder.cn/mall/build/ 开启]");    }    @RequestMapping("/admin-api/erp/**")    public CommonResult<Boolean> erp404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[ERP 模块 yudao-module-erp - 已禁用][参考 https://doc.iocoder.cn/erp/build/ 开启]");    }    @RequestMapping("/admin-api/crm/**")    public CommonResult<Boolean> crm404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[CRM 模块 yudao-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]");    }    @RequestMapping(value = {"/admin-api/report/**"})    public CommonResult<Boolean> report404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[报表模块 yudao-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]");    }    @RequestMapping(value = {"/admin-api/pay/**"})    public CommonResult<Boolean> pay404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[支付模块 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]");    }    @RequestMapping(value = {"/admin-api/ai/**"})    public CommonResult<Boolean> ai404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[AI 大模型 yudao-module-ai - 已禁用][参考 https://doc.iocoder.cn/ai/build/ 开启]");    }    @RequestMapping(value = {"/admin-api/iot/**"})    public CommonResult<Boolean> iot404() {        return CommonResult.error(NOT_IMPLEMENTED.getCode(),                "[IOT 物联网 yudao-module-iot - 已禁用][参考 https://doc.iocoder.cn/iot/build/ 开启]");    }}

3.6 模块间关系图

1.依赖关系,箭头由被引用模块指向引用模块

2.继承关系,箭头由父级指向子级

❌