DAO设计模式实战:从JDBC混乱到数据访问层的优雅重构
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方法时都遵循这个习惯——数据访问层的可读性直接影响整个项目后期的维护效率。

相关新闻

InSSIDer 2.0 Win10/Win11兼容安装与ndisuio.sys驱动修复指南

InSSIDer 2.0 Win10/Win11兼容安装与ndisuio.sys驱动修复指南

简介:WLAN测试软件inssider2.0是一款面向网络运维人员、无线技术学习者及家庭/中小企业IT管理者的专业Wi-Fi分析工具,专用于诊断信号弱、干扰多、连接不稳定等典型无线环境问题。资源包为ZIP格式,共4个文件(1.2MB)&…

2026/10/15 12:13:47 阅读更多 →
STM32心形流水灯:GPIO控制、状态机与PWM进阶实践

STM32心形流水灯:GPIO控制、状态机与PWM进阶实践

简介:本资源是一份面向电子工程初学者与嵌入式爱好者的心形流水灯完整开发项目,聚焦单片机控制LED动态显示的核心实践,解决硬件布局、时序编程与心形动画算法实现等典型问题。压缩包共41个文件,涵盖7个C源文件(含主控逻…

2026/10/15 12:12:46 阅读更多 →
51单片机心形流水灯:从位操作到非阻塞状态机实战

51单片机心形流水灯:从位操作到非阻塞状态机实战

简介:本资源是一份面向电子工程初学者与嵌入式爱好者的心形流水灯完整开发项目,聚焦LED动态灯光控制实践,解决硬件驱动编程、时序逻辑设计与心形图案算法实现等典型问题。压缩包共41个文件,含7个C源文件(如pl.c、delay…

2026/10/15 12:12:46 阅读更多 →

最新新闻

粮油方向论文降重:三类做法的实际体验对比

粮油方向论文降重:三类做法的实际体验对比

粮油方向的论文,重复率高的地方很固定:国标检测方法的步骤描述、原料特性的通用表述、工艺原理的铺垫段。这三类占了重复的大头。要降得干净,得先分清哪些能改、哪些碰不得。 一、先划"不能改"的线 这一步必须在动手前做&#xf…

2026/10/15 16:47:54 阅读更多 →
从零构建月球着陆模拟器:物理模拟与状态机实战

从零构建月球着陆模拟器:物理模拟与状态机实战

最近我把一个存了很久的想法落地了:做一个纯粹的月球着陆模拟器,项目代号就叫 Apollo-11。名字不复杂,但它把我的目标说得很清楚——让玩家用有限的燃料,手动控制一艘着陆器,在起伏的月面上找到一块平地,减…

2026/10/15 16:47:54 阅读更多 →
ESP32修复USB4000光谱仪变砖:EEPROM固件恢复实战

ESP32修复USB4000光谱仪变砖:EEPROM固件恢复实战

1. 项目背景与核心需求拆解1.1 一台“变砖”的光谱仪到底意味着什么手里有一台USB4000光谱仪,插上电脑之后系统毫无反应,设备管理器里连未知设备都不弹出来,这种状态在硬件圈子里通常被叫做“变砖”。光谱仪这类精密光学仪器,内部…

2026/10/15 16:47:54 阅读更多 →
905 nm激光测距模块UART驱动与ROS 2集成实战

905 nm激光测距模块UART驱动与ROS 2集成实战

1. 从一束不可见光说起:905 nm 测距模块到底在解决什么问题如果你玩过扫地机器人、做过无人机定高、或者给机械臂加过避障,大概率绕不开一个东西:激光测距模块。市面上常见的方案有超声波、红外三角测距、ToF 摄像头,还有一类就是…

2026/10/15 16:47:54 阅读更多 →
C# WinForms接口调试工具:HttpClient+SQLite工程化实践

C# WinForms接口调试工具:HttpClient+SQLite工程化实践

简介:这是一款基于C#开发的轻量级Windows桌面接口测试工具,面向.NET初学者、Web API开发者及测试人员,用于快速验证HTTP接口功能与响应行为。工具采用WinForm框架构建图形界面,原生支持GET、POST、PUT、DELETE四种标准请求方法&am…

2026/10/15 16:47:54 阅读更多 →
Transformer为什么强?解析全局建模、并行计算与多模态扩展优势

Transformer为什么强?解析全局建模、并行计算与多模态扩展优势

最近总有人问我同一个问题:Transformer 到底比其他模型强在哪。我做了几年序列建模相关的工作,文本分类、机器翻译、多模态对齐这些任务都反复折腾过,每次换架构、调参、对比实验做下来,对“强”字的理解其实和刚接触时不太一样。…

2026/10/15 16:46:53 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →