开发实录

为什么我们重新设计了 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 或断线后,之前启动的周期推送必须停止

如果仍用平铺规则表,就得额外增加 onOpenafternextStatetimerIdcancelWhen 等字段。字段补得越多,越接近把一个状态机拆散后塞进表格。

所以 WS Flow 没有沿用 HTTP Mock 的形状。HTTP Mock 继续从抓包和 GUI 出发;WebSocket Mock 直接描述「连接中的多轮消息树」。两者的分工也写进了 WebSocket Mock 文档

先定三个约束

写语法之前,我们先给它划了边界。

第一,读起来要像对话。条件写在上一层,返回缩进在下一层;下一轮消息继续往下缩进。即使没读过语言参考,也应该大致看得出先后关系。

第二,服务端主动行为必须是一等公民。连接建立、定时推送、延迟发送和主动断开不能藏在插件或回调里,否则最常见的长连接场景反而最难写。

第三,它不能长成另一门 JavaScript。不提供 iffor、函数和任意表达式。复杂逻辑一旦需要真正的编程能力,就交给 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" }
      }
    }
  ]
}

与这段对话直接相关的业务词只有 challengeloginloginSuccesstick。其余字符大多在解释配置结构:onOpenhandlersmatchreplythen

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 分发消息。有的用 eventactionmethod,还有的是纯文本。

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 聊。