设计模式修炼之路 ㉗ 业务代表模式——替表示层挡住远程的脏活
替表示层挡住远程脏活、查找+重试+异常翻译一肩挑、重试风暴、外观/代理/业务代表三兄弟对账、Feign 转世
目录
J2EE 模式第二站。
㉖ 的 Controller 直接持有 Catalog、直接调用,丝滑顺畅——因为它们在同一个进程里,一行代码就是一次方法调用的全部成本。可 EJB 时代的真实画风是:业务层住在另一台服务器上。表示层每次调用业务,都要先 JNDI 查找服务、再建网络连接、调完还得翻译远程异常、失败了还得琢磨要不要重试……Controller 迟早被这些"远程破事"淹没,沦为一个兼职网管。
在表示层和业务层之间修一条缓冲带:查找、重试、异常翻译这些与业务无关的机械细节,全部由它一个人消化——业务代表模式,J2EE 八式里的清道夫。
一、业务代表模式是什么
J2EE 蓝皮书里的定义大意:业务代表(Business Delegate)是表示层的一个对象,它替表示层去查找、访问业务服务,并处理服务定位、远程调用、异常翻译等底层细节,让表示层只跟业务语义打交道。
三个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| BusinessDelegate(业务代表) | 表示层的业务代理:吸收查找/重试/异常翻译 | OrderDelegate |
| BusinessLookup(业务查找) | 负责定位业务服务(JNDI 的封装) | ServiceLookup |
| BusinessService(业务服务) | 业务层的接口,可能本地可能远程 | OrderService / RemoteOrderService |
一句话理解分工:表示层说业务话("下一单"),业务代表说技术话("查服务、建连接、抖了就重试、远程异常翻成本地异常"),业务层只管业务。
注意它不是 GoF 二十三式之一,是 Sun 的 J2EE 模式目录成员——那个年代"业务逻辑住在远程容器里"是默认设定,这个模式就是为远程边界而生的。
二、优点与缺点
优点:
- 表示层干净:Controller 里只剩业务调用,没有一行 JNDI、没有一坨重试循环——回看 ㉖,MVC 的职责分离才真正完整。
- 机械细节集中一处:重试几次、异常怎么翻译,改一处全体生效——反面教材里这些代码散在每个 Controller 方法里,改一次全项目搜索。
- 异常翻译:RemoteException、ConnectException 这些技术异常被翻译成业务异常,表示层不用 import 一堆远程包——远程性被封装掉了。
- 服务解耦:业务层从远程 EJB 迁回本地 Bean、或换成 HTTP 服务,只改业务代表内部,表示层无感。
缺点:
- 多一层皮:每个业务服务通常配一个代表类,接口容易沦为业务服务的镜像——为分层而分层。
- 单点偏差:重试、超时策略集中在代表身上,配错了影响所有入口(比如重试放大了下游压力——重试风暴)。
- 滞后性陷阱:代表封装的是"当时的业务接口",业务接口演化时代表要跟着改,慢一步就是两层不同步。
三、什么时候用
判断标准:表示层与业务层之间存在"脏边界"——服务查找、远程协议、重试容错、异常翻译,任何一样存在,就值得修缓冲带。
- 远程业务层:EJB、RMI、早期的 WebService——本模式出生场景
- 微服务客户端:调下游服务的所有机械逻辑(负载均衡、熔断、重试)想从 Controller 里赶走
- 服务来源会变:今天本地 Bean、明天远程服务、后天改 HTTP——代表层挡住迁移波
反面对照:同进程调用——直接调,加一层纯属自虐;调用极简单且稳定——一个工具方法搞定,不必立类;已有框架代劳——用了 Feign/Dubbo 这类声明式客户端(第五节的当代转世),再手写业务代表就是双层保险过头。
四、实现方式:远程下单
4.1 反面教材:表示层直面远程服务
先造一个"远程服务"——模拟网络抖动(前 N 次调用失败):
public class RemoteOrderService implements OrderService {
private int pendingFailures;
public RemoteOrderService(int pendingFailures) {
this.pendingFailures = pendingFailures;
}
@Override
public String placeOrder(Order order) {
System.out.println(" [远程调用] placeOrder(" + order.getId() + ") ... 网络往返中");
if (pendingFailures-- > 0) {
throw new IllegalStateException("网络抖动:连接超时(RemoteException 一族)");
}
return "订单 " + order.getId() + " 已受理(" + order.getProductName()
+ " x" + order.getQuantity() + ")";
}
// queryStock 同款,略
}
然后看 Controller 直面它时的惨状:
public class BadController {
public String submitOrder(Order order) {
System.out.println(" [JNDI 查找] OrderService ...(每个方法都要查一遍)");
OrderService service = new RemoteOrderService(1); // 模拟查找结果
for (int attempt = 1; attempt <= 3; attempt++) {
try {
return "下单成功:" + service.placeOrder(order);
} catch (IllegalStateException e) {
System.out.println(" [Controller] 捕获远程异常 → 人肉翻译成业务话术(第 " + attempt + " 次失败)");
}
}
return "系统繁忙,请稍后再试";
}
public String checkStock(String productId) {
System.out.println(" [JNDI 查找] OrderService ...(同款查找,再抄一遍)");
OrderService service = new RemoteOrderService(0);
try {
return "库存:" + service.queryStock(productId);
} catch (IllegalStateException e) {
return "系统繁忙,请稍后再试";
}
}
}
运行输出:
========== 反面教材:表示层直面远程服务 ==========
[表示层] controller.submitOrder(订单 #1001)
[JNDI 查找] OrderService ...(每个方法都要查一遍)
[远程调用] placeOrder(1001) ... 网络往返中
[Controller] 捕获远程异常 → 人肉翻译成业务话术(第 1 次失败)
[远程调用] placeOrder(1001) ... 网络往返中
结果:下单成功:订单 1001 已受理(机械键盘 x1)
[表示层] controller.checkStock("P101")
[JNDI 查找] OrderService ...(同款查找,再抄一遍)
[远程调用] queryStock(P101) ... 网络往返中
结果:库存:42
—— 查找、重试、异常翻译,每个方法抄一遍,Controller 快成网管了 ——
功能没问题,问题在重复:查找、调用、异常翻译这套流程每个方法抄一遍;十个 Controller 方法就是十份拷贝;哪天重试策略从 3 次改成 5 次,全项目正则搜索。
4.2 正解:业务代表 + 业务查找
业务查找(JNDI 的封装,查到就缓存——这个缓存下一步会长大成 ㉜ 服务定位器模式):
public class ServiceLookup {
private OrderService orderService;
public OrderService getOrderService() {
if (orderService == null) {
System.out.println(" [JNDI 查找] OrderService ...(又慢又容易出错的一步)");
orderService = new RemoteOrderService(2);
}
return orderService;
}
}
业务代表:查找、重试、异常翻译一肩挑:
public class OrderDelegate {
private final ServiceLookup lookup = new ServiceLookup();
/** 表示层只需要这一行 */
public String placeOrder(Order order) {
OrderService service = lookup.getOrderService();
IllegalStateException last = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
return service.placeOrder(order);
} catch (IllegalStateException e) {
System.out.println(" [业务代表] 第 " + attempt + " 次尝试失败,自动重试 ...");
last = e;
}
}
// 技术异常在这里翻译成业务异常,表示层拿不到 RemoteException
throw new IllegalStateException("远程服务连续失败,请联系管理员(根因:" + last.getMessage() + ")");
}
public int queryStock(String productId) {
return lookup.getOrderService().queryStock(productId);
}
}
表示层的画风,只剩业务话:
OrderDelegate delegate = new OrderDelegate();
System.out.println(" 结果:" + delegate.placeOrder(new Order("2001", "无线鼠标", 2)));
System.out.println(" 结果:库存 " + delegate.queryStock("P101"));
运行输出:
========== 正解:业务代表替表示层挡枪 ==========
[表示层] delegate.placeOrder(订单 #2001) ← 表示层只写这一行
[JNDI 查找] OrderService ...(又慢又容易出错的一步)
[远程调用] placeOrder(2001) ... 网络往返中
[业务代表] 第 1 次尝试失败,自动重试 ...
[远程调用] placeOrder(2001) ... 网络往返中
[业务代表] 第 2 次尝试失败,自动重试 ...
[远程调用] placeOrder(2001) ... 网络往返中
结果:订单 2001 已受理(无线鼠标 x2)
[表示层] delegate.queryStock("P101") ← 服务引用已缓存,不再 JNDI
[远程调用] queryStock(P101) ... 网络往返中
结果:库存 42
—— 表示层只见业务语义,远程细节全被代表消化 ——
三处对照读出含金量:网络抖了两次,表示层毫不知情(重试在代表内部消化);JNDI 只查了一次(ServiceLookup 缓存了服务引用);第二次调用跳过查找直达远程。反面教材里 Controller 的十份拷贝,在这里是一份。
4.3 三处细节
细节一:异常翻译是本模式的灵魂。 注意 placeOrder 失败到底时抛的还是 IllegalStateException,但消息已经变成业务话术("远程服务连续失败,请联系管理员")——真实项目里该抛自定义的 OrderServiceException,表示层 catch 它时不需要 import 任何远程包。判断一个业务代表合不合格,就看表示层代码里还有没有远程世界的痕迹。
细节二:重试要警惕"重试风暴"。 本文演示 3 次重试很温柔,但想想:下游服务过载时,上游 10 个入口每个重试 3 次——流量瞬间放大成 30 倍,下游直接压死。生产级的重试必须配熔断(连续失败就熔一段时间)和退避(每次等待翻倍)——这两个词留给微服务时代,但思想种子在这里。
细节三:代表接口不必镜像业务接口。 本文 OrderDelegate 的方法与 OrderService 一一对应,是最简单的形制;实战里代表可以按表示层的用例重新组织——比如 checkout(Cart) 一个方法背后调远程的下单 + 扣库存 + 发通知三个服务。代表的接口是给表示层用的,不是给业务层的——它是缓冲带,不是传声筒。
五、在框架里找它
-
OpenFeign(Spring Cloud):写个接口加 @FeignClient 注解,方法上写路径,框架生成实现——负载均衡、重试、超时全在框架层消化。Controller 注入这个接口直接调,见不到一行 RestTemplate。声明式客户端就是业务代表思想的当代转世:接口你定义,脏活框架干。
-
Dubbo 的集群容错:failover(失败自动切换重试)、failfast(快速失败)、failsafe(失败忽略)——Dubbo 在消费者侧把"重试"做成了可配置策略。本文手写的 for 循环,在生产框架里是 Cluster 层的一个策略类(⑮ 的味道)。
-
Resilience4j / Hystrix:熔断、重试、舱壁、降级四件套——专门治理"远程调用不可靠"的框架。业务代表模式里代表身上的职责,被拆成了可组合的装饰器(⑩ 的味道)。
-
EJB 时代原装:Session Facade + Business Delegate + Service Locator 三件套是当年 Sun 蓝皮书的标准答案——本文 ServiceLookup 里那行缓存,就是 Service Locator 模式的种子,㉜ 正式展开。
六、业务代表 vs 外观 vs 代理——中间人三兄弟对账
细心的读者已经发现:业务代表长得像 ⑪ 外观,也像 ⑬ 代理——都是"中间垫一层"。三个中间人摊开对账:
| 外观(⑪) | 代理(⑬) | 业务代表(㉗) | |
|---|---|---|---|
| 出发点 | 子系统调用太碎 | 对目标的访问需要管控 | 表示层与业务层之间边界太脏 |
| 接口形态 | 通常重新设计、更粗 | 与目标**一模一样**,透明替换 | 按表示层需要设计,可重组 |
| 典型行为 | 多个调用收口成一个 | 延迟加载、权限检查、缓存 | 服务查找、重试容错、异常翻译 |
| 出身 | GoF 1994 | GoF 1994 | J2EE 蓝皮书 |
| 一句话 | 替你**少打电话** | 替你**管控电话** | 替你**接通电话并处理杂音** |
三者可以叠加:业务代表内部用代理拿远程引用、用外观收口多个服务调用——中间人不是排他的,按"你替我挡的是什么"来选:挡复杂度选外观,挡访问选代理,挡远程的脏选业务代表。
写在最后
- 业务代表 = 表示层与业务层之间的缓冲带:查找、重试、异常翻译一肩挑
- 表示层只见业务语义——判断合格的标准是它代码里没有远程世界的痕迹
- 重试虽好,当心重试风暴:生产级必须配熔断与退避
- 代表接口按表示层用例设计,是缓冲带不是传声筒
- 中间人三兄弟:外观挡复杂度、代理挡访问、业务代表挡脏边界——可叠加,按需选
㉗ 的订单实体只有三个字段,一次远程调用就传输完毕——这是实体世界的"好养型"。可真实的领域模型里,"客户"这种实体由地址、信用、联系方式、订单历史一堆小对象组成;远程场景下,每个小对象都可能是独立的网络往返。EJB 的答案:把一组依赖对象捆成一个粗粒度实体统一管理——组合实体模式,下一篇见。