PHP 驱动的文档自动化系统:从源码注释自动生成符合 OpenAI 接口标准的 API 规格文档

各位好,把手里的咖啡放下,把那个还在闪烁的“保存”按钮关掉。今天我们不聊 array_merge 是深拷贝还是浅拷贝,也不聊那个在 PHP 8.2 里刚刚被砍掉的 assert() 函数。今天,我们要聊一个更神圣、更让全栈工程师半夜惊醒的话题:文档的同步问题。

我知道,你们中的一些人已经在心里翻白眼了:“这又是那种‘写代码比写文档重要一万倍’的老生常谈吗?” 嘿,老朋友,你说得对。但今天,我们要做的是去“奴役”代码,而不是被代码奴役。我们要构建一个 PHP 驱动的文档自动化系统——一个能把你的 PHP 源码注释变成 OpenAPI 规范文档的炼金术士。

准备好了吗?让我们开始这场从“注释”到“Swagger JSON”的魔法之旅。


第一章:注释是代码的灵魂(或者说,谎言)

首先,让我们面对现实。在大多数项目里,API 文档是活化石。它不仅陈旧,而且往往是错的。

想象一下这个场景:你的后端重构了一个方法,把 userId 改成了 user_id,并且加了一个必填的 role 字段。而你的前端小哥,那个永远一脸无辜的小哥,在某个周五下午两点给你发了 50 条消息:“喂,这接口怎么报错了?”

你打开文档,发现上面写着 userId 是可选的。你再看后端代码,才发现代码是真理,文档是谎言。这种“文档死”现象,就像是你买了一辆新车,说明书上写着“此车可飞”,结果你只能在车库里推着走一样绝望。

为了解决这个问题,我们需要引入“文档即代码”的理念。这意味着,文档不是单独写在 Word 或 Markdown 里的,而是写在代码里的。

在 PHP 世界里,我们有 DocBlocks。这可不是那种写在函数上面的 // This function does something 的简陋注释。我们用的是 /** ... */ 这种正经的注释块。

让我们来看看我们要“读取”的源码长什么样。这就像是在写一个咒语,每一个 @OA 开头的标记,都是我们构建 API 规范的一块砖瓦。

<?php
/**
 * 用户控制器
 * 
 * @OAInfo(
 *     title="我的超级API",
 *     version="1.0.0",
 *     description="这是自动生成的文档,保证比人类写的更诚实。"
 * )
 * 
 * @OATag(
 *     name="user",
 *     description="用户相关操作"
 * )
 */
class UserController 
{
    /**
     * 获取用户信息
     * 
     * @OAGet(
     *     path="/api/users/{id}",
     *     summary="获取指定用户",
     *     tags={"user"},
     *     @OAParameter(
     *         name="id",
         *         in="path",
         *         description="用户ID",
         *         required=true,
     *         @OASchema(type="integer")
     *     ),
     *     @OAResponse(
     *         response=200,
     *         description="成功返回用户对象",
     *         @OAJsonContent(
     *             @OAProperty(property="id", type="integer"),
     *             @OAProperty(property="username", type="string")
     *         )
     *     )
     * )
     */
    public function getUser($id) {
        // 这里是你的业务逻辑...
    }
}

看到了吗?我们的源码里,其实已经包含了 OpenAPI 的 JSON 结构。我们要做的,就是把这段咒语翻译成真正的 JSON 文件。


第二章:PHP 的反射器——透过窗户看世界

好了,既然我们有了带咒语的代码,怎么把它提取出来?直接正则匹配字符串?别逗了,那是给脚本小子干的事。作为一名资深专家,我们要用 PHP 最强大的武器:Reflection(反射)

PHP 的反射机制就像是给你的代码装了透视眼。你不需要去“解析”文件,你只需要问你的代码对象:“嘿,你身上贴了什么标签?你身上有多少个方法?这些方法的注释里写了什么鬼?”

