设计模式预计阅读 19 分钟7543 字3270 次浏览

设计模式修炼之路 ㉘ 组合实体模式——一窝小对象,一次网络往返

一窝小对象一次网络往返、读放大与 N+1、一次提交就是事务边界、依赖对象没有远程身份、JPA 聚合根转世

目录

J2EE 模式第三站。

㉗ 的订单实体只有三个字段,一次远程调用就传输完毕——这是实体世界的"好养型"。可真实的领域模型里,"客户"这种实体由地址、信用、联系方式、订单历史一堆小对象组成;远程场景下,每个小对象都可能是独立的网络往返。

EJB 时代这句话是字面意义的:entity bean 本身就是远程对象,对象图原样铺到远程,调用图就跟着变成远程调用图——看一个客户,四次网络往返起步;搬个家再提个额,两次独立提交,第二次失败,数据库里一半新一半旧。

把一窝依赖对象捆成一个粗粒度实体,统一加载、统一提交、统一管理生命周期——组合实体模式,J2EE 八式里的打包员。

一、组合实体模式是什么

J2EE 蓝皮书的定义大意:组合实体(Composite Entity)是一个粗粒度实体,它管理一组细粒度依赖对象(dependent objects)的生命周期——加载、修改、提交,全部以它为边界;远程调用方只见它这一个入口,永远不直接接触依赖对象。

三个角色:

角色 干什么 本文对应
Composite Entity(组合实体) 粗粒度入口:一次加载、一次提交整组依赖对象 CustomerEntity
Dependent Object(依赖对象) 细粒度 POJO,躲在实体身后,没有远程身份 Address / Credit / Contact
Coarse Grained Object(粗粒度对象) 经典分层里实际持有依赖对象的一层;单组依赖时与实体合并 本文并入 CustomerEntity

一句话理解分工:依赖对象说业务话("提额不能超现有额度一半"),组合实体说边界话("要进来整窝一起进,要出去整窝一起出"),远程世界只见这一个门。

注意它同样不是 GoF 二十三式,是 J2EE 蓝皮书成员——诞生动机很具体:细粒度 entity bean 在分布式环境里是性能灾难,蓝皮书用"粗粒度"给实体世界立了新规矩。

二、优点与缺点

优点:

  1. 网络往返从 N+1 到 1:反面教材里看一个客户四次往返,组合实体一次——读放大直接消失。
  2. 写有事务边界:整组要么全落库、要么全不动;"搬家成功提额失败"的一半新一半旧不可能出现。
  3. 客户端不再懂内部构成:客户由哪几块组成、先加载谁后加载谁,是实体自己的家事——封装回到了实体世界。
  4. 依赖对象可以自由演化:明天给客户加"会员等级",只动实体内部,远程调用方一行不改。

缺点:

  1. 一次加载可能过重:用不到的字段也跟着来——懒加载能缓解,但懒加载会重新引入网络往返(第五节 Hibernate N+1 见分晓),尺度得自己拿捏。
  2. 并发冲突面变大:两个人同时改同一个客户的不同部分,在整组边界上排队等锁——捆得越粗,锁面越大。
  3. 常需配套传输对象:组合实体住在服务器上,客户端最终要的是可序列化的数据快照——这对黄金搭档 ㉝ 再展开。

三、什么时候用

判断标准:远程边界 + 领域对象是一张图(主对象带一群小对象)+ 这群小对象常被整体使用——三条都占,就值得捆。

  • 远程实体访问:EJB、RMI——本模式出生场景
  • 一次业务操作天然跨多个小对象:搬家 + 提额、改账单地址 + 改通知偏好——分散提交没有事务语义
  • 跨服务按聚合取数:微服务间想要"完整的客户",与其开一堆 GET 端点,不如一个聚合接口

反面对照:本地进程内——直接用 ⑨ 组合模式的对象图就好,粗粒度是替远程边界付的成本,本地享受不到收益只剩缺点;对象图巨大且访问模式差异大——客户 + 全部订单历史(几万条),整体加载是灾难,该拆成两个聚合、按需另取。

四、实现方式:搬家 + 提额

场景:客户"张三"由名字、地址、信用、联系方式组成。业务操作:搬家 + 提额——一次操作动两个小对象,正是组合实体的主场。

