OpenReplay 消息二进制协议与 MOBS 代码生成器:从 Schema DSL 到多语言产物的完整指南
【免费下载链接】openreplaySession replay, cobrowsing and product analytics you can self-host. Best for reproducing issues and iterating on your product.项目地址: https://gitcode.com/gh_mirrors/op/openreplay
MOBS(Message Object Binary Schema)是 OpenReplay 仓库中负责定义会话录制消息二进制格式,并从单一 Schema 定义生成 Go / TypeScript / Python / Swift 多语言代码的代码生成子系统。本文以 mobs/README.md 为主线,结合 mobs/messages.rb、mobs/run.rb、mobs/templates 及三套语言原语实现,系统讲解消息 Schema 的 DSL 语法、消息 ID 空间分配、类型映射、ERB 模板渲染原理与底层二进制编解码细节。读完本文,你将掌握 OpenReplay 会话消息从"定义"到"代码"的完整链路,并具备在仓库中定位、理解乃至新增一条消息的实战能力。
一、MOBS 是什么:一条 Schema 定义,五套语言代码
OpenReplay 的会话回放(Session Replay)体系横跨多个技术栈:浏览器端 tracker/tracker 负责采集并序列化用户行为,backend/pkg/messages 负责消息的解码、存储与管道处理,frontend/app/player/web/messages 负责前端播放器还原页面,ee/connectors/msgcodec 负责数据管道中的编解码。这些模块必须对同一种二进制消息格式保持严格一致——任何字段增删、类型调整或消息 ID 变更,都必须在所有语言中同步。
MOBS 正是为解决这一问题而存在的代码生成子系统:开发者只需在 Ruby DSL 文件中声明消息结构,再执行一行命令,即可自动生成所有语言的消息定义、编码函数和解码分发逻辑。仓库根目录下的 mobs/README.md 对此给出最简说明:
Message Object Binary Schema and Code Generator from Templates To generate all necessary files for the project:
sh generate.sh
这条命令背后是一个由 Schema 文件、Ruby 生成器脚本、ERB 模板和三套原语实现构成的完整流水线,其组成如下:
| 角色 | 文件 | 职责 |
|---|---|---|
| 生成入口 | mobs/generate.sh | 调用 Ruby 生成器并格式化 Go 输出 |
| 生成器核心 | mobs/run.rb | 解析 Schema、渲染 ERB 模板、写出产物 |
| Web 消息定义 | mobs/messages.rb | 声明全部 Web 端消息(ID 0–127) |
| 移动端消息定义 | mobs/mobile_messages.rb | 声明 iOS/Android 移动端消息(ID 90 起) |
| 模板集合 | mobs/templates/*.erb | 每种目标语言/模块对应一个 ERB 模板 |
| 二进制原语(Go) | mobs/primitives/primitives.go | uint/int/boolean/string/size 的读写实现 |
| 二进制原语(Python) | mobs/primitives/primitives.py | 与 Go 对齐的 Python 版编解码原语 |
| 二进制原语(Swift) | mobs/primitives/primitives.swift | iOS 端原语实现 |
从架构上看,这形成了一条"Schema DSL → Ruby 元编程解析 → ERB 模板渲染 → 多语言代码落盘"的经典代码生成链路,保证协议的唯一事实来源(Single Source of Truth)始终是 Ruby 定义文件本身。
二、消息 Schema DSL:用 Ruby 声明二进制协议
消息定义的核心是 mobs/messages.rb(Web)与 mobs/mobile_messages.rb(移动端)两个文件。它们不是配置文件,而是被 mobs/run.rb 直接require的 Ruby 代码,通过一个极简的领域特定语言(DSL)描述每条消息。
2.1 顶层 API 与消息声明
run.rb中定义了全局函数message(id, name, opts, &block),并在每次声明时做 ID 去重校验:
$ids = [] $messages = [] def message(id, name, opts = {}, &block) raise "id duplicated #{name}" if $ids.include? id $ids << id opts[:id] = id opts[:name] = name msg = Message.new(**opts, &block) $messages << msg end每条消息通过Message类持有元数据:id(消息类型 ID)、name(消息名)、tracker、replayer、swift、pipeline(管道分类)以及attributes(字段列表)。声明字段时,DSL 为int、uint、boolean、string、data五种基本类型各注册了一个同名方法,把字段名与类型封装成Attribute对象:
%i(int uint boolean string data).each do |type| define_method type do |name, opts = {}| opts.merge!(name: name, type: type) @attributes << Attribute.new(**opts) end end2.2 一个真实的消息声明示例
以 Web 端最常用的SetNodeAttribute(消息 ID 12)为例:
message 12, 'SetNodeAttribute' do uint 'ID' string 'Name' string 'Value' end字段名遵循 PascalCase 命名约定。run.rb中内置了命名转换工具函数:pascal_case(PascalCase)、camel_case(camelCase)、snake_case(snake_case),并做了Id → ID、Url → URL的缩写规范化。因此生成出的各语言标识符风格可以自动适配:Go 里是ID uint64、Name string,TypeScript 里是id: number、name: string,Python 里是self.i_d、self.name。
再看一个带选项的消息NetworkRequest(ID 83),展示选项参数的用法:
message 83, 'NetworkRequest', :replayer => :devtools, :pipeline => 'd' do string 'Type' # fetch/xhr/anythingElse(axios,gql,fonts,image?) string 'Method' string 'URL' string 'Request' string 'Response' uint 'Status' uint 'Timestamp' uint 'Duration' uint 'TransferredBodySize' end2.3 消息选项的含义
每条消息可携带如下选项(默认值定义在Message#initialize中):
| 选项 | 默认值 | 作用 |
|---|---|---|
:tracker | 当前上下文为 web 时为true | 控制该消息是否由 tracker 采集端生成(false表示仅后端/服务端使用) |
:replayer | 当前上下文为 web 时为true | 控制该消息是否参与前端回放;false表示后端专用,:devtools表示仅用于开发者工具面板 |
:swift | 当前上下文为 ios 时为true | 标记是否为 iOS 端消息 |
:pipeline | nil | 管道分类:'a'(URL 基址重写类)、'b'(业务/后端聚合类)、'd'(devtools 面板类)等 |
例如SessionStart声明为:tracker => false, :replayer => false,因为它由后端在会话开始时合成;JSException声明为:replayer => false, :pipeline => 'b',表明它走后端聚合管道。这些标记在模板渲染时被大量用于过滤——例如 TS 播放器类型只挑选msg.replayer != false的消息。
2.4 双上下文加载:Web 与移动端共享同一生成器
run.rb通过切换全局上下文变量$context实现一套生成器同时服务 Web 与移动端:
$context = :web require './messages.rb' $context = :ios require './mobile_messages.rb'$context会作为默认值传入Message构造器(tracker: $context == :web、swift: $context == :ios),因此移动端消息天然标记为swift: true且tracker: false。移动端消息从 ID 90 开始(mobs/mobile_messages.rb 顶部注释明确"90-111 reserved iOS"),例如MobileSessionStart(90)、MobileScreenChanges(96)、MobileClickEvent(100)等。
三、消息类型体系与 ID 空间
从 mobs/messages.rb 的完整定义看,Web 消息 ID 空间覆盖 0–127,按功能可划分为几大类:
| 类别 | 典型消息 | ID 示例 |
|---|---|---|
| 会话生命周期 | Timestamp、SessionStart、SessionEnd、SessionSearch | 0 / 1 / 126 / 127 |
| DOM 树操作 | CreateElementNode、CreateTextNode、MoveNode、RemoveNode、SetNodeAttribute | 8 / 9 / 10 / 11 / 12 |
| 视图与滚动 | SetViewportSize、SetViewportScroll、SetNodeScroll | 5 / 6 / 16 |
| 用户交互 | MouseMove、MouseClick、InputEvent、SelectionChange | 20 / 68 / 32 / 113 |
| 网络与性能 | NetworkRequest、ResourceTiming、WSChannel、PageLoadTiming、WebVitals | 83 / 85 / 84 / 23 / 124 |
| 框架状态(devtools) | Redux、Vuex、MobX、NgRx、Zustand、GraphQL | 121 / 45 / 46 / 47 / 79 / 123 |
| 异常与错误 | JSException、CustomIssue、Incident | 78 / 64 / 87 |
| 后端专用 | IssueEvent、SessionEnd、SessionSearch | 125 / 126 / 127 |
| 移动端 | MobileSessionStart、MobileCrash、MobileViewComponentEvent等 | 90–111 |
文件中还存在大量协议演进痕迹,注释精确标注了废弃时间线,例如:
SetPageLocationDeprecated(4):# DEPRECATED since 14.0.0 -> goto 122,新版本为SetPageLocation(122),新增了DocumentTitle字段;ResourceTimingDeprecatedDeprecated(53):# deprecated during 1.16.0 release;StringDictDeprecated(50):# deprecated @ 10.2024 (v1.21) -> removed @ 2025。
这种"旧 ID 永久保留、新消息申请新 ID"的策略是二进制协议的行业惯例——已落盘的历史数据必须能按旧 ID 解码。文件末尾的# FREE 2, 34, 35, 36, 65, 85, 86, 87, 88则列出了一组空闲 ID(注:其中 85 实际已被ResourceTiming使用,可视为注释与实现的细微出入),供后续协议扩展使用。任何新增消息必须避开已占用 ID,因为run.rb会在重复 ID 时直接抛出"id duplicated"异常。
四、属性类型与多语言映射
Attribute类定义了四种基本类型 + 两种扩展类型的映射规则,这是保证多语言一致性的核心表:
| 类型 | Go(type_go) | TypeScript(type_js) | Cython(type_pyx) | 长度编码(lengh_encoded) |
|---|---|---|---|---|
int | int64 | number | long | 否(zigzag 变长) |
uint | uint64 | number | unsigned long | 否(LEB128 变长) |
string | string | string | str | 是(前置长度) |
data | []byte | 不支持(模板中抛异常) | str | 是(前置长度) |
boolean | bool | —(默认透传) | bint | 否(单字节 0/1) |
json | interface{} | string(带 TODO 注释) | str | 视实现而定 |
值得注意的细节:
data类型在 JS 模板中直接raise异常,说明当前 Web/TS 模板不期望出现二进制块字段;json类型在 JS 侧暂以string兜底,源码中留有# TODO注释,属于未完成的映射,从源码结构看 json 字段目前主要用于 Go 侧的结构化承载;- 生成 Go 编码缓冲区大小时,
messages.go.erb采用估算公式:attributes.count * 10 + 1(每条属性预留 10 字节变长编码空间 + 1 字节消息 ID),并叠加所有长度编码字段的len(field),最终按实际写入位置buf[:p]截断,兼顾性能与正确性。
五、生成器工作原理:run.rb 的模板渲染管线
mobs/run.rb 是整个代码生成器的心脏,完整流程如下:
# 1. 定义 String / Attribute / Message 等 DSL 基础设施 # 2. 定义 $ids / $messages 全局容器与 message() 注册函数 # 3. 加载 Web 消息定义 $context = :web require './messages.rb' # 4. 加载移动端消息定义 $context = :ios require './mobile_messages.rb' # 5. 遍历 templates/*.erb 渲染全部产物 Dir["templates/*.erb"].each do |tpl| e = ERB.new(File.read(tpl)) path = tpl.split '/' t = '../' + path[1].gsub('~', '/') # '~' → '/' t = t[0..-5] # 去掉 '.erb' 后缀 File.write(t, e.result) puts tpl + ' --> ' + t end关键设计在于模板文件名即输出路径:~被替换为目录分隔符/,.erb被剥离,再以mobs/为基准加../前缀,从而映射到仓库根目录下的真实源码位置。例如:
| 模板文件(mobs/templates 下) | 生成产物(仓库根目录相对) |
|---|---|
backend~pkg~messages~messages.go.erb | backend/pkg/messages/messages.go |
backend~pkg~messages~read-message.go.erb | backend/pkg/messages/read-message.go |
backend~pkg~messages~filters.go.erb | backend/pkg/messages/filters.go |
frontend~app~player~web~messages~message.gen.ts.erb | frontend/app/player/web/messages/message.gen.ts |
tracker~tracker~src~common~messages.gen.ts.erb | tracker/tracker/src/common/messages.gen.ts |
ee~connectors~msgcodec~messages.py.erb | ee/connectors/msgcodec/messages.py |
ios/ASMessage.swift | mobs/templates/ios/ASMessage.swift(模板本身,Swift 端在 mobs 子目录内输出) |
因此,仓库中所有带Auto-generated, do not edit//* eslint-disable */头注释的消息代码文件,都是本生成器的产物,直接修改它们是无效的,正确姿势是修改 Schema 或模板后重新运行sh generate.sh。
六、生成流程实战:一行命令生成全仓消息代码
在仓库根目录执行:
sh mobs/generate.sh该脚本内容仅两行:
ruby run.rb gofmt -w ../backend/pkg/messages第一行执行完整的多语言生成;第二行对 Go 输出统一执行gofmt格式化,保证 backend/pkg/messages 下的生成代码始终符合 Go 官方格式规范(这也是唯一需要外部工具链依赖的环节)。运行前提:
- 需要 Ruby 环境(生成器使用标准库
erb,无需额外 gem); - 需要
gofmt(Go 工具链自带); - 必须在
mobs/目录内执行ruby run.rb(run.rb使用相对路径require './messages.rb'与Dir["templates/*.erb"]); - 若需仅格式化而不重新生成,可只执行脚本第二行。
从run.rb的输出逻辑(puts tpl + ' --> ' + t)看,每次生成会在终端打印每个模板到产物的映射关系,方便核对生成范围。
七、二进制编码原语:三种语言,同一套字节语义
所有消息的编解码最终都落到三套原语实现上,它们必须逐字节兼容,否则回放数据就会错乱。三套实现分布在:
- Go:mobs/primitives/primitives.go(被生成代码引用)
- Python:mobs/primitives/primitives.py(被 ee/connectors/msgcodec 引用)
- Swift:mobs/primitives/primitives.swift(iOS 端)
7.1 uint:LEB128 变长编码
Go 侧WriteUint/ReadUint实现了标准的 LEB128:每字节低 7 位为有效数据,最高位为"是否继续"标志,小端序累积位移。primitives.py中同步实现了相同逻辑,其注释还点明了大端/小端在此处无关紧要(因为按字节处理),并给出了 uint64 最大占用 9 字节的边界检查(if i > 9 || i == 9 && b > 1判定溢出)。
func WriteUint(v uint64, buf []byte, p int) int { for v >= 0x80 { buf[p] = byte(v) | 0x80 v >>= 7 p++ } buf[p] = byte(v) return p + 1 }7.2 int:ZigZag 编码
有符号整数采用 ZigZag 映射:uv = uint64(v) << 1,负数取反,把符号位挪到最低位,从而让绝对值小的负数也占用极少字节。Python 实现里对应的还原逻辑是x = -x - 1(等价于 Go 的x = ^x),两处代码逐行对齐,primitives.py甚至直接以 Go 源码注释的形式嵌入了参考实现。
7.3 boolean:单字节 0/1
WriteBoolean将true写为0x01、false写为0x00;读取端p[0] == 1即为真。Python 侧b == 1同样严格比对,而不是用真值判断——这要求所有语言写入端严格遵循 0/1 约定。
7.4 string / data:长度前缀 + 原始字节
字符串先以 LEB128 写入字节长度,再紧跟 UTF-8 字节序列。Go 侧对超长字符串有保护:ReadString中if l > 10e6直接报错"Too long string",防止恶意长度字段导致内存耗尽。Python 侧在解码时使用errors="replace"容错,并将空字节\x00替换为替换符\uFFFD。data([]byte)字段同样采用长度前缀,但不做字符串语义处理。
7.5 size:三字节小端
ReadSize固定读取 3 个字节,以小端序组合成uint64,用于读取消息块的整体尺寸,与消息级编解码配合构成外层帧格式。
八、生成产物解析:Go / TypeScript / Python 三视角
8.1 Go:backend/pkg/messages
模板 backend~pkg~messages~messages.go.erb 为每条消息生成四件套:
- 类型常量:
const ( MsgSetNodeAttribute = 12 ... ); - 结构体:内嵌
message基类 + 类型映射后的字段(uint → uint64、string → string等); Encode() []byte:按前文缓冲区公式分配空间,依次调用WriteXxx写入各字段,首字节为消息 ID;TypeID() int:返回消息 ID,配合接口Message使用。
解码侧由 backend~pkg~messages~read-message.go.erb 生成:每条消息一个DecodeXxx(reader)函数,按声明顺序调用reader.ReadXxx(),并通过ReadMessage(t uint64, reader)以switch t分发到对应解码函数,未识别的 ID 返回unknown message code错误。该 switch 是完全由 Schema 驱动生成的——新增消息后无需手写任何分发逻辑。
过滤器模板 backend~pkg~messages~filters.go.erb 生成三个布尔判断函数:
IsReplayerType(id):排除所有replayer == false的消息(&&链式取非判断);IsMobileType(id):命中所有context == :ios的消息(||链);IsDOMType(id):命中所有replayer == true的消息。
这些函数被后端用于按类型路由消息:回放引擎只关心IsReplayerType为真的消息,移动端专属消息通过IsMobileType快速识别。
8.2 TypeScript:播放器与 tracker
模板按职责拆分成了多份 TS 产物:
- frontend~app~player~web~messages~raw.gen.ts.erb:底层原始消息元组类型;
- frontend~app~player~web~messages~message.gen.ts.erb:在
RawMessage上叠加Timed,导出Message = RawMessage & Timed,并按replayer != false过滤出播放器关心的消息类型; - frontend~app~player~web~messages~tracker.gen.ts.erb 与
tracker-legacy.gen.ts.erb:新旧 tracker 消息序列化; - tracker~tracker~src~common~messages.gen.ts.erb:tracker 公共消息常量与类型定义;
- tracker~tracker~src~main~app~messages.gen.ts.erb:为每个
tracker消息生成带类型的构造工厂函数,将字段以camelCase参数传入并返回元组数组[Type.Xxx, ...fields]; - tracker~tracker~src~webworker~MessageEncoder.gen.ts.erb:Web Worker 中的编码器。
其中 TS 类型映射的关键在type_js:int/uint均映射为number,string保持string,与 Go 侧形成"宽类型"对应——JS 数字实际按 IEEE 754 double 承载 64 位整数,这是跨语言协议常见的取舍。
8.3 Python:消息编解码(msgcodec)
模板 ee~connectors~msgcodec~messages.py.erb 为每条消息生成一个继承自抽象基类Message的 Python 类,类属性__id__记录消息 ID,__init__按 snake_case 接收全部字段;配套的.pyx模板(messages.pyx.erb、msgcodec.pyx.erb)生成 Cython 加速的编解码实现,用long/unsigned long/bint/str与 mobs/primitives/primitives.py 的Codec静态方法对接。这保证了消费侧(如 ee/connectors/consumer.py 所在的数据管道)能以接近原生速度解析与 Go 后端完全一致的二进制流。
8.4 iOS:Swift
模板 mobs/templates/ios/ASMessage.swift 与 mobs/primitives/primitives.swift 覆盖 iOS 端,移动端消息由mobile_messages.rb驱动生成,与 tracker-reactnative / iOS SDK 侧对齐。
九、实战推演:如何新增一条消息
综合上述机制,在 OpenReplay 中新增一条消息的标准流程(以仓库只读视角介绍,不涉及实际修改仓库)如下:
- 分配空闲 ID:在 mobs/messages.rb(或 mobs/mobile_messages.rb)末尾的可用 ID 中选取未被占用的编号;
- 声明消息:在文件末尾追加
message <id>, '<Name>', opts do ... end块,按需设置:tracker、:replayer、:pipeline选项; - 重新生成:进入
mobs/目录执行sh generate.sh(即ruby run.rb && gofmt -w ../backend/pkg/messages),等待终端打印各模板 → 产物映射; - 验证产物:检查 backend/pkg/messages/messages.go 出现对应结构体与常量、
read-message.go的switch出现新分支、TS/Python/Swift 产物同步更新; - 注意兼容性:对已有消息的字段修改遵循"只增不改"原则,删除或改类型会破坏历史会话解码;废弃消息应复制为新 ID 并在旧定义处保留注释(如
# DEPRECATED since 14.0.0 -> goto 122的写法),同时回写文件末尾的空闲 ID 清单。
从 backend/pkg/messages 与 frontend/app/player/web/messages 中大量带 "Auto-generated" 头注释的文件可以看出,这套"手写 Schema + 模板生成"的机制贯穿全仓,是 OpenReplay 会话协议演进的基础设施。
十、总结
MOBS 以"一处定义、处处生成"的设计,把 OpenReplay 横跨 Go / TypeScript / Python / Swift 的会话消息协议收敛到两个 Ruby 定义文件和一组 ERB 模板中。其核心价值在于:
- 单一事实来源:消息 ID、字段顺序、类型语义只维护在 mobs/messages.rb 与 mobs/mobile_messages.rb;
- 模板即产物目录:mobs/templates 中的
~命名规则把模板文件路径直接映射为仓库内生成代码路径,目录结构即配置; - 原语层严格对齐:primitives.go、primitives.py、primitives.swift 三套实现逐字节兼容 LEB128 / ZigZag / 长度前缀协议;
- 演进有痕:废弃消息通过注释与新增 ID 保留历史兼容性,保证存量会话数据永远可解码。
对于需要在 OpenReplay 仓库中排查消息格式问题、扩展新事件类型或理解端到端数据流的开发者,MOBS 目录(mobs/README.md)就是协议的"总纲",从这一入口出发,可以顺藤摸瓜地掌握整条会话数据链路的字节级细节。
【免费下载链接】openreplaySession replay, cobrowsing and product analytics you can self-host. Best for reproducing issues and iterating on your product.项目地址: https://gitcode.com/gh_mirrors/op/openreplay
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考