我不得不自己构建 Symfony validation bundle,因为没有现有的满足我的需求

发布: (2026年3月12日 GMT+8 06:02)
10 分钟阅读
原文: Dev.to

Source: Dev.to

Contents

长话短说

我创建了一个使用 JSON Schema 的请求验证捆绑包,因为没有现有的“schema‑first”验证器满足我的需求。
现在我只需将一个 JSON 文件附加到路由,即可一次性获得所有功能:验证、DTO 映射以及基于单一真实来源的 OpenAPI 文档。

  • Repo:
  • Docs:

Source:

问题

大多数能够从代码生成 API 文档的验证方案(在 Symfony 领域我主要指 FOSRestBundleAPI Platform)假设你的业务逻辑是:

  1. 定义明确且相对稳定
  2. 接近经典的 CRUD 模型(或带有小幅偏差的 CRUD)
  3. 通过你完全控制的干净、REST 风格的端点暴露

换句话说,它们假设 你的应用定义了契约,外部世界需要适配它。

在许多实际项目中情况恰恰相反:API 契约在别处定义(遗留前端、外部系统、合作伙伴),你必须适配该契约。这就是问题产生的地方。

示例负载

{
  "type": "company",
  "user": {
    "name": "John",
    "email": "john@example.com",
    "company": {
      "name": "Acme"
    }
  }
}

验证规则

  • 如果 type = "company",则 user.company.name 为必填。
  • 如果 type = "person",则 user.company 必须不存在。

这算是最优雅的 API 设计吗?可能不是。但想象一下有 2 000 名员工的公司,且前端是几年前写的,恰好发送这种结构的 JSON。你不能仅仅因为不喜欢 JSON 的形状就重新设计一切。

“理想”的 Symfony 验证(不适用于条件规则)

class UserDto
{
    #[Assert\NotBlank]
    public string $name;

    #[Assert\Valid]
    public ?CompanyDto $company;
}

无法 表达条件逻辑 “当 type = company 时,company.name 为必填”。要实现它,通常会采用以下方式之一:

  • 使用自定义约束并配套自己的验证器。
  • 在控制器或服务中编写手动验证逻辑。
  • 使用自定义的 normalizer/denormalizer 并加入额外检查。

额外属性处理

你还需要禁止 UserDto 中未定义的属性。在较新版本的 Symfony 中可以这样写:

#[MapRequestPayload(
    serializationContext: ['allow_extra_attributes' => false]
)]
#[MapQueryString(
    serializationContext: ['allow_extra_attributes' => false]
)]

这是否适用于查询参数取决于具体的类型和上下文。对于请求头,这种做法 根本不起作用

为什么要严格?
忽略未知参数可能导致细微的错误。例如,查询参数 offset 被改名为 page,仍然发送 offset 的客户端会得到错误的页面,且问题可能难以追踪。使用严格验证时,客户端会立即收到关于未知参数的明确错误,使问题立刻可见。

清晰、精确的错误信息是 API 用户体验的一部分:客户端应当准确知道出了什么问题,后端团队也能因此减少支持工单和猜测原因的时间。

思路

我需要的这种验证已经存在多年,只是它并不是以典型的 Symfony 验证器形式出现。我指的是 JSON Schema 标准:

JSON Schema 是一种声明式语言,用于定义 JSON 数据的结构和约束。它正是为下面这类问题(以及更复杂的情况)而设计的:

  • 基于其他字段的条件规则。
  • 嵌套、深层结构化的数据。
  • 对允许和禁止的属性进行严格控制。

想法: 与其把 API 合约强行映射到 DTO 类和注解中,不如让 Symfony 根据完整描述合约的 JSON Schema 来验证传入的请求。换句话说,使 Symfony 的请求验证 先于模式(schema‑first),以 JSON Schema 作为唯一的真实来源。

解决方案

好消息是,我不需要自己实现 JSON Schema。已经有一个成熟的 PHP 实现:

该库接受有效的 JSON Schema 和任意输入数据,验证数据是否符合模式,并且:

  • 当所有内容都有效时返回数据,或
  • 返回结构化的验证错误列表。

在此基础上,剩下的主要是集成工作:

  1. 让它方便地插入到 Symfony 项目中。
  2. 将其接入请求生命周期。
  3. 在需要时提供将已验证数据映射到 DTO 的方式。
  4. Nelmio 集成,以便从相同的 schema 生成 OpenAPI 文档。

