BitTorrent是最经典的P2P文件传输协议之一,虽然现在主流下载工具已经把整个流程封装得很好,但自己动手实现一个简易客户端,是理解P2P网络运作机制的绝佳方式。整个过程的核心链路是:解析种子文件、联系Tracker获取peer列表、与peer握手、交换数据。本文聚焦其中最关键的两步——Tracker通信与peer握手,并用Ruby逐步实现。之所以选择Ruby,是因为它的语法简洁,标准库自带socket和Bencode处理所需的几乎所有能力,代码量少且可读性高,非常适合协议实验。

准备工作:解析种子文件与info_hash计算
在与Tracker通信之前,必须先从.torrent文件中提取关键信息。.torrent文件使用bencode编码,这是一种简洁的二进制序列化格式,只有四种数据类型:整数(以i开头,e结尾,如i42e)、字节串(长度前缀加冒号,如4:spam)、列表(l...e)和字典(d...e)。理解bencode是实现客户端的第一道门槛,因为Tracker请求中最关键的info_hash就是由种子文件中info字典的原始bencode字节计算SHA1得到的。
下面先用Ruby实现一个bencode解码器。注意编码细节:解码字符串时必须使用二进制模式读取,否则多字节字符会破坏长度计算。计算info_hash时要对info字典对应的原始字节串做SHA1,而不是解码后的Ruby哈希对象,这是新手最容易犯的错误——一旦info_hash算错,后续所有peer都会拒绝握手。
require 'digest'
module Bencode
module_function
# 解码入口,返回解析后的对象和剩余字节
def decode(data)
obj, rest = decode_item(data.dup.force_encoding('BINARY'))
[obj, rest]
end
def decode_item(data)
case data[0]
when 'i' then decode_int(data)
when 'l' then decode_list(data)
when 'd' then decode_dict(data)
else decode_string(data)
end
end
def decode_int(data)
data.sub!(/^i(-?\d+)e/, '')
[$1.to_i, data]
end
def decode_string(data)
data.sub(/^(\d+):/, '')
len = $1.to_i
[data.slice!(0, len), data]
end
def decode_list(data)
data.slice!(0) # 去掉开头的l
list = []
until data.start_with?('e')
item, data = decode_item(data)
list << item
end
data.slice!(0)
[list, data]
end
def decode_dict(data)
data.slice!(0) # 去掉开头的d
dict = {}
until data.start_with?('e')
key, data = decode_string(data)
value, data = decode_item(data)
dict[key] = value
end
data.slice!(0)
[dict, data]
end
end
# 计算info_hash:重新定位info字段的原始字节段
def raw_info_bytes(data)
idx = data.index('4:info') + '4:info'.length
obj, rest = Bencode.send(:decode_item, data[idx..-1])
consumed = data[idx..-1].length - rest.length
data[idx, consumed]
end
torrent = File.binread('test.torrent')
info_hash = Digest::SHA1.digest(raw_info_bytes(torrent))
puts info_hash.unpack1('H*')上面的raw_info_bytes方法通过重新截取info字段的原始字节来计算哈希,避免了重新编码时字节不一致的问题。理论上也可以自己实现bencode编码器再重新序列化info字典,但要保证键的顺序和编码方式与原始文件完全一致,稍有差错就会导致info_hash不匹配,所以直接截取原始字节是最稳妥的方案。
与Tracker通信:构造请求并解析peer列表
拿到info_hash后,就可以联系Tracker了。Tracker本质上是一个HTTP服务器,客户端通过GET请求告知自己的状态,Tracker返回当前可用的peer列表。对于单文件种子,请求URL通常包含以下几个参数:info_hash(必须URL编码,且是原始20字节二进制,不是十六进制字符串)、peer_id(客户端自定的20字节标识)、port(监听端口)、uploaded、downloaded、left(剩余字节数)、compact(是否希望接收紧凑格式的peer列表)、event(started、stopped或completed)。
这里有个非常典型的坑:info_hash的URL编码。因为它包含任意二进制字节,直接拼接到URL里会导致Tracker解析失败。Ruby中需要逐字节替换,非字母数字且非常见的字符都要转成百分号编码。compact=1时,Tracker返回的peers字段是紧凑二进制格式:每个peer占6字节,前4字节是IPv4地址,后2字节是大端序端口号,解析起来非常高效。
require 'uri'
require 'open-uri'
require 'json'
PEER_ID = '-RB0001-' + rand(36**12).to_s(36).rjust(12, '0')
def urlencode_hash(hash_bytes)
# 逐字节百分号编码,保留点号和波浪号
hash_bytes.bytes.map do |b|
c = b.chr
/[A-Za-z0-9._~-]/.match?(c) ? c : format('%%%02X', b)
end.join
end
def contact_tracker(torrent, info_hash)
announce = torrent['announce']
params = {
'info_hash' => urlencode_hash(info_hash),
'peer_id' => PEER_ID,
'port' => 6881,
'uploaded' => 0,
'downloaded' => 0,
'left' => torrent['info']['length'],
'compact' => 1,
'event' => 'started'
}
url = announce + '?' + URI.encode_www_form(params)
body = URI.open(url).read
response, = Bencode.decode(body)
peers = []
raw = response['peers']
raw.unpack('C*').each_slice(6) do |chunk|
ip = chunk[0, 4].join('.')
port = chunk[4, 2].pack('C*').unpack1('n')
peers << [ip, port]
end
[peers, response['interval']]
end
peers, interval = contact_tracker(torrent, info_hash)
puts "获取到 #{peers.size} 个peer,建议每 #{interval} 秒重新请求一次"
peers.first(5).each { |ip, port| puts "#{ip}:#{port}" }解析peer列表时用到了Ruby的unpack方法,先把二进制串拆成字节数组,再按6字节一组切分。n模板表示16位大端序无符号整数,正好对应端口号的网络字节序。如果不使用compact模式,Tracker会返回bencode编码的字典列表,包含peer id、ip、port三个字段,两种格式各有利弊:紧凑格式流量小、解析快,字典格式可读性好、便于调试,实际客户端一般都用compact模式。
另外注意Tracker响应中的interval字段,它告诉客户端应该间隔多少秒重新汇报进度。遵守这个间隔很重要,请求过于频繁可能被Tracker封禁,过于稀疏则会导致peer列表过期。一个健壮的客户端还应该处理failure reason字段和failure_code,用来区分是参数错误还是临时故障。
与peer握手:建立TCP连接并验证协议
拿到peer列表后,就到了P2P环节的重头戏——握手。BitTorrent握手消息结构固定,总长度68字节:第一字节是协议名长度(恒为19),接着是19字节的协议字符串BitTorrent protocol,然后是8字节保留字段(目前基本填零,用于扩展协议协商),随后是20字节的info_hash和20字节的peer_id。对方收到握手后,会校验info_hash是否与自己持有的种子一致,一致则回送一个包含自己peer_id的握手,不一致则直接断开连接。
用Ruby的socket库实现握手非常直观。需要注意的是所有读写操作都要用二进制模式,且recv可能返回不完整的分片,TCP是流式协议,一次recv不保证拿到完整68字节,所以要循环读取直到凑齐。这也是初学者常踩的坑:本地测试时往往一次就能读完,但网络稍差就会收到半截握手数据导致解析错乱。
require 'socket'
PROTOCOL = 'BitTorrent protocol'
RESERVED = "\x00" * 8
def build_handshake(info_hash)
[PROTOCOL.length, PROTOCOL, RESERVED, info_hash, PEER_ID].pack('Ca19a8a20a20')
end
def recv_exact(socket, length)
data = +''
while data.length < length
chunk = socket.recv(length - data.length)
raise '连接被对方关闭' if chunk.nil? || chunk.empty?
data << chunk
end
data
end
def handshake_with_peer(ip, port, info_hash)
socket = Socket.tcp(ip, port, connect_timeout: 5)
socket.write(build_handshake(info_hash))
# 读取并校验对方返回的握手
pstrlen = recv_exact(socket, 1).unpack1('C')
raise '非法的协议名长度' unless pstrlen == 19
rest = recv_exact(socket, 19 + 8 + 20 + 20)
proto, reserved, remote_hash, remote_id = rest.unpack('a19a8a20a20')
raise 'info_hash不匹配,对方没有这个种子' unless remote_hash == info_hash
puts "握手成功: #{ip}:#{port} peer_id=#{remote_id.unpack1('H*')}"
socket
rescue SystemCallError, RuntimeError => e
puts "连接 #{ip}:#{port} 失败: #{e.message}"
socket&.close
nil
end
peers.each do |ip, port|
handshake_with_peer(ip, port, info_hash)
end代码中用pack('Ca19a8a20a20')一次性构造握手报文,C是单字节无符号整数,aN是定长二进制字符串,这种写法比手动拼接更不容易出错。校验逻辑也很关键:除了比对info_hash,还应该检查协议字符串是否为BitTorrent protocol,某些实现(尤其是µTP或加密传输场景)可能返回不同的协议标识,简单粗暴地按固定偏移解析会得到错误结果。
握手成功后,连接进入消息交换阶段。对方可能立刻发送bitfield消息,告知自己拥有哪些数据分片,之后双方通过interested、choke、unchoke等状态消息协调下载。如果对多个peer并发握手,可以结合Ruby的Thread或Fiber实现,每个连接独立处理超时和异常,避免某个慢速peer拖垮整个下载循环。
常见问题与调试建议
实现过程中最常见的问题集中在两处。一是info_hash计算错误,表现为所有peer都拒绝握手或Tracker返回错误,排查方法是打印十六进制哈希,与Transmission等成熟客户端的日志对比。二是URL编码问题,如果Tracker返回空的peer列表,多半是info_hash编码方式不符合规范,务必确认发送的是20字节原始二进制的百分号编码,而不是40字符的十六进制串。
调试网络交互时,建议先用本地Tracker(如opentracker)配合另一个客户端做测试,这样握手对象的行为是可预期的。抓包工具也是利器,用Wireshark过滤BT协议流量,可以直观看到握手报文的每个字节,比反复猜测代码逻辑高效得多。此外,读官方规范文档永远是最可靠的学习途径,BEP-3对bencode、Tracker协议和握手流程都有明确定义,本文实现的每个细节都能在其中找到依据。完成这两步之后,下一步就是实现消息循环和分片请求逻辑,届时一个真正能下载文件的客户端就成型了。
RubyBitTorrent客户端peer握手修改时间:2026-09-05 01:58:46