设计模式预计阅读 21 分钟8059 字2516 次浏览

设计模式修炼之路 ㉝ 传输对象模式——一趟网络,带走整份数据

J2EE 模式第八站,也是 J2EE 八式的收官一站。

目录

J2EE 模式第八站,也是 J2EE 八式的收官一站。

㉜ 结尾留了个问号:服务到手之后,数据怎么回去?顺着网络递对象,总不能把实体整个扔过去——它身上挂着行为、挂着一整窝依赖对象。㉘ 也早埋了话:"本篇 describe() 已经隐约有它的影子,㉝ 正式展开。"

答案本篇给:客户端要什么,服务器拷一份打包——只有字段、没有行为、可序列化、拎起来就能走的数据快照。传输对象模式,J2EE 八式的收官一站。

一、传输对象模式是什么

J2EE 蓝皮书的定义大意:传输对象(Transfer Object,业界更常叫 DTO——Data Transfer Object)是一个可序列化的纯数据对象;业务对象按客户端的需要,把实体字段拷进快照,一次远程调用整份送达;客户端改动之后,同样打包一次送回——跨越网络传数据,以"份"为单位,不以"字段"为单位。

三个角色:

角色 干什么 本文对应
Transfer Object(传输对象) 纯数据快照:只有字段和读写方法,可序列化 ClientProfileDto
Business Object(业务对象) 住在服务器:管实体、打包快照、接收写回 ClientService(背后是 ClientEntity)
Client(客户端) 拿快照直接渲染;要行为,另走业务接口 ProfileScreen

一句话理解分工:业务对象说"字段拷进快照,一次拿走",传输对象说"我只带数据不带行为",客户端说"渲染找 DTO,升 VIP 找业务接口"。

它不是 GoF 二十三式,是 J2EE 蓝皮书成员——和 ㉘ 组合实体是黄金搭档:㉘ 管服务器这头的生命周期,㉝ 管数据怎么过网络。第六节把这对搭档的账摊开。

二、优点与缺点

优点:

  1. N 次往返收成 1 次:细粒度接口一个字段一趟网络,渲染资料页 5 趟、1000ms;DTO 一趟全带到。网络延迟按次收费,字段越多省得越狠。
  2. 实体不出门:升级要校验积分这条规则、points 这个中间态、实体的内部结构——全都留在服务器。漏出去的不只是字段,是可以被绕过的规则。
  3. 客户端拿到自包含快照:断网也能渲染,本地就能编辑,改完一次送回——页面不必每点击一下就过一次网络。
  4. 接口对字段变化免疫:DTO 加一个字段,方法签名不动,老客户端照跑;细粒度接口加一个字段就得加一个方法——接口数量跟着字段数量涨。

缺点:

  1. 两套类,一份字段:实体一份、DTO 一份,getProfile 里六个 set 一个不能漏,字段一改两边动——拷贝代码是这个模式的入场费(第五节看工业界怎么把这钱省下来)。
  2. 快照会过期:拿到 DTO 那一刻起它就是过去时——别人改了实体,你手里还是旧数据;两个人同时改,后写的覆盖先写的。版本号、乐观锁,都是给这个缺点打的补丁。
  3. 写回边界靠约定:DTO 上明明有 setVipLevel,updateProfile 却只写四个字段——哪些字段能动、哪些是业务专属,编译器不管,全靠服务器自觉。粗粒度是把双刃剑。

三、什么时候用

判断标准:数据要过边界(网络/进程/层间)+ 一次要多个字段 + 调用按次收费——三条都占,DTO 就值回票价。

  • 典型场景:远程调用返回复合数据——EJB 早期把 Entity Bean 直接远程暴露,每个 getter 一趟网络,正是反面教材的历史原型
  • 日常场景:前后端接口里的 Request/Response 对象、分层架构里 DO→DTO→VO 的转换——你天天在写传输对象,只是没叫它这个大名
  • 反面对照:同进程内调用——对象引用直接传就行,硬套一层 DTO 是脱裤子放屁。"每个实体必配一个 DTO"是过度设计的重灾区:先问数据过不过边界,再问要不要快照

四、实现方式:快照怎么打包

场景:客户资料页。服务器上住着 ClientEntity——六个资料字段加一个 points,外加一条业务规则:

/** 服务器上的客户实体:字段 + 业务行为。业务规则不该顺着网络漏给客户端 */
public class ClientEntity {

