ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MyBatis GenericTokenParser 源码解析:占位符替换与 SQL 预处理的核心引擎

MyBatis GenericTokenParser 源码解析:占位符替换与 SQL 预处理的核心引擎 MyBatis GenericTokenParser 源码解析占位符替换与 SQL 预处理的核心引擎【免费下载链接】source-code-hunter 从源码层面剖析挖掘互联网行业主流技术的底层实现原理为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶Mybatis、Netty、Dubbo 框架及 Redis、Tomcat 中间件等项目地址: https://gitcode.com/GitHub_Trending/so/source-code-hunter导读GenericTokenParser是 MyBatis 解析包org.apache.ibatis.parsing中最核心的通用标记解析器它负责把 SQL 文本中的${}、#{}等占位符按开始标记 结束标记的模式扫描、切分并交给TokenHandler回调替换。在 MyBatis 中#{}参数占位符正是通过它被统一替换为 JDBC 预编译的?从而支撑起整个预处理 SQLPreparedStatement体系。读完本文你将掌握 GenericTokenParser 的完整源码逻辑含转义处理、缺省结束标记等边界场景理解TokenHandler回调设计并透过SqlSourceBuilder与PropertyParser看清它在参数映射和配置属性解析中的真实应用链路。1 GenericTokenParser 的定位解析包里的通用解析器MyBatis 的parsing包位于org.apache.ibatis.parsing其中包含GenericTokenParser通用标记解析器、TokenHandler标记处理器接口、XPathParser基于 XPath 的 XML 解析器、PropertyParser配置属性解析器等。GenericTokenParser之所以叫通用是因为它不关心标记内部的语义——它只负责根据构造时传入的openToken开始标记与closeToken结束标记扫描目标文本把每次命中区间内的内容即expression抽取出来将抽取结果交给TokenHandler#handleToken(String)回调由调用方决定如何替换。这种扫描 回调的解耦设计让同一个解析器可以服务于多处不同语义的场景替换#{}为?、替换${}为属性值、甚至替换配置占位符而无需重复编写扫描逻辑。这与 Spring 中PropertyPlaceholderHelper见 Spring-PropertyPlaceholderHelper.md的设计思路一脉相承。2 类结构与核心字段package org.apache.ibatis.parsing; /** * author Clinton Begin */ public class GenericTokenParser { /** * 开始标记 */ private final String openToken; /** * 结束标记 */ private final String closeToken; /** * 标记处理器 */ private final TokenHandler handler; public GenericTokenParser(String openToken, String closeToken, TokenHandler handler) { this.openToken openToken; this.closeToken closeToken; this.handler handler; } ... }三个核心字段全部在构造方法中注入均为final这意味着解析器的行为在创建后不可变字段类型含义典型取值openTokenString开始标记#{、${closeTokenString结束标记}handlerTokenHandler标记处理器负责把命中的表达式转换为最终输出ParameterMappingTokenHandler、VariableTokenHandler其中TokenHandler是解析包中的接口仅定义了一个方法public interface TokenHandler { String handleToken(String content); }parse(String text)是唯一的对外入口方法返回替换后的完整文本。MyBatis 官方源码的测试类org.apache.ibatis.parsing.GenericTokenParserTest对该方法的行为做了详尽的覆盖验证原文档注释中亦指向该测试类。3 parse() 方法源码逐行解析3.1 空值守卫与快速返回public String parse(String text) { // 判断是否空 if (text null || text.isEmpty()) { return ; } // search open token // 判断 openToken 所在的位置-1不存在 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; ... }两个重要的快速返回分支入参text为null或空字符串时直接返回注意null入参不会抛空指针而是返回空串整个文本中找不到openTokenindexOf返回-1时说明没有任何标记需要处理原样返回文本零开销。通过快速返回后代码将文本转为字符数组src并初始化三个游标/缓冲变量offset当前扫描的偏移位置用于从src中截取未处理区段builder累积最终结果的StringBuilderexpression复用型的StringBuilder用于累积两次标记之间的表达式内容之所以在循环外创建、循环内setLength(0)复用是为了避免频繁创建对象。3.2 主循环定位开始标记// 循环处理 assertEquals(James T Kirk reporting., parser.parse(${first_name} ${initial} ${last_name} reporting.)); // 将${} 转换成正常文本 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 重新计算进行下一步循环 offset start openToken.length(); } else { ... } start text.indexOf(openToken, offset); }start初始为text.indexOf(openToken)循环条件start -1表示还有下一个开始标记。每次迭代末尾通过text.indexOf(openToken, offset)从新的offset位置继续向后查找直到找不到为止。转义分支src[start - 1] \\当开始标记前一个字符是反斜杠\时说明该标记是被显式转义的不应触发替换。此时把offset到start - 1去掉反斜杠之间的内容追加进builder再追加一个原始openToken\${最终输出为${将offset推进到start openToken.length()跳过被转义的标记继续扫描。这样处理后\${name}这样的文本最终会原样保留为${name}输出而不会触发handleToken。3.3 非转义分支查找结束标记} else { // found open token. lets 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重新推算替换的字符串 offset end closeToken.length(); end text.indexOf(closeToken, offset); } else { expression.append(src, offset, end - offset); break; } } if (end -1) { // end -1 closeToken 不存在,获取后面的所有字符串, openToken - closeToken 之间的内容 // close token was not found. builder.append(src, start, src.length - start); offset src.length; } else { // closeToken存在 继续执行 builder.append(handler.handleToken(expression.toString())); offset end closeToken.length(); } }非转义分支的处理逻辑分四步复用/重置 expression首次进入时创建StringBuilder后续进入时setLength(0)清空复用累积标记前文本把offset到start之间的普通文本追加进builder并把offset推进到start openToken.length()即跳过开始标记从新 offset 向后找结束标记int end text.indexOf(closeToken, offset)。若找到且结束标记前一个字符是\即end offset src[end - 1] \\说明结束标记也被转义此时把offset到end - 1之间的内容追加进expression再补一个原始closeToken然后继续向后寻找真正的结束标记——这一层 while 循环专门处理表达式内部包含被转义的结束标记的情况根据 end 是否找到收尾end -1结束标记缺失。此时把从start注意是开始标记的位置到文本末尾的全部内容原样追加进builder并将offset置为src.length。这样#${name这类不完整的标记会以原始文本的形式保留下来不会报错也不会丢失内容end ! -1把两次标记之间的expression.toString()交给handler.handleToken(...)将其返回值追加进builder并把offset推进到end closeToken.length()跳过整个标记区间。3.4 收尾与返回值if (offset src.length) { builder.append(src, offset, src.length - offset); } // 返回的是一个替换后的sql脚本 return builder.toString();主循环结束后若offset仍未到达文本末尾说明还有一段尾随的普通文本例如最后一个标记之后的内容需要追加进builder。最终builder.toString()就是完成全部标记替换后的完整文本。3.5 边界场景小结场景处理方式输出效果text为null或空直接返回空串文本中无开始标记原样返回原文开始标记前有\去掉反斜杠原样保留标记\${a}→${a}结束标记前有\表达式内保留转义结束标记继续向后找真正的结束标记表达式内容不截断找到开始标记但找不到结束标记从开始标记位置起原样保留到末尾不丢失文本、不报错正常闭合表达式交给handleToken结果替换进builder完成替换4 真实应用一SqlSourceBuilder 把#{}变成?GenericTokenParser最典型的生产者就是org.apache.ibatis.builder.SqlSourceBuilder。MyBatis 在构建MappedStatement时会先把映射文件中的 SQL 语句解析成SqlSource而#{}占位符正是在这一过程中被替换为 JDBC 预编译占位符?同时每个占位符对应的参数元数据ParameterMapping被记录下来供后续PreparedStatement绑定参数使用。4.1 ParameterMappingTokenHandlerSqlSourceBuilder内部维护了ParameterMappingTokenHandler它实现了TokenHandler接口其handleToken方法就是?的来源/** * ? 的来源 * * param content * return */ Override public String handleToken(String content) { parameterMappings.add(buildParameterMapping(content)); return ?; }每次命中一个#{}表达式就根据表达式内容如id,jdbcTypeINTEGER构建一个ParameterMapping加入集合然后返回?。最终 SQL 中的每个#{}都对应集合中的一个ParameterMapping二者数量一致、顺序一致这也是PreparedStatement能按序绑定参数的根基。4.2 SqlSourceBuilder#parse/** * sql 参数类型 返回值 * * select idselectByPrimaryKey parameterTypejava.lang.Integer resultMapBaseResultMap * !--mbg.generated-- * select * include refidBase_Column_List / * from hs_sell * where ID #{id,jdbcTypeINTEGER} * /select * 替换成问号 * select * * * ID, USER_ID, GOOD_ID, PRICE, SIZE, COMPANY_ID, GROUP_ID, VERSION, DELETED, CREATE_USER, * CREATE_TIME, UPDATE_USER, UPDATE_TIME, WORK_ORDER_ID * * from hs_sell * where ID ? * * param originalSql sql文本 * param parameterType 默认 object * param additionalParameters * return */ public SqlSource parse(String originalSql, Class? parameterType, MapString, Object additionalParameters) { ParameterMappingTokenHandler handler new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters); // org.apache.ibatis.builder.SqlSourceBuilder.ParameterMappingTokenHandler.handleToken GenericTokenParser parser new GenericTokenParser(#{, }, handler); String sql parser.parse(originalSql); return new StaticSqlSource(configuration, sql, handler.getParameterMappings()); }关键点解析器以#{为开始标记、}为结束标记绑定ParameterMappingTokenHandlerparser.parse(originalSql)得到的是替换完成的 SQL#{}全部变为?最终封装成StaticSqlSource携带handler.getParameterMappings()即所有参数映射的集合形成SQL 模板 参数元数据的完整SqlSource。上面的示例中where ID #{id,jdbcTypeINTEGER}经过解析后变成where ID ?同时记录了一条 jdbcType 为INTEGER的ParameterMapping——这正是 MyBatis 动态 SQL 与 JDBC 预编译之间的关键桥梁。如上图调试现场所示原始 SQL 为INSERT INTO person (name, age, phone, email, address) VALUES(#name,#age,#phone,#email,#address)经GenericTokenParser.parse()处理后变为INSERT INTO person (name, age, phone, email, address) VALUES(?,?,?,?,?)调用栈中可以看到SqlSourceBuilder、RawSqlSource、XMLScriptBuilder等 MyBatis 核心类依次参与直观呈现了占位符替换在 SQL 构建流程中的真实位置。5 真实应用二PropertyParser 解析${}配置占位符#{}之外${}在 MyBatis 中同样常见它用于 SQL 字符串直接拼接如动态表名、排序字段。在解析配置属性层面org.apache.ibatis.parsing.PropertyParser也借助GenericTokenParser实现了${key}→ 属性值的替换。其内部定义了VariableTokenHandler作为TokenHandler实现解析器以${、}为标记从 Properties 中取值替换。由此可以看到GenericTokenParser的复用价值同一套扫描替换逻辑通过注入不同的TokenHandler即可服务于#{}参数占位符、${}SQL 拼接、配置属性占位符等多种语义。这也是其被命名为 Generic 的原因。6 在 MyBatis SQL 构建流程中的位置将GenericTokenParser放入整体流程中看它的调用链大致如下XMLMapperBuilder或注解构建解析映射配置时通过XMLScriptBuilder构建SqlSource参见 MyBatis 初始化对于含if、trim等动态节点的 SQL先构建成DynamicSqlSource运行时由SqlNode.apply(DynamicContext)拼装出最终 SQL 文本参见 Mybatis-DyanmicSqlSourcce.md其中and ID #{ID,jdbcTypeINTEGER}即为典型输入得到的 SQL 文本无论来自RawSqlSource还是DynamicSqlSource最终都会交给SqlSourceBuilder#parse由GenericTokenParser完成#{}→?的替换并产出StaticSqlSource后续PreparedStatementHandler根据ParameterMapping列表通过TypeHandler为?依次绑定参数值。也就是说无论 SQL 是静态写死还是动态拼装最终落到 JDBC 层之前都必然经过GenericTokenParser的占位符归一化。这是 MyBatis 支持预编译、防 SQL 注入针对#{}的底层基石之一。7 小结GenericTokenParser是一段简洁而严谨的文本扫描代码其设计要点可以提炼为通用性openToken/closeToken/TokenHandler三者注入一套逻辑服务多种占位符语义健壮性空值守卫、快速返回、转义处理、结束标记缺失时的原样保留边界场景覆盖完整可测试性官方GenericTokenParserTest对各类边界行为有充分断言原文档注释中的assertEquals(James T Kirk reporting., parser.parse(${first_name} ${initial} ${last_name} reporting.))即是典型用例桥接作用通过SqlSourceBuilder完成#{}→?的转换让 MyBatis 的动态 SQL 与 JDBC 预编译机制无缝衔接同时借助PropertyParser覆盖配置属性占位符解析。理解GenericTokenParser就抓住了 MyBatis SQL 预处理链路中从文本到可执行语句的关键一环也为阅读SqlSourceBuilder、DynamicSqlSource、PropertyParser等后续组件打下了坚实基础。【免费下载链接】source-code-hunter 从源码层面剖析挖掘互联网行业主流技术的底层实现原理为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶Mybatis、Netty、Dubbo 框架及 Redis、Tomcat 中间件等项目地址: https://gitcode.com/GitHub_Trending/so/source-code-hunter创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表