前言
好久不见,从上一篇文章过后,休整了两个月,又逢疫情特殊时期,天天宅在家里挺尸,真是见证了一个人可以懒惰到什么境界。好吧废话不多说了,今天会给大家分享我们常用的持久层框架——MyBatis的工作原理和源码解析。
说实话MyBatis是我第一个接触的持久层框架,在这之前我也没有用过Hibernate,从Java原生的Jdbc操作数据库之后就直接过渡到了这个框架上,当时给我的第一感觉是,有一个框架太方便了,举一个例子吧,我们在Jdbc操作的时候,对于对象的封装,我们是需要通过ResultSet.getXXX(index)来获取值,然后在通过对象的setXXX()方法进行手动注入,这种重复且无任何技术含量的工作一直以来都是被我们程序猿所鄙视的一环,而MyBatis就可以直接将我们的SQL查询出来的数据与对象直接进行映射然后直接返回一个封装完成的对象,这节省了程序猿大部分的时间,当然其实JdbcTemplate也可以做到,但是这里先不说。MyBatis的优点有非常多,当然这也只有同时使用过Jdbc和MyBatis之后,产生对比,才会有这种巨大的落差感,但这并不是今天要讨论的重点,今天的重心还是放在MyBatis是如何做到这些的。
对于MyBatis,给我个人的感受,其工作流程实际上分为两部分:第一,构建,也就是解析我们写的xml配置,将其变成它所需要的对象。第二,就是执行,在构建完成的基础上,去执行我们的SQL,完成与Jdbc的交互。而这篇的重点会先放在构建上。
Xml配置文件
玩过这个框架的同学都知道,我们在单独使用它的时候,会需要两个配置文件,分别是mybatis-config.xml和mapper.xml,在官网上可以直接看到,当然这里为了方便,我就直接将我的xml配置复制一份。
我们不难看出,在mybatis-config.xml这个文件主要是用于配置数据源、配置别名、加载mapper.xml,并且我们可以看到这个文件的
节点中包含了一个
,而这个mapper所指向的路径就是另外一个xml文件:DemoMapper.xml,而这个文件中写了我们查询数据库所用的SQL。
而,MyBatis实际上就是将这两个xml文件,解析成配置对象,在执行中去使用它。
解析
-
MyBatis需要什么配置对象?
虽然在这里我们并没有进行源码的阅读,但是作为一个程序猿,我们可以凭借日常的开发经验做出一个假设。假设来源于问题,那么问题就是:为什么要将配置和SQL语句分为两个配置文件而不是直接写在一起?
是不是就意味着,这两个配置文件会被MyBatis分开解析成两个不同的Java对象?
不妨先将问题搁置,进行源码的阅读。
-
环境搭建
首先我们可以写一个最基本的使用MyBatis的代码,我这里已经写好了。
public static void main(String[] args) throws Exception { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); //创建SqlSessionFacory SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); /******************************分割线******************************/ SqlSession sqlSession = sqlSessionFactory.openSession(); //获取Mapper DemoMapper mapper = sqlSession.getMapper(DemoMapper.class); Map
map = new HashMap<>(); map.put("id","123"); System.out.println(mapper.selectAll(map)); sqlSession.close(); sqlSession.commit(); } 看源码重要的一点就是要找到源码的入口,而我们可以从这几行程序出发,来看看构建究竟是在哪开始的。
首先不难看出,这段程序显示通过字节流读取了mybatis-config.xml文件,然后通过SqlSessionFactoryBuilder.build()方法,创建了一个SqlSessionFactory(这里用到了工厂模式和构建者模式),前面说过,MyBatis就是通过我们写的xml配置文件,来构建配置对象的,那么配置文件所在的地方,就一定是构建开始的地方,也就是build方法。
-
构建开始
进入build方法,我们可以看到这里的确有解析的意思,这个方法返回了一个SqlSessionFactory,而这个对象也是使用构造者模式创建的,不妨继续往下走。
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) { try { //解析mybatis-config.xml //XMLConfigBuilder 构造者 XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties); //parse(): 解析mybatis-config.xml里面的节点 return build(parser.parse()); } catch (Exception e) { throw ExceptionFactory.wrapException("Error building SqlSession.", e); } finally { ErrorContext.instance().reset(); try { inputStream.close(); } catch (IOException e) { // Intentionally ignore. Prefer previous error. } } }
进入parse():
public Configuration parse() { //查看该文件是否已经解析过 if (parsed) { throw new BuilderException("Each XMLConfigBuilder can only be used once."); } //如果没有解析过,则继续往下解析,并且将标识符置为true parsed = true; //解析
节点 parseConfiguration(parser.evalNode("/configuration")); return configuration; } 注意parse的返回值,Configuration,这个似曾相识的单词好像在哪见过,是否与mybatis-config.xml中的
节点有所关联呢?答案是肯定的,我们可以接着往下看。
看到这里,虽然代码量还不是特别多,但是至少现在我们可以在大脑中得到一个大致的主线图,也如下图所示:
沿着这条主线,我们进入parseConfiguration(XNode)方法,接着往下看。
private void parseConfiguration(XNode root) { try { //解析
下的节点 //issue #117 read properties first // propertiesElement(root.evalNode("properties")); // Properties settings = settingsAsProperties(root.evalNode("settings")); loadCustomVfs(settings); loadCustomLogImpl(settings); //别名 解析 // 所谓别名 其实就是把你指定的别名对应的class存储在一个Map当中 typeAliasesElement(root.evalNode("typeAliases")); //插件 pluginElement(root.evalNode("plugins")); //自定义实例化对象的行为 objectFactoryElement(root.evalNode("objectFactory")); //MateObject 方便反射操作实体类的对象 objectWrapperFactoryElement(root.evalNode("objectWrapperFactory")); reflectorFactoryElement(root.evalNode("reflectorFactory")); settingsElement(settings); // read it after objectFactory and objectWrapperFactory issue #631 // environmentsElement(root.evalNode("environments")); databaseIdProviderElement(root.evalNode("databaseIdProvider")); // typeHandlers typeHandlerElement(root.evalNode("typeHandlers")); //主要 指向我们存放SQL的xxxxMapper.xml文件 mapperElement(root.evalNode("mappers")); } catch (Exception e) { throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e); } } 可以看到这个方法已经在解析
下的节点了,例如
,
,
和
。这里主要使用了分步构建,每个解析不同标签的方法内部都对Configuration对象进行了set或者其它类似的操作,经过这些操作之后,一个Configuration对象就构建完毕了,这里由于代码量比较大,而且大多数构建都是些细节,大概知道怎么用就可以了,就不在文章中说明了,我会挑一个主要的说,当然有兴趣的同学可以自己去pull MyBatis的源码看看。
-
Mappers
上文中提到,mybatis-config.xml文件中我们一定会写一个叫做
的标签,这个标签中的
节点存放了我们对数据库进行操作的SQL语句,所以这个标签的构建会作为今天分析的重点。首先在看源码之前,我们先回忆一下我们在mapper标签内通常会怎样进行配置,通常有如下几种配置方式。
这是
标签的几种配置方式,通过这几种配置方式,可以帮助我们更容易理解mappers的解析。private void mapperElement(XNode parent) throws Exception { if (parent != null) { //遍历解析mappers下的节点 for (XNode child : parent.getChildren()) { //首先解析package节点 if ("package".equals(child.getName())) { //获取包名 String mapperPackage = child.getStringAttribute("name"); configuration.addMappers(mapperPackage); } else { //如果不存在package节点,那么扫描mapper节点 //resource/url/mapperClass三个值只能有一个值是有值的 String resource = child.getStringAttribute("resource"); String url = child.getStringAttribute("url"); String mapperClass = child.getStringAttribute("class"); //优先级 resource>url>mapperClass if (resource != null && url == null && mapperClass == null) { //如果mapper节点中的resource不为空 ErrorContext.instance().resource(resource); //那么直接加载resource指向的XXXMapper.xml文件为字节流 InputStream inputStream = Resources.getResourceAsStream(resource); //通过XMLMapperBuilder解析XXXMapper.xml,可以看到这里构建的XMLMapperBuilde还传入了configuration,所以之后肯定是会将mapper封装到configuration对象中去的。 XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments()); //解析 mapperParser.parse(); } else if (resource == null && url != null && mapperClass == null) { //如果url!=null,那么通过url解析 ErrorContext.instance().resource(url); InputStream inputStream = Resources.getUrlAsStream(url); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments()); mapperParser.parse(); } else if (resource == null && url == null && mapperClass != null) { //如果mapperClass!=null,那么通过加载类构造Configuration Class> mapperInterface = Resources.classForName(mapperClass); configuration.addMapper(mapperInterface); } else { //如果都不满足 则直接抛异常 如果配置了两个或三个 直接抛异常 throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one."); } } } } }
我们的配置文件中写的是通过resource来加载mapper.xml的,所以会通过XMLMapperBuilder来进行解析,我们可以进去他的parse方法中看一下:
public void parse() { //判断文件是否之前解析过 if (!configuration.isResourceLoaded(resource)) { //解析mapper文件节点(主要)(下面贴了代码) configurationElement(parser.evalNode("/mapper")); configuration.addLoadedResource(resource); //绑定Namespace里面的Class对象 bindMapperForNamespace(); } //重新解析之前解析不了的节点,先不看,最后填坑。 parsePendingResultMaps(); parsePendingCacheRefs(); parsePendingStatements(); } //解析mapper文件里面的节点 // 拿到里面配置的配置项 最终封装成一个MapperedStatemanet private void configurationElement(XNode context) { try { //获取命名空间 namespace,这个很重要,后期mybatis会通过这个动态代理我们的Mapper接口 String namespace = context.getStringAttribute("namespace"); if (namespace == null || namespace.equals("")) { //如果namespace为空则抛一个异常 throw new BuilderException("Mapper's namespace cannot be empty"); } builderAssistant.setCurrentNamespace(namespace); //解析缓存节点 cacheRefElement(context.evalNode("cache-ref")); cacheElement(context.evalNode("cache")); //解析parameterMap(过时)和resultMap
parameterMapElement(context.evalNodes("/mapper/parameterMap")); resultMapElements(context.evalNodes("/mapper/resultMap")); //解析 节点 // select * from test (可重用的代码段) // sqlElement(context.evalNodes("/mapper/sql")); //解析增删改查节点在这个parse()方法中,调用了一个configuationElement代码,用于解析XXXMapper.xml文件中的各种节点,包括
、
、
(已过时)、
、
、还有增删改查节点,和上面相同的是,我们也挑一个主要的来说,因为解析过程都大同小异。毋庸置疑的是,我们在XXXMapper.xml中必不可少的就是编写SQL,与数据库交互主要靠的也就是这个,所以着重说说解析增删改查节点的方法——buildStatementFromContext()。
在没贴代码之前,根据这个名字就可以略知一二了,这个方法会根据我们的增删改查节点,来构造一个Statement,而用过原生Jdbc的都知道,Statement就是我们操作数据库的对象。
private void buildStatementFromContext(List
list) { if (configuration.getDatabaseId() != null) { buildStatementFromContext(list, configuration.getDatabaseId()); } //解析xml buildStatementFromContext(list, null); } private void buildStatementFromContext(List list, String requiredDatabaseId) { for (XNode context : list) { final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId); try { //解析xml节点 statementParser.parseStatementNode(); } catch (IncompleteElementException e) { //xml语句有问题时 存储到集合中 等解析完能解析的再重新解析 configuration.addIncompleteStatement(statementParser); } } } public void parseStatementNode() { //获取 这个代码段虽然很长,但是一句话形容它就是繁琐但不复杂,里面主要也就是对xml的节点进行解析。举个比上面简单的例子吧,假设我们有这样一段配置:
MyBatis需要做的就是,先判断这个节点是用来干什么的,然后再获取这个节点的id、parameterType、resultType等属性,封装成一个MappedStatement对象,由于这个对象很复杂,所以MyBatis使用了构造者模式来构造这个对象,最后当MappedStatement对象构造完成后,将其封装到Configuration对象中。
代码执行至此,基本就结束了对Configuration对象的构建,MyBatis的第一阶段:构造,也就到这里结束了,现在再来回答我们在文章开头提出的那两个问题:MyBatis需要构造什么对象?以及是否两个配置文件对应着两个对象?,似乎就已经有了答案,这里做一个总结:
MyBatis需要对配置文件进行解析,最终会解析成一个Configuration对象,但是要说两个配置文件对应了两个对象实际上也没有错:
- Configuration对象,保存了mybatis-config.xml的配置信息。
- MappedStatement,保存了XXXMapper.xml的配置信息。
但是最终MappedStatement对象会封装到Configuration对象中,合二为一,成为一个单独的对象,也就是Configuration。
最后给大家画一个构建过程的流程图:
填坑
-
SQL语句在哪解析?
细心的同学可能已经发现了,上文中只说了去节点中获取一些属性从而构建配置对象,但是最重要的SQL语句并没有提到,这是因为这部分我想要和属性区分开单独说,由于MyBatis支持动态SQL和${}、#{}的多样的SQL,所以这里单独提出来说会比较合适。
首先可以确认的是,刚才我们走完的那一整个流程中,包含了SQL语句的生成,下面贴代码(这一段代码相当绕,不好读)。
//解析Sql(重要) 根据sql文本来判断是否需要动态解析 如果没有动态sql语句且 只有#{}的时候 直接静态解析使用?占位 当有 ${} 不解析 SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
这里就是生成Sql的入口,以单步调试的角度接着往下看。
/*进入createSqlSource方法*/ @Override public SqlSource createSqlSource(Configuration configuration, XNode script, Class> parameterType) { //进入这个构造 XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType); //进入parseScriptNode return builder.parseScriptNode(); } /** 进入这个方法 */ public SqlSource parseScriptNode() { //# //会先解析一遍 MixedSqlNode rootSqlNode = parseDynamicTags(context); SqlSource sqlSource; if (isDynamic) { //如果是${}会直接不解析,等待执行的时候直接赋值 sqlSource = new DynamicSqlSource(configuration, rootSqlNode); } else { //用占位符方式来解析 #{} --> ? sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType); } return sqlSource; } protected MixedSqlNode parseDynamicTags(XNode node) { List
contents = new ArrayList<>(); //获取select标签下的子标签 NodeList children = node.getNode().getChildNodes(); for (int i = 0; i < children.getLength(); i++) { XNode child = node.newXNode(children.item(i)); if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) { //如果是查询 //获取原生SQL语句 这里是 select * from test where id = #{id} String data = child.getStringBody(""); TextSqlNode textSqlNode = new TextSqlNode(data); //检查sql是否是${} if (textSqlNode.isDynamic()) { //如果是${}那么直接不解析 contents.add(textSqlNode); isDynamic = true; } else { //如果不是,则直接生成静态SQL //#{} -> ? contents.add(new StaticTextSqlNode(data)); } } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628 //如果是增删改 String nodeName = child.getNode().getNodeName(); NodeHandler handler = nodeHandlerMap.get(nodeName); if (handler == null) { throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement."); } handler.handleNode(child, contents); isDynamic = true; } } return new MixedSqlNode(contents); } /*从上面的代码段到这一段中间需要经过很多代码,就不一段一段贴了*/ public SqlSource parse(String originalSql, Class> parameterType, Map
additionalParameters) { ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters); //这里会生成一个GenericTokenParser,传入#{}作为开始和结束,然后调用其parse方法,即可将#{}换为 ? GenericTokenParser parser = new GenericTokenParser("#{", "}", handler); //这里可以解析#{} 将其替换为? String sql = parser.parse(originalSql); return new StaticSqlSource(configuration, sql, handler.getParameterMappings()); } //经过一段复杂的解析过程 public String parse(String text) { if (text == null || text.isEmpty()) { return ""; } // search open token int start = text.indexOf(openToken); if (start == -1) { return text; } char[] src = text.toCharArray(); int offset = 0; final StringBuilder builder = new StringBuilder(); StringBuilder expression = null; //遍历里面所有的#{} select ? ,#{id1} ${} while (start > -1) { if (start > 0 && src[start - 1] == '\\') { // this open token is escaped. remove the backslash and continue. builder.append(src, offset, start - offset - 1).append(openToken); offset = start + openToken.length(); } else { // found open token. let's search close token. if (expression == null) { expression = new StringBuilder(); } else { expression.setLength(0); } builder.append(src, offset, start - offset); offset = start + openToken.length(); int end = text.indexOf(closeToken, offset); while (end > -1) { if (end > offset && src[end - 1] == '\\') { // this close token is escaped. remove the backslash and continue. expression.append(src, offset, end - offset - 1).append(closeToken); offset = end + closeToken.length(); end = text.indexOf(closeToken, offset); } else { expression.append(src, offset, end - offset); break; } } if (end == -1) { // close token was not found. builder.append(src, start, src.length - start); offset = src.length; } else { //使用占位符 ? //注意handler.handleToken()方法,这个方法是核心 builder.append(handler.handleToken(expression.toString())); offset = end + closeToken.length(); } } start = text.indexOf(openToken, offset); } if (offset < src.length) { builder.append(src, offset, src.length - offset); } return builder.toString(); } //BindingTokenParser 的handleToken //当扫描到${}的时候调用此方法 其实就是不解析 在运行时候在替换成具体的值 @Override public String handleToken(String content) { this.isDynamic = true; return null; } //ParameterMappingTokenHandler的handleToken //全局扫描#{id} 字符串之后 会把里面所有 #{} 调用handleToken 替换为? @Override public String handleToken(String content) { parameterMappings.add(buildParameterMapping(content)); return "?"; } 这段代码相当绕,我们应该站在一个宏观的角度去看待它。所以我直接在这里概括一下:
首先这里会通过
节点获取到我们的SQL语句,假设SQL语句中只有${},那么直接就什么都不做,在运行的时候直接进行赋值。
而如果扫描到了#{}字符串之后,会进行替换,将#{}替换为 ?。
那么他是怎么进行判断的呢?
这里会生成一个GenericTokenParser,这个对象可以传入一个openToken和closeToken,如果是#{},那么openToken就是#{,closeToken就是 },然后通过parse方法中的handler.handleToken()方法进行替换。
在这之前由于已经进行过SQL是否含有#{}的判断了,所以在这里如果是只有${},那么handler就是BindingTokenParser的实例化对象,如果存在#{},那么handler就是ParameterMappingTokenHandler的实例化对象。
分别进行处理。
-
上文中提到的解析不了的节点是什么意思?
根据上文的代码我们可知,解析Mapper.xml文件中的每个节点是有顺序的。
那么假设我写了这么几个节点:
select节点是需要获取resultMap的,但是此时resultMap并没有被解析到,所以解析到
这个节点的时候是无法获取到resultMap的信息的。
我们来看看MyBatis是怎么做的:
private void buildStatementFromContext(List
list, String requiredDatabaseId) { for (XNode context : list) { final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId); try { //解析xml节点 statementParser.parseStatementNode(); } catch (IncompleteElementException e) { //xml语句有问题时 存储到集合中 等解析完能解析的再重新解析 configuration.addIncompleteStatement(statementParser); } } } 当解析到某个节点出现问题的时候,会抛一个异常,然后会调用configuration的addIncompleteStatement方法,将这个解析对象先暂存到这个集合中,等到所有的节点都解析完毕之后,在对这个集合内的解析对象继续解析:
public void parse() { //判断文件是否之前解析过 if (!configuration.isResourceLoaded(resource)) { //解析mapper文件 configurationElement(parser.evalNode("/mapper")); configuration.addLoadedResource(resource); //绑定Namespace里面的Class对象 bindMapperForNamespace(); } //重新解析之前解析不了的节点 parsePendingResultMaps(); parsePendingCacheRefs(); parsePendingStatements(); } private void parsePendingResultMaps() { Collection
incompleteResultMaps = configuration.getIncompleteResultMaps(); synchronized (incompleteResultMaps) { Iterator iter = incompleteResultMaps.iterator(); while (iter.hasNext()) { try { //添加resultMap iter.next().resolve(); iter.remove(); } catch (IncompleteElementException e) { // ResultMap is still missing a resource... } } } } public ResultMap resolve() { //添加resultMap return assistant.addResultMap(this.id, this.type, this.extend, this.discriminator, this.resultMappings, this.autoMapping); }
结语
至此整个MyBatis的查询前构建的过程就基本说完了,简单地总结就是,MyBatis会在执行查询之前,对配置文件进行解析成配置对象:Configuration,以便在后面执行的时候去使用,而存放SQL的xml又会解析成MappedStatement对象,但是最终这个对象也会加入Configuration中,至于Configuration是如何被使用的,以及SQL的执行部分,我会在下一篇说SQL执行的时候分享。
欢迎大家访问我的个人博客:Object's Blog