1. 现实问题:拼 SQL 让安全和类型都失去边界
把用户输入直接拼进 SQL,输入中的引号可能改变语句结构,形成 SQL 注入;即使没有恶意,日期、字符串转义和 null 也会让代码变得脆弱。JDBC 的核心学习价值,是把连接、参数、执行、结果和关闭一段段看清楚,之后使用 MyBatis 或 Repository 仍然能定位问题。
我们写一个按状态查询文章的 DAO。示例中使用 PreparedStatement 占位符,明确查询列,并把每一行映射为不可变记录。连接字符串、账号和密码只作环境配置,不写进源码。
2. 最小可运行示例:绑定参数、映射结果
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class ArticleJdbcDemo {
record Article(long id, String title) {}
static List<Article> findPublished(Connection connection) throws Exception {
String sql = "SELECT id, title FROM article WHERE status = ? ORDER BY id DESC LIMIT ?";
List<Article> result = new ArrayList<>();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, "PUBLISHED");
statement.setInt(2, 20);
try (ResultSet rows = statement.executeQuery()) {
while (rows.next()) {
result.add(new Article(rows.getLong("id"), rows.getString("title")));
}
}
}
return List.copyOf(result);
}
public static void main(String[] args) throws Exception {
try (Connection connection = DriverManager.getConnection(
System.getenv("DB_URL"), System.getenv("DB_USER"), System.getenv("DB_PASSWORD"))) {
findPublished(connection).forEach(System.out::println);
}
}
}
运行前需要把 MySQL JDBC 驱动放进构建依赖,且环境变量指向可测试数据库;本文不在课程仓库创建完整 demo 工程。setString 和 setInt 让驱动知道参数类型,不能用字符串拼接替代。
3. 调用链与对象变化
DriverManager 根据 URL 选择已注册的 JDBC Driver,创建 Connection。prepareStatement 把 SQL 模板交给驱动,参数槽最初为空;set 方法把 Java 值绑定到槽位,驱动在执行时编码成数据库协议。数据库返回结果,ResultSet 维护当前行游标,next() 移动游标并告诉程序是否还有行。
每次 getLong、getString 都把当前行列值转成 Java 类型,DAO 创建一个 Article 记录放入 List。关闭 ResultSet、Statement 和 Connection 释放数据库端游标、语句和连接。List.copyOf 最后创建不可变结果,调用方不能反过来改变 DAO 的中间列表。
4. 为什么这样设计
PreparedStatement 的第一价值是参数与 SQL 结构分离,减少注入风险;第二价值是驱动可以复用解析和类型绑定路径。它不是权限和业务校验的替代品,仍要限制查询范围、列名和排序白名单。动态列名、表名不能用 ? 绑定,需要在受控枚举映射后拼入。
JDBC 资源顺序是 Connection → Statement → ResultSet,关闭顺序相反。DAO 不应把 Connection 作为全局单例长期持有,也不应在循环里每行重新建立连接。连接池会在更高层复用连接,但借用者仍必须关闭逻辑连接。
5. 项目落点:Repository 只做数据边界
博客系统可以有 ArticleRepository.findPublished(int limit) 和 findById(long id),Service 负责发布规则,DAO 负责 SQL 和映射。查询失败抛带 SQL 场景的基础异常,但不要把密码、完整 SQL 参数或敏感内容打进日志。数据库字段可空时使用 wasNull() 或明确的包装类型映射。
练习:实现 insertDraft,使用 RETURN_GENERATED_KEYS 取回 id;再增加一个 title 查询,测试引号、中文、空字符串和超长输入。用数据库日志确认最终发送的 SQL 结构没有把用户文本拼进去。
6. 易错排查
- 参数下标从 1 开始:
setString(0, ...)会失败,逐个核对 SQL 占位符。 - 连接忘记关闭:短测正常,长期运行耗尽数据库连接;统一 try-with-resources。
getString返回 null:数据库列可空,先决定 DTO 是 nullable 还是业务默认值。- 把 LIMIT 当字符串拼接:数值也应该绑定或经过范围校验,动态排序只能走白名单。
7. 一页复习
JDBC 链是驱动建连接、预编译 SQL、绑定参数、执行、移动 ResultSet、映射对象、关闭资源。PreparedStatement 分离结构与值,但不能替代校验和权限。DAO 只负责数据边界,Service 负责业务规则,调用链清楚后换 ORM 也不会失去判断力。
JDBC 的资源状态可以细化为:Connection 尚未借出、已建立但 autoCommit 状态未知、PreparedStatement 已绑定部分参数、ResultSet 位于当前行或已结束、资源已关闭。DAO 只有在成功关闭 ResultSet 和 Statement 后才应返回结果,Connection 的关闭要回到连接池边界。若参数为 null,使用 setNull 和明确 SQL 类型,不能把 Java null 拼成字符串 "null"。
输入到输出的对象变化是:HTTP/query 的文本先由上层变成 limit、status 等类型,PreparedStatement 保存模板和参数,数据库执行后 ResultSet 暴露列值,Article 记录复制出所需字段,List.copyOf 固化快照。映射层应明确时间、金额、可空列和枚举 code 的转换,不能让驱动默认转换隐藏语义。
项目文件可以在 persistence/jdbc/ArticleJdbcRepository 中保存 SQL 和 row mapper,在 application/article 中定义 Repository 接口。测试用假的 DataSource 验证参数绑定,用 Testcontainers 验证真实 SQL 和唯一键;两种测试目的不同。日志只记录 statement 名、耗时、受影响行数和参数类别,不打印密码或完整正文。
排错时 SQL 注入先检查是否存在字符串拼接,参数错位检查占位符从 1 开始和绑定顺序,结果少一行检查 while (rows.next()) 与过滤条件,连接泄漏检查异常路径和池等待。插入成功但 id 为 0,要查生成键选项、列类型和读取方式;查询中文乱码要同时看数据库连接字符集和 Java 解码。
练习是把 findPublished 改成带 cursor 的分页,要求按时间和 id 稳定翻页;写出第一页/第二页的输入输出表,测试同一时间多个 id、空结果、非法 limit、包含引号的关键词和数据库断连。最后用 SQL 客户端复核同一参数,确认问题发生在 Java 绑定还是数据库执行。
JDBC 示例里的每个变量都对应一段外部状态:Connection 表示数据库会话,PreparedStatement 表示 SQL 结构和参数槽,ResultSet 表示服务器返回的游标,Article 是应用内的值快照。关闭 ResultSet 后不能再读取当前行,关闭 Statement 后不能再次执行,关闭 Connection 后不能继续查询;资源状态不是编译器自动替你检查的。
参数绑定要按业务类型选择 setter:状态是受控字符串,limit 是经过范围校验的整数,时间要使用带时区或明确本地语义的类型,金额要与 DECIMAL 精度一致,空值要用 setNull 指定 SQL 类型。动态排序列不能靠 ? 绑定,应将请求中的 newest/oldest 映射为两段固定 SQL,结构和用户文本保持分离。
结果映射时,列名是数据库协议,字段名是 Java 对象属性,二者不一致就需要别名或显式 RowMapper。可空列不能直接假设 getLong 的 0 就是真实值,要结合 wasNull;枚举 code 不认识时应该返回映射错误;文章正文很大时要评估 getString 带来的内存峰值,不能把所有行无界收集进 List。
项目文件可把 SQL 常量和 ArticleRowMapper 放在 persistence,Repository 接口放在 application,事务组合放在 Service。DAO 单测可以用记录参数的 fake Connection,集成测用真实 MySQL 检查 SQL、索引和约束。生产日志使用 statement 名和参数类型,不记录密码、token、完整正文和带个人信息的关键词。
常见故障的原因不同:SQL 注入来自拼接结构,参数错位来自占位符顺序,连接耗尽来自异常路径没有关闭,结果少行来自 cursor 没有 next 到末尾,乱码来自连接/表/客户端字符集不一致。遇到“查询没有报错但数据不对”,先在客户端执行同一 SQL,再比较绑定值、事务提交和映射字段。
练习是实现 cursor 分页和插入草稿:输入包括 status、afterTime、afterId、limit,输出包括 rows 和 nextCursor;测试相同时间多个 id、空页、非法 limit、引号关键词、唯一冲突和断连。把每次测试的 SQL 模板、影响行数和最终数据库状态保留,形成后续 MyBatis 映射的基准。
验证清单:用同一个查询分别传入普通关键词、包含引号的关键词、空状态、非法 limit 和未知排序 key,记录 SQL 模板、参数类型/顺序、受影响行数和结果 id;SQL 文本不应随关键词改变,排序只能从白名单映射。故意让 ResultSet 在读取后关闭、让异常发生在 execute 后和让事务中途断开,确认 try-with-resources 依次归还 ResultSet、Statement、Connection,并把失败状态传给上层。复盘时把数据库行、Article 快照和返回 DTO 分开,避免把可变游标当领域对象长期保存。
进阶附录:批处理与生成键
批量插入可以使用 addBatch 和 executeBatch,但要设置合理批次、处理部分失败和事务边界。生成键的类型和数据库配置相关,读取后不要假设一定是 int。高并发服务应使用连接池和指标观察 active、idle、等待时间,而不是在 DAO 内自行创建线程。
本课按「JDBC 4.3、PreparedStatement 与关系数据访问」的学习范围组织,正文与示例均为本站原创整理。