4.1 反面教材:细粒度远程访问

先看依赖对象——纯 POJO,业务规则住自己身上(这一半是对的,问题出在访问方式):

public class Credit {

    private final int score;
    private int creditLimit;

    public void raiseLimit(int delta) {
        if (delta <= 0) {
            throw new IllegalArgumentException("提额幅度必须为正数");
        }
        if (delta > creditLimit / 2) {
            throw new IllegalArgumentException("单次提额不能超过现有额度的 50%");
        }
        creditLimit += delta;
    }
    // format/parse:模拟数据库一列的存取,略
}

模拟远程持久层:业务层在另一台服务器上,每个方法调用 = 一次网络往返。它提供两套接口——细粒度(每个小对象单独读写)和整行(一次读写整组):

public class CustomerDataStore {

    // ---------- 细粒度接口:一个小对象 = 一次网络往返 ----------

    public String loadName(String id) {
        roundTrip("SELECT name FROM customer WHERE id='" + id + "'");
        return row(id)[0];
    }

    public Address loadAddress(String id) { /* 同款,略 */ }
    public Credit loadCredit(String id) { /* 同款,略 */ }
    public Contact loadContact(String id) { /* 同款,略 */ }

    public void saveAddress(String id, Address address) {
        roundTrip("UPDATE customer SET address=? WHERE id='" + id + "'");
        maybeFail();                     // 模拟网络抖动:写可能失败
        row(id)[1] = address.format();
    }

    public void saveCredit(String id, Credit credit) { /* 同款,略 */ }

    private void roundTrip(String sql) {
        System.out.println("      [网络往返] " + sql + " ... 220ms");
    }
}

再看表示层门户代码的日常——错不在门户程序员,他拿到的接口长这样,只能这么写:

public class BadPortal {

    private final CustomerDataStore store;

    public void showCustomer(String id) {
        String name = store.loadName(id);
        Address address = store.loadAddress(id);
        Credit credit = store.loadCredit(id);
        Contact contact = store.loadContact(id);
        System.out.println("      " + name + " | " + address + " | 信用分 " + credit.getScore()
                + " 额度 " + credit.getCreditLimit() + "元 | " + contact.getPhone());
    }

    /** 一次业务操作 = 搬家 + 提额。细粒度接口下被拆成三次远程调用、两次独立提交 */
    public void moveAndRaise(String id, String street, String city, String zip, int delta) {
        store.saveAddress(id, new Address(street, city, zip));   // 远程写 1
        Credit credit = store.loadCredit(id);                    // 远程读
        credit.raiseLimit(delta);
        store.saveCredit(id, credit);                            // 远程写 2:失败就惨了
    }
}

运行输出:

========== 反面教材:细粒度远程访问 ==========
-- 场景 1:查看一个客户 --
      [网络往返] SELECT name FROM customer WHERE id='C001' ... 220ms
      [网络往返] SELECT address FROM customer WHERE id='C001' ... 220ms
      [网络往返] SELECT credit FROM customer WHERE id='C001' ... 220ms
      [网络往返] SELECT contact FROM customer WHERE id='C001' ... 220ms
      张三 | 人民路 1 号(杭州) | 信用分 650 额度 50000元 | 13800000001
   小结:看 1 个客户 = 4 次网络往返(读放大)

-- 场景 2:搬家 + 提额(一次业务操作)--
      [网络往返] UPDATE customer SET address=? WHERE id='C001' ... 220ms
      [网络往返] SELECT credit FROM customer WHERE id='C001' ... 220ms
      [网络往返] UPDATE customer SET credit=? WHERE id='C001' ... 220ms
   [门户] 第二次远程写失败:网络抖动:提交超时

-- 事后核查(又是 4 次往返)--
      [网络往返] SELECT name FROM customer WHERE id='C001' ... 220ms
      [网络往返] SELECT address FROM customer WHERE id='C001' ... 220ms
      [网络往返] SELECT credit FROM customer WHERE id='C001' ... 220ms
      [网络往返] SELECT contact FROM customer WHERE id='C001' ... 220ms
      张三 | 西湖大道 88 号(杭州) | 信用分 650 额度 50000元 | 13800000001
   小结:地址是新的、额度是旧的——一半新一半旧,没有事务边界