    private final String id;
    private String name;
    private String phone;
    private String city;
    private String street;
    private int vipLevel;
    private int points;

    /** 业务行为:升级要校验积分——这条规则只能住在服务器上 */
    public void upgradeVip() {
        points += 30;
        if (points >= 130) {
            vipLevel++;
            System.out.println("   [实体] 积分 " + points + " 达标,VIP 升到 " + vipLevel);
        } else {
            System.out.println("   [实体] 积分 " + points + " 未达标,VIP 保持 " + vipLevel);
        }
    }
    // getter/setter 略
}

每次网络往返 200ms。先看反面教材。

4.1 反面教材:细粒度远程接口

/** 反面教材:一个字段一个方法,每次调用一趟网络 */
public class RemoteClientService {

    private final Map<String, ClientEntity> store = new HashMap<>();
    private int calls = 0;

    public String getName(String id) {
        roundTrip("getName(" + id + ")");
        return store.get(id).getName();
    }

    public String getPhone(String id) {
        roundTrip("getPhone(" + id + ")");
        return store.get(id).getPhone();
    }

    // getCity、getStreet、getVipLevel、updatePhone 同款,一个字段一个方法……

    private void roundTrip(String what) {
        calls++;
        System.out.println("   [网络] 第 " + calls + " 次往返:" + what + "……(耗时 200ms)");
    }
}

客户端渲染一个页面,跟服务器要五次,UI 代码兼职拼数据:

public class BadProfileScreen {

    public void render(String id) {
        String name = remote.getName(id);
        String phone = remote.getPhone(id);
        String city = remote.getCity(id);
        String street = remote.getStreet(id);
        int vipLevel = remote.getVipLevel(id);
        System.out.println("   [资料页] 姓名=" + name + " 电话=" + phone + " 城市=" + city + " 街道=" + street + " VIP=" + vipLevel);
    }

    public void changePhone(String id, String newPhone) {
        remote.updatePhone(id, newPhone);
    }
}

运行输出:

========== 反面教材:细粒度远程接口,一个字段一趟网络 ==========
-- 渲染客户资料页(u001) --
   [网络] 第 1 次往返:getName(u001)……(耗时 200ms)
   [网络] 第 2 次往返:getPhone(u001)……(耗时 200ms)
   [网络] 第 3 次往返:getCity(u001)……(耗时 200ms)
   [网络] 第 4 次往返:getStreet(u001)……(耗时 200ms)
   [网络] 第 5 次往返:getVipLevel(u001)……(耗时 200ms)
   [资料页] 姓名=张三 电话=138-0000-0001 城市=北京 街道=长安街 1 号 VIP=3
   一个页面 5 次网络往返、1000ms——UI 代码还兼职拼数据

-- 客户改电话(u001) --
   [网络] 第 6 次往返:updatePhone(u001, 139-0000-0002)……(耗时 200ms)
   改一个字段又是一趟网络——字段一多,网络跑成筛子

病灶:网络被当成了本地内存总线。getName() 在本地不过一次内存访问,一个字段一个方法顺理成章;同样的接口搬到网络上,一次调用就是 200ms。更糟的是 UI 兼职拼数据:实体每加一个字段,客户端就多一趟网络、多一行拼装。注意:功能一切正常,资料页照样渲染、电话照样改——反面教材从来不是功能错误,是结构错误。

4.2 正解:传输对象

DTO——纯数据快照,可序列化,没有行为:

public class ClientProfileDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private String id;
    private String name;
    private String phone;
    private String city;
    private String street;
    private int vipLevel;

    // getter/setter 全是普通读写,略

    /** 把字段拼成人话——㉘ describe() 的影子,在这里长成正式身份 */
    public String display() {
        return "姓名=" + name + " 电话=" + phone + " 城市=" + city + " 街道=" + street + " VIP=" + vipLevel;
    }
}

注意:实体七个字段,快照里只有六个——points 没上车,细节二展开。

业务对象——读打包、写解包、行为单独开门:

/** 正解:粗粒度接口 + DTO。读一次打包全部字段;写一次打包送回;行为单独一条路 */
public class ClientService {

    private final Map<String, ClientEntity> store = new HashMap<>();
    private int calls = 0;

