一个用 go 实现的 torrent 客户端,基于 go 1.18
其他原理性部分可参考 wiki BitTorrent Specification
- 种子文件编码
种子文件编码类型是 Bencode 编码,这个编码类型虽然比纯二进制编码效率低,但是由于它结构简单,并且也不会被字节序列所影响(整数数字表示是十进制),对于需要建立 跨平台 应用的 BitTorrent 来讲就显得十分合适。在兼容性方面,由于采用的是 字典结构,只要注意下字典 key 的冲突,加新的 key 都是没大问题的,所以兼容性也非常强
- 种子文件编码规则和结构
一般种子的文件编码和结构是类似于下面这样
d
8:announce
41:http://bttracker.debian.org:6969/announce
7:comment
35:"Debian CD from cdimage.debian.org"
13:creation date
i
1639833767
e
9:httpseeds
l
145:https://cdimage.debian.org/cdimage/release/11.2.0//srv/cdbuilder.debian.org/dst/deb-cd/weekly-builds/amd64/iso-cd/debian-11.2.0-amd64-netinst.iso
145:https://cdimage.debian.org/cdimage/archive/11.2.0//srv/cdbuilder.debian.org/dst/deb-cd/weekly-builds/amd64/iso-cd/debian-11.2.0-amd64-netinst.iso
e
4:info
d
6:length
i
396361728
e
4:name
31:debian-11.2.0-amd64-netinst.iso
12:piece length
i
262144
e
6:pieces
30240:^U����...(一堆 hash 过后的数据)
e
e
-
以
d开头以e结尾的表示dictionaries即 字典,它的格式就是d <bencoding 字符串> <bencoding 编码类型> e -
<bencoding 字符串> 的格式为
<字符串长度>:<字符串>比如上面的
8:announce -
<bencoding 编码类型> 包括了 字典、字符串、列表、整数 这些
-
以
l开头以e结尾的表示list即 列表,它的格式就是l <bencoding 编码类型> e比如上面的
l 145:https://cdimage.debian.org/cdimage/release/11.2.0//srv/cdbuilder.debian.org/dst/deb-cd/weekly-builds/amd64/iso-cd/debian-11.2.0-amd64-netinst.iso 145:https://cdimage.debian.org/cdimage/archive/11.2.0//srv/cdbuilder.debian.org/dst/deb-cd/weekly-builds/amd64/iso-cd/debian-11.2.0-amd64-netinst.iso e -
以
i开头以e结尾的表示integer即 整数,它的格式就是i<整数>e比如上面的
i 1639833767 e
由上面可以看到整个种子文件就是一个以 Bencode 编码 为基础的,一个很大的 字典
对于整个文件的结构而言,需要关注下面几个关键字
announce: 表示tracker服务器的 URLcomment(可选): 表示备注creation date(可选): 表示种子创建的时间,格式为 Unix 标准时间格式info: 表示文件的主要信息,里面是一个字典结构,里面可能有一个文件,也可能有多个文件
对于本项目而言,info 里面包含了
length:debian-11.2.0-amd64-netinst.iso文件的大小为 396361728 bytename:debian-11.2.0-amd64-netinst.iso文件的名称piece length:debian-11.2.0-amd64-netinst.iso文件被分成了 262144 个piecepieces: 每个piece的二进制 blob 数据经过 hash 过后的集合
而客户端所做的工作,就是把这些 pieces 从各路 peers 那里下载到本地,根据种子里面的信息对每个 piece 做 hash 校验,最后合并成一个 debian-11.2.0-amd64-netinst.iso 的过程
- 发送请求
- 首先需要构建一个请求 tracker 服务器的 URL,发送 get 请求,需要在类似于这种 tracker URL
http://bttracker.debian.org:6969/announce后面加上info_hash,peer_id,port,uploaded,downloaded,compact,left这类的参数 info_hash: 就是将种子文件中info对应的内容转换成 byte 后再对其进行 sha1 hash 后的结果peer_id: 就是peer的 id,这个是随机生成的port: 客户端监听的端口号,这个端口号是为 BitTorrent 预留的,范围通常是 6881~6889uploaded: 上传的总的 byte 数量downloaded: 下载的总的 byte 数量compact: 1 表示客户端接收 精简过 的响应,0 表示客户端接收 全部响应left: 客户端还需要下载多少个 byte 才能完整的把info中对应的文件下载完成
最后将这个构建后的 URL encode 一下,使用 http.Client 发送 get 请求
- 解析响应
响应体里面有很多的字段,这里主要关心的是 peer 字段,这个字段里面包含了其对应的 ip 和 port,它是一段二进制数据,所以它的结构是类似于这样的数据结构
[192.168.1.1:6881, ...]
↓
---------------------------------------
|192| |168| |1| |1| |0x1A| |0xE1| |...|
---------------------------------------
所以对于一个 peer 的 size 来讲,它就是 6 个 byte
依据这个结构,将 ip 和 port 拆解出来,组成 Peer 这样的结构,然后将它们拼凑成数组
Peer 的结构如下
type Peer struct {
IP net.IP
Port uint16
}- 握手
上面拿到了 ip 跟 port,下面就可以进行握手操作了
握手的过程也是 发送请求 + 接收响应 + 校验 hash 的过程,基于 tcp
- 发送请求
调用 net.Conn.Write 发送请求
传的参数是上面提到的 infoHash 和一串随机生成的 PeerID
- 接收响应
调用 net.Conn.ReadFull 接收响应
因为是一个二进制 stream 流,所以响应体为类似于这样的一个数据结构
------------------------------------------------------------------------------
|ProtocolIdentifier length| |ProtocolIdentifier| |reserve| |InfoHash| |PeerID|
------------------------------------------------------------------------------
↓ ↓ ↓ ↓ ↓
1 byte ProtocolIdentifier length byte 8 byte 20 byte 20 byte
- 校验 hash
最后检查经过握手后拿到的 infoHash 和本地的 infoHash 是否一致,如果一致就可以进行下载的工作了
- 并发下载
这里的方式是使用 goroutines 和 channels,主要开启 2 个 channel
- 一个用来在
peer之间下载piece,有多少peer开多少线程 - 一个用来处理下载好的
piece
在下载之前,需要先确认客户端和 peer 之间的状态,而这个状态则用双方的 message 来确定
message 的格式如下
---------------------------------------
|length| |message ID| |message Payload|
---------------------------------------
↓ ↓ ↓
4 byte 1 byte n byte
length 表示 message ID + message Payload 占用多少个 byte
message ID 有如下的几种状态
const (
MessageChoke messageID = 0 // 阻塞接收
MessageUnChoke messageID = 1 // 不阻塞接收
MessageInterested messageID = 2 // 有兴趣接收数据
MessageNotInterested messageID = 3 // 没有兴趣接受数据
MessageHave messageID = 4 // 发送者已经下载好了一个 piece
MessageBitfield messageID = 5 // 判断哪些 piece 是 peers 有的,哪些没有
MessageRequest messageID = 6 // 从接收者那里请求一个 message
MessagePiece messageID = 7 // 执行请求,交付一个 piece
MessageCancel messageID = 8 // 取消请求
)message Payload 则承载对应 message ID 的数据
在握手完成之后,需要从 peer 那边先接收 MessageBitfield 的信息,它的 message Payload 表示已经下载成功的那堆 piece,其第一个 byte 的高位 (high bit) 对应于那堆 piece 的第 0 个 piece 的位置
如果其中某个 bit 为 0,那么说明这个 bit 对应的 piece 就是丢失的,
如果其中某个 bit 为 1,那么说明这个 bit 对应的 piece 就是有效的
在末尾多余的 bit 会被设置为 0
比如
0b1101111111
表示 piece 2 丢失,但是其他 piece 有效可用
然后需要发送 unchoke 和 interested message 给 peer,表示这边要开始下载了
在下载时,需要处理几件事情
- 在
Bitfield中查看对应的那堆piece的 index 这个位置,peer有没有这个piece,如果没有就循环再试一次 - 开始下载某个
piece,如果网络断掉就退出,如果下载完成就进行下一步 - sum hash 校验,如果下载下来的
piece跟文件中对应的piecehash 一致,就发送have给peer表示这个piece已经下载完成,并且将这个piece通过 channel 发送给results。否则就回到原点,循环再试一次
对于下载好的 piece 来说,计算它们还差多少个 piece 才完全下完,基于 results 来计算并显示进度
- 连接吞吐量优化
其实就是进行 pipeline 化,在有多个请求的情况下,对于同一个 socket 来讲,请求之间不用等待对应的响应回来再去执行,而是以一个 FIFO 队列的形式去执行,然后再以 FIFO 队列的形式接收响应,如下图所示
显然,用一个请求一个响应的方式效率非常低下
那么这个算法在 goMule 中是怎么实现的呢? 需要如下几步
- 当前
piece数据还没下满,就循环 - 如果没有阻塞,就发送请求去下载,直到 pipeline 请求限制数已满,或者当前请求下载的
piece数据已满(这都是在 state 状态里面查的) - 如果 pipeline 请求限制数已满,或者当前请求下载的
piece数据已满,那么就去更改对应的 state 状态
对于更改 state 状态,这里直接上代码
func (state *pieceProgress) ChangeState() error {
// 读取 message 数据
msg, err := state.Client.Read()
...
switch msg.ID {
case message.MessageUnChoke:
// 非阻塞
// 设置 chocked 状态为 false
state.Client.Choked = false
case message.MessageChoke:
// 阻塞
// 设置 chocked 状态为 true
state.Client.Choked = true
case message.MessageHave:
// 已经下载好了对应的 piece
index, err := message.ParseHave(msg)
...
// 设置一下 Bitfield 的对应 bit
// 表示这个位置的 piece 已经下载好了
// 后面查找时候可以用到
state.Client.Bitfield.SetPiece(index)
case message.MessagePiece:
// 要下载这么大的 piece
n, err := message.ParsePiece(state.Index, state.Buffer, msg)
...
// 记录一下这个有多大,为后面判断有没有下满提供条件
state.Downloaded += n
// 要下这么大,肯定需要增加请求,这里用来检查请求数是否到达了限制数(目前是 5)
state.Backlog--
}
return nil
}- 支持 BitTorrent 核心协议
- 支持下载进度展示
- 支持
.torrent文件解析 - 支持
p2p协议下载 - 支持
peers之间的并发下载
go get github.com/strugglebak/goMule可以尝试用 这个页面 下载 torrent,目前尝试用的例子是 debian-11.2.0-amd64-netinst.iso.torrent
git clone git@github.com:strugglebak/goMule.git
cd goMule
go build
./goMule debian-11.2.0-amd64-netinst.iso.torrent debian.isogo test -v ./...- 支持 Fast extension
- 支持 磁力链接
- 支持 多 tracker
- 支持 UDP tracker
- 支持 DHT
- 支持 PEX
- ...

