Featured image of post Spring 业务解耦:AOP 与事件监听的实战与边界

Spring 业务解耦:AOP 与事件监听的实战与边界

AOP 和事件机制是『让业务代码保持纯粹』的两件武器,但用错就成乱源。本文讲清各自适用场景与边界

写在前面

业务代码里最容易长出"屎山"的两类逻辑——横切关注点(cross-cutting concerns)和副作用副流程

  • 日志、权限、审计、缓存、限流、监控埋点 → 横切关注点
  • 用户注册后送积分、下单后通知库存、修改后清缓存 → 副流程

一旦把这些事写进业务方法,主线逻辑就被淹没

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public Order createOrder(OrderDTO dto) {
    log.info("user {} start create order", userId);
    if (!hasPermission(userId, "ORDER_CREATE")) throw new ...;

    long start = System.currentTimeMillis();
    try {
        // 真正的业务
        Order order = orderRepo.save(new Order(dto));

        // 副流程一坨
        sendNotification(...);
        addUserPoints(userId, order.getAmount());
        cleanCache(userId);
        sendMQ(...);
        // ...

        return order;
    } finally {
        metrics.record("createOrder", System.currentTimeMillis() - start);
        log.info("create order done, id={}", order.getId());
    }
}

业务和"基础设施 + 副流程"完全混在一起。

Spring 提供了两种让业务代码保持纯粹的武器——AOP事件监听。本文把它们的适用场景、边界、最佳实践讲清楚。


一、AOP:解决"横切关注点"

AOP 在解决什么

AOP 把"和业务逻辑交叉但本身不属于业务"的代码抽出来,让业务方法只剩"业务":

1
2
3
4
5
@LogTime
@RequirePermission("ORDER_CREATE")
public Order createOrder(OrderDTO dto) {
    return orderRepo.save(new Order(dto));
}

业务代码变得极简,所有横切逻辑由 AOP 统一处理。

一个完整例子:方法耗时日志

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Aspect
@Component
public class TimingAspect {

    @Around("@annotation(LogTime)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return pjp.proceed();
        } finally {
            long cost = System.currentTimeMillis() - start;
            log.info("{} cost {}ms", pjp.getSignature().toShortString(), cost);
        }
    }
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogTime {}

业务方法加 @LogTime 即可。

AOP 的典型适用场景

场景注解风格
方法耗时日志@LogTime
接口幂等@Idempotent
限流@RateLimit(qps = 100)
缓存@Cached(ttl = 5m)
数据权限@DataScope
重试@Retry(times = 3)
分布式锁@DistributedLock(key)
操作日志@OperationLog("创建订单")

共同特征:和业务无关、跨多个方法、可以"声明式开关"

AOP 的限制和坑

1. Spring AOP 是基于代理的——同类调用不生效

1
2
3
4
5
6
@Service
public class OrderService {
    public void a() { b(); }              // ❌ 调 b 不会触发 b 的切面
    @LogTime
    public void b() { ... }
}

代理只在外部调用入口生效,类内部 this.method() 调用绕过了代理。解决:

1
2
@Autowired private ApplicationContext ctx;
public void a() { ctx.getBean(OrderService.class).b(); }

或者把 b 拆到另一个 Service。

2. private 方法不能被切

代理基于继承/接口,private 方法直接绕过。

3. AOP 调试链路长

stack trace 里多了 CglibProxyJdkDynamicAopProxy 这些层,新人看不懂。

4. 滥用会让代码难追

业务方法签名上看到一堆 @LogTime @Cached @Idempotent @RateLimit @OperationLog @DistributedLock——不熟悉项目的人根本不知道每个注解干啥,行为变成"看不见的魔法"。

经验上:业务方法上的 AOP 注解多于 3 个就值得复盘,多于 5 个往往是过度设计的信号。


二、事件监听:解决"副流程解耦"

Spring 事件机制

业务做完主线后,需要触发一系列副流程——传统写法是直接调用:

1
2
3
4
5
6
7
public Order createOrder(...) {
    Order o = orderRepo.save(...);
    notificationService.send(...);    // 副流程 1
    pointService.addPoints(...);      // 副流程 2
    cacheService.evict(...);          // 副流程 3
    return o;
}

每加一个副流程都要改这个方法,代码越来越臃肿。事件机制的解法——业务方法只发一个事件,副流程自己订阅:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public Order createOrder(...) {
    Order o = orderRepo.save(...);
    publisher.publishEvent(new OrderCreatedEvent(o));    // 只发事件
    return o;
}

@Component
public class NotificationListener {
    @EventListener
    public void onCreate(OrderCreatedEvent e) { ... }
}

@Component
public class PointListener {
    @EventListener
    public void onCreate(OrderCreatedEvent e) { ... }
}

每个副流程独立、可单独测试、加新流程不动主业务。

同步 vs 异步

@EventListener 默认是同步的——和业务在同一个线程、同一个事务。这意味着:

  • 任何 listener 抛异常,主业务回滚
  • listener 慢,主业务也慢

要改成异步,加 @Async

1
2
3
@Async
@EventListener
public void onCreate(OrderCreatedEvent e) { ... }

这时 listener 在另一个线程跑,和主业务事务无关——常用于"发短信、发 MQ"这类不重要、可重试的副流程。

@TransactionalEventListener:等事务提交后再触发

最容易踩的坑——publishEvent 是在主业务事务提交之前调用的(在业务方法内)。所以无论同步还是异步监听器,listener 看到的都是"事务还没提交"的状态——副流程里去查 DB 可能查不到刚写入的数据:

1
2
3
4
@EventListener   // 同步、异步(@Async)都一样
public void onCreate(OrderCreatedEvent e) {
    orderRepo.findById(e.getId());   // ❌ 可能查不到(事务还没提)
}

