☰
云ERP二次开发持久层设计:DAO、会话与结果集映射实践
2026/10/29 13:00:18 网站建设 项目流程

简介:《用友U8 cloud V1.0-持久层技术红皮书》面向用友U8C开发人员与云ERP后端开发者,聚焦U8 cloud V1.0持久层的数据访问设计与实现,帮助读者理解JDBC FrameWork在云ERP场景中的落地方式。文档围绕JdbcSession创建、数据访问异常处理、结果集读取与流式处理、单条及批量更新、日期时间与Blob/Clob等特殊参数类型展开,并延伸至普通Java Bean的持久化、对象映射构造、对象读取与写入等关键环节,配有示例代码和最佳实践建议,便于在开发中排查连接、查询超时与批量更新效率问题。压缩包内为1个PDF文件,约493KB,目录按主题分节,适合按需查阅。已有93人学习,可作为U8C持久层开发与接口调试的案头参考。

1. 需求:为什么一套成品 ERP 还要自己造持久层

接手一套云 ERP 的二次开发,最先崩掉的往往不是业务界面,而是散落在各处的 SQL。U8C 开发里最典型的一幕:某张单据的字段改名,全工程搜出两百多处拼接字符串,改完还要在三种数据库上各验一遍。这就是持久层技术存在的意义——它不讲某个 API 怎么调,而是把数据访问收敛成一层可替换、可测试、可验收的边界。这套方案适合两类人:要照着写毕业设计里「系统架构与数据访问层」章节的学生,和接手存量 ERP 代码、准备做一次数据访问层收口的工程师。下面按需求、架构、会话、结果集、对象映射、验收的顺序拆开讲,每一章都落到能直接抄的代码和判断标准上。

2. 总体架构设计:四层职责与三条调用通道的边界

2.1 直接用 JDBC 更快,为什么还要加一层

裸 JDBC 的优点很实在:运行效率高、执行计划可控、复杂 SQL 想怎么写就怎么写。缺点同样实在——SQL 语句硬编码进 Java 代码,冗余不可避免;获得连接、准备语句、循环结果集、异常捕获这几段代码,同一个项目里能重复几百遍;一旦要在多种关系数据库上跑,方言差异会从各个角落冒出来,项目维护成本直线上升。

引入对象-关系映射中间件的收益,行业内的经验值大约是省掉与对象持久化相关的四成编程量。这个数字不是拍脑袋,它对应的是那些「写一次就再也不用改」的模板代码被彻底收走。但这不等于所有查询都要走 ORM:报表、跑批、跨多表的大聚合,老老实实写原生 SQL 反而更清楚。所以真正要设计的不是「用不用 ORM」,而是「哪些通道走 ORM、哪些走原生 SQL、它们怎么共用同一个会话」。这是整套架构的第一个决策点,也是最容易含糊过去、后期最疼的地方。

2.2 四层结构与各层的关键类型

把数据访问拆成四层,每层只认下一层的接口,职责边界一旦定死,后面加库、换库、加缓存都不用动业务代码。

层次关键类型该做的事不该做的事
业务层各模块 BS 类、VO组织业务规则、决定事务提交时机出现任何 SQL 片段、出现 Connection
持久化门面层BaseDAO、IVOPersistence、IUAPQueryBS提供增删改查的统一入口、屏蔽会话细节处理业务判断
会话层PersistenceManager、JdbcSession、SQLParameter会话获取与释放、参数绑定、批量提交关心返回的是什么对象
结果映射层ResultProcessor 家族把 ResultSet 转成数组/Map/Bean决定业务含义
驱动层DataSource、JDBC Driver连接池、方言实现暴露给业务层

这张表最值得盯的是「不该做的事」那一列。绝大多数持久层腐化,都是从业务层出现一句session.executeUpdate(...)开始的。定规矩的时候不要写「尽量」,要写死:SQL 只能出现在 DAO 或映射元数据类里,业务层连java.sql包的 import 都不允许出现。

