Monorepo 共享 DTO 校验

解读

在国内一线互联网公司的 PHP 面试中,Monorepo 已不再是前端专属话题。随着微服务、BFF、SaaS 多租户架构的流行,后端团队也把多个业务线(商城、支付、运营后台、开放 API)放进同一个 Git 仓库统一管理。此时“共享 DTO(Data Transfer Object)”成为刚需:订单服务、库存服务、Admin 后台、小程序 BFF 都要复用同一套“创建订单”的字段定义与校验规则。如果各服务各写一套 Validator,就会出现字段命名不一致、规则漂移、联调失败、线上脏数据等问题。面试官问“Monorepo 共享 DTO 校验”,核心想考察:

  1. 你是否理解 Monorepo 的代码边界与依赖治理;
  2. 能否用 PHP 生态工具把“定义一次、多处复用”落地;
  3. 在高并发场景下如何保证校验性能与版本兼容。

知识点

  1. Monorepo 目录规范
    • /packages 下放可复用的子模块(私有 Composer 包)
    • /services 下放真正可独立部署的微服务
    • /proto/contracts 放 IDL、OpenAPI、DTO 定义
  2. Composer Path Repository 利用 "type":"path" 做零发布(symlink)依赖,CI 中再转 "type":"vcs" 打 tag 版本
  3. DTO 与 Validator 分层
    • DTO 只做“形状”定义(属性、类型、注释)
    • Validator 只做“规则”定义(必填、范围、正则、业务断言) 两者通过 Attribute 或 XML 映射解耦,方便不同服务按需叠加规则
  4. Symfony Validation & PHP 8 Attribute 用 #[Assert\...] 把规则内聚到 DTO,支持序列化缓存,OPcache 预热后无反射损耗
  5. 版本兼容策略
    • 向后兼容:新增字段必须 nullable 或 default
    • 向前兼容:旧服务忽略未知字段,用 additionalProperties=false 兜底
  6. CI 约束
    • 每次 MR 触发 roave/backward-compatibility-check
    • 变更 DTO 必须同步生成 OpenAPI 文档并通知下游
  7. 性能兜底
    • 预编译 Validator Mapping,生产环境关闭 auto-mapping
    • 对热点接口使用 symfony/cache + APCu 缓存校验元数据
    • 大数组批量校验走 symfony/validatorGroupSequenceProvider,避免一次性反射全部属性

答案

落地步骤(可直接在面试中画图口述,时间控制在 5 分钟):

  1. 仓库目录
monorepo/
├─ packages/
│  └─ dto-order/
│     ├─ composer.json   (name: "company/dto-order")
│     ├─ src/
│     │  └─ CreateOrderDTO.php
│     └─ tests/
├─ services/
│  ├─ order-service/
│  ├─ stock-service/
│  └─ open-api/
└─ composer.json  (root)
  1. 定义共享 DTO(PHP 8 风格)
<?php
declare(strict_types=1);

namespace Company\DtoOrder;

use Symfony\Component\Validator\Constraints as Assert;

final class CreateOrderDTO
{
    #[Assert\NotBlank]
    #[Assert\Positive]
    public int $userId;

    #[Assert\Valid]
    #[Assert\Type("array")]
    public array $items;

    #[Assert\PositiveOrZero]
    public int $couponAmount = 0;

    public ?string $remark = null;
}
  1. 把 dto-order 声明为私有包 packages/dto-order/composer.json
{
  "name": "company/dto-order",
  "type": "library",
  "require": {
    "php": ">=8.1",
    "symfony/validator": "^6.3",
    "symfony/serializer": "^6.3"
  },
  "autoload": {
    "psr-4": {"Company\\DtoOrder\\": "src/"}
  }
}
  1. 各服务引用 services/order-service/composer.json
{
  "repositories": [
    {"type": "path", "url": "../../packages/dto-order"}
  ],
  "require": {
    "company/dto-order": "@dev"
  }
}

本地开发用 symlink,CI 打包时自动替换为 tag 版本,保证回滚能力。

  1. 统一校验入口
use Company\DtoOrder\CreateOrderDTO;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class OrderController
{
    public function __construct(
        private ValidatorInterface $validator,
        private SerializerInterface $serializer
    ) {}

    public function create(Request $req): JsonResponse
    {
        /** @var CreateOrderDTO $dto */
        $dto = $this->serializer->deserialize(
            $req->getContent(),
            CreateOrderDTO::class,
            'json'
        );

        $errors = $this->validator->validate($dto);
        if (count($errors) > 0) {
            return new JsonResponse(['msg' => (string)$errors], 400);
        }

        // 业务逻辑
    }
}
  1. 版本演进示例 v1.0.0 字段:userId、items、couponAmount
    v1.1.0 新增 payMethod 字段,默认 null,老服务无需改动;
    在 CI 中通过 composer require company/dto-order:^1.0 约束,各服务按需升级。

  2. 性能优化

  • 生产环境启用 opcache.validate_timestamps=0
  • Validator 元数据提前 warm-up:php bin/console cache:clear --env=prod
  • 对 2w+ 订单导出的批量接口,使用 GroupSequence 分步校验,先校验格式,再校验库存,降低 30% CPU

拓展思考

  1. 如果团队同时存在 PHP、Go、Node 三种语言,怎样让 DTO 定义“一处编写,三端生成”? 国内主流做法是用 Protobuf 或 JSON Schema 作为真源,CI 中通过 buf 工具生成 PHP 类、Go struct、TypeScript interface,再各自用原生校验器(PHP 用 Symfony Validation,Go 用 protovalidate,Node 用 ajv)。此时 PHP 包仅作为“客户端”消费,避免手工维护。

  2. 多租户场景下,不同租户对同一 DTO 字段的校验规则不一样(例如 A 租户商品单价不得低于 100 元,B 租户不得低于 50 元),如何在共享 DTO 的前提下实现? 把“租户规则”抽成动态配置,存入 Redis 或 Apollo,在 Validator 中自定义 TenantAwareConstraintValidator,运行时读取当前租户配置,再决定是否追加 Range 约束。这样 DTO 形状不变,规则可热更新。

  3. Monorepo 越来越大,Composer 安装耗时 5 分钟,如何提速?

    • 使用 bamarni/composer-bin-plugin 把工具依赖与业务依赖隔离;
    • CI 缓存 /vendor/packages/*/vendor
    • 对只改前端的服务,使用 composer install --prefer-dist --no-dev --apcu-autoloader 并行安装;
    • 终极方案:把子包拆到私有 Satis 仓库,Monorepo 只保留源码,CI 阶段再 composer merge
  4. 如果公司采用 Laravel 而非 Symfony,如何等价落地? Laravel 9+ 已支持 #[Rule] 属性,可自定义 FormRequest 继承 Illuminate\Foundation\Http\FormRequest,在 rules() 方法里返回数组即可。共享 DTO 可放在 /packages/dto 并用 laravel-package-discovery 自动注册。性能方面,Laravel 的 Validator 底层同样支持缓存解析结果,配合 opcache 无压力。

把以上四点展开,就能在面试里把“Monorepo 共享 DTO 校验”从“会用 Composer 路径包”聊到“跨语言、多租户、高性能、持续交付”的架构高度,稳稳拿到加分。