HTTP 斷點下載功能-Python實現

背景

最近在家要下載一個比較大的鏡像文件,由於網絡太差,每次都是下載到中間就停了,文件就下載失敗。我用的是 chrome 自帶的下載功能,就這樣重試了4-5 次,每次都以失敗了結。可恨的是,有時候下載到 80% - 90%,失敗了還要從頭重來。chrome

後來我就想到 wget 命令行,上網搜了一下,發現 wget 是自帶斷點下載功能的。也就是說,若是中間鏈接斷開,能夠直接從已經下載好的地方繼續開始。而後,直接使用 wget 搞定了任務。服務器

若是以前已經有了一個下載部分的文件,也可使用 wget 的 -c 命令,來繼續下載。 能夠參考 nixCraft 上一篇文章 Wget: Resume Broken Downloadmarkdown

琢磨

按說完事以後,我也就該放心啦。文件也下載完了,你還有啥可惦記的呢?但是我內心一直有個疙瘩:HTTP 協議不是無狀態的嗎?重發的請求,是怎麼告訴服務器端,我只要某一段數據的呢?應用程序有事怎麼把不一樣的數據接起來的呢?網絡

而後,我就繼續搜索,發現 HTTP 斷點續傳祕密所在:Range 頭部字段。工具

解釋

斷點下載,通俗一點說,就是客戶端告訴服務器端:我已經下載了前面長度爲 n 的內容, 請從 n+1 個數據給我傳過來,不用從 0 開始從新傳。url

HTTP 1.1 版本中,有個對應的實體頭部作這件事情,那就是 RangeRange 指定要請求實體(entity)的範圍,和多數的程序規範同樣,它也是從 0 開始計數的。好比前面已經下載了 500 bytes 的內容,要請求 500 bytes 之後的內容,只要在 HTTP 請求的頭部加上 Range: bytes=500- 就能夠啦。spa

Range 有幾種不一樣的方式來限定範圍:命令行

  • 500-900:指定開始到結束這一段的長度,記住 Range 是從 0 計數 的,因此這個是要求服務器從 501 字節開始傳,一直到 901 字節結束。這個通常用來特別大的文件分片傳輸,好比視頻。
  • 500-:從 501 bytes 以後開始傳,一直傳到最後。這個就比較適合用於斷點續傳,客戶端已經下載了 500 字節的內容,麻煩把後面的傳過來
  • -500:這個須要注意啦,若是範圍沒有指定開始位置,就是要服務器端傳遞倒數 500 字節的內容。而不是從 0 開始的 500 字節。。
  • 500-900, 1000-2000:也能夠同時指定多個範圍的內容,這種狀況不是很常見

客戶端發出這樣的請求,也要服務器端響應和支持這個功能。前面也提到過,這個實體頭部是 HTTP 1.1 版本才添加的,因此有些 HTTP 服務端使用比較老的版本可能不支持。code

若是服務端支持的話,那麼這個響應裏也要帶一個 content-range 的字段。好比客戶端使用的是 Range: bytes=1024-,那麼對應的服務端返回頭部會包括相似 content-range: bytes 1024-439714/439715 的內容。這個意思就是說,我知道啦,我會從只傳遞 1024 - 439714 範圍的內容,而整個文件的大小是 439715 字節。並且,這個時候響應的 status code 必定是 206,關於 206 的解釋能夠查看 w3 官網上的說明。 。 若是真是這樣也就皆大歡喜啦,可是可能出現兩種異常狀況:orm

不支持

服務端返回的響應頭部沒有 content-range 字段,代表服務端忽略了請求裏 range 字段。多是由於服務端不支持這個功能,那麼也沒辦法,只能從頭開始下載。 注:目前大多數 HTTP 都支持這個功能,因此這個狀況也不多見

支持是支持,但仍是給我從頭傳

服務端返回的響應頭部包含了 content-range,但仍是從 0 開始從新下載的。這個就是服務端實現比較嚴格,考慮到了這個文件可能會被修改的狀況。

什麼意思?考慮一下這個狀況:昨天我下載某個文件,下了一半,今天使用 wget -c 接着下。但問題是:中間某個時間點,這個文件被更新了!URL 沒變,可是文件內容變啦。若是我接着日後下,而後把以前的內容和新下的內容和在一塊兒,極可能就不是一個正常的文件啦,沒有辦法打開。這個狀況比從頭開始下載還要糟糕:辛辛苦苦下載完,結果是不能用的。

若是服務端要下載的文件,或者其餘資源是可變的。就要有一個辦法來標識文件或者資源的惟一性,就是說我以前下載的究竟是哪一個版本的,和如今版本是否是一致的。HTTP 協議裏有兩個辦法能夠來作這件事,固然也就是兩個頭部字段:ETagLast-Modified,都定義在 RFC2616。

  • ETag: 你能夠把 ETag 比做文件的 MD5,用來惟一標識一個文件,它是一串字符
  • Last-Modified:望名思意,就是這個文件上次修改的時間

若是服務端比較嚴格,會檢查你此次要下載的文件和上次下載的有沒有變化,那麼在第一次下載的時候,它必定會提供 ETag 或者 Last-Modified 兩個字段中的至少一個。那麼斷點下載的請求,只要把任意一個放到 If-Range 字段裏傳過去就能夠啦(If-Range 只能和 Range 一塊兒使用,不然會被服務端忽略)。

若是兩次下載的是同一個文件,就會返回 206,從後開始續傳;不然就會返回 200,表示文件變了,要重頭開始下載。

若是客戶端發送的 range 範圍有錯誤,會返回 416,而且 Content-Range 字段形如 bytes */439715,表示提供的範圍有誤,文件總大小是 439715

實現

知道上面的原理,實現一個本身的斷點下載程序其實很簡單。

這段代碼實現的是 wget -c 的功能,能夠實現的效果是:

  • 第一次下載文件會建立新的文件並開始下載
  • 第二次下載,若是發現已經有文件存在,就會發送 Range 請求,從已經下載的從新開始
  • 若是服務器端支持 Content-Range,就接着下載
  • 若是服務器端不支持,或者又重頭髮送(咱們沒有發送 If-Range 頭部),就刪除原來的文件,從新開始

若是要使用 If-Range 就要把第一次請求的數據存起來,爲了簡單起見,咱們不會實現這個功能。

import os
import sys

import requests


def file_size(filename):
    return os.stat(filename).st_size


def download(url, chunk_size=65535):
    downloaded = 0  # How many data already downloaded.
    filename = url.split('/')[-1]  # Use the last part of url as filename

    if os.path.isfile(filename):
        downloaded = file_size(filename)
        print("File already exists. Send resume request after {} bytes".format(
            downloaded))

    # Update request header to add `Range`
    headers = {}
    if downloaded:
        headers['Range'] = 'bytes={}-'.format(downloaded)

    res = requests.get(url, headers=headers, stream=True, timeout=15)

    mode = 'w+'
    content_len = int(res.headers.get('content-length'))
    print("{} bytes to download.".format(content_len))
    # Check if server supports range feature, and works as expected.
    if res.status_code == 206:
        # Content range is in format `bytes 327675-43968289/43968290`, check
        # if it starts from where we requested.
        content_range = res.headers.get('content-range')
        # If file is already downloaded, it will reutrn `bytes */43968290`.

        if content_range and \
                int(content_range.split(' ')[-1].split('-')[0]) == downloaded:
            mode = 'a+'
    if res.status_code == 416:
            print("File download already complete.")
            return

    with open(filename, mode) as fd:
        for chunk in res.iter_content(chunk_size):
            fd.write(chunk)
            downloaded += len(chunk)
            print("{} bytes downloaded.".format(downloaded))

    print("Download complete.")


if __name__ == '__main__':
    url = 'http://dldir1.qq.com/qqfile/QQforMac/QQ_V4.0.4.dmg'
    url = sys.argv[1] if len(sys.argv) == 2 else url
    download(url)
複製代碼

咱們使用了 requests 庫來實現 HTTP 請求,代碼實現起來也比較簡單,須要說明的地方都已經添加了註釋。

稍微修改一下,就能封裝成一個還不錯的下載工具。

相關文章
相關標籤/搜索