☰
手写ORM核心:从JDBC封装到动态SQL与事务管理实战解析
2026/10/23 11:24:37 网站建设 项目流程

简介:这是一份以 ormer 命名的开发实战案例代码,解压后对应 Autoformer 时序预测完整工程,适合希望快速上手 Transformer 类时间序列建模、但不想从零搭框架的开发者;它把数据、模型、训练、评估与可视化整合在一起,便于直接对照学习。RAR 压缩包共 94 个文件,包含 24 个 Python 脚本作为主体逻辑、42 个 pyc 编译文件、12 个 npy 数据/权重、4 个 pth 模型权重、4 个 CSV 股票样例数据、4 个 PDF 文档、2 个 Jupyter Notebook 及 2 个 TXT 结果文件,整体体积 8.1MB,目录划分了 layers、models、data、utils、exp、results 等模块,其中 CSV 数据可直接替换为自有数据集,PDF 可辅助理解原理,ipynb 可查看数据探索与预测可视化过程。项目以 AMZN、NVDA、AAPL、TSLA 四只股票数据为对象,覆盖数据加载、特征构造、Autoformer/Informer/Transformer/Reformer 等模型定义、训练测试、预测与 EDA 可视化等环节,run.py 与 requirements.txt 可直接复现实验。已有 271 人学习,可作为时序预测入门、模型对比或源码阅读的参照工程。

1. 从“黑匣子”到“看得见的SQL”:重读ORM开发实战案例代码

不知道你有没有遇到过这种场景:新接手一个老系统,发现所有数据库操作都堆在Service层,几十行字符串拼接的SQL就在代码里裸奔,想加一个索引都不知道该往哪改。我拿到这份《ormer开发实战案例代码》时,第一反应是——终于有人把一个ORM框架的核心逻辑从里到外拆开讲了。它不是某个开源框架的源码剖析,而是一套完整的ORM开发实战案例,从JDBC的底层封装到动态SQL的渲染再到事务管理,完整覆盖了一个ORM引擎从零到一的过程。这套源码最反直觉的地方在于:你以为ORM是个黑匣子,结果看完整份代码,你发现连MyBatis注解驱动的实现逻辑都能自己写一遍了。适合正在做Java后端、被各种“自动映射”折磨得想手动写JDBC的人,也适合想搞明白框架底层到底干了什么的人。

2. 项目定位与技术选型:这款ORM到底比MyBatis/JPA强在哪

2.1 从JDBC痛点反推设计目标

先看这份案例试图解决的问题。在我翻过的Java项目里,手写JDBC的原始代码普遍长这样:

Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = dataSource.getConnection(); ps = conn.prepareStatement("SELECT * FROM user WHERE age > ?"); ps.setInt(1, 20); rs = ps.executeQuery(); List<User> users = new ArrayList<>(); while (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); // 十几个字段手动映射 } return users; } finally { if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (ps != null) try { ps.close(); } catch (SQLException ignored) {} if (conn != null) try { conn.close(); } catch (SQLException ignored) {} }

这段代码的问题一眼就能看出来:连接管理靠try-finally硬扛、结果集映射靠手写、SQL和Java代码完全耦合。这份案例里的ORM项目,设计目标就是把这三种痛点解决掉,而且解决方式不是直接抄Hibernate,而是自己重新设计了一遍。

2.2 技术栈选型逻辑

整份源码基于JDK 8+构建,核心依赖只有MySQL Connector/J和一个连接池(用的是HikariCP的接口方式,但自己实现了简单版本),没有引入Spring,这就意味着你可以把核心逻辑抠出来独立运行。这种选型有两个好处:第一,去掉Spring依赖后,整个数据库访问层的边界非常清晰——连接管理、SQL渲染、结果映射、事务控制分别是怎么实现的,一读就懂;第二,二次开发时你可以选择把它集成进Spring,也可以保留成独立工具包,灵活度很高。

案例的包结构分层也很直观:

src/main/java ├── ormer/core # 核心注解、配置类 ├── ormer.session # 会话管理(SqlSession等价物) ├── ormer.executor # SQL执行器、结果集映射 ├── ormer.mapping # 实体-表映射、字段映射 ├── ormer.transaction # 事务管理器 └── ormer.util # 字符串处理、反射工具

每个包对应一个独立职责,除了core包以外,其余包之间依赖关系是单向的:session调executor,executor调mapping,mapping调util。这种分层方案意味着你可以随时替换掉某一层,比如把executor里的JDBC实现换成R2DBC异步驱动,不影响上层API。

