Coolify 中的 Laravel Actions 实践:用 lorisleiva/laravel-actions 构建可复用、可测试的多入口点操作
【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify
Coolify 后端基于 Laravel,其app/Actions目录下的全部操作类统一采用lorisleiva/laravel-actions包(composer.json中锁定^2.10.2)来实现。仓库内的技能文档 SKILL.md 系统化地记录了这套模式:一个 Action 类只实现一次handle(...)核心业务逻辑,却可以通过AsActiontrait 同时作为对象、Controller、Job、Listener、Command 五种入口点运行,并能用fake()/mock()/spy()等手段做第一类测试隔离。读完本文,你可以掌握 Coolify 中 Action 的完整骨架、五种入口点的接线方式、队列生命周期钩子的真实用法,以及一套“业务正确性 + 入口点接线”双层测试策略。
何时把一段逻辑写成 Action
技能文档给出的决策规则很明确(见 SKILL.md 的 “When to Use an Action”):
- 用 Action:同一用例需要多个入口点(HTTP、队列、事件、CLI),或需要一等的编排/fake 能力;
- 用普通 Service 类:逻辑是局部的、单入口点的、且不太可能以 Action 形式复用。
从app/Actions的实际组织看,Coolify 遵循了“按领域分子命名空间”的约定:App\Actions\Database、App\Actions\Service、App\Actions\Server、App\Actions\Proxy、App\Actions\Application等 13 个子目录共 60 余个 Action 类,命名统一为描述性的VerbNoun(如StartDatabase、DeployServiceApplication、CleanupDocker)。文档同时约定:
- 业务/领域逻辑只放在
handle(...)中;传输层与框架关注点(HTTP 响应、CLI IO、队列细节)放在适配方法(asController、asJob、asListener、asCommand)中; - 所有 Action 方法优先显式参数与返回类型;
- 复杂数据结构契约优先用 PHPDoc 而非行内注释。
基础骨架与对象入口点
技能文档给出的最小骨架是:
<?php namespace App\Actions; use Lorisleiva\Actions\Concerns\AsAction; class PublishArticle { use AsAction; public function handle(int $articleId): bool { return true; } }对象入口点有三种等价调用方式(详见 references/object.md):
PublishArticle::run($id); // 首选,静态 helper PublishArticle::make()->handle($id); // 显式 make + handle app(PublishArticle::class)->handle($id); // 容器注入(配合构造函数 DI)trait 还额外提供了条件执行:PublishArticle::runIf($condition, ...)与PublishArticle::runUnless($condition, ...),分别在条件成立/不成立时才执行handle(...)。Coolify 源码中大量使用::run()同步编排:例如 RestartDatabase 内部直接return StartDatabase::run($database);,体现了“Action 编排 Action”的组合风格。
一个真实例子是 StartDatabase:它接收八种独立数据库模型的联合类型,在handle(...)中先检查服务器可用性,然后按getMorphClass()分发到StartPostgresql::run()、StartRedis::run()等具体 Action;若数据库开启了公网访问,还会StartDatabaseProxy::dispatch($database)异步派发代理启动。注意这个文件里没有出现任何as*适配方法——这正是文档反复强调的“只按需扩展”:configureJob()之外没有任何多余代码。
作为 Job:队列生命周期钩子在 Coolify 中的真实用法
文档中的 Job Action 完整模式
当 Action 需要以队列形式运行时,文档给出的项目级模式是(骨架完整保留自 SKILL.md):
<?php namespace App\Actions\Demo; use App\Models\Demo; use DateTime; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; class GetDemoData { use AsAction; public int $jobTries = 3; public int $jobMaxExceptions = 3; public function getJobRetryUntil(): DateTime { return now()->addMinutes(30); } public function getJobBackoff(): array { return [60, 120]; } public function getJobUniqueId(Demo $demo): string { return $demo->id; } public function handle(Demo $demo): void { // Core business logic. } public function asJob(JobDecorator $job, Demo $demo): void { // Queue-specific orchestration and retry behavior. $this->handle($demo); } }各成员的含义(“仅按需使用”):
| 成员 | 作用 |
|---|---|
$jobTries | 队列执行的最大尝试次数 |
$jobMaxExceptions | 未处理异常达到该次数后直接判失败 |
getJobRetryUntil() | 绝对重试截止时间(DateTime) |
getJobBackoff() | 每次重试的退避策略(int或按次数组) |
getJobUniqueId(...) | Unique Job 的去重键 |
asJob(JobDecorator $job, ...) | 访问 attempt 元数据、做队列专属分支 |
Coolify 源码中的两类真实配置
Coolify 中定义configureJob(JobDecorator $job)的 Action 有三个,分别展示了两种典型的队列控制写法:
- 指定队列——StartDatabase:
public function configureJob(JobDecorator $job): void { $job->onQueue(deployment_queue()); }所有数据库启动任务统一进入部署队列,避免与高频检查类任务互相挤占。
- 声明式队列属性——DeployServiceApplication 直接声明
public string $jobQueue = 'high';,让服务部署任务走高优先级队列,其handle(...)内部则只关心远程docker compose up -d编排。
更完整的 Job 参考(dispatch 家族、JobDecorator全部钩子)见 references/job.md,要点包括:
- 异步
dispatch(...)、同步dispatchSync(...)/dispatchNow(...)、响应后执行dispatchAfterResponse(...)、条件派发dispatchIf/dispatchUnless; - 链式编排:
Action::withChain([...])->dispatch(...)或Bus::chain([...])->dispatch(),配合makeJob()/makeUniqueJob()包装; JobDecorator钩子:configureJob()、getJobMiddleware()、$jobConnection、$jobQueue、$jobTries、$jobMaxExceptions、$jobBackoff/getJobBackoff()、$jobTimeout、$jobRetryUntil/getJobRetryUntil()、getJobDisplayName()、getJobTags()、getJobUniqueId()/$jobUniqueId、getJobUniqueFor()/$jobUniqueFor、getJobUniqueVia()、$jobDeleteWhenMissingModels/getJobDeleteWhenMissingModels(),以及失败回调jobFailed(?Throwable $e, ...$parameters);- 测试断言助手:
assertPushed()/assertNotPushed()/assertPushedOn(queue, times, callback),回调可接收 Action 实例、派发参数、JobDecorator实例和队列名。
一个能体现入口点切换的调用链:API 控制器 DatabasesController 中十余处StartDatabase::dispatch($database)走队列,而同一 Action 在 RestartDatabase 内部走StartDatabase::run($database)同步执行——同一个handle(...),两种传输方式,业务逻辑零改动。
作为 Controller / Listener / Command
三种入口点在文档中的接线规则都很紧凑:
- Controller:路由直接指向类(invokable 风格),如
Route::post('/articles/{id}/publish', PublishArticle::class);需要 HTTP 适配时加asController(...)并返回响应;输入来自 HTTP 时叠加rules()或自定义 validator 钩子。 - Listener:在
EventServiceProvider中注册 Action 类为监听器,用asListener(EventName $event)收到事件后委托给handle(...)。 - Command:定义
$commandSignature与$commandDescription属性,实现asCommand(Illuminate\Console\Command $command),控制台 IO 只留在该方法内。
对应的深入参考分别为 references/controller.md、references/listener.md、references/command.md;属性注解方式见 references/with-attributes.md。
测试策略:双层验证与 AsFake 全家族
双层测试矩阵
文档要求“业务正确性”与“入口点接线”分开验证:
handle(...)直测:真实依赖 + 工厂数据,验证业务规则本身;- 入口点测试:分别针对
asController(打路由)、asJob(Queue::fake()+assertPushed*)、asListener(派发事件后断言交互)、asCommand(artisan 命令 + 输出断言)。
推荐的测试矩阵为:业务规则测试、HTTP 接线测试(下游 Action 用shouldRun/shouldNotRunfake)、Job 接线测试(dispatch 后断言下游调用)、事件监听测试(事件触发后断言交互)、控制台测试(运行命令断言调用与输出)。最小执行单元示例:php artisan test --compact --filter=PublishArticle。
AsFake 方法族(2.x)
文档按“你想证明什么”来区分每个 fake 方法的适用场景:
mock():整体替换为 mock,适合严格期望与参数断言:
PublishArticle::mock() ->shouldReceive('handle') ->once() ->with(42) ->andReturnTrue();partialMock():部分 mock,保留真实行为只桩掉某个昂贵/内部方法:
PublishArticle::partialMock() ->shouldReceive('fetchRemoteData') ->once() ->andReturn(['ok' => true]);spy():间谍,不预定义期望、事后验证“是否以 X 被调用”:
$spy = PublishArticle::spy()->allows('handle')->andReturnTrue(); // 执行触发 Action 的代码… $spy->shouldHaveReceived('handle')->with(42);shouldRun():mock()->shouldReceive('handle')的快捷式,适合紧凑的编排断言:PublishArticle::shouldRun()->once()->with(42)->andReturnTrue();shouldNotRun():mock()->shouldNotReceive('handle')的快捷式,适合守卫分支/分支覆盖测试;allowToRun():spy + 放行handle,既让执行继续又能断言交互:
$spy = PublishArticle::allowToRun()->andReturnTrue(); // … $spy->shouldHaveReceived('handle')->once();isFake()/clearFake():检测类是否当前被替换、清理 fake 防止跨测试泄漏:
expect(PublishArticle::isFake())->toBeFalse(); PublishArticle::mock(); expect(PublishArticle::isFake())->toBeTrue(); PublishArticle::clearFake(); expect(PublishArticle::isFake())->toBeFalse();实践默认值:分支测试优先shouldRun()/shouldNotRun()提升可读性;行为大体真实、只需调用验证时用spy()/allowToRun();交互契约严格且要快速失败时用mock();fake 可能泄漏时在清理阶段调clearFake();副作用隔离原则——只 fake 被测 Action 边界,而不是 fake 一切。
Pest 风格示例与仓库中的测试现实
文档给出的 Pest 风格示例:
it('dispatches the downstream action', function () { SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0); FinalizeInvoice::run(123); }); it('does not dispatch when invoice is already sent', function () { SendInvoiceEmail::shouldNotRun(); FinalizeInvoice::run(123, alreadySent: true); });对照 Coolify 的测试目录可以看到落地方式:tests/Feature下的 500 余个测试文件广泛使用Queue::fake()、Bus::fake()以及 Action 的派发断言来验证 Job 接线(例如 LifecycleApisTest.php 中多处Queue::fake(),ApplicationPreviewQueueAdvancementTest.php 对预览部署队列推进做断言)。从源码结构看,Coolify 更倾向于以Queue/Busfacade fake 验证“Action 作为 Job 是否被推入预期队列”,这与技能文档中 Job 入口点的assertPushed*断言体系是同一套验证思路。
排障清单与常见陷阱
技能文档收尾部分给出可直接执行的检查清单与陷阱列表,值得原样保留:
排障清单
- 确认类使用了
AsAction且命名空间匹配自动加载(可用composer show lorisleiva/laravel-actions先确认包已安装); - 以 Controller 使用时检查路由注册;
- 使用
dispatch时检查队列配置($jobQueue/configureJob/config/queue.php); - 事件到监听器的映射在
EventServiceProvider中核对; - 传输层关注点留在
as*适配方法里,不要混进handle(...)。
常见陷阱
- 把 HTTP 响应/重定向逻辑写进
handle(...)而不是asController(...); - 在多个
as*方法中重复业务规则,而不是委托给handle(...); - 以为 Listener 接线可以省略显式注册;
- 只测入口点、漏测
handle(...)直接行为; - 对一次性、单上下文逻辑过度使用 Action(无复用压力时保持普通 Service)。
小结
Coolify 的app/Actions目录是 SKILL.md 所述模式的完整实例:StartDatabase、DeployServiceApplication等 Action 把远程 Docker 编排、服务器管理、数据库生命周期等业务逻辑收敛进强类型的handle(...),再用configureJob()/$jobQueue声明队列归属、用::run()与::dispatch()在同步/异步两种传输之间自由切换;测试层则以Queue::fake()、Bus::fake()加上 Action fake 家族完成双层验证。如果你要在类似 Coolify 的大型 Laravel 项目中新增可复用操作,直接按“骨架 → 选入口点 → 补队列钩子 → 双层测试”的工作流推进,即可得到结构一致、可预测测试的代码。
【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考