核心就是 ReflectionClassReflectionMethod。这俩哥们儿是 PHP 内置的,不用装 Composer 包也能用(虽然我们建议你装)。

让我们写一个函数,它能把一个 UserController 变成我们想要的文档结构。

/**
 * 核心解析器:将类解析为 OpenAPI 对象
 */
function parseClassToOpenApi($className) {
    $reflectionClass = new ReflectionClass($className);
    $openApiInfo = new stdClass();

    // 1. 获取类级别的 DocBlock
    $classDocComment = $reflectionClass->getDocComment();

    // 2. 提取 @OAInfo
    if (preg_match('/@OA\Infos*((.*?))/s', $classDocComment, $matches)) {
        $openApiInfo->info = parseParameters($matches[1]);
    }

    // 3. 收集所有的方法(API 端点)
    $methods = [];
    foreach ($reflectionClass->getMethods() as $method) {
        // 我们只关注 public 方法,而且只关注有 @OA 注释的方法
        if ($method->isPublic() && $method->getDocComment()) {
            $methods[] = parseMethodToEndpoint($method);
        }
    }

    // 将方法和信息组装起来
    return [
        'openapi' => '3.0.0',
        'info' => $openApiInfo->info ?? null,
        'paths' => $methods // 这里会是一个 key 为路径,value 为路径详情的数组
    ];
}

这里有个小技巧:我们不需要手动写正则去解析 JSON 结构,因为正则处理复杂的嵌套 JSON 是噩梦。相反,我们将 DocBlock 里的内容看作是参数列表。比如 @OAInfo(title="Test", version="1.0"),其实就等同于 JSON 里的 { "title": "Test", "version": "1.0" }


第三章:转换引擎——从字符串到 JSON Schema

现在,我们要面对最枯燥但也最核心的部分:如何把那个丑陋的注释字符串变成优雅的 OpenAPI JSON 对象。这是我们的“翻译官”。

我们需要一个通用的解析器,它能识别 @OAXxx(Yyy=Zzz) 这种模式。