2.3 与传统ORM框架的对比

我在文档里整理了一张选型对比表,方便你用这份案例代码之前先掂量一下定位:

维度此案例ORMMyBatisJPA/Hibernate
依赖Spring否,纯JDK不强制但常配合常配合,较重
SQL可控性完全可控,注解SQL+动态拼接XML/注解SQLJPQL/HQL,自动生成
结果映射自定义反射映射器ResultHandler/自动映射一级/二级缓存复杂
事务控制手动begin/commit,支持嵌套依赖Spring事务依赖JTA/Spring
学习成本读两小时源码就能上手熟悉XML配置需要理解生命周期

这个对比不是想说谁更好,而是当你的项目对SQL可控性要求高又不想背一个重型框架时,这份案例能给你一个轻量替代方案。

3. 核心模块拆解:一个查询从进入到返回的完整链路

3.1 会话入口:SqlSession的设计哲学

任何ORM的第一步工作,就是给使用者一个“入口”。这份案例里的入口叫SqlSession,它完成的事对应到MyBatis里是SqlSessionFactory加SqlSession两件事的合体。核心代码是这样设计的:

public class SqlSession implements AutoCloseable { private final Connection connection; private final Executor executor; private final TransactionManager txManager; private boolean isAutoCommit = true; public SqlSession(DataSource dataSource) { this.connection = dataSource.getConnection(); this.executor = new SimpleExecutor(connection); this.txManager = new JdbcTransactionManager(connection); } public <T> T selectOne(String statementId, Object param) { return executor.queryOne(statementId, param); } public <T> List<T> selectList(String statementId, Object param) { return executor.queryList(statementId, param); } public int insert(String statementId, Object param) { return executor.update(statementId, param); } public void beginTransaction() { isAutoCommit = false; connection.setAutoCommit(false); } public void commit() { txManager.commit(); isAutoCommit = true; } public void rollback() { txManager.rollback(); isAutoCommit = true; } @Override public void close() { if (!isAutoCommit) { rollback(); } executor.close(); } }

这里有一个细节值得注意:close()方法在未提交事务时主动执行rollback(),而不是直接把连接丢回连接池。很多二次开发的ORM框架都是栽在这——连接关闭时不检查事务状态,导致脏数据悄悄落库。设计者的处理是让事务边界和会话生命周期保持严格一致,防止“忘了commit但连接close了”的经典事故。

3.2 SQL注册与解析:注解驱动的MappedStatement

案例中每个Mapper接口对应一个@Select或@Insert注解,SQL语句通过这些注解注册进Configuration。整个注册逻辑不在mapper层做,而是集中在ormer.core包里做初始化扫描。注册后的SQL会封装成一个MappedStatement对象:

public class MappedStatement { private final String id; // 唯一标识:类名.方法名 private final SqlCommandType sqlType; // SELECT/INSERT/UPDATE/DELETE private final String sqlTemplate; // 原始SQL,含#{xxx}占位符 private final Class<?> resultType; // 返回值类型 private final Class<?> parameterType; // 参数类型 public BoundSql boundSql(Object param) { // 将#{xxx}替换为?,同时记录参数顺序,返回可执行SQL ParameterMapping[] mappings = SqlTextParser.parse(sqlTemplate); String sql = SqlTextParser.replacePlaceholders(sqlTemplate, mappings); return new BoundSql(sql, mappings, param); } }

这个MappedStatement的作用是保存一条SQL的“元信息”,它不直接执行,只是被Executor拿过去做预编译。设计上把“解析”和“执行”分离的好处是:你可以在解析阶段做SQL拦截、改写、权限过滤,而执行层完全不用感知。

SqlTextParser是这里面的核心工具类,它用正则表达式把#{userName}之类的占位符找出来,替换成JDBC的?,同时生成参数顺序列表。我之前自己写过一个简化版本,当时没考虑#{}出现在字符串字面量里的情况,结果SQL里有WHERE name = '#{xxx}'时直接崩了。这份案例里专门处理了这个问题——用indexOf循环扫描而不是全局正则替换,避免钻入字符串字面量内部。这个优化思路值得抄。

3.3 动态SQL拼接:Where条件是怎么“智能”起来的

有了静态SQL还不够,真实业务里最痛的是动态条件。这份案例自己实现了一个简单的动态SQL机制,通过@SqlProvider配合一个SqlSourceBuilder来完成。代码核心逻辑是这样的:

public class SqlSourceBuilder { public static BoundSql build(String sqlTemplate, Object parameter) { StringBuilder finalSql = new StringBuilder(); List<ParameterMapping> mappings = new ArrayList<>(); // 按行解析SQL,遇到<if>节点走条件判断 String[] lines = sqlTemplate.split("\\n"); for (String line : lines) { String trimmed = line.trim(); if (trimmed.startsWith("<if test='")) { String condition = extractCondition(trimmed); if (evaluateCondition(condition, parameter)) { // 取出<if>标签内部的实际SQL片段 String innerSql = extractInnerSql(line); finalSql.append(innerSql).append(" "); // 解析内部SQL的占位符 parsePlaceholders(innerSql, parameter, mappings); } } else { finalSql.append(line).append(" "); parsePlaceholders(line, parameter, mappings); } } return new BoundSql(finalSql.toString().trim(), mappings.toArray(new ParameterMapping[0]), parameter); } private static boolean evaluateCondition(String expression, Object param) { // 当前支持 notNull / notEmpty / gt / lt 四种条件 if (expression.startsWith("notNull:")) { return getFieldValue(param, expression.substring(8)) != null; } if (expression.startsWith("gt:")) { String[] parts = expression.substring(3).split(","); Object field = getFieldValue(param, parts[0]); Number target = NumberUtils.toNumber(parts[1]); return field != null && ((Number) field).doubleValue() > target.doubleValue(); } return true; } }

这个实现的巧妙之处在于它用文本解析替代了XML或OGNL表达式,代码量少但能覆盖大多数场景。实际用的时候,我一般会把动态条件封装成QueryWrapper对象而不是直接传散参,这样可以避免参数名写错的低级问题。这份案例代码虽然只支持四种条件,但二次开发扩展起来很容易,你只需要在evaluateCondition里加一个分支。

3.4 结果集映射:反射自动映射与@Column注解

数据库里返回的ResultSet怎么变成Java对象?案例用的是反射加注解的组合。实体类字段不要求一定和列名完全一致,可以用@Column注解指定映射关系:

public class ResultSetHandler { public <T> List<T> handleResultSets(ResultSet rs, Class<T> resultType) throws SQLException { List<T> results = new ArrayList<>(); ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); String[] columnLabels = new String[columnCount]; for (int i = 0; i < columnCount; i++) { columnLabels[i] = metaData.getColumnLabel(i + 1); } while (rs.next()) { try { T instance = resultType.getDeclaredConstructor().newInstance(); for (int i = 0; i < columnCount; i++) { String columnName = columnLabels[i]; Field field = findFieldByColumn(resultType, columnName); if (field == null) continue; Object value = getResultValue(rs, i + 1, field.getType()); field.setAccessible(true); field.set(instance, value); } results.add(instance); } catch (ReflectiveOperationException e) { throw new SQLException("映射结果集失败: " + resultType.getName(), e); } } return results; } private Field findFieldByColumn(Class<?> clazz, String columnName) { // 优先匹配@Column注解,其次匹配字段名下划线转驼峰 for (Field field : clazz.getDeclaredFields()) { Column column = field.getAnnotation(Column.class); if (column != null && column.name().equalsIgnoreCase(columnName)) { return field; } } String camelCase = camelCase(columnName); try { return clazz.getDeclaredField(camelCase); } catch (NoSuchFieldException e) { return null; } } }

这个映射器的性能不一定比MyBatis里基于Reflector的缓存映射高,但它胜在“看得到底”,你完全能追踪每个字段是怎么从ResultSet里取出并赋值的。实际项目中如果遇到性能瓶颈,优先优化方向是把字段映射关系缓存起来,而不是一天到晚用getDeclaredFields()反射取字段。我在自己项目里改的第一处就是把findFieldByColumn的查找结果放到ConcurrentHashMap里做缓存,实测单次查询耗时降了一半。

4. 事务管理机制与连接池整合:从自动提交到手动控制的边界

4.1 JdbcTransactionManager的实现

事务管理是ORM里最容易写错的部分。这份案例代码里的JdbcTransactionManager只有三个核心方法,但每个方法都值得仔细读一遍。片段如下:

public class JdbcTransactionManager { private final Connection connection; public JdbcTransactionManager(Connection connection) { this.connection = connection; } public void begin() throws SQLException { if (connection.getAutoCommit()) { connection.setAutoCommit(false); } } public void commit() throws SQLException { if (!connection.getAutoCommit()) { connection.commit(); connection.setAutoCommit(true); } } public void rollback() throws SQLException { if (!connection.getAutoCommit()) { connection.rollback(); connection.setAutoCommit(true); } } }

这里的边界在于:能不能嵌套开启多个事务?代码里没有做事务计数器,直接对同一个Connection调用两次begin()不会出问题,但第一次commit()就会把事务提交掉。所以在使用这份案例时,我一般会在自己的Service层维护一个ThreadLocal计数器来模拟传播行为,遇到嵌套调用时最外层统一提交,这样不会把底层事务搞乱。

4.2 简单连接池的实现与参数调优

案例里的连接池是自己写的SimpleDataSource,用BlockingQueue存放空闲连接,代码不复杂,但参数值得关注:

public class SimpleDataSource implements DataSource { private final BlockingQueue<Connection> idleConnections; private final int maximumPoolSize; private final AtomicInteger currentCount = new AtomicInteger(0); public SimpleDataSource(String url, String user, String password, int maxSize) { this.maximumPoolSize = maxSize; this.idleConnections = new LinkedBlockingQueue<>(maxSize); // 预创建核心连接 for (int i = 0; i < Math.min(2, maxSize); i++) { idleConnections.offer(createConnection(url, user, password)); } } @Override public Connection getConnection() throws SQLException { Connection conn = idleConnections.poll(); if (conn != null) { return conn; } if (currentCount.incrementAndGet() <= maximumPoolSize) { return createConnection(url, user, password); } currentCount.decrementAndGet(); // 等待其他线程归还连接 try { return idleConnections.poll(3, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new SQLException("获取连接超时"); } } @Override public void returnConnection(Connection conn) { idleConnections.offer(conn); } }

关于连接池我有几点实战调参建议:

