详细学习MyBatis的异常处理机制

1,260 阅读8分钟

前言

字节码层面学习Java异常底层原理一文中,学习了Java的异常体系和异常原理。

Java异常良好实践一文中,又对Java的异常良好开发实践进行了一个小结。

那么现在我们已经具有了理论知识和异常规约知识,下面就应该去开源项目里瞅一瞅那些大佬们是怎么使用Java异常的。

本文将对MyBatis的异常体系以及异常使用进行学习,MyBatis版本是3.5.6。作为一款成熟的ORM框架,MyBatis有自己一套成熟的异常处理体系。MyBatis的异常体系,有如下几个关键角色。

  • PersistenceException。继承于RuntimeException(直接继承于IbatisException),是MyBatis各个功能模块的异常的父类,所以MyBatis中使用的异常都是运行时异常;
  • ExceptionFactoryMyBatis中根据异常上下文创建PersistenceException的工厂类,配合ErrorContext使用;
  • ErrorContextMyBatis异常处理的灵魂,是一个和线程绑定的全局异常上下文,在打印异常信息时,能够反映出异常存在于哪个映射文件中,是做什么操作时引发的异常以及发生异常的SQL信息等。

正文

一. MyBatis异常体系说明

MyBatis框架自定义了一个异常基类,叫做PersistenceExceptionUML图如下所示。

PersistenceException的UML图

MyBatis各个功能模块自定义的异常均继承于PersistenceException,部分异常类UML图如下所示。

MyBatis异常体系UML图

异常的抛出策略遵循如下原则。

  1. 优先基于逻辑判断的方式抛出异常。在每个功能模块中,会优先对非法条件或场景进行判断校验,如果校验不通过,则抛出功能模块对应的自定义异常;
private void executeWithResultHandler(SqlSession sqlSession, Object[] args) {
    MappedStatement ms = sqlSession.getConfiguration().getMappedStatement(command.getName());
    if (!StatementType.CALLABLE.equals(ms.getStatementType())
        && void.class.equals(ms.getResultMaps().get(0).getType())) {
        throw new BindingException("method " + command.getName()
                                   + " needs either a @ResultMap annotation, a @ResultType annotation,"
                                   + " or a resultType attribute in XML so a ResultHandler can be used as a parameter.");
    }
    Object param = method.convertArgsToSqlCommandParam(args);
    if (method.hasRowBounds()) {
        RowBounds rowBounds = method.extractRowBounds(args);
        sqlSession.select(command.getName(), param, rowBounds, method.extractResultHandler(args));
    } else {
        sqlSession.select(command.getName(), param, method.extractResultHandler(args));
    }
}
  1. 所有底层异常统一封装为MyBatis的自定义异常。比如初始化日志打印器时的各种反射相关异常,获取数据库连接时的各种数据库连接池相关异常,与数据库交互时的各种SQL异常等,均会被MyBatis统一封装为各个功能模块自定义的异常类型,然后向上抛出;
public static Log getLog(String logger) {
    try {
        // 运行时异常,校验异常和Error均可能会发生
        return logConstructor.newInstance(logger);
    } catch (Throwable t) {
        // 捕获到的Throwable统一封装为自定义的LogException
        throw new LogException("Error creating logger for logger " + logger + ".  Cause: " + t, t);
    }
}
  1. 在能够处理自定义异常的地方精确捕获异常。在能够明确下层会抛出哪种异常并且当前能够处理这种异常的情况下,通过try-catch精确的捕获异常。
@Override
public T getResult(ResultSet rs, String columnName) throws SQLException {
    try {
        return getNullableResult(rs, columnName);
    } catch (Exception e) {
        throw new ResultMapException("Error attempting to get column '" + columnName + "' from result set.  Cause: " + e, e);
    }
}

上述getResult() 方法会抛出SQLException,下面是调用getResult() 方法时的两种不同处理策略。

