Zsens Admin 插件分层重构实录:当 Controller 膨胀到 800 行时,如何用"领域事件"解耦 Service 与 Model 的隐形依赖
在 Zsens Admin 插件开发中,分层写法从来不是"建三个文件夹"这么简单。最近重构一个订单管理插件时,Controller 从 200 行膨胀到 800 行,Service 成了"万能胶水",Model 里塞满了业务判断——这篇文章记录我们如何用领域事件 + 职责委托重新切分三层,避免隐性耦合。
一、Controller 的"瘦身陷阱":你以为的精简可能是转移负担
常见误区:把 Controller 的逻辑搬到 Service,Controller 只剩一行调用。
// 错误的"精简"——Controller 成了透传层
public function audit()
{
// 800 行的 Service::audit() 里藏着校验、通知、日志、积分...
return json(OrderService::audit($this->request->post()));
}
问题:Service 膨胀为"上帝类",单次请求触发 7 个副作用,单元测试需要 Mock 整个数据库。
我们的重构原则:Controller 只负责"HTTP 语义转换"与"异常格式封装",即:参数提取 → 类型转换 → 调用领域入口 → 响应组装。
// 重构后:Controller 明确边界
public function audit(int $id)
{
$dto = OrderAuditDto::fromRequest($this->request);
try {
$result = $this->orderApplication->audit($id, $dto);
return json(['code' => 1, 'data' => $result->toArray()]);
} catch (AuditException $e) {
// 统一异常映射为 HTTP 语义
return json(['code' => 0, 'msg' => $e->getMessage()], 422);
}
}
二、Service 层拆分:从"过程脚本"到"应用编排"
ThinkPHP 8 的 Service 常被误用为"事务脚本"。我们的做法是引入 Application / Domain / Infrastructure 三级,但在插件语境下做了裁剪:
| 层级 | 职责 | Zsens Admin 中的落地 |
|---|---|---|
| ApplicationService | 用例编排、事务边界、权限预检 | 对应插件的 app/service/ 目录 |
| DomainService | 核心算法、状态机、业务规则 | 下沉到 app/domain/ 或独立类 |
| EventSubscriber | 副作用解耦 | 利用 ThinkPHP 8 的事件系统 |
关键改造:把"发货后发短信、改库存、记日志"从 Service 抽离为事件监听。
// ApplicationService:只关注主流程与事务
class OrderAppService
{
public function ship(int $orderId, ShipDto $dto): OrderEntity
{
return Db::transaction(function () use ($orderId, $dto) {
$order = $this->orderRepo->find($orderId);
$order->ship($dto->logisticsNo, $dto->company); // 领域行为
$this->orderRepo->save($order);
// 触发领域事件,副作用交给监听者
event(new OrderShipped($order));
return $order;
});
}
}
// EventSubscriber:插件间解耦的关键
class OrderEventSubscriber
{
public function onShipped(OrderShipped $event)
{
// 短信插件可能未安装,用条件触发避免硬依赖
if (plugin_installed('sms')) {
SmsFacade::send($event->order->user_id, 'ship_notice', [...]);
}
// 库存插件独立处理
StockFacade::decreaseLocked($event->order->id);
// 日志由系统级监听统一处理,插件不重复写
}
}
三、Model 的"贫血"与"充血"之争:Think ORM 下的务实选择
Think ORM 的 Model 继承体系偏向 ActiveRecord,容易写成"贫血模型"(只有字段和关联)或"过度充血"(把 Service 逻辑塞进来)。
我们的折中方案:Model 负责数据完整性与查询语义,Domain Entity 负责业务规则。在简单场景下允许 Model 轻度充血,复杂场景引入独立 Entity。
// Model:聚焦数据契约与查询封装
class OrderModel extends Model
{
// 查询语义封装,避免 Controller/Service 里拼 SQL
public function scopePending($query)
{
return $query->where('status', OrderStatus::PAID)
->where('ship_status', ShipStatus::UNSENT);
}
// 数据完整性:自动处理 JSON 字段、时间戳
protected $json = ['extra_info'];
protected $jsonAssoc = true;
// 禁止直接暴露的修改入口
protected $readonly = ['user_id', 'create_time', 'pay_amount'];
}
// Domain Entity:复杂状态机与业务校验
class OrderEntity
{
private OrderModel $model;
public function ship(string $logisticsNo, string $company): void
{
if ($this->model->status !== OrderStatus::PAID) {
throw new InvalidStateException("仅已支付订单可发货");
}
// 物流单号格式校验(领域规则,非格式校验)
if (!Logistics::validateNo($company, $logisticsNo)) {
throw new ValidationException("物流单号与承运商不匹配");
}
$this->model->ship_status = ShipStatus::SENT;
$this->model->logistics = compact('logisticsNo', 'company');
$this->model->ship_time = now();
}
}
四、分层间的"隐形依赖":三个容易踩的坑
坑 1:Service 直接返回 Model 实例
Controller 拿到 Model 后可能触发懒加载的关联查询,N+1 问题藏在分层裂缝里。重构后 Service 返回 DTO 或 Entity,明确数据边界。
// 错误:Controller 可能无意中触发 $order->user->profile
return json(['data' => $orderModel]);
// 正确:DTO 控制序列化字段
return json(['data' => OrderDto::fromEntity($orderEntity)]);
坑 2:Model 里调用其他 Model 的 Service
曾有人在 OrderModel::afterInsert 里直接调用 StockService::lock(),导致单元测试必须启动完整容器。改为事件监听后,Model 只抛事件,不直接依赖外部 Service。
坑 3:事务嵌套与插件 Hook 的冲突
Zsens Admin 的 Hook 机制允许插件拦截核心流程。若 Hook 里再开事务,ThinkPHP 8 的 Db::transaction() 默认是伪嵌套(依赖 savepoint),部分数据库驱动支持不佳。我们的约定:ApplicationService 控制最外层事务,Hook 内禁止独立事务,数据修改通过"延迟事件"在事务提交后执行。
// Hook 中安全写法:注册事务后事件
public function onOrderCreated($order)
{
// 不直接操作数据库,避免嵌套事务
Event::until('order.created.after_commit', function () use ($order) {
// 此处已在事务外,可安全调用其他插件
PointService::grant($order->user_id, $order->pay_amount);
});
}
五、插件语境下的特殊考量:与 Zsens Admin 核心框架的衔接
Zsens Admin 的插件是运行时挂载,分层设计必须考虑"插件可能被禁用、版本可能冲突"。
我们在插件 service 层引入 Adapter 模式对接核心服务:
// 插件内不直接依赖核心类的具体实现
class ZsensAuthAdapter implements AuthPort
{
public function currentUser(): UserDto
{
// 适配 Zsens Admin 的 AdminService,插件内面向接口编程
return UserDto::fromArray(AdminService::getLoginInfo());
}
}
这样当 Zsens Admin 核心升级调整 AdminService 时,只需修改 Adapter,插件内 Domain 层不受影响。
六、验证分层效果的土方法
重构后我们用三个问题自测:
- 能否在不启动 HTTP 服务器的情况下,跑通一笔订单的完整生命周期

