Zsens Admin 插件开发:Controller/Service/Model 分层边界划分与职责逃逸陷阱

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 70 浏览 0 回复

在 Zsens Admin 插件开发中,分层架构的"干净"程度直接决定了插件的可维护性和二次开发成本。基于 ThinkPHP 8 的框架特性,很多开发者容易把分层写成"三层皮、一锅粥"——表面分了目录,实际逻辑到处漂移。本文从具体代码场景出发,讨论 Controller、Service、Model 的边界到底该划在哪。

一、Controller 的禁区:别让 HTTP 逻辑入侵业务

Controller 只应对请求解析响应组装负责。以下是一段典型的"脏"代码:

public function save()
{
    $data = $this->request->post();
    // 校验、格式化、业务判断全堆在这里
    if ($data['type'] == 1) {
        $data['extra'] = json_encode($data['extra']);
    }
    $model = new PluginConfig();
    $model->save($data);
    // 还要触发事件、写日志
    Event::trigger('config_changed', $data);
    Log::write('config update: ' . json_encode($data));
    return json(['code' => 0]);
}

问题很明显:Controller 里混入了数据转换、事件触发、日志记录。正确的做法是把这些收拢到 Service:

public function save()
{
    $dto = ConfigSaveDTO::fromRequest($this->request);
    $result = $this->configService->update($dto);
    return $this->success($result);
}

Controller 只保留参数提取 → DTO 封装 → Service 调用 → 响应返回这一根主线。

二、Service 的陷阱:别做成"事务脚本"或"透传层"

Service 层最容易出现的两种极端:

极端一:胖事务脚本——所有逻辑平铺在一个方法里,几百行代码处理 N 个模型。

极端二:瘦透传层——直接 $this->model->save($data),没有任何业务语义封装。

在 Zsens Admin 插件开发中,建议把 Service 拆成领域服务应用服务两层:

// app/service/ConfigAppService.php  应用服务:编排流程
class ConfigAppService
{
    public function __construct(
        protected ConfigDomainService $domainService,
        protected ConfigCacheService $cacheService,
        protected LogService $logService
    ) {}

    public function update(ConfigSaveDTO $dto): ConfigVO
    {
        return Db::transaction(function () use ($dto) {
            $entity = $this->domainService->applyChanges($dto);
            $this->cacheService->invalidate($entity->group);
            $this->logService->record('config_update', $entity);
            return ConfigVO::fromEntity($entity);
        });
    }
}
// app/service/ConfigDomainService.php  领域服务:纯业务规则
class ConfigDomainService
{
    public function applyChanges(ConfigSaveDTO $dto): ConfigEntity
    {
        $entity = ConfigEntity::fromDTO($dto);
        $entity->validateBusinessRules();  // 如:互斥配置项检测
        $entity->encryptSensitiveFields(); // 如:密钥类配置加密
        $entity->save();
        return $entity;
    }
}

应用服务管流程和事务边界,领域服务管业务规则执行,这样拆分后单元测试可以直插领域服务,无需 mock HTTP 上下文。

三、Model 的定位:数据网关 vs 贫血模型

Think ORM 的 Model 常被误用为"带数据库操作的数组容器"。在插件开发中,Model 应该承担数据访问策略关系定义,而非业务计算:

// 错误:把业务计算塞到 Model
class OrderModel extends Model
{
    public function calcFinalPrice($userLevel)
    {
        // 用户等级折扣、优惠券叠加、积分抵扣...
        // 这会让 Model 依赖太多外部上下文
    }
}

更干净的做法是用查询对象模式封装复杂查询,业务计算上浮到领域服务:

// Model 只定义数据结构和基础查询
class OrderModel extends Model
{
    protected $name = 'plugin_order';
    
    public function scopePaid($query)
    {
        return $query->where('status', '>=', 20);
    }
}

// 查询对象封装复杂条件组装
class OrderQuery
{
    public static function userMonthlyPaid(int $userId, string $month)
    {
        return OrderModel::paid()
            ->where('user_id', $userId)
            ->whereBetweenTime('pay_time', "{$month}-01", "{$month}-31")
            ->field('id,amount,pay_time');
    }
}

四、跨层调用的红线:禁止 Service 直接操作其他插件的 Model

多插件环境下,一个常见错误是 A 插件的 Service 直接 new \plugin\b\model\Xxx()。这会造成隐式耦合,升级时互相踩脚。

Zsens Admin 推荐的解耦方式:

  1. 对外暴露Service 接口(PHP 8.2 的 readonly class 配合接口约束很合适)
  2. 通过事件总线命令总线异步解耦
  3. 必要时用防腐层(Anti-Corruption Layer)做数据映射
// 插件 A 定义接口
namespace plugin\a\contract;
interface UserCreditInterface
{
    public function deduct(int $userId, float $amount, string $scene): bool;
}

// 插件 B 通过依赖注入使用,而非直接操作 Model
class MyService
{
    public function __construct(
        #[Inject(UserCreditInterface::class)]
        protected readonly UserCreditInterface $creditService
    ) {}
}

五、分层校验的落地:DTO 校验 ≠ 业务规则校验

ThinkPHP 8 的验证器适合放在 Controller 层做输入格式校验(必填、类型、范围),但业务规则校验(如"该配置项在高级版才能开启")必须下沉到领域服务:

// Controller 层:格式校验
$this->validate($data, [
    'name|配置标识' => 'require|alphaDash',
    'value|配置值'  => 'require',
]);

// Service 层:业务规则校验
class ConfigEntity
{
    public function validateBusinessRules(): void
    {
        if ($this->isProFeature && !LicenseService::isPro()) {
            throw new BusinessException('pro_feature_not_allowed');
        }
        if ($this->type === 'select' && empty($this->options)) {
            throw new BusinessException('select_options_required');
        }
    }
}

六、一个可自检的分层健康度指标

写完代码后,用这三个问题自测:

  1. Controller 能否一键替换成 CLI 命令? 如果不能,说明 HTTP 逻辑泄漏到了下层。
  2. Service 方法能否在不加载框架的情况下跑单元测试? 如果必须启动整个应用,说明依赖过重。
  3. Model 换一张表结构,有多少处代码要改? 如果到处都要改,说明 Model 被当成了数据契约滥用。

分层不是为了"看起来规范",而是让每一层都有单一且稳定的变更理由。需求变接口时只改 Controller,业务规则调整时只改 Service,表结构迁移时只改 Model——这才是"拆干净"的真正标准。

评论0
回复 · 0
还没有回复
微信客服 微信客服