// 能明确下层会抛出哪种异常且能够处理这种异常的情况
Object createParameterizedResultObject(ResultSetWrapper rsw, Class<?> resultType, List<ResultMapping> constructorMappings,
                                       List<Class<?>> constructorArgTypes, List<Object> constructorArgs, String columnPrefix) {
    boolean foundValues = false;
    for (ResultMapping constructorMapping : constructorMappings) {
        final Class<?> parameterType = constructorMapping.getJavaType();
        final String column = constructorMapping.getColumn();
        final Object value;
        try {
            if (constructorMapping.getNestedQueryId() != null) {
                value = getNestedQueryConstructorValue(rsw.getResultSet(), constructorMapping, columnPrefix);
            } else if (constructorMapping.getNestedResultMapId() != null) {
                final ResultMap resultMap = configuration.getResultMap(constructorMapping.getNestedResultMapId());
                value = getRowValue(rsw, resultMap, getColumnPrefix(columnPrefix, constructorMapping));
            } else {
                final TypeHandler<?> typeHandler = constructorMapping.getTypeHandler();
                value = typeHandler.getResult(rsw.getResultSet(), prependPrefix(column, columnPrefix));
            }
        } catch (ResultMapException | SQLException e) {
            // 精确的捕获ResultMapException和SQLException
            throw new ExecutorException("Could not process result for mapping: " + constructorMapping, e);
        }
        constructorArgTypes.add(parameterType);
        constructorArgs.add(value);
        foundValues = value != null || foundValues;
    }
    return foundValues ? objectFactory.create(resultType, constructorArgTypes, constructorArgs) : null;
}
// 不能明确下层会抛出哪种异常或者当前不能够处理这种异常的情况
@Override
public Object getNullableResult(ResultSet rs, String columnName)
    throws SQLException {
    TypeHandler<?> handler = resolveTypeHandler(rs, columnName);
    return handler.getResult(rs, columnName);
}

总之就是突出一个能处理绝不放过,不能处理绝不逞强

二. ErrorContext

我们使用MyBatis操作数据库时,如果在映射文件中写了一条错误的SQL,此时运行程序,会得到如下报错信息。

org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'book b' at line 4
### The error may exist in com/mybatis/learn/dao/BookMapper.xml
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: SELECT             b.id, b.b_name, b.b_price         FROMM             book b
### Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'book b' at line 4
	
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:149)
	......
Caused by: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'book b' at line 4
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:120)
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97)
	......

通过上述的异常信息,我们清晰的知道了错误发生在哪个映射文件错误与哪个对象有关错误是在进行什么操作时发生错误相关的SQL语句信息错误详细的堆栈信息

MyBatis之所以能够在异常发生时打印出上述的完备的异常信息,就是基于ErrorContext,下面对ErrorContext的实现原理和工作机制进行分析。

MyBatisErrorContext实现成了线程绑定的单例模式,在ErrorContext中有一个静态字段LOCAL,用于存储每个线程的ErrorContext,同时还提供了instance() 方法用于每个线程获取ErrorContext,相关字段和方法如下所示。

public class ErrorContext {
    
    private static final ThreadLocal<ErrorContext> LOCAL = ThreadLocal.withInitial(ErrorContext::new);

    ......

    private ErrorContext() {
    
    }

    public static ErrorContext instance() {
        return LOCAL.get();
    }
    
    ......

}

上述代码可以等效于如下代码。

public class ErrorContext {
    
    private static final ThreadLocal<ErrorContext> LOCAL = new ThreadLocal<ErrorContext>();

    ......

    private ErrorContext() {
    
    }

    public static ErrorContext instance() {
        ErrorContext context = LOCAL.get();
        if (context == null) {
            context = new ErrorContext();
            LOCAL.set(context);
        }
        return context;
    }
    
    ......

}

也就是每个线程在使用MyBatis的过程中,随时可以通过ErrorContextinstance() 方法拿到当前线程绑定的ErrorContext

ErrorContext有如下几个字段,用于存储MyBatis执行过程中的关键信息,如下所示。

public class ErrorContext {
    
    ......
    
    // 用于暂存ErrorContext
    private ErrorContext stored;
    // 保存当前操作的映射文件
    private String resource;
    // 保存当前的行为
    private String activity;
    // 保存当前操作的对象
    // 比如保存当前的MappedStatement的id
    private String object;
    // 保存当前的异常信息
    private String message;
    // 保存当前执行的SQL
    private String sql;
    // 保存异常
    private Throwable cause;
    
    ......
    
}

下面以一条错误的SQL执行全过程,演示ErrorContext的完整工作机制。

已知,MyBatis中,我们通过映射接口执行SQL语句,流程如下。

MyBatis执行SQL语句完整流程图

首先在BaseExecutor中会记录resource,如下所示。

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds,
                         ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
		throws SQLException {
    // 在这里记录resource
    ErrorContext.instance()
        .resource(ms.getResource())
        .activity("executing a query")
        .object(ms.getId());

    ......

    List<E> list;

    ......

    return list;
}

在上述方法中记录了resourcecom/mybatis/learn/dao/BookMapper.xml,虽然也记录了activityobject,但是这两个值会在后续流程节点被覆盖。

继续往下执行,会在BaseStatementHandlerprepare() 方法中记录sql,如下所示。

@Override
public Statement prepare(Connection connection, Integer transactionTimeout) 
    throws SQLException {
    // 在这里记录sql
    ErrorContext.instance().sql(boundSql.getSql());
    Statement statement = null;
    try {
        statement = instantiateStatement(connection);
        setStatementTimeout(statement, transactionTimeout);
        setFetchSize(statement);
        return statement;
    } catch (SQLException e) {
        closeStatement(statement);
        throw e;
    } catch (Exception e) {
        closeStatement(statement);
        throw new ExecutorException("Error preparing statement.  Cause: " + e, e);
    }
}

