漫画翻译服务写好接口只是第一步真正让人头疼的是接进来的人不知道参数怎么传。前段时间我把一套 manga-image-translator 的服务端部署在内部机器上给几个做内容处理流程的同事调用结果头一周收到的提问几乎全是同一类config这个字段到底传 JSON 字符串还是对象、target_lang该填zh还是CHS、翻译器名字写chatgpt还是gpt。这些问题在代码里都有答案只是没有任何地方把它们写下来。于是我花了两天时间做了一件事把这套服务的接口参数自动抽取成 API 文档代码一改文档跟着变。这篇就完整拆解这套自动创建文档的流程包括选型理由、脚本骨架、渲染产物和几个让我返工过的坑适合已经能跑起服务、准备把接口交出去的人参考。1. 先想清楚给图片翻译服务做文档难点在哪1.1 这套服务的对外形态比想象中复杂manga-image-translator 提供两种使用方式一种是命令行直接跑单张图另一种是起一个 HTTP 服务对外提供翻译能力。做文档只关心后者。它的接口数量不多通常就那么几个提交一张图做翻译、提交一批图、查询队列状态、取回结果。数量少是好事但复杂度不在数量上而在参数上。一张漫画图从进来再到出去中间要经过检测、识别、翻译、擦除、排版五个环节每个环节都可以换实现。检测器有不同模型可选OCR 有不同引擎翻译器更是一长串从本地模型到在线接口都有擦除和渲染也各有分支。这些选择在服务端并不是拆成十几个字段而是被压进一个叫config的字段里以 JSON 形式整体传进来。于是文档最难写清楚的部分不是有几个接口而是这个嵌套结构里有哪些键、每个键的取值范围是什么、哪些键互斥、哪些键在某个翻译器下才生效。我一开始也想手写一份 Markdown 说明写了两天后放弃了。原因很直接翻译器列表是从注册表里动态加载的今天支持一批明天加一个就变语言代码表也是维护在源码里的字典。手写文档意味着每次代码变动都要有人记得回来改文字而现实里没人记得。1.2 手写文档必然腐烂的三个具体原因第一个原因是参数来源分散。接口路由定义在一个文件里请求体结构在另一个文件里可选值来自第三个甚至第四个文件。写文档的人得同时看三四个地方才能拼出一条准确描述拼完还要保证以后每次都对。这个人力成本没有任何团队愿意长期承担。第二个原因是示例会过期。文档里的请求示例往往是某次手工调通之后复制进去的图片路径、语言代码、翻译器名字都是当时的值。服务升级之后示例可能还能跑但返回结构变了读文档的人照着写代码就会在解析响应时出错而错误信息跟文档完全对不上排查成本极高。第三个原因最隐蔽文档描述的是一种理想接口代码实现的是实际接口两者之间的差异没人主动暴露。比如代码里同时接受 multipart 表单和 JSON 请求体手写文档通常只写一种用另一种方式调用的人就会一直失败然后怀疑是自己环境问题。我的判断是只要参数集合超过十个、并且存在动态枚举手写文档的维护成本就会超过自动化的搭建成本。这套服务的参数规模远超这个阈值所以自动化不是锦上添花是唯一可持续的方案。1.3 三条可选路线以及我为什么选中间那条自动生成接口文档大致有三条路。第一条是从现有框架的元数据直接导出如果服务本身用了带 OpenAPI 支持的框架基本上一行命令就能拿到一份规范文件。第二条是从运行中的服务反向抓取启动服务后遍历路由表结合请求模型的 schema 拼装文档。第三条是纯静态解析源码用 AST 扫描装饰器和函数签名。第一条最省事但前提是框架支持。这套服务的服务端用的是比较朴素的写法路由是普通装饰器注册的请求体是手工从请求里取字段再解析没有声明式的请求模型。所以第一条路走不通除非我先改造代码而改造代码的收益和风险在现有阶段不划算。第三条看起来最通用实际最脆弱。AST 解析对写法极其敏感换个等价的写法结果就变遇到动态构造的路由基本无能为力。文档生成器本身变成一个需要维护的项目不符合低成本可持续的初衷。我最终选的是第二条加一点点第一条的成分启动服务拿到真实的路由表保证路径和方法绝对准确请求体部分手工收敛成一组 pydantic 模型作为唯一事实来源模型序列化成 JSON Schema再合并进 OpenAPI 结构。这样路由不会漏字段不会错枚举值从注册表里现取。代价是需要写一个几百行的生成脚本但这个脚本写完基本不用再动。路线上手成本准确度维护成本适用条件框架元数据导出极低高极低服务使用声明式路由与请求模型运行时路由 手工模型中等高低路由可枚举、请求体可收敛成模型纯 AST 静态解析中等低高写法规范统一且极少变动提示选路线的时候不要只看第一次生成的效果要看半年后的维护状态。生成器本身越简单越可能被持续使用。2. 摸清接口事实从源码里挖出真实的路由与参数2.1 用运行时的路由表代替肉眼核对文档生成的第一步不是写脚本是把接口清单钉死。我吃过一次亏凭记忆列了四个接口实际跑起来发现有六个漏掉的两个是批量提交和队列查询恰好是别人最常用的。所以这一步必须用机器来枚举人工只做核对。服务端启动之后应用对象上挂着完整的路由表。用几行代码就能把它打印出来路径、允许的方法、对应的处理函数名一次到位。我习惯把结果存成一份文本快照放进仓库后面每次生成文档都拿新快照和它对比多一个少一个立刻能发现。import importlib from flask import Flask # 具体模块路径随版本略有差异以你手上代码里的入口文件为准 mod importlib.import_module(server.main) app: Flask mod.app rows [] for rule in app.url_map.iter_rules(): methods sorted(m for m in rule.methods if m not in {HEAD, OPTIONS}) rows.append((rule.rule, ,.join(methods), rule.endpoint)) for path, methods, endpoint in sorted(rows): print(f{methods:12s} {path:30s} - {endpoint})这段脚本的价值不在输出本身而在于它把接口清单从人的记忆里搬到了可版本化的文件里。后面所有环节都以这份清单为准包括一致性校验。任何一次改动导致路由增减CI 里的对比会直接报出来不会出现文档里还留着一个已经删掉的接口这种尴尬。核对的时候有个细节要注意有些路径带变量段比如任务查询的路径里会有一个任务 ID。这类路径在 OpenAPI 里要转成花括号形式同时补上参数声明。如果直接从路由表原样输出渲染出来会是一个无法在页面上填参数的死链接。2.2 请求体有两种入口文档里必须都写这套服务同时支持两种提交方式一种是表单图片作为文件字段上传其余配置作为普通字段另一种是纯 JSON图片以 base64 编码放进字段里。两种方式在代码里走的是同一个处理函数但取字段的方式不同容错范围也不同。手写文档几乎一定会漏掉一种我见过不止一个对接方卡在这里。处理办法很直接在 OpenAPI 里给同一个路径声明两个 requestBody 变体用 content type 区分。表单类型的 schema 里图片字段声明成二进制字符串JSON 类型的 schema 里图片字段声明成普通字符串并注明是 base64 编码。配置文件这个字段在两种入口下都容易出现误解因为它本身是一个 JSON 对象但在表单里必须以字符串形式提交服务端再二次解析。from pydantic import BaseModel, Field class TranslateForm(BaseModel): image: bytes Field(..., description待翻译图片文件二进制上传) config: str Field( {}, description配置 JSON 的字符串形式服务端会二次解析。注意不要直接传真对象, ) class TranslateJson(BaseModel): image: str Field(..., description待翻译图片的 base64 编码字符串不含 data URL 前缀) config: dict Field( default_factorydict, description配置对象按 JSON 直接提交不需要再序列化一层, )这段模型定义是整个文档生成流程里唯一需要人工维护的部分也是唯一值得人工维护的部分。它把散落在处理函数里的字段提取逻辑翻译成了结构化描述。字段一旦增减模型必须同步而模型不同步会导致校验环节报错反过来逼着人改。这种改动会被发现的机制才是文档不腐烂的根本。2.3 枚举值绝不能手抄必须从注册表现取参数里最容易写错、也最容易过期的就是枚举。翻译器名称、目标语言代码、检测器和 OCR 的可用取值这些都是从源码里的字典或注册表动态构建的。手抄一遍第二天上游加一个翻译器文档就变成了错的。更糟的是错误方向很危险文档里没写的值用户会以为不支持于是绕过服务自己实现一套白白浪费。正确做法是在生成脚本里直接 import 这些注册表把键取出来塞进 schema 的enum字段。这样枚举值永远和代码一致代码加一个文档下一次生成就多一个。唯一需要注意的是排序字典顺序在不同 Python 版本或不同导入路径下可能不同生成前统一排序否则每次 diff 都是一堆无意义的行变动。from manga_image_translator.config import Translator def collect_translators() - list[str]: # 具体入口随版本不同可能是类属性也可能是模块级字典 names getattr(Translator, translators, None) or getattr(Translator, _translators, {}) return sorted(names.keys()) def collect_languages() - list[str]: from manga_image_translator.language import LANGUAGES return sorted(LANGUAGES.keys())有个坑值得单独说语言代码在项目内部可能同时存在两套命名一套是标准短代码一套是面向特定翻译接口的长名称。如果文档只列一套用户按另一套传就会报错而报错信息往往只是不支持的语言不会告诉你有另一套写法。我的处理是在字段描述里两套都列出来并明确说明哪一套是推荐的、哪一套是为了兼容旧调用保留的。这类信息写进字段描述比写在正文里更容易被读到因为看文档的人通常是直接看参数表的。3. 搭生成流水线从模型到可浏览的文档3.1 把散落参数收敛成一份 schema 清单模型定义好之后下一步是让它变成机器可读的 schema。pydantic 自带这个能力一行调用就能拿到完整的 JSON Schema包含字段名、类型、是否必填、默认值和描述。不同大版本的方法名不同新版叫model_json_schema旧版叫schema写脚本时做个兼容判断免得换环境就跑不起来。拿到 schema 之后不要急着往 OpenAPI 里塞。先做一次清理pydantic 生成的 schema 里会带一个$defs段存放嵌套模型的引用直接合并进 paths 会让结构变得难以阅读。我的做法是把嵌套模型拆平因为这套服务的请求体最多两层拆平之后文档读起来更直观。如果层级更深就保留引用让渲染器自己处理。清理完成后把每个接口的请求体 schema 分别挂到对应的路径和方法上同时补上响应结构。响应结构相对固定但有两个字段要特别标注一个是返回的图片字段通常是 base64长度可能非常大另一个是状态字段取值有限应该声明成枚举。这两处标注清楚了对接方在写解析代码时能少走很多弯路。3.2 生成脚本的骨架与关键实现整个生成脚本可以拆成四步枚举路由、加载模型、拼装结构、写出文件。我把它写成一个可以重复执行、结果确定的脚本每次运行都覆盖输出不做增量合并。确定性很重要同样的代码必须产出同样的文件否则版本管理里全是没有意义的噪音。import json from pathlib import Path def build_openapi(routes, models) - dict: doc { openapi: 3.0.3, info: { title: 图片翻译服务接口, version: auto, description: 由生成脚本根据路由表与请求模型自动构建请勿手工编辑, }, paths: {}, components: {schemas: {}}, } for path, methods, endpoint in routes: if not endpoint.startswith(api_): continue oa_path to_openapi_path(path) item doc[paths].setdefault(oa_path, {}) for method in methods: item[method.lower()] { summary: SUMMARY.get(endpoint, endpoint), operationId: endpoint, requestBody: { required: True, content: { multipart/form-data: { schema: models[form] }, application/json: { schema: models[json] }, }, }, responses: { 200: {description: 处理完成, content: {application/json: {schema: models[resp]}}} }, } return doc if __name__ __main__: out Path(docs/openapi.json) out.parent.mkdir(parentsTrue, exist_okTrue) out.write_text( json.dumps(build_openapi(get_routes(), load_models()), ensure_asciiFalse, indent2, sort_keysTrue), encodingutf-8, )脚本里有两个小设计值得说明。第一operationId直接用处理函数名好处是唯一且稳定坏处是暴露了内部命名如果服务不对外开放就无所谓如果对外可以加一层映射表把内部名换成更友好的名字。第二sort_keysTrue和固定缩进必须加上这是让输出可 diff 的前提否则每次生成的字段顺序都可能变版本对比完全没法看。3.3 三份产物规范文件、可交互页面、纯文本表格生成出来的openapi.json只是中间产物真正给人用的是渲染后的东西。我同时出三份一份可交互的页面、一份纯文本的 Markdown 参数表、一份最小可用的调用示例。三份都从同一个规范文件渲染保证完全一致。可交互页面用现成的渲染器把规范文件丢进去就行好处是能直接在页面上填参数、发请求、看响应对接方调试成本极低。纯文本参数表用于贴进内部沟通渠道因为很多人不会专门打开一个页面。调用示例则是把最常见的两条路径写成可复制的命令一条表单上传、一条 JSON 提交覆盖九成以上的使用场景。# 表单方式 curl -X POST http://127.0.0.1:5003/translate \ -F imagesample.png \ -F config{translator:craft,target_lang:CHS} # JSON 方式图片需先 base64 编码 curl -X POST http://127.0.0.1:5003/translate \ -H Content-Type: application/json \ -d payload.json示例里的图片可以用一张几 KB 的小图不要拿真实的大图否则复制粘贴执行会慢得让人放弃。这一点看起来是小事实际上直接影响文档的使用率。我见过很多文档示例用了很大的测试文件结果每次调试都要等十几秒用的人干脆不看文档了。3.4 描述字段怎么写才有人看自动生成不等于不需要写描述。schema 里每个字段的description是唯一的人工输入也是最容易被读者看到的内容。我的写法是一句话说清这个字段控制什么一句话说清取值范围从哪看再补一句最容易踩的注意事项。三段话控制在六十个字以内超过这个长度读的人就跳过了。比如翻译器字段不要写翻译器名称可选值见枚举读者不知道枚举在哪。要写选择翻译实现可选值见下方枚举列表本地模型需要提前下载权重首次调用会明显变慢。把使用代价写进去比罗列参数说明有用得多。再比如目标语言字段要明确写出支持的两套命名方式以及推荐哪一套这一条能省掉对接方至少一次失败尝试。4. 让文档跟着代码走一致性校验与版本管理4.1 把生成结果纳入版本管理自动生成的文件要不要提交进仓库我的答案是必须提交而且要和代码在同一个提交里。理由有三条。第一看文档的人不需要跑脚本克隆下来就能打开。第二任何一次代码改动引起的文档变化都会在代码评审里露出来评审的人能顺手判断这个变化是不是预期的。第三出了问题可以回溯出上一版的文档拿来做对比。提交带来一个问题生成结果必须确定。这就要求脚本里不能有随机因素、不能依赖当前时间、不能依赖字典遍历顺序。上面提到的排序和固定缩进就是为此。还有一个容易忽略的点是换行符如果团队里有人在不同的操作系统上生成行尾差异会让整份文件变成全量改动评审的时候什么也看不出来。解决办法是在仓库里加一份属性配置把生成文件的换行符固定住。4.2 校验脚本如何设计才能真正拦住问题光靠人记得运行生成脚本是不可靠的。我的做法是在持续集成里加一个校验步骤重新生成一份规范文件和仓库里提交的那份做对比只要有差异就以非零状态退出。这样任何改了接口却没更新文档的提交都会被拦下来而且失败信息很明确直接告诉提交者运行生成脚本。python scripts/gen_api_docs.py --out /tmp/openapi.json if ! diff -q /tmp/openapi.json docs/openapi.json /dev/null; then echo 接口文档与代码不一致请运行 scripts/gen_api_docs.py 并提交生成结果 diff -u docs/openapi.json /tmp/openapi.json | head -n 60 exit 1 fi输出里带上前几十行差异是个很实用的细节。失败的人第一反应是看哪里变了如果只告诉他不一致他还得自己跑一遍再对比这一来一回可能就是半小时。直接把差异打出来多数情况下他一眼就能明白是自己加了字段没更新还是别人误改了生成文件。4.3 语义化校验比文本对比更进一步文本对比只能发现变化不能发现坏变化两者不能混为一谈。所以我另外加了一层语义校验新生成的规范文件必须满足几条硬性约束否则即使文本对比通过也算失败。约束不复杂但每一条都对应一次真实事故。校验项检查内容拦截的问题描述完整性每个字段必须有非空 description新增字段忘记写说明枚举非空枚举字段的取值列表不能为空注册表加载失败导致文档空白路径参数声明带变量段的路径必须有对应参数定义渲染后无法填参的死链接响应字段稳定成功响应的必需字段集合不得删减悄悄改坏返回结构示例体积示例响应中单个字符串不超过阈值大图 base64 撑爆渲染器最后一条是血泪教训后面单独说。这层校验的价值在于它把文档正确性从主观判断变成了可执行的规则新来的人不需要了解全部背景也能守住底线。4.4 变更记录的自动化生成接口变动需要让人知道尤其是已经对接过的调用方。手工写变更记录同样会腐烂所以我让校验步骤顺带干一件事如果检测到规范文件有变化就用结构对比工具生成一份差异摘要列出新增字段、删除字段、枚举值增减追加到一个变更记录文件里。这样每次提交就自动多了一段可读的说明不用额外花时间。结构对比工具要选那种理解 OpenAPI 语义的普通文本对比出来的结果人看不懂。差异摘要也不需要写得很详细列出某路径新增某字段、某字段枚举增加了两个值这个粒度就够用。真需要详细说明的地方在提交信息里补一句两条信息合起来就完整了。5. 实测踩过的坑与绕法5.1 base64 图片把渲染器撑爆第一次生成完整文档后我兴冲冲打开可交互页面结果浏览器直接卡死。原因是响应示例里放了一张真实翻译结果base64 编码后接近两兆整个页面加载时要把这串字符解析并高亮前端直接不堪重负。解决办法是在生成阶段就截断示例中的长字符串。响应示例里的图片字段保留一个明显的占位符比如标注为省略约 2MB 的 base64 数据。同时在校验脚本里加一条体积约束任何示例里单个字符串超过设定阈值就报错。这样既保证文档能打开也不会让人误以为返回值是空的。import base64 def scrub(obj, limit: int 512): if isinstance(obj, str): if len(obj) limit: size len(base64.b64decode(obj, validateFalse)) if is_b64(obj) else len(obj) return f已省略长度约 {len(obj)} 字节解码后约 {size} 字节 return obj if isinstance(obj, dict): return {k: scrub(v, limit) for k, v in obj.items()} if isinstance(obj, list): return [scrub(v, limit) for v in obj] return obj还有个衍生问题图片的 base64 里可能出现特殊字符序列直接塞进 JSON 示例里某些渲染器会解析异常。稳妥做法是示例一律走 JSON 序列化不要手拼字符串。这个坑不容易提前想到但只要踩过一次就会在所有示例生成的地方统一加序列化。5.2 异步任务的返回结构是最容易写错的一段翻译一张图耗时不确定服务端通常会提供异步方式先提交拿到任务标识再轮询查询状态最后取结果。这套流程在代码里可能涉及两三个不同的处理函数手写文档时极容易把返回结构写错比如提交返回的是任务标识查询返回的是状态加进度取结果返回的是图片本身三段结构完全不同。我的处理是在文档里把三段分开写清楚并明确画出状态流转提交后进入排队然后处理中最后完成或失败。每个状态对应哪些字段有值、哪些字段为空都要写明白。特别是失败状态一定要说明错误信息字段的名字否则对接方拿到失败响应不知道去哪读原因只能来问你。另一个细节是轮询间隔。文档里不写建议间隔用户可能写成死循环疯狂请求把服务打满。我的写法是明确给出一个建议值并说明理由间隔太短浪费资源太长影响体验。这类操作性建议手写文档经常漏但恰恰是对接方最需要的信息。5.3 内部命名与对外字段不一致造成的误导生成脚本用处理函数名作为操作标识很快就出了问题有个接口的函数名是内部习惯缩写渲染出来的页面上显示的是这个缩写对接方看不懂只能去翻代码。这不是技术错误是信息传达失败。修法很简单加一张映射表把内部标识映射成人能看懂的名称和一句话说明。这张表需要人工维护但它只有几行而且只在新增接口时才需要动成本可以接受。关键是这张表要和路由校验联动如果路由表里出现了映射表里没有的接口校验直接失败逼着人补上说明。这样就避免了新接口偷偷溜进文档却没有可读名称。顺带说一个相关问题某些字段在代码内部叫一个名字在对外接口上叫另一个名字中间有一层转换。文档必须用对外名字同时可以在描述里提一句内部对应关系方便看代码的人对照。如果不提对接方拿着文档去搜代码会搜不到浪费大量时间。5.4 中文描述与编码问题的连环反应描述用中文写读起来最自然但生成流程里每一环都可能对编码有假设。我遇到过两次一次是某个工具的默认编码不是 UTF-8写文件时中文变成乱码另一次是规范文件本身没问题但渲染器读取时用了错误编码页面上的中文全变成问号。两次的现象不同根因一样。现在的做法是所有写文件的地方显式指定编码所有读文件的地方也显式指定不依赖任何默认值。同时在校验步骤里加一条检查打开生成的规范文件确认中文描述能正常读出且长度符合预期。这条检查很便宜但能挡住一整类问题。还有一个隐蔽的坑是某些特殊符号。中文描述里如果混了全角引号或者不间断空格某些解析器会报错而错误信息指向的行号往往和真正的问题位置差很远。我的习惯是描述里只用最普通的标点需要强调的地方用普通引号不追求排版好看。文档的第一目标是能解析、能显示其次才是好看。5.5 生成脚本自身的可维护性最后一个坑不在技术上在习惯上。生成脚本写完能跑之后很容易被当成黑盒谁也不敢改。等到需要加一个接口或者调整结构时发现脚本里到处是硬编码改一处崩一处。我现在写这类脚本会刻意做两件事。一是把所有路径、端口、模块名这些跟环境相关的东西抽成参数脚本本身不假设目录结构。二是把每个步骤拆成独立函数枚举路由、加载模型、拼装、写出各自独立中间用普通数据结构传递方便单独测试某一环。这样即使半年后回来改也能快速定位该动哪里。还有一点值得提脚本要能脱机运行。有些实现为了拿路由表会真的启动一个服务这在持续集成环境里很麻烦要处理端口占用、启动超时、进程回收。能通过导入模块拿到应用对象就不要起服务实在不行也要设计成用完即关不留后台进程。我在一个环境里就因为后台进程没关连续几次构建都失败排查了半天才发现是端口被占。6. 几点补充经验如果这套流程你也打算搭一份有个顺序建议先把路由表枚举出来并纳入版本管理这一步最便宜也最有价值然后把请求体收敛成模型这一步最费时间但收益最大最后才是渲染和校验这一步可以慢慢迭代。不要一上来就追求漂亮的可交互页面页面好不好看对正确性没有帮助反而容易让人误以为文档已经做好了。另一个提醒是关于自动的边界。自动生成能保证结构和枚举永远准确但保证不了描述写得清楚。字段说明、使用建议、常见错误这些内容只能人来写而且需要持续打磨。我现在的做法是每当有对接方来问一个问题就把答案补进对应字段的描述里。问过三次以上的问题一定值得写进文档。这样文档是靠真实提问长出来的每一句都有用。