上个月帮一个配电自动化团队做代码评审,看到 TransformerDistrictServiceProxy 这个类,二百多行,一眼扫过去几乎全是 try-catch、logger.error 和 finally 清理 TraceId。真正调用业务服务的那一行,躲在三十多行异常处理的中间。我数了数:五个方法,同样的 catch 分支被复制了五遍;有两个方法忘记清 TraceId;一个方法把远程超时当成业务失败返回给前端,另一个方法又把它当成系统繁忙。这不是这个团队不认真,而是代理层的异常捕获天然有一种“吞噬业务逻辑”的能力,你写得越严谨,业务代码就越臃肿。这篇文章就围绕代码解耦这件事展开:怎么把业务逻辑从繁杂的代理异常捕获里抽离出来,让代码重新回到只表达业务的状态。我会用一个真实的电力业务对象——“台变”作为业务聚合根,把建模过程和重构步骤完整走一遍,适合正在维护一堆代理类、AOP 切面或者老 Service 的同学参考。
1. 代理层异常捕获的失控现场:业务逻辑是怎么一步步被吞掉的
1.1 一段每个项目里都能见到的“严谨”代码
先看一段改造前的真实样貌。为了讲清楚,我简化成了台变档案查询的代理实现,但结构一模一样:
java复制@Component
public class TransformerDistrictServiceProxy implements ITransformerDistrictService {
private final TransformerDistrictService target;
public TransformerDistrictServiceProxy(TransformerDistrictService target) {
this.target = target;
}
@Override
public Result<DistrictProfile> queryProfile(String tbCode) {
try {
if (tbCode == null || tbCode.trim().isEmpty()) {
return Result.fail(ErrorCode.PARAM_ERROR, "台变编号不能为空");
}
logger.info("queryProfile start, tbCode={}", tbCode);
long start = System.currentTimeMillis();
DistrictProfile profile = target.queryProfile(tbCode);
logger.info("queryProfile end, cost={}ms", System.currentTimeMillis() - start);
return Result.success(profile);
} catch (BusinessException e) {
logger.error("queryProfile business error, tbCode={}", tbCode, e);
return Result.fail(e.getCode(), e.getMessage());
} catch (RemoteTimeoutException e) {
logger.error("queryProfile timeout, tbCode={}", tbCode, e);
return Result.fail(ErrorCode.REMOTE_TIMEOUT, "系统繁忙,请稍后重试");
} catch (Exception e) {
logger.error("queryProfile unknown error, tbCode={}", tbCode, e);
return Result.fail(ErrorCode.SYSTEM_ERROR, "系统繁忙");
} finally {
TraceIdHolder.clear();
}
}
}
这段代码看起来没毛病:参数校验做了,耗时日志打了,业务异常和技术异常区分了,TraceId 也清了。但问题恰恰出在“该做的都做了”上——你看到的是代理类,可它把参数校验、日志埋点、异常映射、上下文清理全占了,业务方法 target.queryProfile(tbCode) 反而成了唯一一个“不需要动脑”的环节。
如果只有这一个方法,我猜没人会大动干戈。但真实项目里,一个代理类往往有十几个方法,往平台上一层套一层的代理、装饰器、门面里塞的也是同样的东西。问题是被复制出来的。
1.2 失控的连锁反应
这类代码的代价不是当下,而是改起来的时候。我总结了四个必然出现的连锁反应。
第一个是“不一致”。代码可以复制,但人不会每次都记得复制完整。同一个代理类里,有的方法 catch 到远程超时返回“系统繁忙”,有的方法返回“网络异常”,还有的方法干脆不 catch,让超时异常直接顶到前端。日志级别也一样:有的方法用 logger.warn,有的用 logger.error,线上按日志级别告警时,行为全凭开发者的手气。
第二个是“加方法的心理门槛”。你新加一个 queryDailyReport 方法,直接写业务逻辑只需要三行新代码,但为了保持这个类的“统一风格”,你脑子里要过一遍九个 catch 分支,写完一看,又是三十多行的样板。为了省事,最常出现的操作是给现有方法加一个 type 参数,把新功能塞进旧方法里,方法签名从两个参数变成五个,代码分支越来越多。代理层的臃肿会直接诱发最差的代码演进。
第三个是“吞异常”。很多人为了不让异常漏到上层,会习惯性地在代理层 catch 所有异常再返回一个 null 或者自定义错误码。问题是,异常被吞掉以后,业务方最多在返回值里看到一个“失败”的标记,失败的根因留在日志文件里,而且日志文件里这行 error 和它对应的那次业务请求之间的关联,往往因为 TraceId 清理不及时彻底断掉。排查线上问题的时候,你大概能查到“这个请求失败了”,但查不到“为什么失败”,因为链路上下文被代理层拆散了。
第四个是“测试成本倒挂”。你给代理类写单测,为了覆盖那几个 catch 分支,需要构造各种异常去模拟 target 的失败路径。这些测试代码的量往往比业务测试还多,而且它们测的其实是“代理样板逻辑”,你的业务规则——比如“台变在检修状态下不允许切换运行方式”——反而淹没在异常分支的测试堆里,没有得到足够的关注。代码解耦的意义首先就在这里:把异常处理的复杂度从业务代码里拆走,业务才能被看见、被测试、被保护。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哪些东西必须搬出去:抓住横切关注点和业务逻辑的边界
2.1 代理方法里混着四类不同“物种”的代码
我在评审时习惯把一个代理方法里的代码按“变化原因”拆开看。几乎所有被异常捕获污染的方法,都混着四类东西。
| 混在一起的代码 | 典型内容 | 变化频率 | 放错位置的后果 |
|---|---|---|---|
| 业务规则 | 台变状态校验、户变关系唯一性、运行方式切换约束 | 随需求频繁变化 | 散落在代理层后,改规则要改多个方法 |
| 技术异常捕获 | 远程超时提示、数据库异常转系统繁忙、未知异常兜底 | 低频但影响全局 | 每个方法各写一份,行为五花八门 |
| 日志埋点 | 入参、出参、耗时、TraceId | 随排查需要调整 | 埋点穿插业务逻辑,业务无法单独阅读 |
| 参数校验 | 非空、格式、长度 | 随接口演进 | 代理层一层、Service 一层、实体一层,层层重复 |
你一看就明白,这四类的“变化频率”和“影响范围”完全不一样。业务规则可能这周就要改一版;技术异常兜底是半年才动一次的地基;日志格式跟着监控平台升级;参数校验跟着接口契约走到终态。把它们硬塞进同一个方法体里,等于把所有变化因素绑在同一块石头上,任何一类发生变化都要重新冒一次风险。
尤其要注意“参数校验”这层。很多代理类把参数非空校验写在 try 块开头,返回一个 PARAM_ERROR,这其实是把接口契约的校验责任放到了一层谁都会经过的、但不属于它的地方。正确的做法是让校验发生在入口边界,用 @Validated、@NotNull 这类声明式手段解决,而不是在每个代理方法里手写三个 if。
2.2 一把简单但好用的判断尺子
判断一段代码该留在业务层还是该挪去基础设施层,我常用的尺子是:问一句话,“如果业务架构师今天把‘检修状态不能切换运行方式’改成‘允许切换,但必须记录审批人’,我需要改几个文件?”
如果答案是“改三个代理方法,因为每个切换入口都写了一遍状态校验”,那就说明业务规则已经被复制到横切代码里了,这是最严重的一种耦合。如果答案只有一个聚合根实体方法,说明业务逻辑的归属是对的。
再看异常捕获,问第二句话:“调用方收到这个异常时,需要知道具体的业务语义吗?”比如“台变不存在”“台变处于检修状态”这类异常,调用方必须知道,因为前端要根据它展示不同的提示,那你就不能在一个统一兜底里把它变成“系统繁忙”。反过来,一个远程连接超时的异常,调用方大多数时候只需要知道“这次调用没成功,属于技术故障”,甚至不需要知道是哪一台下游机器超时,那它就属于典型的横切关注点,应该由统一的异常处理器或代理模板来处理。
日志埋点的判断更简单:日志是为“阅读代码的人”和“运维排查的人”服务的,它不应该参与业务决策。把耗时日志、TraceId 清理收拢到统一模板之后,业务方法里只留 AuditLog.start 这一行埋点,或者干脆全交给 AOP 切面。这才是横切关注点应该有的存在形式。
3. 以“台变”为聚合根重新建模:业务逻辑该长在哪一层
3.1 台变凭什么当业务聚合根
“台变”是配电变压器台区的简称,也就是一台配电变压器以及它供电的低压区域。它天然形成一致性边界:这台变压器带的低压用户、它的运行状态、它的线损指标、它下面挂接的户变关系,都要围绕同一个台区来约束。这跟电商系统里“订单”作为聚合根很像——订单不可能脱离订单项而存在,台变下的户变关系也不可能脱离台变而单独成立。
所以围绕台变建模时,业务逻辑该长在 TransformerDistrict 这个聚合根实体里:它能做什么、什么状态下允许做、什么状态下禁止做,这些规则必须由实体自己保证。应用层服务只负责编排,比如“从仓储里把台变找出来→调用领域方法→保存回去”。接口层和代理层只负责把外部输入翻译成领域调用,再把领域异常翻译成接口响应。三层各管一段,异常捕获从代理层消失也就不奇怪了,因为那本来就不是它该管的事。
3.2 领域异常归领域,技术异常归基础设施
下面用两个核心业务场景把建模方法演示一遍:切换运行方式、挂接用户。先看聚合根实体:
java复制@AggregateRoot
public class TransformerDistrict {
private String tbCode;
private Transformer transformer;
private List<UserAccount> users;
private OperationMode mode;
private DistrictStatus status;
private TransformerDistrict() {
}
public static TransformerDistrict create(String tbCode, Transformer transformer) {
TransformerDistrict district = new TransformerDistrict();
district.tbCode = tbCode;
district.transformer = transformer;
district.users = new ArrayList<>();
district.mode = OperationMode.SINGLE_POWER;
district.status = DistrictStatus.RUNNING;
return district;
}
public void switchTo(OperationMode targetMode) {
if (this.status == DistrictStatus.MAINTENANCE) {
throw new DistrictInMaintenanceException(tbCode, targetMode);
}
if (this.mode == targetMode) {
throw new SameModeException(tbCode, targetMode);
}
this.mode = targetMode;
}
public void bindUser(UserAccount user) {
boolean exists = users.stream()
.anyMatch(u -> u.getUserId().equals(user.getUserId()));
if (exists) {
throw new UserAlreadyBoundException(user.getUserId(), tbCode);
}
users.add(user);
}
}
这里的异常有两个特点:第一,它们都是业务语义明确的自定义异常,比如 DistrictInMaintenanceException、SameModeException、UserAlreadyBoundException,调用方拿到后可以直接决定展示什么文案;第二,它们不携带任何技术上下文,没有堆栈、没有超时信息、没有数据库错误码。
再看应用层服务:
java复制@Service
public class TransformerDistrictApplicationService {
private final TransformerDistrictRepository repository;
public TransformerDistrictApplicationService(TransformerDistrictRepository repository) {
this.repository = repository;
}
@Transactional
public void switchOperationMode(String tbCode, OperationMode targetMode) {
TransformerDistrict district = repository.findByCode(tbCode);
if (district == null) {
throw new DistrictNotFoundException(tbCode);
}
district.switchTo(targetMode);
repository.save(district);
}
}
应用服务里没写任何 try-catch,因为领域异常在这里要做的是“继续向上的传递”——它要在接口边界被翻译成错误码,而不是在一层一层 catch 里被改造成另一个异常。技术异常,比如数据库乐观锁冲突、连接超时,则属于基础设施层,它们应该由 Repository 内部或全局异常处理器统一翻译成 REPOSITORY_CONFLICT、DB_TIMEOUT 这种技术错误码,不掺和业务语义。
3.3 抽离之后,代理类瘦成了一层壳
建模完成以后,原先那个又厚又重的代理类会变成一个薄薄的端口适配器。它不再负责业务规则,不再负责异常兜底,只做两件事:接收外部参数,调用应用服务,然后通过一个统一模板把领域异常翻译成接口响应。
java复制@Component
public class TransformerDistrictEndpointAdapter implements ITransformerDistrictApi {
private final TransformerDistrictApplicationService appService;
public TransformerDistrictEndpointAdapter(TransformerDistrictApplicationService appService) {
this.appService = appService;
}
@Override
public Result<Void> switchOperationMode(String tbCode, OperationMode mode) {
return SafeInvoker.execute("switchOperationMode", () -> {
appService.switchOperationMode(tbCode, mode);
return null;
});
}
}
对比改造前的代码,你会发现真正有业务信息量的部分一目了然:我要切换运行方式,我要调用应用服务,其他事情交给 SafeInvoker。这就是业务逻辑建模方法正确落地后的效果——代码本身会告诉你业务在做什么,而不是告诉你异常处理有多全面。
4. “抽骨”实操链条:从异常映射表到模板方法再到AOP
4.1 第一步:盘清现状,先建一张异常行为映射表
改代码前别急着动手删 catch。我踩过最大的坑就是拿到一个代理类就开始重构,结果把一些“看似异常、其实是业务返回”的分支直接改没了。正确的第一步是给当前代码建一张行为基线表。
把你代理类里每一个 catch 分支都列出来,问三个问题:这个异常在线上被触发过吗?触发频率是多少?调用方拿到这个返回值之后做了什么?比如 RemoteTimeoutException 这个分支,有的方法 catch 之后返回“系统繁忙”,有的方法返回“网络异常”,还有的方法直接 return null,从调用方视角看这三种行为是完全不一样的。你需要先记录这些差异,再决定哪些统一成“远程服务超时”,哪些保留各自的降级逻辑。
| 异常类型 | 当前行为(方法A) | 当前行为(方法B) | 目标行为 |
|---|---|---|---|
| BusinessException | 返回业务错误码 | 返回系统繁忙 | 业务异常原样透传 |
| RemoteTimeoutException | 返回系统繁忙 | 返回网络异常 | 统一为 REMOTE_TIMEOUT |
| DataAccessException | 吞掉日志 | 向上抛 | 统一为 STORAGE_ERROR |
| 未知 Exception | 返回系统繁忙 | 返回 null | 统一为 SYSTEM_ERROR,保留堆栈 |
这张表是整个重构的行为契约,后面不管是换模板还是上 AOP,都要保证这些对外可见行为不偏移。
4.2 第二步:用模板方法收拢统一流程
行为基线确定以后,先上模板方法,这是改动面最小、也最容易对比行为是否变化的一步。模板的核心是把“日志埋点 + 异常映射 + TraceId 清理”这三件事固定下来,业务代码只负责把真正的动作塞进一个 Supplier 里。
java复制public final class SafeInvoker {
private SafeInvoker() {
}
public static <T> Result<T> execute(String bizDesc, Supplier<T> action) {
try {
AuditLog.start(bizDesc);
long start = System.currentTimeMillis();
T result = action.get();
AuditLog.end(bizDesc, start);
return Result.success(result);
} catch (BusinessException e) {
AuditLog.error(bizDesc, e);
return Result.fail(e.getCode(), e.getMessage());
} catch (RemoteTimeoutException e) {
AuditLog.error(bizDesc, e);
return Result.fail(ErrorCode.REMOTE_TIMEOUT, "系统繁忙,请稍后重试");
} catch (Exception e) {
AuditLog.error(bizDesc, e);
return Result.fail(ErrorCode.SYSTEM_ERROR, "系统繁忙");
} finally {
TraceIdHolder.clear();
}
}
}
于是前面那个胖代理方法可以缩成下面这样:
java复制@Override
public Result<DistrictProfile> queryProfile(String tbCode) {
return SafeInvoker.execute("queryProfile", () -> {
if (tbCode == null || tbCode.trim().isEmpty()) {
throw new ParamInvalidException("台变编号不能为空");
}
return target.queryProfile(tbCode);
});
}
注意一个小技巧:原本“返回 Result.fail(PARAM_ERROR)”的参数校验,我改成了“抛 ParamInvalidException”。这不是纯粹风格问题——模板只需要处理一种异常流转路径,就是抛异常往上走,然后由统一的 catch 分支转换为 Result。如果你保留原来的 return Result.fail,异常和返回值两套路径就会同时存在,模板的统一意义也就没了。
4.3 第三步:注解+AOP,把跨类族的重复压缩到零
模板方法适合收拢单个类族的流程,但它要求所有业务方法都主动调 SafeInvoker.execute。如果代码库里存在多个互不相关的代理类、多个不同厂商实现的 Service,或者你希望新来的同事不调用模板也不容易被发现,那就该上注解 + AOP。
先定义一个注解,比如 @BizOperation,标注该方法的业务名称和接入统一异常处理的意图:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BizOperation {
String value();
}
再写一个切面:
java复制@Aspect
@Component
public class BizOperationAspect {
@Around("@annotation(bizOperation)")
public Object around(ProceedingJoinPoint pjp, BizOperation bizOperation) throws Throwable {
AuditLog.start(bizOperation.value());
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
AuditLog.end(bizOperation.value(), start);
return result;
} catch (BusinessException e) {
throw e;
} catch (RemoteTimeoutException e) {
throw new ApiException(ErrorCode.REMOTE_TIMEOUT, "系统繁忙,请稍后重试", e);
} catch (Exception e) {
throw new ApiException(ErrorCode.SYSTEM_ERROR, "系统繁忙", e);
} finally {
TraceIdHolder.clear();
}
}
}
跟模板相比,AOP 最大的差异是切面里不再返回 Result,而是统一把技术异常包装成 ApiException 后继续上抛,最后交给 @RestControllerAdvice 或全局异常处理器转换成 HTTP 响应。这样做的好处是业务方法可以自由选择返回类型,不用每次都被模板的 Result<T> 绑住。代价是排查问题时你得在脑子里多走一层“异常从切面到全局处理器”的路径,同时要特别注意 Spring AOP 的经典陷阱——同类内部方法自调用时不经过代理,注解切面不会生效。那个场景下你需要用 AopContext.currentProxy() 或者把方法拆到另一个 Bean。
模板方法和 AOP 不是二选一的关系。如果你已经有了全局异常处理器,再叠加一个只做异常映射的切面,容易造成重复处理。我的实践是:小团队、方法少、一眼能看完的代理类用模板;方法多、跨多个类、需要统一挂载横切能力的大型服务用 AOP;全局异常处理器永远只负责最后一公里的响应转换。
| 手段 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 模板方法 | 单个类族 | 静态分析直观,无代理陷阱 | 需要主动调用,不调用就漏 |
| 注解+AOP | 跨类族统一挂载 | 声明式,业务方法拦截无感 | 自调用陷阱,排查链路多一跳 |
| 全局异常处理器 | 接口层最后一公里 | 异常映射集中 | 只处理“到达接口层”的异常,覆盖不了被吞掉的场景 |
4.4 第四步:测试迁移与灰度观察
重构代码不算完,测试才是真正加固行为契约的手段。我在迁移异常处理逻辑时,会针对每个代理方法补至少这么几组测试用例:
- 正常路径:target 返回正常结果,断言最终返回
Result.success,日志中能看到耗时。 - 业务异常路径:mock target 抛
BusinessException,断言错误码等于业务异常码,提示语不被模板覆盖。 - 技术异常路径:mock target 抛
RemoteTimeoutException,断言返回REMOTE_TIMEOUT,日志记录为 error。 - 未知异常路径:mock target 抛一个
RuntimeException,断言返回SYSTEM_ERROR,且堆栈没有被吞。 - TraceId 清理:每个用例执行完后,断言
TraceIdHolder.get()为空。
这些用例的价值不是数量,而是把“对外行为不变”这件事固化成可回归的资产。以后谁想改异常映射,跑一遍测试就知道会改动哪些接口行为。
灰度观察也很有必要。上线前先把错误码分布、异常日志量、下游调用重试量这几个指标提前拉一份基线,上线后按接口维度对比。重点看两类偏差:原先被代理层吞掉的异常,是否被统一模板或切面改成了“上抛”,导致调用方看到了新的报错;原先某些方法“降级返回默认值”的行为,是否被统一逻辑改成了“返回系统繁忙”,导致调用方的兜底逻辑失效。只要这两个偏差没有出现,重构基本就稳了。
5. 边界与取舍:哪些捕获必须留在原地,哪些必须继续上抛
5.1 超时和重试是策略,不是 catch 里的一觉
解耦容易走向另一个极端:把所有 catch 都删光,把一切都交给全局异常处理器。但真实的分布式环境里,有些异常必须在原地就处理掉,其中最容易混淆的是“超时重试”。
很多老代码里处理超时的方式是:
java复制catch (RemoteTimeoutException e) {
Thread.sleep(1000);
return target.queryProfile(tbCode);
}
这种把重试写在 catch 块里的做法有两个问题:第一,它会让请求线程阻塞,在高并发场景下线程池很快被打满;第二,它只重试一次,还没有退避策略,下游服务如果是被打垮而不是偶发抖动,这一觉反而会加剧故障。超时重试本质上是一种“调用策略”,应该用专门的组件来表达,比如 Spring Retry 或者 Resilience4j,由独立的策略配置控制重试次数、退避时间、超时上限。代理层和业务逻辑层都只需要知道“这次调用可能抛 RemoteTimeoutException”,至于要不要重试、重试几次,交给策略层。
5.2 基础设施边界的异常翻译不能省
数据库连接断开、消息发送失败、缓存不可用,这三类异常在架构上必须就地处理,不能一路抛到最上层。原因很实在:数据库连接对象需要决定是继续用还是关闭,事务边界需要决定提交还是回滚,消息发送失败可能需要立刻走本地补偿表。如果你把这些异常直接抛给一个统一的异常处理器,框架层面能做的最多只是记录日志和返回一个错误码,但事务该回滚的细节、连接该释放的细节都丢在了半路上。
所以正确做法是:在 Repository、MQ 发送器等基础设施组件内部,把底层异常“翻译”成语义化的基础设施异常再上抛。比如把 SQLIntegrityConstraintViolationException 翻译成 RepositoryConflictException,把连接池获取超时翻译成 StorageTimeoutException。这些翻译动作本身仍然属于横切关注点,它们不应该写进业务类,而应该集中在各个基础设施组件内部。这样既保留了“业务代码不处理技术异常”的干净,又避免了技术异常裸奔到上层时丢失上下文。
5.3 统一接管异常后,我踩过一次“重试风暴”
最后讲一个真实教训。有一年做重构,我满怀信心地把一个老服务里所有代理方法的 catch 块全部收拢到统一 AOP 里,上线前测试也全过了。结果第三天,下游系统截图给我看,说它们在短时间内收到了我们这边成倍的重复请求。
排查到最后,原因很有意思。老代码里有一个方法专门处理“查询台区线损”的远程超时业务,它 catch 住 RemoteTimeoutException 后返回了一个固定的默认值,调用方拿到结果继续走业务。我改造的时候,把这种方式归类成“吞异常”,统一改成了上抛 `
