Пишем свой BitTorrent-клиент на Python

Когда-нибудь думали о том, чтобы написать свой BitTorrent-клиент с блекджеком и без рекламы? Пока вы думали, кто-то уже написал. Перевели статью автора клиента Pieces, в которой он рассказывает, как устроен сам протокол и клиент. К слову, проект доступен под лицензией Apache 2, так что вы можете спокойно делать с этим клиентом что угодно.

Введение в BitTorrent

BitTorrent начал своё существование в 2001 году, когда Брэм Коэн представил первую версию протокола. Большой прорыв произошёл, когда сайты вроде The Pirate Bay принесли ему популярность в качестве средства для загрузки пиратских материалов. Стриминговые сервисы во главе с Netflix, возможно, привели к уменьшению количества людей, скачивающих фильмы через BitTorrent. Тем не менее, BitTorrent по-прежнему используют в разных целях при необходимости распространить большие файлы.

BitTorrent — пиринговый протокол, в котором пиры объединяются с другими пирами для обмена фрагментами данных между собой. Каждый пир одновременно соединяется с несколькими пирами и таким образом скачивает или загружает данные одновременно нескольким пирам. Это здорово с точки зрения ограничения пропускной способности, если сравнивать, например, с загрузкой файла с одного сервера. Также это отлично походит, для того чтобы обеспечить доступность файла, так как место хранения распределено.

В технологии BitTorrent существует файл .torrent, в котором указывается количество фрагментов для данного файла(ов), как пиры должны обмениваться этими фрагментами, а также то, как клиенты могут подтвердить целостность данных.

Далее мы посмотрим на реализацию клиента BitTorrent, и было бы неплохо знать неофициальную спецификацию BitTorrent или хотя бы иметь открытую вкладку с ней. Это, без сомнения, лучший источник информации о протоколе BitTorrent. Официальная спецификация расплывчата, и в ней не хватает определённых деталей, так что лучше использовать именно неофициальную.

Парсим торрент-файл

Первое, что клиент должен сделать, — выяснить, что и откуда он должен скачать. Этот тип информации (метаинформация) хранится в файле .torrent. В метаинформации хранится ряд свойств, которые нам нужны для успешной реализации клиента:

  • Имя файла для загрузки;
  • Размер файла;
  • URL трекера, к которому мы должны подключиться.

Все эти свойства хранятся в бинарном формате Bencode.

Bencode поддерживает четыре типа данных: словари, списки, целые числа и строки — поэтому его легко привести к объекту Python или в формат JSON.

Ниже Bencode описан в расширенной форме Бэкуса-Наура, предоставленной библиотекой Haskell:

<BE>    ::= <DICT> | <LIST> | <INT> | <STR>

<DICT>  ::= "d" 1 * (<STR> <BE>) "e"
<LIST>  ::= "l" 1 * <BE>         "e"
<INT>   ::= "i"     <SNUM>       "e"
<STR>   ::= <NUM> ":" n * <CHAR>; where n equals the <NUM>

<SNUM>  ::= "-" <NUM> / <NUM>
<NUM>   ::= 1 * <DIGIT>
<CHAR>  ::= %
<DIGIT> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

В Pieces кодирование и декодирование bencode-данных реализовано в модуле pieces.bencoding.

Вот пара примеров того, как этот модуль преобразует bencode-данные в объекты Python:

>>> from pieces.bencoding import Decoder

# Целочисленное значение начинается с символа 'i', за которым
# следует набор цифр до завершения символом 'e'.
>>> Decoder(b'i123e').decode()
123

# Строковое значение начинается с определения количества
# символов, которое она содержит. Затем через двоеточие
# следует сама строка. Обратите внимание на то, что
# строка возвращается в бинарном формате, а не в Юникоде.
>>> Decoder(b'12:Middle Earth').decode()
b'Middle Earth'

# Список начинается с символа 'l', за которым следует любое количество
# объектов до завершения символом 'e'.
# Как и в Python, список может содержать объекты любого типа.
>>> Decoder(b'l4:spam4:eggsi123ee').decode()
[b'spam', b'eggs', 123]

