首页
/ Coolify 架构实践:从 app/Actions 到并发锁——Laravel 架构最佳在真实 PaaS 项目中的落地

Coolify 架构实践:从 app/Actions 到并发锁——Laravel 架构最佳在真实 PaaS 项目中的落地

2026-09-06 17:19:54作者:吴年前Myrtle

本文以 Coolify 仓库中的 架构最佳实践文档 为主体,系统讲解八条 Laravel 架构准则:单一职责的 Action 类、构造器依赖注入、面向接口编程、默认降序排序、原子锁防竞态、多字节字符串函数、defer() 后置任务与 Concurrency::run() 并行执行。读完之后,你将掌握每条准则的正确/错误写法对照,并能在 Coolify 这样体量较大的自托管 PaaS(Laravel 12 项目)源码中找到与之对应的真实实现作为参照。

八条准则总览

原文档 architecture.md 给出的八条准则可以概括为三个层次:

层次 准则 解决的问题
结构 单一职责 Action 类、依赖注入、面向接口 业务逻辑分散、难以测试
数据访问 默认降序排序、原子锁 结果不确定、并发竞态
运行期细节 mb_* 函数、defer()ContextConcurrency::run() 多字节编码错误、任务调度开销、请求作用域数据传递

以下逐条展开,并结合 Coolify 源码印证这些准则在真实项目中的形态。

准则一:单一职责的 Action 类

原文档要求把离散的業務操作抽取为可调用(invokable)Action 类,核心示例:

class CreateOrderAction
{
    public function __construct(private InventoryService $inventory) {}

    public function execute(array $data): Order
    {
        $order = Order::create($data);
        $this->inventory->reserve($order);

        return $order;
    }
}

关键思想是:一个类只做一件事——"创建订单并锁定库存"这一完整业务动作封装进一个类,而不是散落在 Controller 的某个方法里。这样该动作可以在 HTTP 请求、队列任务、CLI 命令中被复用,且可以独立测试。

Coolify 是这一准则的大规模实践者。整个 app/Actions 目录就是按业务域组织的 Action 层,包含 70 余个 Action 类,例如:

  • app/Actions/DatabaseStartRedisStartPostgresqlStopDatabaseStartDatabaseProxy 等 13 个,每个负责一类数据库资源的一种操作;
  • app/Actions/ServerValidateServerInstallDockerStartSentinelDeleteServer 等 16 个,覆盖服务器生命周期;
  • app/Actions/ServiceStartServiceRestartServiceDeleteService 等,管理服务(Docker Compose 资源);
  • app/Actions/StripeCancelSubscriptionRefundSubscriptionSyncStripeSubscriptions 等订阅操作。

StartRedis 为例,可以看到典型形态:类通过 handle(StandaloneRedis $database) 方法接收单一类型的模型参数(第 23 行),内部只干一件事——生成启动该 Redis 实例所需的远程命令序列,包括创建配置目录、按 enable_ssl 开关处理 SSL 证书等分支。类里还声明了 $commands 数组属性,最终供外部执行。项目通过 lorisleiva/laravel-actions 包的 AsAction trait 让这类类同时支持 app(StartRedis::class)->execute($db)$db 直接调用的两种方式。

从源码结构看,这种"一个操作一个类"的命名与分层,使得在 app/Http/Controllersapp/Jobs 中都能以相同方式调用同一业务动作,避免了业务逻辑在多处复制。

准则二:依赖注入,避免在类内 app() / resolve()

原文档给出的对照:

错误写法——在方法体内手工解析容器:

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request)
    {
        $service = app(OrderService::class);

        return $service->create($request->validated());
    }
}

正确写法——构造器注入:

class OrderController extends Controller
{
    public function __construct(private OrderService $service) {}

    public function store(StoreOrderRequest $request)
    {
        return $this->service->create($request->validated());
    }
}

构造器注入的价值在于:依赖关系在类的签名上显式可见,测试时可以轻松替换为 mock,且不存在隐藏的解析时机问题。

Coolify 的服务提供者中可以看到容器绑定的真实用法。例如 AppServiceProvider 中:

$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));

HorizonServiceProvider 中则将 Horizon 的 JobRepository 契约绑定到自定义实现:

$this->app->singleton(JobRepository::class, CustomJobRepository::class);
$this->app->singleton(CustomJobRepositoryInterface::class, CustomJobRepository::class);

