Zsens Admin 插件分层实战:当 Service 开始"偷吃"Model 的脏活,我用"仓库契约"重新划定了三方边界

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

上周重构一个积分兑换插件,Controller 才 60 行,Service 却飙到 400+,里面混着原生 SQL、缓存逻辑、甚至直接操作 $_G['cache']。Model 层只剩个空壳,连表前缀都要 Service 自己拼。这不是分层,是 Service 把 Model 活埋了。

这篇不说大道理,直接上我踩过的三类"职责逃逸"现场,以及怎么用"仓库契约"把脏活赶回该去的地方。

一、Service 里的 SQL 拼装:Model 成了 DTO 摆设

最开始的代码长这样:

// Service/ExchangeService.php
public function getUserValidOrders($uid) {
    $table = DB::table('plugin_exchange_orders');
    $sql = "SELECT * FROM {$table} 
            WHERE uid='{$uid}' 
            AND status IN (1,2) 
            AND expire_time > ".TIMESTAMP;
    return DB::fetch_all($sql);
}

问题在哪?Service 知道表结构、知道字段含义、知道 TIMESTAMP 常量。换个人维护,改个字段要翻三四个 Service 文件。

我的改法:Model 只暴露语义化接口,Service 只传业务参数。

// Model/OrderRepository.php(仓库契约)
interface OrderRepositoryInterface {
    public function findValidByUser(int $uid, array $statuses, int $minExpire): array;
}

// Model/OrderRepositoryDb.php(实现)
public function findValidByUser(int $uid, array $statuses, int $minExpire): array {
    return $this->newQuery()
        ->where('uid', $uid)
        ->whereIn('status', $statuses)
        ->where('expire_time', '>', $minExpire)
        ->get();
}

// Service/ExchangeService.php
public function getUserValidOrders($uid) {
    return $this->orderRepo->findValidByUser(
        $uid, 
        [OrderStatus::PAID, OrderStatus::SHIPPED], 
        TIMESTAMP
    );
}

Service 不再知道 expire_time 这个字段名,只关心"有效订单"这个业务概念。Model 的仓库实现可以随便换(DB、缓存、甚至外部 API),Service 无感。

二、Controller 里的流程编排:Service 成了函数仓库

另一个插件的 Controller 典型场景:

// Controller/ExchangeController.php
public function submit() {
    $this->checkLogin();
    $this->checkToken();
    
    $goods = $this->goodsService->getById($_GET['gid']);
    if (!$goods || $goods['stock'] < 1) {
        showmessage('库存不足');
    }
    
    $user = $this->userService->getCredit($_G['uid']);
    if ($user['credit'] < $goods['price']) {
        showmessage('积分不够');
    }
    
    $this->userService->deductCredit($_G['uid'], $goods['price']);
    $this->goodsService->decrStock($goods['id']);
    $orderId = $this->orderService->create([
        'uid' => $_G['uid'],
        'gid' => $goods['id'],
        'price' => $goods['price']
    ]);
    
    $this->logService->write('exchange', $orderId);
    
    showmessage('兑换成功', "plugin.php?id=exchange:order&oid=$orderId");
}

Controller 成了"流程导演",Service 只是被调用的工具函数。更糟的是:扣积分、减库存、建订单三步没有事务,中间报错就数据不一致。

重构后:Controller 只负责"接请求、转参数、吐响应",Service 封装完整业务用例。

// Controller/ExchangeController.php
public function submit() {
    $this->checkLogin();
    $this->checkToken();
    
    try {
        $order = $this->exchangeService->execute(
            new ExchangeCommand(
                uid: $_G['uid'],
                goodsId: (int)$_GET['gid']
            )
        );
    } catch (ExchangeException $e) {
        showmessage($e->getMessage());
    }
    
    showmessage('兑换成功', $order->detailUrl());
}

// Service/ExchangeService.php
public function execute(ExchangeCommand $cmd): OrderEntity {
    return $this->db->transaction(function() use ($cmd) {
        $goods = $this->goodsRepo->lockForUpdate($cmd->goodsId);
        $goods->ensureStock();
        
        $user = $this->userRepo->lockForUpdate($cmd->uid);
        $user->ensureCredit($goods->price);
        
        $user->deduct($goods->price);
        $goods->decrStock();
        
        $order = OrderEntity::create($user, $goods);
        $this->orderRepo->save($order);
        
        $this->eventBus->dispatch(new OrderCreated($order));
        
        return $order;
    });
}

Service 里的 execute 是一个完整的"领域用例",自带事务边界。Controller 连 $_G['uid'] 都不直接碰,通过 Command 对象隔离。

三、Model 里的业务规则:Entity 成了贫血结构

最隐蔽的逃逸:Model 只有 getter/setter,业务规则散落在 Service 各处。

// 到处出现的重复校验
if ($goods['status'] != 1) throw ...
if ($goods['start_time'] > TIMESTAMP) throw ...
if ($goods['end_time'] < TIMESTAMP) throw ...

改成充血模型后,规则内聚到 Entity:

// Model/Entity/GoodsEntity.php
public function ensureAvailable(): void {
    if ($this->status !== self::STATUS_ONLINE) {
        throw new GoodsNotAvailableException('商品已下架');
    }
    if ($this->startTime > TIMESTAMP) {
        throw new GoodsNotAvailableException('兑换未开始');
    }
    if ($this->endTime < TIMESTAMP) {
        throw new GoodsNotAvailableException('兑换已结束');
    }
}

public function ensureStock(): void {
    if ($this->stock < 1) {
        throw new InsufficientStockException();
    }
}

public function decrStock(): void {
    $this->ensureStock();
    $this->stock--;
    $this->recordEvent(new StockChanged($this->id, -1));
}

Service 调用 $goods->decrStock() 就行,不用管"怎么判断库存、怎么扣、要不要发事件"。

我的分层 checklist(Zsens Admin 场景)

现在写新插件前,我会对着这张表自测:

层级该做的事红线(看到就重构)
Controller接收请求、校验输入、调用 Service、返回响应出现 SQL、出现业务计算、直接操作缓存
Service编排领域用例、控制事务边界、发布领域事件知道表字段名、拼 SQL、直接读 $_G
Model/Repository数据映射、查询封装、持久化细节包含业务规则判断(除了数据完整性约束)
Entity封装业务规则、维护自身状态一致性直接操作 DB、依赖外部服务

最后一句话:分层不是为了"看起来规范",是为了"改一处不用翻遍全文"。当你的 Service 开始问 Model"这个字段叫什么"的时候,边界就已经塌了。

你们插件里遇到过哪些"看起来分了层,其实没分"的伪装现场?

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