天天看點

tinyTorrent: 從頭寫一個 Deno 的 BitTorrent 下載下傳器

BitTorrent 想必大家應該都不陌生,中文名叫做“種子”。

“種子”到底是什麼?我一直不太清楚。在寫這個項目之前,我對“種子”的認識停留在使用層面。

當我想找一個資源的時候,我會搜尋

xxx 種子

,一般會在一些非常不知名的小網站裡面擷取到以

.torrent

結尾的種子檔案,然後使用迅雷或者 uTorrent 這樣的下載下傳器來下載下傳。

如果迅雷有一點速度,哪怕幾 kb,那麼大機率我充個會員就搞定了。這個軟體就是這麼的惡心,不用有時候又沒辦法,像極了人生。

其他下載下傳器比如 uTorrent 的話就一切随緣了,有些資源非常快,有些資源非常慢,有些一開始慢後來快。

這些問題是怎麼回事?有沒有改進的辦法?在讀 Jesse Li 的 Building a BitTorrent client from the ground up in Go 之前,我從沒想過。

BitTorrent(BT)

Jesse Li 的部落格圖文并茂,講述了如何用 Go 開發一個 BT 的下載下傳器。内容涉及到 BT 協定以及下載下傳器的代碼設計,思路清晰,值得一讀。

對于喜歡動手的朋友,可以先關掉這篇部落格,參考 Jesse 的代碼嘗試自己寫一個 BT 下載下傳器。寫完以後再回來,對比我用 Deno 開發的下載下傳器,相信會有不一樣的收獲。

我們先來看一下 BT 是什麼。

Tip

BT 一直在演進,新的功能有 DHT,磁力連結等,這裡我們關注早期版本的 BT。

BT 是一個協定,和 HTTP, FTP 一樣,是一個應用層的協定,這個協定被設計用來實作 P2P(Peer to Peer) 下載下傳。

P2P 我想大家都很了解,中文翻譯為點對點,不僅可以用來下載下傳,還可以用來金融😉。

傳統的下載下傳是用戶端請求伺服器擷取資源,下載下傳方和資源提供方的角色很清楚。這樣做的優點是簡單,易于了解,我要下載下傳東西,我就去請求伺服器,缺點也很明顯:

  • 一旦伺服器故障,大家都無法下載下傳
  • 伺服器帶寬有限,下載下傳的人多速度必然下降

而 P2P 則不一樣,每一個用戶端同時也是伺服器,從别人那裡下載下傳資源的同時,也提供資源給到别人。這樣一來,就規避了伺服器模型的缺點:

  • 每個人都是伺服器,除非所有機器都故障了,否則網絡依舊可以運轉
  • 不會去請求單一機器,帶寬得到最大利用

The BitTorrent Protocol Specification 是 BT 協定的官方文檔,裡面闡述了 BT 的核心概念和設計,但是漏了很多細節。推薦配合 Unofficial BitTorrent Specification 這個民間整理的版本一起看,會更有助于了解。

當學習一個新知識的時候,我喜歡用「提問法」來幫助自己梳理,帶着問題去找資料會更有方向。

現在我們知道 BT 是基于種子檔案的一種 P2P 下載下傳協定,那麼很自然地我們可以提出如下的問題:

  • 種子檔案是什麼格式?裡面存儲了哪些資訊?
  • 下載下傳器如何尋找 Peer?如何讓别的 Peer 找到自己?
  • 下載下傳時是以整個檔案為機關嗎?還是會分塊,如果分,怎麼分?
  • 怎樣從 Peer 那裡下載下傳檔案?怎樣提供檔案給到 Peer 下載下傳?
  • 使用種子下載下傳一個檔案的完整流程是怎樣的?

Torrent File

我們先來看第一個問題,種子檔案的格式和裡面存儲的資訊。

種子檔案使用了一種名為 Bencode 的編碼,這個編碼非常簡單,隻支援如下四種資料類型。因為存在 List 和 Dictionary,是以也有能力表達複雜的資料結構。

  • Byte String
  • Integer
  • List
  • Dictionary

