里氏替换在接口契约测试中的验证方法

解读

在国内一线互联网公司的 PHP 后端面试中,面试官问“里氏替换在接口契约测试中的验证方法”,并不是想听背诵定义,而是考察候选人能否把“面向对象设计原则”落地到“可自动化、可度量、可灰度”的测试体系里。核心诉求有三点:

  1. 能否用 PHP 代码证明“子类/实现类”在任何调用场景下都能无感知替换父类/接口;
  2. 能否把证明过程沉淀为 CI 流水线的一环,让每次 MR 都自动校验;
  3. 能否给出量化指标(比如契约分数、兼容性报告)供架构评审参考。
    如果仅回答“写个单元测试 assert 一下就完事”,会被认为缺乏工程化视角;若能结合 PHPUnit + 契约文件 + 静态分析 + 变异测试,才算达到 P7/P8 级别的“技术深度 + 落地能力”。

知识点

  1. 里氏替换原则(LSP):若 S 是 T 的子类型,则 T 出现的地方都能用 S 替换且行为不变;在 PHP 中表现为:接口实现类、继承类、trait 覆写类都必须满足“前置条件不强于父类,后置条件不弱于父类,异常不新增,不变式不破坏”。
  2. 接口契约:包含前置(参数类型/值域)、后置(返回值类型/值域/异常)、不变式(业务规则)、性能(超时/资源上限)、安全(XSS/SQL 注入过滤)。
  3. 契约测试双轨模型:
    a. 消费者侧测试(Consumer Test):由调用方用 Mock 定义期望请求与响应,生成 pact 文件;
    b. 提供者侧测试(Provider Test):由服务方用真实代码启动 PHP 内置服务器,回放 pact 文件并验证。
  4. PHPUnit 桥接 LSP:借助 PHPUnit 的 @dataProvider 批量注入“父类/子类”实例,同一组断言必须全部通过;配合 PHPUnit\Framework\Constraint\IsEqual 深度比较对象状态。
  5. 静态分析加持:PHPStan level 9 + Psalm 扫描“参数逆变、返回值协变”是否合法;若出现“参数收窄或返回值放宽”直接阻断 MR。
  6. 变异测试(Infection):在子类中强制插入变异因子(如把 > 0 改成 >= 0),若测试用例仍全部通过,则说明测试对 LSP 不敏感,需补场景。
  7. 性能回退检测:使用 phpbench 对比子类与父类在同一基准下的吞吐量,差异 >5% 即视为“行为改变”,打破 LSP 隐含的性能契约。
  8. 国内落地经验:
    • 阿里系:在 GitLab CI 中增加 php artisan test:contract --lsp 命令,失败自动打回;
    • 腾讯系:把 pact 文件上传至内部“契约中心”,每日定时全量回归;
    • 字节系:将 LSP 分数(0~100)写入代码库 README.md,低于 90 分需架构师审批才能发布。

答案

下面给出一套可直接跑进国内 GitLab CI 的“PHP 接口契约 + 里氏替换”验证方案,示例场景为“支付通道”接口,支持支付宝、微信、银联三种实现。

  1. 定义契约(接口)
// app/Contracts/PaymentGateway.php
namespace App\Contracts;

interface PaymentGateway
{
    /**
     * @param positive-int $amount 单位为分
     * @throws PaymentException
     * @return array{trade_no:string, status:string}
     */
    public function pay(int $amount): array;
}
  1. 父类实现(基准行为)
// app/Payments/AbstractPayment.php
abstract class AbstractPayment implements PaymentGateway
{
    public function pay(int $amount): array
    {
        if ($amount <= 0) {
            throw new PaymentException('金额必须为正整数');
        }
        return ['trade_no' => uniqid('T'), 'status' => 'SUCCESS'];
    }
}
  1. 子类实现(需验证 LSP)
// app/Payments/AlipayPayment.php
class AlipayPayment extends AbstractPayment
{
    // 严格满足协变/逆变,不抛新异常,不缩小前置,不放宽后置
}
  1. 统一契约测试用例
// tests/Contract/PaymentGatewayLspTest.php
namespace Tests\Contract;

use App\Contracts\PaymentGateway;
use App\Payments\AlipayPayment;
use App\Payments\WechatPayment;
use App\Payments\UnionPayment;
use PHPUnit\Framework\TestCase;

class PaymentGatewayLspTest extends TestCase
{
    public function provider(): array
    {
        return [
            'alipay'  => [new AlipayPayment()],
            'wechat'  => [new WechatPayment()],
            'union'   => [new UnionPayment()],
        ];
    }

    /**
     * @dataProvider provider
     */
    public function testLspCompatibility(PaymentGateway $gateway): void
    {
        // 1. 前置条件:正整数
        $result = $gateway->pay(100);
        $this->assertArrayHasKey('trade_no', $result);
        $this->assertEquals('SUCCESS', $result['status']);

        // 2. 异常场景:金额非法
        $this->expectException(PaymentException::class);
        $gateway->pay(0);
    }
}
  1. 静态分析脚本(.gitlab-ci.yml 片段)
lsp-check:
  stage: test
  script:
    - vendor/bin/phpstan analyse --level=9 app/Payments
    - vendor/bin/psalm --no-cache
    - vendor/bin/infection --min-msi=90 --filter=app/Payments
    - vendor/bin/phpunit --filter=PaymentGatewayLspTest
  allow_failure: false
  1. 性能回退检测
benchmark:
  stage: performance
  script:
    - phpbench run tests/Benchmark/PaymentBench.php --report=aggregate --threshold=5%
  1. 结果判定
  • 若 PHPUnit 测试红,直接打回;
  • 若 PHPStan/Psalm 报出“参数逆变、返回值协变”错误,直接打回;
  • 若 Infection MSI < 90,说明测试对 LSP 不敏感,需补场景;
  • 若 phpbench 显示任一子类吞吐量差异 >5%,需架构师评审是否接受性能契约放宽。

通过以上五步,就把“里氏替换”从一句口号变成了可量化、可自动化、可审计的国内工程实践。

拓展思考

  1. 多语言异构场景:支付网关可能用 Go 重写,如何让 PHP 的 pact 文件与 Go 的提供者共用一套契约?
    → 把契约文件上传到内部“契约中心”,利用 Protobuf + JSON Schema 做跨语言校验,PHP 侧仍用 pact-php,Go 侧用 pact-go,双方共享同一个 consumer-version_selectors,实现异构 LSP 验证。
  2. 水平扩容后的性能契约:子类在 100 QPS 下符合 LSP,但 1w QPS 下因锁粒度不同导致 RT 暴涨,是否算破坏契约?
    → 引入“阶梯基准”:在 CI 中并行跑 1k/5k/10k QPS 三组基准,RT 与错误率必须落在 SLA 走廊内,否则视为性能 LSP 违规。
  3. 商业合规场景:子类为了符合央行新规,在支付后多了一次加密上报,虽然功能一致,但网络 IO 增加,是否算行为改变?
    → 把“可观测性”写进契约:使用 OpenTelemetry 在链路中埋点,若新增 span 导致下游超时率 >0.1%,即判定为 LSP 隐性破坏,需走灰度审批。