    public ClientProfileDto getProfile(String id) {
        roundTrip("getProfile(" + id + ")");
        ClientEntity entity = store.get(id);
        System.out.println("   [服务器] 从实体拷贝字段,打包成 ClientProfileDto");
        ClientProfileDto dto = new ClientProfileDto();
        dto.setId(entity.getId());
        dto.setName(entity.getName());
        dto.setPhone(entity.getPhone());
        dto.setCity(entity.getCity());
        dto.setStreet(entity.getStreet());
        dto.setVipLevel(entity.getVipLevel());
        return dto;
    }

    public void updateProfile(ClientProfileDto dto) {
        roundTrip("updateProfile(dto)");
        ClientEntity entity = store.get(dto.getId());
        entity.setName(dto.getName());
        entity.setPhone(dto.getPhone());
        entity.setCity(dto.getCity());
        entity.setStreet(dto.getStreet());
        System.out.println("   [服务器] 写回实体:name/phone/city/street(VIP 是业务字段,只有业务方法能动)");
    }

    public void upgradeVip(String id) {
        roundTrip("upgradeVip(" + id + ")");
        store.get(id).upgradeVip();
    }

    private void roundTrip(String what) {
        calls++;
        System.out.println("   [网络] 第 " + calls + " 次往返:" + what + "……(耗时 200ms)");
    }
}

客户端——一次拿全,本地编辑,一次送回:

/** 正解客户端:一次 getProfile 拿全,本地改 DTO,一次 updateProfile 送回 */
public class ProfileScreen {

    private final ClientService service;
    private ClientProfileDto current;

    public void render(String id) {
        current = service.getProfile(id);
        System.out.println("   [资料页] " + current.display());
    }

    public void changePhone(String newPhone) {
        current.setPhone(newPhone);
        System.out.println("   [资料页] 本地改 DTO:setPhone(" + newPhone + ")");
        service.updateProfile(current);
    }

    public void upgradeVip(String id) {
        System.out.println("   [资料页] DTO 上没有 upgradeVip()——业务行为不跟着数据出门");
        service.upgradeVip(id);
    }
}

运行输出:

========== 正解:传输对象,一来一回两趟网络 ==========
-- 渲染客户资料页(u001) --
   [网络] 第 1 次往返:getProfile(u001)……(耗时 200ms)
   [服务器] 从实体拷贝字段,打包成 ClientProfileDto
   [资料页] 姓名=张三 电话=138-0000-0001 城市=北京 街道=长安街 1 号 VIP=3
   同一页 1 次往返——DTO 把整份快照一次送到

-- 客户改电话(u001) --
   [资料页] 本地改 DTO:setPhone(139-0000-0002)
   [网络] 第 2 次往返:updateProfile(dto)……(耗时 200ms)
   [服务器] 写回实体:name/phone/city/street(VIP 是业务字段,只有业务方法能动)
   改动打包一次送回——一来一回,网络只跑两趟

-- 客户要升 VIP(u001) --
   [资料页] DTO 上没有 upgradeVip()——业务行为不跟着数据出门
   [网络] 第 3 次往返:upgradeVip(u001)……(耗时 200ms)
   [实体] 积分 130 达标,VIP 升到 4
   数据走 DTO 的路,行为走业务接口的路——两条路不混

三处对照读出含金量:同一页资料,反面 5 趟 1000ms,正解 1 趟 200ms——往返次数与字段数脱钩,字段再涨网络不涨;改电话反面是"UI 喊服务器改一个字段",正解是本地改快照、一次打包送回——服务器一共只被打扰两次;升 VIP 最有意思——DTO 上根本没有 upgradeVip(),客户端想触发业务只能走业务接口那条路,积分规则从头到尾没离开过服务器。

4.3 三处细节

细节一:DTO 是快照,不是实体的引用。 getProfile 返回的 ClientProfileDto 是 new 出来的新对象,字段从实体逐个拷贝——此后实体怎么变,快照不知道;快照怎么改,实体也不知道,直到 updateProfile 写回那一刻。本地调用里"两边各一份"是浪费,跨网络是必须——引用过不了网络,能过的只有序列化的字节流。

细节二:字段要挑着带,不是照抄实体。 points 没进快照——它是业务中间态,客户端看到没意义;vipLevel 进了,但写回时不碰它——升级必须走 upgradeVip() 过积分校验。哪些字段出门、出门的字段哪些能写回,是设计决策而不是体力活。把实体字段一比一塞进 DTO,等于给客户端递了一份实体内部说明书。

