开发实录
为什么我们重新设计了 WebSocket Mock DSL,而不是用 YAML、JSON 或 JS
轻量 Mock,不该先变重
HTTP 联调里,Mock 很轻:抓一帧,勾特征,改响应。一来一回就结束。
WebSocket 的轻量场景其实也不长:连上、登录、回一句、隔几秒推个心跳。要 Mock 的是一小段对话,不是一篇程序。
现成方案却常把这段对话塞进 JSON 规则表、YAML 配置,或一段 JS。功能很强,也能跑。 问题是:本来十来行能说完的事,要先填字段、堆括号、写 setInterval。轻量 Mock 先被容器写重了。
我们重新设计了一套 DSL,图的就是这件事——让轻量 Mock 更轻。界面叫 WS Flow,文件扩展名是 .dpws。人写对话,工具链再把它编译成运行时需要的 JSON 和状态结构。
HTTP 规则表为什么不能直接套过来
最初我们也想复用 HTTP Mock:给每条 WebSocket 消息做一条「消息条件 + 返回内容」规则。很快就发现,两者看起来都在收发数据,交互模型却不同。
HTTP 是一次请求对应一次响应。规则命中后返回内容,这次交互就结束了。WebSocket 则是一条连接里连续发生很多轮:
- 握手完成后,服务端可能不等客户端发消息就先推
challenge - 客户端登录成功后,后续消息才进入「已登录」阶段
- 一个请求可能先回 ACK,再连续推送多帧结果
- heartbeat、行情等消息由定时器主动产生
- logout 或断线后,之前启动的周期推送必须停止
如果仍用平铺规则表,就得额外增加 onOpen、after、nextState、timerId、cancelWhen 等字段。字段补得越多,越接近把一个状态机拆散后塞进表格。
所以 WS Flow 没有沿用 HTTP Mock 的形状。HTTP Mock 继续从抓包和 GUI 出发;WebSocket Mock 直接描述「连接中的多轮消息树」。两者的分工也写进了 WebSocket Mock 文档。
先定三个约束
写语法之前,我们先给它划了边界。
第一,读起来要像对话。条件写在上一层,返回缩进在下一层;下一轮消息继续往下缩进。即使没读过语言参考,也应该大致看得出先后关系。
第二,服务端主动行为必须是一等公民。连接建立、定时推送、延迟发送和主动断开不能藏在插件或回调里,否则最常见的长连接场景反而最难写。
第三,它不能长成另一门 JavaScript。不提供 if、for、函数和任意表达式。复杂逻辑一旦需要真正的编程能力,就交给 JS;DSL 只把短会话写短。
这三个约束也决定了语法的主体:缩进负责结构,少量符号负责匹配、生命周期和发送。
同一段对话,JSON 与 Flow 差在哪
以「连上发 challenge → 登录成功 → 每 15 秒 tick」为例,如果把它抽象成规则 JSON,大致会写成这样:
{
"url": "wss://api.example.com/ws",
"onOpen": [{ "send": { "type": "challenge" } }],
"handlers": [
{
"match": { "type": "login" },
"reply": { "type": "loginSuccess" },
"then": {
"every": "15s",
"send": { "type": "tick" }
}
}
]
}
与这段对话直接相关的业务词只有 challenge、login、loginSuccess 和 tick。其余字符大多在解释配置结构:onOpen、handlers、match、reply、then。
YAML 能去掉括号,但去不掉这层壳:仍然要写键、列表和嵌套对象,而且缩进同时承担「配置归属」和「对话顺序」两种含义。JS 最灵活,可一旦加上事件监听、定时器和清理逻辑,一段 Mock 很容易变成需要维护的小程序。
同一段,Flow 是这样:
# ws wss://api.example.com/ws
# profile type
# ping auto
--@open
challenge
--type=login
loginSuccess
--@loop 15s
tick
--@open 表示连接建立,先向客户端发送 challenge;它下面的 --type=login 等待下一帧登录消息,命中后再返回 loginSuccess。单词返回会按文件头编译成 {"type":"loginSuccess"},业务帧不必每次手写 JSON。延迟和周期推送直接写在行上(+300ms、--@loop 15s),不必另起控制流。
这里省掉的不只是字符数,更是概念切换。写的人不必先想「我要创建一个 handler,再给它挂一个 timer」,只需按实际对话顺序往下写。
缩进不只是排版,它就是会话状态
只写固定回复还不够。真实联调里,经常要表达「登录以后才允许订阅」「退出房间后停止房间消息」。
为此,Flow 用 scope 表示连接当前走到哪一段。~auth 进入已登录 scope,!~auth 退出它:
# profile type
# capture *
--type=login ~auth
{"type":"loginSuccess","token":"$token"}
--@loop 5s
{"type":"heartbeat","ts":"$now"}
--type=refresh
{"type":"profile","token":"$token"}
--type=logout !~auth
logoutSuccess
--type=logout
{"type":"error","reason":"not_logged_in"}
客户端发来登录帧后,字段会按 # capture * 保存为连接变量,返回里可用 $token 引用。进入 auth 后,5 秒一次的 heartbeat 开始运行;收到 logout 时退出 auth,绑定在这个 scope 上的定时器也随之取消。
最后一个普通 --type=logout 是未登录时的兜底。它和 --type=logout !~auth 写的是同一个业务动作,但是否处于 auth scope 决定了走成功还是报错。
这也是我们没有把 --@loop 简单翻译成全局 setInterval 的原因。定时器必须属于某段会话:进入时启动,退出时回收,否则 Mock 跑久了会不断留下幽灵推送。完整的 scope 写法见 .dpws Scope。
用 Profile 适配协议,而不是复制一套语法
并不是所有 WebSocket 协议都用 type 分发消息。有的用 event、action、method,还有的是纯文本。
Flow 把这些差异放进文件头:
# profile event
# dispatch event
# format json
# capture *
--event=message
{"event":"message","text":"收到:$text"}
在 event Profile 下,单词返回 welcome 会编译为 {"event":"welcome"};换成默认的 type Profile,则是 {"type":"welcome"}。JSON-RPC 可以按 method 匹配,纯文本协议可以用 --=... 匹配整帧。
Profile 只提供默认约定,不改变 Flow 的结构。这样不需要为每种业务协议发明新的关键字,也不会把协议字段硬编码进解析器。文件头的完整选项见 .dpws 文件头。
少量符号,各自只做一件事
我们刻意控制了符号预算:
--field=value:匹配客户端消息;嵌套条件表示 AND--@open、--@loop:连接和定时等生命周期事件~name、!~name:进入或退出命名 scope$name:引用当前连接捕获到的变量@file:从文件读取较大的返回内容|:对返回内容做模板、取值或字段修改+500ms:延迟发送,!close:发送后关闭连接
同一条件下写多行返回,会按顺序发送,每行都能有自己的延迟。于是「立即 ACK,300ms 后推一帧进度,再过 400ms 推最终结果」不需要数组和调度代码:
# capture id
--type=agent
{"type":"ack","id":"$id","ok":true}
@fixtures/agent-progress.json | template +300ms
@fixtures/agent-final.json | template +400ms
语法看起来短,但运行语义不能含糊。例如 --type=ping 匹配的是 JSON 业务消息,--@ping 匹配的是 WebSocket 协议级 Ping 帧;两者不能混为一谈。详细匹配与返回规则放在 .dpws 语言参考 中,博客只保留能建立心智模型的部分。
自研 DSL 最贵的不是 Parser
把缩进文本解析成 AST 并不算最难。真正贵的是让失败可理解、行为可验证。
如果只做一个能跑的 Parser,用户写错缩进、引用了不存在的变量,或在 Mock 接管连接后忘记处理协议 Ping,最后看到的只会是「怎么没消息」或「为什么一会儿就断线」。这类静默失败比多写几行 JSON 更糟。
因此 .dpws 不是直接边读边执行,而是经过 parse、validate、compile,再交给 runtime。校验会给出稳定错误码和行号,例如:
- 缩进结构非法
- 未知的生命周期事件或管道
$变量未定义- scope 重复定义
- Mock 截断 upstream,却没有启用
# ping auto或处理--@ping
校验已经接进编辑器;高亮和补全还在路上。错误码的含义可在 .dpws 错误码 查询。我们还保留了 simulate 这一层,让同一份 Flow 可以输入 open、message、tick、close 事件,再检查输出帧、scope 变化和定时器是否按预期停止。
轻的用 DSL,重的仍用 JS
Flow 只对准轻量 Mock:顺序的一小段会话。连接欢迎、登录、订阅、几次推送、心跳和退出,都在它的舒适区。
下面这些情况则应该停下来考虑 JS 或专门的 Mock 服务:
- 多连接之间需要共享并实时修改状态
- 消息会并发、乱序,分支依赖复杂时序
- payload 需要大量随机生成或动态计算
- 要访问数据库、外部 API 或执行任意用户代码
- 团队更看重 JSON Schema、既有 CI 和通用编辑器生态
这不是 DSL 没写完,而是边界有意为之。每增加一种表达能力,都要同时增加解析、报错、调试和文档成本。若最终补齐变量赋值、条件表达式、函数、模块和异步 API,我们只是重新造了一门更陌生的脚本语言。
自研当然也有账:Parser、错误提示、高亮、补全和版本兼容都要自己维护,同事也要学习一组新符号。换来的价值必须足够具体——打开文件能直接读出对话顺序,修改一条返回不用先理解一套配置 Schema,周期推送能随会话 scope 自动启停。
回到最初的问题
我们不是因为 YAML、JSON 或 JS 做不到,才设计 WS Flow。恰恰相反,它们什么都能做;问题在于轻量场景也要为这种通用性付费。
.dpws 选择的是另一种交换:牺牲任意编程能力,换取更短的业务表达;增加一套受控语法,换取可编译、可校验、可模拟的会话模型。
判断它是否值得,不看少写了多少括号,而看联调的人能不能在几十秒内回答三个问题:客户端发什么会命中?服务端接着回什么?这个阶段什么时候结束?
如果一份 Flow 打开后就能直接回答,这套 DSL 才算达到了目标。
和前两篇是同一条线
抓包历史从 sql.js 迁到原生 SQLite,解决的是挂久了的存储。
桌面壳从 Electron 换成 Tauri,解决的是壳太重。
这篇是:轻量长连接 Mock 不该先被 JSON/YAML/JS 写重。我们用更短的 DSL,让它保持轻。
相关文档
下一篇
离开 Electron 之后,静默更新为什么必须自己做——换壳之后现成更新器用不上了。检查和下包放到托盘里后台做,点一下再静默覆盖。
欢迎 下载 DevPeek 看 HTTP Mock 与抓包怎么串;WS Flow 以 WebSocket Mock 文档 为准。不同看法可以到 GitHub Discussions 聊。