本文将对 MyBatis 的异常体系以及异常使用进行学习,MyBatis 版本是 3.5.6。作为一款成熟的 ORM 框架,MyBatis 有自己一套成熟的异常处理体系。MyBatis 的异常体系,有如下几个关键角色。
PersistenceException。继承于 RuntimeException(直接继承于**IbatisException),是 MyBatis 各个功能模块的异常的父类,所以 MyBatis**中使用的异常都是运行时异常;
ExceptionFactory。MyBatis 中根据异常上下文创建 PersistenceException 的工厂类,配合 ErrorContext 使用;
ErrorContext。MyBatis 异常处理的灵魂,是一个和线程绑定的全局异常上下文,在打印异常信息时,能够反映出异常存在于哪个映射文件中,是做什么操作时引发的异常以及发生异常的 SQL 信息等。
一、MyBatis 异常体系说明
MyBatis 框架自定义了一个异常基类,叫做 PersistenceException,UML 图如下所示:
MyBatis 各个功能模块自定义的异常均继承于 PersistenceException,部分异常类 UML 图如下所示:
异常的抛出策略遵循如下原则:
优先基于逻辑判断的方式抛出异常。在每个功能模块中,会优先对非法条件或场景进行判断校验,如果校验不通过,则抛出功能模块对应的自定义异常;
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));
}
}
复制代码
所有底层异常统一封装为 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);
}
}
复制代码
在能够处理自定义异常的地方精确捕获异常。在能够明确下层会抛出哪种异常并且当前能够处理这种异常的情况下,通过 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 的实现原理和工作机制进行分析。
MyBatis 将 ErrorContext 实现成了线程绑定的单例模式,在 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 的过程中,随时可以通过 ErrorContext 的 instance() 方法拿到当前线程绑定的 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 语句,流程如下:
首先在 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;
}
复制代码
在上述方法中记录了 resource 为 com/mybatis/learn/dao/BookMapper.xml,虽然也记录了 activity 和 object,但是这两个值会在后续流程节点被覆盖。
继续往下执行,会在 BaseStatementHandler 的 prepare() 方法中记录 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);
}
}
复制代码
继续往下执行,会在 DefaultParameterHandler 的 setParameters() 方法中记录 activity 和 object,如下所示:
@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) {
......
}
}
复制代码
继续往下执行,就会在 PreparedStatementHandler 的 query() 方法中真正的通过 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 写错,所以在 PreparedStatementHandler 的 query() 方法中通过 PreparedStatement 操作数据库时,会抛出 SQLSyntaxErrorException,该异常会一路往外抛,最终在 DefaultSqlSession 的 selectList() 方法中被捕获,如下所示:
@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 后,会通过 ExceptionFactory 的 wrapException() 方法创建 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 时,会先把 ErrorContext 的 message 和 cause 丰富上,此时 ErrorContext 的所有字段已经完成赋值,然后会通过 ErrorContext 的 toString() 方法组装得到异常的详细信息,最后基于异常详细信息和异常创建 PersistenceException。我们看到的异常的详细打印信息,就是在 ErrorContext 的 toString() 方法中拼接的,下面看一下其实现:
@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 进行初始化,在 DefaultSqlSession 的 selectList() 方法的 finally 代码块中,会调用到 ErrorContext 的 reset() 方法来初始化 ErrorContext,如下所示:
public ErrorContext reset() {
resource = null;
activity = null;
object = null;
message = null;
sql = null;
cause = null;
// 防止内存泄漏
LOCAL.remove();
return this;
}
复制代码
至此,一次数据库操作中,ErrorContext 的使命就完成了。
总结
其实可以发现,MyBatis 的异常使用中,也没有严格遵循异常规约,甚至某些地方还明目张胆的触犯异常规约,但是其实也不妨碍 MyBatis 的强大。
MyBatis 的异常体系,总结如下:
所有异常都是运行时异常;
优先基于逻辑判断的方式抛出异常;
所有底层异常统一封装为 MyBatis 的自定义异常;
能处理绝不放过,不能处理绝不逞强。
此外,MyBatis 自己基于 ErrorContext 实现了一套全局异常处理机制,使得 MyBatis 在异常发生时,能够打印尽可能详细的异常信息,这里给出一个完整的作用流程图:
作者:半夏之沫
链接:https://juejin.cn/post/7241197815060906041
来源:稀土掘金
评论