Пишем свой BitTorrent-клиент на Python
Автор BitTorrent-клиента Pieces рассказывает об устройстве протокола и делится своими опытом написания приложения под этот протокол на Python:
37К открытий39К показов
Когда-нибудь думали о том, чтобы написать свой BitTorrent-клиент с блекджеком и без рекламы? Пока вы думали, кто-то уже написал. Перевели статью автора клиента Pieces, в которой он рассказывает, как устроен сам протокол и клиент. К слову, проект доступен под лицензией Apache 2, так что вы можете спокойно делать с этим клиентом что угодно.
Введение в BitTorrent
BitTorrent начал своё существование в 2001 году, когда Брэм Коэн представил первую версию протокола. Большой прорыв произошёл, когда сайты вроде The Pirate Bay принесли ему популярность в качестве средства для загрузки пиратских материалов. Стриминговые сервисы во главе с Netflix, возможно, привели к уменьшению количества людей, скачивающих фильмы через BitTorrent. Тем не менее, BitTorrent по-прежнему используют в разных целях при необходимости распространить большие файлы.
- Facebook использует его для распространения обновлений в своих огромных датацентрах;
- Amazon S3 реализует его для загрузки статических файлов;
- Он все ещё используется для обычной загрузки больших файлов вроде дистрибутивов Linux.
BitTorrent — пиринговый протокол, в котором пиры объединяются с другими пирами для обмена фрагментами данных между собой. Каждый пир одновременно соединяется с несколькими пирами и таким образом скачивает или загружает данные одновременно нескольким пирам. Это здорово с точки зрения ограничения пропускной способности, если сравнивать, например, с загрузкой файла с одного сервера. Также это отлично походит, для того чтобы обеспечить доступность файла, так как место хранения распределено.
В технологии BitTorrent существует файл .torrent
, в котором указывается количество фрагментов для данного файла(ов), как пиры должны обмениваться этими фрагментами, а также то, как клиенты могут подтвердить целостность данных.
Далее мы посмотрим на реализацию клиента BitTorrent, и было бы неплохо знать неофициальную спецификацию BitTorrent или хотя бы иметь открытую вкладку с ней. Это, без сомнения, лучший источник информации о протоколе BitTorrent. Официальная спецификация расплывчата, и в ней не хватает определённых деталей, так что лучше использовать именно неофициальную.
Парсим торрент-файл
Первое, что клиент должен сделать, — выяснить, что и откуда он должен скачать. Этот тип информации (метаинформация) хранится в файле .torrent
. В метаинформации хранится ряд свойств, которые нам нужны для успешной реализации клиента:
- Имя файла для загрузки;
- Размер файла;
- URL трекера, к которому мы должны подключиться.
Все эти свойства хранятся в бинарном формате Bencode.
Bencode поддерживает четыре типа данных: словари, списки, целые числа и строки — поэтому его легко привести к объекту Python или в формат JSON.
Ниже Bencode описан в расширенной форме Бэкуса-Наура, предоставленной библиотекой Haskell:
В Pieces кодирование и декодирование bencode-данных реализовано в модуле pieces.bencoding
.
Вот пара примеров того, как этот модуль преобразует bencode-данные в объекты Python:
Можно сделать и наоборот — преобразовать Python-объект в bencode-форму:
Эти примеры также можно найти в репозитории проекта.
Реализация парсера довольно простая, здесь не используется asyncio
, и даже не считывается торрент-файл с диска.
Давайте откроем торрент-файл для популярного дистрибутива Linux Ubuntu с помощью парсера из pieces.bencoding
:
Здесь мы можем увидеть часть метаданных, например, имя целевого файла (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 пира, например:
Запрос к трекеру с помощью httpie может выглядеть следующим образом:
Данные ответа обрезаны, так как они всё равно содержат бинарные данные, которые никто не поймёт.
В ответе трекера нас особенно интересуют два свойства:
interval
— интервал в секундах до того, как клиент должен сделать новый запрос к трекеру;peers
— список пиров, представленный бинарной строкой, состоящей из частей по 6 байт. В каждой части 4 байта отвечают за IP-адрес пира и 2 — за номер порта (так как мы используем компактный формат).
Успешный запрос к трекеру даёт вам список пиров для подключения. Это не обязательно будет список вообще всех пиров, а только тех, которые трекер вам назначил. Следующий запрос к трекеру может вернуть другой список пиров.
Асинхронный HTTP
В Python нет встроенной поддержки асинхронного HTTP, даже любимая многими библиотека requests не реализует asyncio
. Поэтому оптимальным вариантом будет использование библиотеку aiohttp.
Pieces использует aiohttp
в классе pieces.tracker.Tracker
для HTTP-запросов к трекеру. Так выглядит укороченная версия этого кода:
Этот метод объявлен с использованием async
и использует асинхронный менеджер контекста async with
, с возможностью приостановки во время совершения HTTP-запроса. После успешного ответа этот метод будет снова приостановлен на время чтения бинарных данных ответа в await response.read()
. Наконец, данные ответа оборачиваются в экземпляр TrackerResponse
, содержащий список пиров или сообщение об ошибке.
В результате использования aiohttp
наш цикл событий может свободно планировать другую работу, пока у нас есть исходящий запрос к трекеру.
Если нужны подробности — загляните в модуль pieces.tracker
.
Цикл
До этого момента мы всё могли бы выполнять синхронно, однако теперь нам нужно подключиться к множеству пиров, что требует асинхронности.
Функция main в pieces.cli
отвечает за создание асинхронного цикла событий. Если опустить некоторые детали argparse
и обработки ошибок, это будет выглядеть как-то так (подробности в исходниках):
Сначала мы создаём цикл событий по умолчанию для этого потока. Затем мы создаём объект TorrentClient
с нужным Torrent
(метаинформация). Он пропарсит торрент-файл и убедится, что всё в порядке.
Затем мы вызываем async-метод client.start()
и оборачиваем его результат в asyncio.Future
, а потом добавляем эту future в цикл событий и просим его работать, пока эта задача не будет завершена.
Это всё? Не совсем — у нас есть цикл (не цикл событий), реализованный в pieces.client.TorrentClient
, который устанавливает соединения с пирами, планирует запросы и т.д.
TorrentClient
является чем-то вроде координатора действий. Он начинает свою работу с создания очереди async.Queue
, которая хранит список доступных для подключения пиров.
Затем он создаёт N объектов pieces.protocol.PeerConnection
, по одному на каждый пир в очереди. Эти объекты будут ожидать до тех пор, пока в очереди не появится незаблокированный пир.
Так как с самого начала очередь пуста, PeerConnection
ничего не будет делать до тех пор, пока мы не заполним очередь. Это происходит в цикле внутри TorrentClient.start
:
В общих чертах о том, что делает этот цикл:
- Проверяет, загрузили ли мы все фрагменты.
- Проверяет, не отменил ли пользователь загрузку.
- Делает запрос к трекеру, если необходимо.
- Добавляет все полученные пиры в очередь доступных пиров.
- Спит 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
:
Запрашиваем фрагменты
Как только клиент переходит в разблокированное состояние, он начнёт запрашивать фрагменты у пира. О том, какой фрагмент нужно скачивать, поговорим чуть позже.
Если мы знаем, что у другого пира есть нужный нам фрагмент, мы можем отправить удалённому пиру запрос с просьбой прислать данные, соответствующие этому фрагменту. Если пир согласится, то он отправит соответствующее сообщение, полезная нагрузка которого — просто кусок данных.
Каждому пиру клиент отправляет только один запрос, а затем терпеливо ожидает сообщение с фрагментом до предпринятия следующих действий. Поскольку клиент параллельно открывает подключения к нескольким пирам, в каждый момент у него будет несколько ожидающих запросов, по одному на подключение.
Если по какой-либо причине клиенту больше не нужен фрагмент, он может отправить сообщение Cancel
, чтобы отменить отправленный ранее запрос.
Другие сообщения
Have — сообщение, которое в любой момент может отправить нам удалённый пир. Это происходит после того, как удалённый пир получает новый фрагмент и хочет сделать его доступным для пиров, подключённых к нему.
Содержимым этого сообщения является индекс фрагмента.
Когда Pieces получает сообщение Have
, он обновляет информацию об имеющихся у пира фрагментах.
KeepAlive —сообщение, которое может быть отправлено в любой момент в любом направлении. Это сообщение не несёт дополнительных данных, а лишь указывает на необходимость поддерживать соединение.
Реализация
PeerConnection
асинхронно открывает TCP-соединение с удалённым пиром с помощью asyncio.open_connection
. Соединение возвращает кортеж из StreamReader
и StreamWriter
. Если соединение было успешно установлено, PeerConnection
отправит и получит сообщение Handshake
.
После обмена рукопожатиями PeerConnection
использует асинхронный итератор, чтобы вернуть поток PeerMessages
и предпринять соответствующее действие.
Использование асинхронного итератора отделяет PeerConnection
от подробностей того, как считывать данные с сокета и как парсить бинарный протокол BitTorrent. Вместо этого PeerConnection
может сфокусироваться на семантике вне зависимости от протокола — например, управлении состоянием пира, получении фрагментов, закрытии соединения.
Код в PeerConnection.start
выглядит примерно так:
Асинхронный итератор — это класс, реализующий методы __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
:
Глядя на бинарную строку, можно сказать, что длина сообщения Have
составляет 5 байт — \x00\x00\x00\x05
, id
равен 4 — \x04
, а полезная нагрузка содержит 33 — \x00\x00\x00!
.
Так как длина сообщения равна 5, а ID использует только один байт, у нас остаётся четыре байта на полезную нагрузку. С помощью struct.unpack
мы можем легко конвертировать их в целое число:
Вот так вне зависимости от протокола все сообщения следуют одной и той же процедуре, и итератор продолжает читать из сокета до обрыва соединения.
Разбираемся с фрагментами
До этого момента мы только обсуждали фрагменты — фрагменты данных, которыми обмениваются два пира. Оказывается, что кроме фрагментов есть ещё и блоки. Если вы уже успели пробежаться по исходному коду, то вы могли заметить в некоторых местах упоминание блоков, поэтому давайте разберёмся с тем, чем на самом деле являются фрагменты.
Фрагмент, как ни странно, является частью данных торрента. Данные торрента разбиваются на N фрагментов одинакового размера, за исключением последнего, который может быть меньшего размера. Длина фрагмента указывается в торрент-файле. Как правило, размер фрагментов составляет 512 килобайт или меньше, а также является степенью двойки.
Тем не менее, фрагменты всё ещё остаются слишком большими для эффективной передачи, поэтому их делят на блоки. Блоки представляют собой куски данных, которыми пиры на самом деле обмениваются. Фрагменты используются для индикации того, что у данного пира есть определённые данные. Если бы использовались только блоки, то это бы сильно увеличило размеры всего — BitField
стал бы длиннее, количество сообщений Have
увеличилось бы, а сам торрент-файл стал бы занимать больше места.
Размер блока составляет 214 (16 384) байт, кроме последнего, который с наибольшей долей вероятности будет меньшего размера.
Посмотрим на пример, в котором .torrent
описывает только один файл foo.txt
для загрузки:
Этот маленький торрент можно разделить на три фрагмента:
Теперь каждый фрагмент делится на блоки:
Суть BitTorrent состоит как раз в обмене такими блоками между пирами. Когда все блоки одного фрагмента загружены, этим фрагментом можно поделиться с другими пирами, и им отправляется сообщение Have
. Как только все фрагменты загружены, пир превращается из личера (загружающего) просто в сидера (раздающего).
Два замечания по поводу официальной спецификации:
- В официальной спецификации как фрагменты, так и блоки называются просто фрагментами, что может легко запутать. Неофициальная спецификация и другие используют понятие блока в качестве фрагмента поменьше, поэтому его используем и мы.
- В официальной спецификации размер блока отличается от того, который используем мы. Однако если посмотреть в неофициальную спецификацию, то можно увидеть, что принято использовать именно 214 байт, вне зависимости от того, что написано в официальной спецификации.
Реализация
После создания TorrentClient
создаётся и PieceManager
, в чьи обязанности входит:
- Определять, какой блок запросить следующим;
- Сохранять полученные блоки в файл;
- Определять момент завершения загрузки.
Когда PeerConnection
успешно обменяется рукопожатиями с другим пиром и получит сообщение BitField
, он сообщит PieceManager
о том, какие фрагменты есть у какого пира (peer_id
). Эта информация будет обновляться при получении каждого сообщения Have
. Благодаря этой информации у PeerManager
есть общая картина того, что где доступно.
Когда первый PeerConnection
перейдёт в состояние Unchoked
, он запросит следующий блок у пира. Следующий блок определяется вызовом метода PieceManager.next_request
.
Метод next_request
очень просто определяет, какой фрагмент запросить следующим:
- После создания
PieceManager
все фрагменты и блоки заранее формируются на основе информации о длине фрагментов, находящейся в торрент-файле. - Все фрагменты помещаются в список отсутствующих фрагментов.
- Во время вызова
next_request
менеджер сделает что-то из следующего:Запросит заново любой блок, время ожидания которого было превышено;Запросит следующий блок текущего фрагмента;Запросит первый блок следующего отсутствующего фрагмента.
Таким образом блоки и фрагменты будут запрашиваться по порядку.
Так как Pieces — простой клиент, в нём не реализовывались какие-либо специальные стратегии по выбору фрагментов. Лучшим решением было бы запрашивать сначала наиболее редкий фрагмент, что хорошо скажется на всех пирах.
Каждый раз при получении блока PieceManager
сохраняет его в памяти. Когда все блоки фрагмента получены, для него вычисляется SHA1-хеш. Этот хеш сравнивается с хешем, хранимым в торрент-файле, и если они совпадают, то фрагмент записывается на диск.
Когда все фрагменты получены, TorrentClient
останавливается, закрывает все открытые TCP-соединения, и программа завершает свою работу с сообщением о том, что торрент был загружен.
Что дальше?
Необходимо реализовать сидирование. Это будет выглядеть примерно так:
- Каждый раз при подключении к нам пира, мы должны отправить ему сообщение
BitField
, чтобы сообщить, какими фрагментами мы располагаем; - Каждый раз, когда мы получаем новый фрагмент, каждый объект
PeerConnection
должен отправить удалённому пиру сообщениеHave
, чтобы сообщить, что у нас появился новый фрагмент.
Для этого нужно сделать так, чтобы PieceManager
возвращал список с нулями и единицами, чтобы отобразить, какие фрагменты у нас есть. А TorrentClient
должен говорить PeerConnection
отправить удалённому пиру сообщение Have
.
Дополнительные возможности, которые, вероятно, можно добавить без особых усилий:
- Многофайловый торрент. Тут придётся пошаманить с
PieceManager
, так как фрагменты и блоки относятся к разным файлам, это повлияет на способ их сохранения; - Продолжение загрузки. Этого можно достичь проверкой уже скачанных файлов с помощью хешей.
37К открытий39К показов