设计模式修炼之路 ㉙ 数据访问对象模式——业务对象不该懂数据库
业务对象不该懂数据库、接口只说业务语言、换实现不换业务、单测不连库、Spring Data 工业化 DAO、DAO 与 Repository 对账
目录
J2EE 模式第四站。
㉘ 的组合实体把"一次加载、一次提交"管得漂漂亮亮——可回头看看 CustomerEntity:load() 里拆字符串、commit() 里拼字符串,SELECT、UPDATE 的痕迹全漏进实体里了。
这不是组合实体的锅,是它的半成品状态:远程边界管好了,存储边界还敞着门。这种"既懂搬家提额、又懂 SELECT UPDATE"的类有个学名——胖实体。它功能一切正常,结构却埋着三颗雷:业务和 SQL 焊死在一起;想单测一条业务规则,第一步必须先连库;哪天要换存储,SQL 长在每个方法里,动起来是全身手术。
把"怎么存"整体搬进一个专职对象,藏在接口后面——业务对象从此只说业务话——数据访问对象模式,J2EE 八式里的隔离墙。
一、数据访问对象模式是什么
J2EE 蓝皮书的定义大意:数据访问对象(Data Access Object,DAO)封装了某种数据源的访问细节,对外暴露一个用业务语言说话的接口;业务对象只依赖这个接口,不知道、也不需要知道背后是 MySQL、Redis 还是内存 HashMap。
四个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Business Object(业务对象) | 只懂业务的领域对象/服务,零持久化知识 | Customer / CustomerService |
| DAO 接口 | 用业务语言声明数据操作 | CustomerDao |
| DAO 实现 | 一种存储一个实现,负责领域对象与存储之间的翻译 | JdbcCustomerDao / InMemoryCustomerDao |
| 工厂或依赖注入(配套) | 决定装配哪个实现 | 本文构造器直传,第五节交给 Spring |
一句话理解分工:业务对象说业务话("把张三找出来"),DAO 说存储话("SELECT ... WHERE id = ?"),中间隔着一个谁也不越界的接口。
它不是 GoF 二十三式,是 J2EE 蓝皮书成员——诞生动机很具体:J2EE 时代的业务组件一旦沾上 JDBC 代码,就和具体存储焊死了,换库、单测、复用全部瘫痪;蓝皮书的答案是给存储立一堵墙,墙的名字叫 DAO。
二、优点与缺点
优点:
- 业务与持久化解耦:领域对象纯净——单测业务规则不需要数据库,一个
new就能开测。 - 换存储 = 换实现:MySQL 换 Redis、生产库换内存缓存,业务代码一行不改——接口不变,世界不变。
- 持久化细节集中一处:SQL、行映射、格式化住在一个文件里,不再每个实体抄一遍、满天飞。
- 团队可分工:领域建模的人写业务对象,懂数据库的人写 DAO,接口是两个工种的合同。
缺点:
- 接口 + 实现双份类文件:小项目就一张表,也要接口加两个实现,仪式感略重——抽象是替变化付的成本,没变化别预付。
- 接口设计容易露馅:一旦接口方法返回 ResultSet、抛 SQLException,抽象当场报废——泄露的不是细节,是依赖。
- 跨表事务尴尬:DAO 天生以单表/单实体为单位,跨表操作要靠服务层串两个 DAO,事务边界容易丢——整组操作记得请 ㉘ 组合实体或服务层管事务。
三、什么时候用
判断标准:业务对象 + 任何存储的边界——这个组合几乎总是值得立一堵 DAO 墙。
- 明确信号一:同一份业务要跑在不同存储上——生产 MySQL、测试内存,DAO 是唯一不用复制代码的方案
- 明确信号二:想单测业务规则不连库——测试跑得快、不脆弱、不因数据库挂了而红
- 明确信号三:SQL 想集中管理——审计、慢查询治理、SQL 注入排查,一处看全
反面对照:纯内存工具、一次性脚本——直接写就行;数据源确定永不变、又没有测试诉求的极小项目——可以省掉接口直接写实现类。除此之外,DAO 已经是现代分层架构的默认件,不是"要不要用"而是"用得规不规范"。
四、实现方式:搬家 + 提额
场景沿用 ㉘:客户张三,业务操作还是搬家 + 提额——这次盯着的不是网络往返,是 SQL 的行踪。
4.1 反面教材:什么都懂的胖实体
public class FatCustomer {
private final String id;
private String name;
private String city;
private int creditLimit;
/** 持久化:实体亲自连库、亲自拆行 */
public void load() {
System.out.println(" [SQL] SELECT name, city, credit_limit FROM customer WHERE id = '" + id + "' ... 3ms");
String[] row = FakeDatabase.selectRow(id);
name = row[0];
city = row[1];
creditLimit = Integer.parseInt(row[2]);
}
/** 持久化:实体亲自拼行、亲自提交 */
public void save() {
System.out.println(" [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '" + id + "' ... 1ms");
FakeDatabase.updateRow(id, new String[]{name, city, String.valueOf(creditLimit)});
}
// ---------- 业务:搬家、提额,与正解版一模一样,略 ----------
}
运行输出:
========== 反面教材:什么都懂的胖实体 ==========
--- 场景一:搬家并提额(功能一切正常) ---
[SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms
[业务] 张三 搬到 上海
[业务] 张三 提额 +5000,当前额度 55000
[SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms
[当前] 张三 | 上海 | 额度 55000
功能没毛病——可业务和 SQL 已经焊死在同一个类里。
--- 场景二:单测“提额不超 50%”这条纯业务规则 ---
[SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C002' ... 3ms
[规则] 拒绝:单次提额 30000 超过现有额度 30000 的 50%
只是测一条业务规则,第一步却必须连库——规则和存储焊死了。
注意场景一的态度:功能一切正常——反面教材不是功能错误,是结构错误。病灶要在场景二和"未来的某天"才发作:
- 业务与 SQL 焊死:想看搬家逻辑,先趟过一遍 SELECT;想审 SQL,得通读业务类——两拨人改同一个文件,天天冲突。
- 单测必须连库:场景二只想验证"提额不超 50%"这条纯业务规则——不碰任何数据,第一步却必须是连库
load()。数据库一挂,几百个业务测试集体飘红,分不清是规则坏了还是环境坏了。 - 换存储 = 全身手术:哪天老板说"这套数据换 Redis / 加一层缓存",SQL 长在实体每个方法里,逐处改;来了新实体 Order,load/save 这套 SQL 还得原样再抄一遍。
4.2 正解:DAO 三件套
第一件:接口,只说业务话。
public interface CustomerDao {
Customer findById(String id);
void save(Customer customer);
}
第二件:JDBC 实现——SQL 文本、行数组与领域对象之间的翻译(㉘ 里 format/parse 那套手艺),从实体身上原封不动搬到这里,代码没白写,只是搬了家:
public class JdbcCustomerDao implements CustomerDao {
@Override
public Customer findById(String id) {
System.out.println(" [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = '" + id + "' ... 3ms");
String[] row = FakeDatabase.selectRow(id);
if (row == null) {
throw new IllegalArgumentException("客户不存在: " + id);
}
return new Customer(id, row[0], row[1], Integer.parseInt(row[2]));
}
@Override
public void save(Customer customer) {
System.out.println(" [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '" + customer.getId() + "' ... 1ms");
FakeDatabase.updateRow(customer.getId(),
new String[]{customer.getName(), customer.getCity(), String.valueOf(customer.getCreditLimit())});
}
}
第三件:第二个实现(内存版,模拟缓存/嵌入式存储)——同一个接口的第二个实现,就是"换存储不换业务"的全部证据。
业务服务只认识接口,构造时给它什么,它就用什么:
public class CustomerService {
private final CustomerDao dao;
public CustomerService(CustomerDao dao) {
this.dao = dao;
}
/** 一次业务操作:搬家 + 提额。全程只见接口,不见一行 SQL */
public void moveAndRaise(String id, String newCity, int raiseDelta) {
Customer customer = dao.findById(id);
customer.move(newCity);
customer.raiseLimit(raiseDelta);
dao.save(customer);
customer.describe();
}
}
运行输出:
========== 正解:DAO 把“怎么存”收走 ==========
--- 场景一:业务代码只认识接口(JDBC 实现) ---
[JDBC] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms
[业务] 张三 搬到 上海
[业务] 张三 提额 +5000,当前额度 55000
[JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms
[当前] 张三 | 上海 | 额度 55000
--- 场景二:换成内存实现,CustomerService 一行不改 ---
[MEM] get(C002) ... 0ms
[业务] 李四 搬到 深圳
[业务] 李四 提额 +3000,当前额度 33000
[MEM] put(C002) ... 0ms
[当前] 李四 | 深圳 | 额度 33000
--- 场景三:单测纯业务规则,零 DAO 零 SQL ---
[规则] 拒绝:单次提额 99999 超过现有额度 40000 的 50%
亲手 new 一个对象就能测——存储压根不用出场。
三处对照读出含金量:场景一和 4.1 场景一是同一个业务,[SQL] 变成了 [JDBC]——SQL 没有消失(它总得有人写),只是从业务类搬进了 DAO;场景二换一个实现类,CustomerService 一行没改,[MEM] 与 [JDBC] 只是两种口音;场景三直击反面教材的病灶二——new Customer("C999", ...) 亲手造一个对象就能测业务规则,存储压根不用出场。
4.3 三处细节
细节一:接口只说业务语言。 findById 返回的是 Customer,不是 ResultSet、不是 String[] 行数组——调用方拿到的直接是领域对象。判断一个 DAO 成不成功就看这条:接口里只要出现存储类型(ResultSet、Connection、SQLException),抽象当场报废,调用方等于隔着接口抱住了数据库。反过来,DAO 实现里爱怎么写 SQL 都行——细节该藏的藏得住,才是这堵墙存在的意义。
细节二:DAO 要薄,只翻译不做业务。 哪天忍不住在 JdbcCustomerDao 里写"提额不能超 50%"的 if 判断,就赶紧停手——那是领域对象的话,DAO 替它说了,规则就有了第二处住所,改规则时必忘一处。原则:规则住在领域对象身上(谁经手都绕不过去),DAO 只管搬运。DAO 越厚,"换存储"越名不副实——厚到一定程度的 DAO,就是把胖实体换了个名字。内存实现还有个小讲究:findById 返回防御性拷贝——不然调用方改了引用,"库"跟着变,save 形同虚设。
细节三:谁来造 DAO。 本文测试里手动 new JdbcCustomerDao() 传进去——手写装配只够演示。真实项目从简单工厂起步,终点是依赖注入:Spring 容器里 @Autowired 一个 Repository,哪个实现、单例还是多例、事务怎么包,全部容器代办。构造注入是 DAO 模式的最后一块拼图:没有它,"换实现"还是要改代码(改 new 的那一行),有了它,换实现只是换配置。
五、在框架里找它
-
Spring Data JPA:
interface CustomerRepository extends JpaRepository<Customer, String>——接口你写,实现框架在运行时生成,连 SQL 都按方法名推出来。这是 DAO 思想的工业化极致:实现都不用写了,方法名findByCity就是接口语言,SELECT ... WHERE city = ?由框架代劳。 -
MyBatis 的 Mapper:Mapper 接口 + XML 映射文件——DAO 接口/实现分离的最显式形态:接口在 Java 里,SQL 在 XML 里,一一对应,中间没有魔法。本文 JdbcCustomerDao 里手写的翻译逻辑,MyBatis 全部声明式替你做了。
-
Hibernate Session / JPA EntityManager:一个通用巨型 DAO——
find、persist、merge之外还管一级缓存、脏检查、延迟加载。功能早已超出本文的薄 DAO,但那堵墙的位置一模一样:业务代码只见接口,不见 SQL。 -
Retrofit(网络源):"数据源"不一定是数据库。Retrofit 把 DAO 思想用在 HTTP API 上:接口声明方法,注解描述 URL 与参数,实现由动态代理生成——
interface GithubService { @GET("users/{u}") Call<User> user(@Path("u") String u); },与CustomerDao一脉相承。Room(Android 数据库)同理。DAO 立的墙,防的是"一切会变的存取细节"。
六、DAO vs Repository——最容易混淆的近亲
面试和代码评审里最常见的问题:DAO 和 Repository 什么区别?摊开对账:
| DAO | Repository | |
|---|---|---|
| 以什么为中心 | **表**:一个 DAO 对应一张表/一个实体 | **聚合**:一个 Repository 对应一个聚合根 |
| 词汇出身 | J2EE 蓝皮书,2000 年前后,面向"怎么存" | DDD(Evans,2003),面向领域 |
| 返回什么 | 单实体/数据行,按表拆 | 完整聚合(主对象 + 依赖对象整组) |
| 事务边界 | 单表为单位,跨表靠服务层串 | 以聚合为单位 |
| 一句话 | 存储的门卫 | 聚合的集合 |
串起 ㉘:Spring Data 官方说 Repository 应该定义在聚合根上,OrderItem 不配拥有自己的 Repository——那其实就是"组合实体 + DAO"的合体演进:㉘ 管"整组进出",㉙ 管"怎么存藏起来",DDD 聚合根仓库两件事一起管。现实里两者界限早已模糊:Spring Data 的接口名叫 Repository,干的多半还是 DAO 的活。名字不重要,边界重要——接口后面藏的是什么,调用方不该知道,这一条两边通用。
写在最后
- DAO = 存储边界上的隔离墙:接口说业务话,实现说存储话,中间谁也不越界
- 疗效:领域对象纯净可单测;换存储换实现不换业务;SQL 集中一处不再满天飞
- 铁律一:接口只说业务语言——返回 ResultSet 的 DAO 是失败的 DAO
- 铁律二:DAO 要薄,只翻译不做业务——规则住在领域对象身上
- 当代转世:Spring Data 接口生成实现、MyBatis Mapper、Retrofit——"数据源"早已不止数据库
- 近亲对账:DAO 以表为中心,Repository 以聚合为中心——㉘ 管整组、㉙ 管藏细节,合体就是 DDD 聚合根仓库
DAO 把"怎么存"收走了,业务层终于干净。可 Web 应用的麻烦不止在存储——请求进门的第一个问题是:谁接的活? Servlet 时代每个 Servlet 各自为政:登录校验复制粘贴二十份、字符编码各设各的、想统一加个访问日志都无处下手,更别提"统一分发到控制器"这种高级货。谁把"入口"统一起来?——前端控制器模式,下一篇见。