2.3 三条通道的选型边界

同一个系统里同时存在三种数据访问方式,必须有明确的选型标准,否则新人一定会挑最顺手的那种,最后三种写法混在一起。

判断规则通常是这样的:单表或简单双表的增删改查,走 BaseDAO 的对象操作,VO 进 VO 出,不写一行 SQL;需要多表关联、聚合、分页排序的列表查询,走 JdbcSession 加原生 SQL,结果用合适的 ResultProcessor 接住;跨模块取数、需要复用别人已经写好的查询逻辑,走 IUAPQueryBS 接口。三条通道共用同一个 PersistenceManager 和同一套异常体系,这样事务和会话管理才不会分裂。

一个符合这个边界的 DAO 骨架长这样:

public class PersonDAO { // 门面:不传参走默认数据源;需要指定库时用 new BaseDAO(dataSource) private BaseDAO dao = new BaseDAO(); // 单表条件查询:交给对象通道,业务层看不到 SQL public PersonVO[] queryByName(String name) throws Exception { PersonVO filter = new PersonVO(); filter.setName(name); // 条件用 VO 承载,避免在业务层拼 where 子句 return (PersonVO[]) dao.queryByCondition(PersonVO.class, filter); } // 聚合查询:走原生 SQL 通道,结果用数组处理器接 public Object[] countByOrg(String pkOrg) throws Exception { PersistenceManager sm = PersistenceManager.getInstance(); JdbcSession session = null; try { session = sm.getJdbcSession(); String sql = "select count(1), max(age) from PERSON where ORG = ?"; SQLParameter p = new SQLParameter(); p.addParam(pkOrg); // 单行多列,ArrayProcessor 最直接 return (Object[]) session.executeQuery(sql, p, new ArrayProcessor()); } finally { if (sm != null) { sm.release(); // 会话必须回收到管理器,不能靠 GC } } } }

这段代码体现的是分层,不是语法。queryByName里业务层完全不知道表名和列名;countByOrg里 SQL 被限制在 DAO 方法内部,出了这个方法就再也见不到。参数pkOrg用占位符绑定而不是字符串拼接,既避免注入,也让数据库能复用执行计划——这一点在批量场景下差别非常明显。

2.4 可迁移性是怎么被设计出来的

「易于迁移到其他数据库」这句话写在任何方案里都很漂亮,落到实现上其实只有三个落点:SQL 方言差异收在 DAO 层、类型差异收在参数封装里、错误码差异收在异常转换里。业务代码一行不改,换库只动这三处。

具体做法是:日期、大文本、二进制这些类型不要直接setObject,统一走 SQLParameter 的专用方法;分页语法不要写死在业务 SQL 里,由 DAO 根据数据源类型拼;数据库报错不要往上抛原始 SQLException,让会话层统一转成 DbException。这三条做到了,所谓的「透明性」才不是一句口号,而是新人接手时能验证的事实。

3. JdbcSession 会话与统一异常:三条生命周期规则

3.1 会话的获取与释放必须成对出现在同一个方法里

会话管理的规则只有一条,但违反它的代价最大:拿到会话的方法,必须负责把它还回去。写的时候把 session 变量声明在 try 外面,获取放在 try 里面,释放放在 finally 里,这是唯一不会翻车的写法。

PersistenceManager sessionManager = null; JdbcSession session = null; try { sessionManager = PersistenceManager.getInstance(); session = sessionManager.getJdbcSession(); // 开始 jdbc 会话 String sql = "update bd_invmandoc set pk_invmandoc = ? where pk_invmandoc = ?"; SQLParameter p = new SQLParameter(); p.addParam("0001AA10000000000DDD"); p.addParam("0001AA10000000000DDD"); session.executeUpdate(sql, p); } catch (DbException e) { // 统一异常分支,见 3.2 throw e; } finally { if (sessionManager != null) { sessionManager.release(); // 归还会话,线程上下文被清空 } }

三个细节值得单独说。PersistenceManager.getInstance()是单例入口,一进一出不要重复创建;release()的语义通常是释放当前线程持有的会话上下文,而不是关掉整个连接池,所以它在 finally 里调用是安全的、也是必须的;session声明在 try 外,是因为 finally 里可能还需要它,写在 try 内会编译不过。这三行看着啰嗦,但它是整个持久层唯一不能省样板的地方。

3.2 为什么异常要抽象成 DbException

SQLException 是受检异常,如果每个 DAO 方法都throws SQLException,业务层的签名会被污染一大片,最后大家都用catch (Exception e) {}糊过去,异常信息彻底丢失。DbException 是抽象异常类,作用是把各家数据库的 SQLErrorCode 归一化成有语义的判断方法,让上层能区分「这是开发期写错了 SQL」还是「这是业务上可预期的数据冲突」。

try { session.executeUpdate(sql, parameter); } catch (DbException e) { if (e.isBadSQLGrammar()) { // 语法错、字段名错、表不存在:属于开发期问题,直接暴露 throw new IllegalArgumentException("SQL 语法或字段名有误: " + sql, e); } if (e.isDataIntegrityViolation()) { // 唯一键冲突、非空约束、外键约束:属于业务可预期分支 throw new BizException("数据冲突,请检查单据编码是否重复"); } // 其余情况不吞,继续向上抛,保留原始堆栈 throw e; }

这两个判断方法的价值在于把「异常处理」从字符串匹配里解放出来。没有它,判断唯一键冲突只能靠e.getMessage().contains("ORA-00001"),换一个库就失效,这是典型的血泪经验。参数上没有花活,就是原始异常对象透传,保证堆栈完整。

3.3 事务边界该放在哪一层

会话层不管提交,这是硬规矩。一个业务方法开一个会话,方法内的多条更新要么全成功要么全回滚,提交和回滚的决策权在 BS 层,因为它才知道这次操作对应的是「保存一张单据」还是「保存单据并回写库存」。DAO 里出现 commit,等于把一个业务事务切成两半,出问题时谁也说不清哪一半已经落库。

批量场景要额外注意节奏。一次会话里堆几千条 addBatch 再统一 executeBatch,看着高效,实际上内存和锁持有时间都会飙上去。我一般按 500 到 1000 条一批,每批执行完继续下一批,中间不做会话重建,这样既控制住内存,也不会长时间占着行锁。

3.4 会话不要跨线程传

会话对象和线程上下文绑定,在线程池里把 session 当参数传给另一个线程,是排查起来最费劲的一类问题:现象是偶发的「会话已关闭」或者数据写到别的连接上,日志里什么都看不出来。规矩很简单,会话在哪个方法里拿的就在哪个方法里放,需要异步处理的数据,先把 VO 组装好再交给异步线程,异步线程自己去拿自己的会话。

4. 结果集与更新落地:十种处理器与四类更新写法

4.1 结果处理器的选型表

查询结果怎么接,是持久层里最高频的决策。系统预置了一整套 ResultProcessor 实现,选错了不是报错,而是拿到一堆需要二次转换的数据,代码越写越长。

处理器返回类型适用场景
ArrayProcessorObject[]单行结果,列少,按序号取值
ArrayListProcessorArrayList(元素为 Object[])多行结果,宽表,按序号取值
MapProcessorHashMap单行结果,按列名取值
MapListProcessorArrayList(元素为 HashMap)多行结果,列不固定,按列名取值
ColumnProcessorObject只取一列一个值,如 count、max
BeanProcessorJavaBean单行,列名与属性名一致
BeanListProcessorArrayList(元素为 JavaBean)多行,列名与属性名一致
BeanMappingProcessorJavaBean单行,列名与属性名不一致,需映射信息
BeanMappingListProcessorArrayList(元素为 JavaBean)多行,列名与属性名不一致

选型上的经验是:内部 DAO 之间传值用 Bean 系,跨模块或临时查询用 Map 系,统计类查询一律 ColumnProcessor,别为了「统一风格」把 count 结果也包成 Bean。

// 统计查询只需要一个数字,ColumnProcessor 用列序号定位,最省事 String sql = "select count(1) from bd_invmandoc where pk_org = ?"; SQLParameter p = new SQLParameter(); p.addParam("0001AA10000000000DDD"); Integer total = (Integer) session.executeQuery(sql, p, new ColumnProcessor(1)); // 列名与属性名对不上的场景,必须用 Mapping 系,否则映射全空 BeanMappingListProcessor proc = new BeanMappingListProcessor( PersonVO.class, new PersonVOMeta()); ArrayList list = (ArrayList) session.executeQuery(sql, p, proc);

第一段里new ColumnProcessor(1)的 1 是列序号,也可以用列名构造,序号写法在select count(1)这种无名列上更稳。第二段的关键是传入映射元数据,没有它,BeanProcessor只能按「列名等于属性名」去猜,猜不中就静默返回 null,这是后面避坑章要展开的一个点。

4.2 自定义处理器的扩展点

预置实现覆盖不了所有需求,比如要把一行结果拼成特定的 DTO、或者要在遍历过程中做流式聚合。扩展方式很直接,继承 BaseProcessor,实现 processResultSet。

public class CustomProcessor extends BaseProcessor { @Override public Object processResultSet(ResultSet rs) throws SQLException { List<String> names = new ArrayList<>(); while (rs.next()) { // 自定义逻辑:只取第二列并做去空处理 String v = rs.getString(2); if (v != null && !v.trim().isEmpty()) { names.add(v.trim()); } } return names; } }

继承 BaseProcessor 而不是直接实现接口,是因为基类里已经把 ResultSet 的关闭、游标初始化这些杂事处理掉了,子类只管转换逻辑。要注意的是,处理器里不要做数据库访问,也不要持有会话引用,它只应该是一个纯粹的「结果转换函数」,否则复用时会出问题。

4.3 四类更新写法的取舍

更新操作按「有没有参数」和「是不是批量」组合成四种,写法差异不大,但性能差异不小。

// 1) 无参数更新:条件写死在 SQL 里,适合配置类、状态位刷新 String sql1 = "update bd_invmandoc set flag = '1' where flag = '0'"; session.executeUpdate(sql1); // 2) 带参数更新:占位符 + SQLParameter,参数按顺序进预编译对象 String sql2 = "update bd_invmandoc set pk_invmandoc = ? where pk_invmandoc = ?"; SQLParameter p2 = new SQLParameter(); p2.addParam("0001AA10000000000DDD"); p2.addParam("0001AA10000000000EEE"); session.executeUpdate(sql2, p2); // 3) 批量带参数:同一个 SQL,多次 addBatch,最后一次执行 String sql3 = "update bd_invmandoc set flag = ? where pk_invmandoc = ?"; for (String pk : pkList) { SQLParameter p3 = new SQLParameter(); p3.addParam("1"); p3.addParam(pk); session.addBatch(sql3, p3); } int rows = session.executeBatch(); // 返回受影响行数总和 // 4) 批量无参数:多条不同 SQL 攒在一起提交 session.addBatch(sql1); session.addBatch("delete from bd_invmandoc_b where pk_invmandoc = '0001AA10000000000DDD'"); int rows2 = session.executeBatch();

第一种写法要慎用,条件写死意味着这一步不可复用,只适合固定的初始化脚本。第二种是日常主力,参数顺序必须和 SQL 里的问号严格对应,错位不会报错,只会改错数据。第三种的收益最大,同一个预编译语句执行 N 次,网络往返和解析开销都省了。第四种适合「一批互不相关的清理语句」,但要注意它不保证跨语句的原子性,需要原子语义时还是得靠事务边界兜住。

4.4 Null、Blob、Clob 三类特殊参数

直接addParam(null)是常见的翻车点,因为驱动拿不到类型信息,无法确定该调setNull的哪个重载。框架为此提供了三个专用方法。

SQLParameter p = new SQLParameter(); // Null:必须显式给出 SQL 类型,否则驱动无法推断 p.addNullParam(Types.INTEGER); // Blob:二进制大对象,传字节数组或流包装对象 p.addBlobParam(new byte[]{1, 2, 3}); // Clob:字符大对象,传字符串 p.addClobParam("长文本内容……"); session.executeUpdate(sql, p);

这三个方法的共同点是「类型由调用方声明」,而不是让框架去猜。Types.INTEGER这类常量来自java.sql.Types,要和目标列的类型对齐,写成Types.VARCHAR去更新一个数值列,某些数据库会直接拒绝。另外这类语句执行完后,注意会话要正常 release,大对象占用的资源比普通语句多,早释放早有好处。

5. 对象持久化避坑:映射元数据、BaseDAO 与五个翻车点

5.1 JavaBean 与数据表的映射约束

对象持久化的前提是承认约束,而不是绕过它。当前这套持久层对 JavaBean 和表之间的关系有几条硬性限制:一个 JavaBean 只能映射成一张表,两者是一对一;表必须有主键,主键名要在映射元数据里明确给出;参与持久化的类必须符合 JavaBean 规范,也就是有无参构造、属性通过 getter/setter 暴露。

-- 与 PersonVO 一一对应的表结构 CREATE TABLE PERSON ( ID NUMBER(5,0) NOT NULL, NAME VARCHAR2(40) NOT NULL, AGE NUMBER(3,0) NOT NULL, PRIMARY KEY (ID) );

字段类型的写法是方言相关的,NUMBER是某一类数据库的写法,换库时要对应换成INT、NUMERIC。这类差异不要指望框架自动处理,它属于建表脚本层面的事,方案里通常的做法是给每种目标库准备一份 DDL,而不是在运行期做类型翻译。

5.2 IMappingMeta:把表和 Bean 的对应关系写成一个类

映射信息不写在注解里,而是单独实现一个元数据类,这是这套持久层比较有辨识度的设计。接口只要求四个方法:主键名、表名、属性数组、列名数组。

public class PersonVOMeta implements IMappingMeta { // 两个数组必须严格同序:attributes[i] 对应 columns[i] private String[] attributes = new String[]{"id", "name", "age"}; private String[] columns = new String[]{"ID", "NAME", "AGE"}; @Override public String getPrimaryKey() { return "ID"; } @Override public String getTableName() { return "PERSON"; } @Override public String[] getAttributes() { return attributes; } @Override public String[] getColumns() { return columns; } }

命名规范一般是nc.vo.xxx.XXXVOMeta,和 VO 放在一起,方便查找。这两个数组的「同序」是整个映射的地基:框架读取时按位置配对,不做名字匹配。所以顺序错了不会抛异常,只会把 name 的值写进 AGE 列,这类问题在测试环境往往发现不了,上了生产才炸。

5.3 BaseDAO 与两个持久化接口的分工

写完元数据类,剩下的编码工作可以交给BaseDAO。这个类是数据库访问帮助类,把常用的持久层操作封了一层,同时实现了IUAPQueryBS(查询侧接口)和IVOPersistence(持久化侧接口),并覆盖了底层通用对象的方法。新写的代码优先用它,不要自己去 new 会话。

// 默认数据源 BaseDAO dao = new BaseDAO(); // 需要连指定库时传入数据源 BaseDAO dao2 = new BaseDAO(dataSource); // 面向接口使用:查询走 IUAPQueryBS,写入走 IVOPersistence IUAPQueryBS query = dao; IVOPersistence persist = dao; // 具体方法签名以接口定义为准,这里只表达调用位置

这种「一个门面类实现多个接口」的设计,好处是调用方按接口编程,只依赖自己需要的那部分能力;坏处是新人容易把两类方法混着用,比如在只读的查询服务里注入 IVOPersistence。我的做法是在模块内做一次简单切分:查询服务和写入服务分别只 import 自己那个接口,编译期就把越界挡住。

至于SuperVO,它走的是通用持久化路径,不需要为每张表单独写映射元数据类,适合字段多、结构稳定的核心单据。普通 JavaBean 灵活、可控,适合二次开发新增的业务对象。两者的取舍标准是:核心单据用 SuperVO,扩展对象用普通 Bean,不要在同一个模块里两种风格来回横跳。

5.4 五个最容易翻车的点

现象一:查出来的数据整列错位。原因基本可以锁定在映射元数据的两个数组不同序,或者数组里漏了一个字段但列名数组没同步删。解决办法是把attributes和columns写成相邻两行、逐项对齐,再补一个「写入后立刻读回比对」的单元测试,几十行代码就能永久堵住这个口子。

现象二:Bean 里明明有值,读出来全是 null。常见原因是属性不合格:字段是包级可见、getter 名字写成了getname()、或者用了is前缀但类型不是 boolean。反射按 JavaBean 规范找方法,找不到就静默跳过,不报错。解决方式是让 IDE 统一生成 getter/setter,代码审查时专门看一眼新增的 VO。

现象三:跑一段时间后连接池耗尽。现象是系统越来越慢,最后大批请求超时,重启就恢复。原因通常是某条异常分支提前 return,跳过了release()。兜底方案是把释放写进 finally,并且禁止在 try 块里 return 之前手动 release——一次就够,重复释放同样有问题。

现象四:更新数值列时报类型不匹配。多半是拿addParam(null)去更新可空列,驱动推断不出类型。改成addNullParam(Types.XXX)并确认 XXX 与列类型一致即可。这类错误在测试环境可能因为数据都非空而长期潜伏,建议在建表阶段就明确哪些列允许为空,测试用例专门覆盖空值路径。

现象五:批量导入跑到一半内存爆掉。原因是一次 addBatch 攒了全量数据。解决方式是分批,每 500 到 1000 条 executeBatch 一次,并统计返回的受影响行数,和预期条数做比对,不一致就整批回滚重来。批量操作最怕的不是慢,是「以为成功了其实少写了几百条」。

6. 验收与答辩:把持久层讲成一条能跑通的链路

验收这套东西,不要对着代码逐行念,按「一条数据从内存到库再回内存」的链路走一遍,评审的人自然就信了。

先做一张检查表,逐项打勾:

检查项判定方法通过标准
会话是否成对释放在 release 处打断点,跑 200 次循环进入次数等于释放次数,连接池活跃数回落
异常是否被语义化故意写错字段名、故意插重复主键分别命中语法分支和数据完整性分支,不吞异常
结果映射是否正确写入后立即读回,逐字段比对无 null 错位、无类型转换异常
批量是否可控导入 1 万条,观察内存曲线内存平稳,受影响行数与预期一致
换库是否只改一处切换数据源配置后重跑用例业务代码零改动,仅方言相关配置变化

然后是答辩演示脚本,控制在八分钟内,三段就够。第一段演示会话与异常:正常更新一次,再故意把表名写错一次,让评审看到isBadSQLGrammar分支被命中、日志里有完整堆栈。第二段演示结果集:同一条 SQL 分别用MapListProcessor和BeanListProcessor接,说明什么时候该用哪个,重点讲列名与属性名不一致时为什么必须上 Mapping 系。第三段演示对象持久化往返:新建一个 VO,写入、读回、改一个字段再更新、最后删除,全程不出现一行手写 SQL。

答辩时最容易被追问的两个问题是「为什么不直接用 JDBC」和「换数据库要改多少」。第一个用四成重复代码和集中化维护回答,第二个当场切数据源演示,比任何解释都有用。

我第一次做这类持久层收口的时候,图省事把 release 写在了 try 的末尾,测试环境跑了两个月没出事,上线第三天连接池满,半夜回滚。从那以后我每次写完会话代码,都强制走一遍「异常路径 + 循环 200 次」的自查,不管多简单的 DAO 都不跳过。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询