跳到主要内容
AZ Tools

BSON 解码器 · Extended JSON 查看器

BSON 是 MongoDB 用来存储和通信的二进制格式:开头是小端 int32 字节数,接着是一串元素——每个元素由一个类型码、一个以 NUL 结尾的字段名和一个长度由类型决定的值组成——最后用一个零字节收尾。本工具在浏览器里直接读这些字节,同时用三种方式呈现文档:一棵字段树,逐个列出元素的类型名、类型字节和占用的字节数;规范 Extended JSON;以及宽松 Extended JSON。规范定义的二十一种元素类型全部支持,包括只会在老数据里遇到的废弃类型——undefined(0x06)、DBPointer(0x0C)、symbol(0x0E),以及二进制子类型 0x02:它在自己的负载开头还藏着第二个长度,凡是忘掉这一点的读取器都会多带回四个垃圾字节。浏览器里的解码器最容易出错的地方是数字。int64、UTC 日期和 MongoDB 内部的 timestamp 都是 64 位,而 JavaScript 的数字是双精度浮点,整数只能精确到 9,007,199,254,740,991,所以 9223372036854775807 只要经过它一次就变成了 9223372036854775808。这里三者全部用 BigInt 读取并直接从 BigInt 输出。decimal128 则从它自己的 128 位系数和偏移指数直接展开成精确的十进制字符串,绝不经过 double——这个类型本来就是为钱准备的,经过 double 就失去了意义。字段树还会把 ObjectId 拆成它真正的三段:四字节创建时间、五字节随机数和三字节计数器;UTC 日期则同时显示原始的有符号毫秒数和对应日期,于是 1970 年之前日期的负数不再让人意外。它不做的事情是猜测:声明长度与文件对不上、始终没有走到结束符、出现规范里没有的类型字节,或者误传了一个 JSON 文件,它都会报出错误名称和字节位置并拒绝,而不是半解析成一棵看着像模像样的树。

使用方法

  1. 把 .bson 文件拖到框里:mongodump 的输出、驱动保存的单个文档,或任何 MongoDB 工具写出的文件都行。
  2. 看字段树。每一行是一个元素,列出 BSON 类型名、实际传输的类型字节、值以及占用的字节数,文档为什么这么大一目了然。
  3. 需要保留类型时用规范 Extended JSON——$numberInt 和 $numberLong 并不是一回事;只是想读数据时用宽松 Extended JSON。
  4. ObjectId 行下方那一行给出创建时间、随机字节和计数器;UTC 日期行下方那一行给出原始毫秒数对应的日期。
  5. 文件被拒绝时,看清是哪一种错误:长度对不上、缺少结束符、类型字节未定义是三种不同的故障,字节位置会告诉你该从哪儿查。

常见问题

同样的数组,为什么 BSON 比 JSON 大很多?
因为 BSON 没有自己的数组类型。数组是作为一个普通文档存的,字段名就是十进制下标 "0"、"1"、"2"……而且每个下标都是真正以 NUL 结尾的字符串。于是一千个 32 位整数除了 4 KB 的值本身,还要各加一个类型字节、一到三个字符的下标名和一个结束符,总共约 8.9 KB。这也是下标键写错却没人发现的原因:驱动按位置读数组、忽略键名,所以键为 "0"、"2"、"x" 的数组照样解出三个元素,直到有东西重新序列化它才出问题。本工具会把这种情况标出来,而不是悄悄抹平。
规范 Extended JSON 和宽松 Extended JSON 有什么区别?
Extended JSON 是把 BSON 写成文本的标准做法,因为有两种用途,所以有两种写法。规范写法给每个值都套上类型标记——{"$numberInt": "1"} 不等于 {"$numberLong": "1"},也不等于 {"$numberDouble": "1.0"}——这样文档转成文本再转回来还是同样的字节。宽松写法则在普通 JSON 能表达的地方去掉外壳:数字就是数字,落在 ISO 8601 范围内的日期变成可读的时间戳。宽松写法读起来舒服得多,也是 mongoexport 的默认输出,但它是有损的:当 1 就只是 1 之后,就再也说不出它原本属于三种数字类型中的哪一种了。
哪些 BSON 值是 JavaScript 表示不了的?这里怎么处理?
有三种。int64 和 UTC 日期是有符号 64 位整数,内部的 timestamp 是一个 64 位字里的两个无符号 32 位半字;而 JavaScript 的数字是双精度浮点,整数只能精确到 9,007,199,254,740,991。再大的值在读入时就被舍入了,很多查看器里 9223372036854775807 会变成 9223372036854775808 就是这么来的。本解码器三者都用 BigInt 读取,并直接从 BigInt 输出数位,全程不经过 double。decimal128 更严重:它是十进制类型,存在的意义就是让 0.1 和 2.675 保持精确,用 double 去读等于把选它的理由抹掉。这里从它的 128 位系数和偏移指数直接展开成十进制字符串。
为什么 1970 年之前的日期显示成负数?
因为 BSON 本来就是这么存的。UTC 日期类型是自 Unix 纪元起的 int64 毫秒数,而且这个整数是有符号的,所以 1970-01-01 之前的任何时刻都是负数,1953 年的生日大约是 −526,608,000,000。这完全合法,任何驱动都能原样往返,但相当多的工具把这个字段当成无符号,或者干脆截到零,于是 1953 年变成了 1970 年,或者跳到两亿九千两百万年。这个类型能容纳的范围远比多数软件支持的日历宽,所以本工具会把原始毫秒数放在日期旁边,并在数值超出日历日期能表达的范围时明确说明。
二进制子类型 0x02 是什么?为什么说它是解析陷阱?
它是最早、如今已废弃的二进制布局。二进制字段的写法是 int32 长度、一个子类型字节,然后是负载——唯独子类型 0x02 的负载开头还有第二个 int32,值正好比外面那个少 4。把它当成普通子类型处理的读取器,会返回一个前面粘着四个多余字节的数据块;因为这四个字节看上去也像正常的二进制数据,所以没人报错,只有真正去用这个值的时候才会发现坏了。2.0 之前的老集合里还留着这种数据,所以凡是声称能读 BSON 的解码器都必须单独处理它。本工具做了处理,并且在内层长度与外层对不上时直接拒绝该字段。
ObjectId 里面到底是什么?是 UUID 吗?
不是。它是有结构的 12 个字节,而人们看它通常就是为了这个结构。前四个字节是大端的 Unix 秒级时间戳,所以按 _id 排序大致等于按创建时间排序,也可以不建单独字段就按日期筛选。接下来五个字节是每个进程一份的随机数,最后三个字节是一个计数器,从随机值开始、每写一个文档加一。旧版 MongoDB 中间放的是机器标识和进程号,会泄露一点服务器信息,3.4 起改掉了。它没有校验和,也没有版本字段,所以任何 24 位十六进制字符在语法上都是合法的 ObjectId,能判断它是否合理的只有内嵌的那个时间戳。

相关工具