Bencode作为一种编码二进制数据的方式,是BitTorrent协议中重要的组成部分之一。

它既是.torrent文件的编码格式,也是BitTorrent协议中的消息编码格式,还是DHT协议中的消息编码格式。

本篇博客虽然不会讲解Bencode的具体实现,但是为了聊聊Bencode中的一些不足之处(Byte String),我首先还是要先介绍一下Bencode的编码规则。

具体的官方文档可以参考 这里

Bencode的编码规则其实相当简单,只有四种基本类型:

  • Integer 就是整数,可以为负数,以i开头,以e结尾,中间是整数的十进制表示,比如i123e表示整数123,又比如i-123e表示整数-123。当为0时,不可以有-号,比如i-0e是不合法的。只能是 i0e。(规范没有规定整数的最大长度)
  • Byte String 可以简单的理解为字符串(实际上是字节字符串 Byte String), 以字符串的长度开头,后面跟着一个冒号:,然后是字符串的内容,比如4:spam表示字符串spam。可以为空字符串,例如0:。(规范没有规定字符串的最大长度)
  • List 就是列表,以l开头,以e结尾,中间是列表的元素,可以是任意Bencode类型的值,比如l5:hello5:worldi123e表示列表["hello", "world", 123]。可以为空列表,例如le
  • Dictionary 就是字典,以d开头,以e结尾,中间是字典的键值对,其中键只能是Byte String类型,值可以是Bencode4中类型中的任意类型,比如d4:name4:john3:agei18ee表示字典{"name": "john", "age": 18}。 可以为空字典,例如de

根据上面的规则,我们可以总结出如下的规律:

  • Bencode无法编码浮点型数据,如果实在需要编码,可以用Byte String代替(二进制编码)。
  • Bencode的列表和字典是可以嵌套的,比如列表中的元素和字典中的值可以是任意Bencode基本类型。
  • Bencode的字典中的键必须是Byte String类型。

Bencode的优点

说完了Bencode的编码规则,我先说我认为Bencode的优点:

  • Bencode的编码规则简单,满足了常用的数据类型,支持嵌套,可以表示复杂的数据结构,同时也可以编码二进制数据。
  • Bencode是顺序编码,每一种数据类型的起始和结束都是固定的,例如列表的起始是l,结束是e,字典的起始是d,结束是e,字节字符串的起始是字符串的长度,结束是字符串的内容,这样的编码方式,使得Bencode的解码非常简单,只需要按照顺序,就可以流式解析每一个字节,天然支持递归解析。节省内存的同时,也提高了解码的效率。
  • 数据结构和JSON类似,但是比JSON更加简单。

Bencode的缺点

Bencode的缺点也是显而易见的,这是在我用Node.js以及Deno分别实现过一个Bencode编解码器之后发现的。

我认为Bencode的最大的缺点就是Byte String没有编解码规则。

Byte String 在原文档中的定义是:字节字符串,也就是二进制字符串,它的编码规则是:以字符串的长度开头,后面跟着一个冒号:,然后是字符串的内容,比如4:spam表示字符串spam。可以为空字符串,例如0:

我们知道在使用utf-8编码的情况下,一个字节可以表示一个ASCII字符,而一个中文字符需要3个字节,所以在Bencode中,一个中文字符需要用3个字节来表示,比如3:中

Bencode默认Byte String是二进制字节流,但是并没有规定具体的编码方式,社区默认Byte String的字节如果都是合法UTF-8字节,就解析成UTF-8字符串,如果包含非法UTF-8字节,就不解析,返回对应的字节数组。

所以这就导致了问题的出现,看下面两个例子:

1.当ByteString作为字典的值时

当ByteString作为字典的值时,如果此时的ByteString包含非UTF-8编码的字符,那么在解码的时候,就需要小心。

因为在Node.js和Deno中,使用TextDecoder解码一个字节数组时,如果字节数组中包含非UTF-8编码的字符,那解码后的字符串,就会丢失数据。

此时如果你再将解码后的字符串编码成字节数组,那么就会得到一个和原来的字节数组不一样的字节数组,这就导致了解码后的数据和编码前的数据不一致。