注意:这与"是否异步"无关——事件触发点本身就在事务提交之前

正确写法:

1
2
3
4
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreate(OrderCreatedEvent e) {
    // 主事务已提交,可以放心读取
}

AFTER_COMMIT 是最常用——只有主事务提交后才触发。生产场景里只要副流程要读 DB 数据,几乎都要用它

事件机制的典型适用场景

  • 领域事件:用户注册、订单创建、支付完成
  • 副流程触发:积分、消息推送、缓存清理
  • 解耦不同模块:订单模块发事件,营销模块、库存模块、积分模块各自订阅
  • 审计 / 操作记录

事件机制的限制和坑

1. 事件不要变成"远程过程调用"

1
2
3
4
@TransactionalEventListener
public Result onCreate(OrderCreatedEvent e) {
    return riskService.check(...);    // ❌ 想要返回值?事件不该这么用
}

事件是单向通知,不是"调用"。需要返回值的逻辑应该走普通 Service 调用。

2. 异步事件 + 事务

@Async + @EventListener 跨线程后默认丢失主线程的事务上下文——副流程内部的 DB 操作处于"自己开新事务"模式。要小心数据一致性。

3. 事件溯源能力弱

Spring 事件是进程内的——重启就没了,没有"事件回放"能力。要做事件溯源(Event Sourcing)需要更专业的方案(Axon、自己写 outbox)。

4. 链式事件失控

A 事件触发 B listener,B listener 又发 C 事件,C 又发 D……复杂业务里事件链能绕成一团乱麻。控制好事件层级,建议不超过 2 跳。


三、AOP vs 事件:怎么选

AOP事件监听
目标横切关注点副流程解耦
与主业务关系“包"主业务(before/after/around)主业务发出,副流程自己订阅
多对一?单切面 → 多方法单事件 → 多 listener
跨事务?同事务可配置(同步/异步/事务后)
业务感知度业务方法加注解或不加业务方法显式 publishEvent
可见性“魔法”比 AOP 显式

简单判断:

  • 跟业务逻辑无关、想做声明式开关 → AOP
  • 业务做完后需要触发若干副流程 → 事件监听
  • 既不是横切又不是副流程 → 直接调 Service

四、典型场景示例对比

场景:下单后清缓存

❌ 反例:写在业务里

1
2
3
4
5
public Order createOrder(...) {
    Order o = orderRepo.save(...);
    redis.delete("user:" + userId + ":orders");
    return o;
}

✅ 用 AOP(声明式缓存)

1
2
@CacheEvict(value = "user:orders", key = "#dto.userId")
public Order createOrder(OrderDTO dto) { ... }

适合"创建/修改/删除都要清同样的 cache”——纯横切。

✅ 用事件(多个副流程都要做)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public Order createOrder(...) {
    Order o = orderRepo.save(...);
    publisher.publishEvent(new OrderCreatedEvent(o));
    return o;
}

@TransactionalEventListener(phase = AFTER_COMMIT)
public void cleanCache(OrderCreatedEvent e) { ... }

@TransactionalEventListener(phase = AFTER_COMMIT)
public void sendMQ(OrderCreatedEvent e) { ... }

副流程多、需要解耦时用事件。


五、组合用法

实际项目里两者经常配合:

1
2
3
4
5
6
@OperationLog("创建订单")        // AOP 记审计
public Order createOrder(...) {
    Order o = orderRepo.save(...);
    publisher.publishEvent(new OrderCreatedEvent(o));   // 事件解耦副流程
    return o;
}
  • AOP 处理"操作日志"这种横切——业务无感
  • 事件处理"积分/通知/MQ"这种副流程——主业务只发事件

六、几条工程惯例

1. 注解上限

业务方法上的 AOP 注解不要超过 3 个。多了说明:

  • 你需要把多个注解合并成业务相关的 meta 注解(@OperationLog 包含审计 + 时间 + 权限)
  • 或者你滥用了 AOP,把本该是业务的逻辑抽离了

2. 事件命名

事件名永远是**「实体 + 动作过去时」**:OrderCreatedEventUserRegisteredEventPaymentCompletedEvent不要叫 CreateOrderEvent——前者表示"已发生",后者像"指令"。

3. 事件的字段最小化

事件载荷只放"必要的标识 + 关键字段",不要塞整个对象。listener 自己再去查 DB——避免事件载荷过大、同时减少耦合。

4. AOP 注解必须有"业务含义"

@LogTime@RateLimit 一看就懂;不要写 @MyAspect1 这种含糊命名——业务方法签名应该自解释。

5. 事件不要用于强一致性场景

事件 = 异步 / 解耦 / 最终一致。强一致用普通调用 + 事务,事件不能替代。

6. 测试覆盖到 AOP 和事件

切面和监听器很容易在测试中被忽略——主业务跑得过、但 AOP/事件没生效,单测不一定能发现。要专门写集成测试覆盖。


小结

把全文压一句:

AOP 解决『跟业务无关但跨方法都要做的事』,事件机制解决『主业务做完之后触发的副流程』——前者是『包』,后者是『发』。

工程上几条建议:

  1. 业务方法专注业务,横切交给 AOP,副流程交给事件
  2. AOP 注解 ≤ 3 个,命名要业务化
  3. 事件名 = 实体 + 过去时
  4. 事务后副流程一定用 @TransactionalEventListener(AFTER_COMMIT)
  5. 事件不要做"返回值调用",那是 Service 的事

把这两个工具用对,业务代码会一下子清爽下来——这才是"代码优雅"的真正姿势。

使用 Hugo 构建
主题 StackJimmy 设计