  • maximumPoolSize不是越大越好,MySQL默认max_connections通常只有100多,我一般在压测环境里边调边看,从10起步,逐步加到20就发现TPS不再涨了。
  • idleConnections队列满的时候,offer()返回false,但连接不会关闭,渗漏风险很大。建议在returnConnection里加一个判断:队列满则直接conn.close(),避免连接泄漏。
  • 这个连接池没有检测死连接的功能,MySQL空闲8小时会断掉连接,导致第一次查询报错。我在改造时加了一个validationQuery机制,每次从池里取连接时先执行SELECT 1,不行就丢弃重建。

把这些整合起来,调用链就清晰了:SqlSession创建时从SimpleDataSource拿连接,事务管理器负责开关自动提交,Executor负责执行SQL并映射结果,最后SqlSession.close()把连接归还给连接池。整个过程没有Spring这种大容器管着,反而让每一步都透明可见。

4.3 事务边界与MappedStatement的协调

还有一个容易踩的雷是:SqlSession内部已经setAutoCommit(false)之后,如果再调用selectList(),走的还是同一个连接,没问题;但如果你在同一线程里创建了两个SqlSession,它们各自持有不同的连接,事务就互相隔离了。所有ORM框架处理事务的关键思想都是“一个事务绑定一个连接”,所以改造时如果想做“跨Session事务”,必须引入ThreadLocal把连接绑定到当前线程,或者直接用TransactionSynchronizationManager这类现成方案。

5. 常见踩坑与排查记录:跑起来容易,跑得稳全靠这几条

5.1 占位符解析把SQL里的字符串字面量也替换了

  • 现象:SQL里写了WHERE name = '#{name}'这种查询,结果参数没传进去,反而把字面量里的#{name}替换掉导致SQL语法错误。
  • 原因:最初的SqlTextParser用的是全局replaceAll正则,只要匹配到#{xxx}就替换,完全不看上下文位置。
  • 解决:改成游标扫描的方式,逐字符遍历SQL字符串,遇到单引号时做状态切换,只有不在字符串字面量内时才能解析#{xxx}。这个坑几乎每个手写ORM都会撞上,我在二次开发时也把同样的逻辑应用到了@Update注解上。

5.2 自动提交与事务混用导致数据错乱

  • 现象:代码里先调了sqlSession.beginTransaction(),执行两条insert后忘记commit()就调close(),结果数据回滚了但日志里没有任何报错。
  • 原因:close()里检测到未提交事务自动rollback(),这是案例的设计,但使用者不了解这个“隐形回滚”机制会误以为是数据丢失。
  • 解决:在调用入口层的finally块里显式判断是否开启过事务,开启过就统一在finally里提交或回滚。我自己的做法是写了一个小的模板方法类,把begin/commit/rollback/close生命周期封装成一个execute(SqlCallback)方法,从根源上杜绝这种遗漏。

5.3 反射映射大量字段时的性能骤降

