起因
我日常要调一堆内网接口。Postman 越做越像一个协作平台:账号、云同步、工作区、团队空间,启动要等,内存也不客气;国内网络下登录态还时不时掉。Insomnia 后来也走了同一条路。
我想要的其实很小:一个双击就能用的本地程序,能拼请求、发包、看结构化响应、管集合和环境变量、粘贴一条 cURL 就能还原,所有数据都只在我这台机器上。
于是有了 Kotae(日语「答え」,回答):一个纯本地的轻量 API 调试工具,界面全中文,不连云端、不需要后端、不需要账号。目前到 0.2.3,Windows / macOS 都有安装包。
底座是 MyGo(Go 桌面应用框架,当前 0.7.4):窗口用系统 webview、前端就是普通 Vite 项目,打包 / 安装包 / 自动更新 / 托盘 / 单实例由框架这一层解决。
先给几个数字,这样后面的技术取舍才有参照:
- 可执行文件
Kotae.exe12.7 MB(Vite 构建的前端已经内嵌进二进制) - NSIS 安装包 4.8 MB
- 前端运行时依赖只有 3 个 MyGo 插件 +
mygo-runtime,没有 UI 框架、没有组件库、没有图标库 - 端到端用例 105 个,跑的是真实二进制 + 真实窗口 + 真实 Go 发包
技术形态:Go 桌面壳 + 系统 WebView
技术栈是 Go 写桌面壳(MyGo 框架)+ 系统 webview 里跑一个普通的 Vite / TypeScript 前端。
这和 Electron 的根本差别在于:不打包 Chromium。窗口用的是系统自带的 webview(Windows 上就是 WebView2,Win11 预装),所以产物里没有那 100MB 级别的内核,前端 dist 只有 325 KB,压缩后随二进制走。
代价也要说清楚:
- 行为受系统 webview 支配,Windows 上拿到什么 WebView2 版本就用什么版本(后面有个性能坑就和它有关)
- 没有 Node.js 运行时,任何"本地能力"都得从 Go 侧开一个方法出来
- 打包、签名、更新这一套要按框架的约定来,不能直接复用 Node 生态的脚手架
对一个只做本地 HTTP 调试的小工具,这个交换很划算。
分层:Go 只做平台能力,业务一行不写进 Go
项目一开始就定了一条线,后面所有代码都绕着它长:
Go 侧不解析业务数据、不碰集合与标签页,只提供平台能力;业务逻辑(集合、历史、环境、发送、插值、持久化)只写在
src/store.ts。
main.go 里除了装配就是装配:
go
jar := newToggleJar()
// 请求全部由 Go 发出:不受 CORS 限制,可写任意请求头。
// 超时不放在 http.Client 上,由前端按请求设置 AbortSignal 计时,
// 这样每条请求可以用不同的超时。
client := &http.Client{Transport: newRoutingTransport(netproxy.Transport()), Jar: jar}
mygo.Use(fetch.New(fetch.Options{Client: client}))
mygo.Use(sqlite.Plugin)
// WebSocket 也由 Go 建立:可以带握手请求头,不受页面同源策略限制。
mygo.Use(websocket.New(websocket.Options{Client: client}))
mygo.Use(updater.New(updater.Options{Language: "zh"}))
mygo.Bind(&System{jar: jar})
System 上绑的方法一共就那么十几个:剪贴板、保存/打开文件、选文件、打开数据目录、系统通知、Cookie 罐开关、单实例就绪。没有一个方法知道"集合"是什么。
前端那一侧的绑定是生成的:npm run generate 把 Go 的公开方法编成 src/mygo.ts,文件头写着 DO NOT EDIT。改了 Go 签名忘了重跑生成,前端拿到的就是旧契约------这条被写进了项目的硬规则里。
业务全在 TypeScript 里。热点文件 src/store.ts 有 2800 行,管着集合树、标签页、环境、插值、发送计划、SSE/WebSocket 流、分层脚本执行;src/ui/* 只做渲染和事件转发。这条边界在半年后救过我:改 UI、改发包、改存储,三件事从不在同一个文件里打架。
决策一:请求必须由 Go 发
在浏览器里做 API 调试工具有四个硬伤:
- CORS :跨域预检直接挡住,很多内网接口压根没配
Access-Control-Allow-Origin - 受限请求头 :
Origin、Cookie、User-Agent、Referer都改不了,而这几个恰好是调接口时最常需要伪造的 - 读不到
Set-Cookie:响应里的这个头对 JS 不可见 - Cookie 是浏览器的:登录态和你日常浏览混在一个罐里,想清空就清空不了
所以请求由 Go 侧发出,前端只是拼参数。四个问题一次消失。
但"交给 Go 发"并不等于把 net/http 往上一扔,中间有三个不显然的地方。
超时不放在 http.Client 上
http.Client.Timeout 是客户端级 的,一套 Client 只能有一个。而"逐请求超时"是这个工具的基本要求------查库存的接口 1 秒,导报表的接口 60 秒。
所以 Client.Timeout 留空,前端用 AbortSignal 计时,取消时把 signal 传下去。
请求级代理怎么塞进共享 Client
代理与"跳过 TLS 校验"在 UI 上是每条请求 的选项,但 fetch 插件只有一个共享的 http.Client。两条路:要么改框架(不划算),要么在到达 Transport 之前把请求级选项"走私"进去。
选了后者:前端给每条请求带两个内部头,routingTransport.RoundTrip 里剥掉它们,按 (proxy, insecure) 换一条对应的 *http.Transport:
go
func (rt *routingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// 复制一份再动头:原始请求还留在 http.Client 的重定向链里,改它会污染 via。
out := req.Clone(req.Context())
proxy := strings.TrimSpace(out.Header.Get(proxyHeader))
// ... 取 insecureHeader,两者都空就直接走 base
if proxy != "" || insecure {
next, err := rt.transportFor(proxy, insecure)
if err != nil { return nil, err }
return next.RoundTrip(out)
}
return rt.base.RoundTrip(out)
}
transportFor 按 key 缓存 *http.Transport(连接池要复用,不能每条请求新建),并校验代理协议只允许 http / https / socks5,非法时返回一句中文提示:代理地址 %q 不合法,示例:http://127.0.0.1:7897。
这两个头在发出前被删掉,服务器看不到,用户也看不到。
重定向链路只能自己采
http.Client 自己完成跳转,页面只拿到最终响应。但"这条请求到底跳了几次、每次落在哪、DNS 和 TLS 各花了多久"是排查代理和网关问题的关键信息。
采集点只能放在 Transport 里:前端给每条请求一个 trace id 塞进 X-Mygo-Trace-Id,Transport 给这一跳挂 httptrace.ClientTrace:
go
func (t *hopTimer) trace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
DNSStart: func(httptrace.DNSStartInfo) { t.dnsStart = time.Now() },
DNSDone: func(httptrace.DNSDoneInfo) { t.dnsDone = time.Now() },
GetConn: func(string) { t.connStart = time.Now() },
GotConn: func(info httptrace.GotConnInfo) { t.connDone = time.Now(); t.reused = info.Reused },
TLSHandshakeStart: func() { t.tlsStart = time.Now() },
TLSHandshakeDone: func(cs tls.ConnectionState, err error) { t.tlsDone = time.Now() },
GotFirstResponseByte: func() { if t.firstByte.IsZero() { t.firstByte = time.Now() } },
}
}
一跳一条记录(方法、URL、状态码 + DNS / 连接 / TLS / 首字节 / 总计,GotConn 的 Reused 还能标出"这次没重连"),按 trace id 累加后 JSON + base64 塞回 X-Mygo-Trace 响应头,页面解出来渲染成「链路」页签。
两个配套的小决定:
- 内部头的命名统一带
X-Mygo-前缀,页面渲染响应头列表时把这批过滤掉。否则用户会在自己的响应头里看到一堆框架内部字段,非常脏。 - trace 的 map 有上限(
traceLimit = 64),按插入顺序丢最早的。页面万一来不及取(用户把标签页关了),内存也不会一直涨。
决策二:WebSocket 也交给 Go
浏览器的 new WebSocket(url, protocols) 没有 headers 参数。这意味着:
Authorization带不进去(要鉴权的 WS 服务只能靠 query 传 token,而这通常不是服务端设计的用法)Cookie只能靠浏览器自动带,你没法指定- 子协议只能通过第二个参数传,想额外加头也没门
对接口调试工具来说这是致命的。所以 WebSocket 由 Go 侧完成握手与帧编解码,并且复用同一个 http.Client ------ 这一句是重点:Cookie 罐是共享的,页面上先调一次登录接口拿到的 Cookie,接着连 WS 时自动进握手,不需要手动搬运。
用起来是这样:协议从 HTTP 切成 WebSocket,握手头就是普通请求头表格(Authorization、Cookie 随便写),子协议从 Sec-WebSocket-Protocol 里取;连上后消息分「收 / 发 / 系统」三类,面板底部输入框回车发送。
边界也得自己定,不能无限收:单个连接最多保留 500 条消息、单条最多 256 KB,超出裁掉并在界面提示。
SSE 反过来留在前端 :它就是一次普通的 HTTP 响应流,页面按事件块自己解析(event / data / id 三类字段,注释行当心跳丢弃,多行 data 拼成一条),复用同一套 fetch、同一个取消按钮。为它再开一条 Go 通道没有收益。
决策三:存储从 JSON 换成 SQLite,而且是差异提交
0.1 的存储是 userData 下的 workspace.json:每次改动全量序列化、全量写盘。
三个问题很早就暴露了:
- 连续编辑(拖分栏、输入变量)会在几十毫秒内触发多次全量写
- 写到一半崩溃 = 整个工作区损毁,因为覆盖是原子的,但内容是整体的
- 想统计、想 grep、想手工修一条数据,全靠文本编辑器
现在的存储层是 SQLite,用的是 MyGo 的 sqlite 插件:预编译的 sqlite3 通过 purego 加载,不需要 cgo ,mygo build 会把对应平台的原生库(Windows 上是 mygo-sqlite3.dll,约 1.4 MB)放到产物旁边一起打进去。用户装完就能用,不用单独装数据库。
表拆成 collections / folders / requests / history / environments / variables / tabs / settings / meta,集合树的层级和顺序由 position 列还原,文件夹与请求共用一个序号空间。
真正值得写下来的是 save() 的三条性质(下面省略了错误处理):
ts
export async function save(data: PersistedWorkspace): Promise<void> {
const next = rows(data); // 内存快照展开成行,每行带 signature
const previous = written; // 上次写成功的行
const statements: Statement[] = [];
const kept = new Set<string>();
for (const row of next) {
const key = `${row.table}:${row.id}`;
kept.add(key);
if (previous.get(key) === row.signature) continue; // 内容没变,跳过
statements.push({ sql: row.sql, params: row.params });
}
// 上次有、这次没有 = 被删掉的行(或历史被裁剪)
for (const key of previous.keys()) {
if (kept.has(key)) continue;
const cut = key.indexOf(":");
statements.push({ sql: `DELETE FROM ${key.slice(0, cut)} WHERE id = ?`, params: [key.slice(cut + 1)] });
}
if (statements.length === 0) return;
await db.transaction(statements); // BEGIN IMMEDIATE,整批一个事务
written = new Map(next.map((row) => [`${row.table}:${row.id}`, row.signature]));
}
一、内容没变就不写。 每行带一个 signature,是这行内容的 JSON.stringify,不含时间戳 。否则每一次保存都会因为 updated_at 变化而重写全库------这个坑第一版就踩了,表现是"只改了一个环境变量的值,数据库文件每秒磨一次盘"。
二、删行必须 DELETE。 只做 INSERT ... ON CONFLICT DO UPDATE 会让删掉的集合留在库里,重启后又"复活"。所以按 table:id 做差集,剩下的当删除处理。
三、整批一个事务,失败不更新指纹。 写入抛错时 written 保持旧值,下一次保存会把整批差异重新尝试一遍。这条比"回滚"更重要------它保证了"内存里看到的 = 库里有的"这个不变量迟早收敛。
再叠一层 220ms 防抖,把连续编辑合批:
ts
saveTimer = window.setTimeout(() => { /* 真正 save */ }, 220);
迁移路径也定死了:首次启动把旧的 workspace.json 读进来迁进库,然后把原文件改名成 workspace.json.bak(不删 );解析失败就跳过迁移并在界面提示。版本判断只有一个入口------meta.schema_version,禁止靠"表存不存在"猜版本。
决策四:脚本不假装沙箱
0.2.x 加了 Postman 风格的 pm 脚本:前置脚本算签名、改请求;后置脚本解密响应、拿 401 换 token 重发。
实现上做了一个不太"安全"但很诚实的决定:脚本用 AsyncFunction 在页面里直接跑,没有沙箱隔离 。同一段代码能拿到 window、localStorage、整个文档 DOM。
为什么不套 iframe + sandbox 或者别的隔离层?因为要做到真正的隔离,得把 pm 的全部能力序列化跨边界传递,还要处理异步、对象引用、错误堆栈------这是另一个项目的工程量,而且它给人的安全感是假的(隔离层本身也能被绕过)。
于是把边界显式化:
脚本是用户自己写的本地 JS。所以来自导入文件(Postman Collection / HAR)的脚本一律标「未信任」,不进执行链;用户在脚本库里点「信任并启用」之后才执行。
这是一个用户能在一秒内理解的安全模型,而不是一句"我们做了沙箱"。
两个实现细节值得提:
1. 5 秒预算,但等网络的时间不计入。
ts
const SCRIPT_TIMEOUT_MS = 5000;
问题是脚本里可以 await pm.sendRequest(...) 真的再发一次 HTTP。如果计时器一路跑,一次慢登录请求就能把整个脚本拖进超时。所以发请求前把计时器 disarm,回来后把已经花掉的时间扣掉再重新 arm:
脚本自身最多跑 5 秒,等网络的时间不计入。
2. pm.execution.resend(),让 401 重登一条链跑完。
后置脚本拿到 401 之后,可以 pm.sendRequest 调登录接口拿 token、写进环境变量、然后调 pm.execution.resend() 让同一个请求重发一次。重发最多 3 次,变量会重新插值。
这套"碰到过期 token 自动刷新再重试"的流程,在别的工具里要么靠集合级 pre-request 拼,要么人点两次。这里是三步脚本:
js
if (pm.response.code === 401) {
const res = await pm.sendRequest({
url: "{{baseUrl}}/auth/refresh",
method: "POST",
body: JSON.stringify({ token: pm.environment.get("refreshToken") }),
});
pm.environment.set("accessToken", res.json().access_token);
pm.execution.resend();
}
顺带一个交互决定:前置/后置操作是分层 的有序操作列表------全局 → 集合 → 文件夹(外→内)→ 请求,后置反向,层内按列表顺序。每层可混排「脚本」「脚本库引用」「等待时间」三种步骤,一行一步、点箭头就地展开、用 ... 菜单上移/下移。
没做拖拽排序。这类列表通常只有三五行,拖拽的目标区域太小、误操作代价高,上下移动按钮又准又快。
决策五:设计系统靠机械闸门,不靠自觉
一个人维护 + AI 帮着改 CSS,最现实的风险不是"写不出界面",而是三个月后长出第二套配色、四种圆角、五档字号。
所以先有一份权威文档 docs/设计系统.md,线条很硬:
| 维度 | 定调 |
|---|---|
| 强调色 | 只有一种:靛蓝。第二强调色一律不批 |
| 圆角 | 小圆角、方硬感,6 档 |
| 阴影 | 只用于「浮起」的元素,不用于分区 |
| 动效 | ≤ 180ms,缓出,无回弹 |
| 文案 | 全中文,动词开头,一句话说完 |
令牌三层:全局物理值只在 :root 与主题块出现 → 别名令牌(--accent / --text-2 / --fs-md)→ 组件规则只允许引用别名层。
但文档只是文档。真正管住的是脚本 scripts/check-ui-tokens.mjs,npm run lint:ui 跑它,CI 与提交前都过:
- 令牌段之外出现
#hex/rgb()/hsl()→ 报违例 font-size: 12px→ 报违例border-radius: 7px(50%除外)→ 报违例- 裸
box-shadow→ 报违例 var(--xxx)引用了未定义的令牌 → 报违例- 未登记的动画与时长 → 报违例(
fade-in只允许 120ms,pop-in只允许 90/180ms,spin只允许 0.7s)
每条违例都带文件行号和一句修法提示,形如:
css
src/style.css:<行号> 裸色值 #3b82f6 → 改用 var(--accent/--text-2/--border ...),或先在令牌段定义语义令牌
src/style.css:<行号> 裸字号 font-size: 12px → 改用 var(--fs-2xs|xs|sm|md|lg|xl|2xl)
一句话总结这条经验:"注意保持风格统一"不是规则,能 exit 1 的才是规则。
配套还有一条闸门是"两种主题都要看":截图由 e2e 生成,浅色深色各一套,只验证一种就提交属于未验证。
踩过的两个 WebView2 性能坑
一、冷 GPU 缓存下的首帧长帧
现象很怪:用户第一次点「新建脚本」会卡一下,之后再点就不卡了;e2e 每轮都是全新 userData,所以每轮都复现。
根因是 WebView2 的 GPU 着色器/栅格缓存(userData 下的 GPUCache)是冷的时候,某类控件第一次被绘制要现场编译,实测一次 74ms 级长帧。
修法不是"优化渲染",而是提前把这份开销付掉:启动完成、用户还没动手时,把常用控件的形状先画两帧再扔掉。
难点在两个前提,缺一个就白做:
- 节点必须在视口内 且真的被绘制 。
display: none、opacity: 0、挪到left: -4000px都不会触发绘制 - 要画够两帧:第一帧提交绘制,第二帧确保结果落到合成器
所以预热层用的是 opacity: 0.01 + pointer-events: none(肉眼不可见,但确实在绘制),保留两帧后移除。实测「新建脚本」首帧从 74ms 降到 0。
这件事的长期成本是:以后新增任何"首屏之后才第一次绘制"的控件,都要加进预热清单,否则那个 70ms 会以"随机卡一下"的形式漏给用户。
二、backdrop-filter 的代价
弹窗遮罩上原本叠了一层 backdrop-filter: blur()。视觉上几乎看不出来------遮罩本身已经把背景压暗了------但在软件渲染的机器上,一次要几百毫秒一帧,表现是"打开设置面板时开关浮层卡成幻灯片"。
删掉之后视觉无感知,交互明显顺。模糊是这类工具里性价比最低的视觉效果:它值几百毫秒,却只提供"更贵一点"的观感。
测试:真实二进制 + 真实窗口 + 真实 Go 发包
没有用 Playwright / Cypress。理由:测的是桌面应用,不是网页。
- 深链(
kotae://import?curl=...)、单实例锁、托盘、关闭到托盘、"打包后前端是否真的进了二进制"------这些页面层的浏览器自动化一个都测不到 - 反过来,页面层的能力(读写 DOM、截图)是够用的
做法是:应用在 //go:build e2e 下链接 MyGo 的 Automation 插件,只在这个 tag 里 ------正式构建没有这个标签,产物里不存在 inspector。测试时把 MYGO_AUTOMATION 指向一个 Unix socket,协议是换行分隔的 JSON。页面窗口只能做三件事:eval(执行页面脚本)、capture(截图)、menu(点菜单)------点不了原生控件。
隔离做得很干脆:
- 每轮换一个应用名(单实例锁按名字区分,真实应用开着也能并行),再配一个临时 userData 目录
- 固定测试数据故意用旧版
workspace.json格式投喂,每轮都真实走一遍迁移路径 - 桩服务在
e2e/echo.go:回显请求、慢响应、重定向、错误码、Cookie、二进制、五层嵌套 JSON、HMAC 验签;流式端点在e2e/stream.go:SSE 逐条推,WebSocket 用手写的 RFC 6455 握手与帧编解码,不引第三方库
一条写进规则的测试纪律:等待条件必须锚定"这次操作造成的可观测差异" 。不许 > 0、不许只看元素存在、不许 sleep。改了 A 就断言 A 变,别拿 B 当替身。
还有一条反向的:为了让用例变绿去改 src/ 的示例数据或默认行为,属于作弊。
现在默认一轮 105 个用例,从"每个界面的控件都要有名字、有尺寸、中心点真能点中"到"后置脚本发现 401 后登录、写环境变量、重发到 200"这种整链路场景都在里面。打包用例(MYGO_E2E_PACKAGING=1,断言界面来自二进制)和 README 截图用例单独开关,不占日常那一轮。
明确不做
边界写在文档里,也写在 README 里:
- 请求录制代理
- 集合运行器与批跑
- 压测 / 性能测试
- 云同步与团队协作、账号系统
- OpenAPI 生成请求
- 完整断言框架
理由都是一句话:这些都在把"轻量本地工具"变成"重量级测试平台"。而"轻量、纯本地"是这个项目唯一的立身之本。
已知的代价也如实写在 README 里,不藏着:
- 安装包没有代码签名,Windows 首次运行会被 SmartScreen 拦一次(「更多信息」→「仍要运行」)
- macOS 未签名未公证,首次打开要右键 →「打开」
- Linux 托盘需要
libayatana-appindicator3,没装时托盘创建失败只记日志,其余功能照常
小结
一个人 + 一个 agent,从立项到 0.2.3,105 个 e2e 用例。回头看,值得复用的经验大概这几条:
- 桌面壳里,能交给原生侧的能力别在页面里绕。 发包、WebSocket、Cookie 罐交给 Go,"CORS 报错"这类问题根本不会出现。
- 框架只给一个共享 Client 时,请求级选项走私到 Transport 里,不要为了一个请求级开关去改框架。
- 存储层「内容没变就不写」比「写得更快」重要。 指纹 + 差集 + 单事务,三条性质比任何缓存策略都实在。
- 做不到沙箱就明说不做。 把安全边界收敛成用户能理解的一个开关(信任/未信任),比造一层假隔离更负责任。
- 风格一致性靠 lint,不靠自觉。 能
exit 1的规则才是规则。 - 冷缓存的首帧开销要提前付。 桌面 webview 的性能问题经常不是"算法慢",而是"第一次编译着色器"。
- 测试要么测真实的东西,要么别测。 真实二进制、真实窗口、真实 Go 发包------这是这套桌面技术栈唯一能证明"打包后可用的"路径。
项目在 GitHub:fayvo-hub/Kotae,Releases 里有 Windows 安装包 / 免安装单文件 exe 和 macOS dmg。有愿意试用的欢迎提 issue;数据全在本机 workspace.db 里,装了不满意,删掉数据目录就干净了。
示例图











