ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP规格模式实战:把散落的业务规则收拢成可复用、可组合、可测试的领域对象

PHP规格模式实战:把散落的业务规则收拢成可复用、可组合、可测试的领域对象 做PHP业务系统做得久了你会发现最头疼的往往不是某个算法写不出来而是业务规则像一个幽灵一样散落在Controller、Service、Repository甚至模板里。同一个“订单是否可见”的判断列表页写一遍统计数据里抄一遍管理后台再复制一遍等到产品经理说口径变了你就只能全项目搜索关键词一处漏改就是线上事故。我在团队里推的这套PHP方案核心就落在“规格”两个字上把业务规则抽成一个个规格对象用它们来做组合判断、复用和测试。这篇文章就是专门把我在PHP里落地规格模式Specification Pattern的完整方案、代码、协作方式以及踩过的重要的坑一次性交代清楚。1. 为什么我会在PHP项目里用上规格模式1.1 业务规则散落带来的痛点我先讲一段真实的经历。当时我们维护一个电商订单系统里面有一条规则“订单已支付且超过24小时未发货则视为超时订单需要在客服后台单独标记同时定时任务要自动发送提醒退款申请也要优先处理。”看起来很简单的规则对吧但它在系统里的落点至少有四个地方订单列表页的筛选条件status paid AND paid_at now - 24h AND shipped_at IS NULL客服后台的“超时订单”Tab同样的条件但用Redis缓存了一份ID列表定时任务脚本里扫表的时候又拼了一遍相同条件退款申请的时候需要判断“这单是不是超时单”如果是走加急审批流程这四个地方分别由不同同事写的写法也各不一样。有人用whereRaw(paid_at ?, [date(Y-m-d H:i:s, strtotime(-24小时))])有人用Carbon::now()-subHours(24)还有人在paid_at now() - interval 24 hour这种SQL语法上翻了车。结果就是某次口径从“24小时”改成“36小时”全局搜索替换了四个文件漏了一个超时单就全部判断错了。这种事非常典型。业务规则散落以后你很难回答一个简单的问题这条规则到底在系统里生效了几次规格模式就是冲着这个问题来的。1.2 规格模式到底是什么规格模式最早是Eric Evans在《领域驱动设计》里系统讲过的模式。它的核心思想非常朴素一个业务规则就是一个对象。以前我们习惯把规则写成if条件散落在方法里。规格模式要求你给规则一个类、一个名字、一个明确的方法。比如“金额大于1000”不是$order-amount 1000而是一个AmountAboveSpecification对象它有一个专门判断方法$spec new AmountAboveSpecification(1000); $spec-isSatisfiedBy($order);这样做的价值在于规则变成了可以传递、组合、复用、单测的一等公民。你可以把多个规格对象用“与、或、非”组合起来构造出复杂的业务判断$spec $this-isVip() -and(new AmountAboveSpecification(1000)) -and((new NotShippedSpecification())-not());你不需要完整落地DDD那一套战术模式也可以单独把规格模式引入到现有PHP项目中。这一点很重要因为很多团队一听“领域驱动设计”就觉得重但从规格模式切入门槛其实低得多。1.3 什么情况下值得引入我不是一个“万物皆模式”的鼓吹者。根据我的经验如果项目里符合下面这些特征规格模式就很值得引入特征说明同一条规则被多个地方复用列表、统计、权限、校验都会用到同一个判断规则经常变化产品动不动调整阈值、时间窗口、条件组合规则之间存在组合关系比如“会员且金额大于1000”或者“非活动订单”规则需要被独立测试业务人员要求对这些判断写专门的测试用例反过来如果只是一个简单的CRUD筛选调用方也只有一个那就老老实实写查询条件不要硬套规格模式。过度设计比没有设计更让人头疼这一点我在后面第5部分单独展开。2. 规格模式的核心抽象从接口到组合逻辑2.1 最小可用的规格接口要让规格模式在PHP里健康运转第一步是定一个最小的接口。我见过很多团队一上来就搞一个巨大的SpecificationInterface里面塞满了方法结果学习成本很高。我的建议是从最核心的方法开始interface SpecificationInterface { public function isSatisfiedBy(mixed $candidate): bool; }就一个方法它的意思也很直白给定一个候选对象这个规格是否被满足。比如“金额大于1000元”的规格final class AmountAboveSpecification implements SpecificationInterface { public function __construct(private readonly float $minAmount) { } public function isSatisfiedBy(mixed $candidate): bool { return $candidate instanceof Order $candidate-getTotalAmount() $this-minAmount; } }你可能注意到两个细节。第一方法入参我用了mixed这给规格留下很大的灵活性它可以判断订单、会员、商品、优惠券甚至一个普通的字符串。第二构造器里我用了PHP 8的属性提升constructor property promotion和readonly这让规格类非常简洁也减少了可变状态带来的麻烦。instanceof Order这个检查也很重要。因为规格对象可能被误传给错误的候选对象显式检查类型可以让错误在第一时间暴露而不是静默返回false把问题拖到线上。2.2 组合逻辑and / or / not单打独斗的规格没有灵魂。规格模式真正的威力来自组合两个规则可以合并成一个规则。我最常用的做法是给每个规格都加上and()、or()、not()这三个方法它们分别对应逻辑运算中的“与、或、非”。为了避免在每个规格类里重复写这三个方法我通常会搭配一个traitinterface SpecificationInterface { public function isSatisfiedBy(mixed $candidate): bool; public function and(SpecificationInterface $other): SpecificationInterface; public function or(SpecificationInterface $other): SpecificationInterface; public function not(): SpecificationInterface; }trait ComposableSpecification { public function and(SpecificationInterface $other): SpecificationInterface { return new AndSpecification($this, $other); } public function or(SpecificationInterface $other): SpecificationInterface { return new OrSpecification($this, $other); } public function not(): SpecificationInterface { return new NotSpecification($this); } }然后三个组合类分别是这样final class AndSpecification implements SpecificationInterface { use ComposableSpecification; /** var SpecificationInterface[] */ private array $specifications; public function __construct(SpecificationInterface ...$specifications) { $this-specifications $specifications; } public function isSatisfiedBy(mixed $candidate): bool { foreach ($this-specifications as $specification) { if (!$specification-isSatisfiedBy($candidate)) { return false; } } return true; } public function getSpecifications(): array { return $this-specifications; } }final class OrSpecification implements SpecificationInterface { use ComposableSpecification; public function __construct( private readonly SpecificationInterface $left, private readonly SpecificationInterface $right, ) { } public function isSatisfiedBy(mixed $candidate): bool { return $this-left-isSatisfiedBy($candidate) || $this-right-isSatisfiedBy($candidate); } public function getLeft(): SpecificationInterface { return $this-left; } public function getRight(): SpecificationInterface { return $this-right; } }final class NotSpecification implements SpecificationInterface { use ComposableSpecification; public function __construct( private readonly SpecificationInterface $wrapped ) { } public function isSatisfiedBy(mixed $candidate): bool { return !$this-wrapped-isSatisfiedBy($candidate); } public function getWrapped(): SpecificationInterface { return $this-wrapped; } }有了这三个类调用方就可以写出非常自然的链式表达式$spec (new BuyerIsVipSpecification(2)) -and(new AmountAboveSpecification(500)) -or(new FirstOrderSpecification());中文读法差不多就是“会员等级≥2 且 金额≥500或者 首单”。哪个业务人员来了都能大致看懂。这里有个方法论上的细节我把组合类设计成只是引用了其他规格对象而不是复制它们的业务逻辑。AndSpecification不知道也不关心里面装的是什么规格它的职责只有一个——“都要满足”。这就让组合类的代码永远保持简单而且可以独立测试。2.3 为什么组合类要做成不可变在设计这些组合方法时有一个非常容易踩的坑把and()实现成了修改当前对象。比如在AmountAboveSpecification里加一个$this-nextSpecification $other然后在isSatisfiedBy里去判断看起来省了几个类实际上后面会付出巨大代价。原因有两个。第一规格对象会被多个调用方共享。如果and()会修改自身那么同一个规格对象在A处组合成“会员且金额大于500”在B处又组合成“会员但不包含某种状态”后一次组合会覆盖前一次导致A处的判断结果错乱。第二不可变对象的调试成本低。当你看到一个规格组合树时你知道它建完之后绝不会变排查线上问题的时候你可以放心地反复调用isSatisfiedBy不需要担心自己的哪次调用污染了规格本身。所以我的建议是所有组合方法都返回一个新的规格对象保持原对象和所有子规格原封不动。这虽然多创建了几个对象但PHP每次请求都是短生命周期这点开销完全不值得牺牲正确性。3. 实战案例用规格模式重构一段真实业务筛选代码3.1 先看一段原始的条件代码假设现在要做订单列表页页面上有三个筛选维度运营人员只能看到自己所属门店的订单订单状态需要是“待支付”或者“已支付且超过24小时未发货”订单金额大于1000元很多同事的第一版实现会是这样public function listOrders(Operator $operator, int $page): LengthAwarePaginator { return Order::query() -whereIn(shop_id, $operator-visibleShopIds()) -where(function (Builder $query) { $query-where(status, Order::STATUS_PENDING) -orWhere(function (Builder $q) { $q-where(status, Order::STATUS_PAID) -where(paid_at, , now()-subHours(24)) -whereNull(shipped_at); }); }) -where(total_amount, , 1000) -orderBy(created_at, desc) -paginate($page); }这段问题明显如果“超过24小时”改成“超过48小时”或者“金额大于1000”改成“金额大于等于1000”你要在列表页、导出、统计、消息推送等多个方法里逐一修改。更糟的是你没法在单元测试里精确测试“已支付且超时未发货”这个规则因为它是嵌在查询构造器里的。3.2 把规则建模为规格对象我们用规格模式把这三个规则分别建模。门店可见规则final class ShopVisibleSpecification implements SpecificationInterface { use ComposableSpecification; /** param int[] $visibleShopIds */ public function __construct(private readonly array $visibleShopIds) { } public function isSatisfiedBy(mixed $candidate): bool { return $candidate instanceof Order in_array($candidate-getShopId(), $this-visibleShopIds, true); } }待支付或者超时未发货规则final class PendingOrOverdueSpecification implements SpecificationInterface { use ComposableSpecification; public function __construct( private readonly int $overdueHours 24, private readonly ?CarbonInterface $now null ) { } public function isSatisfiedBy(mixed $candidate): bool { if (!$candidate instanceof Order) { return false; } if ($candidate-getStatus() Order::STATUS_PENDING) { return true; } $now $this-now ?? Carbon::now(); return $candidate-getStatus() Order::STATUS_PAID $candidate-getPaidAt()?-lte($now-copy()-subHours($this-overdueHours)) $candidate-getShippedAt() null; } }金额规则final class AmountAboveSpecification implements SpecificationInterface { use ComposableSpecification; public function __construct(private readonly float $minAmount) { } public function isSatisfiedBy(mixed $candidate): bool { return $candidate instanceof Order $candidate-getTotalAmount() $this-minAmount; } }这三个类都做了同一件事把“规则描述”和“规则实现”绑在同一个地方。以后要改超时时间只需要改PendingOrOverdueSpecification的构造参数或者默认值所有用到它的地方同步生效。我还专门给PendingOrOverdueSpecification加了一个$now参数。别小看这个参数测试的时候你不可能真的等24小时有了它就可以把“当前时间”注入进去任何时间点都能稳定测试。这也是规格模式的好处——规则对象可以方便地构造各种测试场景。3.3 在业务层里组合使用有了规格对象之后列表查询在业务层可以这样组织public function listOrders(Operator $operator, int $page): LengthAwarePaginator { $spec (new ShopVisibleSpecification($operator-visibleShopIds())) -and(new PendingOrOverdueSpecification()) -and(new AmountAboveSpecification(1000)); return $this-orderRepository-matching($spec) -orderBy(created_at, desc) -paginate($page); }而统计模块要统计这些订单的总数不需要重新写条件public function countOrdersForDashboard(Operator $operator): int { $spec (new ShopVisibleSpecification($operator-visibleShopIds())) -and(new PendingOrOverdueSpecification()) -and(new AmountAboveSpecification(1000)); return $this-orderRepository-countMatching($spec); }这就是复用。规格对象成了一个可以被传参、被组合、被测试的领域概念而不是散落在SQL里的字符串。3.4 规格带来的单元测试在没有规格类之前你想测“已支付且超时未发货”这个规则可能要上数据库、造一条订单记录、把paid_at改成24小时前然后跑列表接口再断言返回结果。这套流程慢且容易受数据污染。有了规格类测试就变成纯内存操作public function test_overdue_order_satisfies_rule(): void { $order $this-buildOrder([ status Order::STATUS_PAID, paid_at Carbon::now()-subHours(25), shipped_at null, ]); $spec new PendingOrOverdueSpecification(24, Carbon::now()); $this-assertTrue($spec-isSatisfiedBy($order)); } public function test_paid_not_overdue_does_not_satisfy_rule(): void { $order $this-buildOrder([ status Order::STATUS_PAID, paid_at Carbon::now()-subHours(2), shipped_at null, ]); $spec new PendingOrOverdueSpecification(24, Carbon::now()); $this-assertFalse($spec-isSatisfiedBy($order)); }这种测试速度极快几乎不依赖数据库跑一遍几十毫秒。业务方问你“这个规则到底准不准”的时候你直接把这几个测试用例摆出来比任何口头解释都管用。4. 规格模式与数据访问层的协作方式4.1 规格要如何翻译成SQL条件到这里会有人提出一个非常实际的问题你的规格类里面是isSatisfiedBy($order)判断的是内存里的Order对象。但线上订单表可能有几十万行你不可能把全表数据拉出来再在PHP里一个对象一个对象地判断。那性能怎么办这是一个核心问题也是规格模式最容易翻车的地方。规格模式本质上是一个内存判断模式而业务系统大量场景需要的是SQL条件过滤。所以我们需要一套翻译机制让同一个规格既能做内存判断又能在数据库查询层被翻译成查询条件。我的做法是给规格增加一个可选接口interface QuerySpecificationInterface extends SpecificationInterface { /** * 返回用于查询构造器的条件描述。 * * return arrayint, array{0: string, 1: string, 2: mixed} */ public function getQueryConditions(): array; }每个“既能内存判断、又能转化为查询条件”的规格都实现这个接口。比如AmountAboveSpecification可以改成final class AmountAboveSpecification implements QuerySpecificationInterface { use ComposableSpecification; public function __construct(private readonly float $minAmount) { } public function isSatisfiedBy(mixed $candidate): bool { return $candidate instanceof Order $candidate-getTotalAmount() $this-minAmount; } public function getQueryConditions(): array { return [ [total_amount, , $this-minAmount], ]; } }然后把翻译工作交给Repository。以Laravel为例final class OrderRepository { public function matching(QuerySpecificationInterface $spec): \Illuminate\Database\Eloquent\Builder { $query Order::query(); foreach ($spec-getQueryConditions() as [$field, $operator, $value]) { $query-where($field, $operator, $value); } return $query; } }这样规格对象既能在单元测试里被直接用于内存判断又能在列表查询时被翻译成where total_amount ?各司其职。4.2 QueryComposer递归翻译组合树但上面的做法有个明显缺陷组合类AndSpecification、OrSpecification、NotSpecification本身不会实现getQueryConditions()他们是包装其他规格的容器。你传一个组合规格给Repository没法直接展开。所以需要一个专门递归遍历组合树、把每个叶子节点的查询条件拼到查询构造器里的组件。我给这个组件起名叫QueryComposerfinal class QueryComposer { public function apply(Builder $query, SpecificationInterface $spec): Builder { if ($spec instanceof AndSpecification) { foreach ($spec-getSpecifications() as $part) { $query $this-apply($query, $part); } return $query; } if ($spec instanceof OrSpecification) { $left $spec-getLeft(); $right $spec-getRight(); return $query-where(function (Builder $sub) use ($left, $right) { $this-apply($sub, $left); $this-apply($sub, $right); }); } if ($spec instanceof NotSpecification) { $wrapped $spec-getWrapped(); return $query-whereNot(function (Builder $sub) use ($wrapped) { $this-apply($sub, $wrapped); }); } if ($spec instanceof QuerySpecificationInterface) { foreach ($spec-getQueryConditions() as [$field, $operator, $value]) { $query-where($field, $operator, $value); } return $query; } throw new InvalidArgumentException( sprintf(Unsupported specification class: %s, $spec::class) ); } }这个QueryComposer内部是一个类型驱动的递归。遇到AndSpecification就逐个展开遇到OrSpecification就把内部两个条件放进同一个闭包里达到orWhere的分组效果遇到NotSpecification就包一层whereNot。到了叶节点如果是QuerySpecificationInterface就把查询条件逐个拼进去。这里我留了一个兜底的异常分支。如果某个规格只实现了内存判断isSatisfiedBy却没有实现查询条件它就没法用于数据库查询。这样设计是故意的——它逼迫我们明确知道每个规格的能力边界避免把不该放在数据库里的复杂条件硬塞进去。4.3 内存过滤与SQL过滤到底怎么选我见过一些团队一上来就规定“所有规格都必须翻译成SQL”结果遇到复杂关联逻辑翻译出来的SQL可读性极差。反过来也有团队全部走内存过滤几十万行记录拉进内存直接把服务打挂。我的实践原则是分三层场景推荐方式原因小数据量缓存配置、内存中的策略集合isSatisfiedBy内存判断简单直观不需要为每个小规则做SQL映射常规列表、统计、导出QueryComposer翻译成查询条件数据库索引能用上性能可控跨聚合、强领域逻辑的组合先粗粒度SQL过滤再内存精筛让数据库干它擅长的事让领域规则保留在内存里比如一个规格要判断“该用户是否满足会员升级条件”它可能需要计算近30天有效订单、退单率、售后单数量等多个指标。这种规则如果硬翻译成一个SQL写出来又长又难维护。我的做法是先用SQL把候选用户粗筛一遍比如只取活跃用户剩余少量数据再用规格对象慢慢判断。这样既不会N1也不会造出天书SQL。4.4 Repository层应该暴露什么样的方法为了让查询层不直接暴露复杂的规格组合逻辑建议Repository只暴露几个简单方法public function matching(QuerySpecificationInterface $spec): Builder; public function countMatching(QuerySpecificationInterface $spec): int; public function matchingWithPaginate(QuerySpecificationInterface $spec, int $perPage): LengthAwarePaginator;这样业务层负责描述“我要什么”Repository负责翻译成“怎么查”。后续如果要从MySQL换到Elasticsearch至少规格对象的表达层不用动只需要换一套翻译器。这一点对于业务代码的长期可维护性价值很大。5. 这些年踩过的坑和应对策略5.1 不要为简单规则硬套规格规格模式最大的坑是过度设计。我曾经在一个项目里把一个只有“状态等于1”这种丝毫无变化空间的规则也做成了规格类。结果就是一个“规格对象”里面就一行判断多加了一层类也多加了一个文件、一条命名约定。团队里新来的同事看了半天也没搞明白为什么要这么绕。我的判断标准很简单一个规则至少要被三个地方复用或者它经常变化、并且有组合需求才值得建规格类。如果一个规则只在一个查询里出现一次那就老老实实写where(status, 1)。规格模式是给复杂业务降复杂度的不是给简单CRUD增加仪式感的。5.2 警惕规格对象变成“SQL小抄”有些项目走着走就歪了规格类里的getQueryConditions()直接返回一段包含字段名和运算符的数组字段名写死了数据库列名比如[total_amount, , 1000]。当数据库列改名或者换一个存储引擎你需要全改一遍规格类。我的建议是让规格类尽量表达业务概念不要直接暴露物理字段。AmountAboveSpecification内部可以对Repository隐藏物理字段让翻译器去完成“业务字段 → 物理字段”的映射。但这也不是绝对的如果你用的是现成ORM、表结构稳定直接在规格里写字段名反而更直观。关键在于团队要明确规格是领域规则的载体不是SQL生成器。5.3 命名规范和目录约束规格类一旦多起来如果没有规范就会变成一团乱。我建议每个需要规格的业务模块单独建目录例如app/Domain/Order/Specification/ ├── AmountAboveSpecification.php ├── PendingOrOverdueSpecification.php ├── ShopVisibleSpecification.php └── BuyerIsVipSpecification.php命名上统一以Specification结尾别写Condition、Rule、Filter这些混着来。一旦团队约定好了IDE自动补全和代码搜索都会高效很多。另一个小技巧是如果某些组合经常被用到可以提供一个工厂类比如OrderSpecs::newDemandList($operator)避免业务层每次都拼长长的一串链式调用。5.4 警惕循环里调规格的性能问题规格对象本身是轻量的但如果你在循环里反复调用isSatisfiedBy而且规格内部又去数据库查询关联数据那就变成经典N1问题。我踩过这样的坑一个规格hasEnoughInventoryToSupport在遍历100个订单时逐单查库存表结果是100次查询页面响应时间直接飙到3秒。解决办法是规格内部的数据依赖尽量提前注入或者用Repository做一次批量加载。比如SellerHasInventorySpecification可以接收一个已经按SKU索引好的库存数组而不是自己逐条查表。规格对象看重的是可组合和可测试不是它内部的数据库访问能力。5.5 规格和策略模式、状态模式的区别写规格模式的时候经常有人混着用顺手把策略模式也叫“规则模式”。它们的区别我用一个表格说明模式解决的核心问题典型场景规格模式业务规则可以布尔组合、复用、单测订单筛选、权限判断、营销资格策略模式同一个接口下的算法可以互相替换运费计算、优惠策略、日志驱动状态模式对象行为随内部状态变化而变化订单状态机、审批流规格模式的重点是“判断是否满足”策略模式的重点是“用什么方式执行”状态模式的重点是“状态迁移时的行为”。如果拿不准先问自己我要复用的是一个判断条件还是一段可以替换的算法答案清楚了模式也就选对了。最后说点私人体会。规格模式不是银弹它最值钱的地方在于逼着你去思考这条规则到底属于哪个领域对象、哪些场景要复用、产品改口径时改几个地方就能收住。我团队用了两年之后最大的收获其实不是代码量减少而是需求变更更稳了——以前改规则是全局搜索加“碰运气”现在是找一个规格类、改一个方法、补两条测试。如果你也在PHP项目里被同类问题困扰建议先挑最痛的一两条规则开始别一口气全量重构等跑通一个闭环再慢慢铺开也不迟。
返回列表