導讀
本文通過MyBatis一個低版本的bug(3.4.5之前的版本)入手,分析MyBatis的一次完整的查詢流程,從配置文件的解析到一個查詢的完整執(zhí)行過程詳細解讀MyBatis的一次查詢流程,通過本文可以詳細了解MyBatis的一次查詢過程。在平時的代碼編寫中,發(fā)現(xiàn)了MyBatis一個低版本的bug(3.4.5之前的版本),由于現(xiàn)在很多工程中的版本都是低于3.4.5的,因此在這里用一個簡單的例子復現(xiàn)問題,并且從源碼角度分析MyBatis一次查詢的流程,讓大家了解MyBatis的查詢原理。
01 問題現(xiàn)象
在今年的敏捷團隊建設(shè)中,我通過Suite執(zhí)行器實現(xiàn)了一鍵自動化單元測試。Juint除了Suite執(zhí)行器還有哪些執(zhí)行器呢?由此我的Runner探索之旅開始了!
1.1 場景問題復現(xiàn)
如下圖所示,在示例Mapper中,下面提供了一個方法queryStudents,從student表中查詢出符合查詢條件的數(shù)據(jù),入?yún)⒖梢詾閟tudent_name或者student_name的集合,示例中參數(shù)只傳入的是studentName的List集合
List studentNames = new LinkedList<>(); studentNames.add("lct"); studentNames.add("lct2"); condition.setStudentNames(studentNames);
期望運行的結(jié)果是
select * from student WHERE student_name IN ( 'lct' , 'lct2' )但是實際上運行的結(jié)果是
==> 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
通過運行結(jié)果可以看到,沒有給student_name單獨賦值,但是經(jīng)過MyBatis解析以后,單獨給student_name賦值了一個值,可以推斷出MyBatis在解析SQL并對變量賦值的時候是有問題的,初步猜測是foreach循環(huán)中的變量的值帶到了foreach外邊,導致SQL解析出現(xiàn)異常,下面通過源碼進行分析驗證
02 MyBatis查詢原理
理解,首先 MCube 會依據(jù)模板緩存狀態(tài)判斷是否需要網(wǎng)絡(luò)獲取最新模板,當獲取到模板后進行模板加載,加載階段會將產(chǎn)物轉(zhuǎn)換為視圖樹的結(jié)構(gòu),轉(zhuǎn)換完成后將通過表達式引擎解析表達式并取得正確的值,通過事件解析引擎解析用戶自定義事件并完成事件的綁定,完成解析賦值以及事件綁定后進行視圖的渲染,最終將目標頁面展示到屏幕。
2.1 MyBatis架構(gòu)
2.1.1 架構(gòu)圖
先簡單來看看MyBatis整體上的架構(gòu)模型,從整體上看MyBatis主要分為四大模塊:
接口層:主要作用就是和數(shù)據(jù)庫打交道
數(shù)據(jù)處理層:數(shù)據(jù)處理層可以說是MyBatis的核心,它要完成兩個功能:
通過傳入參數(shù)構(gòu)建動態(tài)SQL語句;
SQL語句的執(zhí)行以及封裝查詢結(jié)果集成List
框架支撐層:主要有事務(wù)管理、連接池管理、緩存機制和SQL語句的配置方式
引導層:引導層是配置和啟動MyBatis 配置信息的方式。MyBatis 提供兩種方式來引導MyBatis :基于XML配置文件的方式和基于Java API 的方式
2.1.2 MyBatis四大對象
貫穿MyBatis整個框架的有四大核心對象,ParameterHandler、ResultSetHandler、StatementHandler和Executor,四大對象貫穿了整個框架的執(zhí)行過程,四大對象的主要作用為:
ParameterHandler:設(shè)置預(yù)編譯參數(shù)
ResultSetHandler:處理SQL的返回結(jié)果集
StatementHandler:處理sql語句預(yù)編譯,設(shè)置參數(shù)等相關(guān)工作
Executor:MyBatis的執(zhí)行器,用于執(zhí)行增刪改查操作
2.2從源碼解讀MyBatis的一次查詢過程
首先給出復現(xiàn)問題的代碼以及相應(yīng)的準備過程
2.2.1 數(shù)據(jù)準備
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配置文件
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 studentNames = new LinkedList<>(); studentNames.add("lct"); studentNames.add("lct2"); condition.setStudentNames(studentNames); //執(zhí)行方法 List students = mapper.queryStudents(condition); }
2.2.3 查詢過程分析
1.SqlSessionFactory的構(gòu)建
先看SqlSessionFactory的對象的創(chuàng)建過程
//1.獲取SqlSessionFactory對象 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
代碼中首先通過調(diào)用SqlSessionFactoryBuilder中的build方法來獲取對象,進入build方法
public SqlSessionFactory build(InputStream inputStream) { return build(inputStream, null, null); }
調(diào)用自身的build方法
圖1 build方法自身調(diào)用調(diào)試圖例
在這個方法里會創(chuàng)建一個XMLConfigBuilder的對象,用來解析傳入的MyBatis的配置文件,然后調(diào)用parse方法進行解析
圖2 parse解析入?yún)⒄{(diào)試圖例
在這個方法中,會從MyBatis的配置文件的根目錄中獲取xml的內(nèi)容,其中parser這個對象是一個XPathParser的對象,這個是專門用來解析xml文件的,具體怎么從xml文件中獲取到各個節(jié)點這里不再進行講解。這里可以看到解析配置文件是從configuration這個節(jié)點開始的,在MyBatis的配置文件中這個節(jié)點也是根節(jié)點
然后將解析好的xml文件傳入parseConfiguration方法中,在這個方法中會獲取在配置文件中的各個節(jié)點的配置
圖3 解析配置調(diào)試圖例
以獲取mappers節(jié)點的配置來看具體的解析過程
進入mapperElement方法
mapperElement(root.evalNode("mappers"));
圖4 mapperElement方法調(diào)試圖例
看到MyBatis還是通過創(chuàng)建一個XMLMapperBuilder對象來對mappers節(jié)點進行解析,在parse方法中
public void parse() { if (!configuration.isResourceLoaded(resource)) { configurationElement(parser.evalNode("/mapper")); configuration.addLoadedResource(resource); bindMapperForNamespace(); } parsePendingResultMaps(); parsePendingCacheRefs(); parsePendingStatements(); }
通過調(diào)用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 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還是通過創(chuàng)建一個XMLStatementBuilder對象來對增刪改查節(jié)點進行解析,通過調(diào)用這個對象的parseStatementNode方法,在這個方法里會獲取到配置在這個標簽下的所有配置信息,然后進行設(shè)置
圖5 parseStatementNode方法調(diào)試圖例
解析完成以后,通過方法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);
圖6 增加解析完成的mapper方法調(diào)試圖例
可以看到一個mappedstatement中包含了一個增刪改查標簽的詳細信息
圖7 mappedstatement對象方法調(diào)試圖例
而一個configuration就包含了所有的配置信息,其中mapperRegistertry和mappedStatements
圖8 config對象方法調(diào)試圖例
具體的流程
圖9 SqlSessionFactory對象的構(gòu)建過程
2.SqlSession的創(chuàng)建過程
SqlSessionFactory創(chuàng)建完成以后,接下來看看SqlSession的創(chuàng)建過程
SqlSession sqlSession = sqlSessionFactory.openSession();
首先會調(diào)用DefaultSqlSessionFactory的openSessionFromDataSource方法
@Override public SqlSession openSession() { return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false); }
在這個方法中,首先會從configuration中獲取DataSource等屬性組成對象Environment,利用Environment內(nèi)的屬性構(gòu)建一個事務(wù)對象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(); } }
事務(wù)創(chuàng)建完成以后開始創(chuàng)建Executor對象,Executor對象的創(chuàng)建是根據(jù) executorType創(chuàng)建的,默認是SIMPLE類型的,沒有配置的情況下創(chuàng)建了SimpleExecutor,如果開啟二級緩存的話,則會創(chuàng)建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; }
創(chuàng)建executor以后,會執(zhí)行executor = (Executor) interceptorChain.pluginAll(executor)方法,這個方法對應(yīng)的含義是使用每一個攔截器包裝并返回executor,最后調(diào)用DefaultSqlSession方法創(chuàng)建SqlSession
圖10 SqlSession對象的創(chuàng)建過程
3.Mapper的獲取過程
有了SqlSessionFactory和SqlSession以后,就需要獲取對應(yīng)的Mapper,并執(zhí)行mapper中的方法
StudentDao mapper = sqlSession.getMapper(StudentDao.class);
在第一步中知道所有的mapper都放在MapperRegistry這個對象中,因此通過調(diào)用org.apache.ibatis.binding.MapperRegistry#getMapper方法來獲取對應(yīng)的mapper
public T getMapper(Class type, SqlSession sqlSession) { final MapperProxyFactory mapperProxyFactory = (MapperProxyFactory) 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對應(yīng)的都是一個代理類,獲取到mapper對應(yīng)的代理類以后執(zhí)行newInstance方法,獲取到對應(yīng)的實例,這樣就可以通過這個實例進行方法的調(diào)用
public class MapperProxyFactory { private final Class mapperInterface; private final Map methodCache = new ConcurrentHashMap(); public MapperProxyFactory(Class mapperInterface) { this.mapperInterface = mapperInterface; } public Class getMapperInterface() { return mapperInterface; } public Map getMethodCache() { return methodCache; } @SuppressWarnings("unchecked") protected T newInstance(MapperProxy mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); } public T newInstance(SqlSession sqlSession) { final MapperProxy mapperProxy = new MapperProxy(sqlSession, mapperInterface, methodCache); return newInstance(mapperProxy); } }
獲取mapper的流程為
圖11 Mapper的獲取過程
4.查詢過程
獲取到mapper以后,就可以調(diào)用具體的方法
//執(zhí)行方法 List students = mapper.queryStudents(condition);
首先會調(diào)用org.apache.ibatis.binding.MapperProxy#invoke的方法,在這個方法中,會調(diào)用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; }
首先根據(jù)SQL的類型增刪改查決定執(zhí)行哪個方法,在此執(zhí)行的是SELECT方法,在SELECT中根據(jù)方法的返回值類型決定執(zhí)行哪個方法,可以看到在select中沒有selectone單獨方法,都是通過selectList方法,通過調(diào)用org.apache.ibatis.session.defaults.DefaultSqlSession#selectList(java.lang.String, java.lang.Object)方法來獲取到數(shù)據(jù)
@Override public List 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的相關(guān)信息,然后調(diào)用org.apache.ibatis.executor.CachingExecutor#query()方法
圖12 query()方法調(diào)試圖示
在這個方法中,首先對SQL進行解析根據(jù)入?yún)⒑驮糞QL,對SQL進行拼接
圖13 SQL拼接過程代碼圖示
調(diào)用MapperedStatement里的getBoundSql最終解析出來的SQL為
圖14 SQL拼接過程結(jié)果圖示
接下來調(diào)用org.apache.ibatis.parsing.GenericTokenParser#parse對解析出來的SQL進行解析
圖15 SQL解析過程圖示
最終解析的結(jié)果為
圖16 SQL解析結(jié)果圖示
最后會調(diào)用SimpleExecutor中的doQuery方法,在這個方法中,會獲取StatementHandler,然后調(diào)用org.apache.ibatis.executor.statement.PreparedStatementHandler#parameterize這個方法進行參數(shù)和SQL的處理,最后調(diào)用statement的execute方法獲取到結(jié)果集,然后 利用resultHandler對結(jié)進行處理
圖17 SQL處理結(jié)果圖示
查詢的主要流程為
圖18 查詢流程處理圖示
5.查詢流程總結(jié)
總結(jié)整個查詢流程如下
圖19 查詢流程抽象
2.3場景問題原因及解決方案
2.3.1 個人排查
這個問bug出現(xiàn)的地方在于綁定SQL參數(shù)的時候再源碼中位置為
@Override public List 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是一個動態(tài)綁定參數(shù)的SQL,因此最終會走到org.apache.ibatis.scripting.xmltags.DynamicSqlSource#getBoundSql這個方法中去
public BoundSql getBoundSql(Object parameterObject) { BoundSql boundSql = sqlSource.getBoundSql(parameterObject); List 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; }
在這個方法中,會調(diào)用 rootSqlNode.apply(context)方法,由于這個標簽是一個foreach標簽,因此這個apply方法會調(diào)用到org.apache.ibatis.scripting.xmltags.ForEachSqlNode#apply這個方法中去
@Override public boolean apply(DynamicContext context) { Map 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 mapEntry = (Map.Entry) 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; }
當調(diào)用appItm方法的時候?qū)?shù)進行綁定,參數(shù)的變量問題都會存在bindings這個參數(shù)中區(qū)
private void applyItem(DynamicContext context, Object o, int i) { if (item != null) { context.bind(item, o); context.bind(itemizeItem(item, i), o); } }
進行綁定參數(shù)的時候,綁定完成foreach的方法的時候,可以看到bindings中不止綁定了foreach中的兩個參數(shù)還額外有一個參數(shù)名字studentName->lct2,也就是說最后一個參數(shù)也是會出現(xiàn)在bindings這個參數(shù)中的,
private void applyItem(DynamicContext context, Object o, int i) { if (item != null) { context.bind(item, o); context.bind(itemizeItem(item, i), o); } }
圖20 參數(shù)綁定過程
最后判定
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; }
可以看到在調(diào)用evaluateBoolean方法的時候會把context.getBindings()就是前邊提到的bindings參數(shù)傳入進去,因為現(xiàn)在這個參數(shù)中有一個studentName,因此在使用Ognl表達式的時候,判定為這個if標簽是有值的因此將這個標簽進行了解析
圖21 單個參數(shù)綁定過程
最終綁定的結(jié)果為
圖22 全部參數(shù)綁定過程
因此這個地方綁定參數(shù)的地方是有問題的,至此找出了問題的所在。
2.3.2 官方解釋
翻閱MyBatis官方文檔進行求證,發(fā)現(xiàn)在3.4.5版本發(fā)行中bug fixes中有這樣一句
圖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會改變?nèi)肿兞康膯栴}。
圖24 此問題官方修復代碼示例
2.3.3 修復方案
升級MyBatis版本至3.4.5以上
如果保持版本不變的話,在foreach中定義的變量名不要和外部的一致
03 源碼閱讀過程總結(jié)
理解,首先 MCube 會依據(jù)模板緩存狀態(tài)判斷是否需要網(wǎng)絡(luò)獲取最新模板,當獲取到模板后進行模板加載,加載階段會將產(chǎn)物轉(zhuǎn)換為視圖樹的結(jié)構(gòu),轉(zhuǎn)換完成后將通過表達式引擎解析表達式并取得正確的值,通過事件解析引擎解析用戶自定義事件并完成事件的綁定,完成解析賦值以及事件綁定后進行視圖的渲染,最終將目標頁面展示到屏幕。
MyBatis源代碼的目錄是比較清晰的,基本上每個相同功能的模塊都在一起,但是如果直接去閱讀源碼的話,可能還是有一定的難度,沒法理解它的運行過程,本次通過一個簡單的查詢流程從頭到尾跟下來,可以看到MyBatis的設(shè)計以及處理流程,例如其中用到的設(shè)計模式:
圖25 MyBatis代碼結(jié)構(gòu)圖
組合模式:如ChooseSqlNode,IfSqlNode等
模板方法模式:例如BaseExecutor和SimpleExecutor,還有BaseTypeHandler和所有的子類例如IntegerTypeHandler
Builder模式:例如 SqlSessionFactoryBuilder、XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder、CacheBuilder
工廠模式:例如SqlSessionFactory、ObjectFactory、MapperProxyFactory
代理模式:MyBatis實現(xiàn)的核心,比如MapperProxy、ConnectionLogger
審核編輯:湯梓紅
-
源碼
+關(guān)注
關(guān)注
8文章
639瀏覽量
29185 -
mybatis
+關(guān)注
關(guān)注
0文章
60瀏覽量
6709
發(fā)布評論請先 登錄
相關(guān)推薦
評論