deno-bencode 是我給 Deno 寫的一個 Bencode 編解碼庫,我們現在使用這個庫來看看種子檔案中到底有什麼。

我們用 Debian 的官方種子檔案來測試。

// decode.ts
import { decode } from "https://deno.land/x/[email protected]/mod.ts"

const file = Deno.args[0]
console.log(decode(Deno.readFileSync(file)))
           
$ wget https://cdimage.debian.org/debian-cd/current/amd64/bt-cd/debian-10.6.0-amd64-netinst.iso.torrent
$ deno run --allow-read decode.ts debian-10.6.0-amd64-netinst.iso.torrent
{
  announce: "http://bttracker.debian.org:6969/announce",
  comment: '"Debian CD from cdimage.debian.org"',
  "creation date": 1601120878,
  httpseeds: [
    "https://cdimage.debian.org/cdimage/release/10.6.0//srv/cdbuilder.debian.org/dst/deb-cd/weekly-builds...",
    "https://cdimage.debian.org/cdimage/archive/10.6.0//srv/cdbuilder.debian.org/dst/deb-cd/weekly-builds..."
  ],
  info: {
    length: 365953024,
    name: "debian-10.6.0-amd64-netinst.iso",
    "piece length": 262144,
    pieces: Uint8Array(27920) [
      144,  55, 173,  67, 115, 234, 169, 248, 222,  41, 139, 142, 125,
      100, 183, 130,  43, 148, 137, 130,   2, 194,  83, 109, 140, 147,
      123, 174, 234, 135,  58, 207, 217, 141, 107,  86, 245, 137,  79,
      150,  23,  33, 151, 157, 125, 159,  97,  10, 200, 137,  36, 158,
       74,  19,  97, 194, 171, 164,  32, 145, 175, 213,  91, 193, 120,
       26,  89, 109, 114,  61,  90, 166, 168, 137, 218, 154, 219, 119,
      107,  46, 240,  50, 134, 161, 162,  18, 224,  51, 210,  61,  41,
        6, 207, 124,  62, 199, 227, 134, 146, 206,
      ... 27820 more items
    ]
  }
}
           

首先,種子檔案是一個 Bencode 編碼的 Dictionary,裡面含有一些字段,比較重要的是這些:

  • announce

    : 這是一個 URL,作用後面再說
  • info

    : 這個又是一個 Dictionary,裡面含有檔案相關的資訊
    • length

      : 檔案的總長度,機關是位元組
    • name

      : 檔案名
    • piece length

      : Piece(分段) 的長度,機關是位元組
    • pieces

      : 一個數組,裡面對應了每個 Piece 的 SHA1 哈希值,用于校驗(SHA1 哈希值長度固定為 20 個位元組)

從檔案裡面的資訊來看,我們可以得知,種子是分為 Piece 的,每個 Piece 的長度在檔案中已經确定,同時,種子檔案也會提供每個 Piece 的 SHA1 哈希值用于校驗 Piece 的有效性。

我們來核對一下資料。

  • 檔案長度是 365953024 個位元組,也就是 349MB,
  • 每個 Piece 的長度為 262144 個位元組,也就是 256KB。
  • 那麼一共是

    365953024 / 262144 = 1396

    個 Piece(注意,這裡不一定整除,也就是說,最後一個 Piece 它的長度可能不等于 piece length)
  • 每個 Piece 的 SHA1 哈希是 20 個位元組,是以總的是

    1396 * 20 = 27920

    個位元組

所有資料都對上了🎉

大家可以發現,上面的種子隻包含一個檔案,很多時候,我們打開種子時,裡面會有多個檔案,下載下傳器會讓我們選擇哪些檔案需要被下載下傳。

這裡就涉及到另外一個問題,單檔案種子 和 多檔案種子,它們存儲的資訊略有不同,我們用一個例子來看。

我随便找了一個 Taylor Swift 的專輯 Red 的種子,打開看看。

