
1. 项目概述与核心需求拆解1.1 这个ax到底解决什么问题先说结论ax 是我近期做的一个内部工具项目全称可以理解为 API eXplorer核心就一件事——把日常开发里那堆零零散散的接口请求、抓包、调试、排查的活儿收敛到一个能落地、能复用、也能给别人抄作业的流程里。前后端分离做了这么多年接口联调早就成了家常便饭但真正把这块做顺手的人其实不多Postman 里点得飞起到了线上环境出问题就抓瞎Fiddler 和浏览器 DevTools 都能抓包但抓到之后怎么批量重构请求、怎么校验响应、怎么定位超时慢请求每一项都是经验活。我建 ax 这个项目就是想拿一套统一的方案把这些问题串起来。1.2 使用者画像与适用场景我先说清楚这套东西适合谁免得你花时间读完发现用不上。如果你是这么几类人ax 的思路会特别有用第一类是前端工程师联调时被接口跨域、鉴权、参数格式折腾得不行需要快速看清请求到底长什么样第二类是后端工程师要排查线上接口超时、定位请求参数被谁改了、复核回调签名逻辑第三类是测试和运维同学要批量验证接口可用性、做简单的接口巡检和异常监控。我自己是后端出身后来折腾全栈这个工具的很多设计都是从线上出问题了我得最快时间复现并定位这个角度倒推出来的。1.3 为什么值得花力气做一个自己的轮子市面上的工具不是不够好而是它们解决的问题各不一样。Postman 适合手工调试和团队共享接口文档但对抓包—改包—回放这条链路支持得不够直接Apifox 这类工具强在 API 生命周期管理但要等它真正贴近每个团队的业务场景还是有点距离wireshark 倒是抓包神器可对 HTTP 应用层的分析和重构请求来讲太重了。ax 的设计目标很朴素能抓请求、能解析请求、能改请求、能按自己的逻辑批量回放请求、还能顺手把结果落成报表。说白了就是把我在开发中反复重复的那套土办法固化成一个有目录结构、有配置、有文档的工程让下一次遇到同类问题时不用再从零开始试。2. 方案选型与整体架构思路2.1 技术栈选型的取舍逻辑技术选型这件事我踩过不少坑这里直接讲结论和理由。第一层是抓包/请求捕获。浏览器 DevTools 绝大多数场景已经够用但它只能看浏览器发出的请求没法处理客户端 App、服务端到服务端的调用。所以 ax 里我引入了 mitmproxy 作为代理抓包层配合 Python 脚本解析流量。mitmproxy 的优势在于它是可编程的流量经过时可以直接用 Python 钩子处理保存、过滤、改写都方便。当然如果是 HTTPS 流量需要在客户端安装并信任 mitmproxy 的根证书这个在自测环境完全没问题但要注意别在正式生产环境乱挂代理。第二层是请求分析与回放。语言选了 Python库用 requests 和 httpx。requests 大家最熟资料多问题少httpx 支持 HTTP/2在模拟线上环境时会用到。底层用 curl 做了补充因为有些场景下 curl 的 --trace-ascii 能输出比 requests 更细的 TLS 层信息排查握手问题时有奇效。第三层是数据落盘与展示。抓到的请求统一存成 JSONL 格式一行一个请求后续用 Pandas 做聚合统计用 Jinja2 模板生成 HTML 报告。为什么不用数据库因为 ax 的核心定位是轻量、可搬运一个目录拷走就能跑JSONL 在任何环境里都能读比 SQLite 还省心出了问题也方便用文本工具直接处理。2.2 整体架构与请求流转链路整个工具的流程控制在一块我拆成五个阶段捕获 → 过滤 → 解析 → 重构 → 回放与校验每个阶段都是一组独立脚本前一个阶段产出的文件就是后一个阶段的输入。好处是每一层都能单独跑、单独调试数据在磁盘上可见不像有些工具把流程封装得死死的出了 bug 只能干瞪眼。捕获层负责把原始流量落地为 HAR 或 mitmproxy 的 flow 文件过滤层去掉静态资源、第三方 CDN、健康检查之类的噪音请求解析层把 URL、Header、Body、Cookie 等字段标准化并对请求做去重和归类重构层是核心它负责把解析后的请求改造成可回放的格式比如替换域名、刷新 token、修改参数最后回放层发送请求并校验结果把响应码、耗时、body 特征写进报告。2.3 目录结构与模块划分ax 的目录结构尽量扁平方便别的同事接手。长期实践下来项目文件放得太深会让人失去维护欲望放得太散又变成一锅粥。我的目录是这样的ax/ ├── capture/ # 抓包模块mitmproxy 插件 │ └── addon.py ├── parse/ # 解析模块HAR/JSONL → 标准化请求 │ └── parser.py ├── filter/ # 过滤模块按域名、URL 模式、扩展名过滤 │ └── filter.py ├── replay/ # 回放模块请求发送与响应校验 │ ├── requester.py │ └── verifier.py ├── report/ # 报告生成模块 │ └── renderer.py ├── config.yaml # 全局配置 ├── output/ # 所有产出的中间文件与最终报告 └── tests/ # 单元测试和端到端测试用例每个模块只依赖上一层的产物不跨层调用。这个约束帮了大忙有次回放模块出了 bug我直接把 parse 阶段的 JSONL 文件交给同事用他自己的脚本解析两边对拍数据半小时就定位了问题。3. 核心模块剖析与关键技术点3.1 捕获层mitmproxy 插件的实现要点mitmproxy 的插件机制其实很简单本质是定义一个 addons 列表里面挂上实现了特定钩子函数的类。我用到的钩子有两个request(flow) 在请求经过代理时触发response(flow) 在响应返回时触发。# capture/addon.py import json import time from mitmproxy import http class AxCapture: def __init__(self, output_path: str): self.output_path output_path self.buffer [] def request(self, flow: http.HTTPFlow) - None: entry { timestamp: time.time(), method: flow.request.method, url: flow.request.pretty_url, headers: dict(flow.request.headers), body: flow.request.get_text(), source: request, } self.buffer.append(entry) if len(self.buffer) 100: self._flush() def response(self, flow: http.HTTPFlow) - None: entry { timestamp: time.time(), method: flow.request.method, url: flow.request.pretty_url, status_code: flow.response.status_code, response_headers: dict(flow.response.headers), response_body: flow.response.get_text(), source: response, } self.buffer.append(entry) if len(self.buffer) 100: self._flush() def _flush(self) - None: with open(self.output_path, a, encodingutf-8) as f: for item in self.buffer: f.write(json.dumps(item, ensure_asciiFalse) \n) self.buffer.clear() addons [AxCapture(output/raw_requests.jsonl)]这里有个细节缓存 buffer 凑满 100 条再写盘是为了避免每条请求都触发一次磁盘 IO。流量大的时候比如联调时一个页面动辄几十个请求如果每条都同步写文件代理本身会成为瓶颈。实测在本地压测场景下批量写比逐条写快了三倍以上。另一个细节是请求体和响应体都调用了 get_text()这个操作会缓存内容如果你只需要抓包不需要分析 body千万别这么干。内存会随着大响应体比如图片 base64、大 JSON迅速膨胀。生产环境里我通常会加一个 body_size_limit 参数超过阈值直接记为 [truncated]。3.2 解析层把乱糟糟的请求变成结构化数据从抓包层拿到的数据是相对原始的有大量重复字段、不同团队的接口风格各异、URL 里还可能带各种动态参数。解析层要干的事情就是把这堆东西清洗成标准结构。我定义的标准化请求格式如下{ id: req_001, method: GET, scheme: https, host: api.example.com, path: /v2/user/info, query: {uid: 12345, tab: profile}, headers: {Accept: application/json, X-Login-Token: ...}, body: {}, meta: {capture_time: 1710000000, source: mitmproxy} }解析的核心难点在 query 和 body 的拆分。URL 里的参数看起来是 keyvalue 结构但 value 可能被 URL 编码过还可能有同名参数比如 ?tagatagb简单用 split() 处理会丢数据。我直接用了 urllib.parse.parse_qs它能处理同名参数返回字典里每个 key 对应的 value 是列表保留全部数据。body 的解析更麻烦。常见的有三类JSON、application/x-www-form-urlencoded、multipart/form-data。JSON 用 json.loads 解析成对象方便后续改参数表单类用 parse_qsmultipart 就用正则先粗切出各字段再逐个处理。我在实际项目中见过最坑的是一部分字段是 JSON 字符串、一部分是普通文本的混合表单那个需要单独写逻辑。3.3 过滤模块去噪的几种实用姿势抓包半小时有效请求可能就十几个其余全是静态资源、埋点、日志上报、心跳探测。过滤模块我写了三种模式按优先级从高到低排列第一域名过滤。用 allow_hosts 和 deny_hosts 两个列表。比如只保留 api.example.com其余一概丢弃。实现上直接比对 URL 的 netloc 字段不需要引入正则性能好也好理解。第二路径模式过滤。有时候同一域名下既有业务接口也有静态资源比如 /static/、/assets/、/healthz。这时用前缀匹配加通配符规则配置写在 YAML 里。# config.yaml filter: deny_extensions: - .js - .css - .png - .jpg - .svg - .woff - .woff2 deny_path_prefixes: - /static/ - /assets/ - /healthz - /metrics allow_path_patterns: - /api/v\d/.*第三关键词采样。有些请求本身没有明显特征但它的响应里包含指定关键词比如 error、timeout这种我会在解析之前先用轻量探测请求过滤一遍。这个方法不算优雅但在面对不清楚内部结构的第三方 API 时命中率比纯规则高得多。3.4 重构层把抓到的请求改成能复用的请求这是 ax 里最有价值的一个模块也是我花最多时间打磨的地方。抓包只是看见流量重构才是把流量变成生产力。重构层要做三件事。第一是改写 URL把测试环境的域名替换成目标环境的域名把 query 里过期的时间戳参数更新。第二是刷新鉴权联调环境通常带着登录态token 有有效期直接回放会 401这个模块里我留了一个 Signer 接口具体实现由使用者按自己业务的签名逻辑去写。第三是参数改写有时抓到的请求里有不能被回放的字段比如一个下单接口的 order_id如果不改回放时可能直接命中重复下单校验。# replay/requests.py class RequestReconstructor: def __init__(self, host_mapping: dict, signerNone): self.host_mapping host_mapping self.signer signer def reconstruct(self, raw_request: dict) - dict: rebuilt { method: raw_request[method], url: self._rewrite_url(raw_request[url]), headers: dict(raw_request[headers]), body: raw_request.get(body, {}) } # 移除 hop-by-hop 头这些头只对单跳有效 for hop_header in (Connection, Keep-Alive, Proxy-Authenticate, TE, Trailer, Upgrade): rebuilt[headers].pop(hop_header, None) if self.signer: rebuilt[headers][X-Timestamp] str(int(time.time())) rebuilt[headers][X-Signature] self.signer.sign(rebuilt) return rebuilt重放请求前移除 hop-by-hop 头这件事是我踩过一次大坑后长记性加上的。第一次重构请求时只换了 Host 和 token结果请求发过去一直报 400排查半天发现是请求头里带了一个原始环境特有的 Connection: close 和 Upgrade 头目标服务器完全不认这套直接给拒了。后来我翻 RFC 7230 才意识到这类头只对当前传输链路有意义不应该被转发到下一跳。4. 实操过程与核心环节实现4.1 最小可运行示例从抓包到回放理论讲再多不如直接跑一遍。我拿一个用户信息查询接口作为例子演示 ax 的完整链路。假设要抓取的请求长这样GET https://api.example.com/v2/user/info?uid10001tabprofile头部带 X-Login-Token: abc123。第一步启动 mitmproxy 代理并加载插件mitmproxy -p 8081 -s capture/addon.py --set block_globalfalse--set block_globalfalse 是让代理允许来自非本机的连接如果只是本机调试可以省略。然后让客户端浏览器或 App走 127.0.0.1:8081 这个代理正常操作一遍要复现的流程。跑完后 output/raw_requests.jsonl 里会抓到全部流量下一步做过滤python parse/parser.py --input output/raw_requests.jsonl --output output/parsed.jsonl python filter/filter.py --input output/parsed.jsonl --output output/filtered.jsonl --config config.yaml这两步跑完可以用 jq 快速看一眼过滤后的数据长什么样jq {method, url, status_code} output/filtered.jsonl | head -20可以看到只剩业务接口了。第三步是配置重构规则。在 config.yaml 里加一段replay: host_mapping: api.example.com: api.pre.example.com auth: type: header key: X-Login-Token value_env: AX_TOKEN # 从环境变量读避免硬编码然后执行重放python replay/requester.py --input output/filtered.jsonl --output output/replay_result.jsonl回放脚本的核心逻辑很简单读入每条请求重构发送记录结果。# replay/requester.py import json import time import requests from .reconstructor import RequestReconstructor def replay(input_path: str, output_path: str, config: dict): recon RequestReconstructor( host_mappingconfig[host_mapping], signerNone, ) results [] with open(input_path, r, encodingutf-8) as f: for idx, line in enumerate(f): req json.loads(line) rebuilt recon.reconstruct(req) start time.time() try: resp requests.request( methodrebuilt[method], urlrebuilt[url], headersrebuilt[headers], jsonrebuilt[body] if rebuilt[body] else None, timeout10, allow_redirectsFalse, ) results.append({ id: freq_{idx:04d}, url: rebuilt[url], status_code: resp.status_code, elapsed_ms: round((time.time() - start) * 1000, 2), response_preview: resp.text[:500] }) except requests.exceptions.Timeout: results.append({ id: freq_{idx:04d}, url: rebuilt[url], status_code: -1, elapsed_ms: -1, response_preview: TIMEOUT }) with open(output_path, w, encodingutf-8) as f: for item in results: f.write(json.dumps(item, ensure_asciiFalse) \n)4.2 鉴权与签名逻辑的处理刚才代码里 Signer 是空着的这块我单独拉出来说因为它是实际落地时最容易卡壳的地方。常见的有四类鉴权场景。第一类最简单Header 里塞一个静态 token从环境变量里读出来直接填。第二类是动态 token比如登录后返回的 access_token带过期时间需要在回放前先调一次登录接口刷新 token。第三类是签名请求参数按字典序排列后拼接密钥做哈希签名的算法通常由后端定死每个人写的实现都略有不同。第四类是 Cookie 鉴权这个稍微麻烦一点因为有些接口依赖 Cookie 里的多个字段回放时得完整带过去。我的通用做法是给 Reconstructor 传入一个 auth_context 对象它有几个方法# replay/auth.py class AuthContext: def refresh_token(self) - str: # 通过登录接口拿到新 token pass def sign(self, payload: dict, secret: str) - str: # 按业务签名规则生成签名 pass具体某个项目的签名算法是什么外人没法替你定我能给的建议是把签名和验签的函数单独放到一个模块不要散落在各个脚本里方便测试。4.3 响应校验与数据落盘回放请求只是前半程后半程是判断响应对不对。我常用的校验手段有三种按成本从低到高排列。第一种是状态码校验最简单前提是你知道接口正常情况下应该返回什么。200 不一定就是好的有些接口处理逻辑异常时照样返回 200业务错误码在 body 里。所以我会在配置里加一个 expected_status 字段默认 200也支持列表。第二种是 JSON 路径校验。用 JSONPath 表达式从响应 body 里提取指定字段跟预期值做对比。比如 {$.code: 0, $.data.user_id: 10001}这种校验方式比较灵活能覆盖大部分业务字段的检查。第三种是响应体大小和耗时统计分析。回放完成后结果文件里的每条记录都带 elapsed_ms 和 status_code我用 Pandas 做个简单聚合计算 p50、p90、p95、p99 耗时统计各状态码占比识别慢请求。# report/summary.py import pandas as pd def generate_summary(result_path: str) - dict: df pd.read_json(result_path, linesTrue) ok_df df[df[status_code] 200] summary { total: len(df), ok_count: len(ok_df), ok_rate: round(len(ok_df) / max(len(df), 1) * 100, 2), elapsed_p50_ms: ok_df[elapsed_ms].quantile(0.5).round(2) if len(ok_df) else None, elapsed_p90_ms: ok_df[elapsed_ms].quantile(0.9).round(2) if len(ok_df) else None, elapsed_p95_ms: ok_df[elapsed_ms].quantile(0.95).round(2) if len(ok_df) else None, elapsed_p99_ms: ok_df[elapsed_ms].quantile(0.99).round(2) if len(ok_df) else None, } return summary用分位数而不是平均值来评耗时是因为平均值容易被少数几个慢请求带偏p95 更能代表用户真实体验。这是做性能分析的基本功后面排查问题时极有用。4.4 报告生成一份能直接甩给同事看的产出在团队协作中报告的价值往往一点不比调试工具本身低。我常用的产出格式有两种HTML 和 Markdown。HTML 用 Jinja2 模板渲染适合发给同事在线看或集成到 CI 的 artifactMarkdown 适合贴到文档或合并到 MR 描述里方便代码评审时直接引用。报告至少包含四块内容回放概览总请求数、成功率、耗时分布、失败请求清单状态码、URL、失败原因、慢请求 TOP 20、以及请求明细表可展开包含 header 和 body 的摘要。# report/renderer.py from jinja2 import Template import json HTML_TEMPLATE html headtitleReplay Report/title/head body h1API Replay Report/h1 pTotal: {{ total }} | Ok: {{ ok_count }} | Rate: {{ ok_rate }}%/p table border1 trthID/ththURL/ththStatus/ththElapsed(ms)/th/tr {% for r in results %} tr td{{ r.id }}/td td{{ r.url }}/td td{{ r.status_code }}/td td{{ r.elapsed_ms }}/td /tr {% endfor %} /table /body /html def render_html(results: list, output_path: str): t Template(HTML_TEMPLATE) html t.render(resultsresults, totallen(results)) with open(output_path, w, encodingutf-8) as f: f.write(html)这里要提一个坑Jinja2 模板渲染时如果 URL 里带着未转义的特殊字符生成的 HTML 可能会错乱。我一般会在渲染前对 URL 做一次 urllib.parse.quote 处理或者用 Jinja2 自带的 escape 过滤器实测下来前者更稳。5. 常见问题与排查技巧实录5.1 回放请求超时或连接被重置这是问得最多的问题。回放时明明抓包时还好好的一发出去就超时。我总结下来主要三个原因。第一目标环境网络不通或者当前机器跟目标环境之间有防火墙只允许特定 IP 访问。排查方法很简单先用 curl 直接打一次目标接口排除工具因素。第二代理配置残留。有些脚本里设置了环境变量 HTTP_PROXY/HTTPS_PROXYrequests 会默认读取这些变量走代理导致回放请求又绕回 mitmproxy 或不知名的代理上反复握手最终超时。解决办法是在 requests.request 调用里显式传 proxies{http: None, https: None}覆盖掉环境变量。第三连接池耗尽。高并发回放时 requests.Session 的默认连接池为 10超过就要排队。需要扩容直接指定session requests.Session() adapter requests.adapters.HTTPAdapter(pool_connections50, pool_maxsize100) session.mount(https://, adapter) session.mount(http://, adapter)5.2 响应乱码与编码识别问题抓到的响应体打开一看全是乱码第一反应是被压缩了。先检查响应头里的 Content-Encoding如果是 gzip 或 brrequests 在正常调用时会自动解压但如果你直接用 raw 模式或者拿到的是自己用 socket 收的数据就要手动解压。还有一个很容易忽略的情况响应头里没有字符集信息但实际是 UTF-8有的老系统会按 GBK 发。我的处理办法是先用 charset-normalizer 库探测探测失败再回退到 GBK。这个库比 chardet 快准确率也更高一些。5.3 抓包能看见请求回放却总是 401这个问题的根源多半不是签名算法变了而是少带了某个 Header 或 Cookie。最典型的场景是调用有一个前置接口先给浏览器种了一个 Cookie后续接口都依赖这个 Cookie 里的 session 标识。抓包时浏览器会自动带上但你手动构造请求时根本没有这一步。解决办法是检查请求链找出种 Cookie 的接口在回放脚本里先跑一次该接口把返回的 Set-Cookie 解析成字典再给后续请求带上。还有一类 401 是因为请求头顺序不同。个别服务端对签名校验的实现比较脆弱虽然没有规范要求必须按特定顺序拼接但有些后端懒直接用原始字符串做校验。我的建议是如果怀疑这个问题拿抓包时的原始 header 顺序原样回放先排除这个因素。5.4 慢请求定位是网络问题还是服务端问题回放结果里看到某个接口耗时 p99 超过 3 秒第一件事不是怀疑服务端而是先区分耗时发生在哪个环节。我在 requester 里会额外记录 DNS 解析耗时、TCP 建连耗时、首字节耗时和总耗时# replay/requester.py 中的耗时细分 resp session.send(req, streamTrue) total time.time() - start # 结合 urllib3 的 connection 属性可以拿到更细的指标如果是 DNS 解析慢大概率是本地 DNS 配置的问题或者目标域名解析到了异常节点。首字节耗时高说明服务端处理慢需要看服务端日志。总耗时高但首字节快说明响应体下载慢考虑是不是带宽受限或者响应体太大。这一套分析路径我整理成了一张速查表现象优先排查项工具/命令请求超时代理残留、防火墙curl -v, 检查 ENV 代理变量连接被重置TLS 版本不匹配、连接池openssl s_client, --trace-ascii大量 401缺 Cookie、签名算法变化对比原始 header 顺序首字节慢服务端处理慢、数据库慢查询服务端 trace慢日志整体慢DNS 解析、带宽dig, 下载测速响应乱码Content-Encoding、字符集检查响应头charset-normalizer6. 进阶扩展从单次分析到持续巡检6.1 把回放脚本变成定时任务ax 做大了之后我给它加了一个轻量巡检能力本质上就是用 cron 或系统计划任务把回放脚本定时跑起来。比如说每天凌晨拉取一次核心接口的请求快照回放到生产环境把结果写成报告再推送通知。这块我建议你不要一上来就想着上 Prometheus 那一套重型监控那是运维团队干的活作为一个开发自用工具先用最简单的定时触发加报告落盘观察几天有没有异常告警需要再升级。巡检脚本比单次回放多一个告警逻辑。我用了最简单的规则成功率低于 99% 或 p95 耗时超过 2 秒就发一条通知。通知渠道可以用钉钉/企微的 Webhook也可以用邮件看你们公司习惯什么。6.2 与 CI/CD 流程的集成注意点如果你打算把 ax 集成到 GitLab CI 或 Jenkins 流水线里有几个额外的坑要提醒。第一不要在 CI 环境里跑抓包。CI 容器通常没有 GUI 浏览器也没有真实用户操作路径抓包场景天然缺失。正确用法是直接把之前抓好的请求快照文件作为测试数据提交到仓库里注意脱敏流水线里只跑解析、重构、回放、校验这几个环节。第二token 不要写进代码仓库。用 CI 变量传或者在流水线的前置步骤里动态获取。第三回放的请求里如果包含真实用户数据手机号、身份证等落盘前一定要做脱敏。我在 filter 模块里加了一个 desensitize 步骤对常见字段做正则匹配后替换成占位符。# parse/desensitize.py import re SENSITIVE_FIELDS [phone, id_card, name, address] def desensitize(obj): if isinstance(obj, dict): for key in list(obj.keys()): if key.lower() in SENSITIVE_FIELDS and isinstance(obj[key], str): obj[key] mask(obj[key]) else: desensitize(obj[key]) elif isinstance(obj, list): for item in obj: desensitize(item) return obj def mask(value: str) - str: if len(value) 4: return * * len(value) return value[:2] * * (len(value) - 4) value[-2:]脱敏这步一定不能省。宁可脱敏后丢了部分排查信息也不能把用户隐私数据带进日志或报告。6.3 数据规模上来之后的性能优化当一次抓包的量到几万条时纯脚本处理就开始吃紧。我的经验是先在抓包阶段就做粗过滤能丢掉的静态资源绝对不留这一下能减少 80% 的数据量。解析阶段用 Python 的 multiprocessing 按文件分片并行跑每个进程处理一个分片最后合并。实际上在绝大多数场景下这个优化做了之后后面根本不会再卡。真到了单次几十万条请求的规模那不应该用 Python 脚本直接上 ELK 或者 ClickHouse 才是正确姿势。我自己在实际使用中最大的体会是工具的价值不在于功能堆得多花哨而在于你遇到问题时能不能用它最快地找到答案。ax 的核心不是某个模块写得多么巧妙而是把抓包—解析—重构—回放—校验—报告这条链路完整地打通了。你完全不需要照抄我的代码但可以把这套思路拆开按自己的业务场景组装一个顺手的版本。每次接到一个需要排查前端的接口问题我都在后悔之前没更早地把这套流程固定下来——好在现在补上了后面的日子确实轻松了不少。