  • 现象:一张表30多个字段,查询列表页时接口响应时间从50ms涨到500ms。
  • 原因:ResultSetHandler每次查询都调用getDeclaredFields(),字段越多反射开销越大。
  • 解决:在Configuration初始化时预热实体类的字段映射表,用ConcurrentHashMap<Class<?>, FieldMapping[]>缓存,后续查询直接取缓存数组。修改后整个列表接口降回到60ms,优化效果立竿见影。

5.4 order by参数用#{}传值导致SQL语法报错

  • 现象:SELECT * FROM user ORDER BY #{sortField}传了create_time,结果SQL报错说syntax error near '?'。
  • 原因:#{}被替换成?,但ORDER BY ?在MySQL里会被当成排序值而不是排序列名,数字列名还勉强通过,字符串直接语法错误。
  • 解决:动态SQL解析时增加一个${}原样拼接语法,用于列名和表名等结构性参数,但使用时要小心SQL注入。如果你不希望引入${},可以把排序列名做白名单限制,我在代码里就是枚举允许排序的字段,不允许直接透传。

5.5 连接池没有归还连接导致Connection耗尽

  • 现象:压测跑到500TPS时系统大量报错Connection is not available。
  • 原因:查询抛异常时SqlSession.close()没有被执行到,连接一直卡在executor里没归还。
  • 解决:在SimpleExecutor.close()里加了一个兜底——先把ResultSet和PreparedStatement都关掉,再关连接。但治本办法还是要求调用方把SqlSession放在try-with-resources块里,Java的AutoCloseable此时才算真正用起来。

6. 二次开发实战:把这份案例改造成支持自定义逻辑删除的ORM

读完全部源码之后,我一次真实的改造是在一个模拟项目X中把这份案例做成了逻辑删除支持。逻辑删除的核心是在SQL执行前自动追加deleted = 0条件。思路是:利用MappedStatement里记录的sqlType,在生成BoundSql之后对它做一次SQL改写。改造点有三个。

第一步,给MappedStatement增加一个logicDelete配置字段:

public class MappedStatement { private boolean logicDelete = false; private String logicDeleteColumn = "deleted"; public BoundSql boundSql(Object param) { BoundSql boundSql = SqlSourceBuilder.build(sqlTemplate, param); if (sqlType == SqlCommandType.SELECT && logicDelete) { String sql = boundSql.getSql(); if (!sql.toLowerCase().contains("where")) { sql += " WHERE " + logicDeleteColumn + " = 0"; } else { sql += " AND " + logicDeleteColumn + " = 0"; } boundSql = new BoundSql(sql, boundSql.getParameterMappings(), param); } return boundSql; } }

这个改动最怕的是用户SQL里已经有deleted字段条件,重复追加会导致互斥数据查不出来,所以我在改写前加了检测逻辑——用正则匹配deleted\s*=,如果已经存在条件就不再追加。第二步,在@Update注解的处理里也做类似拦截,把UPDATE user SET ...改写成UPDATE user SET ... , deleted=0 WHERE ...——不过这个实现比较暴力,更优雅的方式是把deleted=0改写成deleted=1,具体业务场景决定。第三步是包装ResultSetHandler,确保查询出来的实体对象里deleted字段不会显示出来,避免上层业务被这个字段困扰。

改完这三步后,我在一个模拟项目X里做了验证:先插入一条数据,再调用查询接口,发现SQL日志里自动追加了AND deleted = 0,手动在数据库里把deleted置为1后,接口查询结果消失但数据还在表中。这个效果基本对标了MyBatis-Plus的逻辑删除能力,而且整个改动过程因为源码结构清晰,只用了两个小时。

接下来另一个方向是把连接池换成HikariCP。做法也很简单,SimpleDataSource本来实现的就是DataSource接口,只需把SqlSession的构造器改成接收DataSource接口类型,然后配置HikariCP的bean即可,上层业务代码一行都不用动。

从头到尾做完一次改造,记忆最深的教训是:动手改框架之前,一定要先把SQL解析和连接管理这两块核心逻辑阅读至少一遍,否则你在业务层打的补丁越多,框架层越难维护。从那以后我每次拿到一个ORM框架,都强制自己先读Executor和TransactionManager两个实现,再开始写业务代码。这算是我从这份案例代码里得到的最有价值的习惯。希望这份项目解析笔记能帮到你,下次再遇到“ORM黑盒”问题时,你也有了从底层拆解它的底气。

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

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

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

立即咨询