其中 CustomJobRepository 实现了 CustomJobRepositoryInterface——契约(app/Contracts 接口 + app/Repositories 实现)放在容器边界上,正是下一条准则的注脚。

准则三:面向接口编程(系统边界处)

原文档强调:在系统边界(支付网关、通知渠道、外部 API)依赖契约而非具体类,以获得可测试性与可替换性。

错误(依赖具体类):

class OrderService
{
    public function __construct(private StripeGateway $gateway) {}
}

正确(依赖接口):

interface PaymentGateway
{
    public function charge(int $amount, string $customerId): PaymentResult;
}

class OrderService
{
    public function __construct(private PaymentGateway $gateway) {}
}

并在 Service Provider 中完成绑定:

$this->app->bind(PaymentGateway::class, StripeGateway::class);

Coolify 的对应实例就是上文提到的 JobRepository 绑定:应用代码面向 JobRepository 契约编程,实现可被 CustomJobRepository 替换而不触及调用方。同理,CustomJobRepositoryInterface 的存在说明项目把"可替换实现"这件事显式建模成了契约。这种模式在对接 Stripe、通知渠道(app/Notifications/Channels 下有 12 种渠道实现)等外部系统时尤其重要——边界处换实现的成本被压缩到一行绑定。

准则四:未显式指定顺序时,默认按 id / created_at 降序

原文档:没有显式 ORDER BY 时,行顺序是未定义的。

错误:

$posts = Post::paginate();

正确:

$posts = Post::latest()->paginate();

Coolify 源码中大量列表查询遵循了这一习惯,例如 Backup Index 页 中的 ->latest() 链式调用(第 37 行)、Service/Heading 中取最近一次活动 Activity::...->latest()->first()(第 100 行)、ScheduledJobDiagnostics 中诊断命令的 ->latest()(第 84 行)。对活动日志、备份执行记录这类"时间线 UI"而言,latest() 不仅保证顺序确定性,也让"最新一条在前"成为默认语义,无需前端再排序。

准则五:用原子锁防止竞态条件

原文档给出两种手段:

Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
    $order->process();
});

// Or at query level
$product = Product::where('id', $id)->lockForUpdate()->first();
  • Cache::lock() 是应用层锁,适合跨进程(Web 进程 + 队列 worker)互斥;
  • lockForUpdate() 是数据库行锁(SELECT ... FOR UPDATE),适合事务内的并发更新。

Coolify 中 Cache::lock 有多处真实用例:

  1. SentinelControllershouldDispatchUpdate() 方法里用 Cache::lock($lockKey, 10)->block(5, ...) 保护"读取旧状态哈希 → 比较 → 写入新哈希"这段复合操作。哨兵(Sentinel)状态推送是高频写端点,若不加锁,两次并发推送会互相覆盖判断结果;代码还捕获 LockTimeoutException 优雅降级(第 133 行)。
  2. SshMultiplexingHelper:SSH 连接复用的控制逻辑用 Cache::lock 防止同一连接被并发建立。
  3. AdminDeleteUser:管理命令用 10 分钟锁保护删除流程,防止重复执行。
  4. DeleteScheduledVolumeBackup:删除卷备份前锁住对应备份任务,防止备份作业与删除动作竞态,锁超时设为 timeout + 300 以覆盖任务实际执行时长。

注意第 4 例的锁键通过 VolumeBackupJob::lockKey($backup->id) 生成——锁键的命名约定与任务一一对应,是排查"谁持锁"时的关键。

准则六:优先使用 mb_* 多字节字符串函数

原文档指出:PHP 标准字符串函数按字节计数,而 mb_* 系列按字符计数,处理 UTF-8 时必须用后者;若 Laravel 提供 Str 助手则优先用 Str(底层同样是多字节安全的)。

错误:

strlen('José');          // 5 (bytes, not characters)
strtolower('MÜNCHEN');  // 'mÜnchen' — fails on multibyte

正确:

mb_strlen('José');             // 4 (characters)
mb_strtolower('MÜNCHEN');     // 'münchen'

// Prefer Laravel's Str helpers when available
Str::length('José');          // 4
Str::lower('MÜNCHEN');        // 'münchen'

