本文通過MyBatis一個低版本的bug(3.4.5之前的版本)入手,分析MyBatis的一次完整的查詢流程,從組態檔的解析到一個查詢的完整執行過程詳細解讀MyBatis的一次查詢流程,通過本文可以詳細瞭解MyBatis的一次查詢過程。在平時的程式碼編寫中,發現了MyBatis一個低版本的bug(3.4.5之前的版本),由於現在很多工程中的版本都是低於3.4.5的,因此在這裡用一個簡單的例子復現問題,並且從原始碼角度分析MyBatis一次查詢的流程,讓大家瞭解MyBatis的查詢原理。
1.1 場景問題復現
如下圖所示,在範例Mapper中,下面提供了一個方法queryStudents,從student表中查詢出符合查詢條件的資料,入參可以為student_name或者student_name的集合,範例中引數只傳入的是studentName的List集合
List<String> studentNames = new LinkedList<>();
studentNames.add("lct");
studentNames.add("lct2");
condition.setStudentNames(studentNames);
<select id="queryStudents" parameterType="mybatis.StudentCondition" resultMap="resultMap">
select * from student
<where>
<if test="studentNames != null and studentNames.size > 0 ">
AND student_name IN
<foreach collection="studentNames" item="studentName" open="(" separator="," close=")">
#{studentName, jdbcType=VARCHAR}
</foreach>
</if>
<if test="studentName != null and studentName != '' ">
AND student_name = #{studentName, jdbcType=VARCHAR}
</if>
</where>
</select>
期望執行的結果是
select * from student WHERE student_name IN ( 'lct' , 'lct2' )
但是實際上執行的結果是
==> Preparing: select * from student WHERE student_name IN ( ? , ? ) AND student_name = ?
==> Parameters: lct(String), lct2(String), lct2(String)
<== Columns: id, student_name, age
<== Row: 2, lct2, 2
<== Total: 1
通過執行結果可以看到,沒有給student_name單獨賦值,但是經過MyBatis解析以後,單獨給student_name賦值了一個值,可以推斷出MyBatis在解析SQL並對變數賦值的時候是有問題的,初步猜測是foreach迴圈中的變數的值帶到了foreach外邊,導致SQL解析出現異常,下面通過原始碼進行分析驗證
2.1 MyBatis架構
2.1.1 架構圖
先簡單來看看MyBatis整體上的架構模型,從整體上看MyBatis主要分為四大模組:
介面層:主要作用就是和資料庫打交道
資料處理層:資料處理層可以說是MyBatis的核心,它要完成兩個功能:
框架支撐層:主要有事務管理、連線池管理、快取機制和SQL語句的設定方式
引導層:引導層是設定和啟動MyBatis 設定資訊的方式。MyBatis 提供兩種方式來引導MyBatis :基於XML組態檔的方式和基於Java API 的方式
2.1.2 MyBatis四大物件
貫穿MyBatis整個框架的有四大核心物件,ParameterHandler、ResultSetHandler、StatementHandler和Executor,四大物件貫穿了整個框架的執行過程,四大物件的主要作用為:
2.2 從原始碼解讀MyBatis的一次查詢過程
首先給出復現問題的程式碼以及相應的準備過程
2.2.1 資料準備
CREATE TABLE `student` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`student_name` varchar(255) NULL DEFAULT NULL,
`age` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1;
-- ----------------------------
-- Records of student
-- ----------------------------
INSERT INTO `student` VALUES (1, 'lct', 1);
INSERT INTO `student` VALUES (2, 'lct2', 2);
2.2.2 程式碼準備
1.mapper組態檔
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="mybatis.StudentDao">
<!-- 對映關係 -->
<resultMap id="resultMap" type="mybatis.Student">
<id column="id" property="id" jdbcType="BIGINT" />
<result column="student_name" property="studentName" jdbcType="VARCHAR" />
<result column="age" property="age" jdbcType="INTEGER" />
</resultMap>
<select id="queryStudents" parameterType="mybatis.StudentCondition" resultMap="resultMap">
select * from student
<where>
<if test="studentNames != null and studentNames.size > 0 ">
AND student_name IN
<foreach collection="studentNames" item="studentName" open="(" separator="," close=")">
#{studentName, jdbcType=VARCHAR}
</foreach>
</if>
<if test="studentName != null and studentName != '' ">
AND student_name = #{studentName, jdbcType=VARCHAR}
</if>
</where>
</select>
</mapper>
2.範例程式碼
public static void main(String[] args) throws IOException {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
//1.獲取SqlSessionFactory物件
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
//2.獲取物件
SqlSession sqlSession = sqlSessionFactory.openSession();
//3.獲取介面的代理類物件
StudentDao mapper = sqlSession.getMapper(StudentDao.class);
StudentCondition condition = new StudentCondition();
List<String> studentNames = new LinkedList<>();
studentNames.add("lct");
studentNames.add("lct2");
condition.setStudentNames(studentNames);
//執行方法
List<Student> students = mapper.queryStudents(condition);
}
2.2.3 查詢過程分析
1.SqlSessionFactory的構建
先看SqlSessionFactory的物件的建立過程
//1.獲取SqlSessionFactory物件
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
程式碼中首先通過呼叫SqlSessionFactoryBuilder中的build方法來獲取物件,進入build方法
public SqlSessionFactory build(InputStream inputStream) {
return build(inputStream, null, null);
}
呼叫自身的build方法
圖1 build方法自身呼叫偵錯圖例
在這個方法裡會建立一個XMLConfigBuilder的物件,用來解析傳入的MyBatis的組態檔,然後呼叫parse方法進行解析
圖2 parse解析入參偵錯圖例
在這個方法中,會從MyBatis的組態檔的根目錄中獲取xml的內容,其中parser這個物件是一個XPathParser的物件,這個是專門用來解析xml檔案的,具體怎麼從xml檔案中獲取到各個節點這裡不再進行講解。這裡可以看到解析組態檔是從configuration這個節點開始的,在MyBatis的組態檔中這個節點也是根節點
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<properties>
<property name="dialect" value="MYSQL" /> <!-- SQL方言 -->
</properties>
然後將解析好的xml檔案傳入parseConfiguration方法中,在這個方法中會獲取在組態檔中的各個節點的設定
圖3 解析設定偵錯圖例
以獲取mappers節點的設定來看具體的解析過程
<mappers>
<mapper resource="mappers/StudentMapper.xml"/>
</mappers>
進入mapperElement方法
mapperElement(root.evalNode("mappers"));
圖4 mapperElement方法偵錯圖例
看到MyBatis還是通過建立一個XMLMapperBuilder物件來對mappers節點進行解析,在parse方法中
public void parse() {
if (!configuration.isResourceLoaded(resource)) {
configurationElement(parser.evalNode("/mapper"));
configuration.addLoadedResource(resource);
bindMapperForNamespace();
}
parsePendingResultMaps();
parsePendingCacheRefs();
parsePendingStatements();
}
通過呼叫configurationElement方法來解析設定的每一個mapper檔案
private void configurationElement(XNode context) {
try {
String namespace = context.getStringAttribute("namespace");
if (namespace == null || namespace.equals("")) {
throw new BuilderException("Mapper's namespace cannot be empty");
}
builderAssistant.setCurrentNamespace(namespace);
cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
resultMapElements(context.evalNodes("/mapper/resultMap"));
sqlElement(context.evalNodes("/mapper/sql"));
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
} catch (Exception e) {
throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);
}
}
以解析mapper中的增刪改查的標籤來看看是如何解析一個mapper檔案的
進入buildStatementFromContext方法
private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
for (XNode context : list) {
final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
try {
statementParser.parseStatementNode();
} catch (IncompleteElementException e) {
configuration.addIncompleteStatement(statementParser);
}
}
}
可以看到MyBatis還是通過建立一個XMLStatementBuilder物件來對增刪改查節點進行解析,通過呼叫這個物件的parseStatementNode方法,在這個方法裡會獲取到設定在這個標籤下的所有設定資訊,然後進行設定
圖5 parseStatementNode方法偵錯圖例
解析完成以後,通過方法addMappedStatement將所有的設定都新增到一個MappedStatement中去,然後再將mappedstatement新增到configuration中去
builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
resultSetTypeEnum, flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
可以看到一個mappedstatement中包含了一個增刪改查標籤的詳細資訊
圖7 mappedstatement物件方法偵錯圖例
而一個configuration就包含了所有的設定資訊,其中mapperRegistertry和mappedStatements
圖8 config物件方法偵錯圖例
具體的流程
圖9 SqlSessionFactory物件的構建過程 圖9 SqlSessionFactory物件的構建過程
2.SqlSession的建立過程
SqlSessionFactory建立完成以後,接下來看看SqlSession的建立過程
SqlSession sqlSession = sqlSessionFactory.openSession();
首先會呼叫DefaultSqlSessionFactory的openSessionFromDataSource方法
@Override
public SqlSession openSession() {
return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
}
在這個方法中,首先會從configuration中獲取DataSource等屬性組成物件Environment,利用Environment內的屬性構建一個事務物件TransactionFactory
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
Transaction tx = null;
try {
final Environment environment = configuration.getEnvironment();
final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
} catch (Exception e) {
closeTransaction(tx); // may have fetched a connection so lets call close()
throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
事務建立完成以後開始建立Executor物件,Executor物件的建立是根據 executorType建立的,預設是SIMPLE型別的,沒有設定的情況下建立了SimpleExecutor,如果開啟二級快取的話,則會建立CachingExecutor
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
建立executor以後,會執行executor = (Executor)
interceptorChain.pluginAll(executor)方法,這個方法對應的含義是使用每一個攔截器包裝並返回executor,最後呼叫DefaultSqlSession方法建立SqlSession
圖10 SqlSession物件的建立過程
3.Mapper的獲取過程
有了SqlSessionFactory和SqlSession以後,就需要獲取對應的Mapper,並執行mapper中的方法
StudentDao mapper = sqlSession.getMapper(StudentDao.class);
在第一步中知道所有的mapper都放在MapperRegistry這個物件中,因此通過呼叫
org.apache.ibatis.binding.MapperRegistry#getMapper方法來獲取對應的mapper
public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
if (mapperProxyFactory == null) {
throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
}
try {
return mapperProxyFactory.newInstance(sqlSession);
} catch (Exception e) {
throw new BindingException("Error getting mapper instance. Cause: " + e, e);
}
}
在MyBatis中,所有的mapper對應的都是一個代理類,獲取到mapper對應的代理類以後執行newInstance方法,獲取到對應的範例,這樣就可以通過這個範例進行方法的呼叫
public class MapperProxyFactory<T> {
private final Class<T> mapperInterface;
private final Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<Method, MapperMethod>();
public MapperProxyFactory(Class<T> mapperInterface) {
this.mapperInterface = mapperInterface;
}
public Class<T> getMapperInterface() {
return mapperInterface;
}
public Map<Method, MapperMethod> getMethodCache() {
return methodCache;
}
@SuppressWarnings("unchecked")
protected T newInstance(MapperProxy<T> mapperProxy) {
return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
}
public T newInstance(SqlSession sqlSession) {
final MapperProxy<T> mapperProxy = new MapperProxy<T>(sqlSession, mapperInterface, methodCache);
return newInstance(mapperProxy);
}
}
獲取mapper的流程為
圖11 Mapper的獲取過程
4.查詢過程
獲取到mapper以後,就可以呼叫具體的方法
//執行方法
List<Student> students = mapper.queryStudents(condition);
首先會呼叫
org.apache.ibatis.binding.MapperProxy#invoke的方法,在這個方法中,會呼叫org.apache.ibatis.binding.MapperMethod#execute
public Object execute(SqlSession sqlSession, Object[] args) {
Object result;
switch (command.getType()) {
case INSERT: {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.insert(command.getName(), param));
break;
}
case UPDATE: {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.update(command.getName(), param));
break;
}
case DELETE: {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.delete(command.getName(), param));
break;
}
case SELECT:
if (method.returnsVoid() && method.hasResultHandler()) {
executeWithResultHandler(sqlSession, args);
result = null;
} else if (method.returnsMany()) {
result = executeForMany(sqlSession, args);
} else if (method.returnsMap()) {
result = executeForMap(sqlSession, args);
} else if (method.returnsCursor()) {
result = executeForCursor(sqlSession, args);
} else {
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
}
break;
case FLUSH:
result = sqlSession.flushStatements();
break;
default:
throw new BindingException("Unknown execution method for: " + command.getName());
}
if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
throw new BindingException("Mapper method '" + command.getName()
+ " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
}
return result;
}
首先根據SQL的型別增刪改查決定執行哪個方法,在此執行的是SELECT方法,在SELECT中根據方法的返回值型別決定執行哪個方法,可以看到在select中沒有selectone單獨方法,都是通過selectList方法,通過呼叫
org.apache.ibatis.session.defaults.DefaultSqlSession#selectList(java.lang.String, java.lang.Object)方法來獲取到資料
@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();
}
}
在selectList中,首先從configuration物件中獲取MappedStatement,在statement中包含了Mapper的相關資訊,然後呼叫
org.apache.ibatis.executor.CachingExecutor#query()方法
圖12 query()方法偵錯圖示
在這個方法中,首先對SQL進行解析根據入參和原始SQL,對SQL進行拼接
圖13 SQL拼接過程程式碼圖示
呼叫MapperedStatement裡的getBoundSql最終解析出來的SQL為
圖14 SQL拼接過程結果圖示
接下來呼叫
org.apache.ibatis.parsing.GenericTokenParser#parse對解析出來的SQL進行解析
圖15 SQL解析過程圖示
最終解析的結果為
圖16 SQL解析結果圖示
最後會呼叫SimpleExecutor中的doQuery方法,在這個方法中,會獲取StatementHandler,然後呼叫
org.apache.ibatis.executor.statement.PreparedStatementHandler#parameterize這個方法進行引數和SQL的處理,最後呼叫statement的execute方法獲取到結果集,然後 利用resultHandler對結進行處理
圖17 SQL處理結果圖示
查詢的主要流程為
圖18 查詢流程處理圖示
5.查詢流程總結
總結整個查詢流程如下
圖19 查詢流程抽象
2.3 場景問題原因及解決方案
2.3.1 個人排查
這個問bug出現的地方在於繫結SQL引數的時候再原始碼中位置為
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
由於所寫的SQL是一個動態繫結引數的SQL,因此最終會走到
org.apache.ibatis.scripting.xmltags.DynamicSqlSource#getBoundSql這個方法中去
public BoundSql getBoundSql(Object parameterObject) {
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings == null || parameterMappings.isEmpty()) {
boundSql = new BoundSql(configuration, boundSql.getSql(), parameterMap.getParameterMappings(), parameterObject);
}
// check for nested result maps in parameter mappings (issue #30)
for (ParameterMapping pm : boundSql.getParameterMappings()) {
String rmId = pm.getResultMapId();
if (rmId != null) {
ResultMap rm = configuration.getResultMap(rmId);
if (rm != null) {
hasNestedResultMaps |= rm.hasNestedResultMaps();
}
}
}
return boundSql;
}
在這個方法中,會呼叫 rootSqlNode.apply(context)方法,由於這個標籤是一個foreach標籤,因此這個apply方法會呼叫到
org.apache.ibatis.scripting.xmltags.ForEachSqlNode#apply這個方法中去
@Override
public boolean apply(DynamicContext context) {
Map<String, Object> bindings = context.getBindings();
final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
if (!iterable.iterator().hasNext()) {
return true;
}
boolean first = true;
applyOpen(context);
int i = 0;
for (Object o : iterable) {
DynamicContext oldContext = context;
if (first) {
context = new PrefixedContext(context, "");
} else if (separator != null) {
context = new PrefixedContext(context, separator);
} else {
context = new PrefixedContext(context, "");
}
int uniqueNumber = context.getUniqueNumber();
// Issue #709
if (o instanceof Map.Entry) {
@SuppressWarnings("unchecked")
Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
applyIndex(context, mapEntry.getKey(), uniqueNumber);
applyItem(context, mapEntry.getValue(), uniqueNumber);
} else {
applyIndex(context, i, uniqueNumber);
applyItem(context, o, uniqueNumber);
}
contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
if (first) {
first = !((PrefixedContext) context).isPrefixApplied();
}
context = oldContext;
i++;
}
applyClose(context);
return true;
}
當呼叫appItm方法的時候將引數進行繫結,引數的變數問題都會存在bindings這個引數中區
private void applyItem(DynamicContext context, Object o, int i) {
if (item != null) {
context.bind(item, o);
context.bind(itemizeItem(item, i), o);
}
}
進行繫結引數的時候,繫結完成foreach的方法的時候,可以看到bindings中不止繫結了foreach中的兩個引數還額外有一個引數名字studentName->lct2,也就是說最後一個引數也是會出現在bindings這個引數中的,
private void applyItem(DynamicContext context, Object o, int i) {
if (item != null) {
context.bind(item, o);
context.bind(itemizeItem(item, i), o);
}
}
圖20 引數繫結過程
最後判定
org.apache.ibatis.scripting.xmltags.IfSqlNode#apply
@Override
public boolean apply(DynamicContext context) {
if (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context);
return true;
}
return false;
}
可以看到在呼叫evaluateBoolean方法的時候會把context.getBindings()就是前邊提到的bindings引數傳入進去,因為現在這個引數中有一個studentName,因此在使用Ognl表示式的時候,判定為這個if標籤是有值的因此將這個標籤進行了解析
圖21 單個引數繫結過程
最終繫結的結果為
圖22 全部引數繫結過程
因此這個地方繫結引數的地方是有問題的,至此找出了問題的所在。
2.3.2 官方解釋
翻閱MyBatis官方檔案進行求證,發現在3.4.5版本發行中bug fixes中有這樣一句
圖23 此問題官方修復github記錄 圖23 此問題官方修復github記錄
修復了foreach版本中對於全域性變數context的修改的bug
issue地址為https://github.com/mybatis/mybatis-3/pull/966
修復方案為https://github.com/mybatis/mybatis-3/pull/966/commits/84513f915a9dcb97fc1d602e0c06e11a1eef4d6a
可以看到官方給出的修改方案,重新定義了一個物件,分別儲存全域性變數和區域性變數,這樣就會解決foreach會改變全域性變數的問題。
圖24 此問題官方修復程式碼範例
2.3.3 修復方案
MyBatis原始碼的目錄是比較清晰的,基本上每個相同功能的模組都在一起,但是如果直接去閱讀原始碼的話,可能還是有一定的難度,沒法理解它的執行過程,本次通過一個簡單的查詢流程從頭到尾跟下來,可以看到MyBatis的設計以及處理流程,例如其中用到的設計模式:
圖25 MyBatis程式碼結構圖
https://mybatis.org/mybatis-3/zh/index.htm