我用 Go + 系统 WebView 写了个纯本地 Postman:不装 Electron、不连云端

起因

我日常要调一堆内网接口。Postman 越做越像一个协作平台:账号、云同步、工作区、团队空间,启动要等,内存也不客气;国内网络下登录态还时不时掉。Insomnia 后来也走了同一条路。

我想要的其实很小:一个双击就能用的本地程序,能拼请求、发包、看结构化响应、管集合和环境变量、粘贴一条 cURL 就能还原,所有数据都只在我这台机器上。

于是有了 Kotae(日语「答え」,回答):一个纯本地的轻量 API 调试工具,界面全中文,不连云端、不需要后端、不需要账号。目前到 0.2.3,Windows / macOS 都有安装包。

底座是 MyGo(Go 桌面应用框架,当前 0.7.4):窗口用系统 webview、前端就是普通 Vite 项目,打包 / 安装包 / 自动更新 / 托盘 / 单实例由框架这一层解决。

先给几个数字,这样后面的技术取舍才有参照:

  • 可执行文件 Kotae.exe 12.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 调试工具有四个硬伤:

  1. CORS :跨域预检直接挡住,很多内网接口压根没配 Access-Control-Allow-Origin
  2. 受限请求头 :Origin、Cookie、User-Agent、Referer 都改不了,而这几个恰好是调接口时最常需要伪造的
  3. 读不到 Set-Cookie:响应里的这个头对 JS 不可见
  4. 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:每次改动全量序列化、全量写盘。

三个问题很早就暴露了:

  1. 连续编辑(拖分栏、输入变量)会在几十毫秒内触发多次全量写
  2. 写到一半崩溃 = 整个工作区损毁,因为覆盖是原子的,但内容是整体的
  3. 想统计、想 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 用例。回头看,值得复用的经验大概这几条:

  1. 桌面壳里,能交给原生侧的能力别在页面里绕。 发包、WebSocket、Cookie 罐交给 Go,"CORS 报错"这类问题根本不会出现。
  2. 框架只给一个共享 Client 时,请求级选项走私到 Transport 里,不要为了一个请求级开关去改框架。
  3. 存储层「内容没变就不写」比「写得更快」重要。 指纹 + 差集 + 单事务,三条性质比任何缓存策略都实在。
  4. 做不到沙箱就明说不做。 把安全边界收敛成用户能理解的一个开关(信任/未信任),比造一层假隔离更负责任。
  5. 风格一致性靠 lint,不靠自觉。 能 exit 1 的规则才是规则。
  6. 冷缓存的首帧开销要提前付。 桌面 webview 的性能问题经常不是"算法慢",而是"第一次编译着色器"。
  7. 测试要么测真实的东西,要么别测。 真实二进制、真实窗口、真实 Go 发包------这是这套桌面技术栈唯一能证明"打包后可用的"路径。

项目在 GitHub:fayvo-hub/Kotae,Releases 里有 Windows 安装包 / 免安装单文件 exe 和 macOS dmg。有愿意试用的欢迎提 issue;数据全在本机 workspace.db 里,装了不满意,删掉数据目录就干净了。

示例图

相关推荐
IT_陈寒38 分钟前
Redis连接池泄漏那次,我差点把生产环境搞崩了
前端·人工智能·后端
灿飞屋1 小时前
Kotlin与Java虚拟机三:控制流
android·java·开发语言·后端·python·kotlin·安卓
石小石Orz1 小时前
AI 正在蚕食我们的编码能力
前端·后端·算法
陈随易1 小时前
专注飞机 FocusFlight,两个人把番茄钟做成了一趟航班
前端·后端·程序员
量化分析码农2 小时前
【Python量化波动率实战 04】低波动异象:低波组真的跑赢高波组吗
后端
风上猫2 小时前
MySQL多值索引:让 JSON 数组也能走索引(以及 6 个容易踩的坑)
后端
苏三说技术4 小时前
聊聊Redisson最常见的8个坑
后端
fthux6 小时前
待办清单写满了,别忘了给休息留个位置:试试 Tick Rest
开源·github·mac·提醒·open source·消息提醒·tick rest·tick rest for mac·消息提醒app