$ deno run --allow-read decode.ts red.torrent
{
  announce: "http://tracker.nwps.ws:6969/announce",
  "announce-list": [
    [ "http://tracker.nwps.ws:6969/announce" ],
    [ "http://tracker.winglai.com/announce" ],
    [ "http://fr33dom.h33t.com:3310/announce" ],
    [ "http://exodus.desync.com:6969/announce" ],
    [ "http://torrent.gresille.org/announce" ],
    [ "http://tracker.trackerfix.com/announce" ],
    [ "udp://tracker.btzoo.eu:80/announce" ],
    [ "http://tracker.windsormetalbattery.com/announce" ],
    [ "udp://10.rarbg.me:80/announce" ],
    [ "udp://ipv4.tracker.harry.lu:80/announce" ],
    [ "udp://tracker.ilibr.org:6969/announce" ],
    [ "udp://tracker.zond.org:80/announce" ],
    [ "http://torrent-tracker.ru/announce.php" ],
    [ "http://bigfoot1942.sektori.org:6969/announce" ],
    [ "http://tracker.best-torrents.net:6969/announce" ],
    [ "http://announce.torrentsmd.com:6969/announce" ],
    [ "udp://tracker.token.ro:80/announce" ],
    [ "udp://open.demonii.com:80" ],
    [ "udp://tracker.coppersurfer.tk:80" ],
    [ "http://tracker.thepiratebay.org/announce" ],
    [ "udp://9.rarbg.com:2710/announce" ],
    [ "udp://open.demonii.com:1337/announce" ],
    [ "udp://tracker.ccc.de:80/announce" ],
    [ "udp://tracker.istole.it:80/announce" ],
    [ "udp://tracker.publicbt.com:80/announce" ],
    [ "udp://tracker.openbittorrent.com:80/announce" ],
    [ "udp://tracker.istole.it:80/announce" ],
    [ "http://tracker.istole.it/announce" ],
    [ "udp://tracker.publicbt.com:80/announce" ],
    [ "http://tracker.publicbt.com/announce" ],
    [ "udp://open.demonii.com:1337/announce" ],
    [ "udp://11.rarbg.me:80/announce" ],
    [ "udp://10.rarbg.me:80/announce" ],
    [ "udp://9.rarbg.com:2710/announce" ],
    [ "udp://tracker.token.ro:80/announce" ],
    [ "udp://12.rarbg.me:80/announce" ],
    [ "http://tracker.trackerfix.com/announce" ]
  ],
  comment: "Torrent downloaded from torrent cache at http://itorrents.org",
  "created by": "uTorrent/3210",
  "creation date": 1351095350,
  encoding: "UTF-8",
  info: {
    files: [
      { length: 13236894, path: [Array] },
      { length: 12992666, path: [Array] },
      { length: 12031154, path: [Array] },
      { length: 11899411, path: [Array] },
      { length: 11535936, path: [Array] },
      { length: 11465792, path: [Array] },
      { length: 9888023, path: [Array] },
      { length: 9853495, path: [Array] },
      { length: 9781419, path: [Array] },
      { length: 9684472, path: [Array] },
      { length: 9681093, path: [Array] },
      { length: 9574507, path: [Array] },
      { length: 9355103, path: [Array] },
      { length: 9154619, path: [Array] },
      { length: 9028224, path: [Array] },
      { length: 8994573, path: [Array] },
      { length: 8903823, path: [Array] },
      { length: 8895321, path: [Array] },
      { length: 8859865, path: [Array] },
      { length: 8304962, path: [Array] },
      { length: 8188974, path: [Array] },
      { length: 7797281, path: [Array] },
      { length: 7357902, path: [Array] }
    ],
    name: "Taylor Swift - Red (Deluxe Version)",
    "piece length": 16384,
    pieces: Uint8Array(276460) [
      107,  33, 238, 211, 243,  14, 230, 146,  23,  98, 147, 188, 251, 168,
      170, 253, 105,  99,  55, 208, 230,  60,  87, 198,  22, 246, 245, 186,
      141, 162,  52, 196, 196, 128,  98, 236, 121,  55, 150, 208,  40, 194,
       18,  57, 112, 165, 245,  17,  18,  51,   4,  44, 243, 254,  34, 207,
       12, 106, 201, 132,  96, 207,  61, 144, 118, 130, 211,  91,   7, 141,
       71,  36, 129, 132,  70, 115,  27, 133,  80, 240, 140, 121, 239,  28,
      240,  58, 212,  35,  20, 208,  94, 203, 176, 178, 126,  90,  37, 255,
      245,  17,
      ... 276360 more items
    ]
  }
}
           

