写JDBC写了三年,我发现大多数新手写的JDBC代码都有一个通病:一顿操作猛如虎,一看代码二百五。数据库连接、PreparedStatement、ResultSet、try-catch-finally,每个方法里都复制粘贴一遍,表结构一改,全项目遭殃。这篇博文不聊虚的,就聊怎么用面向对象的思路把JDBC程序从“能用”变成“好用”。无论你是刚学Java的在校生,还是写了两三年业务代码想重构数据访问层的开发,这篇文章都能给你一套可以立刻落地的方案。
面向对象的核心不是“把代码写进类里”,而是把变化封装起来、把依赖抽象出来。JDBC程序最容易变的是什么?数据库类型、SQL语句、结果集映射、异常处理。如果这些变化全部散落在业务代码里,那你写的就还是面向过程。反过来,如果你能把连接管理、SQL执行、结果映射这些重复劳动封装成通用组件,业务层只需要关心“我要查什么、我要存什么”,这才是面向对象该有的样子。
下面我会从设计思路、核心代码、完整案例、常见坑点四个维度展开,全程用实际代码说话,所有内容都基于我自己的项目实践,你可以直接照着改。
1. 为什么JDBC程序必须走向面向对象
1.1 原生JDBC开发的核心痛点
很多教程在教JDBC时,习惯写一个“三步走”:加载驱动、获取连接、执行SQL。这种写法没有错,但一旦落到真实项目里,问题立刻暴露。
第一是代码重复。每写一个查询方法,你都要重复写Class.forName、DriverManager.getConnection、prepareStatement、executeQuery、ResultSet遍历,然后finally关闭。一个项目如果有二十张表的增删改查,这些模板代码就会重复上百次。这不仅是体力活,更致命的是,一旦你换数据库驱动、改连接参数,所有方法都要跟着改。
第二是耦合严重。SQL写在业务方法里,ResultSet的字段提取也写在业务方法里,表结构一变,业务代码马上崩。更难受的是,业务层和数据库操作完全纠缠在一起,你没法单独测试某一个模块,也没办法快速替换数据库实现。
第三是异常处理混乱。原生JDBC的checked exception(SQLException)要求你必须处理,于是你看到满屏幕的try-catch,要么直接吞掉异常,要么throws抛给上层,最后根本不知道问题出在哪。
第四是资源泄漏。Connection、Statement、ResultSet这三个资源,任何一个忘记关闭,在高并发下都会把数据库连接池打满,应用直接假死。而这种问题,新手往往过了好几天才会发现。
1.2 面向对象解决什么:封装、抽象、复用
面向对象的核心价值,就是处理“变化”。JDBC程序里有哪些变化?我总结了四类。
- 数据库产品变了:Oracle切MySQL,只需要改驱动和URL,不能让业务代码跟着改。
- SQL语句变了:由具体需求决定,因为SQL本身携带业务规则,所以SQL适合放在独立的方法中,但执行逻辑应当复用。
- 结果集映射变了:查询结果转换成Java对象,这部分最繁琐,也最有封装空间。
- 事务处理变了:默认自动提交、手动提交、回滚策略,应该集中控制,而不是散落在业务代码里。
把这几类变化分别封装到不同层次,业务层面对的不再是Connection和ResultSet,而是一个个“对象”,这就是面向对象思路的核心价值。
1.3 生活化类比:从路边摊到中央厨房
你可以把原生JDBC想象成路边小吃摊:每一道菜都是从洗菜、切菜、炒菜到装盘全在一口锅里完成,客人多了就手忙脚乱,厨师一走摊子就黄了。而面向对象的JDBC像一个中央厨房:采购、切配、炒制、装盘各自有专门的岗位,后厨通过菜单(DAO接口)接单,厨师照着标准流程做菜,换菜单只需要改配菜间的食谱,不用把整个厨房拆了重建。
这个类比里,DataSource是采购部门,DAO是切配间,SQL映射是菜谱,事务则是厨房的规章制度。当你的数据访问层做成这种结构,增删改查对你来说就不再是重复劳动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 面向对象JDBC的总体设计与分层
2.1 分层设计:实体层、工具层、DAO层、业务层
我推荐的方案是经典的四层结构,虽然不是最复杂的设计,但对绝大多数项目来说性价比最高。
- Entity层(实体层):一张表一个类,字段和表结构一一对应,有些项目也叫POJO或Model。
- Util层(工具层):封装数据库连接和资源释放,对外提供getConnection和closeAll方法。
- DAO层(数据访问层):定义增删改查接口,提供实现类,每个实体对应一个DAO接口和实现。
- Service层(业务层):调用DAO完成业务逻辑,通常在Service层控制事务。
有人会问,为什么需要DAO接口和实现拆分?直接写一个UserDAO类不就行了?接口的意义在于抽象:Service层依赖接口,不代表具体实现。将来你把JDBC换成MyBatis,只需要提供一个新的DAO实现类,业务层一行代码都不用改。这就是“依赖倒置”,面向对象设计的一条核心原则。
mybatis-plus的Service实现里,接口继承IService、实现类继承ServiceImpl,你会发现本质上和我说的这套结构一模一样。所以你现在把JDBC的DAO层写好,今后学任何ORM框架都会觉得似曾相识。
2.2 关键类的职责划分
每个类只干一件事,这是面向对象最朴素也是最重要的原则。
- DbUtils类:负责获取连接和关闭资源,静态方法为主。这里我通常只做连接获取和资源关闭,不掺入任何业务代码。
- BaseDAO类:泛型抽象类,封装通用的增删改查。具体DAO继承它,自动获得findById、findAll、save、update、deleteById这些通用能力。
- UserDAO类:如果通用方法够用,可以一行代码都不写,直接继承BaseDAO。当有特殊SQL需求(比如根据用户名查用户、分页查询)时,再在UserDAO中补充自定义方法。
- UserService类:业务层,一个方法对应一个完整业务单元,如“注册用户”“修改密码”,内部可以调用多个DAO方法,同时控制事务。
2.3 包结构规划与命名规范
包结构直接决定了项目的可读性,我喜欢按外层技术栈、内层业务模块来组织。
com.example.demo.entity -- 实体类
com.example.demo.util -- 工具类
com.example.demo.dao -- DAO接口
com.example.demo.dao.impl -- DAO实现
com.example.demo.service -- 业务类
这套结构的好处是,任何新同事接手项目,看一眼包名就知道该去哪里找代码。实体类命名和数据库表名保持一致,比如user表对应User类,字段直接用驼峰命名,如果数据库字段是下划线风格,就在SQL里用列别名转换。
3. 核心代码实现:从连接工具类到泛型DAO
3.1 数据库连接工具类封装
连接管理是最容易被忽略却最关键的部分。早期JDBC都是DriverManager直接获取连接,但真实项目强烈推荐使用连接池(HikariCP或Druid)。下面我先写一个不含连接池的工具类,方便理解原理,然后再补一个连接池版本。
java复制package com.example.demo.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class DbUtils {
private static final String DRIVER = "com.mysql.cj.jdbc.Driver";
private static final String URL = "jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai";
private static final String USER = "root";
private static final String PASSWORD = "123456";
static {
try {
Class.forName(DRIVER);
} catch (ClassNotFoundException e) {
throw new ExceptionInInitializerError("数据库驱动加载失败,请检查依赖");
}
}
private DbUtils() {
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
public static void closeAll(Connection conn, Statement stmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (stmt != null) {
try {
stmt.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
这里最值得说的是static代码块。驱动只需要加载一次,用static块做类加载时初始化最合适。如果你把Class.forName放在每次getConnection里,虽然语法没错,但每次连接都去加载驱动,纯属浪费性能。
构造函数改成private,是为了防止别人new一个没有意义的DbUtils对象。工具类全是静态方法,根本不需要实例化,private构造器是从编码规范上堵住滥用。
3.2 实体类的编写方式
实体类对应数据库表,最简单的写法就是私有字段加getter/setter。用Java的话,我会额外重写toString方法,方便调试时打印。如果使用lombok,直接用@Data注解更省事。为照顾不熟悉lombok的读者,我这里写完整版。
java复制package com.example.demo.entity;
public class User {
private Integer id;
private String username;
private String password;
private Integer age;
public User() {
}
public User(Integer id, String username, String password, Integer age) {
this.id = id;
this.username = username;
this.password = password;
this.age = age;
}
// 省略getter和setter
// 省略toString
}
实体类需要注意几个细节:必须提供无参构造器,因为后续要用反射调用Class.newInstance()创建对象;字段类型用包装类而不是基本类型,因为数据库字段可能是NULL,如果是int类型,查询NULL时会直接抛异常;字段命名要和SQL查询结果的列名对应,如果对不上,手工映射会非常痛苦。
3.3 手写泛型BaseDAO,通用增删改查
这是整篇文章最关键的部分。我用泛型+反射实现一套通用的增删改查,让子类零代码完成常规操作。
java复制package com.example.demo.dao;
import com.example.demo.entity.User;
import com.example.demo.util.DbUtils;
import java.lang.reflect.Field;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public abstract class BaseDAO<T> {
private final Class<T> clazz;
@SuppressWarnings("unchecked")
public BaseDAO() {
// 通过反射获取子类实际泛型类型
this.clazz = (Class<T>) ((java.lang.reflect.ParameterizedType) getClass()
.getGenericSuperclass()).getActualTypeArguments()[0];
}
public List<T> findAll() {
// 表名默认是类名的下划线形式,比如User -> user
String tableName = toTableName(clazz.getSimpleName());
String sql = "SELECT * FROM " + tableName;
return queryList(sql);
}
public T findById(Integer id) {
String tableName = toTableName(clazz.getSimpleName());
String sql = "SELECT * FROM " + tableName + " WHERE id = ?";
List<T> list = queryList(sql, id);
return list.isEmpty() ? null : list.get(0);
}
public int save(T entity) {
Field[] fields = clazz.getDeclaredFields();
StringBuilder columns = new StringBuilder();
StringBuilder values = new StringBuilder();
List<Object> params = new ArrayList<>();
for (Field field : fields) {
field.setAccessible(true);
try {
// 自增主键不参与insert
if ("id".equals(field.getName()) && field.get(entity) == null) {
continue;
}
if (columns.length() > 0) {
columns.append(", ");
values.append(", ");
}
columns.append("`").append(toColumnName(field.getName())).append("`");
values.append("?");
params.add(field.get(entity));
} catch (IllegalAccessException e) {
throw new RuntimeException("实体字段访问失败", e);
}
}
String sql = "INSERT INTO " + toTableName(clazz.getSimpleName())
+ " (" + columns + ") VALUES (" + values + ")";
return update(sql, params.toArray());
}
private List<T> queryList(String sql, Object... params) {
List<T> list = new ArrayList<>();
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
conn = DbUtils.getConnection();
ps = conn.prepareStatement(sql);
for (int i = 0; i < params.length; i++) {
ps.setObject(i + 1, params[i]);
}
rs = ps.executeQuery();
ResultSetMetaData metaData = rs.getMetaData();
int columnCount = metaData.getColumnCount();
while (rs.next()) {
T obj = clazz.newInstance();
for (int i = 1; i <= columnCount; i++) {
String columnLabel = metaData.getColumnLabel(i);
String fieldName = toCamelCase(columnLabel);
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, rs.getObject(i));
}
list.add(obj);
}
} catch (Exception e) {
throw new RuntimeException("查询失败: " + sql, e);
} finally {
DbUtils.closeAll(conn, ps, rs);
}
return list;
}
protected int update(String sql, Object... params) {
Connection conn = null;
PreparedStatement ps = null;
try {
conn = DbUtils.getConnection();
ps = conn.prepareStatement(sql);
for (int i = 0; i < params.length; i++) {
ps.setObject(i + 1, params[i]);
}
return ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("更新失败: " + sql, e);
} finally {
DbUtils.closeAll(conn, ps, null);
}
}
private String toTableName(String className) {
// 简单的驼峰转下划线
return className.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
}
private String toColumnName(String fieldName) {
return fieldName.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
}
private String toCamelCase(String columnName) {
StringBuilder sb = new StringBuilder();
boolean upper = false;
for (char c : columnName.toCharArray()) {
if (c == '_') {
upper = true;
} else if (upper) {
sb.append(Character.toUpperCase(c));
upper = false;
} else {
sb.append(c);
}
}
return sb.toString();
}
}
这段代码里有几个关键点需要单独讲。
泛型的获取是通过getGenericSuperclass完成的。子类写class UserDAO extends BaseDAO
ResultSet到对象的映射,靠的是ResultSetMetaData提供的列名。你在SQL里写SELECT username AS user_name,metaData.getColumnLabel返回的就是别名。所以表字段用下划线、实体字段用驼峰,完全可以通过这一层自动转换。
这里只是演示手写映射的核心逻辑,生产环境的字段类型转换会更复杂(比如数据库的DATE到java.time.LocalDate),但这已经足够给你一个扩展的方向。
3.4 UserDAO:具体DAO实现
有了BaseDAO,UserDAO短得令人舒适。
java复制package com.example.demo.dao;
import com.example.demo.entity.User;
public class UserDAO extends BaseDAO<User> {
}
就这些。findAll、findById、save、update、deleteById这些通用方法,UserDAO天然就有了。如果某个查询只有User需要,直接在UserDAO里加自定义方法。
java复制public User findByUsername(String username) {
String sql = "SELECT * FROM user WHERE username = ?";
List<User> list = queryList(sql, username);
return list.isEmpty() ? null : list.get(0);
}
但这里注意,queryList在BaseDAO里是private,自定义方法需要用到它,所以要把queryList和update从private改成protected,这样具体DAO子类才能调用。这是我在实际编码中踩过的坑,一开始全用private,结果子类想复用却动不了,只好改成protected或者用public的工具方法暴露。最终我倾向于把通用方法保留protected,并且保证它们不参与业务逻辑,属于“可复用的原子能力”。
3.5 Service层事务控制
事务控制放在Service层最合适,因为一个业务方法可能调用多个DAO方法,要么全部成功,要么全部回滚。
java复制package com.example.demo.service;
import com.example.demo.dao.UserDAO;
import com.example.demo.entity.User;
import com.example.demo.util.DbUtils;
import java.sql.Connection;
import java.sql.SQLException;
public class UserService {
private UserDAO userDAO = new UserDAO();
public boolean register(String username, String password, Integer age) {
Connection conn = null;
try {
conn = DbUtils.getConnection();
conn.setAutoCommit(false);
User user = new User(null, username, password, age);
int result = userDAO.save(user);
conn.commit();
return result > 0;
} catch (SQLException e) {
try {
if (conn != null) {
conn.rollback();
}
} catch (SQLException ex) {
ex.printStackTrace();
}
throw new RuntimeException("注册失败", e);
} finally {
DbUtils.closeAll(conn, null, null);
}
}
}
注意,在同一个事务里的所有DAO操作,必须使用同一个Connection,否则事务不生效。上面这个例子只有一个save操作,体现不明显。如果将来在一个事务里调用多个DAO方法,就需要考虑用ThreadLocal把Connection绑定到当前线程,或者引入Spring的@Transactional。这里先记住一个原则:事务作用域在Service,Connection传递是核心难题,等真正需要跨DAO事务时再上ThreadLocal或框架。
4. 实操过程:用户管理模块完整案例
4.1 环境准备与依赖配置
我自己用的开发环境是JDK 8 + IDEA 2022.1 + MySQL 8.0,Maven项目。如果你用SQLServer,驱动依赖改成com.microsoft.sqlserver:mssql-jdbc,URL改成jdbc:sqlserver://localhost:1433;DatabaseName=demo,原理完全一样。
Maven依赖这里只需要一个MySQL驱动:
xml复制<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
如果IDEA里依赖下载失败,报download from maven failed,多半是网络访问中央仓库受限。解决方法是改用阿里云镜像或腾讯云镜像,在settings.xml里配置mirror。具体做法我给你写下来。
xml复制<mirror>
<id>aliyun</id>
<mirrorOf>central</mirrorOf>
<name>Aliyun Maven Mirror</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
配置完成后,重启IDEA让它重新导入项目,基本就能解决。这个问题在热词里出现频率很高,本质是IDEA自带的Maven仓库地址在国外,下载依赖总是超时。如果你的公司有内部私服,把mirrorOf配成私服地址效果更好。
4.2 数据库表准备
执行下面的SQL创建一张user表:
sql复制CREATE TABLE `user` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL,
`password` VARCHAR(100) NOT NULL,
`age` INT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
数据库字段我这里故意用下划线风格,实体类用驼峰,这样能体现出ResultSet映射的自动转换能力。
4.3 测试插入用户数据
写一个测试类,模拟“第1关:JDBC插入用户数据”这个场景。
java复制package com.example.demo;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
public class Main {
public static void main(String[] args) {
UserService userService = new UserService();
boolean ok = userService.register("zhangsan", "123456", 25);
System.out.println("插入结果: " + ok);
UserDAO userDAO = new UserDAO();
User user = userDAO.findById(1);
System.out.println("查询结果: " + user);
}
}
执行后控制台应该打印“插入结果: true”,然后输出一个User对象。如果发现查询到的User是null,先检查BaseDAO里的表名生成逻辑,我的代码是用类名小写当表名,所以User类对应user表,如果表名带前缀(比如t_user),需要重写toTableName方法,或者直接加一个表名注解。
4.4 模拟真实业务场景:登录校验
除了增删改查,我再演示一个最常见的业务逻辑:登录校验。在UserService里加一个login方法。
java复制public User login(String username, String password) {
User user = userDAO.findByUsername(username);
if (user != null && user.getPassword().equals(password)) {
return user;
}
return null;
}
这个方法的逻辑看起来很简单,但有一个安全细节要提醒:真实场景下密码不能明文存储,至少要加盐哈希。我在演示项目里用明文是为了简化,生产环境建议使用BCrypt加密后再比对。这不是JDBC的问题,而是面向对象设计里“职责分离”的体现:业务层负责校验规则,DAO层负责数据读写,各管一摊。
4.5 完整运行效果与效果验证
运行测试后,你会在控制台看到类似输出:
text复制插入结果: true
查询结果: User{id=1, username='zhangsan', password='123456', age=25}
到这里,一个基于“面向对象思路”的JDBC程序已经跑通了。你可以继续扩展:加一个update方法更新用户年龄,加一个deleteById方法删除用户,每加一个方法都是在现有架构上做加法,而不是重写一遍。
5. 典型问题排查与避坑经验
5.1 IDEA报错“download from maven failed”
这几乎是每个用IDEA写JDBC的新手都会遇到的问题。IDEA在第一次导入Maven项目时会自动下载依赖,如果仓库地址连不通,就会报这个错。
排查顺序应该是:先看本地仓库路径有没有生成对应的jar包,没有的话看下载日志里的URL是什么,最后检查mirror配置。我遇到过一种迷惑情况:settings.xml配置没问题,IDEA却还在连中央仓库,这时候去Settings -> Build Tools -> Maven,把User settings file换成你配置的正确路径,而且要确保Local repository路径一致。改完点一下Reload All Maven Projects,等右下角进度条跑完就正常了。
还有一个冷门原因:本地仓库里存在一个下载了一半的.lastUpdated文件,IDEA会认为依赖已经在本地,其实根本不能用。解决方案是去本地仓库把对应的目录删掉,再重新reimport。
5.2 SQLServer的targetServerType到底是什么
热词里出现了“jdbc targetservertype含义”,这是SQLServer JDBC驱动特有的连接参数。常见值是databaseEngine,作用是告诉驱动直接连数据库引擎,而不是走前置的SQL Server Browser服务。当你用sqlserver的jdbc url时,如果总是连接超时,可以加一句:
text复制jdbc:sqlserver://host:1433;DatabaseName=demo;targetServerType=databaseEngine
targetServerType的可选值还包括failover、replica等,分别用于高可用读副本场景。普通单机开发,直接配databaseEngine最省事,它可以跳过一些不必要的服务发现过程,降低握手延迟。我当年第一次配SQLServer连接时没加这个参数,结果连了三次全都超时,后来查文档才发现连接字符串里每个分号分隔的参数名不能拼错,一旦拼错驱动不会报编译错,而是默默忽略,然后连接失败。
5.3 Flink JDBC连接器异常怎么排查
如果你是搞实时计算的,Flink JDBC连接器报错也别怕,大部分原因是驱动版本和数据库版本不匹配,或者连接参数没对齐。我见过一个项目用的是Flink 1.14,mysql驱动用了5.1.49,结果直接抛异常说driver not found。解决方法是换mysql-connector-java 8.x,并确保pom里scope不是provided。
另一个常见坑是checkpoint恢复后连接池失效,导致“connection is not available, request timed out”之类的错误。这时候优先看连接的保活配置,在JDBC URL里加上autoReconnect=true和useSSL=false,同时在连接器参数里加大空闲超时时间。Flink端到端延迟变高时,很多人第一反应是调并行度,其实先检查数据库连接池参数更有效。
5.4 Elasticsearch版本兼容问题
热词里那句“this version of the jdbc driver is only compatible with elasticsearch versio”,其实是连接Elasticsearch SQL时的驱动版本校验。Elasticsearch的JDBC驱动(es-jdbc)版本必须与ES服务端版本大版本一致,比如ES 7.10就要用7.10的驱动,不能拿6.x的去连7.x,否则驱动会直接拒绝建立连接。
值得一提的是,Elasticsearch SQL JDBC驱动在不同版本里API改动很大,建议直接参考对应版本文档。如果你只是做简单查询,也可以不引入驱动,用REST接口配合ODBC,但从面向对象角度看,把ES访问封装成一个独立的Repository类,上层业务只依赖接口,以后换版本、换客户端实现都影响不到业务代码。这正是我们前面讲的依赖抽象,在非关系型数据库场景同样适用。
5.5 资源释放与事务回滚的实战心得
最后必须说资源释放。Connection、Statement、ResultSet必须遵循“后打开的先关闭”原则,也就是先关ResultSet,再关Statement,最后关Connection。我的DbUtils.closeAll就是按这个顺序处理的。
事务回滚方面,最容易被忽视的是回滚语句本身也可能抛SQLException。很多同学写rollback时不包try-catch,导致回滚失败时反而抛出新异常,把原始异常遮盖掉了。我在上面UserService的代码里已经做了处理,虽然看起来啰嗦,但真实项目必须这样写。
另一个心得是:用完的连接一定要还,使用连接池时更是如此。我记得有个项目线上频繁出现too many connections,查了半天,发现是一个查询方法在业务上return之后,finally里的closeAll根本没走到,因为return之前在手动事务的commit阶段抛了异常。后来把finally里的资源关闭代码重构到最外层,问题就消失了。
5.6 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| ClassNotFoundException | 没引入JDBC驱动依赖 | 检查pom.xml,确认driver坐标 |
| Communications link failure | URL/端口/防火墙问题 | 确认数据库地址和端口,测试telnet |
| download from maven failed | 中央仓库不可达 | 配置阿里云镜像,清理.lastUpdated |
| SQLServer连接超时 | 缺targetServerType参数 | 连接串加databaseEngine |
| 查询结果全为null | 列名和实体字段映射不上 | 用列别名或检查toCamelCase逻辑 |
| 操作数据库中文乱码 | MySQL连接编码不对 | URL加characterEncoding=utf8 |
| 事务没生效 | 同一个事务用了不同Connection | 借助ThreadLocal绑定连接或引入框架 |
6. 面向对象JDBC的进阶方向
6.1 从手写JDBC到ORM框架的演进
如果你把上面的BaseDAO继续打磨,封装注解、封装CRUD拼接、缓存元数据,你其实就是在重写一个微型MyBatis。我曾经在项目里手写过一套简单的ORM,用了自定义注解@Table、@Column,通过反射在类加载时解析元数据并缓存,加上简单的条件构造器。跑通之后,我对MyBatis底层原理的理解比看十篇源码解析都深刻。
如果你现在还在用原生JDBC,我建议不要急着上框架,先自己封装一遍。等你在封装过程中遇到“映射嵌套对象”“延迟加载”“动态SQL”这些问题时,再去看MyBatis官方文档,你会发现自己跟作者踩过同一条路。
6.2 泛型与反射的进一步应用
通用DAO里用的泛型和反射只是冰山一角。更进一步,你可以做字段级元数据缓存、类型转换器注册表、SQL条件对象封装。比如设计一个QueryBean,通过链式调用生成where条件:
java
QueryBean query = new QueryBean();
query.eq("age", 25).like("username", "zhang");
List
code复制
这样业务层写出来的东西就非常接近流畅API了,读起来像在用JPA的Specification。核心思路还是面向对象:把SQL片段建模成对象,再通过对象解释器生成SQL。这里面有一个容易被忽略的坑,就是使用反射时要处理好字段访问权限,setAccessible(true)在Java 17之后需要额外的模块开放参数,如果你用新版本JDK,需要留意。
### 6.3 连接池、事务管理与设计模式的结合
数据源替换成HikariCP非常简单,只需要把DbUtils的getConnection改为从DataSource获取。
java
private static final HikariDataSource dataSource = new HikariDataSource();
static {
Properties props = new Properties();
props.setProperty("jdbcUrl", URL);
props.setProperty("dataSource.user", USER);
props.setProperty("dataSource.password", PASSWORD);
props.setProperty("maximumPoolSize", "10");
// 其他属性按需配置
dataSource = new HikariDataSource(new HikariConfig(props));
}
连接池的好处不用我多说,但有一点要注意:HikariCP默认会检测连接可用性,如果你配置了connectionTestQuery,MySQL 8建议直接不用配,因为驱动自带ping优化。配错了反而影响性能。
事务管理再往前走一步,就是引入代理模式。用动态代理给Service方法加事务拦截器,方法开始前开事务,方法正常结束提交,方法抛异常回滚。这其实就是Spring声明式事务的原理。等你能用Proxy + InvocationHandler手写一个简单事务代理时,再去理解@Transactional的传播行为,才会真正通透。
我个人在实际操作中最深的体会是,面向对象不是理论课,而是一条条代码改出来的。JDBC程序从面向过程改造成面向对象,最大的收益不是代码变短,而是重构成本骤降。系统一开始用DriverManager直连,后来换HikariCP,只用改动DbUtils一个类;表结构加了字段,只需要动实体类和SQL,业务层完全不感知。
最后再分享一个小技巧:每次写完一个DAO方法,就立刻想清楚这个方法的“变化点”在哪里。如果发现变化点是SQL本身,就把SQL留在具体方法里;如果发现变化点是结果映射,就把它收敛到通用的映射器里。坚持这个习惯三个月,你写出的代码自然就带上了面向对象的味道。