function parseParameters($docString) {
    // 这是一个非常暴力的字符串处理方法,但在无依赖的情况下很有效
    // 我们把换行符和多余空格替换成逗号,方便分割
    $cleanString = preg_replace('/s+/', ' ', $docString);

    $params = [];

    // 1. 解析 Info 或其他带键值对的参数
    // 这里我们简化处理,只提取 key 和 value
    preg_match_all('/(w+)s*=s*["']([^"']*)["']/', $cleanString, $matches, PREG_SET_ORDER);

    foreach ($matches as $match) {
        $params[$match[1]] = $match[2];
    }

    // 2. 解析 JSON 内容(比如 Response 里的 JsonContent)
    // 比如包含 @OAJsonContent(...) 这种部分,我们可以尝试提取 JSON 字符串
    if (preg_match('/@OA\JsonContents*((.*?))/s', $docString, $jsonMatch)) {
        // 这里通常需要更复杂的解析,因为 JsonContent 里可能嵌套了 @OAProperty
        // 为了演示,我们假装我们已经把它转成了真正的 JSON Schema 对象
        $params['example'] = ['mock_data' => 'parsed_from_json_content'];
    }

    return $params;
}

function parseMethodToEndpoint(ReflectionMethod $method) {
    $docComment = $method->getDocComment();
    $path = '';
    $httpMethod = '';
    $summary = '';
    $parameters = [];
    $responses = [];

    // 3. 提取路径和方法
    preg_match('/@OA.(Get|Post|Put|Delete)s*(s*paths*=s*["']([^"']*)["']/', $docComment, $pathMatch);
    preg_match('/@OA.(Get|Post|Put|Delete)/', $docComment, $methodMatch);

    if ($pathMatch && $methodMatch) {
        $path = $pathMatch[2];
        $httpMethod = strtolower($methodMatch[1]);
    }

    // 4. 提取 Summary
    if (preg_match('/summarys*=s*["']([^"']*)["']/', $docComment, $summaryMatch)) {
        $summary = $summaryMatch[1];
    }

    // 5. 提取参数
    // 这是一个非常硬核的正则,用来提取 @OAParameter
    // 它能捕获 name, in, required, schema type 等信息
    preg_match_all('/@OA\Parameters*(s*names*=s*["']([^"']*)["'].*?@OA\Schemas*(s*types*=s*["']([^"']*)["']/', $docComment, $paramMatches, PREG_SET_ORDER);

    foreach ($paramMatches as $match) {
        $parameters[] = [
            'name' => $match[1],
            'in' => 'path', // 简化处理,通常需要根据 in 参数区分是 query 还是 path
            'required' => true, // 简化处理,默认都是必填
            'schema' => [
                'type' => $match[2]
            ]
        ];
    }

    // 6. 提取 Response
    // 类似地,我们提取 @OAResponse 和里面的内容
    // ... (此处省略提取 Response 的代码,逻辑同上)

    return [
        $path => [
            $httpMethod => [
                'summary' => $summary,
                'parameters' => $parameters,
                'responses' => [
                    '200' => [
                        'description' => 'Success',
                        'content' => [
                            'application/json' => [
                                'schema' => [
                                    'type' => 'object',
                                    // 这里可以添加更多细节...
                                ]
                            ]
                        ]
                    ]
                ]
            ]
        ]
    ];
}

看,这就是魔法。我们用 PHP 的字符串处理能力,从一行行注释中“抠”出了 JSON 结构。虽然这里为了代码简洁,正则写得很粗糙,但它演示了核心原理:解析 -> 映射 -> 生成


第四章:组装与输出——生成最终的文件

有了上面的 parseClassToOpenApi,我们只需要遍历你的项目目录,找到所有的 Controller 文件,然后喂给它。它会吐出最终的 openapi.json

让我们写一个脚本来跑这个流程。

function generateDocumentation($controllerDir) {
    // 获取目录下所有的 .php 文件
    $files = glob($controllerDir . '/*.php');
    $allPaths = [];
    $info = [];

    foreach ($files as $file) {
        $className = basename($file, '.php');

        // 我们需要一个自动加载器来加载这个文件,否则反射器会看不懂
        // 在实际项目中,这里应该调用 Composer 的 Autoloader
        require_once $file;

        // 这里假设你的命名空间是 AppControllers
        $fullClassName = "App\Controllers\{$className}";

        if (class_exists($fullClassName)) {
            $openApiData = parseClassToOpenApi($fullClassName);

            // 合并路径信息
            if (isset($openApiData['paths'])) {
                $allPaths = array_merge_recursive($allPaths, $openApiData['paths']);
            }

            // 合并 Info 信息(取第一个)
            if (isset($openApiData['info']) && empty($info)) {
                $info = $openApiData['info'];
            }
        }
    }

    // 组装最终的 OpenAPI 3.0 对象
    $finalSwagger = [
        'openapi' => '3.0.0',
        'info' => $info,
        'paths' => $allPaths,
        'components' => [
            'schemas' => [
                'User' => [
                    'type' => 'object',
                    'properties' => [
                        'id' => ['type' => 'integer'],
                        'username' => ['type' => 'string']
                    ]
                ]
            ]
        ]
    ];

    // 输出 JSON
    $jsonOutput = json_encode($finalSwagger, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    file_put_contents(__DIR__ . '/openapi.json', $jsonOutput);

    echo "文档生成完毕!位置:openapi.jsonn";
    echo "一共扫描了 " . count($files) . " 个文件。n";
}

// 执行生成
generateDocumentation(__DIR__ . '/src/Controllers');

运行这段脚本,你就会得到一个 openapi.json 文件。把它扔给 Swagger UI,或者 Postman,或者任何支持 OpenAPI 的工具。你得到的不是一个静态的 HTML 文档,而是一个实时反映你当前代码状态的文档。


第五章:进阶技巧——不要让你的工具太蠢

现在,你有一个能跑的文档生成器了。但作为一个资深专家,我们不能止步于此。如果你真的要在一个生产环境里使用这个,你需要解决几个棘手的问题。

1. 处理继承(多态)

现实中的代码往往很混乱。你可能有一个 BaseController,里面定义了通用的 @OATag,然后子类 UserController 继承它。或者,你有一个基类定义了通用的 Schema。

你的 ReflectionClass 需要处理继承关系。你需要写一个递归函数,先去父类里找找有没有定义了啥,再去找子类。

function getInheritedDocBlocks($reflectionClass) {
    $docBlocks = [];

    // 先看父类
    if ($parentClass = $reflectionClass->getParentClass()) {
        $docBlocks = array_merge($docBlocks, getInheritedDocBlocks($parentClass));
    }

    // 再看自己
    if ($docComment = $reflectionClass->getDocComment()) {
        $docBlocks[] = $docComment;
    }

    return $docBlocks;
}

2. 处理数组参数和嵌套结构

你可能会看到这样的注释:
@OAParameter(name="filters", in="query", @OASchema(type="array", @OAItems(type="integer")))

这是 OpenAPI 对数组的标准写法。你需要用正则更聪明地捕获 @OAItems,然后把它转换成 JSON Schema 里的 items 字段。

3. 集成到 CI/CD(持续集成/持续部署)

这是最重要的一步。你不能每次想看文档的时候才手动跑脚本。

你应该把这个脚本加到你的 GitLab CI 或 GitHub Actions 里。
当有人提交代码时,CI 跑一遍你的测试,同时也跑一遍文档生成脚本。如果生成的文档和 Git 仓库里的 openapi.json 不一致(通常我们用 git diff 对比),CI 就会报错:“嘿!你的代码改了,文档没更新!赶紧去修!”

这就形成了一个闭环。代码变了,文档必须变。

4. 性能优化

如果你的项目有几万个类,每次生成文档都 require_once 所有文件会慢得像蜗牛。这时候,你需要一个简单的类映射文件,或者直接使用 Composer 的 AutoLoader 在内存中加载。


第六章:构建一个“良心”的自动化系统

现在,让我们把所有东西拼起来,变成一个稍微像模像样的系统。这不是完整的框架,但绝对是个好用的工具。

为了解决刚才提到的“继承”和“依赖”问题,我们假设项目里有一个 ControllerScanner 类。

class ControllerScanner 
{
    private $controllerNamespace = 'App\Controllers';
    private $outputFile = 'openapi.json';

    public function generate() {
        $openApiData = [
            'openapi' => '3.0.0',
            'info' => [
                'title' => 'My Auto-Generated API',
                'version' => '1.0.0',
                'description' => '由 PHP Reflection 引擎自动生成,不写废话。'
            ],
            'paths' => [],
            'components' => [
                'schemas' => []
            ]
        ];

        // 扫描目录
        $files = glob(__DIR__ . '/src/Controllers/*.php');

        foreach ($files as $file) {
            $className = basename($file, '.php');
            $fullClassName = $this->controllerNamespace . '\' . $className;

            if (class_exists($fullClassName)) {
                $reflection = new ReflectionClass($fullClassName);
                $this->processClass($reflection, $openApiData);
            }
        }

        // 写入文件
        file_put_contents($this->outputFile, json_encode($openApiData, JSON_PRETTY_PRINT));
        echo "Generated {$this->outputFile} successfully.n";
    }

    private function processClass(ReflectionClass $class, &$openApiData) {
        // 1. 提取类级别的 Tag
        // ... (略过提取逻辑)

        // 2. 处理方法
        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
            // 跳过构造函数、魔术方法等
            if ($method->isConstructor() || $method->isMagic()) continue;

            $doc = $method->getDocComment();
            if (!$doc) continue;

            // 解析路径和方法
            preg_match('/@OA.(Get|Post|Put|Delete)s*(s*paths*=s*["']([^"']*)["']/', $doc, $match);
            if (!$match) continue;

            $path = $match[2];
            $methodType = strtolower($match[1]);

            // 解析 Summary
            $summary = 'No description';
            preg_match('/summarys*=s*["']([^"']*)["']/', $doc, $summaryMatch);
            if ($summaryMatch) $summary = $summaryMatch[1];

            // 解析参数
            $parameters = $this->parseParameters($doc);

            // 添加到 paths
            if (!isset($openApiData['paths'][$path])) {
                $openApiData['paths'][$path] = [];
            }

            $openApiData['paths'][$path][$methodType] = [
                'summary' => $summary,
                'parameters' => $parameters,
                'responses' => [
                    '200' => [
                        'description' => 'Successful response',
                        'content' => [
                            'application/json' => [
                                'schema' => [
                                    '$ref' => '#/components/schemas/CommonResponse'
                                ]
                            ]
                        ]
                    ]
                ]
            ];
        }
    }

    private function parseParameters($doc) {
        // 实际实现应该处理 @OAParameter, @OABodyContent 等复杂情况
        // 这里返回空数组是为了演示结构
        return [];
    }
}

// 运行
$scanner = new ControllerScanner();
$scanner->generate();

第七章:吐槽与反思

好了,代码写完了。现在你看着这个生成的 openapi.json,是不是觉得有点丑?全是硬编码的字符串?

确实,手动解析 DocBlock 的正则很脆弱。如果有人不小心少了一个逗号,整个解析器就会崩溃。而且,像 @OAJsonContent 这种包含嵌套结构的注释,手动解析简直是噩梦。

所以,作为一个负责任的专家,我必须提醒你:这只是一个教育性质的演示。

在真实的生产环境中,如果你不想写几万行正则代码,你应该使用成熟的工具。
比如 ApiGen(老牌神器)。
比如 Nelmio ApiDocBundle(Symfony 生态最爱)。
比如 phpDocumentor

理解原理是最重要的。即使你用了 Nelmio,理解它是如何利用 PHP 反射来扫描注释的,也能让你在使用它时更得心应手。你才能知道为什么有时候你的注解不起作用,为什么有时候生成的 JSON 结构不对。

手动实现一个微型版本,能让你明白 API 文档生成的本质:源码 -> 反射 -> 映射 -> JSON


第八章:如何避免成为“文档乞丐”

最后,让我们聊聊心态。

很多开发人员对写文档有抵触情绪,觉得那是浪费时间。他们觉得自己写了代码,逻辑通了就行。但当他们把 API 部署上去,结果发现前端因为一个字段名的大小写对不上而折腾了一下午的时候,他们就会后悔没有写注释。

我们要追求的是“零摩擦文档”

当你修改代码时,注释是改了还是没改?这是第一个念头。
当你加了一个新接口时,你有没有在注释里写清楚参数?这是第二个念头。
当别人问你接口怎么调的时候,你能不能直接把 openapi.json 发给他?这是第三个念头。

如果你能做到这三点,你就从“代码工人”晋升为了“架构专家”。

这套 PHP 自动化系统,其实就是帮你省去了那第三个念头。它不要求你手写 JSON,它要求你手写注释。写注释的难度远低于手写 JSON,而且注释写在代码里,永远不会丢。


结语:行动起来吧

代码不会撒谎,但人类会。
你的注释是代码里的活体细胞。只要代码活着,注释就会告诉你真相。

不要再让 swagger.json 成为那个被遗忘在仓库角落里的废弃文件了。把这段脚本扔进你的项目里,配置好你的 DocBlock 注释。

当你敲下 php generate_docs.php 的那一刻,看着控制台输出的 Generated openapi.json successfully,你会感到一种前所未有的快感。那不是简单的文件生成,那是你对混乱秩序的胜利,是程序员的浪漫。

去试试吧。如果你在解析复杂的正则时头发掉光了,欢迎回来找我。但在此之前,尽情享受征服代码的快感吧!

(讲座结束,大家鼓掌,记得点赞,谢谢!)

发表回复

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