多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从HAR抓包到Schema重建:REST与GraphQL API逆向文档树实战

从HAR抓包到Schema重建:REST与GraphQL API逆向文档树实战 做API逆向重构最有意思的地方不是“拿到接口”——接口谁都能抓到真正麻烦的是把散落在各个请求里的零散信息整理成一棵能看懂、能维护、能直接拿去对接的参考文档树。尤其是当你面对一个同时开了REST和GraphQL两种风格接口的老系统没有源码、没有运维给文档只有一台不断在跑请求的机器和一个抓包工具这时候你才会明白所谓的“逆向”本质上是把一个黑盒系统的行为用工程手段还原成一张结构清晰的地图。这篇文章我打算用一次真实项目复盘的方式把完整链路拆开讲怎么抓流量、怎么抽元信息、怎么把REST端点归一化成资源树、怎么通过GraphQL的introspection能力重建Schema最后怎么把这两套东西统一输出成一个Markdown文档树。全程使用Python核心依赖就是requests、json、pathlib这几个基础库不涉及任何花哨框架。适合的人群是已经开始写爬虫但一直停留在“取个页面、解析个字段”阶段、想做更深一层接口分析和逆向工程的开发者也适合负责对接内部老接口的测试和运维同学。1. 项目整体设计与思路拆解1.1 为什么需要把API“逆向”成参考文档树我见过太多团队用excel表格维护接口清单几十个接口往一个sheet里塞字段名、请求方式、是否必填全靠打电话问。这种粗放的方式在接口少的时候勉强能用一旦接口数量过百或者同时存在REST和GraphQL两套协议就彻底失控了。所谓“逆向重构API”不是去破解什么加密算法而是从实际发生的HTTP请求响应中反推出接口的参数约束、字段类型、对象关系最终形成一份结构化文档。这个过程的产出物就叫参考文档树。参考文档树的价值在于三件事新同学接手项目时不用找三天前离职的同事问接口含义看树形文档就能定位到具体端点和参数。自动化测试可以用它做契约测试请求参数从文档树里随机组合响应字段对照树结构逐层校验。后续要做接口Mock这棵树就是天然的Mock数据生成器。1.2 REST和GraphQL双修的文档树怎么设计REST和GraphQL的形态完全不一样不能混在一张表里。REST的文档树天然是URL路径树/users/123/orders这种结构展开就是一棵目录树而GraphQL的文档树应该是“类型关系图”的文本化表达入口是Query和Mutation中间节点是对象类型叶子节点是标量字段。我选择的是“分库不分家”的方案顶层分rest/和graphql/两个目录各自再往下展开。这样做的好处是两套文档可以分别用不同的脚本生成、单独更新但整体提交到同一个仓库里需要人工看的时候又能从统一入口进入。整套实现的思路可以概括成四个阶段采集、解析、建模、落盘。采集阶段靠抓包工具导出请求记录解析阶段从记录中提取结构化信息建模阶段把信息组织成对象关系和参数约束落盘阶段把模型写成Markdown文件树。每一步的输出都是下一步的输入边界清晰遇到问题也好定位。2. 技术选型与准备工作2.1 抓包与接口采集工具怎么选这个项目的数据源头是接口请求记录。如果你在调试自己开发的前后端直接用浏览器自带的DevToolsNetwork面板里勾选“Preserve log”操作一遍业务就能拿到一份完整的请求列表导出为HAR格式后用Python解析即可。如果目标场景是测试环境里的服务间调用更适合用mitmproxy来做流量镜像。mitmproxy需要配置一下SSL证书的信任这个操作在任何代理工具里都一样。Charles和Burp Suite也能干这件事但我觉得拿来做自动化数据处理还不如用浏览器导出HAR因为HAR本身就是结构化JSON解析起来没有任何成本。这里要强调一个前提整个过程只针对你拥有合法访问权限的系统。自己公司的内部服务、自己买的VPS上部署的应用、公开的纯演示API比如Star Wars GraphQL API拿来做逆向重构学习完全没问题。未经授权对别人的线上系统做接口探测那属于攻击行为不在本文讨论范围内。2.2 Python侧的核心依赖与安装实际写代码时我用到的库很少。requests用来发起请求处理GraphQL的introspection时也用它pathlib负责生成目录结构json是标准库但配合write保证缩进可读。三个库加起来整个脚本不过几百行。pip install requests就这一个第三方库其余全部用Python标准库。不装爬虫框架是因为接口逆向重构的核心工作在“解析和建模”而不是“并发抓取”框架的重抽象在这里反而是负担。requests库简单直接POST一个JSON body、获取响应、抛出状态异常都是爬虫开发里最常用的能力。2.3 信息抽取要抓住三个支点在正式动手前你得清楚要从原始流量里抽哪些信息。我自己总结为三个支点端点信息请求的URL、HTTP方法、是REST还是GraphQL入口。参数约束查询参数、路径参数、请求体字段以及字段的类型、是否必填、默认值。响应结构响应JSON的嵌套结构、字段类型、数组元素类型。这三个支点抓全了文档树就不会缺主干。后续的建模阶段所有操作都是围绕这三个支点做转换和降噪。3. REST API参考文档树逆向实操3.1 从HAR文件中抽取接口元信息HAR文件里每个entry包含request和response两块request里有method、url、queryString、postDataresponse里有content.text。我写了一个轻量解析函数把这些字段转成统一的中间结构。import json from pathlib import Path from urllib.parse import urlparse def parse_har(har_path: str) - list[dict]: with open(har_path, r, encodingutf-8) as f: har json.load(f) entries [] for entry in har[log][entries]: req entry[request] url urlparse(req[url]) query {p[name]: p[value] for p in req.get(queryString, [])} body_text post_data req.get(postData) if post_data and post_data.get(text): body_text post_data[text] resp entry[response] resp_text content resp.get(content, {}) if content.get(text): resp_text content[text] entries.append({ method: req[method], host: url.netloc, path: url.path, query: query, body: body_text, status: resp[status], response: resp_text, }) return entries这段代码不复杂但有两点值得注意。第一queryString在HAR里是数组同一个参数名可能重复出现我直接用字典推导式会让重复参数被覆盖如果业务里确实需要保留要改成dict.setdefault(name, [])的追加模式。第二响应体不一定永远是JSON也有可能是XML或者纯文本所以这里先原样保存为字符串等到做字段分析时再按实际情况解析。整个parse_har函数就是整个项目的数据入口后续所有分析、建模都在它返回的entries列表上展开。3.2 把端点清单变成资源目录树拿到请求列表之后下一步是去掉重复、归一化路径参数。比如系统里真实的请求URL是/api/users/23513/profile和/api/users/77245/profile在文档树里必须合并成一个/api/users/{id}/profile。不做这步归一化文档树会被上千个无意义的ID节点填满等于没做。路径参数归一化我采用的是段位加形态判断URL路径按/拆开如果某一段是纯数字就视为{integer}如果一段超过设定长度比如32位散列值就视为{key}。实际实现如下。import re def normalize_path(path: str) - str: parts path.strip(/).split(/) normalized [] for part in parts: if re.fullmatch(r\d, part): normalized.append({id}) elif re.fullmatch(r[0-9a-f]{16,}, part): normalized.append({key}) elif re.fullmatch(r[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}, part): normalized.append({uuid}) else: normalized.append(part) return / /.join(normalized)路径归一化是纯粹的启发式规则不会有百分之百准确率。比如有的系统用字符串做ID这段规则就识别不了。不过没关系识别不了的字符串段会原样保留后续人工看一遍Markdown文档再微调即可自动化加人工兜底效率和准确率都能兼顾。归一化之后我用一个嵌套字典来建资源树层级是资源段 → 资源段 → 端点 → HTTP方法 → 文档条目。from collections import defaultdict def build_rest_tree(entries: list[dict]) - dict: tree {} for item in entries: path normalize_path(item[path]) parts [p for p in path.split(/) if p] node tree for part in parts: if part not in node: node[part] {} node node[part] method_item { method: item[method], query: item[query], body: item[body], response: item[response], } node.setdefault(_endpoints, []).append(method_item) return tree注意这里_endpoints这个特殊key它挂在路径节点的字典里存储该资源路径下所有绑定的方法和请求样例。为什么不用顶层数组因为字典结构天然支持rest/users/profile这样的路径导航无论是生成目录还是生成文档都方便而_endpoints这个带下划线的key在遍历时很容易被识别出来。3.3 从请求体与响应体反推字段约束字段类型推断是整个REST逆向中工作量最大的部分因为不是每个请求都带了完整的body也不是每个响应都有完整的字段列表。我的做法是“样本合并”同一个端点、同一个方法的所有请求响应样例逐字段做并集字段出现过就算存在类型按出现频次最高的类型标记。def infer_schema_from_samples(samples: list[str]) - dict: field_types {} frequency {} for sample in samples: if not sample: continue try: data json.loads(sample) except json.JSONDecodeError: continue for key, value in data.items(): if key not in field_types: field_types[key] type(value).__name__ frequency[key] 1 else: frequency[key] 1 current type(value).__name__ if current ! field_types[key]: field_types[key] current return field_types这个简单的字段类型推断处理不了嵌套结构。处理嵌套的正确姿势是递归下降对每个值先判断是不是dict或list是则继续深入最终形成一棵JSON Schema风格的结构树。这里给一个更完整的版本def json_to_schema(data): if isinstance(data, dict): properties {} for k, v in data.items(): properties[k] json_to_schema(v) return {type: object, properties: properties} elif isinstance(data, list): items None if data: items json_to_schema(data[0]) return {type: array, items: items} elif isinstance(data, bool): return {type: boolean} elif isinstance(data, int): return {type: integer} elif isinstance(data, float): return {type: number} else: return {type: string}把多个请求体的JSON逐个json_to_schema再合并得出来的结构就是文档树里“参数约束”小节的内容。合并时如果同一个字段一次是string、一次是integer我倾向于标记为string|integer并写进“注意”栏里面避免把类型的多样性抹平。3.4 把REST树落盘为Markdown文档树建好了最终是要给人看的。我用pathlib直接把嵌套字典映射到文件系统每个资源路径对应一个目录路径末端对应的端点方法写进一个Markdown文件。文件里用三段式结构说明、请求参数表、响应字段表。def dump_rest_docs(tree: dict, outdir: str): root Path(outdir) / rest root.mkdir(parentsTrue, exist_okTrue) for dir_path, node in tree.items(): dir_full root / dir_path dir_full.mkdir(parentsTrue, exist_okTrue) endpoints node.get(_endpoints, []) if endpoints: doc_path dir_full / README.md lines [f# {dir_path}, , ## Endpoints, ] for ep in endpoints: lines.append(f### {ep[method]} {dir_path}) lines.append() lines.append(**Query参数**) for k, v in ep[query].items(): lines.append(f- {k} {v}) lines.append() doc_path.write_text(\n.join(lines), encodingutf-8)实际落地时我不会在每个路径下都放README.md因为目录层级多了之后文件爆炸。更常见的做法是把整棵资源树写成一个大的rest-api.md在文档里通过Markdown的多级标题来展示树形结构。这样文件数量可控代码review时也方便看diff。4. GraphQL Schema逆向重构与文档树生成4.1 introspection query怎么用GraphQL和REST最大的差异在于GraphQL本身提供了一套自我描述机制——introspection。只要服务器端没有显式关闭任何GraphQL端点都允许你发一段introspection query来获取完整的类型系统描述。这就是GraphQL“逆向”最友好的地方不需要抓几十个请求一次查询就能拿到全部类型、字段、参数、枚举值信息。introspection query的标准写法如下这段查询可以拿到所有类型名、种类、描述、字段以及字段的类型链。类型链的解析是重点因为GraphQL里字段类型是一个嵌套结构比如[User!]!在introspection返回里会呈现为一串type套ofType的JSON。INTROSPECTION_QUERY query IntrospectionQuery { __schema { queryType { name } mutationType { name } subscriptionType { name } types { kind name description fields(includeDeprecated: true) { name description args { name description type { kind name ofType { kind name ofType { kind name } } } } type { kind name ofType { kind name ofType { kind name } } } } } } } 用Python调用时就是发一次POST请求body里带{query: INTROSPECTION_QUERY}。很多公共GraphQL API都允许匿名调用introspection比如SWAPI的GraphQL版本甚至不需要认证。如果要访问需要登录的接口服务器要求token在做这个之前也顺手带上否则返回的schema里会少掉某些类型和字段。4.2 解析Introspection结果重建类型关系树introspection返回里最麻烦的地方是“类型都是互相引用的”。比如User类型有一个posts字段posts的类型是PostPost类型里又有一个author字段类型又指回User。直接递归遍历会进入循环所以我在解析时一定要维护一个set记录已经解析过的类型或正在解析中的类型。下面这段是我用的解析核心输出结果是一个树形字典每个类型的字段下挂type、args以及对复杂类型的“引用关系”。def resolve_type(type_obj: dict) - str: if type_obj is None: return null kind type_obj.get(kind) name type_obj.get(name) if kind in (SCALAR, ENUM): return name of_type type_obj.get(ofType) base resolve_type(of_type) if of_type else name if kind NON_NULL: return f{base}! if kind LIST: return f[{base}] return base def build_schema_tree(schema: dict) - dict: types {} for t in schema[types]: if t[name].startswith(__): continue fields t.get(fields) field_map {} if fields: for f in fields: field_map[f[name]] { description: f.get(description), type: resolve_type(f[type]), args: [ {name: a[name], type: resolve_type(a[type])} for a in f.get(args, []) ], } types[t[name]] { kind: t[kind], description: t.get(description), fields: field_map, } return typesresolve_type这个函数的本质是把GraphQL的TypeRef递归结构压成一个可读的字符串比如把{kind: NON_NULL, ofType: {kind: LIST, ofType: {kind: OBJECT, name: User}}}压成[User]!。这一步是人类阅读文档树的关键如果直接显示JSON结构大批人会看得一头雾水。4.3 字段关系在文档树里的表达方式类型关系树建好之后落盘成文档时我对字段的引用方式做了区分。标量类型的字段直接写类型名枚举类型写枚举值列表对象类型的字段则写成User - Post这样带箭头的引用形式并在文档里附上跳转链接。这样查文档时顺着箭头就能在类型之间游走相当于构建了路由导航。GraphQL文档树的目录结构我按Query/、Mutation/、Subscription/、Objects/、Enums/分层。树形导航通过文件名前缀实现比如Query_user.md、User.md。同名的类型和Query方法不多见但一旦出现加上前缀就能防止文件名冲突。def dump_graphql_docs(schema_tree: dict, outdir: str): root Path(outdir) / graphql root.mkdir(parentsTrue, exist_okTrue) for type_name, info in schema_tree.items(): kind info[kind] if kind in (SCALAR, INPUT_OBJECT): continue kind_dir root / kind kind_dir.mkdir(parentsTrue, exist_okTrue) lines [f# {type_name}, ] if info[description]: lines.append(info[description]) lines.append() lines.append(## Fields) lines.append() lines.append(| 字段 | 类型 | 参数 | 说明 |) lines.append(| --- | --- | --- | --- |) for field_name, field_info in info[fields].items(): args_str , .join( f{a[name]}: {a[type]} for a in field_info[args] ) lines.append( f| {field_name} | {field_info[type]} | {args_str} | {field_info.get(description) or } | ) (kind_dir / f{type_name}.md).write_text( \n.join(lines), encodingutf-8 )枚举类型的处理就简单得多直接生成一个“枚举值清单”。因为枚举类型在GraphQL里属于叶子节点不需要往下继续展开。4.4 批量请求还是单个请求有人会问一次introspection拿到所有类型之后字段详情都有了还需要给每个类型单独发请求吗我的实操结论是不需要。标准introspection已经包含了所有类型的完整字段定义。只有在字段类型本身需要递归导出“层级关系”时我会在解析脚本里做本地递归而不是再次发请求。每发一次请求都要过鉴权、占服务器资源能一次拿完的数据绝不分两次。如果API响应特别大比如一个大型BFF服务的schema有几百个类型introspection返回可能达到几MB这时候要分片处理。GraphQL没有官方分页introspection但你可以自己构造两个query一个只查type名称和kind另一个按名称批量__type(name: XXX)查单个类型的字段。两次控制在两个请求内完成避免线上服务响应超时。5. 常见问题与排查技巧实录5.1 路径参数无法被启发式规则识别怎么办路径归一化的启发式规则在实际项目里一定会遇到“漏网之鱼”。比如有的系统用/api/packages/1.2.3/files版本号1.2.3既不是纯数字也不是UUID会被原样保留。这会导致不同版本文档被拆成多个目录节点把树撑大。我的处理方式是给normalize_path加一个可选参数extra_patterns允许调用方用正则列表补充自定义命名模式。比如r\d\.\d\.\d可以命中版本号。这个参数从配置文件里读而不是写死在代码里这样换一个项目只需要改配置文件就能适配新的命名规则。5.2 REST字段推断遇到空值响应字段类型推断时最坑的情况是字段存在但值为null。json_to_schema里如果遇到None我默认标记成null类型。但一个字段在正常数据里是string恰好某个样例是null就容易产生误导让你以为这个字段可空。更麻烦的是如果所有样例里这个字段都是null类型直接就是null等于没推出来。建议的兜底方案是把null视为“类型未知”合并时如果一个字段在其他样例里有非null值形态就按非null的类型算同时在文档字段说明里标注“该字段可能出现null”。表驱动里加一列可空 | 是/否/未知生成文档时对应输出。这一列信息对接口调用方非常重要能避免用户在写代码时忽略空指针问题。5.3 GraphQL的introspection被关闭时怎么重建不是所有GraphQL服务都开放introspection生产环境里很多服务器会设置graphql-disable-introspection。遇到这种情况逆向的难度会显著上升。策略是退回到抓包像REST那样采集实际发生的请求和响应从query document里反推使用的字段名和参数。具体做法是用graphql库解析抓到的query字符串得到AST从AST里提取出所有Field节点形成“被使用字段集合”。再把每个请求URL里的operationName和variables对应起来。这样反复采集几十个请求后就能拼凑出大概的类型关系。为了把拼凑的结果落盘成文档树我封装了一个ast_to_field_map(query)函数。from graphql import parse def ast_to_field_map(query: str) - dict: doc parse(query) fields {} def walk(selection_set, prefix): for sel in selection_set.selections: if sel.kind field: name sel.name.value key f{prefix}{name} if prefix else name fields[key] { args: {a.name.value: a.value.value for a in sel.arguments} } if sel.selection_set: walk(sel.selection_set, key .) walk(doc.definitions[0].selection_set) return fields这个方案能重建“使用视图”但重建不了“完整Schema视图”整个文档树里会出现不少未覆盖到的字段。文档里要明确标记“基于采样重构非完整Schema”避免误导。5.4 请求鉴权参数如何处理接口逆向绕不开鉴权。普通的基础鉴权是直接在请求头里带token处理起来很简单——只要你本人在浏览器里登录过直接把请求头里的Authorization内容复制进脚本变量即可。但很多内部系统的鉴权是动态的比如签名参数里有时间戳和nonce请求重放两次就会失效。这个问题的长期方案是让脚本支持从环境变量里读token并且在采集样本时手动标记“携带鉴权”的请求。文档树的元信息里也要保存鉴权要求字段标注“本接口需要Bearer Token”还是“公开接口”。文档树本来就是给人用的把鉴权信息写清楚远比叫别人扒代码看网关配置要省事。5.5 文档树生成之后怎么维护文档树的最大敌人是“生成一次之后再也不更新”。接口一旦迭代旧文档就会变成误导人的东西。为了降低维护成本我的做法是把前文所有脚本串成一个Makefile目标在CI里每天跑一次。流程是这样的上午定时拉取网关最近的请求日志或HAR文件跑一遍build_rest_tree和build_schema_tree然后生成文档和上次生成的文档做diff。如果diff超过100行说明有接口变更在群里自动发一条提醒这样文档树永远是自己更新的状态不需要人工去“记得更新”。实际用下来这个机制坚持了几个月文档树已经成了团队里唯一靠谱的接口说明来源。最后的几点实操体会这整条链路做下来我最深的体会是逆向API文档树不是一次性项目而是一套持续运行的机制。抓包、解析、建模、落盘这些步骤都应该被固化下来变成可重复执行的脚本和规则。不要指望一上来就把所有代码写得完美我第一版也只处理了REST部分GraphQL是后来在项目里遇到第二个服务时才加的。结构上把采集、解析、输出拆成了三个独立模块之后每扩展一种新协议都只是加一个parser的事文档树的输出层几乎不用动。另外这套东西的价值上限由数据质量决定。抓包时操作的业务路径越全逆向出来的文档树就越完整。地铁路线全覆盖一个道理——经常走的那条线你已经滚瓜烂熟但你能保证没去过的那条支线上就一定没有重要接口吗所以我对所有团队的建议都是定期更新抓包脚本把业务主流程和分支流程的请求都捞进去文档树才能真正充当“脚手架”的角色而不是几张见过就忘的表。
返回列表