可以發現,最大的差別在于

info

裡面多了一個字段叫做

files

。預設的

console.log

沒有列印出

path

内容,我們改一下代碼,單獨列印

files

// decode2.ts
import { decode } from "https://deno.land/x/[email protected]/mod.ts"

const file = Deno.args[0]
const result = decode(Deno.readFileSync(file)) as any
console.log(result.info.files)
           
$  deno run --allow-read decode2.ts red.torrent
[
  { length: 13236894, path: [ "Taylor Swift - All Too Well.mp3" ] },
  {
    length: 12992666,
    path: [ "Taylor Swift - State of Grace (Acoustic Version).mp3" ]
  },
  {
    length: 12031154,
    path: [ "Taylor Swift Feat Gary Lightbody - The Last Time.mp3" ]
  },
  { length: 11899411, path: [ "Taylor Swift - State of Grace.mp3" ] },
  { length: 11535936, path: [ "Taylor Swift - The Moment I Knew.mp3" ] },
  { length: 11465792, path: [ "Taylor Swift - Sad Beautiful Tragic.mp3" ] },
  {
    length: 9888023,
    path: [ "Taylor Swift Feat Ed Sheeran - Everything Has Changed.mp3" ]
  },
  { length: 9853495, path: [ "Taylor Swift - I Almost Do.mp3" ] },
  { length: 9781419, path: [ "Taylor Swift - Treacherous.mp3" ] },
  { length: 9684472, path: [ "Taylor Swift - Treacherous (Demo).mp3" ] },
  { length: 9681093, path: [ "Taylor Swift - The Lucky One.mp3" ] },
  { length: 9574507, path: [ "Taylor Swift - Begin Again.mp3" ] },
  { length: 9355103, path: [ "Taylor Swift - 22.mp3" ] },
  { length: 9154619, path: [ "Taylor Swift - Red (Demo).mp3" ] },
  { length: 9028224, path: [ "Taylor Swift - Come Back... Be Here.mp3" ] },
  { length: 8994573, path: [ "Taylor Swift - Red.mp3" ] },
  { length: 8903823, path: [ "Taylor Swift - Girl At Home.mp3" ] },
  { length: 8895321, path: [ "Taylor Swift - Starlight.mp3" ] },
  { length: 8859865, path: [ "Taylor Swift - I Knew You Were Trouble..mp3" ] },
  { length: 8304962, path: [ "Taylor Swift - Stay Stay Stay.mp3" ] },
  { length: 8188974, path: [ "Taylor Swift - Holy Ground.mp3" ] },
  {
    length: 7797281,
    path: [ "Taylor Swift - We Are Never Ever Getting Back Together.mp3" ]
  },
  { length: 7357902, path: [ "Digital Booklet - Red (Deluxe).pdf" ] }
]
           

現在就很清楚了,對多檔案種子來說,

files

裡面存儲了每個檔案的長度,以及每個檔案的路徑。

Tracker

現在我們來看第二個問題,如何找到 Peer 以及如何讓 Peer 找到我們?

這裡的關鍵就是種子檔案中存儲的

announce

字段,這個字段是一個 URL,這個 URL 指向了一個 Tracker 伺服器。

Tracker 伺服器顧名思義,是一個追蹤者,或者說是中介。它本身不提供任何下載下傳服務,它的作用是用來溝通 Peers。

每個 Peer 通過 PeerID 來辨別自己,這是一個 20 位元組的資料,格式沒有要求。

我們可以通過請求 Tracker 擷取到目前資源有哪些 Peer,同時,我們可以向 Tracker 注冊自己成為一個 Peer。

Tracker 使用 HTTP 協定,請求時通過 Query 攜帶參數,下面是三個關鍵參數:

  • info_hash

    : 這個用來表明我們請求的資源是什麼,在 BT 下載下傳中,對資源的唯一辨別使用的是 InfoHash,也就是種子檔案中的

    info

    字段的内容進行 SHA1 哈希以後得到的結果,20 個位元組
  • peer_id

    : 我們自己生成的辨別身份的一個 ID,20 個位元組
  • port

    : 我們用戶端的監聽端口,用于接收其他 Peer 發來的消息

Tracker 傳回的資訊使用 Bencode 編碼,裡面含有兩個資料,

interval

peers

{
  interval: 900,
  peers: Uint8Array(300) [
    171,  33, 254,  92, 200, 213,  75,  85, 105, 120,  26, 225,  87, 122,
    122, 178, 217,   4,  89, 160, 104,  18, 200, 213, 105, 233,  64,  91,
    200, 213, 112,   3, 198, 231, 200, 213, 177, 136, 104,   4, 200, 213,
     84,   3, 130,  32, 234,  96, 206, 144,  63, 149, 200, 213,  51,  15,
    200,  26, 194, 246,  95,  78, 126, 134, 200, 213, 100,  38,  32, 104,
    200, 213, 123, 113,  10, 254, 200, 213, 148, 251, 183,  98,  26, 225,
    186, 179, 163,  68,  26, 225,  38,  88, 192,  43,  26, 225,  90, 189,
    212, 240,
    ... 200 more items
  ]
}
           

peers

是一個 Byte Array,每 6 個位元組代表一個 Peer,前 4 個位元組為 IP 位址,後 2 個位元組為 BigEndian 的端口号。

以上面的輸出為例,我們可以得知,第一個 Peer 的位址是

171.33.254.92:51413

Tip

當然,後來 BT 擴充了一個

peers6

字段用來傳回 IPv6 的位址。

Download Process

最後我們來梳理一下使用 BT 下載下傳的完整流程:

  1. 解析種子檔案
  2. 請求 Tracker,擷取 Peers 清單
  3. 請求 Peers,下載下傳 Piece,根據

    pieces

    字段校驗 Piece 的有效性
  4. 組裝 Piece,得到完整的檔案

從種子檔案中,我們可以知道,資源被劃分為 Piece,每個 Piece 的長度在種子檔案中已經确定。

這裡我們說的資源可以是一個檔案(單檔案種子),也可以是多個檔案(多檔案種子),在 BT 下載下傳的時候,其實不區分這兩種情況。不管是單檔案還是多檔案,都是下載下傳一定數量的 Piece。在多檔案的情況下,得到總資料以後,再根據

files

字段中标明的長度和路徑來進行切割。

怎樣請求 Peers 下載下傳 Piece?這裡就是 BT 協定的重點部分。

當我們 TCP 連接配接 Peer 的時候,第一步是握手。

發送如下資料給到對方進行握手:

  • 1 位元組的協定長度 ProtocolLength,填寫固定值

    0x13

  • 19 個位元組的協定名 ProtocolName,填寫固定值

    BitTorrent protocol

  • 8 位元組的保留字段 Reserved,都填寫為 0
  • 20 個位元組的 InfoHash,從種子檔案中計算得到
  • 20 個位元組的 PeerID,我們自己生成

如果對方是一個正常的 BT Peer 的話,我們會收到同樣結構的響應,從中提取出 InfoHash,如果和我們發送的 InfoHash 一樣,那麼就握手成功了~

握手成功以後,接下來便是互發消息。BT 是基于 TCP 的一個上層協定,和任何一個自定義協定一樣,BT 定義了自己的消息格式 BTMessage,Peer 之間通過 BTMessage 來交換資訊。

一個 BTMessage 由三部分構成:

  • 4 位元組的 Length,BigEndian
  • 1 位元組的 ID,表明消息的類型
  • X 位元組的消息體 Payload,含有具體的資料,X 為 Length - 1

重要的消息類型有如下幾種:

  • Choke

    : 告訴對方不能請求任何資料,先等待
  • Unchoke

    : 告訴對方可以開始請求資料了
  • Have

    : 告訴對方我有某個 Piece
  • Bitfield

    : 将我有的所有 Piece 編碼成 Bitfield 發送給對方
  • Request

    : 向對方請求下載下傳某個 Piece
  • Piece

    : 發送 Piece 資料給到對方

當我們連接配接 Peer 時,預設處于 Choked 狀态,也就是不允許向 Peer 請求任何資料,必須先等待 Peer 發送

Unchoke

消息。

這裡還有一個細節,當我們使用

Request

下載下傳時,并不是一次請求一個完整的 Piece,而是分為 Block 下載下傳,Block 的大小可以在消息體中指定,一般為 16K。

是以,從 Peer 下載下傳資料的流程是

  • 握手
  • 接收 Peer 發送的 Bitfield 資訊,獲知 Peer 有哪些 Piece
  • 等待 Peer 發送 Unchoke 資訊
  • 下載下傳 Piece1
    • 發送

      Request

      消息給 Peer,請求 Piece1 的 Block1
    • 收到

      Piece

      消息,得到 Block1 資料
    • 請求 Piece1 的 Block2
    • 收到 Block2 資料
    • Piece1 的所有 Block 下載下傳完畢,校驗 SHA1 哈希值
    • 下載下傳 Piece2

每一個消息類型的具體消息體這樣就不再展開了,這些細節對于了解 BT 不重要,在編碼時對照 Spec 來做就好。

Implementaion

根據上面的知識,我們可以來開發 BT 用戶端了。

很顯然,這是一個 IO Bound 的程式,核心是請求 Peer 擷取資料,配合狀态管理維護 Piece 和 Block 的狀态。

這樣一個問題給到我,我的第一想法自然是 Go,網絡 + 并發這都是 Go 的強項,再加上強大的标準庫和生态,寫一個 CLI 的 BT 下載下傳器自然是手到擒來。

但是,考慮到 Jesse 的程式就是用 Go 寫的,我也用 Go 的話難免不從他那兒“借鑒”,不如換個語言來寫,可以加深自己對 BT 的了解和認識。

Why Deno?

我最熟悉的語言除了 Go 就是 JS,Deno 是我關注很久的項目,但一直沒有在上面去寫點什麼,不如借着這個機會去試試看 Deno+TypeScript 感覺到底如何。

Deno 是 Node 的作者 Ryan Dahl 的最新作品,目标是提供一個安全、現代化的 JS+TS 運作時,修複 Node 的一些問題。

Tip

Ryan Dahl 曾做過一次分享:10 Things I Regret About

Node.js,很有意思。

相比于 Node,我喜歡 Deno 的有如下幾點:

  • 基于 URL 的包管理,終于沒有

    node_modules

    了,感謝上帝 🙏
  • 原生支援 TypeScript
  • 強大的标準庫
  • 沙箱和權限控制

Tip

Deno 的名稱實際上是把

Node

no

de

重新排列而得到的。

類型系統的重要性我想是不言而喻的,對于中小型項目來說,TypeScrit 的價值或許并不明顯,但是對于大型項目來說,TypeScript 是必不可少的。

TypeScript 所代表的類型系統有很多好處:

  • 更精确的表達:完備的類型系統可以更加精确地抽象和表達我們要描述的問題
  • 盡早發現 bug:在編譯時而不是在運作時
  • 放心大膽的重構:我想前端人員對于重構 JS 代碼應該都是很忐忑的,沒有什麼辦法能保證所有涉及到的地方都已經被修改
  • 更快的性能:編譯器/虛拟機可以根據類型資訊生成更高效的代碼
  • 最好的文檔:注釋總會過時,類型不會

這裡我想引用 Rob Pike 的一句話:

Data dominates. If you’ve chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming. - Rob Pike

對于日常的開發任務來說,資料結構是核心,一切都圍繞着資料結構來進行。在 TypeScript 下,我們對每一個變量的資料結構都有清楚的了解,這一點至關重要。

Test Torrent

确定了語言以後,在正式開發前,我們先來思考一下怎麼測試我們的程式。

一開始我的想法是使用一些官方的種子,比如 Debian 的,但是它的問題是裡面包含的檔案很大,300+ MB,因為伺服器在國外,下載下傳速度很慢,用來測試顯然是很不理想的。

是以,一個很自然的想法是,能不能找到小一點的官方的種子?

想來想去,除了 Linux 鏡像以外,我實在想不到還有什麼東西會提供官方種子。而 Linux 鏡像中,體積比較小的比如 CoreOS 或者是 Alpine,都沒有提供種子下載下傳的選項,本來體積就小,也确實不需要提供種子😂。

既然找不到官方的,那試試在國外随便找一個?海盜灣上種子倒是一堆,但是我找了半天,都沒有合适的,體積小的倒是有,但是速度都很不理想。

換個思路,找找看國内有沒有什麼比較權威的種子站?答案是沒有😂 也有可能是我孤陋寡聞了,不過我在國内搜尋“種子站”,出來的站點實在是…你懂的…

這個環節花了我很多時間,但是這個又是必須要做的事情。沒有測試種子,我根本沒辦法驗證最後的程式是正确的。

突然,我有了一個機智的想法!

我為什麼不自己建立一個種子檔案呢?自己給自己做種,一切可控,随時可以測試😉。

如果要讓 BT 下載下傳在自己的本機上玩起來,我們需要兩個東西:

  • 一個 Tracker 伺服器
  • 一個 BT 用戶端

先來安裝 Tracker 伺服器,很容易就可以找到一個開源實作 bittorrent-tracker。

$ yarn add bittorrent-tracker
$ ./node_modules/.bin/bittorrent-tracker --http
           

現在我們擁有了一個運作在本機 8000 端口的 Tracker 伺服器,Tracker URL 是

http://localhost:8000/announce

接下來我們需要個 BT 用戶端來給我們做種。

我第一個想法是 uTorrent,但是很可惜,它在 Mac 10.15 上運作不了。

接下來嘗試 qBittorrent,可以在 Mac 10.15 上運作。打開以後,選擇建立種子,随便選擇一個檔案,然後 Tracker 填寫上面的位址。

用 Jesse 的用戶端試着下載下傳一下這個種子,我們會發現,失敗了…

Jesse 的用戶端是可以下載下傳 Debian 的種子的,是以用戶端實作沒有問題,隻能是 qBittorrent 出了問題。

再換個思路,考慮到我是高貴的 Setapp 會員,那麼試着找找有沒有商業的 BT 用戶端,付錢的多少要靠譜一點。

然後我就找到了 Folx,果然,收費的軟體看起來都特别精緻,UI 和體驗都舒服極了,完全不是粗糙的 qBittorrent 能比。

Tip

當然,開源也有很多精緻的軟體,比如 iina。

打開 Folx,選擇

File -> Create Torrent File

,随便選擇一個檔案,填入 Tracker 位址,我們就得到了一個種子。

tinyTorrent: 從頭寫一個 Deno 的 BitTorrent 下載下傳器

至此,我們終于解決了測試種子的問題🎉 可以開始着手編寫代碼了~

Afterthought

tinyTorrent 是我最後完成的 BT 下載下傳器。

說它是下載下傳器,因為它并不是一個完整的用戶端,用 BT 的術語來說,這是一個 Leech。這個下載下傳器隻從 Peer 下載下傳内容,但是從不上傳内容給到 Peer。

理清 BT 協定以後,具體的開發其實很快,當然部分是因為很多細節并沒有被很好處理,畢竟這個項目主要的作用還是用來學習和了解 BT 協定。

The Good

先來談談開發過程中讓人覺得愉快的地方。

  • VSCode 确實很香,除了偶爾的卡頓,編寫 TypeScript 體驗非常好。
  • Deno 的标準庫對于熟悉 Go 的人來說很好用。

畢竟基本上是照着 Go 設計的,常見的功能标準庫中都有,接口也很清晰。

像單元測試、日志、哈希函數這些事情,都不需要依賴三方了。

The Bad

再來談談開發過程中一些槽點。

  • 首先,Deno 的生态比之 Node 目前來說是天壤之别。

第一步解析種子需要用的 Bencode 編碼,Node 自然是有庫的 node-bencode,Deno 自然是沒有的。

解析 Bencode 需要操作二進制資料,而相關接口 Deno 和 Node 中不一樣,是以這個庫也沒辦法直接拷貝過來用。

Tip

Node 的庫隻要沒有用到 Node 特有的 Module,都可以直接拷貝過來用。

是以,我首先需要花一點時間來遷移這個庫到 Deno,說是遷移,其實是重寫,了解代碼以後,順手就重寫了😉。

deno-bencode 是最後完成的 Deno 中的 Bencode 編解碼庫,功能不多,但是足夠用了。

這裡順手提一下,Node 的生态确實很大,但是品質并不盡如人意。

解碼 Bencode 的時候需要判斷一下 ByteArray 是不是有效的 UTF8 字元串,搜尋了一下 NPM,第一個結果就是 is-utf8 這個包。

這是一個周下載下傳量達到 800 多萬次的包,按理來說應該不會有任何問題,可以放心使用。

但是當我簡單看了一下代碼之後就發現,這個包的實作根本就是錯誤的。

UTF8 是相容 ASCII 的,但是在這個包的 代碼 中卻認為 ASCII 的控制字元不是 UTF8,也就是

isUTF8("\x00") === false

下載下傳量如此大的包,竟然有如此明顯的錯誤,實在是讓人無話可說。

  • 其次,雖然 Deno/Node 擁有事件驅動的異步模型,但是要編寫高并發的 IO 程式依舊很痛苦。

不是能力上達不到,而是寫法上很别扭,或者說思維模型上很别扭。

思考這樣一個問題:我們有 100 個 Piece 要下載下傳,有 10 個 Peer 可以連接配接。那麼怎樣并發地去請求這 10 個 Peer?怎樣配置設定這 100 個 Piece?當某個 Peer 無法連接配接時,怎樣将 Piece 轉交給别的 Peer?下載下傳好的結果怎樣彙總?出現了錯誤怎樣上報?怎樣等待下載下傳全部完成?

如果是 Go,這個問題解決起來很簡單,Goroutine 和 Channel 簡直就是為這個問題而生。

但是在 Deno/Node 中,這就變得複雜了,不是一個

Promise.all

能搞定的問題。

  • 最後,Deno 還有一些地方不太成熟。

感受最深的是,涉及到網絡的接口比如

Deno.conect

居然沒有逾時選項,這是認真的嗎?

當然,我們可以自己想辦法包裝,但是這些事情接口層應該要強調,逾時在網絡開發中是十分重要的配置,新手很容易忽略。

其次,文檔還有欠缺。比如說當我用了 ky 這個庫以後,Deno 會報一些類型沖突的錯誤,但是我找遍了文檔,也不知道該怎麼去限制 Deno 加載的類型庫。

最後隻能去問官方,Issue 在這裡。幸運的是,官方的響應速度非常快,點贊👍(Elixir 也是一樣,José Valim 的響應速度簡直讓人感動)

The Future

因為是一個學習項目,不嚴謹的地方太多了,未來有時間,我想可以在這些方面進行改進:

  • 首先也是最重要的,支援上傳,讓它成為一個完整的 BT 用戶端
  • 支援多檔案種子
  • 支援磁力連結
  • 支援代理😉
  • 更加完善的錯誤處理和重試機制
  • 更加完善的日志系統

BT 下載下傳器在我看來是一個非常适合學習新語言以後的練手項目,涉及到的知識面比較廣,需要解析檔案、處理二進制資料、網絡通信、并發控制等。同時,最終的作品也有實用價值,使用自己的下載下傳器下載下傳資源,想想都激動😉。

祝大家 Happy Coding~🎉