1. 先搞清楚DAO到底解决了什么问题而不是急着写代码1.1 大多数项目中数据访问代码是怎么失控的做后端这些年我接手过不少“看起来能跑”的项目。最让人头疼的不是框架选得有多偏而是数据访问逻辑的混乱程度。就拿我印象很深的一个某在线课程平台的前期代码来说业务层里直接写JDBCDriverManager.getConnection()出现好几次ResultSet手动一个一个映射到对象查询条件一多就在if里拼字符串拼接SQL。同一个需求三个模块用了三种不同的写法哪怕它们查的是同一张course表。这种代码最大的问题不是“丑”而是改动代价极高。比如课程表新增一个cover_url字段你要找出所有涉及课程查询的方法逐个改映射逻辑漏掉一个就是线上事故。再比如数据库从单机MySQL迁到读写分离你需要把所有散落的连接获取代码全部重写。更别说测试了——业务逻辑和数据访问完全耦合想对Service写个单元测试必须先启动数据库准备一堆脏数据否则连编译都过不去。很多团队一开始觉得“项目小用不着设计模式”结果就是小项目在半年内长成刺猬。今天聊的DAO设计模式Data Access Object就是专门用来治这个病的把数据访问这件事从业务逻辑里彻底拆出来形成独立的“数据访问层”。1.2 DAO模式的本质把数据访问当成一个独立“服务”来看待DAO模式的定义不复杂为某个持久化数据源数据库、文件、远程服务等提供一个统一的操作接口业务层只面向这个接口编程不关心底下是JDBC、MyBatis还是别的什么。打个比方。你去餐厅吃饭不用关心后厨用的是煤气灶还是电磁炉你只需要看菜单下单。菜单就是DAO接口后厨就是DAO实现类而你这个人就是业务调用方。今天后厨换了个灶台比如从MySQL换成PostgreSQL你作为顾客完全感知不到因为你只和菜单打交道。但这里有三个点很多人理解偏了DAO不等于一个简单的数据库工具类。工具类只是封装了一堆query()、execute()方法返回值是ListMapString, Object本质上还是没有领域语义。DAO的核心是“为业务提供持久化服务”它的方法名应该像业务语言findCourseById()、updateCourseStatus()而不是通用的query(String sql)。DAO不是Service层的附属品。有的同学把DAO方法写在Service类里面理由是“我项目简单一个Service对应一个DAO够了”。这样做一时爽等Service里业务逻辑变多了你会发现这个类既要做事务控制、业务校验又要写SQL职责混在一起迟早会变成没人敢动的“上帝类”。DAO的边界不是“一张表一个DAO”这么机械。小项目里一个实体配一个DAO没问题但当查询跨越多个表、涉及报表聚合的时候可以按“业务聚合”来划分DAO比如OrderReportDao专门负责订单相关的统计分析不必非得跟某一两张表绑定。理解了这三层你才会明白为什么DAO模式被讨论了几十年今天依然有生命力——它本质上是在模块之间画了一条稳定的边界线这条线让业务代码和数据代码可以各自演进互不拖累。2. 一个完整的DAO设计案例从混乱到清晰的持久层改造2.1 业务场景与最初的代码乱象当时我处理的那个课程管理模块需求其实很简单课程列表、课程详情、修改课程上下架状态、按分类筛选。最初的代码长这样简化了逻辑public class CourseService { public ListCourse listByCategory(String category) { ListCourse list new ArrayList(); Connection conn DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps conn.prepareStatement( select * from course where category ? and status 1); ps.setString(1, category); ResultSet rs ps.executeQuery(); while (rs.next()) { Course c new Course(); c.setId(rs.getLong(id)); c.setName(rs.getString(name)); // ... 十几个字段手动映射 list.add(c); } // 关闭资源的代码写得七零八落 return list; } public void updateStatus(Long courseId, int status) { // 又是连接又是SQL又是状态判断 } }看起来只有两个方法但里面塞了连接管理、SQL拼接、结果集映射、异常处理四件事。没过多久产品要求“课程列表要同时返回已报名的学员人数”开发直接在listByCategory里加了一个LEFT JOIN顺手把统计逻辑也塞了进来——数据访问与统计业务彻底焊死。2.2 分步拆解接口、实现类、实体对象如何分工当时的改造思路很简单就三步。第一步定义DAO接口把“课程数据需要提供什么能力”先列出来。public interface CourseDao { ListCourse listByCategory(String category, Page page); Course findById(Long courseId); boolean updateStatus(Long courseId, int status); }注意这里的方法名是业务化、面向使用的。调用方看到findById就知道这方法返回一个课程对象而不是需要自己去解析Map。第二步用JDBC写一个实现类把数据库细节全部收进来。public class JdbcCourseDao implements CourseDao { private final DataSource dataSource; public JdbcCourseDao(DataSource dataSource) { this.dataSource dataSource; } Override public Course findById(Long courseId) { try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement( select * from course where id ?)) { ps.setLong(1, courseId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } } } catch (SQLException e) { throw new DataAccessRuntimeException(查询课程失败, e); } return null; } private Course mapRow(ResultSet rs) throws SQLException { Course c new Course(); c.setId(rs.getLong(id)); c.setName(rs.getString(name)); c.setCategory(rs.getString(category)); c.setStatus(rs.getInt(status)); // ... 其他字段 return c; } }第三步Service层只面向CourseDao编程完全不感知JDBC细节。public class CourseService { private final CourseDao courseDao; public CourseService(CourseDao courseDao) { this.courseDao courseDao; } public ListCourse listByCategory(String category) { if (!StringUtils.hasText(category)) { throw new IllegalArgumentException(分类不能为空); } return courseDao.listByCategory(category, new Page(1, 20)); } }改造之后第一感觉是类变多了每个实体对应一个DAO接口、一个DAO实现项目结构看着“重”了。但这个“重”是值得的。后续换ORM框架时业务层一行不用改只换实现类写单元测试时mock一个CourseDao接口Service逻辑随便测。2.3 为什么接口要先于实现出现——从依赖倒置说起有的同学会问“我只写一个JdbcCourseDao类不也行吗为什么非要加一层接口”关键在于依赖方向。如果不加接口Service直接持有JdbcCourseDao那依赖方向就是Service - 具体数据库实现。一旦数据库实现发生变化换数据库、换ORM、加缓存Service就得跟着改。加了接口之后依赖方向反转了Service依赖于CourseDao这个抽象JdbcCourseDao也依赖于CourseDao这个抽象。高层模块和低层模块都依赖抽象具体实现可以随时替换这就是《设计模式》里说的依赖倒置原则。用个更实在的例子。后来我们把这个模块从纯JDBC迁移到Spring的NamedParameterJdbcTemplate底层连接方式、SQL写法全变了但CourseService和CourseDao接口一个字母都没动——我们只是新建了一个SpringJdbcCourseDao实现然后改一行装配代码。这就是“接口先于实现”的真正价值它不是形式主义而是给未来预留的替换点。3. 通用DAO的抽取与边界控制3.1 泛型基类的设计思路项目里的实体一多你很快会发现CourseDao、UserDao、OrderDao里面都有findById、save、update、delete这些模板方法重复代码很重。于是很多人会想着抽一个BaseDaoT。一个相对平衡的基类设计长这样public abstract class BaseDaoT { protected final DataSource dataSource; private final RowMapperT rowMapper; protected abstract String getTableName(); protected abstract String getIdColumn(); public T findById(Long id) { String sql select * from getTableName() where getIdColumn() ?; // 执行查询用 rowMapper 映射 } public ListT findAll() { String sql select * from getTableName(); // ... } }子类只需要提供表名、ID字段名、行映射器就能获得基础CRUD能力。但这里有个很容易走歪的地方别让通用基类无限膨胀。我见过有人往BaseDao里加findByCondition(MapString, Object)、findByPage、batchInsert、dynamicQuery最后基类变成一百多行的“万能胶水类”每个子类都在里面找自己能用的方法。通用DAO的本意是消除显而易见的重复不是把所有可能用到的查询都塞进去。通用程度停留在“单表基础CRUD 简单的列表查询”就好复杂的专属查询放回各子类。3.2 条件查询、分页与排序的取舍多条件动态查询是DAO设计里绕不开的难题。如果每多一个筛选条件就加一个方法接口会迅速爆炸findByCategoryAndStatus、findByCategoryAndKeyword、findByStatusAndDateRange……我当时用的折中方案是引入一个查询参数对象。public class CourseQuery { private String category; private Integer status; private String keyword; private String sortBy; private int pageNum 1; private int pageSize 20; // getter / setter }然后DAO接口变成ListCourse findList(CourseQuery query);实现类里再根据query里的字段逐个拼SQL条件。这种做法比方法爆炸好维护也比直接把MapString, Object扔给DAO清晰——参数对象的字段有类型、有含义调用方一眼能看懂。代价是每个实体都要建一个Query类类数量又变多了。但这属于“结构化成本”好处是后续扩展筛选条件时只需要在Query里加字段接口签名保持不变。在接口稳定性优先的系统中这笔投入很值。3.3 别把DAO做成“万能工具类”抽了通用DAO之后另一个反模式也随之出现Service层开始把DAO当成“SQL执行器”。// 反模式示例 courseDao.executeSql(delete from course where category xxx);这种方法是把数据库的耦合从一个类扩散到了调用方。一旦数据库的字段、表结构调整所有写裸SQL的业务代码都得跟着排查。DAO的职责是“封装数据访问”不是“提供SQL执行通道”。如果业务上确实有一些复杂查询宁可把它定义成一个有语义的DAO方法比如batchDeleteByCategory(String category)也不要开一个通用的executeSql口子。4. 实战中DAO最容易踩的坑与排查经验4.1 事务边界DAO方法里的Transactional是个坑很多同学习惯直接在DAO的方法上加Transactional理由是“我的修改操作要保证原子性”。这个习惯的隐患在于事务的粒度。DAO方法通常是细粒度的比如updateCourseStatus()只改一张表的一个字段。事务放在DAO层意味着每次DAO调用都是一个独立事务而当Service里需要组合多个DAO操作时事务就被切成了多段。举个真实案例。某订单模块的orderService.createOrder()需要同时执行“插入订单表”“扣减库存”“写流水表”三个操作分别调用三个DAO方法。如果每个DAO方法都开了事务第二步扣库存失败了第一步插入订单已经提交你没法回滚。后来我们把Transactional全部上移到Service方法才真正保证了业务操作的整体原子性。正确边界是DAO负责单条数据操作事务的开关放在Service层。事务关心的是“业务场景的完整性”不是“单条SQL的完整性”。这个调整虽然简单但在好多项目里反复踩。4.2 返回类型与异常处理的经典错误DAO方法的返回类型设计直接影响调用方代码的可靠性。踩过的最典型坑是查询单条记录时返回null调用方忘了判空直接course.getTitle()NPE从测试环境一路爆到线上。处理方式我后来统一成两派允许缺省的单条查询返回OptionalT强迫调用方处理“查不到”的情况。不允许缺省的业务场景直接抛出明确的“数据不存在”异常由统一异常处理器转换。另外DAO实现里不要吞异常。尤其不要在catch块里只e.printStackTrace()然后返回null。这会让Service层拿到一个null排查问题时只能看到“空指针”看不到真正的SQL异常。更好的做法是包装成自定义的DataAccessRuntimeException保留原始异常链并附上当前操作的上下文信息。为排查线上问题省下的时间远超过包装异常的那几行代码。4.3 连接池与资源关闭问题纯JDBC的DAO实现里最容易翻车的是资源关闭。我早年写代码时连接、语句、结果集都没有用try-with-resources某次数据库连接数飙到上限整个应用卡死。后来统一改成try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ... }Connection、Statement、ResultSet都实现了AutoCloseable用try-with-resources可以在正常流程和异常流程下都正确关闭资源。如果是手动管理资源的老代码务必认真检查finally块里的关闭顺序先关ResultSet再关Statement最后关Connection顺序反了在某些驱动下会出怪问题。更建议的做法是直接用连接池如HikariCP替代手写连接管理。连接池帮你解决了连接的复用与回收能大幅降低资源泄漏的风险。DAO实现类里不要自己DriverManager.getConnection()而是通过DataSource获取连接——这也是便于后续做连接池、读写分离的基础。5. DAO模式在现代技术栈中的演进与现实选择5.1 从JPA到MyBatis它们和DAO是什么关系现在写Java后端很少还有人手写JDBC大家更多用Spring Data JPA、MyBatis这类框架。很多人会问都用框架了还要不要设计DAO说清楚一件事JPA的Repository和MyBatis的Mapper本质上就是DAO的替身或者说它们已经在帮你实现DAO层了。以Spring Data JPA为例public interface CourseRepository extends JpaRepositoryCourse, Long { ListCourse findByCategoryAndStatus(String category, int status); }findByCategoryAndStatus会自动生成查询实现你不需要写SQL也不需要写实现类。它对外提供的接口视角和DAO完全一致只是“接口的生成和实现”被框架接管了。MyBatis稍微不同。你写一个Mapper接口再配一个XML文件MyBatis在启动时生成动态代理实现Mapper接口。这其实就是DAO实现类的一种自动化生产机制只不过生产者从程序员变成了框架。所以DAO模式没死它是被主流框架“内化”了。学习DAO模式的核心价值在于理解为什么要有这一层抽象而不是纠结于是否手写接口和实现类。5.2 什么时候可以放弃手写DAO我的建议是分情况简单CRUD为主、查询规则单一的项目直接用Spring Data JPA的Repository或者MyBatis的通用Mapper不需要再套一层自定义DAO。项目里多套一层只会增加无意义代码。查询逻辑复杂、动态条件多、SQL需要精细调优的项目我推荐MyBatis这一类XML里写动态SQL很容易控制。此时MyBatis Mapper本身就是你的数据访问层Service面向Mapper接口编程即可。业务规模大、团队协作多的中大型项目即使用了JPA/MyBatis也可以再包一个薄薄的DAO层目的是隐藏持久化技术选型。团队内部可以约定改持久化框架时Service层不感知。一般来说技术选型越统一代码越简单收益越大。不要为了套模式而套模式。5.3 我的选型建议这几年我个人的落地经验是项目起步时用Spring Data JPA把实体关系、仓库接口快速搭起来等到系统里出现“复杂的报表统计”“动态多维筛选”这类需求时再引入MyBatis对复杂查询单独建Mapper。两种持久化技术并存时DAO层的价值就开始凸显了Service依赖的是自定义的CourseDao接口不依赖JPA还是MyBatis底层技术切换时Service层不跟着动荡。这个方案看起来“不纯粹”但团队协作时它带来的边界稳定性比“只用一种框架”更值钱。最后再分享一个实际操作中的习惯无论用DAO还是Repository写数据访问方法时一定把业务语义放名字里。countEnrolledStudents(Long courseId)比selectCountByCourseId读起来更直观我也要求团队在写Mapper和Repository方法时都遵循这个习惯——数据访问层的可读性直接影响整个项目后期的维护效率。