OpenReplay 消息二进制协议与 MOBS 代码生成器:从 Schema DSL 到多语言产物的完整指南
2026/9/23 6:36:52 网站建设 项目流程

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.gouint/int/boolean/string/size 的读写实现
二进制原语(Python)mobs/primitives/primitives.py与 Go 对齐的 Python 版编解码原语
二进制原语(Swift)mobs/primitives/primitives.swiftiOS 端原语实现

从架构上看,这形成了一条"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(消息名)、trackerreplayerswiftpipeline(管道分类)以及attributes(字段列表)。声明字段时,DSL 为intuintbooleanstringdata五种基本类型各注册了一个同名方法,把字段名与类型封装成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 end

2.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 → IDUrl → URL的缩写规范化。因此生成出的各语言标识符风格可以自动适配:Go 里是ID uint64Name string,TypeScript 里是id: numbername: string,Python 里是self.i_dself.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' end

2.3 消息选项的含义

每条消息可携带如下选项(默认值定义在Message#initialize中):

选项默认值作用
:tracker当前上下文为 web 时为true控制该消息是否由 tracker 采集端生成(false表示仅后端/服务端使用)
:replayer当前上下文为 web 时为true控制该消息是否参与前端回放;false表示后端专用,:devtools表示仅用于开发者工具面板
:swift当前上下文为 ios 时为true标记是否为 iOS 端消息
:pipelinenil管道分类:'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 == :webswift: $context == :ios),因此移动端消息天然标记为swift: truetracker: 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 示例
会话生命周期TimestampSessionStartSessionEndSessionSearch0 / 1 / 126 / 127
DOM 树操作CreateElementNodeCreateTextNodeMoveNodeRemoveNodeSetNodeAttribute8 / 9 / 10 / 11 / 12
视图与滚动SetViewportSizeSetViewportScrollSetNodeScroll5 / 6 / 16
用户交互MouseMoveMouseClickInputEventSelectionChange20 / 68 / 32 / 113
网络与性能NetworkRequestResourceTimingWSChannelPageLoadTimingWebVitals83 / 85 / 84 / 23 / 124
框架状态(devtools)ReduxVuexMobXNgRxZustandGraphQL121 / 45 / 46 / 47 / 79 / 123
异常与错误JSExceptionCustomIssueIncident78 / 64 / 87
后端专用IssueEventSessionEndSessionSearch125 / 126 / 127
移动端MobileSessionStartMobileCrashMobileViewComponentEvent90–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_goTypeScript(type_jsCython(type_pyx长度编码(lengh_encoded
intint64numberlong否(zigzag 变长)
uintuint64numberunsigned long否(LEB128 变长)
stringstringstringstr是(前置长度)
data[]byte不支持(模板中抛异常)str是(前置长度)
booleanbool—(默认透传)bint否(单字节 0/1)
jsoninterface{}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.erbbackend/pkg/messages/messages.go
backend~pkg~messages~read-message.go.erbbackend/pkg/messages/read-message.go
backend~pkg~messages~filters.go.erbbackend/pkg/messages/filters.go
frontend~app~player~web~messages~message.gen.ts.erbfrontend/app/player/web/messages/message.gen.ts
tracker~tracker~src~common~messages.gen.ts.erbtracker/tracker/src/common/messages.gen.ts
ee~connectors~msgcodec~messages.py.erbee/connectors/msgcodec/messages.py
ios/ASMessage.swiftmobs/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.rbrun.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

WriteBooleantrue写为0x01false写为0x00;读取端p[0] == 1即为真。Python 侧b == 1同样严格比对,而不是用真值判断——这要求所有语言写入端严格遵循 0/1 约定。

7.4 string / data:长度前缀 + 原始字节

字符串先以 LEB128 写入字节长度,再紧跟 UTF-8 字节序列。Go 侧对超长字符串有保护:ReadStringif l > 10e6直接报错"Too long string",防止恶意长度字段导致内存耗尽。Python 侧在解码时使用errors="replace"容错,并将空字节\x00替换为替换符\uFFFDdata[]byte)字段同样采用长度前缀,但不做字符串语义处理。

7.5 size:三字节小端

ReadSize固定读取 3 个字节,以小端序组合成uint64,用于读取消息块的整体尺寸,与消息级编解码配合构成外层帧格式。

八、生成产物解析:Go / TypeScript / Python 三视角

8.1 Go:backend/pkg/messages

模板 backend~pkg~messages~messages.go.erb 为每条消息生成四件套:

  1. 类型常量const ( MsgSetNodeAttribute = 12 ... )
  2. 结构体:内嵌message基类 + 类型映射后的字段(uint → uint64string → string等);
  3. Encode() []byte:按前文缓冲区公式分配空间,依次调用WriteXxx写入各字段,首字节为消息 ID;
  4. 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_jsint/uint均映射为numberstring保持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 中新增一条消息的标准流程(以仓库只读视角介绍,不涉及实际修改仓库)如下:

  1. 分配空闲 ID:在 mobs/messages.rb(或 mobs/mobile_messages.rb)末尾的可用 ID 中选取未被占用的编号;
  2. 声明消息:在文件末尾追加message <id>, '<Name>', opts do ... end块,按需设置:tracker:replayer:pipeline选项;
  3. 重新生成:进入mobs/目录执行sh generate.sh(即ruby run.rb && gofmt -w ../backend/pkg/messages),等待终端打印各模板 → 产物映射;
  4. 验证产物:检查 backend/pkg/messages/messages.go 出现对应结构体与常量、read-message.goswitch出现新分支、TS/Python/Swift 产物同步更新;
  5. 注意兼容性:对已有消息的字段修改遵循"只增不改"原则,删除或改类型会破坏历史会话解码;废弃消息应复制为新 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询