接手事件驱动的项目,最尴尬的瞬间不是代码写不出来,而是本地一切正常——事件一抛,Action 里的处理逻辑立刻开跑,日志里一行行副作用记得明明白白;代码推到自托管部署平台之后,日志干干净净,业务侧的邮件没发、库存没扣、通知没推。你盯着代码看了半小时,最后发现问题不在业务逻辑,而在「注册」这一层:asListener这个入口根本没被触发。Laravel Actions 把控制器、任务、命令、监听器统一收进一个类里,用起来确实顺手,但asListener这条路径牵扯的东西比想象中多——事件注册方式、事件缓存、配置缓存、队列 worker 的镜像版本,任何一环出问题,监听器都会安静地消失,连报错都懒得给你。这篇内容围绕 Action 接入领域事件的完整链路展开,讲清楚asListener存在的理由、注册的几种写法、在 Coolify 这类自托管部署平台上最容易翻车的几个位置,以及一套我自己反复用过的排查顺序。适合已经把 Laravel Actions 用起来、准备或者正在把它推到生产环境的人看;也适合刚接触事件驱动、想搞清楚「事件抛出去了为什么没人接」的读者。
1. 事件驱动项目里,Action 当监听器到底解决了什么
1.1 传统 Listener 类在复杂业务里的三个拖累
先说说为什么大家会想把监听器换掉。Laravel 原生的事件监听器是一个独立的类,放在app/Listeners下,一个类一个handle方法,职责单一,这本来是好设计。但业务一旦复杂起来,这套结构就开始露出疲态。第一个拖累是文件膨胀:一个订单支付事件,可能要发邮件、加积分、写审计日志、通知仓库,四个监听器类,每个类里三五行有效代码,剩下的全是use语句和类声明,翻目录的时候一眼望去全是文件名,找起来费劲。
第二个拖累是逻辑无处安放。监听器里如果只有三行代码,那还好;可一旦这段逻辑需要被别处复用——比如「发送订单回执」这个动作,既要在支付事件后触发,也要能通过接口手动重发,还要能在后台任务里批量跑——你就得在监听器外面再包一层 Service,监听器退化成一行转发,多了一层没有实际价值的壳。第三个拖累是测试成本:监听器类不挂在路由上,不挂在命令行上,只能靠Event::fake()或者手动dispatch来测,写测试的时候要为它单独设计一套入口。
Action 模式针对的就是这三个问题。一个 Action 类既是控制器,也是队列任务,也是命令行入口,也是事件监听器,逻辑只写一份,四条进入路径共用同一段实现。文件数量下来了,复用不用再包壳,测试也可以从任意一个入口打进去。
1.2 asListener 入口存在的意义:一个类,多条进入路径
Laravel Actions 的一个类可以暴露多个入口方法:asController对应 HTTP 入口,asJob对应队列入口,asCommand对应命令行入口,asListener对应事件入口,handle则是通用的默认实现。这套设计的核心假设是:同一个业务动作,换个触发方式而已,内部逻辑应该高度重合。
举个具体场景。假设有一个「发放优惠券」的 Action,运营后台可以手动发(HTTP 入口),支付成功后自动发(事件入口),夜间批处理补发(命令行入口)。三个入口拿到的入参不一样:HTTP 入口拿到的是Request,事件入口拿到的是一个领域事件对象,命令行入口拿到的可能是几个标量参数。如果全部塞进handle,方法签名要么写成万能参数,要么内部分支判断,很快就乱了。
asListener的作用就是把这些入口拆开。你在类里分别定义asController(Request $request)、asListener(OrderPaid $event)、asCommand(),每个方法负责把自己的入参归一化成内部结构,然后调用同一个私有方法或者handle。这样每个入口的签名都干净,参数解析也交给各自的适配层去做,业务逻辑只写一次。
需要提醒一点:Action 被注册为监听器时,框架默认会去找handle。asListener是显式声明的入口,用它的前提是你确实需要区分——如果这个类只做监听器一件事,直接写handle反而更省事,少一层跳转,排查的时候也少一个变量。我在项目里见过不少人所有 Action 都无脑写asListener,结果类里同时存在handle和asListener,调用链变成handle → asListener → 真实逻辑,多了一层完全没有必要的间接。
1.3 别急着上:哪些场景其实不该用 Action 做监听器
工具是好工具,但不是所有监听器都适合改成 Action。有三种情况我会劝人保持原生 Listener。第一种是纯副作用、永不复用的监听器,比如「记录一条审计日志」,这段逻辑这辈子只会被这一个事件触发,包成 Action 的唯一收益是文件少了一个,但代价是类里多了一堆 trait 和入口方法,反而更重。
第二种是需要在监听器里做大量条件过滤的场景。原生监听器可以用Event::listen的闭包形式,也可以用shouldQueue加条件判断,写起来很轻。Action 类一旦挂上AsActiontrait,类本身的职责就变重了,纯粹做过滤的场合用它有点浪费。
第三种是团队里没人熟悉这套包的情况。Action 模式最怕的就是「只有写的人懂」,一旦这个人不在,其他人看到一个类同时挂路由、挂队列、挂事件,第一反应是懵的。判断标准很简单:这个 Action 会不会被两种以上的方式触发?会,就值得用;不会,就老老实实写 Listener。这个判断我在每次评审时都会问一遍,能省掉不少过度设计。
2. 把 Action 挂到事件上的三条注册路径
2.1 显式 Event::listen:最不容易出错的一条
注册路径里,我最推荐的是显式写Event::listen。写法很直白:
use App\Actions\SendOrderReceipt; use App\Events\OrderPaid; use Illuminate\Support\Facades\Event; // 在 AppServiceProvider::boot() 或 bootstrap/app.php 的 withEvents 中 Event::listen(OrderPaid::class, SendOrderReceipt::class);这段代码的好处是完全透明:你要是怀疑监听器没注册上,打开这个文件看一眼就知道了,不需要去猜自动发现的规则匹配到了什么。项目里事件数量在二十个以内的时候,全部显式注册,维护成本极低,而且做代码审查时一眼就能看出哪个事件有哪些消费者。
如果你的项目还在用旧版的EventServiceProvider,那就是熟悉的$listen数组:
protected $listen = [ OrderPaid::class => [ SendOrderReceipt::class, GrantLoyaltyPoints::class, ], ];这条路径唯一需要注意的是注册时机。Event::listen必须早于事件分发,所以放在boot()里是安全的,放在控制器方法里就晚了。有人为了「按需注册」把监听写在中间件里,结果事件在中间件之前就抛出去了,监听器自然收不到。
2.2 事件自动发现与目录约定
Laravel 支持事件自动发现,早期版本靠EventServiceProvider里的shouldDiscoverEvents()返回true,新版本骨架里没有这个类了,改成在bootstrap/app.php里声明扫描目录:
->withEvents(discover: [ __DIR__.'/../app/Listeners', __DIR__.'/../app/Actions', ])扫描的规则是:类里的handle方法,如果第一个参数的类型提示是一个存在的类,就把它当成事件类型,自动注册。注意这里的关键点——自动发现认的是handle,不是asListener。如果你的 Action 只写了asListener而没有handle,靠自动发现是挂不上去的。这一点很容易被忽略,因为文档里讲 Action 的多入口时,asListener和handle是并列介绍的,读者容易以为两者等价。
所以我的建议是:走自动发现这条路,就用handle作为监听器入口;需要区分入口时,改回显式注册。两条路不要混着走,混着走最容易出现「有的 Action 收到事件、有的收不到」这种诡异现象,排查起来能耗掉半天。
自动发现还有一个隐性成本:每次请求都要扫描目录做反射判断,所以生产环境必须跑php artisan event:cache。而这个缓存,恰恰是后面 Coolify 那一段里最容易出问题的地方。
2.3 方法名与参数签名的匹配规则
不管走哪条注册路径,事件对象是怎么传进方法的,值得单独说清楚。Laravel 的事件分发器在调用监听器时,会把事件实例作为第一个参数传进去,同时还会把事件名、payload 一并带上。也就是说,你完全可以这样写:
class SendOrderReceipt { use AsAction; public function asListener(OrderPaid $event, string $eventName): void { // $eventName 就是 OrderPaid::class } }第二个参数拿到事件名,这个用法在需要做通用处理时很有用——比如一个 Action 要监听好几个事件,用一个match按事件名分流。参数顺序不能反,第一个永远是事件对象。
如果你的 Action 想走队列,监听器本身需要实现ShouldQueue接口:
class SendOrderReceipt implements ShouldQueue { use AsAction; public $tries = 3; public $backoff = [10, 60, 300]; public function asListener(OrderPaid $event): void { // } }实现接口之后,事件分发时不会立即执行,而是把监听器推到队列里。这时候执行的就不再是「事件分发」这一次请求的进程,而是队列 worker 进程。容器里跑的是哪份代码、用的是哪份配置,就成了新的问题源。后面 Coolify 那一节会专门展开。
2.4 用 event:list 验证注册结果
不管用哪种方式注册,落定之前都跑一次:
php artisan event:list这个命令会列出当前应用里所有已注册的事件和对应的监听器。输出里有你的 Action 类名,说明注册成功;没有,说明注册路径没生效。这是最省事的一道验证,比在代码里打dd强得多。
我习惯把它跟测试绑在一起。Laravel 提供了Event::assertListening,可以断言某个事件绑定了某个监听器:
use Illuminate\Support\Facades\Event; public function test_order_paid_listener_is_registered(): void { Event::assertListening( OrderPaid::class, SendOrderReceipt::class ); }这个断言看起来没什么技术含量,但它能挡住一类很隐蔽的回归:有人重构时把注册从AppServiceProvider挪到了某个条件分支里,或者把withEvents的扫描目录改了,本地测试环境因为缓存没清还能跑,测试环境和生产环境就直接失效。把注册关系写成断言,成本几行代码,收益是这条链路再也不会无声无息地断掉。
3. Coolify 部署链路上,监听器最容易静默失效的四个位置
3.1 构建期与运行期的环境变量错位
Coolify 这类自托管部署平台的典型工作流是:从代码仓库拉代码,用 Nixpacks 或者 Dockerfile 构建镜像,然后起容器。环境变量在界面里配置,注入到容器运行时。问题出在这里——Laravel 的配置读取分两个阶段,构建期和运行期。
假设你在config/queue.php里写了env('QUEUE_CONNECTION', 'sync'),然后在代码里用config('queue.default')读取,这是标准做法,没问题。但如果你直接在监听器里写env('MAIL_FROM_ADDRESS'),一旦生产环境跑了php artisan config:cache,env()会返回null,因为缓存之后 Laravel 不再加载.env文件,环境变量只在生成缓存的那一刻被读进 config。
这个坑对监听器的影响特别大,因为监听器往往是在后台静默执行的,env()返回null不会报错,它只是让邮件发送到了一个空地址,或者让某个开关永远判定为 false。表现出来就是「事件收到了,但什么也没发生」。
我的做法是把规则写死:业务代码里只允许出现config(),env()只能出现在config/目录下的文件里。这条规则可以用静态分析工具卡住,团队里推广起来阻力也不大,因为它确实能挡住真实的问题。
还有一个相关的细节:Coolify 里改环境变量之后,光点保存不够,必须重新部署或者至少执行一次php artisan config:cache,否则容器里跑的还是旧配置。我在项目里遇到过一次,同事在界面上改了队列驱动,改完刷新页面看着一切正常,因为 web 容器重启了,但 worker 容器没重启,它兜里还揣着旧的配置缓存,接着往旧驱动里塞任务。
3.2 配置缓存与事件缓存的执行顺序
部署脚本里的缓存命令,顺序是有讲究的。常见写法是把一堆缓存命令串起来:
php artisan config:cache php artisan route:cache php artisan view:cache php artisan event:cache看起来挺整齐,但如果你的项目里存在「事件注册依赖配置」的情况,顺序就变得重要了。比如某个 ServiceProvider 的boot()里判断if (config('features.new_billing'))再决定是否Event::listen,那么config:cache必须在最前面,先把配置固化下来,后面的event:cache才能读到正确的开关值。
反过来,如果你用了自动发现,event:cache会把扫描结果写进bootstrap/cache/events.php。这个文件一旦生成,后续代码里新增的监听器不会自动生效,必须重新跑event:cache。所以部署脚本里event:cache必须每次都执行,不能因为「上次跑过了」就跳过。
我把部署脚本里的顺序固定成下面这样,跑了两年多没出过问题:
| 顺序 | 命令 | 目的 |
|---|---|---|
| 1 | php artisan config:cache | 固化配置,后续命令依赖它 |
| 2 | php artisan event:cache | 固化事件与监听器的映射 |
| 3 | php artisan route:cache | 路由缓存 |
| 4 | php artisan view:cache | 视图缓存 |
| 5 | php artisan migrate --force | 数据库迁移 |
| 6 | php artisan queue:restart | 通知 worker 重启 |
注意第 6 步,它是后面两小节的主角。
3.3 队列 worker 跑的是旧镜像
监听器一旦实现ShouldQueue,它的执行者就从 web 容器变成了 worker 容器。Coolify 里这两个通常是独立的服务,各自跑各自的镜像。部署的时候,web 容器因为是新起的,代码肯定是最新的;但 worker 容器如果没被重建,它跑的还是老代码。
表现出来就是最让人抓狂的那种现象:你修好了监听器里的一个 bug,推上去,web 端测着手动触发没问题,但线上事件一抛,处理结果还是错的。查了半天代码,发现代码是对的——只是 worker 里跑的不是这份代码。
解决办法是php artisan queue:restart。这个命令并不真的「重启进程」,它只是往缓存里写一个时间戳,worker 每次循环开始时检查这个时间戳,发现变了就优雅退出,交给 Supervisor 或者容器的重启策略拉起新进程。
关键点来了:queue:restart依赖缓存,而缓存必须是 worker 能读到的那一份。如果你的CACHE_STORE设成file,而 web 容器和 worker 容器是两个独立的文件系统,那么 web 写入的信号文件,worker 根本看不见,它会一直跑下去,跑到容器重启为止。这个问题非常隐蔽,因为命令执行成功了,返回码是 0,日志里也写了「Broadcasting queue restart signal」,看着一切正常,实际上什么也没发生。
正确做法是把缓存切成redis或者database,让两个容器共享同一份存储。如果项目规模小不想引入 Redis,用 database 也完全够用。我在几个小项目里就是CACHE_STORE=database,配合queue:restart跑得很稳。
3.4 共享缓存缺失导致 queue:restart 失效
上一节提到了缓存共享,这一节展开说说为什么会踩。Coolify 用 Docker 部署,每个服务一个容器,容器之间默认不共享文件系统。开发者本地开发时,web 和 worker 都在同一台机器上,storage/framework/cache是同一个目录,queue:restart自然生效;部署到平台之后,这个前提消失了,但代码逻辑没变,于是失效。
判断方法很简单,在部署脚本里加一步验证:
php artisan tinker --execute=" cache()->put('deploy:ping', now()->toDateTimeString(), 60); echo cache()->get('deploy:ping'); "如果 web 容器里能写能读,就在 worker 容器里执行同样的读取:
php artisan tinker --execute="echo cache()->get('deploy:ping', 'MISS');"读到MISS,说明两边缓存不共享,queue:restart必然是失效的。这时候要么换缓存驱动,要么在部署脚本里直接重启 worker 容器(Coolify 支持在部署后重启指定服务),后者更粗暴但有效。
顺便说一个兜底措施:给 worker 加上--max-jobs或者--max-time参数,让它跑一段时间自己退出,靠容器的重启策略拉起新进程。这样即使信号没传到,最坏情况下几分钟内也会自然更新。我在生产环境的启动命令里一直挂着--max-time=3600,虽然牺牲了一点性能(每次重启有几十毫秒的空窗),换来的是「代码改了但 worker 没更新」这类问题基本绝迹。
4. 一次完整的排查复盘:事件抛出了,Action 没动
4.1 从业务日志反推事件是否真的被分发
前段时间遇到一个典型故障:用户支付成功,页面提示正常,但订单回执邮件没发出去。监听器是一个SendOrderReceipt的 Action,实现了ShouldQueue。先确认最外层:事件到底有没有被抛出来。支付回调的入口代码里有一行OrderPaid::dispatch($order),我在它后面紧跟一行日志:
OrderPaid::dispatch($order); \Log::info('OrderPaid dispatched', ['order_id' => $order->id]);这一步的目的是把「事件是否分发」和「监听器是否执行」两件事区分开。如果这行日志都没有,问题在分发之前,可能是支付回调根本没进来,或者事务回滚了;如果有日志但没收到邮件,问题就在监听器这一侧。查下来日志有,说明分发正常,问题锁定在后半段。
4.2 逐层缩小:dispatcher 到注册表的排查链路
排查顺序我固定成四层,一层一层往里收:
第一层,注册表。执行php artisan event:list | grep -A 3 OrderPaid,看SendOrderReceipt在不在列表里。这里发现它不在。第一反应是注册代码写错了,打开AppServiceProvider看了半天,代码是对的。
第二层,缓存。容器里bootstrap/cache/目录下有events.php,说明事件缓存生效了。把缓存清掉再跑一次event:list,SendOrderReceipt出现了。这就对上了:注册代码没问题,但事件缓存是旧的,这份缓存生成的时候,SendOrderReceipt这个类还没被创建出来。
第三层,部署脚本。去看 Coolify 里配置的部署后命令,发现只有config:cache、route:cache和migrate,压根没有event:cache。也就是说,bootstrap/cache/events.php是很久以前某次手动执行时留下的,之后一直没更新过。
第四层,验证。在部署脚本里补上php artisan event:cache,重新部署,event:list正常,发一封测试邮件,收到了。
4.3 根因定位与修复动作
根因其实很朴素:事件缓存文件是旧的。但这个「旧」是怎么来的,值得说清楚。项目早期用的是手动注册,没开自动发现,所以event:cache这个命令从来没被写进部署流程,反正手动注册不依赖缓存。后来重构时改成了自动发现,扫描app/Actions目录,注册方式变了,但部署脚本忘了同步改。这就是典型的「架构演进了一步,运维配置没跟上」。
修复动作有三条。一是部署脚本补上event:cache,放在config:cache之后。二是加了一条部署后的自检命令,event:list输出里找不到关键监听器就让部署失败:
php artisan event:list | grep -q "SendOrderReceipt" || exit 1这条命令看着土,但非常好用。它把「配置错误」从「线上跑了一周才发现」提前到了「部署当场失败」,修复成本相差一个数量级。三是把bootstrap/cache/目录加进了容器启动的清理列表,确保每次部署都是干净状态开始,不依赖上一次留下的任何缓存文件。
4.4 为什么这个坑在本地永远复现不了
最后说一句这个坑最气人的地方:本地复现不了。因为本地开发时,php artisan serve启动的进程里根本没有bootstrap/cache/events.php,或者虽然有,但因为代码一直在改动,event:cache早就被清掉了。本地环境默认是「无缓存」状态,而生产环境是「缓存固化」状态,两者的行为差异巨大。
所以我现在的习惯是:本地开发时定期手动跑一次config:cache和event:cache,跑一遍核心流程,看看有没有异常,跑完记得config:clear恢复。这个动作看起来繁琐,但它能提前暴露掉一整类「缓存固化之后才出现」的问题。另一个土办法是在 CI 里加一个任务,专门用生产模式的缓存跑一遍集成测试,成本高一点,但能挡住最致命的问题。
5. 让 Action 监听器在生产环境站得住:幂等、事务与失败处理
5.1 幂等是被重复执行的常态
事件监听器在分布式环境里被重复执行,这不是异常,是常态。队列超时重试、worker 意外退出后任务重新入队、部署过程中的中继,任何一个环节都可能导致同一个监听器对同一个事件执行两次。如果你的监听器里写的是「给用户加 100 积分」,二次执行就是多给了 100 分,用户不会告诉你,但财务会对账。
处理幂等的标准套路是加一道去重闸门。最简单的是数据库唯一索引,在积分流水表上建unique(event_id, user_id),插入冲突就吞掉异常:
try { PointTransaction::create([ 'event_id' => $event->id, 'user_id' => $event->order->user_id, 'amount' => 100, ]); } catch (UniqueConstraintViolationException $e) { return; // 已经处理过,直接跳过 }用数据库约束的好处是它是最强的一道防线,即使业务代码里判断漏了,数据库也会兜住。缺点是依赖具体驱动抛出的异常类型,mysql和pgsql不一样,需要做兼容。
另一种做法是用缓存锁,Cache::add()是原子的,返回 true 说明抢到了锁,可以执行:
if (! Cache::add("order-paid:{$event->id}", 1, now()->addHours(24))) { return; }这种写法简单,但依赖缓存的可靠性。前面说过 Coolify 里缓存可能是file驱动,容器重启就丢,锁也跟着丢,幂等就失效了。所以我更倾向数据库约束,缓存锁作为辅助。
5.2 事务边界决定监听器看到什么
事务是另一个高频翻车点。假设你在DB::transaction里创建订单、扣库存,然后OrderPaid::dispatch($order)。默认情况下,同步监听器会立刻执行,此时事务还没提交,监听器里去查订单数据,可能查到,也可能查不到,取决于数据库的隔离级别。更糟的是如果事务后面回滚了,监听器已经执行的副作用(比如发了邮件)收不回来。
Laravel 提供了ShouldHandleEventsAfterCommit接口,监听器实现它之后,会被推迟到事务提交后执行:
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit; class SendOrderReceipt implements ShouldHandleEventsAfterCommit, ShouldQueue { use AsAction; public function asListener(OrderPaid $event): void { // } }这个接口的语义是「等当前所有活跃事务提交之后再跑」。它的实现依赖连接上的事务层级计数,所以在嵌套事务的场景下也能正确工作。
我的判断标准是:只要监听器里要读数据库,就加这个接口。因为读数据库就意味着依赖写入的结果,而写入结果只有在提交后才对其他连接可见(同步监听器和主流程在同一个连接上,能看到未提交数据,但队列化的监听器在另一个进程里,看不到)。这条规则听起来保守,但它避免了大量「偶发」的排查工作——那种「大部分时候正常,偶尔数据不对」的问题,十有八九是事务边界没处理好。
5.3 重试策略与失败任务的落地处理
队列化的监听器失败之后,Laravel 会按tries和backoff重试,最终失败进入failed_jobs表。这套机制好用,但需要针对监听器的特点做调整。
监听器的重试次数不宜太多。业务事件通常有时效性,邮件晚到十分钟还能接受,晚到三小时就变成骚扰了。我的配置一般是tries = 3,backoff = [10, 60, 300],也就是 10 秒、1 分钟、5 分钟,总共跨度不到 6 分钟,超过这个时间还没成功,大概率不是瞬时故障,而是代码或者数据问题,继续重试没意义,不如直接进失败表等人处理。
failed_jobs不能只看不管。我见过不少项目,失败表里堆了几千条记录,没人看,因为「反正重试过了」。监听器的失败往往对应着一个业务动作没完成,比如积分没到账、通知没发出,这些是会引发用户投诉的。我的做法是给失败表加一个每日巡检,超过 10 条就发告警,人工介入。同时给一部分监听器写failed()方法做兜底:
public function failed(OrderPaid $event, \Throwable $e): void { \Log::error('SendOrderReceipt failed', [ 'order_id' => $event->order->id, 'error' => $e->getMessage(), ]); // 落一条待人工处理的记录 ManualTask::create([ 'type' => 'send_receipt', 'payload' => ['order_id' => $event->order->id], ]); }这段兜底逻辑的价值在于,它把「静默丢失」变成了「显式待办」。哪怕自动流程全部失败,至少有人会看到。
5.4 同一 Action 兼任多入口的隔离策略
最后说说 Action 同时是控制器和监听器时的隔离问题。这种类最容易出现的问题是两个入口共享了实例状态。Laravel 的容器默认对每个请求解析一个新实例,但队列任务和事件监听器在同一个 worker 进程里可能连续执行多个任务,如果 Action 里有成员变量被修改,状态会串。
避免的方式很简单:不要给 Action 类加可变成员变量。所有中间状态都放在方法内的局部变量里,或者作为参数传递。这个规则和控制器的最佳实践是一致的,但因为 Action 同时承担多种角色,违反它的代价更大。
另一个隔离点是异常处理。控制器里的异常会被 Laravel 的异常处理器捕获,返回一个漂亮的错误页;监听器里的异常会被事件分发器捕获,如果监听器是同步的,异常会向上冒泡,可能中断主流程;如果是队列化的,异常会被队列处理器捕获,进入重试流程。这两种行为差异很大,所以我在 Action 里会显式区分入口:
public function asController(Request $request) { try { $this->handle($request->all()); } catch (BusinessException $e) { return response()->json(['message' => $e->getMessage()], 422); } } public function asListener(OrderPaid $event): void { $this->handle($event->toArray()); // 不吞异常,让队列重试机制接管 }控制器入口吞掉业务异常并转成 HTTP 响应,监听器入口让异常抛出去,交给队列重试。同一个内部方法,两种异常策略,这是我认为多入口设计里最有价值的一种用法。
我个人在这些项目里踩下来,感受最深的一点是:Action 这个模式真正的复杂度不在业务代码,而在「它到底从哪条路进来的」。写代码的时候觉得一个类挂四个入口很优雅,出问题的时候才发现,每挂一个入口就多一条需要验证的链路。所以后来我的习惯是,每加一个asListener,就顺手写一个Event::assertListening的断言,再加一条部署后的event:list自检。这两行东西挡住的坑,比任何架构设计都实在。