技术考古:iBATIS 2.3.4 时代的持久层,是如何用 Map 玩转 307 个存储过程的?

老炮踩坑录 · 技术深挖系列

基于真实企业内网系统持久层实读,5 个 sqlmap 文件、307 个存储过程声明逐项统计

关键词:iBATIS 2.3.4 · SqlMapClient · parameterMap · HashMap 键绑定 · 数据库游标 · 自定义 TypeHandler

👋 欢迎阅读

🏠个人主页: 知守观

📘我的专栏: 老炮踩坑录

💻当前内容:一个 2010 年前后投产的业务系统的持久层

引言

前三篇翻的是配置文件和异常处理。这篇往下一层,看看这些代码是怎么跟数据库打交道的。

2022 年我接手维护这套系统的时候,它已经在生产上跑了十来年。数据访问层用的是 ibatis-sqlmap 2.3.4.726,属于 MyBatis 3 发布之前的 2.3 系列。很多人只在老项目里见过这个 artifact,见过的人也不一定读过它的配置。

我把五个业务 sqlmap 文件从头到尾过了一遍,用脚本统计了 procedure、parameterMap、游标参数、超时设置的数量。数字摆出来,这套持久层的写法和今天的 MyBatis 差得比想象中远。

依赖坐标写 org.apache.ibatis,包名还是 com.ibatis

先看依赖。

xml 复制代码
<dependency>
    <groupId>org.apache.ibatis</groupId>
    <artifactId>ibatis-sqlmap</artifactId>
    <version>2.3.4.726</version>
    <scope>compile</scope>
</dependency>

groupId 是 org.apache.ibatis,跟今天 MyBatis 3 的坐标在同一个命名空间下。光看坐标会以为是同一套东西的不同版本,翻到 import 就清楚了:

go 复制代码
import com.ibatis.sqlmap.engine.type.ObjectTypeHandler;

包名还是 com.ibatis.sqlmap。iBATIS 在 2010 年迁进 Apache 基金会,坐标改了,包名没跟着改。MyBatis 3 是后来整体重写,才把包名挪到 org.apache.ibatis。

这两点放在一起有个副作用:ibatis-sqlmap 和 mybatis 在 Maven 里是两个互不相干的 artifact,坐标不冲突,同一个 classpath 上可以同时装。我在别的项目里见过两套并存的,这个仓库里只有一套,这点我查过依赖树确认。

配置文件:一个 sqlMapConfig,七份 sqlMap,一个全局超时

iBATIS 2 的配置入口叫 sqlMapConfig,MyBatis 3 叫 configuration。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMapConfig PUBLIC "-//iBATIS.com//DTD SQL Map Config 2.0//EN"
    "http://www.ibatis.com/dtd/sql-map-config-2.dtd">
​
<sqlMapConfig>
    <settings defaultStatementTimeout="5" />
    <sqlMap resource="com/example/voice/client/pool/sql/sqlmap_pool.xml" />
    <sqlMap resource="com/example/voice/client/reg/sql/sqlmap_reg.xml" />
    <sqlMap resource="com/example/uc/acct/sql/sqlmap_acct.xml" />
    <sqlMap resource="com/example/uc/oper/sql/sqlmap_oper.xml" />
    <sqlMap resource="com/example/uc/biz/voice/sql/sqlmap_biz.xml" />
    <sqlMap resource="com/example/voice/server/sql/sqlmap_cfg.xml" />
    <sqlMap resource="com/example/voice/server/sql/sqlmap_task.xml" />
</sqlMapConfig>

整个文件十四行。settings 只有一个属性 defaultStatementTimeout="5",单位是秒,作用域是这个 SqlMapClient 下的所有语句。七份 sqlMap 用 resource 挂进来,值是包路径下的 xml 文件。

