关注公众号【1024个为什么】,及时接收最新推送文章!
本篇文章基于 MySQL Connector/J 5.1.40, clientPrepareStatement 模式。
写前 2 篇文章看源码时,发现 PrepareStatement 替换问号的方案比想象的要复杂、高效,总结一下加深印象,以后有类似的场景也可以借用此方案。
从一个带问号的 SQL 语句到一个正常的带真实参数的 SQL 语句,可以分为下图中的三步。
本次就以下面的 SQL 语句为例,看一下替换问号的过程。
SELECT * FROM t1 WHERE id=? AND status=? ORDER BY id DESC LIMIT 10;
| 第一步,把动态 SQL(带问号的) 拆成静态 SQL 段
这一步的触发时机是获取 PreparedStatement 对象时
PreparedStatement ps = conn.prepareStatement(sql);
拆后的信息会被封装在 PerseInfo 中,PerseInfo 是 PreparedStatement 的内部类
而真正拆的过程,是在 PerseInfo 的构造方法中,拆分的结果会存到 2 个数组中,endpointList、staticSql,就是图中框 ① 里的。
通过源码看一下拆分过程
for (i = this.statementStartPos; i < this.statementLength; ++i) {
char c = sql.charAt(i);
...
if ((c == '?') && !inQuotes && !inQuotedId) {
// 添加静态 SQL 段的起止下标
endpointList.add(new int[] { lastParmEnd, i });
lastParmEnd = i + 1;
}
...
// 添加最后一个问号后的静态 SQL 段的起止下标
endpointList.add(new int[] { lastParmEnd, this.statementLength });
this.staticSql = new byte[endpointList.size()][];
for (i = 0; i < this.staticSql.length; i++) {
int[] ep = endpointList.get(i);
int end = ep[1];
int begin = ep[0];
int len = end - begin;
...
this.staticSql[i] = StringUtils.getBytes(sql, encoding, conn.getServerCharset(), begin, len, conn.parserKnowsUnicode(), conn,
conn.getExceptionInterceptor());
可以看到, SQL 语句是按字符的粒度遍历,找到 '?' ,就把 lastParmEnd (初始值=0)和当前下标添加到 endpointList 中,lastParmEnd + 1 ,继续遍历,直到结束。遍历结束后,把最后一个问号后面的内容也当做一个静态 SQL 段加到 endpointList 中。
简单来说,带有 n 个问号的 SQL 语句, 会被拆分成 n + 1 个静态 SQL 段(不含问号的内容)。
然后遍历 endpointList,根据里面记录的下标,从原始 SQL 中截取出对应的静态 SQL 段。
这 2 个数组的数据拿到后,就能知道有多少个参数了,接下来就会初始化存放参数的数组,为下一步设置参数铺好路。
com.mysql.jdbc.PreparedStatement#initializeFromParseInfo
this.parameterValues = new byte[this.parameterCount][]
this.parameterTypes = new int[this.parameterCount];
下面主要会用到这 2 个。
| 第二步,设置参数
触发时机是
ps.setInt(1,666);
ps.setInt(2,5);
这里会把所有参数内容先转换成字符串再转换成 byte 后,放到第一步初始化好的 parameterValues 中
protected final void setInternal(int paramIndex, byte[] val) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
int parameterIndexOffset = getParameterIndexOffset();
checkBounds(paramIndex, parameterIndexOffset);
this.isStream[paramIndex - 1 + parameterIndexOffset] = false;
this.isNull[paramIndex - 1 + parameterIndexOffset] = false;
this.parameterStreams[paramIndex - 1 + parameterIndexOffset] = null;
this.parameterValues[paramIndex - 1 + parameterIndexOffset] = val;
}
}
同时也会设置好每个参数对应的真实的类型。
this.parameterTypes[parameterIndex - 1 + getParameterIndexOffset()] = Types.INTEGER;
这里注意一下,源码中的下标都做了减 1 的处理,这也是为什么我们设置参数时,传入的下标从 1 开始,而内部都是数组,下标从 0 开始。
| 第三步,执行时,才组装真正的 SQL
触发时机是
ResultSet resultSet = ps.executeQuery();
内部会经过这个方法 com.mysql.jdbc.PreparedStatement#fillSendPacket(),它里面会把参数和第一步里的静态 SQL 段组装到一起。
组装过程如下
for (int i = 0; i < batchedParameterStrings.length; i++) {
checkAllParametersSet(batchedParameterStrings[i], batchedParameterStreams[i], i);
sendPacket.writeBytesNoNull(this.staticSqlStrings[i]);
if (batchedIsStream[i]) {
streamToBytes(sendPacket, batchedParameterStreams[i], true, batchedStreamLengths[i], useStreamLengths);
} else {
sendPacket.writeBytesNoNull(batchedParameterStrings[i]);
}
}
sendPacket.writeBytesNoNull(this.staticSqlStrings[batchedParameterStrings.length]);
会遍历参数信息,拼装好参数后,遍历结束后再拼上最后一个静态 SQL 段。
这里说是拼装,其实操作的都是 byte 数组,直接往 byte 数组中轮流交替放置静态 SQL 段、参数内容。
到这里就得到了带有真实参数内容的一个完整的 SQL,可以直接发到服务端执行了。
| 总结一下
整个过程,就遍历了一次原始 SQL,时间复杂度是 O(SQL.length)。而且这个遍历拆分过程,还可以缓存,相同的 SQL 就不用重复遍历拆分了。
后面 2 次遍历,时间复杂度都是 O(参数个数.length),用最少的遍历解决问题。
原创不易,如有收获,一键三连,感谢支持!
相关阅读: