- 后端
- API设计
- 文档
- 代码生成
【免费下载链接】swagger-php
A php swagger annotation and parsing library
swagger-php 的 Spec 流水线(Assembler → Specification → Resolver → Augmenters → Compiler)并非一条写死的链路,而是一组可以替换、追加的扩展点。本文围绕仓库中的 扩展点参考 与 扩展点指南 展开,逐一讲解 Translator、子类化 spec 属性、withSpecification()贡献、Resolver、Augmenter、Merger 与 Compiler 的作用机制、内置默认实现和自定义接入方式,并结合 Builder 源码 与各契约接口说明底层调用关系。读完你既能理解每个钩子何时被调用、以什么顺序被调用,也能直接写出可运行的框架接入代码。
管道总览:每个扩展点在哪里,按什么顺序被触达
官方 Spec 流水线架构 给出的完整链路是:
Source files → Assembler → Specification → Resolver → Augmenters → Compiler → OpenAPI document对应到 Builder::doBuildSpec() 的实现,实际执行顺序是:
- Assembler从反射对象读取属性、解析嵌套关系,把根属性投入
Specification的各个桶(bucket); withSpecification()钩子(若注册)在 Resolver 运行之前拿到已装配的Specification并向其add()属性;- Resolver找出被引用但没有对应组件的 FQCN,交给 resolver 链逐个尝试;
- Augmenters以分组管道形式运行,阶段顺序为resolve → reduce → augment(见 Group 枚举);
- Compiler按目标版本(默认
3.1.0,可由#[OpenApi(version: …)]或setVersion()覆盖)把Specification编译为文档。
扩展点“按管道到达顺序”大致是:Translators(装配期,每个 reflector 一次)→ 子类化属性(静态声明)→withSpecification()贡献(装配后、解析前)→ Resolvers(解析期)→ Augmenters(增强期)→ Mergers(贯穿 reduce 阶段)→ Compiler(输出期)。
Translators:把“非 spec 属性”翻译成 spec 属性
机制与运行时机
Translator 在装配期间运行,每个 reflector 调用一次。契约见 AttributeTranslatorInterface,实现只需回答两个问题:
getAttributes():从该 reflector 上读取哪些原始 PHP 属性;translate():返回装配器应当视为“已声明”的属性列表。
translate()收到两个参数:$created是本次读取到的原始属性实例,$attributes是此前 translator 链已经解析出的累积结果。它可以向其中追加、替换或原样放行,顺序遵循注册顺序。属性工厂会做最后一轮过滤,只保留实现AttributeInterface的实例(见 AttributeFactory::readAttributes())。
每个 translator 还需要实现reset(),装配器在每次顶层收集单元(例如每个类)开始时调用它,用于清理跨层累积的状态。
内置默认 Translator
AttributeFactory 构造时注册了两个默认 translator:
| Translator | 职责 |
|---|---|
| DefaultAttributeTranslator | 默认实现,处理原生 OpenApi 属性:用ReflectionAttribute::IS_INSTANCEOF读取所有实现AttributeInterface的属性 |
| OptionalPropertyAttributeTranslator | 为只写了OA\Schema或OA\Encoding的 schema 属性补上必需的OA\Property |
第二个 translator 正是“隐式OA\Property快捷写法”的实现来源:当ReflectionProperty(或构造函数参数)上只有OA\Schema而无OA\Property时,它在最前面插入一个new OA\Property();只有OA\Encoding时则插入new OA\Property\Encoded()(见 源码第 52-56 行)。OA\MediaType的快捷方式则由Augmenter\Shortcuts增强器负责,OA\Parameter的快捷方式则是普通子类——三者的实现路径各不相同。
自定义 Translator:把框架路由属性变成 Operation
指南 route_attribute.php 给出了一个对 swagger-php 一无所知的框架属性:
#[\Attribute(\Attribute::TARGET_METHOD)] final class Route { public function __construct(public string $path, public string $method = 'get') { } }配套的 route_translator.php 把它翻译成 spec 属性:
final class RouteTranslator implements AttributeTranslatorInterface { public function reset(): void { } public function getAttributes( \ReflectionClass|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter|\ReflectionClassConstant $reflector, ): array { return $reflector->getAttributes(Route::class); } public function translate( array $attributes, array $created, \ReflectionClass|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter|\ReflectionClassConstant $reflector, ): array { foreach ($created as $route) { if ($route instanceof Route) { $attributes[] = new OA\Operation( path: $route->path, method: $route->method, operationId: $reflector->getName(), ); } } return $attributes; } }“把外来属性翻译成 spec 属性”只是 Translator 的一种用法;另一种是补写一个没人写的原生属性——swagger-php 自己就是通过DefaultAttributeTranslator与OptionalPropertyAttributeTranslator这么用的。
注册位置:withAttributeFactory()内嵌withTranslators()
Translator 注册在属性工厂上,所以withTranslators()必须嵌套在withAttributeFactory()之内:
$builder->withAttributeFactory(fn (AttributeFactory $factory) => $factory->withTranslators( fn (TypedList $translators) => $translators->add(new RouteTranslator()) ));注意withAttributeFactory()是立即执行的:它针对 builder 已持有的工厂实例调用回调,重复调用配置的是同一个实例(见 Builder::withAttributeFactory())。
子类化 spec 属性:用反射推导构造函数参数
spec 属性不是final的,可以被子类化。子类通过反射自己推导构造函数参数,把重复样板从被注解的代码里移走。指南 dto_attribute.php 演示了一个继承自OA\Schema的Dto:
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Dto extends OA\Schema { public function __construct(string $of) { $class = new \ReflectionClass($of); parent::__construct( schema: $class->getShortName(), required: array_map( static fn (\ReflectionProperty $property): string => $property->getName(), $class->getProperties(\ReflectionProperty::IS_PUBLIC), ), ); } }于是#[Dto(of: Pet::class)]一次就完成了两件事:把 schema 命名为类短名Pet,并把所有 public 属性标记为required,两处都不用手写。对管道的其余部分而言,这个子类就是一个OA\Schema——合并、嵌套(containment)和编译都一视同仁。
Contributing to the Specification:在解析前把属性“放进”文档
Builder::withSpecification()在Resolver 运行之前把装配好的Specification交给一个 callable(见 Builder 文档)。向它add()的属性会像扫描所得一样走完后续全部管道。指南 contribution.php 演示了从路由表构建文档的场景——没有任何属性可扫描,纯命令式注册:
function buildFromRoutes(array $routes): Result { return (new Builder()) ->setMode(Mode::SPEC) ->addSource(new \ReflectionClass(Pet::class)) ->withSpecification(function (Specification $specification) use ($routes): void { $specification->add(new OA\Info(title: 'Registered routes', version: '1.0.0')); foreach ($routes as $path => $model) { $specification->add(new OA\Operation\Get(path: $path, responses: [ new OA\Response(response: 200, description: 'OK', content: [ new OA\MediaType\Json(schema: new OA\Schema(ref: $model)), ]), ])); } }) ->build(); }产物(contribution-3.1.0.yaml):
openapi: 3.1.0 info: title: 'Registered routes' version: 1.0.0 paths: /pets: get: operationId: a258179ad504a1eacaafc2ef43cbd7ba responses: 200: description: OK content: application/json: schema: $ref: '#/components/schemas/Pet' components: schemas: Pet: type: object properties: name: type: string age: type: integer required: - name - age关键语义有三条:
- 管道读取的是 reflector,不是源文件——属性从哪里来无关紧要。贡献的属性如果携带 reflector,就会被当作装配器自己的属性处理:给了
ReflectionMethod的 Operation 会从方法取 summary、description 和 operationId;给了ReflectionParameter的 Parameter 会取名称、类型和是否必填。一个知道[UserController::class, 'index']对应某条路由的框架,可以把这一切都交接过去。 - 没有任何 reflector 的东西(实体注册表、序列化器配置)只能以“裸”属性形式进来——
withSpecification()是它进入文档的唯一途径。 - Augmenter 也能
add(),但它运行在解析之后,所以它添加的属性里的$ref不会被解析。经验法则:用 Augmenter 丰富已有的内容,用withSpecification()放置新内容。
用setMeta()标记贡献,供下游识别
一旦添加,贡献的属性就和扫描属性没有区别。如果后续步骤需要区分它们——比如 Merger 决定优先级、某个 Augmenter 想跳过它们——就在添加时用setMeta()在你自己拥有的键名下做标记,之后用getMeta()读回。meta按写入者键名隔离,swagger-php 核心不读写它(见 AbstractAttribute::setMeta() 与 AttributeInterface)。
Resolvers:补全被引用但从未收集的类
从 reflector 播种意味着文档可以点名一个从未作为源出现的类:添加一个控制器,它某个$ref指向一个 DTO,而没有任何人收集过这个 DTO。每个这样的名字都会进入 resolver 步骤(Resolver.php 的实现会对未解析 FQCN 循环收敛,上限MAX_ITERATIONS = 50,超限会抛出“resolver loop did not converge”异常)。
默认 Resolver:Resolver\Reflection
Resolver\Reflection 覆盖普通场景:
- 类(含接口、枚举)存在且携带 spec 属性 → 用正在使用的装配器
collect()收集它,引用即解析; - 类完全不存在(
class_exists/interface_exists/enum_exists均失败)→ 返回false,交给链上下一个 resolver; - 类收集后没有产出任何组件→ 同样返回
false,未解析的引用被报告而非静默丢弃。
“FQCN 视为已解析”的定义是:收集后buildComponentIndex()->find($fqcn)能找到一个AttributeInterface实例(见 源码第 30-33 行)。这带来一个实用收益:把相关类全部列为 builder 源变成可选的——只要被引用链上的类都携带 spec 属性,只添加一个控制器就够。
注册自定义 Resolver
Builder::withResolver()把 resolver 列表交给 callable。resolver 按顺序尝试,第一个成功者获胜,所以在默认 resolver 之后添加的 resolver,接手的是默认 resolver 未能解析的类。insert()则把新 resolver 放到默认项之前。
一个常见的自定义场景:从类的 public 属性和 PHP 类型推导 schema,让不带任何 spec 属性的 DTO 也能出现在文档里。契约极简(ResolverInterface):
interface ResolverInterface { public function resolve(string $fqcn, Assembler $assembler): bool; }resolver 拿到的是构建本 specification 的那个Assembler,用其collect()收集 reflector 会把结果直接并入当前 specification;返回true表示已处理,停止该 FQCN 的链。
Augmenters:装配后对整棵 Specification 的增强
Augmenter 在装配之后、对整个Specification运行,负责补齐属性里没写的内容。它不强制实现任何接口——任何接收Specification并返回它的 callable 都可以;PipeInterface只是用来声明阶段,不实现它的管道会落入默认组(augment)。
阶段:resolve → reduce → augment
Builder 用 Augmenter\Group 枚举给管道分三组:
| 阶段 | 用途 |
|---|---|
| Resolve | 从 PHP 反射和跨桶关系推导数据 |
| Reduce | 过滤或移除条目 |
| Augment | 追加派生元数据 |
同一阶段内的管道按注册顺序运行(见 Pipeline::ordered() 的分桶实现)。内置管道的完整清单与每个阶段的用途见 Augmenters 参考;Builder::getDefaultAugmenters() 里可以看到内置管道是从Inheritance到EnumDescriptions的完整列表,且最后还追加了第二个Augmenter\Merge,把迟注册增强器新增的内容也做一次归约。
一个最小自定义 Augmenter
指南 tag_augmenter.php 演示了按控制器给操作打 tag:
final class TagFromController implements PipeInterface { public function __invoke(mixed $specification): mixed { foreach ($specification->operations as $operation) { $reflector = $operation->getReflector(); if ($reflector instanceof \ReflectionMethod) { $controller = $reflector->getDeclaringClass()->getShortName(); $operation->tags ??= [preg_replace('/Controller$/', '', $controller) . 's']; } } return $specification; } public function group(): string|\BackedEnum { return Group::Augment; } }注册方式:withAugmenters(fn (Pipeline $augmenters) => $augmenters->add(new TagFromController()))。withAugmenters()同样针对 builder 已持有的同一管道实例执行。
Specification 提供的三个视图
Specification(源码)是扁平容器,每个根属性类型一个桶:openapi、info、servers、tags、pathItems、operations、schemas、responses、parameters、requestBodies、headers、securitySchemes、links、examples、mediaTypes、attachables。Augmenter 需要的三个视图都从这里拿:
getWalker():遍历整棵装配树;buildComponentIndex():解析某个$ref对应的组件;buildPathItemHierarchy():找出管辖某个类的PathItem层级。
路径级元数据必须通过层级读取:一个类如果没有自己的PathItem,它就受祖先类的PathItem管辖——直接看类本身的属性会漏掉。
Mergers:决定同一键下的两个属性谁活下来
问题背景
两个属性可能争抢同一个键:同一路径同一方法的两个 Operation、两个都叫Pet的 schema——扫描找到一个、withSpecification()钩子贡献另一个、继承克隆造出第三个。在 Merger 介入之前,Augmenter\Merge会先把每个根桶归约到“每键一项”(Augmenter/Merge.php),否则编译器只能“碰巧”保留最后写入的那一个。
Merger 契约(MergerInterface)由三个方法组成:
supports(string $class): bool——按注册顺序被询问,先声明类型的先处理;identity(AttributeInterface $attribute): ?string——什么让两个属性“是同一个”;返回null表示该属性永不合并、直接放行;merge(AttributeInterface $earlier, AttributeInterface $later): AttributeInterface——幸存者是谁。
$earlier与$later按生产者顺序排列,这是管道唯一保证的顺序,所以return $later就是 last-wins。超出该顺序的优先级是核心不持有的策略,由注册的 merger 自行决定。
默认 Merger:Merge\LastWins(万能兜底)
LastWins 是兜底 merger:supports()恒为true,每键存活一个、保留后者,并通过日志警告两个来源位置。它做的是归约而非折叠:两个进、一个出,编译器永远看不到冲突,它自己的偶然规则也就不再参与决策。把两个属性的字段合并起来是类型专属 merger 的职责,要注册在它前面。
身份判定由共享的 IdentityTrait 定义,按集合归属:
| 属性类型 | 身份键 |
|---|---|
| Operation | 路径 + 方法,或 webhook + 方法 |
| components 桶里的组件 | 组件键(component:…) |
| 路径绑定的 PathItem | 路径(path:…) |
| Tag | 名称(tag:…) |
| Servers、安全要求、外部文档 | 无身份(按位置排列,重复合法,直接放行) |
Builder 的默认 merger 列表只有[LastWins](getDefaultMergers());测试 OperationsTest::testIsNotADefaultMerger() 验证了Merge\Operations不在默认列表里。
自定义 Merger:识别自己的属性并决定优先级
指南给出了一个完整示例:扫描已描述的操作,对抗一个由钩子贡献并标记为“自己的”操作时,保留前者:
use OpenApi\Contracts\AttributeInterface; use OpenApi\Contracts\MergerInterface; use OpenApi\Spec as OA; final class MyOperationMerger implements MergerInterface { public function supports(string $class): bool { return is_a($class, OA\Operation::class, true); } public function identity(AttributeInterface $attribute): ?string { return $attribute->path !== null && $attribute->method !== null ? $attribute->method . ' ' . $attribute->path : null; } public function merge(AttributeInterface $earlier, AttributeInterface $later): AttributeInterface { return $later->getMeta(self::class, false) ? $earlier : $later; } }注册:把它插到兜底 merger之前,因为链上第一个声明处理某类型的 merger 负责该类型,而LastWins声称处理一切、必须垫底:
use OpenApi\Merge; use OpenApi\Utils\TypedList; $builder->withMergers(fn (TypedList $mergers) => $mergers->insert( new MyOperationMerger(), Merge\LastWins::class, ));“身份与折叠同居一对象”是有意设计:消费者对自有类型换一种键控方式时,只需改动一处。需要识别自己产物的包,在创建属性时用setMeta(MyOperationMerger::class, true)打标,在merge()里读回即可;核心既不写也不读这些 meta。
可选 Merger:Merge\Operations字段级折叠
Merge\Operations(源码)是可选的:不是 Builder 默认 merger。当同一个路径和方法的操作被多处描述(例如withSpecification()贡献的路由表 + 属性补充的 responses)时,把它注册到LastWins之前,就能逐字段折叠两个操作而非整体二选一:
$builder->withMergers(fn ($mergers) => $mergers->insert(new Merge\Operations(), Merge\LastWins::class));身份沿用兜底 merger 的(路径+方法或 webhook+方法)——注册折叠改变的是两个操作如何合并,而不是哪两个是同一个。折叠规则(与参考文档一致,测试 OperationsTest.php 逐条验证):
- 只有一方设置的字段总是被采用;
- 双方都设置的字段由构造时的
Mode决定(默认Mode::Last):折进后者、折进前者,或只取不重叠的部分;Mode也可以是接收两者并返回一个的 callable,让选择跟随生产者用setMeta()留下的标记而非到达顺序; - 字段冲突会被报告(记录被保留的值),双方设置相同的不会;
parameters按“名称 + 位置(in)”键控,responses按状态码键控;在First/Last下,双方都描述的参数或响应会向内折叠一层(参数连同其schema),因此路由的pattern能保留下来对抗属性的type;在NonOverlapping下则整体取前者的;requestBody、externalDocs、security、servers、callbacks各是一个整体决策,从模式保留的那一方整取;显式security: []会被设置并构成“退出”;tags与attachables取并集,x(扩展字段)在相同模式下逐键合并;- 幸存者是一个新 Operation:前者的副本再折入后者,两边的贡献都不被改动;它保留前者的 reflector、源码位置和
meta,前者没有 reflector 时回退到后者的。
配置项(参考文档明确给出):
| 设置 | 类型 | 默认值 | 说明 |
|---|---|---|---|
mode | OpenApi\Merge\Mode\|callable | Mode::Last | 同一身份的属性重叠时的处理方式:一个Mode,或一个接收 earlier/later 并返回一个的 callable |
Merge\Mode枚举的三个取值(Mode.php):First(冲突字段保留前者)、Last(保留后者,默认)、NonOverlapping(只取独有部分,双方都设的字段保留前者且不折叠)。用 callable 按对选模式,是“生产者打标决定优先级”的免写 merger 方案:
use OpenApi\Contracts\AttributeInterface; use OpenApi\Merge; new Merge\Operations( fn (AttributeInterface $earlier, AttributeInterface $later): Merge\Mode => $later->getMeta(MyProducer::class) ? Merge\Mode::First : Merge\Mode::Last, );一个细节:只含ref的参数能与同名同位置的 inline 参数折叠,因为Parameters增强器会先给组件参数补上名称和位置,折叠后存活的是那个引用。
合并通过Augmenter\Merge执行
合并这一遍由 Augmenter\Merge 执行,它被注册两次:第一次作为reduce 阶段的首个管道(此时所有身份都已存在——PathItems在 resolve 阶段解析了路径,Names推断出组件键),第二次作为最后一个管道(把迟增强器新增的内容也归约一遍)。Augmenter\Merge::reduce()只处理根集合——键冲突来自不同来源(扫描、钩子、resolver、继承克隆)的地方;同一属性内部的两个重复键(如一个操作里两个200响应)是同一作者同一处写的两条记录,留给编译器做 last-write-wins 并报告。
Compilers:替换输出编译器
Builder::setCompiler()替换把Specification变成文档的编译器。否则编译器会从目标版本自动解析——resolveCompiler() 依次询问OpenApi30Compiler、OpenApi31Compiler、OpenApi32Compiler,都不支持就抛出No compiler available for version '...'。因此这个钩子只服务于“出厂编译器产不出的输出”。
Classic 逃生舱:withGenerator()
Builder::withGenerator()配置经典Generator:在 classic 模式下它是整条管道,在 hybrid 模式下它是扫描那一遍。callable 收到一个默认Generator,可以就地配置或返回另一个。Spec 模式没有Generator,该钩子永远不会被调用。
hybrid 模式下,Generator到达时带着空的处理器管道——在这里添加的 processor 是唯一会运行的,且它看到的是“刚扫描完”的注解:属性还没挂到 schema 上,继承也还没产生任何allOf。桥接(HybridBridge)和 augmenters 之后才做这些工作(见 doHybridAssemble())。所以:只读取或改动作者所写内容的 processor 在 hybrid 下可用;依赖上述结构的 processor 应该写成 augmenter。
组合示例:一个完整的定制构建
指南 wiring.php 把上面的RouteTranslator与TagFromController装配到一个以 reflector 而非目录为源的 builder 上:
function buildSpec(): Result { return (new Builder()) ->setMode(Mode::SPEC) ->addSource(new \ReflectionClass(PetController::class)) ->withAttributeFactory(fn (AttributeFactory $factory) => $factory->withTranslators( fn (TypedList $translators) => $translators->add(new RouteTranslator()) )) ->withAugmenters(fn (Pipeline $augmenters) => $augmenters->add(new TagFromController())) ->build(); }给定携带#[Route(path: '/pets')]与#[OA\Response]的控制器(pet_controller.php),产物(wiring-3.1.0.yaml):
openapi: 3.1.0 info: title: Pets version: '1.0' paths: /pets: get: tags: - Pets operationId: list responses: 200: description: OKoperationId: list来自 Translator 用$reflector->getName()生成,tags: [Pets]来自TagFromController增强器对PetController类名的处理,path: /pets来自框架的Route属性——三个扩展点各司其职。
边界:哪些地方刻意不是扩展点
指南最后一节划定了边界,每一条都有理由和替代方案:
- 属性类型不会为下游便利而放宽。强类型正是 DTO 的价值所在。只对某个集成有意义的元数据,若在源码中与被描述属性相邻声明就放
Attachable,若由代码沿途附加就放setMeta()——两者都不会进入生成的文档,也不该放进放宽后的$ref: string|object。 - 没有框架专属代码,也没有此计划。Translators、贡献、augmenters 和 attachables 就是契约;框架需要的一切都可以基于它们在本仓库之外构建。
- 没有事件、没有监听器。管道是确定性的、自上而下顺序读取,这保证一个错误文档能追溯到产出它的步骤。事件顺序读代码看不出来,也更难测试。
- 装配器内部保持内部。
collect()是契约;它如何解析嵌套可以自由变化,也确实变化过。
延伸阅读
- Builder 参考:每个钩子的独立说明与签名
- Augmenters 参考:内置管道与其阶段
- 架构文档:各阶段如何拼装
- Spec 管道内部:slot map、解析过程,以及新属性必须遵循的规则
- 各默认实现的直接源码:
DefaultAttributeTranslator与OptionalPropertyAttributeTranslator在 src/Assembler/、Resolver\Reflection在 src/Resolver/Reflection.php、LastWins与Operations在 src/Merge/、Augmenter\Merge在 src/Augmenter/Merge.php,合并行为有 tests/Merge/OperationsTest.php 与 tests/Augmenter/MergeTest.php 逐条验证。
- 后端
- API设计
- 文档
- 代码生成
【免费下载链接】swagger-php
A php swagger annotation and parsing library
相关推荐
swagger-php Spec Attributes 完全指南:OpenApi\Spec 类型化属性与 spec 流水线实战
swagger php Spec Attributes 完全指南:OpenApi\Spec 类型化属性与 spec 流水线实战 导读 本文聚焦 swagger
后端API设计文档代码生成Tiny RDM连接Redis Sentinel哨兵模式与Cluster集群模式:高可用部署实战指南
Tiny RDM连接Redis Sentinel哨兵模式与Cluster集群模式:高可用部署实战指南 Tiny RDM(Tiny Redis Desktop M
后端API设计文档代码生成DeepSpeed 流水线并行深度指南:PipelineModule、PipelineEngine 与可扩展调度器全解析
DeepSpeed 流水线并行深度指南:PipelineModule、PipelineEngine 与可扩展调度器全解析 流水线并行(Pipeline Para
推理引擎大模型
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考