细节三:贫血是刻意的,别治。 DTO 只有字段没有行为,教科书管这叫"贫血对象"还捎带批评——那说的是领域模型,不是 DTO。传输对象的职责就是运数据:行为要是跟着数据出了门,积分校验就管不住客户端了。数据走一条路,行为走另一条路,正是这个模式的灵魂。

五、在框架里找它

  1. Java Record:record ClientProfile(String id, String name, String phone) {}——一行定义不可变快照,构造、读访问、equals、hashCode 全部免写。JDK 14 亲手给 DTO 造的官配语法,"两套类一份字段"的样板代码从此少一半。

  2. MapStruct:ClientProfileDto toDto(ClientEntity entity) 一个接口声明,字段拷贝在编译期生成实现——getProfile 里那六个 set,工业界早交给代码生成器了。同类的 Spring BeanUtils.copyProperties 走运行期反射,慢一点,但零声明。

  3. 前后端的 Request/Response:每个 REST 接口的 body 都是一份序列化快照;分层架构的 DO→DTO→VO 流水线,是把快照按读者细分——数据库里的 DO、给别处服务的 DTO、给前端渲染的 VO,同一份数据三种打包。

  4. GraphQL:打包粒度的进化版——REST 是服务器定死一份快照,GraphQL 让客户端声明要哪几个字段,一次请求取回一棵自定义的树。和 ㉘ 里说的方向一致:从"服务器说给你什么"走向"客户端说要什么"。

  5. 名字的公案:这个模式最初叫 Value Object——J2EE 蓝皮书早期版本和 Fowler 的 PoEAA 都用过这个名字;后来为了不和"不可变值语义"的 Value Object 混淆,蓝皮书改名 Transfer Object,Fowler 也专门撰文承认命名失当、改称 Data Transfer Object。今天满地的 VO/DTO 之争,源头都在这段公案。

六、对账——㉝ 传输对象 vs ㉘ 组合实体:黄金搭档摊账

㉘ 结尾埋的话:"㉝ 和 ㉘ 则是黄金搭档:组合实体住在服务器上管生命周期,传输对象负责把数据快照递到客户端手里。"搭档摊账:

传输对象(㉝) 组合实体(㉘)
住在哪 网络两头跑,序列化上路 服务器上不动窝
管什么 数据怎么过网络 生命周期怎么成组(一次加载、一次提交)
身上有什么 只有字段——贫血是刻意 字段 + 行为 + 一窝依赖对象
客户端碰什么 快照(本地可改,打包送回) 只碰组合实体的门(门后整窝进出)
典型同框 EJB 标准答案:客户端 ←DTO→ Session Bean(业务对象)→ 组合实体 → 数据库

一句话:㉘ 管服务器这头的"进整窝、出整窝",㉝ 管网络上的"一去一回各一趟"——一个在源头捆生命周期,一个在路上捆数据。㉘ 那篇还留过一张三兄弟对账表(⑨ 捆结构、㉝ 捆数据、㉘ 捆生命周期),本篇补上最后一块拼图。再拉 ㉗ ㉜ 入伙算个总账:㉜ 管"服务从哪儿来",㉗ 管"远程怎么说得像本地",㉝ 管"数据怎么打包回去"——找服务的路、通话的路、运数据的路,三路齐全,J2EE 八式就此收官。

写在最后

  • 传输对象 = 跨边界的数据快照:只有字段、可序列化,一次打包整份送达
  • 疗效:往返次数与字段数脱钩(5 趟变 1 趟);实体不出门,业务规则不出门;客户端手里自包含
  • 代价:两套类一份字段、快照会过期、写回边界靠约定——版本号与乐观锁是标配补丁
  • 铁律:数据走 DTO,行为走业务接口——两条路不混,贫血是刻意
  • 黄金搭档:㉘ 在源头捆生命周期,㉝ 在路上捆数据,⑨ 捆结构——三兄弟的账,两篇合清
  • 当代转世:Record 一行定义、MapStruct 编译期拷贝、每个 JSON Response——你天天在写它,只是没叫它这个大名

J2EE 八式至此收官。回头望,从 ② 单例一路走到 ㉝,三十二个模式各治一种痛;可每次诊断病灶、开出药方,反复露面的都是几张熟面孔——单一职责、开闭、依赖倒置……它们从没单独出场,却藏在每一篇的病理与药理里。该算总账了——六大原则再回首,全系列最后一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论