const bytes = Uint8Array.from([0xff, 0x32, 0x33, 0xe4])
const str = new TextDecoder().decode(bytes)
const newBytes = new TextEncoder().encode(str)

console.log(newBytes) // Uint8Array(8) [239, 191, 189,  50,51, 239, 191, 189]

// 可以看到上面的newBytes和原来的bytes不一样
// .torrent文件元信息中的info.pieces字段就是一个ByteString,它的值是一个二进制字节流,强行解码成字符串也只是乱码,而且会丢失无法被解码成utf-8的字节。

所以我在实现Bencode的解码器的时候,就需要在解码ByteString的时候,判断一下字节数组中是否包含非UTF-8编码的字符,如果包含,那么就不使用TextDecoder解码成字符串,而是直接返回字节数组。

2.当ByteString作为字典的键时

如果只是一个普通的utf-8字符串,作为字典的键,那么在解码的时候,就没有问题,但是如果是一个二进制字节流,那么就会有问题。比如:

正常的bencode字典解码

'd4:name4:john3:agei18ee' => {"name": "john", "age": 18}

当字典的键是一个byte string的时候,就会有问题,这种情况通常出现在BT客户端请求Tracker的scrape请求的情况下,Tracker会返回一个bencode的字典,结构大概如下:

{
  files: {
    "<Byte String>": {
      complete: 38,
      downloaded: 0,
      incomplete: 0,
      name: "lubuntu-16.04.6-desktop-amd64.iso"
    },
    "<Byte String>": {
      complete: 310,
      downloaded: 6,
      incomplete: 12,
      name: "ubuntu-18.04.6-live-server-amd64.iso"
    }
}

请把上面的"<Byte String>"看成一个字节数组(Unit8Array),也就是二进制数据,我之所以用"<Byte String>"表示,是因为如果强行解码成字符串,那么就会得到一个乱码的字符串,而且会丢失无法被解码成utf-8的字节。

在上述的结构中,files这个字典的每一个键都是20字节的infoHash(这里不讨论什么是infoHash,详情请去开头的官方文档查看),他们在Bencode中的编码类型都是Byte String,所以在解码的时候,就会存在问题。

因为在JavaScript中,对象的键肯定不能是字节数组,所以我在解码的时候,将包含非法utf-8字符的Byte String都解码成Unit8Array,然后序列化成 Unit8Array[xx,xx,xx,xx] 的形式(xx是一个[0-255]的整型,因为一个字节的范围就是[0-255])

{
  files: {
    "Unit8Array[0,70,120,242,226,120,16,48,188,87,115,122,135,250,247,170,251,23,79,248]": {
      complete: 38,
      downloaded: 0,
      incomplete: 0,
      name: "lubuntu-16.04.6-desktop-amd64.iso"
    },
    "Unit8Array[0,178,28,249,43,92,48,207,246,42,18,97,84,194,53,113,151,154,76,171]": {
      complete: 310,
      downloaded: 6,
      incomplete: 12,
      name: "ubuntu-18.04.6-live-server-amd64.iso"
    }
}

并且提供了isByteKeybyteKey2Unit8Array两个方法,用于判断字典中一个键是否是字节数组,以及将字符串转换成Unit8Array类型。

最后

其实Bencode只要对字节字符串的编码规则做一些改进,问题就可以迎刃而解。

我在StackOverflow上找到了相关的提问,也有人给出了回答,认为是Bencode的设计存在一些问题。(详见此处)

毕竟BT协议在2001年就发布了,如今已是2023年了。一个20多年前的协议,在大家(peer、tracker、dht-node)都遵从协议自行处理特例的情况下,已经发展壮大到现在的规模了,也就没有必要再去修改了。

该篇博客是 [BT-0x00] 系列的头篇,我打算以 [BT-0x00] 为前缀,写一些关于BT协议的文章,用于总结积累我在开发CellBit这款BT客户端的过程中,遇到的一些问题,以及一些有趣的东西。

最后推荐一下自己写的Deno的bencode库:bencode