DTD 的地址指向 www.ibatis.com。这个域名现在还能不能解析,我没试。iBATIS 2 的解析器默认会去取 DTD,项目里我没找到注册本地 EntityResolver 的地方,Spring 的 SqlMapClientFactoryBean 也没传这个参数给它。离线环境启动会不会卡在 DTD 下载上,我不敢下结论。

同一个仓库里还有第二份配置,是核心模块自己用的,结构一样,挂了五份 sqlMap。两份配置指向的业务 xml 有重叠,这个重复后面还要提。

五个文件,307 个存储过程

五个业务 sqlmap 逐行数了一遍:

(sqlmap_pool.xml 和 sqlmap_reg.xml 属于客户端侧,不在这次统计里。)

307 个 <procedure>,307 个 <parameterMap>,数量完全相同。这 307 个 parameterMap 的 class 属性全部是 java.util.HashMap,没有一个用 POJO。

sqlmap_cfg.xml 里一个存储过程都没有,十三个 <statement> 加若干普通查询,全是 SELECT ... AS 别名 的写法:

vbnet 复制代码
<statement id="querySysParams" resultClass="com.example.uc.oper.bsn.parammgt.bean.SysParamInfo">
    SELECT t.CONFIG_ID AS id,
           t.CONFIG_NAME AS configName,
           t.CONFIG_VALUE AS configValue,
           t.CONFIG_DESC AS configDesc
      FROM T_SYS_CONFIG t
     ORDER BY TO_NUMBER(CONFIG_ID)
</statement>

resultClass 写全类名,列的别名必须跟 Java 字段名逐字对齐。这里没有 resultMap,也没有驼峰转换开关。

剩下四个文件里是那 307 个存储过程,加上一个 <update>,再加上十三个 <statement>,就是这个仓库的全部 SQL。业务查询、更新、事务性写操作,绝大多数落在 Oracle 存储过程里,Java 侧负责拼参数、调过程、解结果。

那个唯一的 <update> 值得单独看一眼:

xml 复制代码
<update id="modifyNewUser" parameterClass="UserInfo">
<![CDATA[
    UPDATE T_USER_INFO
       SET NEWUSER_FLAG = #newUserFlag#
     WHERE USER_NO = #userNo#
       AND EXT_NO IS NULL
       AND PART_KEY = SUBSTR(#userNo#, -3, 2)
]]>
</update>

#userNo# 在这条 SQL 里出现两次,一次当等值条件,一次当 SUBSTR 的入参。iBATIS 2 支持这种重复引用,每次出现各占一个参数位。parameterClass 指向一个 POJO,跟前面 307 个 HashMap 用的是另一套写法------同一个仓库里两种参数风格并存。

全仓库 0 个 namespace,0 个 #{},0 个 $,0 个 cacheModel,0 个 selectKey。#{} 是 MyBatis 3 的语法,iBATIS 2 用 #name#,两套占位符互不兼容,这几个统计是我用正则跑出来的。

参数绑定靠位置和字符串,三处必须手工对齐

挑最长的那个来看。P_VB_LEAVELOGIN 的 parameterMap 有 63 个 <parameter>,这里只截头两个和末尾两个:

xml 复制代码
<parameterMap id="P_VB_LEAVELOGIN_PARAM" class="java.util.HashMap">
    <parameter property="i_Caller" jdbcType="VARCHAR" mode="IN"/>
    <parameter property="i_Callee" jdbcType="VARCHAR" mode="IN"/>
    <!-- 中间省略 59 个 -->
    <parameter property="o_AccountId" javaType="java.lang.String" jdbcType="VARCHAR" mode="OUT"/>
    <parameter property="o_NetType" javaType="java.lang.Integer" jdbcType="INTEGER" mode="OUT"/>
</parameterMap>
​
<procedure id="P_VB_LEAVELOGIN" parameterMap="P_VB_LEAVELOGIN_PARAM">
    { CALL P_VB_LEAVELOGIN(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) }
</procedure>

那行 CALL 里数出来 63 个问号,跟 63 个 <parameter> 对得上。

Java 侧对应六十多个 put:

javascript 复制代码
Map<String, Object> loginParam = new HashMap<String, Object>();
loginParam.put("i_Caller", loginInfo.getCaller());
loginParam.put("i_Callee", loginInfo.getCallee());
loginParam.put("i_CallerHideFlag", flag);
loginParam.put("i_Reason", reason);
loginParam.put("i_IsRelationNo", null == isRelationNo ? 0 : isRelationNo.intValue());
loginParam.put("i_IsRelationBox", null == isRelationBox ? 0 : isRelationBox.intValue());
// 中间省略
loginParam.put("o_Ret", null);
loginParam.put("o_LevelId", null);
// 中间省略
loginParam.put("o_FtpInfoRef", null);

三处要对齐:问号的数量、<parameter> 的声明顺序、property 字符串跟 Map 的 key。

任意一处错位,iBATIS 2 都不会在启动时报出来。XML 解析只检查语法,不检查 CALL 里的问号跟 parameterMap 里的声明是否一一对应,也不检查 Java 里到底 put 了哪些 key。错误发生在第一次调用的时候,报出来的是 JDBC 层的参数下标或类型错误,CallableStatement 的消息里通常带 #63、#62 这种序号,跟哪个业务字段对不上关系不大。

这也是为什么在这套写法里加一个输出参数,要同时改四个地方:Oracle 存储过程签名、XML 的 parameterMap、CALL 问号数量、Java 的 put 和后续 get。

mode="INOUT" 在五个文件里只出现一次,在 P_BIZ_QUERYAGINGUSER 的参数里,i_GroupNo 一个参数既传进去又读回来。

输出参数写回 Map,游标靠自定义 TypeHandler

存储过程的输出参数直接写回传给 queryForObject 的那个 Map。代码里取值靠强转:

erlang 复制代码
getSqlMapClientTemplate().queryForObject("P_VB_LEAVELOGIN", loginParam);
relatingSet = (ResultSet) loginParam.get("o_RelatingBoxInfoRef");
​
int result = ((Integer) loginParam.get("o_Ret")).intValue();

o_Ret 在 XML 里声明的是 javaType="java.lang.Integer"、mode="OUT",数据库返回 INTEGER。这行强转没做 null 判断,存储过程如果没给 o_Ret 赋值,这里就是空指针。整个过程里 o_Ret 都赋了值,这个前提只写在存储过程里,Java 侧看不见。

游标参数多声明两个属性:

ini 复制代码
<parameter property="o_RelatingBoxInfoRef" javaType="java.lang.Object"
           jdbcType="ORACLECURSOR" mode="OUT"
           typeHandler="com.example.uc.comm.util.ResultSetHandler"/>

ORACLECURSOR 是 iBATIS 2 里 Oracle 驱动的类型标识,81 个输出参数用到它。typeHandler 指向自己写的一个类,全文八十八行:

scala 复制代码
public class ResultSetHandler extends ObjectTypeHandler {
​
    private static final Logger LOGGER = Logger.getLogger(ResultSetHandler.class);
​
    public Object getResult(CallableStatement cs, int columnIndex) throws SQLException {
        Object object = cs.getObject(columnIndex);
        if (cs.wasNull()) {
            return null;
        }
​
        //by xtsong00142835: If the object is an instance of ResultSet, create a new cached one and return
        //Because the raw one will be closed after the statement is closed
        try {
            CachedRowSet cachedResultSet = newCachedRowSet();
            cachedResultSet.populate((ResultSet) object);
            ((ResultSet) object).close();
            return cachedResultSet;
        } catch (Exception ex) {
            LOGGER.error("ResultSetHandler has occured an exception.", ex);
            return object;
        }
    }
​
    protected CachedRowSet newCachedRowSet() throws SQLException {
        return new OracleCachedRowSet();
    }
}

注释是 2010 年写的,原话是 "Because the raw one will be closed after the statement is closed"。写这段的人当时已经知道 Oracle 返回的游标跟语句绑定,语句一关,游标就废。所以这里用 OracleCachedRowSet 把数据整段读进内存,然后把原生游标关掉。

catch 分支返回的是 object,也就是那个原生 ResultSet。populate 失败时,调用方拿到的是一个还开着、但马上会随语句关闭而失效的 ResultSet。异常记了一行日志,方法签名继续返回 Object,iBATIS 内部的参数映射逻辑不知道出过事。这个返回值后面在 DAO 里被强转成 ResultSet 拿去 next(),next() 抛出的 SQLException 走到 DAO 的 catch 里再记一条日志。两条日志,隔了几层,只能靠时间戳对上。

游标在 DAO 里的消费方式是自己 while 循环:

vbscript 复制代码
while (ftpInfoSet.next()) {
    info = new StorePointInfo();
    info.setPointId(ftpInfoSet.getString("Fileserver_Id"));
    info.setFtpVirtualPath(ftpInfoSet.getString("Ftp_Virtual_Path"));
    info.setHost(ftpInfoSet.getString("Host"));
    info.setUserName(ftpInfoSet.getString("Username"));
    info.setPassword(ftpInfoSet.getString("Password"));
    info.setPort(ftpInfoSet.getInt("Port"));
}

这段放在 finally 里关游标,异常路径上也关。列名是数据库里的原样大小写,换个库或者换个驱动,getString("Host") 就可能取不到。

MyBatis 3 里同样的东西写成:

sql 复制代码
<select id="leaveLogin" statementType="CALLABLE" parameterType="map" resultMap="boxInfoMap">
    { CALL P_VB_LEAVELOGIN(
        #{i_Caller, mode=IN, jdbcType=VARCHAR},
        #{i_Callee, mode=IN, jdbcType=VARCHAR},
        #{o_Ret, mode=OUT, jdbcType=INTEGER},
        #{o_RelatingBoxInfoRef, mode=OUT, jdbcType=CURSOR, resultMap=boxInfoMap}
      ) }
</select>

参数按名字绑定,游标结果用 resultMap 自动映射,不用自己写 while 循环和列名字符串。

SQL ID 被当成配置数据,从 Spring XML 注入

ParamLoadDao 里有一行很能说明当年的设计取向:

typescript 复制代码
private Map<ParamType, String> sqlIds;
​
public Map<String, ParamInfo> load(ParamType paramType) {
    List<ParamInfo> paramInfos;
    HashMap<String, ParamInfo> param;
    try {
        paramInfos = getSqlMapClientTemplate().queryForList(sqlIds.get(paramType));
        ...

sqlIds 这个 Map 的值是 SQL ID 字符串,从来不在代码里写死,由 Spring XML 注入:

ini 复制代码
<bean id="paramLoader" class="com.example.voice.server.dao.ParamLoadDao">
    <property name="sqlMapClient" ref="defaultSqlMapClient" />
    <property name="sqlIds">
        <map>
            <entry key="SYS" value="querySysParams" />
            <entry key="SRV" value="querySrvParams" />
            <entry key="COS" value="queryCosParams" />
            <entry key="SOFT" value="querySoftParams" />
            <entry key="COSTYPE" value="queryCostypeParams" />
            <entry key="FLOWINFO" value="queryCallControlFlowInfo" />
            <entry key="STRATEGY" value="queryCallControlStrategy" />
            <entry key="TIMEZONE" value="queryTimeZone" />
            <entry key="NUMBERPER" value="queryNumberPre" />
            <entry key="MENUCONFIG" value="queryMenuConfig" />
            <entry key="RESCONFIG" value="queryResConfig" />
            <entry key="OPTIONCONFIG" value="queryOptionConfig" />
            <entry key="LANGUAGEINFO" value="queryLanguageInfo" />
        </map>
    </property>
</bean>

十三个枚举值对应十三个 SQL ID,两边靠字符串对上。枚举里加一个值而忘了在 XML 里加 entry,sqlIds.get(paramType) 返回 null,queryForList(null) 抛什么异常我没去试,这个方法的 catch 是 catch (Exception e):

kotlin 复制代码
} catch (Exception e) {
    LOGGER.error("Error to get param from DB, error: " + e);
    return null;
}

异常变成一行日志加一个 null 返回值。调用方拿到 null,再用的时候才空指针。

把 SQL ID 外置的做法本身有它的道理:同一套 DAO 代码,不同局点挂不同的 SQL,改配置不改代码。放在 2010 年前后,这是很常见的发行版策略。代价是 SQL ID 从编译期可见的东西变成了运行期字符串,IDE 里跳转不到,改名没有提示,只能靠全局搜索。

四个 SqlMapClient,解析同一份配置,事务管理器只认一个数据源

Spring 那边一共声明了四个 SqlMapClientFactoryBean:

ini 复制代码
<bean id="defaultSqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
    <property name="configLocation" value="classpath:application/sqlmap_client.xml" />
    <property name="dataSource" ref="defaultDataSource" />
</bean>
​
<bean id="clientSqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
    <property name="configLocation" value="classpath:application/sqlmap_client.xml" />
    <property name="dataSource" ref="defaultDataSource" />
</bean>
​
<bean id="sqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
    <property name="configLocation" value="classpath:application/sqlmap_client.xml" />
    <property name="dataSource" ref="multiDataSource" />
</bean>
​
<bean id="multiSqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
    <property name="configLocation" value="classpath:application/sqlmap_client.xml" />
    <property name="dataSource" ref="multiDataSource" />
</bean>

四个 bean,配置路径完全相同,数据源分两组:两个挂 defaultDataSource(c3p0 连接池),两个挂 multiDataSource(自研的路由数据源,按局点切库)。

按 Spring 的 SqlMapClientFactoryBean 语义,每个 bean 自己 build 一个 SqlMapClientImpl,同一份 xml 要被解析四遍,四份 SqlMap 对象各有各的语句缓存。

事务这边:

ini 复制代码
<tx:annotation-driven transaction-manager="transactionManager" proxy-target-class="true"/>
​
<bean id="transactionManager"
    class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="defaultDataSource" />
</bean>

DataSourceTransactionManager 绑的是 defaultDataSource。绑 multiDataSource 的那两个 SqlMapClient,拿到的连接不来自这个事务管理器。@Transactional 在挂 multiDataSource 的 DAO 上会开事务,开在哪儿,我这里只能看到配置层面的东西,没有跑起来验证过连接的实际归属。

DAO 装配是逐个 bean 写 <property name="sqlMapClient" ref="..."/>,几十个 DAO 各挑一个。挑错了不会报错,数据会写到另一个库去。这类错在测试环境里通常看不出来,因为测试环境只有一个库。

对照表

org.springframework.orm.ibatis 这个包在 Spring 4 里被移除了,这点我记得,但没在这套环境里验证过具体版本,写在这里当成迁移时的注意点。

三点没确认

一是 DTD 到底有没有联网取 。配置里写的是 http://www.ibatis.com/dtd/sql-map-config-2.dtd,iBATIS 2 解析外部 DTD 的策略我没读源码,项目里也没找到 EntityResolver 注册。

二是四个 SqlMapClient 是不是真的把同一份 xml 解析了四遍 。我是按 SqlMapClientFactoryBean 的 build 语义推的,没有在启动日志里抓到解析次数。

三是挂 multiDataSource 的 DAO 是否真的落在 transactionManager 之外 。配置上事务管理器只绑了 defaultDataSource,实际连接归属涉及自研路由数据源的实现,那个类我没通读,手上也没有能跑的环境。

自查打卡

这套写法的核心风险是三处靠字符串对齐的东西。能自动查的部分,用一条对比查:

ini 复制代码
# 1. 把所有声明的 SQL ID 抽出来
grep -ho '<procedure id="[^"]*"' $(find . -name "sqlmap_*.xml") \
  | sed 's/.*id="//;s/"//' | sort -u > declared.txt
​
# 2. 把 Java 里字面量调用抽出来
grep -rhoE 'queryFor(Object|List)("[^"]+"' --include=*.java . \
  | sed 's/.*("//;s/"//' | sort -u > used.txt
​
# 3. 声明了但没人调的
comm -23 declared.txt used.txt

第三步查出来的是"写进 xml 但 Java 里没有字面量调用"的 ID。变量传进去的调用抓不到,比如 queryForList(sqlIds.get(paramType)),还有从常量类里取 ID 的写法,这些都会漏。这个差集只能当线索,不能当结论。

另一个能自动化的检查是把 CALL 那行的 ? 数量跟 parameterMap 里的 <parameter> 数量比一比,十来行脚本就能跑。这个仓库里 307 对,跑一次能确认现在是对的,以后改 XML 也不会漏。

老炮点评

这套持久层在今天看确实陌生,放回当年的环境,它有它的合理性。SQL 归 DBA 维护,存储过程里封装业务规则,Java 侧只做参数传递,职责分得很清楚。存储过程改一次,所有调用方自动生效,不用发版。电信行业那时一个局点一个库,这种集中管 SQL 的方式比分散在 Java 里好维护。

问题出在"三处手工对齐、零校验"这个组合上。问号数量、parameter 顺序、Map 的 key,任意一处错位都要等到运行期才暴露,报错信息指向 JDBC 层,不指向出错的 XML 行。63 个参数的存储过程,加一个字段要改四处,漏一处就是生产问题。

能立刻做的改进是启动时自检。把所有 sqlmap 解析一遍,把 CALL 里的问号和 parameterMap 数量对齐检查,把 Java 里出现的 SQL ID 字面量和配置里的 ID 做差集,对不上的直接让应用起不来。这种东西写一次几十行,跑一次几十毫秒,比等生产报警划算。当年没有这套,现在补也来得及,只是要动的地方多。

最后问一句:你手上还有没有在跑的 iBATIS 2 项目?最大的一张 parameterMap 有多少个参数?评论区报个数,让我们感受一下老家伙的威力。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

欢迎留言探讨。你的每一次互动,都是我继续更新的动力🚀

老炮自查打卡 跑一下文章里提到的那段对比命令: comm -23 declared.txt used.txt 看看你的老项目里,有多少 procedure 写了但根本没人调?或者更离谱的,有几张巨大的 parameterMap?打在评论区,我看看谁的项目最"原汁原味"。


下期预告:《遗留语音系统的生存现状:十个子模块的架构账本》

还是这个仓库。十个子模块,两代 Web 框架,四套日志配置,三份数据库连接串,这些前面几篇只各翻了一角。下一篇把模块清单和依赖关系统一对一遍,看看一个跑了十几年的电信系统,还剩些什么。

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,真实项目复盘,让你少走弯路。

相关推荐
程序员cxuan3 小时前
不是,为啥这个模型突然就爆火了??
人工智能·后端·程序员
the局外人3 小时前
Python 中使用 SSE 实现大模型流式输出
后端·python·llm
苍何4 小时前
用 WorkBuddy + LibTV 做 AI 视频,干活有点猛啊!(附教程)
后端
苍何4 小时前
我的新书冲上京东热榜第一了!
后端
captain3764 小时前
MyBatis Plus(1)
java·服务器·mybatis
香蕉大香蕉(06敬畏市场)4 小时前
MySQL 单点详解(08)
java·数据库·mysql·面试·八股
thefool1122664 小时前
HTTPS协议
java
acd120095 小时前
Nginx 反向代理的缓冲区坑:后端响应大时接口偶发 502
java·运维·nginx
装不满的克莱因瓶5 小时前
支付订单成功异步MQ权益发放设计和开发
java·架构·系统架构·rabbitmq·硬件架构