代码即文档:如何用 PHP 精准炼制 OpenAPI 药剂
各位好!欢迎来到今天的“代码之魂”特别讲座。
今天我们要聊一个几乎所有资深开发者的噩梦,也是一个让产品经理和后端在深夜里互殴的永恒话题——文档。
我们不需要再假装不知道发生了什么。当你改了一个接口,返回值变了;当你加了一个参数,忘记改文档;当你把 GET 改成 POST,Swagger 页面还是绿的。这就像是你在厨房炒菜,告诉朋友“菜在锅里”,朋友却不知道火开大了没有,油盐放没放。
今天,我要教大家如何用 PHP 写一个“自动化文档生成器”。这不仅仅是写代码,这是在给代码穿上盔甲,给接口戴上项链。我们将利用 PHP 强大的反射机制,像透视眼一样看穿你的代码,把那些藏在 /** ... */ 里的秘密,翻译成标准的 OpenAPI (Swagger) 规范,最后生成一个美得像艺术品一样的交互式文档界面。
准备好你们的键盘,我们要开始炼丹了!
第一部分:直面“文档屎山”
首先,让我们把心态摆正。为什么我们要写这个系统?
想象一下,你维护着这样一个 API:
- 控制器里有 50 个方法。
- 每个方法都有 3 到 5 个参数。
- 每个参数的类型五花八门:
string,int,DateTime,UserDTO,array<Product>,NullableBool。 - 还有一些奇怪的校验规则:
@Max(100),@MinLength(3),@Email。
然后,你需要手写一份 OpenAPI 文档。这意味着你要在 paths 下复制粘贴 50 次结构,然后在 components.schemas 里定义 20 个 DTO。一旦业务逻辑变动,你改代码的时候,手抖了一下,忘了改文档。一周后,前端找你:“兄弟,这个接口报错了,文档上明明写着必填,你这里没填啊!”你:“……”
这就是我们要解决的核心痛点:代码与文档的分裂。
我们要构建的系统核心思想是:代码是唯一的真理来源。 只要代码在,文档就应该自动生成。文档就是代码的“尸检报告”或者“体检报告”,它不应该是个独立的存在,它应该是代码的延伸。
第二部分:PHP 反射——给你的代码装上 X 光机
要实现这个目标,PHP 提供了一个强大的工具,叫反射。
你可以把反射想象成一种魔法。普通的函数调用是“我调用你”,而反射是“我看着你内部的一切,包括你长什么样(方法名、参数类型),你肚子里有什么(属性、注释)”。
我们的工具包里主要有两个武器:
ReflectionClass:用来窥探类本身。ReflectionMethod和ReflectionParameter:用来窥探类里的方法和参数。
让我们来写一段代码,定义一个典型的控制器。注意看,我在这里埋下了“伏笔”。
<?php
declare(strict_types=1);
namespace AppController;
use SymfonyComponentHttpFoundationJsonResponse;
/**
* 用户控制器
*/
final class UserController
{
/**
* 获取用户列表
*
* @Route(path="/api/v1/users", methods={"GET"})
* @OperationSummary("获取所有用户")
*
* @param int $page 页码
* @param int $limit 每页数量
* @return JsonResponse
*/
public function list(int $page = 1, int $limit = 10): JsonResponse
{
// 模拟数据
return new JsonResponse([
'data' => [],
'page' => $page,
'limit' => $limit
]);
}
/**
* 创建新用户
*
* @Route(path="/api/v1/users", methods={"POST"})
* @OperationSummary("创建用户")
*
* @param UserCreateDTO $user 用户数据
* @return JsonResponse
*/
public function create(UserCreateDTO $user): JsonResponse
{
return new JsonResponse(['id' => 1, 'status' => 'created']);
}
}
这段代码本身很普通,甚至有点无聊。但注意那个 @Route 和 @OperationSummary 注释。在普通的 PHP 运行中,这些注释一文不值,它们只是字符串。
但是,当我们用 ReflectionClass 打开这个类时,这些字符串就会变成我们可以分析的数据。这就是我们要构建的系统的基石。
第三部分:编写扫描器——游走在文件系统之间
现在,我们要写一个命令行工具来扫描目录。想象一下,我们有一个 Scanner 类,它拿着手电筒,在项目的 src/Controller 目录里四处乱逛。
<?php
namespace AppScanner;
use AppAnnotationOperation; // 假设我们有一个简单的注解类
use AppAnnotationRoute;
use ReflectionClass;
use ReflectionMethod;
use ReflectionParameter;
class ControllerScanner
{
/**
* 扫描目录下所有控制器
*
* @param string $directory
* @return array
*/
public function scan(string $directory): array
{
$files = glob($directory . '/*.php');
$controllers = [];
foreach ($files as $file) {
// 去掉路径,只留类名
$className = str_replace([$directory . '/', '.php'], '', $file);
$className = str_replace('/', '\', $className);
try {
$reflectionClass = new ReflectionClass($className);
if ($reflectionClass->isInstantiable() && $reflectionClass->isSubclassOf('SymfonyBundleFrameworkBundleControllerAbstractController')) {
$controllers[] = $reflectionClass;
}
} catch (ReflectionException $e) {
// 忽略不存在的类
}
}
return $controllers;
}
}
这里的逻辑很简单:glob 找文件,new ReflectionClass 找类。就像在超市里找你最喜欢的零食一样,只要包装袋上有标签,我们就能把它拿下来。
第四部分:解析语义——从注释中提炼金矿
有了反射对象,我们下一步就是提取信息。这是最核心的部分。我们需要遍历每个方法,看看它是不是一个 API 端点。
我们需要一个解析器,它需要读取方法上的 DocBlock(注释块),然后提取出 @Route 里的 URL 和 HTTP 方法,以及 @OperationSummary 里的描述。
<?php
class OpenApiParser
{
/**
* 解析单个方法生成 OpenAPI 路径定义
*/
public function parseMethod(ReflectionMethod $method): ?array
{
$docComment = $method->getDocComment();
if (!$docComment) {
return null; // 没有注释,说明不是 API 接口,跳过
}
// 简单的解析逻辑:提取 @Route 注释
// 实际项目中,你可以使用 Symfony 的 Doctrine Parser 或者专门的注解解析库
if (preg_match('/@Route([^)]+)/s', $docComment, $matches)) {
// 提取方法名,比如 @Route(path="/users", methods={"GET"})
// 这里为了演示,我们做一个极其简化的正则提取
$routeParts = explode('path="', $matches[0]);
if (isset($routeParts[1])) {
$path = trim(str_replace('")', '', $routeParts[1]));
// 提取 HTTP 方法
$methods = ['GET', 'POST', 'PUT', 'DELETE'];
$httpMethod = 'GET'; // 默认值
foreach ($methods as $m) {
if (strpos($docComment, strtoupper($m)) !== false) {
$httpMethod = strtoupper($m);
break;
}
}
// 构建路径对象
return [
'path' => $path,
'method' => $httpMethod,
'summary' => $this->extractSummary($docComment),
'parameters' => $this->parseParameters($method),
'responses' => $this->parseResponses($method)
];
}
}
return null;
}
/**
* 提取描述
*/
private function extractSummary(string $docComment): string
{
if (preg_match('/@OperationSummary("([^"]+)")/', $docComment, $matches)) {
return $matches[1];
}
return 'No summary provided';
}
/**
* 解析参数:这是最关键的步骤
*/
private function parseParameters(ReflectionMethod $method): array
{
$params = [];
foreach ($method->getParameters() as $parameter) {
$paramName = $parameter->getName();
// 尝试从注释中获取类型
$typeHint = $parameter->getType();
$typeName = $typeHint ? $typeHint->getName() : 'mixed';
$params[] = [
'name' => $paramName,
'in' => 'query', // 默认为 query,实际需要判断是 Path 还是 Body
'schema' => [
'type' => $this->mapPhpTypeToOpenApi($typeName)
],
'required' => !$parameter->isOptional()
];
}
return $params;
}
/**
* PHP 类型到 OpenAPI Schema 类型的映射
* 这就像是一个翻译官,把 PHP 的方言翻译成 JSON Schema 的方言
*/
private function mapPhpTypeToOpenApi(string $phpType): string
{
$map = [
'int' => 'integer',
'float' => 'number',
'string' => 'string',
'bool' => 'boolean',
'array' => 'array',
'object' => 'object'
];
return $map[$phpType] ?? 'string';
}
private function parseResponses(ReflectionMethod $method): array
{
// 这里可以解析 @Response 注释,生成 200, 400, 500 等响应定义
// 为了演示,我们返回一个通用的 200
return [
'200' => [
'description' => 'Success',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object'
]
]
]
]
];
}
}
看懂了吗?这段代码干了一件很酷的事:它把 PHP 的类型提示 int $page 和参数名 $page 转化成了 JSON 格式的 OpenAPI 参数定义。这意味着,如果你的代码里写了 $page = 1,生成的文档里就会有一个 default: 1 的字段。
这还不算完,这只是最基础的骨架。真正的魔法在于处理复杂类型。
第五部分:处理复杂对象——不仅仅是基本类型
刚才的代码里,我们只处理了 int 和 string。但实际业务中,我们的参数往往是对象,比如 UserCreateDTO。
这时候,ReflectionParameter 告诉我们参数类型是 UserCreateDTO。但在 OpenAPI 规范里,我们需要定义 UserCreateDTO 的结构(有哪些字段,类型是什么,是否必填)。
我们需要递归地反射这些 DTO 类。
/**
* 深度解析 Schema
*/
public function parseSchemaFromType(string $typeName): array
{
// 如果是 PHP 内置类型,直接返回
$builtinTypes = ['int', 'string', 'bool', 'float', 'array'];
if (in_array($typeName, $builtinTypes)) {
return ['type' => $typeName];
}
// 如果是自定义类
try {
$reflectionClass = new ReflectionClass($typeName);
// 1. 尝试从反射类中读取属性信息
$properties = [];
foreach ($reflectionClass->getProperties() as $property) {
$propType = $property->getType();
$propName = $property->getName();
// 简化处理:读取属性类型
$typeStr = $propType ? $propType->getName() : 'string';
$properties[$propName] = [
'type' => $this->mapPhpTypeToOpenApi($typeStr),
'description' => 'Auto-generated from property ' . $propName
];
}
// 2. 尝试从 DocBlock 注释中读取更详细的信息(比如 @MinLength, @NotNull)
$docComment = $reflectionClass->getDocComment();
if ($docComment) {
// ... 提取注解逻辑 ...
// 这里假设我们提取到了长度限制
// $properties['username']['minLength'] = 3;
}
return [
'type' => 'object',
'properties' => $properties,
'required' => [] // 需要手动标记 required
];
} catch (ReflectionException $e) {
// 类找不到,返回一个未知类型
return ['type' => 'object', 'description' => 'Unknown type: ' . $typeName];
}
}
这个递归过程至关重要。当我们的 API 返回一个 UserResponseDTO,这个 DTO 里面又套了一个 AddressDTO,我们的文档生成器就会一层层剥开它,直到把所有细节都暴露出来。
这就像剥洋葱,虽然会有点辣眼睛,但最终你会看到真相。
第六部分:组装 OpenAPI 规范——拼积木
现在,我们有了控制器列表,有了方法路径,有了参数,有了 Schema。我们需要把它们组装成一个完整的 OpenAPI JSON 文件。
这就是所谓的“组装”。
class OpenApiBuilder
{
public function build(array $controllers): array
{
$openApiSpec = [
'openapi' => '3.0.0',
'info' => [
'title' => 'My Awesome API',
'version' => '1.0.0',
'description' => 'This documentation is automatically generated. Do not edit manually. If you see a typo, fix the code.'
],
'servers' => [
[
'url' => 'http://localhost:8000/api',
'description' => 'Development server'
]
],
'paths' => [],
'components' => [
'schemas' => []
]
];
$parser = new OpenApiParser();
foreach ($controllers as $controllerClass) {
$methods = $controllerClass->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
$routeData = $parser->parseMethod($method);
if ($routeData) {
// 构建路径 Key,比如 /api/v1/users
$pathKey = $routeData['path'];
// 初始化路径
if (!isset($openApiSpec['paths'][$pathKey])) {
$openApiSpec['paths'][$pathKey] = [];
}
// 根据方法类型 (GET, POST) 构建请求体
$methodKey = strtolower($routeData['method']);
$operation = [
'summary' => $routeData['summary'],
'parameters' => $routeData['parameters'],
'responses' => $routeData['responses']
];
// 如果是 POST/PUT,处理 Request Body
if (in_array($routeData['method'], ['POST', 'PUT'])) {
$firstParam = $routeData['parameters'][0] ?? null;
if ($firstParam && $firstParam['name'] === 'body') {
$operation['requestBody'] = [
'content' => [
'application/json' => [
'schema' => $this->resolveSchema($firstParam['schema'])
]
]
];
}
}
$openApiSpec['paths'][$pathKey][$methodKey] = $operation;
}
}
}
return $openApiSpec;
}
}
看,这就是魔术发生的地方。我们只是简单地遍历数组,然后“复制粘贴”进结构里。所有的逻辑都在前面的步骤里完成了。
第七部分:渲染与输出——把 JSON 变成 HTML
有了 JSON,我们还需要把它变成人类能看懂的 HTML。这就需要用到 OpenAPI 的可视化工具。
最流行的有:
- Swagger UI:最经典,默认展示。
- Redoc:更美观,更像是一个文档网站。
- Stoplight:功能强大,付费版更好看。
我们的系统只需要负责生成 JSON 文件,然后把路径指向这些工具的 CDN 即可。
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>API Documentation</title>
<!-- 引入 Redoc -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js">
<style>
body { margin: 0; padding: 0; }
</style>
</head>
<body>
<!-- 指向我们生成的 json 文件 -->
<redoc spec-url="./docs/openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"></script>
</body>
</html>
运行我们的脚本:
php bin/generate-docs.php
这个脚本会扫描代码,运行解析器,生成 public/docs/openapi.json。
然后你打开浏览器访问 http://localhost:8000。
哇哦!你的 API 瞬间变成了一本精美的杂志。前端同学可以直接在这个页面上点击“Try it out”,填入参数,查看结果。
第八部分:进阶技巧——让文档“活”起来
光做到上面还不够。一个资深专家的系统应该考虑更多边界情况。
1. 处理枚举类型
PHP 8.1 引入了 enum。这简直是文档生成的福音!
enum Status: string
{
case PENDING = 'pending';
case ACTIVE = 'active';
case INACTIVE = 'inactive';
}
// 在控制器中
public function updateStatus(int $id, Status $status) { ... }
我们的 mapPhpTypeToOpenApi 需要升级:
private function mapPhpTypeToOpenApi(string $phpType): array
{
if (class_exists($phpType) && method_exists($phpType, 'cases')) {
// 这是一个 PHP 8.1 的枚举
return [
'type' => 'string',
'enum' => array_map(fn($case) => $case->value, $phpType::cases())
];
}
// ... 其他逻辑
}
这样生成的 JSON 就会包含 "enum": ["pending", "active", "inactive"],前端控件会自动变成下拉菜单,而不是一个随意的文本框。
2. 处理集合类型
当参数是 array<UserDTO> 时,我们的 Schema 应该定义成 type: array,并且 items 指向 UserDTO 的结构。
private function mapPhpTypeToOpenApi(string $phpType): array
{
if (substr($phpType, -2) === '[]') {
$itemType = substr($phpType, 0, -2);
return [
'type' => 'array',
'items' => $this->parseSchemaFromType($itemType)
];
}
// ...
}
3. 处理安全认证
如果你使用了 JWT 或 API Key,请务必在控制器方法的注释里加上 @Security(name="ApiKeyAuth")。
解析器提取到这个后,将其注入到 OpenAPI 规范的 securitySchemes 和 security 字段中。
// 生成的 OpenAPI 会是这样
{
"security": [
{
"ApiKeyAuth": []
}
],
"components": {
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-KEY"
}
}
}
}
前端在调用接口时,就会自动弹出输入框让你填 Key。
第九部分:维护与心智模型
好了,系统搭好了,代码写完了。现在有个问题:你怎么维护这个生成器?
其实,维护这个系统的难度,远低于维护一份手写文档。
想象一下,你改了一个 DTO 的属性,加了一个 @Max(100) 注解。你只需要运行一下 php bin/generate,然后刷新文档页面。你会看到文档里的 Schema 更新了,字段限制也生效了。
这形成了一个正向循环:
写好注释 -> 运行生成 -> 看到文档 -> 证明注释有效 -> 更有动力写注释。
这就是所谓的“代码即文档”的禅意。你的注释不再是枯燥的文字,它们变成了实实在在的 JSON 结构,变成了页面上可交互的 UI。
代码即真理
在这个系统中,代码是唯一真理。文档是代码的“尸体”。尸体是用来解剖的,不是用来乱改的。如果你发现文档写得不对,别急着去改 JSON,先回去改代码。
第十部分:结语——别再手写文档了
各位,通过今天的讲座,我们从一个 PHP 控制器出发,一路追踪到了 OpenAPI 的 JSON 结构。
我们学习了如何用 PHP 反射穿透类的外壳,如何从 DocBlock 中提取语义,如何构建递归的 Schema 解析器,最后组装成标准的 OpenAPI 规范。
不要小看这个系统。它不仅仅是“省事”。它解决了软件工程中最大的信任问题:沟通成本。
当你把这份自动生成的文档扔给前端,告诉他:“这是我的 API,你可以直接用。”你会获得一种前所未有的掌控感。因为你知道,文档上写的每一个字段,都真实存在于你的代码里。
这就是自动化文档的力量。它让代码说话,让接口呼吸。
下次当你面对空白的 Markdown 文档页面,准备敲下 ## Get Users 的时候,请停下来,摸着你的键盘,问自己一个问题:“我是想写这篇文档,还是想运行这个脚本让文档自动生成?”
选择后者。因为作为一名资深开发者,你应该相信代码的力量,而不是键盘的力量。
现在,去吧,去让你的文档自动生成起来!别忘了在代码里多写点注释,毕竟,没人喜欢维护一个没有注释的迷宫。