我不得不自己构建 Symfony validation bundle,因为没有现有的满足我的需求
Source: Dev.to
Contents
长话短说
我创建了一个使用 JSON Schema 的请求验证捆绑包,因为没有现有的“schema‑first”验证器满足我的需求。
现在我只需将一个 JSON 文件附加到路由,即可一次性获得所有功能:验证、DTO 映射以及基于单一真实来源的 OpenAPI 文档。
- Repo:
- Docs:
Source: …
问题
大多数能够从代码生成 API 文档的验证方案(在 Symfony 领域我主要指 FOSRestBundle 和 API Platform)假设你的业务逻辑是:
- 定义明确且相对稳定
- 接近经典的 CRUD 模型(或带有小幅偏差的 CRUD)
- 通过你完全控制的干净、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 和任意输入数据,验证数据是否符合模式,并且:
- 当所有内容都有效时返回数据,或
- 返回结构化的验证错误列表。
在此基础上,剩下的主要是集成工作:
- 让它方便地插入到 Symfony 项目中。
- 将其接入请求生命周期。
- 在需要时提供将已验证数据映射到 DTO 的方式。
- 与 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)