# Словарь начинается с символа 'd' и завершается символом 'e'.
# Объекты между этими символами должны быть парами строка + объект.
# Порядок важен, поэтому используется OrderedDict
>>> Decoder(b'd3:cow3:moo4:spam4:eggse').decode()
OrderedDict([(b'cow', b'moo'), (b'spam', b'eggs')])

Можно сделать и наоборот — преобразовать Python-объект в bencode-форму:

>>> from collections import OrderedDict
>>> from pieces.bencoding import Encoder

>>> Encoder(123).encode()
b'i123e'

>>> Encoder('Middle Earth').encode()
b'12:Middle Earth'

>>> Encoder(['spam', 'eggs', 123]).encode()
bytearray(b'l4:spam4:eggsi123ee')

>>> d = OrderedDict()
>>> d['cow'] = 'moo'
>>> d['spam'] = 'eggs'
>>> Encoder(d).encode()
bytearray(b'd3:cow3:moo4:spam4:eggse')

Эти примеры также можно найти в репозитории проекта.

Реализация парсера довольно простая, здесь не используется asyncio, и даже не считывается торрент-файл с диска.

Давайте откроем торрент-файл для популярного дистрибутива Linux Ubuntu с помощью парсера из pieces.bencoding:

>>> with open('tests/data/ubuntu-16.04-desktop-amd64.iso.torrent', 'rb') as f:
...     meta_info = f.read()
...     torrent = Decoder(meta_info).decode()
...
>>> torrent
OrderedDict([(b'announce', b'http://torrent.ubuntu.com:6969/announce'), (b'announce-list', [[b'http://torrent.ubuntu.com:6969/announce'], [b'http://ipv6.torrent.ubuntu.com:6969/announce']
]), (b'comment', b'Ubuntu CD releases.ubuntu.com'), (b'creation date', 1461232732), (b'info', OrderedDict([(b'length', 1485881344), (b'name', b'ubuntu-16.04-desktop-amd64.iso'), (b'piece
length', 524288), (b'pieces', b'\x1at\xfc\x84\xc8\xfaV\xeb\x12\x1c\xc5\xa4\x1c?\xf0\x96\x07P\x87\xb8\xb2\xa5G1\xc8L\x18\x81\x9bc\x81\xfc8*\x9d\xf4k\xe6\xdb6\xa3\x0b\x8d\xbe\xe3L\xfd\xfd4\...')]))])

Здесь мы можем увидеть часть метаданных, например, имя целевого файла (ubuntu-16.04-desktop-amd64.iso) и общий размер в байтах (1 485 881 344).

Обратите внимание на то, что ключи в OrderedDict являются бинарными строками. Bencode — бинарный протокол, поэтому строки в формате UTF-8 не подойдут в качестве ключей.

Реализованный в Pieces класс-обёртка pieces.torrent.Torrent, который использует эти свойства, абстрагирует бинарные строки и прочие детали от остальной части клиента. Этот класс только реализует свойства, используемые в Pieces.

Подключаемся к трекеру

Теперь, когда мы можем декодировать торрент-файл и получить представление данных в виде Python-объекта, нам нужно получить список пиров для подключения. И здесь на помощь приходит трекер. Трекер — это центральный сервер, который отслеживает доступные пиры для выбранного торрента. Трекер не содержит никаких данных торрента, только список доступных пиров и их статистику.

Составляем запрос

Свойство announce в метаинформации — это HTTP URL трекера, к которому мы будем подключаться с помощью следующих URL-параметров:

  • info_hash — SHA1-хеш словаря с информацией в торрент-файле;
  • peer_id — уникальный ID, сгенерированный для данного клиента;
  • uploaded — общее количество отправленных байтов;
  • downloaded — общее количество загруженных байтов;
  • left — количество байтов, которое клиенту осталось загрузить;
  • port — TCP-порт, на котором клиент слушает;
  • compact — принимает ли клиент компактный список пиров.

Размер peer_id должен составлять ровно 20 байт. Существуют два основных соглашения по генерации этого ID. Pieces следует Azureus-стилю при генерации ID пира, например:

>>> import random
# -<2-символьный id><номер версии из 4 цифр>-
>>> '-PC0001-' + ''.join([str(random.randint(0, 9)) for _ in range(12)])
'-PC0001-478269329936'

Запрос к трекеру с помощью httpie может выглядеть следующим образом:

$ http GET "http://torrent.ubuntu.com:6969/announce?info_hash=%90%28%9F%D3M%FC%1C%F8%F3%16%A2h%AD%D85L%853DX&peer_id=-PC0001-706887310628&uploaded=0&downloaded=0&left=699400192&port=6889&compact=1"
HTTP/1.0 200 OK
Content-Length: 363
Content-Type: text/plain
Pragma: no-cache

d8:completei3651e10:incompletei385e8:intervali1800e5:peers300:£¬%ËÌyOk‚Ý—.ƒê@_<K+Ô\Ý Ámb^TnÈÕ^ŒAˏOŒ*ÈÕ1*ÈÕ>¥³ÈÕBä)ðþ¸ÐÞ¦Ô/ãÈÕÈuÉæÈÕ
...

Данные ответа обрезаны, так как они всё равно содержат бинарные данные, которые никто не поймёт.

В ответе трекера нас особенно интересуют два свойства:

  • interval — интервал в секундах до того, как клиент должен сделать новый запрос к трекеру;
  • peers — список пиров, представленный бинарной строкой, состоящей из частей по 6 байт. В каждой части 4 байта отвечают за IP-адрес пира и 2 — за номер порта (так как мы используем компактный формат).

Успешный запрос к трекеру даёт вам список пиров для подключения. Это не обязательно будет список вообще всех пиров, а только тех, которые трекер вам назначил. Следующий запрос к трекеру может вернуть другой список пиров.

Асинхронный HTTP

В Python нет встроенной поддержки асинхронного HTTP, даже любимая многими библиотека requests не реализует asyncio. Поэтому оптимальным вариантом будет использование библиотеку aiohttp.

Pieces использует aiohttp в классе pieces.tracker.Tracker для HTTP-запросов к трекеру. Так выглядит укороченная версия этого кода:

async def connect(self, first=None, uploaded=0, downloaded=0):
    params = {...}
    url = self.torrent.announce + '?' + urlencode(params)

    async with self.http_client.get(url) as response:
        if response.status != 200:
            raise ConnectionError('Unable to connect to tracker')
        data = await response.read()
        return TrackerResponse(bencoding.Decoder(data).decode())

Этот метод объявлен с использованием async и использует асинхронный менеджер контекста async with, с возможностью приостановки во время совершения HTTP-запроса. После успешного ответа этот метод будет снова приостановлен на время чтения бинарных данных ответа в await response.read(). Наконец, данные ответа оборачиваются в экземпляр TrackerResponse, содержащий список пиров или сообщение об ошибке.

В результате использования aiohttp наш цикл событий может свободно планировать другую работу, пока у нас есть исходящий запрос к трекеру.

Если нужны подробности — загляните в модуль pieces.tracker.

Цикл

До этого момента мы всё могли бы выполнять синхронно, однако теперь нам нужно подключиться к множеству пиров, что требует асинхронности.

Функция main в pieces.cli отвечает за создание асинхронного цикла событий. Если опустить некоторые детали argparse и обработки ошибок, это будет выглядеть как-то так (подробности в исходниках):

import asyncio

from pieces.torrent import Torrent
from pieces.client import TorrentClient

loop = asyncio.get_event_loop()
client = TorrentClient(Torrent(args.torrent))
task = loop.create_task(client.start())

try:
    loop.run_until_complete(task)
except CancelledError:
    logging.warning('Event loop was canceled')

Сначала мы создаём цикл событий по умолчанию для этого потока. Затем мы создаём объект TorrentClient с нужным Torrent (метаинформация). Он пропарсит торрент-файл и убедится, что всё в порядке.

Затем мы вызываем async-метод client.start() и оборачиваем его результат в asyncio.Future, а потом добавляем эту future в цикл событий и просим его работать, пока эта задача не будет завершена.

Это всё? Не совсем — у нас есть цикл (не цикл событий), реализованный в pieces.client.TorrentClient, который устанавливает соединения с пирами, планирует запросы и т.д.

TorrentClient является чем-то вроде координатора действий. Он начинает свою работу с создания очереди async.Queue, которая хранит список доступных для подключения пиров.

Затем он создаёт N объектов pieces.protocol.PeerConnection, по одному на каждый пир в очереди. Эти объекты будут ожидать до тех пор, пока в очереди не появится незаблокированный пир.

Так как с самого начала очередь пуста, PeerConnection ничего не будет делать до тех пор, пока мы не заполним очередь. Это происходит в цикле внутри TorrentClient.start:

async def start(self):
    self.peers = [PeerConnection(self.available_peers,
                                 self.tracker.torrent.info_hash,
                                 self.tracker.peer_id,
                                 self.piece_manager,
                                 self._on_block_retrieved)
                    for _ in range(MAX_PEER_CONNECTIONS)]

    # Время, когда мы в последний раз обращались к трекеру (timestamp)
    previous = None
    # Интервал между обращениями (в секундах)
    interval = 30 * 60

    while not self.piece_manager.complete and not self.abort:
        current = time.time()
        if (not previous) or (previous + interval < current):
            response = await self.tracker.connect(
                first=previous if previous else False,
                uploaded=self.piece_manager.bytes_uploaded,
                downloaded=self.piece_manager.bytes_downloaded)

            if response:
                previous = current
                interval = response.interval
                self._empty_queue()
                for peer in response.peers:
                    self.available_peers.put_nowait(peer)
        else:
            await asyncio.sleep(5)
    self.stop()

В общих чертах о том, что делает этот цикл:

  1. Проверяет, загрузили ли мы все фрагменты.
  2. Проверяет, не отменил ли пользователь загрузку.
  3. Делает запрос к трекеру, если необходимо.
  4. Добавляет все полученные пиры в очередь доступных пиров.
  5. Спит 5 секунд.

Итак, каждый раз при запросе к трекеру список пиров обнуляется, и если мы не получаем в результате запроса другой список, то PeerConnection не запустится. Это продолжается до тех пор, пока загрузка не будет завершена или отменена.

Протокол пиров

После получения IP пира и номера порта от трекера, клиент установит TCP-соединение с этим пиром. Затем пиры начнут обмениваться сообщениями при помощи протокола пиров. Давайте сначала пройдёмся по разным частям этого протокола, а затем посмотрим на его реализацию.

Рукопожатие

Первое сообщение, которое нужно отправить, это Handshake (рукопожатие). Обмен рукопожатиями инициирует подключающийся клиент.

Сразу после отправки рукопожатия наш клиент должен получить рукопожатие от удалённого пира.

Сообщение Handshake содержит два важных поля:

  • peer_id — уникальный ID каждого пира;
  • info_hash — The SHA1-хеш для словаря с информацией.

Если info_hash не совпадает с торрентом, который мы собираемся скачать, мы закрываем соединение.

После обмена рукопожатиями удалённый пир может отправить сообщение BitField.  Его задача — сообщить клиенту, какие фрагменты есть у пира. Pieces поддерживает принятие сообщений BitField, так как большинство клиентов их отправляет. Однако из-за того, что Pieces на данный момент не поддерживает сидирование, такие сообщения никогда не отправляются, а только принимаются.

Сообщение BitField содержит последовательность байтов. Если прочесть их в бинарном режиме, то каждый бит будет представлять один фрагмент. Если бит равен 1, то это значит, что у пира есть такой фрагмент, если 0 — такого фрагмента нет. Таким образом, каждый байт представляет до 8 фрагментов.

Каждый клиент начинает работу в состоянии Choked и Not interested. Это значит, что клиент не может запрашивать фрагменты у удалённого пира, а также то, что мы в этом и не заинтересованы.

  • Choked (заблокирован) — в этом состоянии пир не может запрашивать фрагменты у другого пира;
  • Unchoked (разблокирован) — в этом состоянии пир может запрашивать фрагменты у другого пира;
  • Interested (заинтересован) — это состояние говорит о том, что пир заинтересован в получении фрагментов;
  • Not interested (не заинтересован) — это состояние говорит о том, что пир не заинтересован в получении фрагментов.

Считайте Choked и Unchoked правилами, а Interested и Not interested — намерениями между двумя пирами.

После обмена рукопожатиями мы отправляем сообщение Interested удалённому пиру, говоря о том, что мы хотим перейти в состояние Unchoked, чтобы начать запрашивать фрагменты.

Пока клиент не получит сообщение Unchoke, он не может запрашивать фрагменты у удалённого пира. Таким образом, PeerConnection будет заблокирован (в пассивном состоянии) до тех пор, пока либо не будет разблокирован, либо не будет установлено соединение.

Вот к такой последовательности сообщений мы стремимся, когда создаём PeerConnection:

              Handshake
    клиент --------------> пир    Инициируем обмен рукопожатиями

              Handshake
    клиент <-------------- пир    Сравниваем хеши

              BitField
    клиент <-------------- пир    Возможно, получаем BitField

             Interested
    клиент --------------> пир    Даём пиру понять, что мы заинтересованы в загрузке

              Unchoke
    клиент <-------------- пир    Пир разрешает нам начать запрашивать фрагменты

Запрашиваем фрагменты

Как только клиент переходит в разблокированное состояние, он начнёт запрашивать фрагменты у пира. О том, какой фрагмент нужно скачивать, поговорим чуть позже.

Если мы знаем, что у другого пира есть нужный нам фрагмент, мы можем отправить удалённому пиру запрос с просьбой прислать данные, соответствующие этому фрагменту. Если пир согласится, то он отправит соответствующее сообщение, полезная нагрузка которого — просто кусок данных.

Каждому пиру клиент отправляет только один запрос, а затем терпеливо ожидает сообщение с фрагментом до предпринятия следующих действий. Поскольку клиент параллельно открывает подключения к нескольким пирам, в каждый момент у него будет несколько ожидающих запросов, по одному на подключение.

Если по какой-либо причине клиенту больше не нужен фрагмент, он может отправить сообщение Cancel, чтобы отменить отправленный ранее запрос.

Другие сообщения

Have — сообщение, которое в любой момент может отправить нам удалённый пир. Это происходит после того, как удалённый пир получает новый фрагмент и хочет сделать его доступным для пиров, подключённых к нему.

Содержимым этого сообщения является индекс фрагмента.

Когда Pieces получает сообщение Have, он обновляет информацию об имеющихся у пира фрагментах.

KeepAlive —сообщение, которое может быть отправлено в любой момент в любом направлении. Это сообщение не несёт дополнительных данных, а лишь указывает на необходимость поддерживать соединение.

Реализация

PeerConnection асинхронно открывает TCP-соединение с удалённым пиром с помощью asyncio.open_connection. Соединение возвращает кортеж из StreamReader и StreamWriter. Если соединение было успешно установлено, PeerConnection отправит и получит сообщение Handshake.

После обмена рукопожатиями PeerConnection использует асинхронный итератор, чтобы вернуть поток PeerMessages и предпринять соответствующее действие.

Использование асинхронного итератора отделяет PeerConnection от подробностей того, как считывать данные с сокета и как парсить бинарный протокол BitTorrent. Вместо этого PeerConnection может сфокусироваться на семантике вне зависимости от протокола — например, управлении состоянием пира, получении фрагментов, закрытии соединения.

Код в PeerConnection.start выглядит примерно так:

async for message in PeerStreamIterator(self.reader, buffer):
    if type(message) is BitField:
        self.piece_manager.add_peer(self.remote_id, message.bitfield)
    elif type(message) is Interested:
        self.peer_state.append('interested')
    elif type(message) is NotInterested:
        if 'interested' in self.peer_state:
            self.peer_state.remove('interested')
    elif type(message) is Choke:
        ...

Асинхронный итератор — это класс, реализующий методы __aiter__ и __anext__, которые являются async-версиями методов __iter__ и __next__ стандартных итераторов в Python.

В процессе итерирования PeerStreamIterator будет читать данные из StreamReader, и, если данных достаточно, попытается пропарсить их и вернуть соответствующее PeerMessage.

Протокол BitTorrent использует сообщения с переменной длиной. Каждое сообщение имеет вид <length><id><payload>:

  • length — 4-байтовое целое значение;
  • id — одиночный десятичный байт;
  • payload является переменной и зависит от сообщения.

Таким образом, буфер парсится и возвращается из итератора, как только в нём оказывается достаточно данных для следующего сообщения.

Все сообщения декодируются с помощью стандартного модуля struct, в котором есть методы для конвертации Python-объектов в Cи-структуры и наоборот. Этот модуль использует короткие строки для описания того, что нужно конвертировать. Например, >Ib значит «Big-Endian, 4-байтное беззнаковое целое число, 1-байтовый символ».

Обратите внимание на то, что в BitTorrent все сообщения используют Big-Endian.

Это позволяет с лёгкостью создавать юнит-тесты для кодирования и декодирования сообщений. Посмотрим на тесты для сообщения Have:

class HaveMessageTests(unittest.TestCase):
    def test_can_construct_have(self):
        have = Have(33)
        self.assertEqual(
            have.encode(),
            b"\x00\x00\x00\x05\x04\x00\x00\x00!")

    def test_can_parse_have(self):
        have = Have.decode(b"\x00\x00\x00\x05\x04\x00\x00\x00!")
        self.assertEqual(33, have.index)

Глядя на бинарную строку, можно сказать, что длина сообщения Have составляет 5 байт — \x00\x00\x00\x05, id равен 4 — \x04, а полезная нагрузка содержит 33 —  \x00\x00\x00!.

Так как длина сообщения равна 5, а ID использует только один байт, у нас остаётся четыре байта на полезную нагрузку. С помощью struct.unpack мы можем легко конвертировать их в целое число:

>>> import struct
>>> struct.unpack('>I', b'\x00\x00\x00!')
(33,)

Вот так вне зависимости от протокола все сообщения следуют одной и той же процедуре, и итератор продолжает читать из сокета до обрыва соединения.

Разбираемся с фрагментами

До этого момента мы только обсуждали фрагменты — фрагменты данных, которыми обмениваются два пира. Оказывается, что кроме фрагментов есть ещё и блоки. Если вы уже успели пробежаться по исходному коду, то вы могли заметить в некоторых местах упоминание блоков, поэтому давайте разберёмся с тем, чем на самом деле являются фрагменты.

Фрагмент, как ни странно, является частью данных торрента. Данные торрента разбиваются на N фрагментов одинакового размера, за исключением последнего, который может быть меньшего размера. Длина фрагмента указывается в торрент-файле. Как правило, размер фрагментов составляет 512 килобайт или меньше, а также является степенью двойки.

Тем не менее, фрагменты всё ещё остаются слишком большими для эффективной передачи, поэтому их делят на блоки. Блоки представляют собой куски данных, которыми пиры на самом деле обмениваются. Фрагменты используются для индикации того, что у данного пира есть определённые данные. Если бы использовались только блоки, то это бы сильно увеличило размеры всего — BitField стал бы длиннее, количество сообщений Have увеличилось бы, а сам торрент-файл стал бы занимать больше места.

Размер блока составляет 214 (16 384) байт, кроме последнего, который с наибольшей долей вероятности будет меньшего размера.

Посмотрим на пример, в котором .torrent описывает только один файл foo.txt для загрузки:

name: foo.txt
length: 135168
piece length: 49152

Этот маленький торрент можно разделить на три фрагмента:

фрагмент 0: 49 152 байт
фрагмент 1: 49 152 байт
фрагмент 2: 36 864 байт (135168 - 49152 - 49152)
          = 135 168

Теперь каждый фрагмент делится на блоки:

фрагмент 0:
    блок 0: 16 384 байт (2^14)
    блок 1: 16 384 байт
    блок 2: 16 384 байт
         =  49 152 байт

фрагмент 1:
    блок 0: 16 384 байт
    блок 1: 16 384 байт
    блок 2: 16 384 байт
         =  49 152 байт

фрагмент 2:
    блок 0: 16 384 байт
    блок 1: 16 384 байт
    блок 2:  4 096 байт
         =  36 864 байт

в сумме:     49 152 байт
          +  49 152 байт
          +  36 864 байт
          = 135 168 байт

Суть BitTorrent состоит как раз в обмене такими блоками между пирами. Когда все блоки одного фрагмента загружены, этим фрагментом можно поделиться с другими пирами, и им отправляется сообщение Have. Как только все фрагменты загружены, пир превращается из личера (загружающего) просто в сидера (раздающего).

Два замечания по поводу официальной спецификации:

  1. В официальной спецификации как фрагменты, так и блоки называются просто фрагментами, что может легко запутать. Неофициальная спецификация и другие используют понятие блока в качестве фрагмента поменьше, поэтому его используем и мы.
  2. В официальной спецификации размер блока отличается от того, который используем мы. Однако если посмотреть в неофициальную спецификацию, то можно увидеть, что принято использовать именно 214 байт, вне зависимости от того, что написано в официальной спецификации.

Реализация

После создания TorrentClient создаётся и PieceManager, в чьи обязанности входит:

  • Определять, какой блок запросить следующим;
  • Сохранять полученные блоки в файл;
  • Определять момент завершения загрузки.

Когда PeerConnection успешно обменяется рукопожатиями с другим пиром и получит сообщение BitField, он сообщит PieceManager о том, какие фрагменты есть у какого пира (peer_id). Эта информация будет обновляться при получении каждого сообщения Have. Благодаря этой информации у PeerManager есть общая картина того, что где доступно.

Когда первый PeerConnection перейдёт в состояние Unchoked, он запросит следующий блок у пира. Следующий блок определяется вызовом метода PieceManager.next_request.

Метод next_request очень просто определяет, какой фрагмент запросить следующим:

  1. После создания PieceManager все фрагменты и блоки заранее формируются на основе информации о длине фрагментов, находящейся в торрент-файле.
  2. Все фрагменты помещаются в список отсутствующих фрагментов.
  3. Во время вызова next_request менеджер сделает что-то из следующего:
    • Запросит заново любой блок, время ожидания которого было превышено;
    • Запросит следующий блок текущего фрагмента;
    • Запросит первый блок следующего отсутствующего фрагмента.

Таким образом блоки и фрагменты будут запрашиваться по порядку.

Так как Pieces — простой клиент, в нём не реализовывались какие-либо специальные стратегии по выбору фрагментов. Лучшим решением было бы запрашивать сначала наиболее редкий фрагмент, что хорошо скажется на всех пирах.

Каждый раз при получении блока PieceManager сохраняет его в памяти. Когда все блоки фрагмента получены, для него вычисляется SHA1-хеш. Этот хеш сравнивается с хешем, хранимым в торрент-файле, и если они совпадают, то фрагмент записывается на диск.

Когда все фрагменты получены, TorrentClient останавливается, закрывает все открытые TCP-соединения, и программа завершает свою работу с сообщением о том, что торрент был загружен.

Что дальше?

Необходимо реализовать сидирование. Это будет выглядеть примерно так:

  • Каждый раз при подключении к нам пира, мы должны отправить ему сообщение BitField, чтобы сообщить, какими фрагментами мы располагаем;
  • Каждый раз, когда мы получаем новый фрагмент, каждый объект PeerConnection должен отправить удалённому пиру сообщение Have, чтобы сообщить, что у нас появился новый фрагмент.

Для этого нужно сделать так, чтобы PieceManager возвращал список с нулями и единицами, чтобы отобразить, какие фрагменты у нас есть. А TorrentClient должен говорить PeerConnection отправить удалённому пиру сообщение Have.

Дополнительные возможности, которые, вероятно, можно добавить без особых усилий:

  • Многофайловый торрент. Тут придётся пошаманить с PieceManager, так как фрагменты и блоки относятся к разным файлам, это повлияет на способ их сохранения;
  • Продолжение загрузки. Этого можно достичь проверкой уже скачанных файлов с помощью хешей.

Перевод статьи «A BitTorrent client in Python 3.5»