☰
从HAR抓包到Schema重建:REST与GraphQL API逆向文档树实战
2026/10/9 8:36:48 网站建设 项目流程

做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 抓包与接口采集工具怎么选

这个项目的数据源头是接口请求记录。如果你在调试自己开发的前后端,直接用浏览器自带的DevTools,Network面板里勾选“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、postData,response里有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", encoding="utf-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(parents=True, exist_ok=True) for dir_path, node in tree.items(): dir_full = root / dir_path dir_full.mkdir(parents=True, exist_ok=True) 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), encoding="utf-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的类型是Post,Post类型里又有一个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 types

resolve_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(parents=True, exist_ok=True) 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(parents=True, exist_ok=True) 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), encoding="utf-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的事,文档树的输出层几乎不用动。

另外,这套东西的价值上限由数据质量决定。抓包时操作的业务路径越全,逆向出来的文档树就越完整。地铁路线全覆盖一个道理——经常走的那条线你已经滚瓜烂熟,但你能保证没去过的那条支线上就一定没有重要接口吗?所以我对所有团队的建议都是:定期更新抓包脚本,把业务主流程和分支流程的请求都捞进去,文档树才能真正充当“脚手架”的角色,而不是几张见过就忘的表。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询