下面我将展示几个简短的示例。

快速示例

架构

{
    "$schema": "https://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "query": {
            "type": "object",
            "properties": {},
            "additionalProperties": true
        },
        "headers": {
            "type": "object",
            "properties": {
                "authorization": {
                    "type": "string",
                    "description": "Bearer token for authentication",
                    "pattern": "^Bearer .+",
                    "example": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ6..."
                },
                "x-api-version": {
                    "type": "string",
                    "description": "API version",
                    "enum": ["v1", "v2"],
                    "example": "v1"
                },
                "content-type": {
                    "type": "string",
                    "description": "Request content type",
                    "enum": ["application/json"],
                    "example": "application/json"
                }
            },
            "additionalProperties": true
        },
        "body": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 100,
                    "description": "User's full name",
                    "example": "Jane Smith"
                },
                "email": {
                    "type": "string",
                    "format": "email",
                    "description": "User's email address",
                    "example": "john.doe@example.com"
                },
                "age": {
                    "type": "integer",
                    "minimum": 21,
                    "maximum": 100,
                    "description": "User's age (optional)",
                    "example": 30
                }
            },
            "required": ["name", "email"],
            "additionalProperties": false
        }
    }
}

示例 1 – 使用内置请求对象进行验证

#[OA\Post(
    operationId: 'validateUser',
    summary: 'Validate user',
)]
#[Route('/user', name: '_example_validation_user', methods: ['POST'])]
public function validateUser(
    #[MapRequest('./user-create.json')] ValidatedRequest $request
): JsonResponse {
    $payload = $request->getPayload();
    $body    = $payload->getBody();

    return $this->json([
        'success' => true,
        'message' => 'User data is valid',
        'data'    => [
            'name'  => $body->name,
            'email' => $body->email,
            'age'   => $body->age ?? null,
        ],
        'example' => 'This uses ValidatedRequest (standard way)',
    ], 200);
}

示例 2 – 使用自定义 DTO 进行验证(通过 ValidatedDtoInterface

#[OA\Post(
    operationId: 'createProfile',
    summary: 'Create profile',
)]
#[Route('/profile', name: '_example_validation_profile', methods: ['POST'])]
public function createProfile(
    #[MapRequest('./user-create.json')] UserApiDtoRequest $profile
): JsonResponse {
    return $this->json([
        'success' => true,
        'message' => 'Profile created successfully',
        'profile' => [
            'name'  => $profile->name,
            'email' => $profile->email,
            'age'   => $profile->age,
        ],
        'note' => sprintf(
            'This demonstrates DTO auto‑injection: MapRequestResolver calls %s::fromPayload() automatically',
            UserApiDtoRequest::class
        ),
    ], 201);
}

结果如何?

  • 专注的解决方案 – 与其重新发明轮子,该 bundle 填补了现有 Symfony 工具未能很好覆盖的特定空白(基于 schema 的请求验证)。
  • 减少重复 – 你不再需要在 DTO 约束、控制器和 OpenAPI 注解中重复相同的规则。
  • 自动同步 – Nelmio 从用于验证的同一 JSON Schema 文件生成文档,因此你的文档始终与实际行为匹配。
  • 以契约为中心的设计 – 整个 API 契约存放在干净的 JSON 文件中,而不是分散在 PHP 属性和类中。

如果你需要一种在不创建大量冗余 DTO 类和注解的情况下验证请求的方式,这个 bundle 正是为此场景而设计的。

  • Repo:
  • Docs:

完整故事

这篇文章是故事的简短、聚焦版:介绍该 bundle 的功能以及如何开始使用。如果你想阅读包含所有细节和坑点的完整版本,我另写了一篇更长的帖子。

在那篇帖子中,我会讲述:

  • 该 bundle 是如何在一个非常混乱的真实项目中诞生的,而不是在一个全新示例中
  • 为什么传统的 DTO + Assertions 验证在 500 + 条路由后无法继续使用
  • 如何让 JSON Schema 成为后端、前端和文档的唯一契约语言
  • 该 bundle 如何把 Symfony、Opis JSON Schema 和 Nelmio 粘合在一起

你可以在这里阅读完整的故事(从 完整故事 部分开始):

[thing#the-full-story](https://outcomer.hashnode.dev/symfony-bundle-that-validates-anything-and-everything#the-full-story)
0 浏览
Back to Blog

相关文章

阅读更多 »