三个病灶全齐:读放大(看 1 个客户 4 次往返,EJB 时代 220ms 一次,近一秒耗在网络上);写不一致(搬家成功、提额失败,数据库里一半新一半旧,还没法重试——重试等于再搬一次家);拼装逻辑散落("客户由哪几块组成"这个知识被逼着写在每个调用方里)。

4.2 正解:组合实体

CustomerEntity:一个粗粒度对象管住整组依赖对象——加载时组装、修改时在内存、提交时打包:

public class CustomerEntity {

    private final String id;
    private final CustomerDataStore store;

    private String name;
    private Address address;   // 依赖对象 1
    private Credit credit;     // 依赖对象 2
    private Contact contact;   // 依赖对象 3

    /** 一次网络往返,整组依赖对象全部就位 */
    public void load() {
        String[] row = store.loadRow(id);
        name = row[0];
        address = Address.parse(row[1]);
        credit = Credit.parse(row[2]);
        contact = Contact.parse(row[3]);
    }

    /** 一次网络往返,整组原子提交——要么全落库,要么全不动 */
    public void commit() {
        store.saveRow(id, new String[]{name, address.format(), credit.format(), contact.format()});
    }

    public void move(String street, String city, String zip) {
        address = new Address(street, city, zip);
    }

    public void raiseCreditLimit(int delta) {
        credit.raiseLimit(delta);
    }

    public String describe() { /* 一行拼好整组,略 */ }
}

调用方的画风,只见一个门:

CustomerEntity customer = new CustomerEntity("C001", store);

customer.load();                                  // 1 次往返,整组就位
customer.move("西湖大道 88 号", "杭州", "310000");  // 改内存,不碰网络
customer.raiseCreditLimit(5000);                  // 改内存,不碰网络
customer.commit();                                // 1 次往返,整组提交

运行输出(故意让第一次提交失败):

========== 正解:组合实体 ==========
-- 加载客户 C001 --
      [网络往返] SELECT * FROM customer WHERE id='C001' ... 220ms
   客户视图:张三 | 人民路 1 号(杭州) | 信用分 650 额度 50000元 | 13800000001
   小结:1 次网络往返,整组依赖对象就位

-- 业务操作:搬家 + 提额 --
      [网络往返] UPDATE customer SET name=?,address=?,credit=?,contact=? WHERE id='C001' ... 220ms
   [组合实体] 提交失败:网络抖动:提交超时——整组未落库,重试一次
      [网络往返] UPDATE customer SET name=?,address=?,credit=?,contact=? WHERE id='C001' ... 220ms
   提交后:张三 | 西湖大道 88 号(杭州) | 信用分 650 额度 55000元 | 13800000001
   小结:一次加载、一次提交,业务操作有了事务边界

三处对照读出含金量:加载一次往返整组就位(反面教材四次);搬家和提额在内存里完成,全程零网络;提交失败后整组原封不动,重试提交的是同一个完整操作——反面教材那个"一半新一半旧"的僵局,在这里结构上就不可能发生。

4.3 三处细节

细节一:依赖对象没有远程身份。 回头数数:Address、Credit、Contact 从头到尾没有被远程直接访问过——远程世界只见 CustomerEntity 一个门。这是组合实体的铁律:依赖对象的可见性止步于实体边界。业务规则(提额上限 50%)住在依赖对象身上,不管谁经手,规则绕不过去。

细节二:一次提交就是事务边界。 反面教材的病灶二,根治靠的不是"加重试"而是"改提交单位":细粒度提交时重试等于重复副作用(再搬一次家);整组提交时重试是安全重放(提交失败 = 什么都没发生,重放同一个完整提交——真实世界还要配幂等键,防"超时但其实成功"的悬案)。经典 EJB 里容器管理的事务(CMT)直接以实体边界为事务边界——实体边界 = 事务边界 = 锁边界,一体三面,这也是缺点二"锁面变大"的来源。