Coolify 源码遵循了这一准则,典型场景是用户输入(标签名、仓库路径)的校验与比较:

  • HandlesTagsApimb_strlen($tagName) < 2 用于标签名长度校验(第 88、133、153 行);
  • TagsControllermb_strlen($name) < 2
  • MatchesManualWebhookApplicationshash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath))——先做多字节安全的小写化,再用 hash_equals 做时序安全比较,两个细节都到位;
  • SummarizesDiffText:用 mb_strlen($value) > self::SINGLE_LINE_LIMIT 判断摘要行长度。

这些位置如果误用 strlen/strtolower,含非 ASCII 字符的标签或仓库名就会出现校验错误甚至匹配失败。

准则七:用 defer() 处理响应后轻量工作

原文档区分了两类"响应后工作":

  • 轻量、无需在进程崩溃后幸存的任务(日志、统计、清理)→ 用 defer(),回调在 HTTP 响应发出后、同一进程内执行,没有队列开销;
  • 必须跨进程存活、需要重试的工作 → 用队列 Job。

错误(给琐碎工作派 Job):

dispatch(new LogPageView($page));

正确(同进程、响应后执行):

defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));

适用前提:defer() 是 Laravel 11.23+ 引入的特性,要求 PHP 8.3+。Coolify 的 composer.json 声明 laravel/framework: ^12.65.0,满足该前提,理论上可直接采用这一准则;从当前源码检索结果看,defer( 尚未在 app/ 中广泛使用——可以推断这类准则更多是作为后续重构与代码评审的基线。

准则八:ContextConcurrency::run()

原文档还给出两项现代 Laravel 能力:

请求作用域数据用 Context facade 传递——数据自动贯穿 middleware、controller、job、log,且会自动传播到入队任务:

// In middleware
Context::add('tenant_id', $request->header('X-Tenant-ID'));

// Anywhere later — controllers, jobs, log context
$tenantId = Context::get('tenant_id');

敏感数据用 Context::addHidden() 隐藏出日志上下文;若数据绝不应离开当前进程,则不要放进 Context(因为它会随 Job 传播)。

并行执行用 Concurrency::run()——每个闭包在独立子进程中运行,无需异步库,且子进程内拥有完整的 Laravel 访问:

use Illuminate\Support\Facades\Concurrency;

[$users, $orders] = Concurrency::run([
    fn () => User::count(),
    fn () => Order::where('status', 'pending')->count(),
]);

适合彼此独立的数据库查询、API 调用或计算。Coolify 同样声明了 Laravel 12 依赖,具备使用条件;当前 app/ 源码中尚未检索到这两项的既有用例,属于可落地的改进方向而非现有行为。

准则九:约定优于配置

原文档最后一条:遵循 Laravel 约定,不要无故覆盖默认值。

错误(冗余覆盖):

class Customer extends Model
{
    protected $table = 'Customer';
    protected $primaryKey = 'customer_id';

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
    }
}

正确(依赖约定):

class Customer extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }
}

约定(表名复数小写、主键 id、关联表按字母序拼接)让模型类保持最小化;只有数据库 schema 历史包袱与约定不符时才显式声明,且声明越少越好——每多一行覆盖,就多一处将来迁移 schema 时的遗忘点。

小结:准则与 Coolify 源码对照

准则 原文档位置 Coolify 中的对应证据
单一职责 Action 第 3 节 app/Actions 全目录 70+ 类,如 StartRedis
依赖注入 第 22 节 AppServiceProvider 的构造器注入与容器绑定
面向接口 第 52 节 CustomJobRepositoryInterface + HorizonServiceProvidersingleton 绑定
默认降序 第 83 节 Backup/Index.php 等处的 ->latest()
原子锁 第 97 节 SentinelControllerCache::lock(...)->block(5, ...)
mb_* 函数 第 110 节 MatchesManualWebhookApplicationsmb_strtolower
defer() / Context / Concurrency 第 130、146、160 节 框架版本(composer.jsonlaravel/framework ^12.65.0)已具备使用前提
约定优于配置 第 175 节 app/Models 中大量模型不重写 $table/$primaryKey

这套准则的核心逻辑是一致的:把"隐式"变"显式"——依赖显式注入、顺序显式声明、并发显式加锁、编码显式多字节安全——从而让一个 48 个控制器、100+ 个 Livewire 组件、70+ 个 Action 的 Laravel 项目(如 Coolify)在增长过程中保持可测试、可替换、可推理。

登录后查看全文
热门项目推荐
相关项目推荐