继续往下执行,会在DefaultParameterHandlersetParameters() 方法中记录activityobject,如下所示。

@Override
public void setParameters(PreparedStatement ps) {
    // 在这里记录activity和object
    ErrorContext.instance()
        .activity("setting parameters")
        .object(mappedStatement.getParameterMap().getId());
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    if (parameterMappings != null) {
        ......
    }
}

继续往下执行,就会在PreparedStatementHandlerquery() 方法中真正的通过PreparedStatement操作数据库,如下所示。

@Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler)
    	throws SQLException {
    // 这里是JDBC里的PreparedStatement
    PreparedStatement ps = (PreparedStatement) statement;
    // 由于之前故意将SQL写错所以这里会报错
    ps.execute();
    return resultSetHandler.handleResultSets(ps);
}

由于之前故意在映射文件中将SQL写错,所以在PreparedStatementHandlerquery() 方法中通过PreparedStatement操作数据库时,会抛出SQLSyntaxErrorException,该异常会一路往外抛,最终在DefaultSqlSessionselectList() 方法中被捕获,如下所示。

@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
        MappedStatement ms = configuration.getMappedStatement(statement);
        return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    } catch (Exception e) {
        throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
        ErrorContext.instance().reset();
    }
}

捕获到SQLSyntaxErrorException后,会通过ExceptionFactorywrapException() 方法创建PersistenceException,如下所示。

public static RuntimeException wrapException(String message, Exception e) {
    // 先记录message和cause到ErrorContext中
    // 然后通过ErrorContext的toString()方法组装异常详细信息
    // 最后基于异常详细信息和异常创建PersistenceException
    return new PersistenceException(ErrorContext.instance().message(message).cause(e).toString(), e);
}

在创建PersistenceException时,会先把ErrorContextmessagecause丰富上,此时ErrorContext的所有字段已经完成赋值,然后会通过ErrorContexttoString() 方法组装得到异常的详细信息,最后基于异常详细信息和异常创建PersistenceException。我们看到的异常的详细打印信息,就是在ErrorContexttoString() 方法中拼接的,下面看一下其实现。

@Override
public String toString() {
    StringBuilder description = new StringBuilder();

    // 拼接message
    if (this.message != null) {
        description.append(LINE_SEPARATOR);
        description.append("### ");
        description.append(this.message);
    }

    // 拼接resource
    if (resource != null) {
        description.append(LINE_SEPARATOR);
        description.append("### The error may exist in ");
        description.append(resource);
    }

    // 拼接object
    if (object != null) {
        description.append(LINE_SEPARATOR);
        description.append("### The error may involve ");
        description.append(object);
    }

    // 拼接activity
    if (activity != null) {
        description.append(LINE_SEPARATOR);
        description.append("### The error occurred while ");
        description.append(activity);
    }

    // 拼接sql
    if (sql != null) {
        description.append(LINE_SEPARATOR);
        description.append("### SQL: ");
        description.append(sql
                   .replace('\n', ' ')
                   .replace('\r', ' ')
                   .replace('\t', ' ')
                   .trim());
    }

    // 拼接cause
    if (cause != null) {
        description.append(LINE_SEPARATOR);
        description.append("### Cause: ");
        description.append(cause.toString());
    }

    return description.toString();
}

最后,一次数据库操作结束时,无论操作是否成功,都需要对ErrorContext进行初始化,在DefaultSqlSessionselectList() 方法的finally代码块中,会调用到ErrorContextreset() 方法来初始化ErrorContext,如下所示。

public ErrorContext reset() {
    resource = null;
    activity = null;
    object = null;
    message = null;
    sql = null;
    cause = null;
    // 防止内存泄漏
    LOCAL.remove();
    return this;
}

至此,一次数据库操作中,ErrorContext的使命就完成了。

总结

其实可以发现,MyBatis的异常使用中,也没有严格遵循异常规约,甚至某些地方还明目张胆的触犯异常规约,但是其实也不妨碍MyBatis的强大。

MyBatis的异常体系,总结如下。

  1. 所有异常都是运行时异常
  2. 优先基于逻辑判断的方式抛出异常
  3. 所有底层异常统一封装为MyBatis的自定义异常
  4. 能处理绝不放过,不能处理绝不逞强

此外,MyBatis自己基于ErrorContext实现了一套全局异常处理机制,使得MyBatis在异常发生时,能够打印尽可能详细的异常信息,这里给出一个完整的作用流程图。

MyBatis执行SQL语句完整流程图-补充异常处理节点