接手一个老项目,打开 app/controller/Order.php,create 方法一共 210 行。参数校验、库存扣减、订单落库、优惠券核销、发短信、加积分、写操作日志,八件事挤在一个方法里。最要命的是中间那三行:库存扣完、订单还没写进数据库,短信已经发出去了。后来测试环境出了一次事务回滚,用户收到「您的订单已创建」,后台却查不到这笔单子。
这篇文章记录我怎么把这段代码拆开。拆法不新鲜,就是依赖注入加领域事件,但 ThinkPHP 在这两块上有几个坑,网上讲得不多,尤其是容器绑定的生命周期和事务与事件的时序,我各踩了一次。
先看拆之前的样子
为了后面好对照,把问题代码简化成核心几行:
public function create(Request $request)
{
$data = $request->only(['goods_id', 'num', 'coupon_id']);
Validate::rule([
'goods_id' => 'require|integer|gt:0',
'num' => 'require|integer|between:1,99',
])->check($data);
$goods = GoodsModel::find($data['goods_id']);
if ($goods['stock'] < $data['num']) {
return json(['code' => 1, 'msg' => '库存不足']);
}
Db::startTrans();
try {
$goods->stock -= $data['num'];
$goods->save();
$order = OrderModel::create([...]);
if ($data['coupon_id']) {
CouponModel::where('id', $data['coupon_id'])->update(['used' => 1]);
}
// 这一行就是事故现场
SmsService::send($order['user_id'], '下单成功');
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
return json(['code' => 0, 'data' => $order]);
}
问题不止一个。短信这条副作用不受事务保护,回滚它也跟着发出去了。积分、日志还没写,但按这个写法迟早也会塞进来。单元测试根本没法写,因为 SmsService 是静态调用,没法替换成假的。
目标:控制器只留 15 行
拆完之后我想要的效果是这样的:
class OrderController extends BaseController
{
public function create(Request $request, PlaceOrderService $service)
{
$data = $request->only(['goods_id', 'num', 'coupon_id']);
Validate::rule([
'goods_id' => 'require|integer|gt:0',
'num' => 'require|integer|between:1,99',
])->check($data);
$order = $service->handle($this->userId(), $data);
return json(['code' => 0, 'data' => $order]);
}
}
校验留在控制器是因为它是 HTTP 层的事,跟业务无关。剩下的全部下沉。注意 PlaceOrderService 是直接写在方法签名里的,不需要在构造里 new,ThinkPHP 的容器会自动解析。
第一步:接口绑定,让 Service 可替换
为什么用接口而不是直接用类?因为我要能在测试里把数据库实现换掉。订单的持久化我抽一个接口出来:
namespace appcontract;
interface OrderRepository
{
public function save(array $data): array;
public function findById(int $id): ?array;
public function lockGoods(int $goodsId): array;
}
实现类走数据库:
namespace apprepository;
use appcontractOrderRepository;
use appmodelGoodsModel;
use appmodelOrderModel;
class DbOrderRepository implements OrderRepository
{
public function save(array $data): array
{
$order = OrderModel::create($data);
return $order->toArray();
}
public function findById(int $id): ?array
{
$order = OrderModel::find($id);
return $order ? $order->toArray() : null;
}
public function lockGoods(int $goodsId): array
{
// 加排他锁,防止并发下单超卖
return GoodsModel::where('id', $goodsId)->lock(true)->findOrFail()->toArray();
}
}
然后在 app/provider.php 里把接口绑到实现:
<?php
return [
'bind' => [
appcontractOrderRepository::class => apprepositoryDbOrderRepository::class,
],
'instance' => [],
];
这一步做完,Service 里就可以写 public function __construct(OrderRepository $repo),容器自动把 DbOrderRepository 塞进来。测试里改一下 provider,换成内存实现,整条链路不碰数据库。
坑一:bind 不是单例
我一开始以为 bind 绑定之后,整个请求周期里拿到的都是同一个对象。不是的。每次 app()->make() 都会重新实例化一次具体类。
对 OrderRepository 这种无状态的东西没影响,多 new 几次无非多点开销。但后面我要写的那个事件收集器是有状态的——它内部有个计数器记录当前嵌套了几层事务——如果每次注入都是新对象,计数器永远是 0 和 1 之间跳,事务提交时的派发逻辑直接失效。
解决办法是把有状态的对象用 instance 注册,而不是 bind。但 app/provider.php 是纯配置数组,写不了逻辑,所以得走服务类:
namespace appservice;
class DomainService extends thinkService
{
public function register(): void
{
// instance 注册的是同一个对象实例
$this->app->instance(
appsupportEventCollector::class,
new appsupportEventCollector()
);
}
}
然后在 app/service.php 里挂上:
<?php
return [
appserviceDomainService::class,
];
一句话总结这个区别:bind 管的是「用哪个类」,instance 管的是「用哪个对象」。有状态的用后者。
第二步:把副作用变成领域事件
先定义事件类。它就是个普通的 PHP 对象,只携带数据,不做任何事:
namespace appevent;
class OrderPaid
{
public function __construct(
public readonly int $orderId,
public readonly int $userId,
public readonly float $amount
) {}
}
然后是监听器。发短信一个,加积分一个:
namespace applistener;
use appeventOrderPaid;
class SendOrderSms
{
public function handle(OrderPaid $event): void
{
// 真实项目里这里应该丢进队列,
// 同步发短信会把接口响应拖慢几百毫秒
thinkfacadeQueue::push(
appjobSendSmsJob::class,
['userId' => $event->userId, 'tpl' => 'order_paid']
);
}
}
namespace applistener;
use appeventOrderPaid;
class AddUserPoints
{
public function handle(OrderPaid $event): void
{
$points = (int) floor($event->amount);
appmodelUserModel::where('id', $event->userId)
->inc('points', $points)
->update();
}
}
注册在 app/event.php:
<?php
return [
'bind' => [],
'listen' => [
appeventOrderPaid::class => [
applistenerSendOrderSms::class,
applistenerAddUserPoints::class,
],
],
'subscribe' => [],
];
到这一步,Service 里只要一行 Event::trigger(new OrderPaid(...)) 就能把所有副作用带动起来。加新功能不用再改 Service,新写个监听器挂上去就行。
第三步:事务和事件的时序问题
这里才是整个方案真正的难点。
如果我把 Event::trigger 写在 Db::transaction 的闭包里,会得到一个很别扭的状态:事件是同步执行的,监听器立刻跑,这时候数据库事务还没提交。加积分的那个监听器去查用户表,读到的还是旧数据(因为用的是同一个连接,能看到自己事务内的修改,但订单表如果是在别的地方写入的,就可能读不到)。更糟的是,一旦后面某个环节抛异常回滚,短信可能已经通过队列发出去了。
反过来,把 Event::trigger 写到 Db::transaction 闭包外面也不对,因为那个时候你可能已经不在事务里了,而且如果事务内部有多层嵌套,你不知道该在哪一层之后触发。
我需要的是「事务提交后派发」。ThinkPHP 没有内置这个能力,自己写一个收集器,逻辑不复杂:
namespace appsupport;
use thinkfacadeEvent;
class EventCollector
{
/** @var object[] */
private array $pending = [];
private int $depth = 0;
/** 事务开始,层数 +1 */
public function begin(): void
{
$this->depth++;
}
/** 事务提交,层数 -1,归零时统一派发 */
public function commit(): void
{
$this->depth--;
if ($this->depth <= 0) {
$this->depth = 0;
$this->flush();
}
}
/** 事务回滚,清空队列,什么都不发 */
public function rollback(): void
{
$this->depth--;
if ($this->depth <= 0) {
$this->depth = 0;
$this->pending = [];
}
}
/**
* 投递事件。
* 不在事务里就直接派发,在事务里就排队。
*/
public function push(object $event): void
{
if ($this->depth === 0) {
Event::trigger($event);
return;
}
$this->pending[] = $event;
}
private function flush(): void
{
$queue = $this->pending;
$this->pending = [];
foreach ($queue as $event) {
try {
Event::trigger($event);
} catch (Throwable $e) {
// 事务已经提交,这里再抛出去会让调用方以为下单失败
// 所以只记日志,不让异常往外冒
thinkfacadeLog::error('事件派发失败: ' . get_class($event), [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
}
}
注意 depth 这个计数器。为什么需要它?因为嵌套事务是真实存在的。比如 PlaceOrderService 里开了事务,它调用的 CouponService 里可能又开了一层。内层提交的时候整个业务还没结束,这时候派发事件就早了。只有最外层提交,才是真正的「落定」。
然后用一个帮助函数把事务和收集器绑在一起:
namespace appsupport;
use thinkfacadeDb;
class Transaction
{
public static function run(callable $callback)
{
$collector = app(EventCollector::class);
$collector->begin();
try {
$result = Db::transaction($callback);
$collector->commit();
return $result;
} catch (Throwable $e) {
$collector->rollback();
throw $e;
}
}
}
这里 app(EventCollector::class) 能拿到同一个对象,全靠前面用 instance 注册的服务。如果当时用了 bind,这里每次都会 new 一个新的,begin 和 commit 落在两个不同的对象上,计数永远是 0,事件一个都不会派发。这个 bug 极其隐蔽——不报错、不警告,只是短信不发、积分不加,你要翻遍日志才能反应过来。
第四步:重写 Service
零件齐了,主流程长这样:
namespace appservice;
use appcontractOrderRepository;
use appeventOrderPaid;
use appexceptionBizException;
use appsupportEventCollector;
use appsupportTransaction;
class PlaceOrderService
{
public function __construct(
private OrderRepository $orders,
private EventCollector $events
) {}
public function handle(int $userId, array $data): array
{
return Transaction::run(function () use ($userId, $data) {
// 悲观锁,防超卖
$goods = $this->orders->lockGoods($data['goods_id']);
if ($goods['stock'] < $data['num']) {
throw new BizException('库存不足');
}
// 扣库存
appmodelGoodsModel::where('id', $goods['id'])
->dec('stock', $data['num'])
->update();
// 下单
$order = $this->orders->save([
'user_id' => $userId,
'goods_id' => $goods['id'],
'num' => $data['num'],
'amount' => $goods['price'] * $data['num'],
'status' => 'paid',
'created_at' => date('Y-m-d H:i:s'),
]);
// 核销优惠券
if (!empty($data['coupon_id'])) {
$affected = appmodelCouponModel::where('id', $data['coupon_id'])
->where('used', 0)
->update(['used' => 1, 'order_id' => $order['id']]);
if ($affected === 0) {
// 抛出后 Transaction 会自动回滚,
// 已经入队的事件也会被清空
throw new BizException('优惠券不可用');
}
}
// 副作用不在这里做,只投递事件
$this->events->push(new OrderPaid(
$order['id'],
$userId,
(float) $order['amount']
));
return $order;
});
}
}
现在如果优惠券核销失败,异常往上抛,Transaction::run 捕获后调用 rollback(),队列里的 OrderPaid 被直接丢弃。库存、订单、优惠券全部回滚,短信一条不发。这就是我想要的效果。
监听器里失败了怎么办
上面收集器的 flush 里我加了 try-catch,这不是偷懒。事务已经提交,订单是真实存在的,这时候监听器抛异常,异常会一路冒到控制器,用户看到「下单失败」,但数据库里明明有单子。这种不一致比「短信没发出去」严重得多。
所以派发阶段的失败只记日志。但光记日志也不够,会丢事。更好的做法是把监听器里所有可能失败的操作都改成投递队列:
namespace applistener;
use appeventOrderPaid;
class SendOrderSms
{
public function handle(OrderPaid $event): void
{
// 只是把任务写进 Redis 队列,几乎不会失败
thinkfacadeQueue::push(
appjobSendSmsJob::class,
['userId' => $event->userId, 'tpl' => 'order_paid']
);
}
}
队列投递本身是轻量的,真正慢的 HTTP 请求和第三方调用都发生在队列消费者里,那边有重试机制。这么一改,派发阶段出问题的概率从「第三方接口挂了」降到「Redis 挂了」,后者本来也不该在业务请求里处理。
有一点要注意:Queue::push 在事务未提交时投递,消费者可能比生产者先跑,读到旧数据。因为我们把投递放在了事务提交之后,这个问题自动没了。这也是「提交后派发」的附带好处。
测试怎么写
拆完之后测试反而简单了。写一个内存版的仓库实现:
namespace testsfake;
use appcontractOrderRepository;
class InMemoryOrderRepository implements OrderRepository
{
public array $orders = [];
public array $goods = ['id' => 1, 'stock' => 10, 'price' => 99.0];
public function save(array $data): array
{
$data['id'] = count($this->orders) + 1;
$this->orders[$data['id']] = $data;
return $data;
}
public function findById(int $id): ?array
{
return $this->orders[$id] ?? null;
}
public function lockGoods(int $goodsId): array
{
return $this->goods;
}
}
测试里临时改绑定:
public function testPlaceOrderTriggersEvent()
{
$fake = new testsfakeInMemoryOrderRepository();
app()->instance(appcontractOrderRepository::class, $fake);
$fired = [];
thinkfacadeEvent::listen(
appeventOrderPaid::class,
function ($event) use (&$fired) {
$fired[] = $event->orderId;
}
);
$service = app(appservicePlaceOrderService::class);
$order = $service->handle(1001, ['goods_id' => 1, 'num' => 2]);
$this->assertCount(1, $fired);
$this->assertEquals($order['id'], $fired[0]);
$this->assertCount(1, $fake->orders);
}
整个过程不碰 MySQL,跑一次不到 100 毫秒。这种测试写几十个也无所谓。相比之下,拆之前那个 210 行的控制器,想测「优惠券无效时不应该发短信」,得把 SmsService 的静态方法 mock 掉,几乎不可能。
几个补充的判断
事件要不要都走收集器?我现在的规则是:只要事件的产生点可能在事务里,就走。如果确定不在事务里,比如用户登录后记录活跃时间,直接 Event::trigger 更简单。统一走收集器虽然一致,但多一层间接,调试时会绕。
监听器要不要做返回值?不要。这是事件驱动最容易跑偏的地方。一旦有人开始依赖监听器的返回值做分支,事件就从「通知」变成了「调用」,那还不如直接写方法调用。
什么时候别用这套?小项目、逻辑只有一处、短期内不会加副作用,就别抽了。抽出来三层是给「同一个逻辑被多处调用」和「副作用会不断增加」的场景准备的。订单创建正好符合这两条,所以值得。
队列是不是必须的?不是。但如果监听器里有任何 HTTP 调用(短信、推送、第三方对账),强烈建议换成队列。同步发短信会让下单接口多几百毫秒,这个成本比多维护一个队列进程高得多。
收尾
整个改造大概花了两天,代码量比原来多了三成——多了接口、实现、事件类、监听器、收集器和事务包装。看起来是亏了。但后面两周加「下单送优惠券」的需求时,只写了 40 行:一个事件类、一个监听器,挂到 event.php 上,Service 里加了一行 push。如果还是那个 210 行的控制器,这次改动至少得再花半天,而且得小心别把已有的库存扣减逻辑弄坏。
最实在的收益是那个事务回滚的 bug。它之前只在测试环境偶尔出现,没人能稳定复现。改完之后我把优惠券核销改成必失败,跑了一遍完整流程:库存没扣、订单没建、短信没发、积分没加。四条全是干净的。
如果你手上也有一个正在膨胀的控制器,不用一次拆到底。先做第一步——把那个静态调用的 SmsService 换成注入,这一小步就能让你开始写测试。剩下的两件事,等第一个「加个功能又要改这个方法」的需求出现时再动。

