Coolify 架构实践:从 app/Actions 到并发锁——Laravel 架构最佳在真实 PaaS 项目中的落地
本文以 Coolify 仓库中的 架构最佳实践文档 为主体,系统讲解八条 Laravel 架构准则:单一职责的 Action 类、构造器依赖注入、面向接口编程、默认降序排序、原子锁防竞态、多字节字符串函数、defer() 后置任务与 Concurrency::run() 并行执行。读完之后,你将掌握每条准则的正确/错误写法对照,并能在 Coolify 这样体量较大的自托管 PaaS(Laravel 12 项目)源码中找到与之对应的真实实现作为参照。
八条准则总览
原文档 architecture.md 给出的八条准则可以概括为三个层次:
| 层次 | 准则 | 解决的问题 |
|---|---|---|
| 结构 | 单一职责 Action 类、依赖注入、面向接口 | 业务逻辑分散、难以测试 |
| 数据访问 | 默认降序排序、原子锁 | 结果不确定、并发竞态 |
| 运行期细节 | mb_* 函数、defer()、Context、Concurrency::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/Database:
StartRedis、StartPostgresql、StopDatabase、StartDatabaseProxy等 13 个,每个负责一类数据库资源的一种操作; - app/Actions/Server:
ValidateServer、InstallDocker、StartSentinel、DeleteServer等 16 个,覆盖服务器生命周期; - app/Actions/Service:
StartService、RestartService、DeleteService等,管理服务(Docker Compose 资源); - app/Actions/Stripe:
CancelSubscription、RefundSubscription、SyncStripeSubscriptions等订阅操作。
以 StartRedis 为例,可以看到典型形态:类通过 handle(StandaloneRedis $database) 方法接收单一类型的模型参数(第 23 行),内部只干一件事——生成启动该 Redis 实例所需的远程命令序列,包括创建配置目录、按 enable_ssl 开关处理 SSL 证书等分支。类里还声明了 $commands 数组属性,最终供外部执行。项目通过 lorisleiva/laravel-actions 包的 AsAction trait 让这类类同时支持 app(StartRedis::class)->execute($db) 与 $db 直接调用的两种方式。
从源码结构看,这种"一个操作一个类"的命名与分层,使得在 app/Http/Controllers 与 app/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 有多处真实用例:
- SentinelController:
shouldDispatchUpdate()方法里用Cache::lock($lockKey, 10)->block(5, ...)保护"读取旧状态哈希 → 比较 → 写入新哈希"这段复合操作。哨兵(Sentinel)状态推送是高频写端点,若不加锁,两次并发推送会互相覆盖判断结果;代码还捕获LockTimeoutException优雅降级(第 133 行)。 - SshMultiplexingHelper:SSH 连接复用的控制逻辑用
Cache::lock防止同一连接被并发建立。 - AdminDeleteUser:管理命令用 10 分钟锁保护删除流程,防止重复执行。
- 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 源码遵循了这一准则,典型场景是用户输入(标签名、仓库路径)的校验与比较:
- HandlesTagsApi:
mb_strlen($tagName) < 2用于标签名长度校验(第 88、133、153 行); - TagsController:
mb_strlen($name) < 2; - MatchesManualWebhookApplications:
hash_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/ 中广泛使用——可以推断这类准则更多是作为后续重构与代码评审的基线。
准则八:Context 与 Concurrency::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 + HorizonServiceProvider 的 singleton 绑定 |
| 默认降序 | 第 83 节 | Backup/Index.php 等处的 ->latest() |
| 原子锁 | 第 97 节 | SentinelController 的 Cache::lock(...)->block(5, ...) |
mb_* 函数 |
第 110 节 | MatchesManualWebhookApplications 的 mb_strtolower |
defer() / Context / Concurrency |
第 130、146、160 节 | 框架版本(composer.json 中 laravel/framework ^12.65.0)已具备使用前提 |
| 约定优于配置 | 第 175 节 | app/Models 中大量模型不重写 $table/$primaryKey |
这套准则的核心逻辑是一致的:把"隐式"变"显式"——依赖显式注入、顺序显式声明、并发显式加锁、编码显式多字节安全——从而让一个 48 个控制器、100+ 个 Livewire 组件、70+ 个 Action 的 Laravel 项目(如 Coolify)在增长过程中保持可测试、可替换、可推理。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00