Coolify 中的 Laravel Actions 实践:用 lorisleiva/laravel-actions 构建可复用、可测试的多入口点操作
2026/9/7 14:35:03 网站建设 项目流程

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\DatabaseApp\Actions\ServiceApp\Actions\ServerApp\Actions\ProxyApp\Actions\Application等 13 个子目录共 60 余个 Action 类,命名统一为描述性的VerbNoun(如StartDatabaseDeployServiceApplicationCleanupDocker)。文档同时约定:

  • 业务/领域逻辑只放在handle(...)中;传输层与框架关注点(HTTP 响应、CLI IO、队列细节)放在适配方法(asControllerasJobasListenerasCommand)中;
  • 所有 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 有三个,分别展示了两种典型的队列控制写法:

  1. 指定队列——StartDatabase:
public function configureJob(JobDecorator $job): void { $job->onQueue(deployment_queue()); }

所有数据库启动任务统一进入部署队列,避免与高频检查类任务互相挤占。

  1. 声明式队列属性——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()/$jobUniqueIdgetJobUniqueFor()/$jobUniqueForgetJobUniqueVia()$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 全家族

双层测试矩阵

文档要求“业务正确性”与“入口点接线”分开验证:

  1. handle(...)直测:真实依赖 + 工厂数据,验证业务规则本身;
  2. 入口点测试:分别针对asController(打路由)、asJobQueue::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 所述模式的完整实例:StartDatabaseDeployServiceApplication等 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询