不知道你有没有过这种经历:一个后台管理系统,实体类十几个,每个实体都要写一套增删改查。刚开始还能耐着性子复制粘贴改改表名和字段,写多了真想摔键盘。我见过太多项目里 UserDao、OrderDao、ProductDao 里面的代码长得几乎一模一样,区别只有 SQL 语句里的表名和占位符参数不一样。其实这个场景非常适合用 JDBC 封装一个通用的 BaseDao 数据库访问层,把重复的 CRUD 逻辑收敛到一个父类里,子类只负责提供实体类型,剩下的增删改查、批量操作、分页查询全部复用。这篇文章就把我实际项目中沉淀下来的一套 BaseDao 封装思路完整分享出来,代码可以直接拷贝到自己项目里改改用,同时也会把我在封装过程中踩过的坑逐个讲清楚。
1. 为什么我劝你先别急着上框架,而是自己动手封装一个BaseDao
1.1 原始JDBC开发的核心痛点
咱们先回到最原始的 JDBC 写法,你大概率写过类似的代码:
java复制public User findById(Long id) {
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
User user = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(URL, USERNAME, PASSWORD);
String sql = "SELECT id, username, password, nickname, email, create_time FROM user WHERE id = ?";
ps = conn.prepareStatement(sql);
ps.setLong(1, id);
rs = ps.executeQuery();
if (rs.next()) {
user = new User();
user.setId(rs.getLong("id"));
user.setUsername(rs.getString("username"));
user.setPassword(rs.getString("password"));
user.setNickname(rs.getString("nickname"));
user.setEmail(rs.getString("email"));
user.setCreateTime(rs.getTimestamp("create_time"));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (rs != null) {
try { rs.close(); } catch (SQLException e) { e.printStackTrace(); }
}
if (ps != null) {
try { ps.close(); } catch (SQLException e) { e.printStackTrace(); }
}
if (conn != null) {
try { conn.close(); } catch (SQLException e) { e.printStackTrace(); }
}
}
return user;
}
这段代码的问题很明显:第一,Connection、PreparedStatement、ResultSet 三件套的获取和关闭代码是重复的,每写一个方法就要复制一遍;第二,从 ResultSet 往实体对象里塞值的过程完全是体力活,字段少还好,字段一多,行数直接爆炸;第三,整个方法被 try-catch-finally 包得严严实实,实际业务逻辑只有那四五行,剩下全是模板代码。
一个实体写 7 个方法,10 个实体就是 70 个方法,而且绝大多数方法结构完全一样。这时候你就意识到,该做一次通用封装了。这也是 MyBatis、Hibernate 这类框架存在的意义——把 JDBC 的样板代码收敛掉。但在我们还没有引入重量级框架,或者项目里就是想保持轻量 JDBC 操作的时候,自己写一个 BaseDao 就是性价比最高的选择。
提示:BaseDao 的核心价值不是替代 MyBatis,而是在不引入复杂框架的前提下,把 Java 操作数据库的重复劳动降到最低。它特别适合小项目、工具类项目、临时脚本,以及想彻底搞清楚 ORM 底层原理的学习场景。
1.2 BaseDao要解决什么问题,不解决什么问题
动手写代码之前,先把边界划清楚。
BaseDao 要解决的,是那些统一规则之下的操作——单表 CRUD、根据主键查询、条件查询、批量插入、分页查询。这类操作占日常开发的大头,而且逻辑高度一致,非常适合抽象。它不解决的,是多表关联查询、复杂子查询、动态条件组合特别多的场景。你说一个 SQL 要 join 五张表,这种查询强行让框架去套壳,写出来的东西反而比手写 SQL 还难受,还不如直接在子类里加一个独立方法,保留原生 SQL 能力。
我的建议是:BaseDao 负责 80% 的通用 CRUD,剩下那 20% 的复杂查询,子类直接声明方法,在方法里手写 SQL,把 BaseDao 暴露出来的 getConnection() 方法直接用起来就行。这样既享受了封装带来的便利,又不会被封装限制住。
所以整个封装的设计原则其实是两条:
- 通用能力全部收敛到父类,子类尽量保持干净;
- 特殊需求不硬套,保留直接拿到连接操作 SQL 的口子。
带着这两条原则往下走,接下来看看核心的泛型和反射部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 泛型与反射:让BaseDao自己知道在操作哪张表
2.1 泛型如何拿到真实的实体类型
BaseDao<T> 是一个泛型抽象类,子类继承它的时候会写明具体实体类型,比如:
java复制public class UserDao extends BaseDao<User> {
}
问题来了:父类在运行时怎么知道 T 到底是什么?因为 Java 泛型在编译后会被擦除,你不能直接 T.class,所以需要通过反射去解析父类的泛型签名,代码长这样:
java复制@SuppressWarnings("unchecked")
protected BaseDao() {
Type superclass = getClass().getGenericSuperclass();
if (superclass instanceof ParameterizedType) {
ParameterizedType type = (ParameterizedType) superclass;
this.entityClass = (Class<T>) type.getActualTypeArguments()[0];
} else {
throw new RuntimeException("BaseDao必须指定泛型类型");
}
}
这里的逻辑是:UserDao 继承 BaseDao<User> 的时候,Java 会把 User 这个类型以 ParameterizedType 的形式记录在类元数据里,子类实例化时在构造方法里通过 getGenericSuperclass() 就能把这个信息解析出来。拿到 entityClass 之后,后面所有的反射操作都有据可依了。
有一个细节需要注意:如果我们的层级是 UserDao extends BaseDao<User>,这段代码直接运行没问题。但如果你中间多加了一层,比如 BaseDao<User> extends AbstractDao,那 getGenericSuperclass() 拿到的泛型信息就变成 BaseDao<User> 传给 AbstractDao 的签名,仍然可以解析。真正会出问题的是那种绕了几层、泛型信息丢失的情况,所以封装的时候最好在构造方法里加一层校验,解析不到就快速失败。
2.2 注解映射:字段到列的规则
有了实体类型,下一步就是实体字段和数据库列的映射。最灵活的方案是自定义两个注解,一个标注表名,一个标注字段对应的列名和主键标记。
java复制@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Table {
String name();
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Column {
String name() default "";
boolean isId() default false;
}
实体类里这样使用:
java复制@Table(name = "user")
public class User {
@Column(name = "id", isId = true)
private Long id;
@Column(name = "username")
private String username;
@Column(name = "create_time")
private LocalDateTime createTime;
// 构造方法、getter/setter 省略
}
我见过不少封装的教程选择直接用字段名映射列名(把驼峰转下划线),这种方案在大部分场景下也没问题。但我还是更推荐显式注解,因为现实项目里总会有一些字段不想映射到数据库表,比如冗余的统计字段、缓存字段;还有一些历史表列名和 Java 字段名对应关系很怪,靠规则映射无论如何都别扭。显式注解加一个默认值——注解没写 name 的时候就默认用字段名,两种场景都能覆盖。
2.3 动态生成SQL语句的完整推演
接下来是整个封装的核心:动态生成 SQL。
以 insert 为例,我们要根据 User 的字段生成:
sql复制INSERT INTO user (username, password, nickname, email, create_time) VALUES (?, ?, ?, ?, ?)
实现思路是:反射拿到 User 的所有字段,筛掉 isId 字段(主键自增的情况下不参与插入),对于其余字段,按注解拿列名,再统一收集成列名列表和占位符列表。类似的,update 生成 UPDATE user SET username = ?, password = ? WHERE id = ?,selectById 生成 SELECT 所有列 FROM user WHERE id = ?。
代码统一在静态初始化的时候把所有字段元数据解析好,避免每次 CRUD 都重新反射一遍。这一点对性能影响很大,我第一次封装的时候就是在每个方法里临时反射,结果批量插入几千条数据慢得离谱,后来改成元数据缓存,性能直接提升了一个数量级。
具体来说,我设计了一个内部数据结构 TableMetaData:
java复制public class TableMetaData {
private String tableName;
private List<FieldColumnInfo> fieldColumnInfos;
private FieldColumnInfo idInfo;
}
FieldColumnInfo 里面包含 Field(反射字段)、columnName(列名)、isId 等。整个 BaseDao 在构造时一旦确定 entityClass,就立刻构建 TableMetaData 并缓存到成员变量里,后面所有的方法都直接复用这份元数据。
这里有一个容易踩的坑:反射字段的时候如果实体存在继承关系,比如 User extends BaseEntity,getDeclaredFields() 只拿不到父类字段。为了稳妥,我写了一个工具方法,一层一层往上遍历父类,把父类的字段也收集进来,直到 Object 为止。
3. 通用CRUD方法实现:代码可以直接抄
3.1 数据库连接与资源释放的闭环
连接管理和资源释放看起来是一件小事,但没处理好会造成连接泄漏,生产环境一会儿就报 Too many connections。
我用了最稳妥的做法:每个操作内部分别获取连接和释放连接。虽然这种做法比一个连接贯穿多个操作要慢一些,但在中小型项目里足够用,而且逻辑最简单,不会出现跨方法传递连接导致忘记关闭的问题。
java复制protected Connection getConnection() throws SQLException {
Connection conn = DriverManager.getConnection(URL, USERNAME, PASSWORD);
conn.setAutoCommit(true);
return conn;
}
释放资源用 try-with-resources 处理,所有 AutoCloseable 资源声明在 try 头里,Java 会保证关闭:
java复制try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
// 业务逻辑
}
这个语法虽然常见,但很多人容易忽略 ResultSet 也是 AutoCloseable 的。如果方法里创建了 ResultSet,同样要放进 try 的资源管理里,顺序是 Connection、PreparedStatement、ResultSet。关闭顺序恰好和声明顺序相反,但是 try-with-resources 已经处理了这个细节,不用我们操心。
3.2 通用insert:返回自增主键
通用 insert 的重点除了拼 SQL,还有拿到数据库自动生成的主键。很多人封装 insert 的时候只返回受影响行数,导致插入之后还得再查一遍才能拿到主键,这显然不够优雅。
正确做法是创建 PreparedStatement 时显式声明 RETURN_GENERATED_KEYS,插入完成后从 ResultSet 里取生成的主键,再通过反射赋值回实体的主键字段:
java复制public int insert(T entity) {
TableMetaData meta = getTableMetaData();
List<FieldColumnInfo> insertColumns = meta.getInsertableColumns();
String sql = buildInsertSql(meta);
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
setParameters(ps, insertColumns, entity);
int affectedRows = ps.executeUpdate();
if (affectedRows > 0 && meta.getIdInfo() != null) {
try (ResultSet generatedKeys = ps.getGeneratedKeys()) {
if (generatedKeys.next()) {
Object key = generatedKeys.getObject(1);
ReflectUtils.setFieldValue(entity, meta.getIdInfo().getField(), key);
}
}
}
return affectedRows;
} catch (Exception e) {
throw new DaoException("insert执行失败", e);
}
}
setParameters 是封装出来的通用方法,遍历字段列表,从实体对象里反射取出对应字段的值,逐个调用 ps.setObject()。这里我选择 setObject 而不是根据类型逐个判断,是因为 JDBC 驱动对常见类型都能正确识别,写起来也简洁。
注意:
RETURN_GENERATED_KEYS在 MySQL 下返回的是自增主键,在 PostgreSQL 里用法略有差异。如果你的项目切换了数据库,这个细节需要额外测试。
3.3 通用update/delete/findById
update 生成的 SQL 是 UPDATE user SET username = ?, password = ?, ... WHERE id = ?,要点是字段列表不能包含主键。执行的时候先给占位符设置字段值,再在最后补上主键值。
java复制public int update(T entity) {
TableMetaData meta = getTableMetaData();
List<FieldColumnInfo> updateColumns = meta.getUpdatableColumns();
if (updateColumns.isEmpty() || meta.getIdInfo() == null) {
throw new DaoException("没有可更新字段或缺少主键");
}
String sql = buildUpdateSql(meta);
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
int index = 1;
for (FieldColumnInfo info : updateColumns) {
ps.setObject(index++, ReflectUtils.getFieldValue(entity, info.getField()));
}
ps.setObject(index, ReflectUtils.getFieldValue(entity, meta.getIdInfo().getField()));
return ps.executeUpdate();
} catch (Exception e) {
throw new DaoException("update执行失败", e);
}
}
deleteById 就更简单了,主键占位符一填,直接执行。
findById 要注意的是查询出来的 ResultSet 如何映射回实体对象。我写了一个 mapRowToEntity(ResultSet rs) 方法,通过 ResultSetMetaData 拿到所有列名,再和之前解析的字段元数据匹配,找到对应 Field 后通过反射赋值:
java复制public T findById(Object id) {
String sql = buildSelectByIdSql();
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return mapRowToEntity(rs);
}
return null;
}
} catch (Exception e) {
throw new DaoException("findById执行失败", e);
}
}
mapRowToEntity 里面有一个细节处理:从 ResultSet 取出来的值可能是 java.sql.Timestamp,而实体字段是 java.time.LocalDateTime,直接 setObject 再反射注入会报类型不匹配。解决办法是在反射赋值之前判断一下类型,如果是时间类型就做一次兼容转换。这个小坑卡了我半天,后面专门写了个 convertValue 工具方法处理。
3.4 条件查询与动态SQL拼接
条件查询是实际开发中最高频的查询方式。findAll() 不带条件还好,findByCondition 就要考虑条件怎么传。我的设计是让调用方传入一段 where 条件片段的 SQL 和对应的参数列表,比如:
java复制List<User> users = userDao.findByCondition("WHERE age > ? AND status = ?", 18, 1);
这样设计的优点是灵活,任何条件都能拼,而且参数依然使用占位符,SQL 注入风险被 PreparedStatement 天然挡在门外。缺点是很考验调用方的 SQL 功底,但这本来就是 Java 开发的基本功,可以接受。
如果你想要更友好的条件封装,可以引入一个简单的 QueryCondition 类,里面维护一组 AND、OR 逻辑。不过在小项目里,直接传 where 片段已经够用,过度设计反而增加理解成本。
4. 批量操作与分页:把高频场景一并覆盖
4.1 批量插入的正确姿势
批量插入在数据导入、初始化数据这类场景里很常见。JDBC 提供了 PreparedStatement.addBatch() 和 executeBatch() 机制,但这里有个非常关键的性能前提:如果不在 JDBC URL 加上 rewriteBatchedStatements=true,MySQL 驱动默认还是逐条执行,完全享受不到批量的性能优势。
我第一次用 executeBatch() 的时候没加这个参数,导入 10 万条数据硬是跑了 3 分多钟,加了参数之后直接掉到十几秒。这个参数是从 MySQL 5.1 驱动开始支持的高效批量提交模式,驱动会在客户端重写 SQL,把多条插入合并成 INSERT INTO ... VALUES (...), (...), ... 的形式,网络往返次数大幅减少。
我的 batchInsert 方法是这样的:
java复制public int[] batchInsert(List<T> entities, int batchSize) {
if (entities == null || entities.isEmpty()) {
return new int[0];
}
TableMetaData meta = getTableMetaData();
String sql = buildInsertSql(meta);
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false);
int[] result = new int[entities.size()];
int count = 0;
for (int i = 0; i < entities.size(); i++) {
int index = 1;
for (FieldColumnInfo info : meta.getInsertableColumns()) {
ps.setObject(index++, ReflectUtils.getFieldValue(entities.get(i), info.getField()));
}
ps.addBatch();
count++;
if (count % batchSize == 0) {
int[] batchResult = ps.executeBatch();
System.arraycopy(batchResult, 0, result, i - batchSize + 1, batchResult.length);
count = 0;
}
}
if (count > 0) {
int[] batchResult = ps.executeBatch();
System.arraycopy(batchResult, 0, result, entities.size() - count, batchResult.length);
}
conn.commit();
return result;
} catch (Exception e) {
throw new DaoException("batchInsert执行失败", e);
}
}
手动管理事务是批量操作绕不开的一环。addBatch 攒够一定数量再 executeBatch,能减少大批量提交时的内存压力;最后一次性 commit 也把多次磁盘同步的开销降下来了。
4.2 分页查询实现
分页查询是另一个绕不开的话题。不同数据库的分页 SQL 差别很大,MySQL 是 LIMIT ?, ?,Oracle 是 ROWNUM,PostgreSQL 虽然也支持 LIMIT 但细节又不同。所以我抽了一个 dialect 的概念,用简单的方式判断当前是什么数据库。
java复制public PageResult<T> page(int pageNum, int pageSize) {
if (pageNum < 1) {
pageNum = 1;
}
String countSql = "SELECT COUNT(*) FROM " + meta.getTableName();
String pageSql = buildPageSql(pageNum, pageSize);
long total;
List<T> list;
try (Connection conn = getConnection();
PreparedStatement countPs = conn.prepareStatement(countSql);
ResultSet countRs = countPs.executeQuery()) {
countRs.next();
total = countRs.getLong(1);
} catch (Exception e) {
throw new DaoException("count执行失败", e);
}
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(pageSql)) {
setPageParameters(ps, pageNum, pageSize);
try (ResultSet rs = ps.executeQuery()) {
list = new ArrayList<>();
while (rs.next()) {
list.add(mapRowToEntity(rs));
}
}
} catch (Exception e) {
throw new DaoException("page执行失败", e);
}
return new PageResult<>(total, list, pageNum, pageSize);
}
buildPageSql 在 MySQL 下返回 SELECT 所有列 FROM 表 LIMIT ?, ?,在 Oracle 下返回套了一层子查询的 ROWNUM 写法。这两个 ? 参数一个是偏移量((pageNum - 1) * pageSize),一个是每页条数,顺序不能搞反。
4.3 简单批量更新的实现思路
批量更新比批量插入要复杂一点,因为更新条件各不相同的话,很难像插入那样通过 rewriteBatchedStatements 合并成一条 SQL 获得最大收益。但如果你更新的是一批记录的主键和其他字段值,依然可以用 addBatch 的思路:
对每条实体分别设置 update SQL 的参数,加入批处理,最后统一执行。这里就没有插值合并的效果了,但至少减少了应用和数据库之间的往返次数,执行效率比逐条 update 高很多。
如果你的批量更新场景非常复杂,比如有的记录更新这个字段,有的记录更新那个字段,那就别硬套批量方法了,老老实实分开更新,或者干脆写一个专用方法。通用封装的边界就在这——它把 80% 场景优化好,剩下 20% 永远应该让调用方自己掌控。
5. 实战测试:一个UserDao跑通全部能力
5.1 实体类与数据表准备
代码看再多,不如跑一遍。我先建一张简单的用户表:
sql复制CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`password` VARCHAR(100) NOT NULL,
`nickname` VARCHAR(50) DEFAULT NULL,
`email` VARCHAR(100) DEFAULT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
对应的实体类就是我前面写的 User,加了 @Table 和 @Column 注解。为了展示继承场景的处理,我再定义一个 BaseEntity,把 createTime 提取到父类里。
5.2 继承BaseDao后业务代码的样子
子类的干净程度是这次封装成功与否的最好验证:
java复制public class UserDao extends BaseDao<User> {
// 如果只是通用CRUD,这里什么都不用写
}
是的,你没看错,一个完整支持 insert、update、delete、findById、findAll、条件查询、分页、批量插入的 DAO,子类代码是空的。要加一个“根据用户名查询”的特殊方法,只需要:
java复制public class UserDao extends BaseDao<User> {
public User findByUsername(String username) {
List<User> users = findByCondition("WHERE username = ? LIMIT 1", username);
return users.isEmpty() ? null : users.get(0);
}
}
这种组合方式让 BaseDao 既有通用能力,又保留了扩展口子。
5.3 测试用例跑起来
我用一个简单的 public static void main 方法测试全流程:
java复制public static void main(String[] args) {
UserDao userDao = new UserDao();
// 新增
User user = new User();
user.setUsername("zhangsan");
user.setPassword("123456");
user.setNickname("张三");
user.setEmail("zhangsan@example.com");
userDao.insert(user);
System.out.println("插入后主键: " + user.getId());
// 查询
User queryResult = userDao.findById(user.getId());
System.out.println("查询结果: " + queryResult.getUsername());
// 更新
queryResult.setNickname("张三丰");
userDao.update(queryResult);
// 分页
PageResult<User> page = userDao.page(1, 10);
System.out.println("总记录数: " + page.getTotal());
// 批量插入
List<User> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
User u = new User();
u.setUsername("user_" + i);
u.setPassword("pwd_" + i);
list.add(u);
}
userDao.batchInsert(list, 20);
}
实测下来,插入能正确返回自增主键,更新后重新查询字段值已变化,分页返回的总数和列表都是正确的。到这里封装的核心功能已经验证完毕。
6. 我在封装过程中踩过的坑与填坑方案
6.1 批量插入性能低下
前面提到 rewriteBatchedStatements=true 是关键,但还有一个容易被忽略的点:setAutoCommit(false) 必须放在 PreparedStatement 创建之前或者同连接上操作。我一开始在获取连接后没有关自动提交,每次 executeBatch() 都自动提交一次,性能反而比逐条插入还要差。后来把事务控制和批处理配合起来,性能数据才正常。
实测对比(MySQL 8.0,插入 10 万条,每条 5 个字段):
| 方案 | 耗时 |
|---|---|
| 逐条插入,自动提交 | 约 180 秒 |
| addBatch,自动提交 | 约 120 秒 |
| addBatch + rewriteBatchedStatements | 约 15 秒 |
| addBatch + rewriteBatchedStatements + 手动提交 | 约 10 秒 |
这个对比很有说服力,强烈建议所有用 MySQL 做批量写入的项目,JDBC URL 里把 rewriteBatchedStatements=true 加上。
6.2 数据库列名大小写引发的映射问题
MySQL 在 Windows 下默认表名和列名不区分大小写,但在 Linux 下区分大小写。如果实体注解里写的是 @Column(name = "UserName"),而表结构里列名是 username,查出来的 ResultSetMetaData 列名可能是 username,反射赋值时按列名匹配就会失败。
解决办法是在构建字段元数据的时候统一转成大写(或者统一转成小写)再比较,这样大小写差异就无所谓了。具体做法是 columnName.toUpperCase(Locale.ROOT),同时 ResultSetMetaData.getColumnLabel(i) 拿到的列名也转成大写再比对。
6.3 反射赋值时int与Integer的坑
实体字段如果声明为基本类型 int,反射调用 Field.set(obj, value) 的时候,如果 value 是 Integer 没问题;但如果从数据库查出来是 Long,Java 的反射不会做自动拆装箱转换,直接抛 IllegalArgumentException。
这种情况在自增主键字段上特别常见:主键声明成 Long 一般没事,但如果你声明成 long,从 ResultSet.getObject("id") 回来的是 Long,反射到 long 字段就报错。我的 convertValue 工具专门做了一层类型兜底转换,把数字类型之间、时间类型之间的互转都处理掉。
6.4 setAccessible在更高版本JDK下的处理
实体类的字段一般都是 private,反射读写之前需要 setAccessible(true)。Java 8 到 Java 11 这个操作都很顺畅,但 Java 17 之后如果项目用了强封装模块,可能会遇到 InaccessibleObjectException。
一般业务项目不会直接触及模块系统,所以问题不大。但如果你的代码要发成一个 Java 库给其他人用,最好在 --add-opens 参数里给你的实体包开一个白名单,或者干脆保持 public 的 setter 方式。我的 BaseDao 里默认在字段元数据构建阶段统一调用 setAccessible(true),并且缓存了 Field 对象,后续读写不再重复调用,这是性能和兼容性的一个折中。
这个坑其实提醒我:封装本身是没有止境的,每个项目运行环境不一样,测试覆盖再全也可能在某些环境翻车。所以 BaseDao 里的异常信息打全一点,方便排查。
7. 进阶扩展:接入连接池与后续演进思路
7.1 用HikariCP替换DriverManager连接管理
BaseDao 里的 getConnection() 目前是直接 DriverManager.getConnection(),每次请求都新建物理连接,小项目压力不大,但并发上来后会明显拖慢速度,因为建立 TCP 连接、MySQL 服务端认证这些开销都不小。
解决方案是引入连接池。以 HikariCP 为例,改造十分简单:
java复制private static final HikariDataSource dataSource;
static {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(URL);
config.setUsername(USERNAME);
config.setPassword(PASSWORD);
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
dataSource = new HikariDataSource(config);
}
@Override
protected Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
只改 getConnection() 一个方法,BaseDao 其余所有代码完全不用动,这就是连接获取逻辑集中到单个方法的好处。HikariCP 的连接获取效率非常高,而且自带了连接有效性检测,比手动维护连接池省心得多。
如果你不想引入完整连接池,也可以使用 Spring 的 DriverManagerDataSource 或者 Apache Commons DBCP2,甚至可以直接用数据库驱动自带的连接池。不过从性能和稳定性综合来看,HikariCP 是最省心的选择。
7.2 和MyBatis等框架的关系:BaseDao的价值边界
聊到这儿你可能会问,都封装到这份上了,直接用 MyBatis 不香吗?
我的看法是,BaseDao 和 MyBatis 解决的是不同粒度的问题。BaseDao 适合这种场景:你只需要单表 CRUD,而且希望代码里没有任何 XML 映射文件,也不想学习复杂框架的配置。对于小型项目、内网工具、教学项目,BaseDao 的轻量和透明是明显优势。MyBatis 则强在复杂的 SQL 管理和动态 SQL 构建,尤其是那些 SQL 复杂到需要专门维护 Mapper XML 的项目,用 MyBatis 是正确选择。
有一些项目甚至两种方案共存,简单实体的 CRUD 走 BaseDao,复杂查询走 MyBatis。这不算架构洁癖问题,而是务实地让工具匹配场景。如果你正在写一个轻量级的内部管理系统,不妨先花两小时把 BaseDao 搭起来,用到后面你会发现,大部分 DAO 层代码真的不需要再重复写了。
从另一个角度来看,自己封装 BaseDao 的过程,其实是一次对 ORM 底层原理最好的学习实践。通过亲手实现泛型反射映射、SQL 动态生成、批量处理和连接池接入,你对 MyBatis 的底层理解会比直接使用框架深刻得多。以后再去读 MyBatis 的源码,你会发现每一个核心概念都似曾相识——ResultSetHandler 就是 mapRowToEntity 的成熟版,DynamicSqlSource 就是动态 SQL 拼接的工业级实现。这也是我这次封装最大的额外收获。
