在执行 SQL 之前,需要将 SQL 语句完整的解析出来。我们都知道 SQL 是配置在映射文 件中的,但由于映射文件中的 SQL 可能会包含占位符#{},以及动态 SQL 标签,比如、 等。
因此,我们并不能直接使用映射文件中配置的 SQL。MyBatis 会将映射文件中的 SQL解析成一组 SQL 片段。如果某个片段中也包含动态SQL相关的标签,那么,MyBatis会对该片段再次进行分片。最终,一个 SQL 配置将会被解析成一个 SQL 片段树。
需要对片段树进行解析,以便从每个片段对象中获取相应的内容。然后将这些内容 组合起来即可得到一个完成的 SQL 语句,这个完整的 SQL 以及其他的一些信息最终会存储 在 BoundSql 对象中。
成员变量信息 1 2 3 4 5 private final String sql; private final List<ParameterMapping> parameterMappings; private final Object parameterObject; private final Map<String, Object> additionalParameters; private final MetaObject metaParameters;
含义
变量名
类型
用途
sql
String
一个完整的 SQL 语句,可能会包含问号 ? 占位符
parameterMappings
List
参数映射列表,SQL 中的每个 #{xxx} 占位符都会被解析成相应的 ParameterMapping 对象
parameterObject
Object
运行时参数,即用户传入的参数,比如 Article对象,或是其他的参数
additionalParameters
Map
附加参数集合,用于存储一些额外的信息,比如 datebaseId 等
metaParameters
MetaObject
additionalParameters 的元信息对象
第一站是 MappedStatement 的 getBoundSql 方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // -☆- MappedStatement public BoundSql getBoundSql(Object parameterObject) { // 调用 sqlSource 的 getBoundSql 获取 BoundSql BoundSql boundSql = sqlSource.getBoundSql(parameterObject); List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); if (parameterMappings == null || parameterMappings.isEmpty()) { // 创建新的 BoundSql,这里的 parameterMap 是 ParameterMap 类型。 // 由<ParameterMap> 节点进行配置,该节点已经废弃,不推荐使用。 // 默认情况下,parameterMap.getParameterMappings() 返回空集合 boundSql = new BoundSql(configuration, boundSql.getSql(), parameterMap.getParameterMappings(), parameterObject); } // 省略不重要的逻辑 return boundSql; }
MappedStatement 的 getBoundSql 在内部调用了 SqlSource 实现类的 getBoundSql 方法。
SqlSource 是一个接口,它有如下几个实现类:
DynamicSqlSource
RawSqlSource
StaticSqlSource
ProviderSqlSource
VelocitySqlSource
首先我们把最后两个排除掉,不常用。剩下的三个实现类中,仅前两个实现类会在映射文件解析的过程中被使用。
当SQL 配置中包含${}(不是#{})占位符,或者包含、等标签时,会被认为是 动态 SQL,此时使用 DynamicSqlSource 存储 SQL 片段。
否则,使用 RawSqlSource 存储 SQL配置信息。
相比之下 DynamicSqlSource存储的SQL片段类型较多,解析起来也更为复杂一些。因此下面我将分析 DynamicSqlSource 的 getBoundSql 方法。弄懂这个,RawSqlSource也不在话下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // -☆- DynamicSqlSource public BoundSql getBoundSql(Object parameterObject) { // 创建 DynamicContext DynamicContext context = new DynamicContext(configuration, parameterObject); // 解析 SQL 片段,并将解析结果存储到 DynamicContext 中 rootSqlNode.apply(context); SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass(); // 构建 StaticSqlSource,在此过程中将 sql 语句中的占位符 #{} 替换为问号 ?, // 并为每个占位符构建相应的 ParameterMapping SqlSource sqlSource = sqlSourceParser.parse( context.getSql(), parameterType, context.getBindings()); // 调用 StaticSqlSource 的 getBoundSql 获取 BoundSql BoundSql boundSql = sqlSource.getBoundSql(parameterObject); // 将 DynamicContext 的 ContextMap 中的内容拷贝到 BoundSql 中 for(Map.Entry<String, Object> entry : context.getBindings().entrySet()){ boundSql.setAdditionalParameter(entry.getKey(), entry.getValue()); } return boundSql; }
DynamicSqlSource 的 getBoundSql 方法的代码看起来不多,但是逻辑却并不简单。 该方法由数个步骤组成,这里总结一下:
创建 DynamicContext
解析 SQL 片段,并将解析结果存储到 DynamicContext 中
解析 SQL 语句,并构建 StaticSqlSource
调用 StaticSqlSource 的 getBoundSql 获取 BoundSql
将 DynamicContext 的 ContextMap 中的内容拷贝到 BoundSql 中
DynamicContext DynamicContext 是 SQL 语句构建的上下文,每个 SQL 片段解析完成后,都会将解析结 果存入 DynamicContext 中。待所有的SQL片段解析完毕后,一条完整的SQL语句就会出现在 DynamicContext 对象中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class DynamicContext { public static final String PARAMETER_OBJECT_KEY = "_parameter"; public static final String DATABASE_ID_KEY = "_databaseId"; private final ContextMap bindings; private final StringBuilder sqlBuilder = new StringBuilder(); public DynamicContext( Configuration configuration, Object parameterObject) { // 创建 ContextMap if (parameterObject != null && !(parameterObject instanceof Map)) { MetaObject metaObject = configuration.newMetaObject(parameterObject); bindings = new ContextMap(metaObject); } else { bindings = new ContextMap(null); } // 存放运行时参数 parameterObject 以及 databaseId bindings.put(PARAMETER_OBJECT_KEY, parameterObject); bindings.put(DATABASE_ID_KEY, configuration.getDatabaseId()); } }
sqlBuilder 变量用于存放 SQL 片段的解析结果
bindings 则用于存储一些额外的信息,比如运行时参数和 databaseId 等。
bindings类型为 ContextMap,ContextMap 定义在 DynamicContext 中
是一个静态内部类。该类继承自 HashMap,并覆写了 get 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 static class ContextMap extends HashMap<String, Object> { private MetaObject parameterMetaObject; public ContextMap(MetaObject parameterMetaObject) { this.parameterMetaObject = parameterMetaObject; } @Override public Object get(Object key) { String strKey = (String) key; // 检查是否包含 strKey,若包含则直接返回 if (super.containsKey(strKey)) { return super.get(strKey); } if (parameterMetaObject != null) { // 从运行时参数中查找结果 return parameterMetaObject.getValue(strKey); } return null; } }
DynamicContext 对外提供了两个接口,用于操作 sqlBuilder。
1 2 3 4 5 6 7 8 public void appendSql(String sql) { sqlBuilder.append(sql); sqlBuilder.append(" "); } public String getSql() { return sqlBuilder.toString().trim(); }
解析 SQL ⽚段 对于一个包含了${}占位符,或、等标签的 SQL,在解析的过程中,会被分解 成多个片段。每个片段都有对应的类型,每种类型的片段都有不同的解析逻辑。
在源码中,片段这个概念等价于sql节点,即SqlNode。SqlNode是一个接口,它有众多的实现类。其继承体系如下:
StaticTextSqlNode 用于存储静态文本
TextSqlNode 用于存储带有${}占位符的文本
IfSqlNode 则用于存储节点的内容
MixedSqlNode 内部维护了一个 SqlNode集合,用于存储各种各样的 SqlNode
MixedSqlNode 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class MixedSqlNode implements SqlNode { private final List<SqlNode> contents; public MixedSqlNode(List<SqlNode> contents) { this.contents = contents; } @Override public boolean apply(DynamicContext context) { // 遍历 SqlNode 集合 for (SqlNode sqlNode : contents) { // 调用 salNode 对象本身的 apply 方法解析 sql sqlNode.apply(context); } return true; } }
MixedSqlNode 可以看做是 SqlNode 实现类对象的容器,凡是实现了 SqlNode 接口的类 都可以存储到 MixedSqlNode 中,包括它自己。MixedSqlNode 解析方法 apply 逻辑比较简单,即遍历 SqlNode 集合,并调用其他 SqlNode 实现类对象的 apply 方法解析 sql。
StaticTextSqlNode 1 2 3 4 5 6 7 8 9 10 11 12 public class StaticTextSqlNode implements SqlNode { private final String text; public StaticTextSqlNode(String text) { this.text = text; } @Override public boolean apply(DynamicContext context) { context.appendSql(text); return true; } }
StaticTextSqlNode 用于存储静态文本,所以它不需要什么解析逻辑,直接将其存储的 SQL 片段添加到 DynamicContext 中即可。
TextSqlNode 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public class TextSqlNode implements SqlNode { private final String text; private final Pattern injectionFilter; @Override public boolean apply(DynamicContext context) { // 创建 ${} 占位符解析器 GenericTokenParser parser = createParser( new BindingTokenParser(context, injectionFilter)); // 解析 ${} 占位符,并将解析结果添加到 DynamicContext 中 context.appendSql(parser.parse(text)); return true; } private GenericTokenParser createParser(TokenHandler handler) { // 创建占位符解析器,GenericTokenParser 是一个通用解析器, // 并非只能解析 ${} 占位符 return new GenericTokenParser("${", "}", handler); } private static class BindingTokenParser implements TokenHandler { private DynamicContext context; private Pattern injectionFilter; public BindingTokenParser( DynamicContext context, Pattern injectionFilter) { this.context = context; this.injectionFilter = injectionFilter; } @Override public String handleToken(String content) { Object parameter = context.getBindings().get("_parameter"); if (parameter == null) { context.getBindings().put("value", null); }else if(SimpleTypeRegistry.isSimpleType(parameter.getClass())){ context.getBindings().put("value", parameter); } // 通过 ONGL 从用户传入的参数中获取结果 Object value = OgnlCache.getValue(content, context.getBindings()); String srtValue = (value == null ? "" : String.valueOf(value)); // 通过正则表达式检测 srtValue 有效性 checkInjection(srtValue); return srtValue; } } }
GenericTokenParser 是一个通用的标记解析器,用于解析形如${xxx},#{xxx}等标记 。GenericTokenParser负责将标记中的内容抽取出来,并将标记内容交给相应的TokenHandler 去处理。
BindingTokenParser 负责解析标记内容,并将解析结果返回给GenericTokenParser,用于替换${xxx}标记。
实例 1 2 3 SELECT * FROM article WHERE author = '${author}' SELECT * FROM article WHERE author = 'tianxiaobo' // 解析后
一般情况下,使用 {author}时就会出现灾难性问题——SQL 注入。比如我们构建这样一个参数 author=tianxiaobo’;DELETE FROM article;#,然后我们把这个参数传给 TextSqlNode 进行解析。得到的结果如下
1 SELECT * FROM article WHERE author = 'tianxiaobo'; DELETE FROM article;#'
由于传入的参数没有经过转义,最终导致了一条SQL被恶意参数拼接成了两条SQL。更要命的是,第二天SQL会把article表的数据清空,这个后果就很严重了(从删库到跑路)。这就是为什么我们不应该在 SQL 语句中是用${}占位符,风险太大。
IfSqlNode 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class IfSqlNode implements SqlNode { private final ExpressionEvaluator evaluator; private final String test; private final SqlNode contents; public IfSqlNode(SqlNode contents, String test) { this.test = test; this.contents = contents; this.evaluator = new ExpressionEvaluator(); } @Override public boolean apply(DynamicContext context) { // 通过 ONGL 评估 test 表达式的结果 if (evaluator.evaluateBoolean(test, context.getBindings())) { // 若 test 表达式中的条件成立,则调用其他节点的 apply 方法进行解析 contents.apply(context); return true; } return false; } }
IfSqlNode 的 apply 方法逻辑并不复杂,首先是通过 ONGL 检测 test 表达式是否为 true,如果为 true,则调用其他节点的 apply方法继续进行解析。
需要注意的是节点中也可嵌套其他的动态节点,并非只有纯文本。因此 contents 变量遍历指向的是 MixedSqlNode,而非 StaticTextSqlNode。
WhereSqlNode 1 2 3 4 5 6 7 8 9 10 public class WhereSqlNode extends TrimSqlNode { /** 前缀列表 */ private static List<String> prefixList = Arrays.asList( "AND ", "OR ", "AND\n", "OR\n", "AND\r", "OR\r", "AND\t", "OR\t"); public WhereSqlNode(Configuration configuration, SqlNode contents) { // 调用父类的构造方法 super(configuration, contents, "WHERE", prefixList, null, null); } }
WhereSqlNode 和 SetSqlNode 都是基于 TrimSqlNode 实现的
TrimSqlNode 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class TrimSqlNode implements SqlNode { private final SqlNode contents; private final String prefix; private final String suffix; private final List<String> prefixesToOverride; private final List<String> suffixesToOverride; private final Configuration configuration; @Override public boolean apply(DynamicContext context) { // 创建具有过滤功能的 DynamicContext FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context); // 解析节点内容 boolean result = contents.apply(filteredDynamicContext); // 过滤掉前缀和后缀 filteredDynamicContext.applyAll(); return result; } }
apply 方法首选调用了其他SqlNode的apply方法解析节点内容,这步操作完成后,FilteredDynamicContext中会得到一条SQL片段字符串。接下里需要做的事情是过滤字符串前缀后和后缀,并添加相应的前缀和后缀。
这个事情由 FilteredDynamicContext 负责,FilteredDynamicContext 是 TrimSqlNode 的私有内部类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 private class FilteredDynamicContext extends DynamicContext { private DynamicContext delegate; /** 构造方法会将下面两个布尔值置为 false */ private boolean prefixApplied; private boolean suffixApplied; private StringBuilder sqlBuffer; public void applyAll() { sqlBuffer = new StringBuilder(sqlBuffer.toString().trim()); String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH); if (trimmedUppercaseSql.length() > 0) { // 引用前缀和后缀,也就是对 sql 进行过滤操作,移除掉前缀或后缀 applyPrefix(sqlBuffer, trimmedUppercaseSql); applySuffix(sqlBuffer, trimmedUppercaseSql); } // 将当前对象的 sqlBuffer 内容添加到代理类中 delegate.appendSql(sqlBuffer.toString()); } private void applyPrefix(StringBuilder sql, String trimmedUppercaseSql){ if (!prefixApplied) { // 设置 prefixApplied 为 true,以下逻辑仅会被执行一次 prefixApplied = true; if (prefixesToOverride != null) { for (String toRemove : prefixesToOverride) { // 检测当前 sql 字符串是否包含前缀,比如 'AND ', 'AND\t'等 if (trimmedUppercaseSql.startsWith(toRemove)) { // 移除前缀 sql.delete(0, toRemove.trim().length()); break; } } } // 插入前缀,比如 WHERE if (prefix != null) { sql.insert(0, " "); sql.insert(0, prefix); } } } // 该方法逻辑与 applyPrefix 大同小异 private void applySuffix(StringBuilder sql, String trimmedUppercaseSql) { if (!suffixApplied) { suffixApplied = true; if (suffixesToOverride != null) { for (String toRemove : suffixesToOverride) { if (trimmedUppercaseSql.endsWith(toRemove) || trimmedUppercaseSql.endsWith(toRemove.trim())) { int start = sql.length() - toRemove.trim().length(); int end = sql.length(); sql.delete(start, end); break; } } } if (suffix != null) { sql.append(" "); sql.append(suffix); } } } }
applyAll 方法的逻辑比较简单,首先从 sqlBuffer 中获取 SQL 字符串。然后调用 applyPrefix和 applySuffix进行过滤操作。最后将过滤后的SQL字符串添加到被装饰的类中
applyPrefix方法会首先检测SQL字符串是不是以”AND”,”OR”,或”AND\n”,”OR\n”等前缀开头,若是则将前缀从 sqlBuffer中移除。然后将前缀插入到sqlBuffer的首部,整个逻辑就结束了。
解析#{}占位符 经过前面的解析,我们已经能从 DynamicContext 获取到完整的 SQL 语句了。但这并不 意味着解析过程就结束了,因为当前的SQL语句中还有一种占位符没有处理,即#{}。与${}占位符的处理方式不同,MyBatis 并不会直接将#{}占位符替换为相应的参数值。
#{}占位符的解析逻辑是包含在 SqlSourceBuilder 的 parse 方法中,该方法最终会将解析 后的 SQL以及其他的一些数据封装到StaticSqlSource 中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 // -☆- SqlSourceBuilder public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) { // 创建 #{} 占位符处理器 ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler( configuration, parameterType, additionalParameters); // 创建 #{} 占位符解析器 GenericTokenParser parser = new GenericTokenParser("#{", "}", handler); // 解析 #{} 占位符,并返回解析结果 String sql = parser.parse(originalSql); // 封装解析结果到 StaticSqlSource 中,并返回 return new StaticSqlSource( configuration, sql, handler.getParameterMappings()); }
重点关注#{}占位符处理器 ParameterMappingTokenHandler 的逻辑。
1 2 3 4 5 6 public String handleToken(String content) { // 获取 content 的对应的 ParameterMapping parameterMappings.add(buildParameterMapping(content)); // 返回 ? return "?"; }
ParameterMappingTokenHandler 的 handleToken 方法看起来比较简单,但实际上并非如 此。
GenericTokenParser 负责将#{}占位符中的内容抽取出来,并将抽取出的内容传给 handleToken 方法。handleToken 方法负责将传入的参数解析成对应的 ParameterMapping 对象,这步操作由 buildParameterMapping 方法完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 private ParameterMapping buildParameterMapping(String content) { /* * 将 #{xxx} 占位符中的内容解析成 Map。大家可能很好奇一个普通的字符串是怎么 * 解析成 Map 的,举例说明一下。如下: * * #{age,javaType=int,jdbcType=NUMERIC,typeHandler=MyTypeHandler} * * 上面占位符中的内容最终会被解析成如下的结果: * * { * "property": "age", * "typeHandler": "MyTypeHandler", * "jdbcType": "NUMERIC", * "javaType": "int" * } * * parseParameterMapping 内部依赖 ParameterExpression 对字符串进行解析, * ParameterExpression 的逻辑不是很复杂,这里就不分析了。 */ Map<String, String> propertiesMap = parseParameterMapping(content); String property = propertiesMap.get("property"); Class<?> propertyType; // metaParameters 为 DynamicContext 成员变量 bindings 的元信息对象 if (metaParameters.hasGetter(property)) { propertyType = metaParameters.getGetterType(property); /* * parameterType 是运行时参数的类型。如果用户传入的是单个参数,比如 Article * 对象,此时 parameterType 为 Article.class。如果用户传入的多个参数,比如 * [id = 1, author = "coolblog"],MyBatis 会使用 ParamMap 封装这些参数, * 此时 parameterType 为 ParamMap.class。如果 parameterType 有相应的 * TypeHandler,这里则把 parameterType 设为 propertyType */ } else if (typeHandlerRegistry.hasTypeHandler(parameterType)) { propertyType = parameterType; } else if(JdbcType.CURSOR.name().equals(propertiesMap.get("jdbcType"))){ propertyType = java.sql.ResultSet.class; } else if (property == null||Map.class.isAssignableFrom(parameterType)){ // 如果 property 为空,或 parameterType 是 Map 类型, // 则将 propertyType 设为 Object.class propertyType = Object.class; } else { // 代码逻辑走到此分支中,表明 parameterType 是一个自定义的类, // 比如 Article,此时为该类创建一个元信息对象 MetaClass metaClass = MetaClass.forClass( parameterType, configuration.getReflectorFactory()); // 检测参数对象有没有与 property 想对应的 getter 方法 if (metaClass.hasGetter(property)) { // 获取成员变量的类型 propertyType = metaClass.getGetterType(property); } else { propertyType = Object.class; } } // -------------------------- 分割线 --------------------------- ParameterMapping.Builder builder = new ParameterMapping.Builder( configuration, property, propertyType); // 将 propertyType 赋值给 javaType Class<?> javaType = propertyType; String typeHandlerAlias = null; // 遍历 propertiesMap for (Map.Entry<String, String> entry : propertiesMap.entrySet()) { String name = entry.getKey(); String value = entry.getValue(); if ("javaType".equals(name)) { // 如果用户明确配置了 javaType,则以用户的配置为准 javaType = resolveClass(value); builder.javaType(javaType); } else if ("jdbcType".equals(name)) { // 解析 jdbcType builder.jdbcType(resolveJdbcType(value)); } else if ("mode".equals(name)) {...} else if ("numericScale".equals(name)) {...} else if ("resultMap".equals(name)) {...} else if ("typeHandler".equals(name)) { typeHandlerAlias = value; } else if ("jdbcTypeName".equals(name)) {...} else if ("property".equals(name)) {...} else if ("expression".equals(name)) { throw new BuilderException("……"); } else { throw new BuilderException("……"); } } if (typeHandlerAlias != null) { // 解析 TypeHandler builder.typeHandler(resolveTypeHandler(javaType, typeHandlerAlias)); } // 构建 ParameterMapping 对象 return builder.build(); }
buildParameterMapping 代码很多,逻辑看起来很复杂。但是它做的事情却不是很 多,只有 3 件事情。如下:
解析 content
解析 propertyType,对应分割线之上的代码
构建 ParameterMapping 对象,对应分割线之下的代码
实例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class SqlSourceBuilderTest { @Test public void test() { // 带有复杂 #{} 占位符的参数,接下里会解析这个占位符 String sql = "SELECT * FROM Author WHERE age =" + "#{age,javaType=int,jdbcType=NUMERIC}"; SqlSourceBuilder sqlSourceBuilder = new SqlSourceBuilder(new Configuration()); SqlSource sqlSource = sqlSourceBuilder.parse( sql, Author.class, new HashMap<>()); BoundSql boundSql = sqlSource.getBoundSql(new Author()); System.out.println(String.format("SQL: %s\n", boundSql.getSql())); System.out.println(String.format( "ParameterMappings: %s", boundSql.getParameterMappings())); } } public class Author { private Integer id; private String name; private Integer age; // 省略 getter/setter }
SQL 中的#{age,…}占位符被替换成了问号?。#{age,…}也被解析成了一个 ParameterMapping 对象。
StaticSqlSource 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class StaticSqlSource implements SqlSource { private final String sql; private final List<ParameterMapping> parameterMappings; private final Configuration configuration; public StaticSqlSource(Configuration configuration, String sql) { this(configuration, sql, null); } public StaticSqlSource(Configuration configuration, String sql, List<ParameterMapping> parameterMappings) { this.sql = sql; this.parameterMappings = parameterMappings; this.configuration = configuration; } @Override public BoundSql getBoundSql(Object parameterObject) { // 创建 BoundSql 对象 return new BoundSql( configuration, sql, parameterMappings, parameterObject); } }