细节三:经典结构还能再分一层,加载策略还有两派。 蓝皮书完整版是 CompositeEntity → CoarseGrainedObject → DependentObject 三层:实体背后还有一个"粗粒度对象"实际持有依赖对象。实践里一个实体只带一组依赖对象时,实体自己就是粗粒度对象(本文做法),带多组时才值得分层。加载策略则分饿汉/懒汉两派:本文 load() 是饿汉——一次全来,最省往返;懒汉是首次访问某个依赖对象才加载它——省流量但重新引入网络往返,第五节会看到它在 ORM 里的著名翻车现场。

五、在框架里找它

  1. JPA / Hibernate 的实体关联:@OneToMany、@ElementCollection 把对象图映射成表关系;fetch = EAGER 就是饿汉组合实体,LAZY 就是懒汉。而 Hibernate 著名的 N+1 查询问题——懒加载一对多,遍历时每行触发一次查询——正是反面教材的读放大在 ORM 里的转世。解药 JOIN FETCH、@EntityGraph,本质都是把数据访问掰回"一次往返、整组就位"。

  2. Spring Data 的聚合根仓库:官方文档明说 Repository 应该定义在聚合根上,OrderItem 这样的从属对象不配拥有自己的 Repository——外部想改订单项?先拿订单聚合,再从聚合内部改。这就是 DDD 聚合根思想:一个聚合一个根,根管整组,事务一致性以聚合为单位——组合实体模式的学院派后代,连"依赖对象不对外"这条铁律都原样继承。

  3. MyBatis 的聚合映射:resultMap 里的 <association>、<collection>,一次 JOIN 把客户、地址、联系方式映射成完整对象图——"一次查询组装整组"在 SQL 映射层的实现,连本文 format/parse 手工拼装的动作都替你自动化了。

  4. GraphQL:读侧同类问题的当代解——REST 时代看一个页面开 N 个端点,GraphQL 一次请求声明整棵需要的树。注意方向相反:组合实体是服务器定义打包粒度,GraphQL 是客户端声明要什么——但"别让我为一张图跑 N 趟"的诉求,三十年没变过。

六、组合实体 vs 组合模式 vs 传输对象——"捆东西"三兄弟对账

组合实体和 ⑨ 组合模式都姓"组合",和 ㉝ 传输对象都干"打包"——摊开对账:

组合模式(⑨) 传输对象(㉝) 组合实体(㉘)
捆的是什么 **结构**:部分与整体同一接口 **数据**:一次网络搬一坨字段 **生命周期**:加载、提交以组为单位
结构形态 树形,可递归嵌套 扁平快照,通常只读 一主多仆,主对象是唯一入口
捆完给谁看 调用方(统一遍历) 网络对面的客户端 远程调用方只见主对象
出身 GoF 1994 J2EE 蓝皮书 J2EE 蓝皮书
一句话 树里树外一个样 一次打包一份快照 要进整窝进,要出整窝出

⑨ 和 ㉘ 名字最容易混:⑨ 捆的是结构(对外统一接口),㉘ 捆的是生命周期(对内统一管理)——一个组合实体内部用什么结构组织依赖对象,随便,⑨ 甚至可以当实现细节用上。㉝ 和 ㉘ 则是黄金搭档:组合实体住在服务器上管生命周期,传输对象负责把数据快照递到客户端手里——本篇 describe() 已经隐约有它的影子,㉝ 正式展开。

写在最后

  • 组合实体 = 远程边界上的粗粒度对象:一窝依赖对象,一次加载、一次提交
  • 疗效:读放大(N+1)变一次往返;写散沙变事务边界;客户端不再被迫懂内部构成
  • 代价:加载变重、锁面变大——这笔钱只为远程边界付,本地进程别硬套
  • 铁律:依赖对象没有远程身份,业务规则住在依赖对象身上
  • 当代转世:JPA 聚合映射、Spring Data 按聚合根建仓、DDD 聚合根——N+1 问题在 ORM 时代等你
  • 三兄弟对账:⑨ 捆结构、㉝ 捆数据、㉘ 捆生命周期——名字像,账要分清

组合实体把"一次加载、一次提交"管得漂漂亮亮——可回头看看 CustomerEntity:load() 里拆字符串、commit() 里拼字符串,SELECT、UPDATE 的痕迹全漏进实体里了。业务对象不该懂数据库,这坨"怎么存"的细节谁来收走?——数据访问对象模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论