[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"site-settings":3,"\u002Farticles\u002Fdesign-pattern-29":26,"\u002Farticles\u002Fdesign-pattern-29\u002Frelated":43,"\u002Farticles\u002Fdesign-pattern-29\u002Fadjacent":72,"\u002Fcategories":84,"render-design-pattern-29":110},{"code":4,"message":5,"data":6},0,"ok",{"author_city":7,"author_years":8,"site_tagline":9,"site_description":10,"skill_one_desc":11,"author_status":12,"skill_two_title":13,"about_hero_image":9,"skill_one_title":14,"icp":15,"author_role":16,"og_default_image":9,"closing_quote_date":17,"toolkit":18,"skill_two_desc":19,"author_bio":9,"site_footer":9,"site_url":20,"author_tagline":21,"weekly_thought":9,"tagline_line_one":22,"author_bio_two":9,"closing_quote":23,"site_name":24,"tagline_line_two":25,"home_hero_image":9},"西安","10","","花开空白 - 个人博客","熟悉Java、Python、Go等后端语言。","努力工作中","前端开发","后端开发","陕ICP备2021009682号-1","开发者","2022年·秋","Java,JavaScript,TypeScript,Python,Go","熟悉Js、Ts语言以及Vue、uniapp等框架。","https:\u002F\u002Firule.cn","很高兴遇见你。","把复杂的事，","把每次一发布，都当成一次和陌生人的握手。","花开空白","写成简单的故事。",{"code":4,"message":5,"data":27},{"id":28,"slug":29,"title":30,"summary":31,"coverUrl":9,"categoryId":32,"status":33,"topFlag":4,"viewCount":34,"likeCount":4,"wordCount":35,"readingMinutes":36,"publishedAt":37,"contentMd":38,"contentHtml":39,"tags":40,"metaTitle":9,"metaDescription":9,"keywords":9,"canonicalUrl":9,"robots":41,"ogImage":9,"faq":42,"aiSummary":42},"2113935074440253441","design-pattern-29","设计模式修炼之路 ㉙ 数据访问对象模式——业务对象不该懂数据库","业务对象不该懂数据库、接口只说业务语言、换实现不换业务、单测不连库、Spring Data 工业化 DAO、DAO 与 Repository 对账","2113897628365230081",1,3913,6821,18,"2025-04-15T11:27:29","> J2EE 模式第四站。\n\n㉘ 的组合实体把\"一次加载、一次提交\"管得漂漂亮亮——可回头看看 CustomerEntity：load() 里拆字符串、commit() 里拼字符串，SELECT、UPDATE 的痕迹全漏进实体里了。\n\n这不是组合实体的锅，是它的半成品状态：远程边界管好了，存储边界还敞着门。这种\"既懂搬家提额、又懂 SELECT UPDATE\"的类有个学名——**胖实体**。它功能一切正常，结构却埋着三颗雷：业务和 SQL 焊死在一起；想单测一条业务规则，第一步必须先连库；哪天要换存储，SQL 长在每个方法里，动起来是全身手术。\n\n**把\"怎么存\"整体搬进一个专职对象，藏在接口后面——业务对象从此只说业务话**——数据访问对象模式，J2EE 八式里的隔离墙。\n\n## 一、数据访问对象模式是什么\n\nJ2EE 蓝皮书的定义大意：数据访问对象（Data Access Object，DAO）封装了某种数据源的访问细节，对外暴露一个用业务语言说话的接口；业务对象只依赖这个接口，不知道、也不需要知道背后是 MySQL、Redis 还是内存 HashMap。\n\n四个角色：\n\n\u003Ctable>\u003Cthead>\u003Ctr>\u003Cth>角色\u003C\u002Fth>\u003Cth>干什么\u003C\u002Fth>\u003Cth>本文对应\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\u003Ctbody>\u003Ctr>\u003Ctd>Business Object（业务对象）\u003C\u002Ftd>\u003Ctd>只懂业务的领域对象\u002F服务，零持久化知识\u003C\u002Ftd>\u003Ctd>Customer \u002F CustomerService\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>DAO 接口\u003C\u002Ftd>\u003Ctd>用业务语言声明数据操作\u003C\u002Ftd>\u003Ctd>CustomerDao\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>DAO 实现\u003C\u002Ftd>\u003Ctd>一种存储一个实现，负责领域对象与存储之间的翻译\u003C\u002Ftd>\u003Ctd>JdbcCustomerDao \u002F InMemoryCustomerDao\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>工厂或依赖注入（配套）\u003C\u002Ftd>\u003Ctd>决定装配哪个实现\u003C\u002Ftd>\u003Ctd>本文构造器直传，第五节交给 Spring\u003C\u002Ftd>\u003C\u002Ftr>\u003C\u002Ftbody>\u003C\u002Ftable>\n\n一句话理解分工：**业务对象说业务话（\"把张三找出来\"），DAO 说存储话（\"SELECT ... WHERE id = ?\"），中间隔着一个谁也不越界的接口。**\n\n它不是 GoF 二十三式，是 J2EE 蓝皮书成员——诞生动机很具体：J2EE 时代的业务组件一旦沾上 JDBC 代码，就和具体存储焊死了，换库、单测、复用全部瘫痪；蓝皮书的答案是给存储立一堵墙，墙的名字叫 DAO。\n\n## 二、优点与缺点\n\n**优点：**\n\n1. **业务与持久化解耦**：领域对象纯净——单测业务规则不需要数据库，一个 `new` 就能开测。\n2. **换存储 = 换实现**：MySQL 换 Redis、生产库换内存缓存，业务代码一行不改——接口不变，世界不变。\n3. **持久化细节集中一处**：SQL、行映射、格式化住在一个文件里，不再每个实体抄一遍、满天飞。\n4. **团队可分工**：领域建模的人写业务对象，懂数据库的人写 DAO，接口是两个工种的合同。\n\n**缺点：**\n\n1. **接口 + 实现双份类文件**：小项目就一张表，也要接口加两个实现，仪式感略重——抽象是替变化付的成本，没变化别预付。\n2. **接口设计容易露馅**：一旦接口方法返回 ResultSet、抛 SQLException，抽象当场报废——泄露的不是细节，是依赖。\n3. **跨表事务尴尬**：DAO 天生以单表\u002F单实体为单位，跨表操作要靠服务层串两个 DAO，事务边界容易丢——整组操作记得请 ㉘ 组合实体或服务层管事务。\n\n## 三、什么时候用\n\n判断标准：**业务对象 + 任何存储的边界**——这个组合几乎总是值得立一堵 DAO 墙。\n\n- **明确信号一：同一份业务要跑在不同存储上**——生产 MySQL、测试内存，DAO 是唯一不用复制代码的方案\n- **明确信号二：想单测业务规则不连库**——测试跑得快、不脆弱、不因数据库挂了而红\n- **明确信号三：SQL 想集中管理**——审计、慢查询治理、SQL 注入排查，一处看全\n\n反面对照：**纯内存工具、一次性脚本**——直接写就行；**数据源确定永不变、又没有测试诉求**的极小项目——可以省掉接口直接写实现类。除此之外，DAO 已经是现代分层架构的默认件，不是\"要不要用\"而是\"用得规不规范\"。\n\n## 四、实现方式：搬家 + 提额\n\n场景沿用 ㉘：客户张三，业务操作还是**搬家 + 提额**——这次盯着的不是网络往返，是 SQL 的行踪。\n\n### 4.1 反面教材：什么都懂的胖实体\n\n```java\npublic class FatCustomer {\n\n    private final String id;\n    private String name;\n    private String city;\n    private int creditLimit;\n\n    \u002F** 持久化：实体亲自连库、亲自拆行 *\u002F\n    public void load() {\n        System.out.println(\"    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = '\" + id + \"' ... 3ms\");\n        String[] row = FakeDatabase.selectRow(id);\n        name = row[0];\n        city = row[1];\n        creditLimit = Integer.parseInt(row[2]);\n    }\n\n    \u002F** 持久化：实体亲自拼行、亲自提交 *\u002F\n    public void save() {\n        System.out.println(\"    [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '\" + id + \"' ... 1ms\");\n        FakeDatabase.updateRow(id, new String[]{name, city, String.valueOf(creditLimit)});\n    }\n\n    \u002F\u002F ---------- 业务：搬家、提额，与正解版一模一样，略 ----------\n}\n```\n\n运行输出：\n\n```\n========== 反面教材：什么都懂的胖实体 ==========\n--- 场景一：搬家并提额（功能一切正常） ---\n    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms\n    [业务] 张三 搬到 上海\n    [业务] 张三 提额 +5000，当前额度 55000\n    [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms\n    [当前] 张三 | 上海 | 额度 55000\n    功能没毛病——可业务和 SQL 已经焊死在同一个类里。\n\n--- 场景二：单测“提额不超 50%”这条纯业务规则 ---\n    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C002' ... 3ms\n    [规则] 拒绝：单次提额 30000 超过现有额度 30000 的 50%\n    只是测一条业务规则，第一步却必须连库——规则和存储焊死了。\n```\n\n注意场景一的态度：**功能一切正常**——反面教材不是功能错误，是结构错误。病灶要在场景二和\"未来的某天\"才发作：\n\n1. **业务与 SQL 焊死**：想看搬家逻辑，先趟过一遍 SELECT；想审 SQL，得通读业务类——两拨人改同一个文件，天天冲突。\n2. **单测必须连库**：场景二只想验证\"提额不超 50%\"这条纯业务规则——不碰任何数据，第一步却必须是连库 `load()`。数据库一挂，几百个业务测试集体飘红，分不清是规则坏了还是环境坏了。\n3. **换存储 = 全身手术**：哪天老板说\"这套数据换 Redis \u002F 加一层缓存\"，SQL 长在实体每个方法里，逐处改；来了新实体 Order，load\u002Fsave 这套 SQL 还得原样再抄一遍。\n\n### 4.2 正解：DAO 三件套\n\n**第一件：接口，只说业务话。**\n\n```java\npublic interface CustomerDao {\n\n    Customer findById(String id);\n\n    void save(Customer customer);\n}\n```\n\n**第二件：JDBC 实现**——SQL 文本、行数组与领域对象之间的翻译（㉘ 里 format\u002Fparse 那套手艺），从实体身上原封不动搬到这里，**代码没白写，只是搬了家**：\n\n```java\npublic class JdbcCustomerDao implements CustomerDao {\n\n    @Override\n    public Customer findById(String id) {\n        System.out.println(\"    [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = '\" + id + \"' ... 3ms\");\n        String[] row = FakeDatabase.selectRow(id);\n        if (row == null) {\n            throw new IllegalArgumentException(\"客户不存在: \" + id);\n        }\n        return new Customer(id, row[0], row[1], Integer.parseInt(row[2]));\n    }\n\n    @Override\n    public void save(Customer customer) {\n        System.out.println(\"    [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '\" + customer.getId() + \"' ... 1ms\");\n        FakeDatabase.updateRow(customer.getId(),\n                new String[]{customer.getName(), customer.getCity(), String.valueOf(customer.getCreditLimit())});\n    }\n}\n```\n\n**第三件：第二个实现**（内存版，模拟缓存\u002F嵌入式存储）——同一个接口的第二个实现，就是\"换存储不换业务\"的全部证据。\n\n**业务服务只认识接口**，构造时给它什么，它就用什么：\n\n```java\npublic class CustomerService {\n\n    private final CustomerDao dao;\n\n    public CustomerService(CustomerDao dao) {\n        this.dao = dao;\n    }\n\n    \u002F** 一次业务操作：搬家 + 提额。全程只见接口，不见一行 SQL *\u002F\n    public void moveAndRaise(String id, String newCity, int raiseDelta) {\n        Customer customer = dao.findById(id);\n        customer.move(newCity);\n        customer.raiseLimit(raiseDelta);\n        dao.save(customer);\n        customer.describe();\n    }\n}\n```\n\n运行输出：\n\n```\n========== 正解：DAO 把“怎么存”收走 ==========\n--- 场景一：业务代码只认识接口（JDBC 实现） ---\n    [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms\n    [业务] 张三 搬到 上海\n    [业务] 张三 提额 +5000，当前额度 55000\n    [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms\n    [当前] 张三 | 上海 | 额度 55000\n\n--- 场景二：换成内存实现，CustomerService 一行不改 ---\n    [MEM] get(C002) ... 0ms\n    [业务] 李四 搬到 深圳\n    [业务] 李四 提额 +3000，当前额度 33000\n    [MEM] put(C002) ... 0ms\n    [当前] 李四 | 深圳 | 额度 33000\n\n--- 场景三：单测纯业务规则，零 DAO 零 SQL ---\n    [规则] 拒绝：单次提额 99999 超过现有额度 40000 的 50%\n    亲手 new 一个对象就能测——存储压根不用出场。\n```\n\n三处对照读出含金量：场景一和 4.1 场景一是**同一个业务**，`[SQL]` 变成了 `[JDBC]`——SQL 没有消失（它总得有人写），只是从业务类搬进了 DAO；场景二**换一个实现类，CustomerService 一行没改**，`[MEM]` 与 `[JDBC]` 只是两种口音；场景三直击反面教材的病灶二——`new Customer(\"C999\", ...)` 亲手造一个对象就能测业务规则，**存储压根不用出场**。\n\n### 4.3 三处细节\n\n**细节一：接口只说业务语言。** `findById` 返回的是 `Customer`，不是 ResultSet、不是 String[] 行数组——调用方拿到的直接是领域对象。判断一个 DAO 成不成功就看这条：**接口里只要出现存储类型（ResultSet、Connection、SQLException），抽象当场报废**，调用方等于隔着接口抱住了数据库。反过来，DAO 实现里爱怎么写 SQL 都行——细节该藏的藏得住，才是这堵墙存在的意义。\n\n**细节二：DAO 要薄，只翻译不做业务。** 哪天忍不住在 JdbcCustomerDao 里写\"提额不能超 50%\"的 if 判断，就赶紧停手——那是领域对象的话，DAO 替它说了，规则就有了第二处住所，改规则时必忘一处。原则：**规则住在领域对象身上（谁经手都绕不过去），DAO 只管搬运**。DAO 越厚，\"换存储\"越名不副实——厚到一定程度的 DAO，就是把胖实体换了个名字。内存实现还有个小讲究：findById 返回防御性拷贝——不然调用方改了引用，\"库\"跟着变，save 形同虚设。\n\n**细节三：谁来造 DAO。** 本文测试里手动 `new JdbcCustomerDao()` 传进去——手写装配只够演示。真实项目从简单工厂起步，终点是依赖注入：Spring 容器里 `@Autowired` 一个 Repository，哪个实现、单例还是多例、事务怎么包，全部容器代办。**构造注入是 DAO 模式的最后一块拼图**：没有它，\"换实现\"还是要改代码（改 new 的那一行），有了它，换实现只是换配置。\n\n## 五、在框架里找它\n\n1. **Spring Data JPA**：`interface CustomerRepository extends JpaRepository\u003CCustomer, String>`——接口你写，实现框架在运行时生成，连 SQL 都按方法名推出来。这是 DAO 思想的工业化极致：**实现都不用写了**，方法名 `findByCity` 就是接口语言，`SELECT ... WHERE city = ?` 由框架代劳。\n\n2. **MyBatis 的 Mapper**：Mapper 接口 + XML 映射文件——DAO 接口\u002F实现分离的最显式形态：接口在 Java 里，SQL 在 XML 里，一一对应，中间没有魔法。本文 JdbcCustomerDao 里手写的翻译逻辑，MyBatis 全部声明式替你做了。\n\n3. **Hibernate Session \u002F JPA EntityManager**：一个通用巨型 DAO——`find`、`persist`、`merge` 之外还管一级缓存、脏检查、延迟加载。功能早已超出本文的薄 DAO，但那堵墙的位置一模一样：业务代码只见接口，不见 SQL。\n\n4. **Retrofit（网络源）**：\"数据源\"不一定是数据库。Retrofit 把 DAO 思想用在 HTTP API 上：接口声明方法，注解描述 URL 与参数，实现由动态代理生成——`interface GithubService { @GET(\"users\u002F{u}\") Call\u003CUser> user(@Path(\"u\") String u); }`，与 `CustomerDao` 一脉相承。Room（Android 数据库）同理。**DAO 立的墙，防的是\"一切会变的存取细节\"。**\n\n## 六、DAO vs Repository——最容易混淆的近亲\n\n面试和代码评审里最常见的问题：DAO 和 Repository 什么区别？摊开对账：\n\n\u003Ctable>\u003Cthead>\u003Ctr>\u003Cth>\u003C\u002Fth>\u003Cth>DAO\u003C\u002Fth>\u003Cth>Repository\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\u003Ctbody>\u003Ctr>\u003Ctd>以什么为中心\u003C\u002Ftd>\u003Ctd>**表**：一个 DAO 对应一张表\u002F一个实体\u003C\u002Ftd>\u003Ctd>**聚合**：一个 Repository 对应一个聚合根\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>词汇出身\u003C\u002Ftd>\u003Ctd>J2EE 蓝皮书，2000 年前后，面向\"怎么存\"\u003C\u002Ftd>\u003Ctd>DDD（Evans，2003），面向领域\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>返回什么\u003C\u002Ftd>\u003Ctd>单实体\u002F数据行，按表拆\u003C\u002Ftd>\u003Ctd>完整聚合（主对象 + 依赖对象整组）\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>事务边界\u003C\u002Ftd>\u003Ctd>单表为单位，跨表靠服务层串\u003C\u002Ftd>\u003Ctd>以聚合为单位\u003C\u002Ftd>\u003C\u002Ftr>\u003Ctr>\u003Ctd>一句话\u003C\u002Ftd>\u003Ctd>存储的门卫\u003C\u002Ftd>\u003Ctd>聚合的集合\u003C\u002Ftd>\u003C\u002Ftr>\u003C\u002Ftbody>\u003C\u002Ftable>\n\n串起 ㉘：Spring Data 官方说 Repository 应该定义在**聚合根**上，OrderItem 不配拥有自己的 Repository——那其实就是\"**组合实体 + DAO**\"的合体演进：㉘ 管\"整组进出\"，㉙ 管\"怎么存藏起来\"，DDD 聚合根仓库两件事一起管。现实里两者界限早已模糊：Spring Data 的接口名叫 Repository，干的多半还是 DAO 的活。名字不重要，边界重要——**接口后面藏的是什么，调用方不该知道**，这一条两边通用。\n\n## 写在最后\n\n- DAO = 存储边界上的隔离墙：接口说业务话，实现说存储话，中间谁也不越界\n- 疗效：领域对象纯净可单测；换存储换实现不换业务；SQL 集中一处不再满天飞\n- 铁律一：接口只说业务语言——返回 ResultSet 的 DAO 是失败的 DAO\n- 铁律二：DAO 要薄，只翻译不做业务——规则住在领域对象身上\n- 当代转世：Spring Data 接口生成实现、MyBatis Mapper、Retrofit——\"数据源\"早已不止数据库\n- 近亲对账：DAO 以表为中心，Repository 以聚合为中心——㉘ 管整组、㉙ 管藏细节，合体就是 DDD 聚合根仓库\n\nDAO 把\"怎么存\"收走了，业务层终于干净。可 Web 应用的麻烦不止在存储——请求进门的第一个问题是：**谁接的活？** Servlet 时代每个 Servlet 各自为政：登录校验复制粘贴二十份、字符编码各设各的、想统一加个访问日志都无处下手，更别提\"统一分发到控制器\"这种高级货。谁把\"入口\"统一起来？——前端控制器模式，下一篇见。\n","\u003Cblockquote>\n \u003Cp>J2EE 模式第四站。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>㉘ 的组合实体把\"一次加载、一次提交\"管得漂漂亮亮——可回头看看 CustomerEntity：load() 里拆字符串、commit() 里拼字符串，SELECT、UPDATE 的痕迹全漏进实体里了。\u003C\u002Fp>\n\u003Cp>这不是组合实体的锅，是它的半成品状态：远程边界管好了，存储边界还敞着门。这种\"既懂搬家提额、又懂 SELECT UPDATE\"的类有个学名——\u003Cstrong>胖实体\u003C\u002Fstrong>。它功能一切正常，结构却埋着三颗雷：业务和 SQL 焊死在一起；想单测一条业务规则，第一步必须先连库；哪天要换存储，SQL 长在每个方法里，动起来是全身手术。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>把\"怎么存\"整体搬进一个专职对象，藏在接口后面——业务对象从此只说业务话\u003C\u002Fstrong>——数据访问对象模式，J2EE 八式里的隔离墙。\u003C\u002Fp>\n\u003Ch2>一、数据访问对象模式是什么\u003C\u002Fh2>\n\u003Cp>J2EE 蓝皮书的定义大意：数据访问对象（Data Access Object，DAO）封装了某种数据源的访问细节，对外暴露一个用业务语言说话的接口；业务对象只依赖这个接口，不知道、也不需要知道背后是 MySQL、Redis 还是内存 HashMap。\u003C\u002Fp>\n\u003Cp>四个角色：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>角色\u003C\u002Fth>\n   \u003Cth>干什么\u003C\u002Fth>\n   \u003Cth>本文对应\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>Business Object（业务对象）\u003C\u002Ftd>\n   \u003Ctd>只懂业务的领域对象\u002F服务，零持久化知识\u003C\u002Ftd>\n   \u003Ctd>Customer \u002F CustomerService\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>DAO 接口\u003C\u002Ftd>\n   \u003Ctd>用业务语言声明数据操作\u003C\u002Ftd>\n   \u003Ctd>CustomerDao\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>DAO 实现\u003C\u002Ftd>\n   \u003Ctd>一种存储一个实现，负责领域对象与存储之间的翻译\u003C\u002Ftd>\n   \u003Ctd>JdbcCustomerDao \u002F InMemoryCustomerDao\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>工厂或依赖注入（配套）\u003C\u002Ftd>\n   \u003Ctd>决定装配哪个实现\u003C\u002Ftd>\n   \u003Ctd>本文构造器直传，第五节交给 Spring\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>一句话理解分工：\u003Cstrong>业务对象说业务话（\"把张三找出来\"），DAO 说存储话（\"SELECT ... WHERE id = ?\"），中间隔着一个谁也不越界的接口。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>它不是 GoF 二十三式，是 J2EE 蓝皮书成员——诞生动机很具体：J2EE 时代的业务组件一旦沾上 JDBC 代码，就和具体存储焊死了，换库、单测、复用全部瘫痪；蓝皮书的答案是给存储立一堵墙，墙的名字叫 DAO。\u003C\u002Fp>\n\u003Ch2>二、优点与缺点\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>优点：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Col>\n \u003Cli>\u003Cstrong>业务与持久化解耦\u003C\u002Fstrong>：领域对象纯净——单测业务规则不需要数据库，一个 \u003Ccode>new\u003C\u002Fcode> 就能开测。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>换存储 = 换实现\u003C\u002Fstrong>：MySQL 换 Redis、生产库换内存缓存，业务代码一行不改——接口不变，世界不变。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>持久化细节集中一处\u003C\u002Fstrong>：SQL、行映射、格式化住在一个文件里，不再每个实体抄一遍、满天飞。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>团队可分工\u003C\u002Fstrong>：领域建模的人写业务对象，懂数据库的人写 DAO，接口是两个工种的合同。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>\u003Cstrong>缺点：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Col>\n \u003Cli>\u003Cstrong>接口 + 实现双份类文件\u003C\u002Fstrong>：小项目就一张表，也要接口加两个实现，仪式感略重——抽象是替变化付的成本，没变化别预付。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>接口设计容易露馅\u003C\u002Fstrong>：一旦接口方法返回 ResultSet、抛 SQLException，抽象当场报废——泄露的不是细节，是依赖。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>跨表事务尴尬\u003C\u002Fstrong>：DAO 天生以单表\u002F单实体为单位，跨表操作要靠服务层串两个 DAO，事务边界容易丢——整组操作记得请 ㉘ 组合实体或服务层管事务。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>三、什么时候用\u003C\u002Fh2>\n\u003Cp>判断标准：\u003Cstrong>业务对象 + 任何存储的边界\u003C\u002Fstrong>——这个组合几乎总是值得立一堵 DAO 墙。\u003C\u002Fp>\n\u003Cul>\n \u003Cli>\u003Cstrong>明确信号一：同一份业务要跑在不同存储上\u003C\u002Fstrong>——生产 MySQL、测试内存，DAO 是唯一不用复制代码的方案\u003C\u002Fli>\n \u003Cli>\u003Cstrong>明确信号二：想单测业务规则不连库\u003C\u002Fstrong>——测试跑得快、不脆弱、不因数据库挂了而红\u003C\u002Fli>\n \u003Cli>\u003Cstrong>明确信号三：SQL 想集中管理\u003C\u002Fstrong>——审计、慢查询治理、SQL 注入排查，一处看全\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>反面对照：\u003Cstrong>纯内存工具、一次性脚本\u003C\u002Fstrong>——直接写就行；\u003Cstrong>数据源确定永不变、又没有测试诉求\u003C\u002Fstrong>的极小项目——可以省掉接口直接写实现类。除此之外，DAO 已经是现代分层架构的默认件，不是\"要不要用\"而是\"用得规不规范\"。\u003C\u002Fp>\n\u003Ch2>四、实现方式：搬家 + 提额\u003C\u002Fh2>\n\u003Cp>场景沿用 ㉘：客户张三，业务操作还是\u003Cstrong>搬家 + 提额\u003C\u002Fstrong>——这次盯着的不是网络往返，是 SQL 的行踪。\u003C\u002Fp>\n\u003Ch3>4.1 反面教材：什么都懂的胖实体\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-java\">public class FatCustomer {\n\n    private final String id;\n    private String name;\n    private String city;\n    private int creditLimit;\n\n    \u002F** 持久化：实体亲自连库、亲自拆行 *\u002F\n    public void load() {\n        System.out.println(\"    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = '\" + id + \"' ... 3ms\");\n        String[] row = FakeDatabase.selectRow(id);\n        name = row[0];\n        city = row[1];\n        creditLimit = Integer.parseInt(row[2]);\n    }\n\n    \u002F** 持久化：实体亲自拼行、亲自提交 *\u002F\n    public void save() {\n        System.out.println(\"    [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '\" + id + \"' ... 1ms\");\n        FakeDatabase.updateRow(id, new String[]{name, city, String.valueOf(creditLimit)});\n    }\n\n    \u002F\u002F ---------- 业务：搬家、提额，与正解版一模一样，略 ----------\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>运行输出：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>========== 反面教材：什么都懂的胖实体 ==========\n--- 场景一：搬家并提额（功能一切正常） ---\n    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms\n    [业务] 张三 搬到 上海\n    [业务] 张三 提额 +5000，当前额度 55000\n    [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms\n    [当前] 张三 | 上海 | 额度 55000\n    功能没毛病——可业务和 SQL 已经焊死在同一个类里。\n\n--- 场景二：单测“提额不超 50%”这条纯业务规则 ---\n    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C002' ... 3ms\n    [规则] 拒绝：单次提额 30000 超过现有额度 30000 的 50%\n    只是测一条业务规则，第一步却必须连库——规则和存储焊死了。\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>注意场景一的态度：\u003Cstrong>功能一切正常\u003C\u002Fstrong>——反面教材不是功能错误，是结构错误。病灶要在场景二和\"未来的某天\"才发作：\u003C\u002Fp>\n\u003Col>\n \u003Cli>\u003Cstrong>业务与 SQL 焊死\u003C\u002Fstrong>：想看搬家逻辑，先趟过一遍 SELECT；想审 SQL，得通读业务类——两拨人改同一个文件，天天冲突。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>单测必须连库\u003C\u002Fstrong>：场景二只想验证\"提额不超 50%\"这条纯业务规则——不碰任何数据，第一步却必须是连库 \u003Ccode>load()\u003C\u002Fcode>。数据库一挂，几百个业务测试集体飘红，分不清是规则坏了还是环境坏了。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>换存储 = 全身手术\u003C\u002Fstrong>：哪天老板说\"这套数据换 Redis \u002F 加一层缓存\"，SQL 长在实体每个方法里，逐处改；来了新实体 Order，load\u002Fsave 这套 SQL 还得原样再抄一遍。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3>4.2 正解：DAO 三件套\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>第一件：接口，只说业务话。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-java\">public interface CustomerDao {\n\n    Customer findById(String id);\n\n    void save(Customer customer);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>第二件：JDBC 实现\u003C\u002Fstrong>——SQL 文本、行数组与领域对象之间的翻译（㉘ 里 format\u002Fparse 那套手艺），从实体身上原封不动搬到这里，\u003Cstrong>代码没白写，只是搬了家\u003C\u002Fstrong>：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-java\">public class JdbcCustomerDao implements CustomerDao {\n\n    @Override\n    public Customer findById(String id) {\n        System.out.println(\"    [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = '\" + id + \"' ... 3ms\");\n        String[] row = FakeDatabase.selectRow(id);\n        if (row == null) {\n            throw new IllegalArgumentException(\"客户不存在: \" + id);\n        }\n        return new Customer(id, row[0], row[1], Integer.parseInt(row[2]));\n    }\n\n    @Override\n    public void save(Customer customer) {\n        System.out.println(\"    [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '\" + customer.getId() + \"' ... 1ms\");\n        FakeDatabase.updateRow(customer.getId(),\n                new String[]{customer.getName(), customer.getCity(), String.valueOf(customer.getCreditLimit())});\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>第三件：第二个实现\u003C\u002Fstrong>（内存版，模拟缓存\u002F嵌入式存储）——同一个接口的第二个实现，就是\"换存储不换业务\"的全部证据。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>业务服务只认识接口\u003C\u002Fstrong>，构造时给它什么，它就用什么：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-java\">public class CustomerService {\n\n    private final CustomerDao dao;\n\n    public CustomerService(CustomerDao dao) {\n        this.dao = dao;\n    }\n\n    \u002F** 一次业务操作：搬家 + 提额。全程只见接口，不见一行 SQL *\u002F\n    public void moveAndRaise(String id, String newCity, int raiseDelta) {\n        Customer customer = dao.findById(id);\n        customer.move(newCity);\n        customer.raiseLimit(raiseDelta);\n        dao.save(customer);\n        customer.describe();\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>运行输出：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>========== 正解：DAO 把“怎么存”收走 ==========\n--- 场景一：业务代码只认识接口（JDBC 实现） ---\n    [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms\n    [业务] 张三 搬到 上海\n    [业务] 张三 提额 +5000，当前额度 55000\n    [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms\n    [当前] 张三 | 上海 | 额度 55000\n\n--- 场景二：换成内存实现，CustomerService 一行不改 ---\n    [MEM] get(C002) ... 0ms\n    [业务] 李四 搬到 深圳\n    [业务] 李四 提额 +3000，当前额度 33000\n    [MEM] put(C002) ... 0ms\n    [当前] 李四 | 深圳 | 额度 33000\n\n--- 场景三：单测纯业务规则，零 DAO 零 SQL ---\n    [规则] 拒绝：单次提额 99999 超过现有额度 40000 的 50%\n    亲手 new 一个对象就能测——存储压根不用出场。\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>三处对照读出含金量：场景一和 4.1 场景一是\u003Cstrong>同一个业务\u003C\u002Fstrong>，\u003Ccode>[SQL]\u003C\u002Fcode> 变成了 \u003Ccode>[JDBC]\u003C\u002Fcode>——SQL 没有消失（它总得有人写），只是从业务类搬进了 DAO；场景二\u003Cstrong>换一个实现类，CustomerService 一行没改\u003C\u002Fstrong>，\u003Ccode>[MEM]\u003C\u002Fcode> 与 \u003Ccode>[JDBC]\u003C\u002Fcode> 只是两种口音；场景三直击反面教材的病灶二——\u003Ccode>new Customer(\"C999\", ...)\u003C\u002Fcode> 亲手造一个对象就能测业务规则，\u003Cstrong>存储压根不用出场\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Ch3>4.3 三处细节\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>细节一：接口只说业务语言。\u003C\u002Fstrong> \u003Ccode>findById\u003C\u002Fcode> 返回的是 \u003Ccode>Customer\u003C\u002Fcode>，不是 ResultSet、不是 String[] 行数组——调用方拿到的直接是领域对象。判断一个 DAO 成不成功就看这条：\u003Cstrong>接口里只要出现存储类型（ResultSet、Connection、SQLException），抽象当场报废\u003C\u002Fstrong>，调用方等于隔着接口抱住了数据库。反过来，DAO 实现里爱怎么写 SQL 都行——细节该藏的藏得住，才是这堵墙存在的意义。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>细节二：DAO 要薄，只翻译不做业务。\u003C\u002Fstrong> 哪天忍不住在 JdbcCustomerDao 里写\"提额不能超 50%\"的 if 判断，就赶紧停手——那是领域对象的话，DAO 替它说了，规则就有了第二处住所，改规则时必忘一处。原则：\u003Cstrong>规则住在领域对象身上（谁经手都绕不过去），DAO 只管搬运\u003C\u002Fstrong>。DAO 越厚，\"换存储\"越名不副实——厚到一定程度的 DAO，就是把胖实体换了个名字。内存实现还有个小讲究：findById 返回防御性拷贝——不然调用方改了引用，\"库\"跟着变，save 形同虚设。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>细节三：谁来造 DAO。\u003C\u002Fstrong> 本文测试里手动 \u003Ccode>new JdbcCustomerDao()\u003C\u002Fcode> 传进去——手写装配只够演示。真实项目从简单工厂起步，终点是依赖注入：Spring 容器里 \u003Ccode>@Autowired\u003C\u002Fcode> 一个 Repository，哪个实现、单例还是多例、事务怎么包，全部容器代办。\u003Cstrong>构造注入是 DAO 模式的最后一块拼图\u003C\u002Fstrong>：没有它，\"换实现\"还是要改代码（改 new 的那一行），有了它，换实现只是换配置。\u003C\u002Fp>\n\u003Ch2>五、在框架里找它\u003C\u002Fh2>\n\u003Col>\n \u003Cli>\n  \u003Cp>\u003Cstrong>Spring Data JPA\u003C\u002Fstrong>：\u003Ccode>interface CustomerRepository extends JpaRepository&lt;Customer, String&gt;\u003C\u002Fcode>——接口你写，实现框架在运行时生成，连 SQL 都按方法名推出来。这是 DAO 思想的工业化极致：\u003Cstrong>实现都不用写了\u003C\u002Fstrong>，方法名 \u003Ccode>findByCity\u003C\u002Fcode> 就是接口语言，\u003Ccode>SELECT ... WHERE city = ?\u003C\u002Fcode> 由框架代劳。\u003C\u002Fp>\u003C\u002Fli>\n \u003Cli>\n  \u003Cp>\u003Cstrong>MyBatis 的 Mapper\u003C\u002Fstrong>：Mapper 接口 + XML 映射文件——DAO 接口\u002F实现分离的最显式形态：接口在 Java 里，SQL 在 XML 里，一一对应，中间没有魔法。本文 JdbcCustomerDao 里手写的翻译逻辑，MyBatis 全部声明式替你做了。\u003C\u002Fp>\u003C\u002Fli>\n \u003Cli>\n  \u003Cp>\u003Cstrong>Hibernate Session \u002F JPA EntityManager\u003C\u002Fstrong>：一个通用巨型 DAO——\u003Ccode>find\u003C\u002Fcode>、\u003Ccode>persist\u003C\u002Fcode>、\u003Ccode>merge\u003C\u002Fcode> 之外还管一级缓存、脏检查、延迟加载。功能早已超出本文的薄 DAO，但那堵墙的位置一模一样：业务代码只见接口，不见 SQL。\u003C\u002Fp>\u003C\u002Fli>\n \u003Cli>\n  \u003Cp>\u003Cstrong>Retrofit（网络源）\u003C\u002Fstrong>：\"数据源\"不一定是数据库。Retrofit 把 DAO 思想用在 HTTP API 上：接口声明方法，注解描述 URL 与参数，实现由动态代理生成——\u003Ccode>interface GithubService { @GET(\"users\u002F{u}\") Call&lt;User&gt; user(@Path(\"u\") String u); }\u003C\u002Fcode>，与 \u003Ccode>CustomerDao\u003C\u002Fcode> 一脉相承。Room（Android 数据库）同理。\u003Cstrong>DAO 立的墙，防的是\"一切会变的存取细节\"。\u003C\u002Fstrong>\u003C\u002Fp>\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>六、DAO vs Repository——最容易混淆的近亲\u003C\u002Fh2>\n\u003Cp>面试和代码评审里最常见的问题：DAO 和 Repository 什么区别？摊开对账：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>\u003C\u002Fth>\n   \u003Cth>DAO\u003C\u002Fth>\n   \u003Cth>Repository\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>以什么为中心\u003C\u002Ftd>\n   \u003Ctd>**表**：一个 DAO 对应一张表\u002F一个实体\u003C\u002Ftd>\n   \u003Ctd>**聚合**：一个 Repository 对应一个聚合根\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>词汇出身\u003C\u002Ftd>\n   \u003Ctd>J2EE 蓝皮书，2000 年前后，面向\"怎么存\"\u003C\u002Ftd>\n   \u003Ctd>DDD（Evans，2003），面向领域\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>返回什么\u003C\u002Ftd>\n   \u003Ctd>单实体\u002F数据行，按表拆\u003C\u002Ftd>\n   \u003Ctd>完整聚合（主对象 + 依赖对象整组）\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>事务边界\u003C\u002Ftd>\n   \u003Ctd>单表为单位，跨表靠服务层串\u003C\u002Ftd>\n   \u003Ctd>以聚合为单位\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>一句话\u003C\u002Ftd>\n   \u003Ctd>存储的门卫\u003C\u002Ftd>\n   \u003Ctd>聚合的集合\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>串起 ㉘：Spring Data 官方说 Repository 应该定义在\u003Cstrong>聚合根\u003C\u002Fstrong>上，OrderItem 不配拥有自己的 Repository——那其实就是\"\u003Cstrong>组合实体 + DAO\u003C\u002Fstrong>\"的合体演进：㉘ 管\"整组进出\"，㉙ 管\"怎么存藏起来\"，DDD 聚合根仓库两件事一起管。现实里两者界限早已模糊：Spring Data 的接口名叫 Repository，干的多半还是 DAO 的活。名字不重要，边界重要——\u003Cstrong>接口后面藏的是什么，调用方不该知道\u003C\u002Fstrong>，这一条两边通用。\u003C\u002Fp>\n\u003Ch2>写在最后\u003C\u002Fh2>\n\u003Cul>\n \u003Cli>DAO = 存储边界上的隔离墙：接口说业务话，实现说存储话，中间谁也不越界\u003C\u002Fli>\n \u003Cli>疗效：领域对象纯净可单测；换存储换实现不换业务；SQL 集中一处不再满天飞\u003C\u002Fli>\n \u003Cli>铁律一：接口只说业务语言——返回 ResultSet 的 DAO 是失败的 DAO\u003C\u002Fli>\n \u003Cli>铁律二：DAO 要薄，只翻译不做业务——规则住在领域对象身上\u003C\u002Fli>\n \u003Cli>当代转世：Spring Data 接口生成实现、MyBatis Mapper、Retrofit——\"数据源\"早已不止数据库\u003C\u002Fli>\n \u003Cli>近亲对账：DAO 以表为中心，Repository 以聚合为中心——㉘ 管整组、㉙ 管藏细节，合体就是 DDD 聚合根仓库\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>DAO 把\"怎么存\"收走了，业务层终于干净。可 Web 应用的麻烦不止在存储——请求进门的第一个问题是：\u003Cstrong>谁接的活？\u003C\u002Fstrong> Servlet 时代每个 Servlet 各自为政：登录校验复制粘贴二十份、字符编码各设各的、想统一加个访问日志都无处下手，更别提\"统一分发到控制器\"这种高级货。谁把\"入口\"统一起来？——前端控制器模式，下一篇见。\u003C\u002Fp>",[],"index,follow",null,{"code":4,"message":5,"data":44},[45,54,63],{"id":46,"slug":47,"title":48,"summary":49,"coverUrl":9,"categoryId":32,"status":33,"topFlag":4,"viewCount":50,"likeCount":4,"wordCount":51,"readingMinutes":52,"publishedAt":53},"2113935015761940481","design-pattern-28","设计模式修炼之路 ㉘ 组合实体模式——一窝小对象，一次网络往返","一窝小对象一次网络往返、读放大与 N+1、一次提交就是事务边界、依赖对象没有远程身份、JPA 聚合根转世",3270,7543,19,"2025-04-07T11:55:37",{"id":55,"slug":56,"title":57,"summary":58,"coverUrl":9,"categoryId":32,"status":33,"topFlag":4,"viewCount":59,"likeCount":4,"wordCount":60,"readingMinutes":61,"publishedAt":62},"2113935321048551425","design-pattern-33","设计模式修炼之路 ㉝ 传输对象模式——一趟网络，带走整份数据","J2EE 模式第八站，也是 J2EE 八式的收官一站。",2516,8059,21,"2025-07-09T21:16:41",{"id":64,"slug":65,"title":66,"summary":67,"coverUrl":9,"categoryId":32,"status":33,"topFlag":4,"viewCount":68,"likeCount":4,"wordCount":69,"readingMinutes":70,"publishedAt":71},"2113934922031828994","design-pattern-27","设计模式修炼之路 ㉗ 业务代表模式——替表示层挡住远程的脏活","替表示层挡住远程脏活、查找+重试+异常翻译一肩挑、重试风暴、外观\u002F代理\u002F业务代表三兄弟对账、Feign 转世",1811,6147,16,"2025-03-25T21:44:13",{"code":4,"message":5,"data":73},{"prev":74,"next":75},{"id":46,"slug":47,"title":48,"summary":49,"coverUrl":9,"categoryId":32,"status":33,"topFlag":4,"viewCount":50,"likeCount":4,"wordCount":51,"readingMinutes":52,"publishedAt":53},{"id":76,"slug":77,"title":78,"summary":79,"coverUrl":9,"categoryId":32,"status":33,"topFlag":4,"viewCount":80,"likeCount":4,"wordCount":81,"readingMinutes":82,"publishedAt":83},"2113935138768293890","design-pattern-30","设计模式修炼之路 ㉚ 前端控制器模式——所有请求，先过我这道门","横切关注点写一次全站无死角、抄漏一处校验就是一个洞、门神必须薄、路由分散声明集中执行、与 MVC 的 C 对账",2755,5825,15,"2025-05-12T18:40:38",{"code":4,"message":5,"data":85},[86,92,97,103],{"id":87,"name":88,"slug":89,"description":90,"sortOrder":4,"articleCount":91},"1946000000000000001","antlr4","tech","编程与技术笔记","13",{"id":32,"name":93,"slug":94,"description":95,"sortOrder":4,"articleCount":96},"设计模式","design-pattern","设计模式系列","34",{"id":98,"name":99,"slug":100,"description":101,"sortOrder":33,"articleCount":102},"1946000000000000002","随笔","essay","生活与思考","0",{"id":104,"name":105,"slug":106,"description":107,"sortOrder":108,"articleCount":109},"2105200093764698113","工具","tools","好用的工具推荐",2,"1",{"html":111,"toc":112},"\u003Cblockquote>\n \u003Cp>J2EE 模式第四站。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>㉘ 的组合实体把\"一次加载、一次提交\"管得漂漂亮亮——可回头看看 CustomerEntity：load() 里拆字符串、commit() 里拼字符串，SELECT、UPDATE 的痕迹全漏进实体里了。\u003C\u002Fp>\n\u003Cp>这不是组合实体的锅，是它的半成品状态：远程边界管好了，存储边界还敞着门。这种\"既懂搬家提额、又懂 SELECT UPDATE\"的类有个学名——\u003Cstrong>胖实体\u003C\u002Fstrong>。它功能一切正常，结构却埋着三颗雷：业务和 SQL 焊死在一起；想单测一条业务规则，第一步必须先连库；哪天要换存储，SQL 长在每个方法里，动起来是全身手术。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>把\"怎么存\"整体搬进一个专职对象，藏在接口后面——业务对象从此只说业务话\u003C\u002Fstrong>——数据访问对象模式，J2EE 八式里的隔离墙。\u003C\u002Fp>\n\u003Ch2 id=\"toc-1\">一、数据访问对象模式是什么\u003C\u002Fh2>\n\u003Cp>J2EE 蓝皮书的定义大意：数据访问对象（Data Access Object，DAO）封装了某种数据源的访问细节，对外暴露一个用业务语言说话的接口；业务对象只依赖这个接口，不知道、也不需要知道背后是 MySQL、Redis 还是内存 HashMap。\u003C\u002Fp>\n\u003Cp>四个角色：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>角色\u003C\u002Fth>\n   \u003Cth>干什么\u003C\u002Fth>\n   \u003Cth>本文对应\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>Business Object（业务对象）\u003C\u002Ftd>\n   \u003Ctd>只懂业务的领域对象\u002F服务，零持久化知识\u003C\u002Ftd>\n   \u003Ctd>Customer \u002F CustomerService\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>DAO 接口\u003C\u002Ftd>\n   \u003Ctd>用业务语言声明数据操作\u003C\u002Ftd>\n   \u003Ctd>CustomerDao\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>DAO 实现\u003C\u002Ftd>\n   \u003Ctd>一种存储一个实现，负责领域对象与存储之间的翻译\u003C\u002Ftd>\n   \u003Ctd>JdbcCustomerDao \u002F InMemoryCustomerDao\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>工厂或依赖注入（配套）\u003C\u002Ftd>\n   \u003Ctd>决定装配哪个实现\u003C\u002Ftd>\n   \u003Ctd>本文构造器直传，第五节交给 Spring\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>一句话理解分工：\u003Cstrong>业务对象说业务话（\"把张三找出来\"），DAO 说存储话（\"SELECT ... WHERE id = ?\"），中间隔着一个谁也不越界的接口。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>它不是 GoF 二十三式，是 J2EE 蓝皮书成员——诞生动机很具体：J2EE 时代的业务组件一旦沾上 JDBC 代码，就和具体存储焊死了，换库、单测、复用全部瘫痪；蓝皮书的答案是给存储立一堵墙，墙的名字叫 DAO。\u003C\u002Fp>\n\u003Ch2 id=\"toc-2\">二、优点与缺点\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>优点：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Col>\n \u003Cli>\u003Cstrong>业务与持久化解耦\u003C\u002Fstrong>：领域对象纯净——单测业务规则不需要数据库，一个 \u003Ccode>new\u003C\u002Fcode> 就能开测。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>换存储 = 换实现\u003C\u002Fstrong>：MySQL 换 Redis、生产库换内存缓存，业务代码一行不改——接口不变，世界不变。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>持久化细节集中一处\u003C\u002Fstrong>：SQL、行映射、格式化住在一个文件里，不再每个实体抄一遍、满天飞。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>团队可分工\u003C\u002Fstrong>：领域建模的人写业务对象，懂数据库的人写 DAO，接口是两个工种的合同。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>\u003Cstrong>缺点：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Col>\n \u003Cli>\u003Cstrong>接口 + 实现双份类文件\u003C\u002Fstrong>：小项目就一张表，也要接口加两个实现，仪式感略重——抽象是替变化付的成本，没变化别预付。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>接口设计容易露馅\u003C\u002Fstrong>：一旦接口方法返回 ResultSet、抛 SQLException，抽象当场报废——泄露的不是细节，是依赖。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>跨表事务尴尬\u003C\u002Fstrong>：DAO 天生以单表\u002F单实体为单位，跨表操作要靠服务层串两个 DAO，事务边界容易丢——整组操作记得请 ㉘ 组合实体或服务层管事务。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2 id=\"toc-3\">三、什么时候用\u003C\u002Fh2>\n\u003Cp>判断标准：\u003Cstrong>业务对象 + 任何存储的边界\u003C\u002Fstrong>——这个组合几乎总是值得立一堵 DAO 墙。\u003C\u002Fp>\n\u003Cul>\n \u003Cli>\u003Cstrong>明确信号一：同一份业务要跑在不同存储上\u003C\u002Fstrong>——生产 MySQL、测试内存，DAO 是唯一不用复制代码的方案\u003C\u002Fli>\n \u003Cli>\u003Cstrong>明确信号二：想单测业务规则不连库\u003C\u002Fstrong>——测试跑得快、不脆弱、不因数据库挂了而红\u003C\u002Fli>\n \u003Cli>\u003Cstrong>明确信号三：SQL 想集中管理\u003C\u002Fstrong>——审计、慢查询治理、SQL 注入排查，一处看全\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>反面对照：\u003Cstrong>纯内存工具、一次性脚本\u003C\u002Fstrong>——直接写就行；\u003Cstrong>数据源确定永不变、又没有测试诉求\u003C\u002Fstrong>的极小项目——可以省掉接口直接写实现类。除此之外，DAO 已经是现代分层架构的默认件，不是\"要不要用\"而是\"用得规不规范\"。\u003C\u002Fp>\n\u003Ch2 id=\"toc-4\">四、实现方式：搬家 + 提额\u003C\u002Fh2>\n\u003Cp>场景沿用 ㉘：客户张三，业务操作还是\u003Cstrong>搬家 + 提额\u003C\u002Fstrong>——这次盯着的不是网络往返，是 SQL 的行踪。\u003C\u002Fp>\n\u003Ch3 id=\"toc-5\">4.1 反面教材：什么都懂的胖实体\u003C\u002Fh3>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> FatCustomer\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    private\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> final\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> String id;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    private\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> String name;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    private\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> String city;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    private\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> int\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> creditLimit;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    \u002F** 持久化：实体亲自连库、亲自拆行 *\u002F\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> void\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> load\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        System.out.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">println\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = '\"\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> +\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> id \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"' ... 3ms\"\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        String\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">[] row \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> FakeDatabase.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">selectRow\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(id);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        name \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> row[\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">0\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">];\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        city \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> row[\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">];\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        creditLimit \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> Integer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">parseInt\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(row[\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">2\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">]);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    \u002F** 持久化：实体亲自拼行、亲自提交 *\u002F\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> void\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> save\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        System.out.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">println\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"    [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '\"\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> +\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> id \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"' ... 1ms\"\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        FakeDatabase.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">updateRow\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(id, \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">new\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> String\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">[]{name, city, String.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">valueOf\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(creditLimit)});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    \u002F\u002F ---------- 业务：搬家、提额，与正解版一模一样，略 ----------\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>运行输出：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>========== 反面教材：什么都懂的胖实体 ==========\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>--- 场景一：搬家并提额（功能一切正常） ---\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [业务] 张三 搬到 上海\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [业务] 张三 提额 +5000，当前额度 55000\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [SQL] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [当前] 张三 | 上海 | 额度 55000\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    功能没毛病——可业务和 SQL 已经焊死在同一个类里。\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>--- 场景二：单测“提额不超 50%”这条纯业务规则 ---\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [SQL] SELECT name, city, credit_limit FROM customer WHERE id = 'C002' ... 3ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [规则] 拒绝：单次提额 30000 超过现有额度 30000 的 50%\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    只是测一条业务规则，第一步却必须连库——规则和存储焊死了。\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>注意场景一的态度：\u003Cstrong>功能一切正常\u003C\u002Fstrong>——反面教材不是功能错误，是结构错误。病灶要在场景二和\"未来的某天\"才发作：\u003C\u002Fp>\n\u003Col>\n \u003Cli>\u003Cstrong>业务与 SQL 焊死\u003C\u002Fstrong>：想看搬家逻辑，先趟过一遍 SELECT；想审 SQL，得通读业务类——两拨人改同一个文件，天天冲突。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>单测必须连库\u003C\u002Fstrong>：场景二只想验证\"提额不超 50%\"这条纯业务规则——不碰任何数据，第一步却必须是连库 \u003Ccode>load()\u003C\u002Fcode>。数据库一挂，几百个业务测试集体飘红，分不清是规则坏了还是环境坏了。\u003C\u002Fli>\n \u003Cli>\u003Cstrong>换存储 = 全身手术\u003C\u002Fstrong>：哪天老板说\"这套数据换 Redis \u002F 加一层缓存\"，SQL 长在实体每个方法里，逐处改；来了新实体 Order，load\u002Fsave 这套 SQL 还得原样再抄一遍。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3 id=\"toc-6\">4.2 正解：DAO 三件套\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>第一件：接口，只说业务话。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> interface\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> CustomerDao\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    Customer \u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">findById\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(String \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">id\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    void\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> save\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(Customer \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">customer\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>第二件：JDBC 实现\u003C\u002Fstrong>——SQL 文本、行数组与领域对象之间的翻译（㉘ 里 format\u002Fparse 那套手艺），从实体身上原封不动搬到这里，\u003Cstrong>代码没白写，只是搬了家\u003C\u002Fstrong>：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> JdbcCustomerDao\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> implements\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> CustomerDao\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    @\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">Override\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    public\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> Customer \u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">findById\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(String \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">id\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        System.out.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">println\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"    [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = '\"\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> +\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> id \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"' ... 3ms\"\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        String\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">[] row \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> FakeDatabase.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">selectRow\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(id);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        if\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (row \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">==\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> null\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">            throw\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> IllegalArgumentException\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"客户不存在: \"\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> +\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> id);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        return\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> Customer\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(id, row[\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">0\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">], row[\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">], Integer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">parseInt\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(row[\u003C\u002Fspan>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">2\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">]));\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    @\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">Override\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> void\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> save\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(Customer \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">customer\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        System.out.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">println\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"    [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = '\"\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> +\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">getId\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"' ... 1ms\"\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        FakeDatabase.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">updateRow\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">getId\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(),\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">                new\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> String\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">[]{customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">getName\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(), customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">getCity\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(), String.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">valueOf\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">getCreditLimit\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">())});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>第三件：第二个实现\u003C\u002Fstrong>（内存版，模拟缓存\u002F嵌入式存储）——同一个接口的第二个实现，就是\"换存储不换业务\"的全部证据。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>业务服务只认识接口\u003C\u002Fstrong>，构造时给它什么，它就用什么：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> CustomerService\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    private\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> final\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> CustomerDao dao;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    public\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> CustomerService\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(CustomerDao \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">dao\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">        this\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">.dao \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> dao;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    \u002F** 一次业务操作：搬家 + 提额。全程只见接口，不见一行 SQL *\u002F\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    public\u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> void\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> moveAndRaise\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(String \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">id\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, String \u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\">newCity\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">int\u003C\u002Fspan>\u003Cspan style=\"color:#E36209;--shiki-dark:#FFAB70\"> raiseDelta\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        Customer customer \u003C\u002Fspan>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> dao.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">findById\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(id);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">move\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(newCity);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">raiseLimit\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(raiseDelta);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        dao.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">save\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(customer);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        customer.\u003C\u002Fspan>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">describe\u003C\u002Fspan>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>运行输出：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>========== 正解：DAO 把“怎么存”收走 ==========\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>--- 场景一：业务代码只认识接口（JDBC 实现） ---\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [JDBC] SELECT name, city, credit_limit FROM customer WHERE id = 'C001' ... 3ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [业务] 张三 搬到 上海\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [业务] 张三 提额 +5000，当前额度 55000\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [JDBC] UPDATE customer SET name=?, city=?, credit_limit=? WHERE id = 'C001' ... 1ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [当前] 张三 | 上海 | 额度 55000\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>--- 场景二：换成内存实现，CustomerService 一行不改 ---\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [MEM] get(C002) ... 0ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [业务] 李四 搬到 深圳\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [业务] 李四 提额 +3000，当前额度 33000\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [MEM] put(C002) ... 0ms\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [当前] 李四 | 深圳 | 额度 33000\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>--- 场景三：单测纯业务规则，零 DAO 零 SQL ---\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    [规则] 拒绝：单次提额 99999 超过现有额度 40000 的 50%\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    亲手 new 一个对象就能测——存储压根不用出场。\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>三处对照读出含金量：场景一和 4.1 场景一是\u003Cstrong>同一个业务\u003C\u002Fstrong>，\u003Ccode>[SQL]\u003C\u002Fcode> 变成了 \u003Ccode>[JDBC]\u003C\u002Fcode>——SQL 没有消失（它总得有人写），只是从业务类搬进了 DAO；场景二\u003Cstrong>换一个实现类，CustomerService 一行没改\u003C\u002Fstrong>，\u003Ccode>[MEM]\u003C\u002Fcode> 与 \u003Ccode>[JDBC]\u003C\u002Fcode> 只是两种口音；场景三直击反面教材的病灶二——\u003Ccode>new Customer(\"C999\", ...)\u003C\u002Fcode> 亲手造一个对象就能测业务规则，\u003Cstrong>存储压根不用出场\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Ch3 id=\"toc-7\">4.3 三处细节\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>细节一：接口只说业务语言。\u003C\u002Fstrong> \u003Ccode>findById\u003C\u002Fcode> 返回的是 \u003Ccode>Customer\u003C\u002Fcode>，不是 ResultSet、不是 String[] 行数组——调用方拿到的直接是领域对象。判断一个 DAO 成不成功就看这条：\u003Cstrong>接口里只要出现存储类型（ResultSet、Connection、SQLException），抽象当场报废\u003C\u002Fstrong>，调用方等于隔着接口抱住了数据库。反过来，DAO 实现里爱怎么写 SQL 都行——细节该藏的藏得住，才是这堵墙存在的意义。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>细节二：DAO 要薄，只翻译不做业务。\u003C\u002Fstrong> 哪天忍不住在 JdbcCustomerDao 里写\"提额不能超 50%\"的 if 判断，就赶紧停手——那是领域对象的话，DAO 替它说了，规则就有了第二处住所，改规则时必忘一处。原则：\u003Cstrong>规则住在领域对象身上（谁经手都绕不过去），DAO 只管搬运\u003C\u002Fstrong>。DAO 越厚，\"换存储\"越名不副实——厚到一定程度的 DAO，就是把胖实体换了个名字。内存实现还有个小讲究：findById 返回防御性拷贝——不然调用方改了引用，\"库\"跟着变，save 形同虚设。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>细节三：谁来造 DAO。\u003C\u002Fstrong> 本文测试里手动 \u003Ccode>new JdbcCustomerDao()\u003C\u002Fcode> 传进去——手写装配只够演示。真实项目从简单工厂起步，终点是依赖注入：Spring 容器里 \u003Ccode>@Autowired\u003C\u002Fcode> 一个 Repository，哪个实现、单例还是多例、事务怎么包，全部容器代办。\u003Cstrong>构造注入是 DAO 模式的最后一块拼图\u003C\u002Fstrong>：没有它，\"换实现\"还是要改代码（改 new 的那一行），有了它，换实现只是换配置。\u003C\u002Fp>\n\u003Ch2 id=\"toc-8\">五、在框架里找它\u003C\u002Fh2>\n\u003Col>\n \u003Cli>\n  \u003Cp>\u003Cstrong>Spring Data JPA\u003C\u002Fstrong>：\u003Ccode>interface CustomerRepository extends JpaRepository&lt;Customer, String&gt;\u003C\u002Fcode>——接口你写，实现框架在运行时生成，连 SQL 都按方法名推出来。这是 DAO 思想的工业化极致：\u003Cstrong>实现都不用写了\u003C\u002Fstrong>，方法名 \u003Ccode>findByCity\u003C\u002Fcode> 就是接口语言，\u003Ccode>SELECT ... WHERE city = ?\u003C\u002Fcode> 由框架代劳。\u003C\u002Fp>\u003C\u002Fli>\n \u003Cli>\n  \u003Cp>\u003Cstrong>MyBatis 的 Mapper\u003C\u002Fstrong>：Mapper 接口 + XML 映射文件——DAO 接口\u002F实现分离的最显式形态：接口在 Java 里，SQL 在 XML 里，一一对应，中间没有魔法。本文 JdbcCustomerDao 里手写的翻译逻辑，MyBatis 全部声明式替你做了。\u003C\u002Fp>\u003C\u002Fli>\n \u003Cli>\n  \u003Cp>\u003Cstrong>Hibernate Session \u002F JPA EntityManager\u003C\u002Fstrong>：一个通用巨型 DAO——\u003Ccode>find\u003C\u002Fcode>、\u003Ccode>persist\u003C\u002Fcode>、\u003Ccode>merge\u003C\u002Fcode> 之外还管一级缓存、脏检查、延迟加载。功能早已超出本文的薄 DAO，但那堵墙的位置一模一样：业务代码只见接口，不见 SQL。\u003C\u002Fp>\u003C\u002Fli>\n \u003Cli>\n  \u003Cp>\u003Cstrong>Retrofit（网络源）\u003C\u002Fstrong>：\"数据源\"不一定是数据库。Retrofit 把 DAO 思想用在 HTTP API 上：接口声明方法，注解描述 URL 与参数，实现由动态代理生成——\u003Ccode>interface GithubService { @GET(\"users\u002F{u}\") Call&lt;User&gt; user(@Path(\"u\") String u); }\u003C\u002Fcode>，与 \u003Ccode>CustomerDao\u003C\u002Fcode> 一脉相承。Room（Android 数据库）同理。\u003Cstrong>DAO 立的墙，防的是\"一切会变的存取细节\"。\u003C\u002Fstrong>\u003C\u002Fp>\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2 id=\"toc-9\">六、DAO vs Repository——最容易混淆的近亲\u003C\u002Fh2>\n\u003Cp>面试和代码评审里最常见的问题：DAO 和 Repository 什么区别？摊开对账：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>\u003C\u002Fth>\n   \u003Cth>DAO\u003C\u002Fth>\n   \u003Cth>Repository\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>以什么为中心\u003C\u002Ftd>\n   \u003Ctd>**表**：一个 DAO 对应一张表\u002F一个实体\u003C\u002Ftd>\n   \u003Ctd>**聚合**：一个 Repository 对应一个聚合根\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>词汇出身\u003C\u002Ftd>\n   \u003Ctd>J2EE 蓝皮书，2000 年前后，面向\"怎么存\"\u003C\u002Ftd>\n   \u003Ctd>DDD（Evans，2003），面向领域\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>返回什么\u003C\u002Ftd>\n   \u003Ctd>单实体\u002F数据行，按表拆\u003C\u002Ftd>\n   \u003Ctd>完整聚合（主对象 + 依赖对象整组）\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>事务边界\u003C\u002Ftd>\n   \u003Ctd>单表为单位，跨表靠服务层串\u003C\u002Ftd>\n   \u003Ctd>以聚合为单位\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>一句话\u003C\u002Ftd>\n   \u003Ctd>存储的门卫\u003C\u002Ftd>\n   \u003Ctd>聚合的集合\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>串起 ㉘：Spring Data 官方说 Repository 应该定义在\u003Cstrong>聚合根\u003C\u002Fstrong>上，OrderItem 不配拥有自己的 Repository——那其实就是\"\u003Cstrong>组合实体 + DAO\u003C\u002Fstrong>\"的合体演进：㉘ 管\"整组进出\"，㉙ 管\"怎么存藏起来\"，DDD 聚合根仓库两件事一起管。现实里两者界限早已模糊：Spring Data 的接口名叫 Repository，干的多半还是 DAO 的活。名字不重要，边界重要——\u003Cstrong>接口后面藏的是什么，调用方不该知道\u003C\u002Fstrong>，这一条两边通用。\u003C\u002Fp>\n\u003Ch2 id=\"toc-10\">写在最后\u003C\u002Fh2>\n\u003Cul>\n \u003Cli>DAO = 存储边界上的隔离墙：接口说业务话，实现说存储话，中间谁也不越界\u003C\u002Fli>\n \u003Cli>疗效：领域对象纯净可单测；换存储换实现不换业务；SQL 集中一处不再满天飞\u003C\u002Fli>\n \u003Cli>铁律一：接口只说业务语言——返回 ResultSet 的 DAO 是失败的 DAO\u003C\u002Fli>\n \u003Cli>铁律二：DAO 要薄，只翻译不做业务——规则住在领域对象身上\u003C\u002Fli>\n \u003Cli>当代转世：Spring Data 接口生成实现、MyBatis Mapper、Retrofit——\"数据源\"早已不止数据库\u003C\u002Fli>\n \u003Cli>近亲对账：DAO 以表为中心，Repository 以聚合为中心——㉘ 管整组、㉙ 管藏细节，合体就是 DDD 聚合根仓库\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>DAO 把\"怎么存\"收走了，业务层终于干净。可 Web 应用的麻烦不止在存储——请求进门的第一个问题是：\u003Cstrong>谁接的活？\u003C\u002Fstrong> Servlet 时代每个 Servlet 各自为政：登录校验复制粘贴二十份、字符编码各设各的、想统一加个访问日志都无处下手，更别提\"统一分发到控制器\"这种高级货。谁把\"入口\"统一起来？——前端控制器模式，下一篇见。\u003C\u002Fp>",[113,116,119,122,125,129,132,135,138,141],{"id":114,"text":115,"level":108},"toc-1","一、数据访问对象模式是什么",{"id":117,"text":118,"level":108},"toc-2","二、优点与缺点",{"id":120,"text":121,"level":108},"toc-3","三、什么时候用",{"id":123,"text":124,"level":108},"toc-4","四、实现方式：搬家 + 提额",{"id":126,"text":127,"level":128},"toc-5","4.1 反面教材：什么都懂的胖实体",3,{"id":130,"text":131,"level":128},"toc-6","4.2 正解：DAO 三件套",{"id":133,"text":134,"level":128},"toc-7","4.3 三处细节",{"id":136,"text":137,"level":108},"toc-8","五、在框架里找它",{"id":139,"text":140,"level":108},"toc-9","六、DAO vs Repository——最容易混淆的近亲",{"id":142,"text":143,"level":108},"toc-10","写在最后"]