PHP 驱动的自动化文档生成系统:基于注释语义自动构建符合 OpenAPI 标准的说明书

代码即文档:如何用 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 提供了一个强大的工具,叫反射

你可以把反射想象成一种魔法。普通的函数调用是“我调用你”,而反射是“我看着你内部的一切,包括你长什么样(方法名、参数类型),你肚子里有什么(属性、注释)”。

我们的工具包里主要有两个武器:

  1. ReflectionClass:用来窥探类本身。
  2. ReflectionMethodReflectionParameter:用来窥探类里的方法和参数。

让我们来写一段代码,定义一个典型的控制器。注意看,我在这里埋下了“伏笔”。

<?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 的字段。

这还不算完,这只是最基础的骨架。真正的魔法在于处理复杂类型


第五部分:处理复杂对象——不仅仅是基本类型

刚才的代码里,我们只处理了 intstring。但实际业务中,我们的参数往往是对象,比如 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 的可视化工具。

最流行的有:

  1. Swagger UI:最经典,默认展示。
  2. Redoc:更美观,更像是一个文档网站。
  3. 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 规范的 securitySchemessecurity 字段中。

// 生成的 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 的时候,请停下来,摸着你的键盘,问自己一个问题:“我是想写这篇文档,还是想运行这个脚本让文档自动生成?”

选择后者。因为作为一名资深开发者,你应该相信代码的力量,而不是键盘的力量。

现在,去吧,去让你的文档自动生成起来!别忘了在代码里多写点注释,毕竟,没人喜欢维护一个没有注释的迷宫。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注