goroutine&waitgroup下載檔案

slowquery發表於2022-09-13

0.1、索引

blog.waterflow.link/articles/16630...

當我們下載一個大檔案的時候,會因為下載時間太久而超時或者出錯。那麼我麼我們可以利用goroutine的特性併發分段的去請求下載資源。

1、Accept-Ranges

首先下載連結需要在響應中返回Accept-Ranges,並且它的值不為 “none”,那麼該伺服器支援範圍請求。比如我們可以利用HEAD請求來進行檢測

...

// head請求獲取url的header
    head, err := http.Head(url)
    if err != nil {
        return err
    }

  // 判斷url是否支援指定範圍請求及哪種型別的分段請求
    if head.Header.Get("Accept-Ranges") != "bytes" {
        return errors.New("not support range download")
    }

...

我們可以使用curl命令看下head頭

curl -I https://agritrop.cirad.fr/584726/1/Rapport.pdf
HTTP/1.1 200 OK
Date: Tue, 13 Sep 2022 13:52:08 GMT
Server: HTTPD
Strict-Transport-Security: max-age=63072000
X-Content-Type-Options: nosniff
X-Frame-Options: sameorigin
Content-MD5: K4j+rsagurPwGP/5cm8k8Q==
Last-Modified: Tue, 04 Jul 2017 08:26:16 GMT
Expires: Wed, 13 Sep 2023 13:52:08 GMT
Content-Disposition: inline; filename=Rapport.pdf
Accept-Ranges: bytes # 允許範圍請求,單位是位元組
Content-Length: 6659798 # 檔案的完整大小
Content-Type: application/pdf
X-XSS-Protection: 1; mode=block
X-Permitted-Cross-Domain-Policies: none
Cache-Control: public

其中,Accept-Ranges: bytes 表示界定範圍的單位是 bytes 。這裡 Content-Length也是有效資訊,因為它提供了檔案的完整大小。

2、Range

假如伺服器支援範圍請求的話,你可以使用 Range 首部來生成該類請求。該首部指示伺服器應該返回檔案的哪一或哪幾部分。

...
req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        fmt.Println("初始化request失敗:", err)
        return
    }

    rangeL := fmt.Sprintf("bytes=%d-%d", start, end)
    fmt.Println("字元範圍:", rangeL)
  // 獲取制定範圍的資料
    req.Header.Add("Range", rangeL)
    res, err := client.Do(req)
...

單一範圍

我們可以請求資源的某一部分。這次我們依然用 cURL 來進行測試。”-H” 選項可以在請求中追加一個首部行,在這個例子中,是用 Range 首部來請求圖片檔案的前 1024 個位元組。

curl https://agritrop.cirad.fr/584726/1/Rapport.pdf -i -H "Range: bytes=0-1023"
HTTP/1.1 206 Partial Content
Date: Tue, 13 Sep 2022 14:00:47 GMT
Server: HTTPD
Strict-Transport-Security: max-age=63072000
X-Content-Type-Options: nosniff
X-Frame-Options: sameorigin
Content-MD5: K4j+rsagurPwGP/5cm8k8Q==
Last-Modified: Tue, 04 Jul 2017 08:26:16 GMT
Expires: Wed, 13 Sep 2023 14:00:47 GMT
Content-Disposition: inline; filename=Rapport.pdf
Accept-Ranges: bytes
Content-Range: bytes 0-1023/6659798 # 返回指定的位元組
Content-Length: 1024
Content-Type: application/pdf
X-XSS-Protection: 1; mode=block
X-Permitted-Cross-Domain-Policies: none
Cache-Control: public

Content-Range表示請求的資源在整個資源中的位置,這個時候Content-Length就不是表示整個資源的大小,而是請求資源的大小。

多重範圍

我們也可以請求多個範圍,只需要在Range中指定多個即可

curl https://agritrop.cirad.fr/584726/1/Rapport.pdf -i -H "Range: bytes=0-50, 100-150"
HTTP/1.1 206 Partial Content
Date: Tue, 13 Sep 2022 14:04:53 GMT
Server: HTTPD
Strict-Transport-Security: max-age=63072000
X-Content-Type-Options: nosniff
X-Frame-Options: sameorigin
Content-MD5: K4j+rsagurPwGP/5cm8k8Q==
Last-Modified: Tue, 04 Jul 2017 08:26:16 GMT
Expires: Wed, 13 Sep 2023 14:04:53 GMT
Content-Disposition: inline; filename=Rapport.pdf
Accept-Ranges: bytes
Content-Length: 312
Content-Type: multipart/byteranges; boundary=4876db1cd4aa85af6
X-XSS-Protection: 1; mode=block
X-Permitted-Cross-Domain-Policies: none
Cache-Control: public


--4876db1cd4aa85af6
Content-type: application/pdf
Content-range: bytes 0-50/6659798

內容
--4876db1cd4aa85af6
Content-type: application/pdf
Content-range: bytes 100-150/6659798

內容
--4876db1cd4aa85af6--

伺服器返回 206 Partial Content 狀態碼和 Content-Type:multipart/byteranges; boundary=3d6b6a416f9b5 頭部,Content-Type:multipart/byteranges 表示這個響應有多個 byterange。每一部分 byterange 都有他自己的 Content-type 頭部和 Content-Range,並且使用 boundary 引數對 body 進行劃分。

3、goroutine

我們程式碼中透過獲取Contetn-Length總大小,和spPart分成了3部分,透過goroutine進行並行的單一範圍請求。然後把最終請求的結果儲存在臨時檔案。之後再把這3部分內容統一儲存到最終的檔案中

具體程式碼如下:

package main

import (
    "errors"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
)

// 透過Content-Length分成3部分併發執行
var spPart = 3

// 任務編排控制
var wg sync.WaitGroup

func main() {
    url := "https://agritrop.cirad.fr/584726/1/Rapport.pdf"

    err := DownloadFile(url, "rapport.pdf")
    if err != nil {
        panic(err)
    }
}

func DownloadFile(url string, filename string) error {
    if strings.TrimSpace(url) == "" {
        return nil
    }

  // head請求獲取url的header
    head, err := http.Head(url)
    if err != nil {
        return err
    }

  // 判斷url是否支援指定範圍請求及哪種型別的分段請求
    if head.Header.Get("Accept-Ranges") != "bytes" {
        return errors.New("not support range download")
    }

    contentLen, err := strconv.Atoi(head.Header.Get("Content-Length"))
    if err != nil {
        return err
    }

    offset := contentLen / spPart

    for i := 0; i < spPart; i++ {
        wg.Add(1)
        start := offset * i
        end := offset * (i + 1)
        name := fmt.Sprintf("part%d", i)

        go rangeDownload(url, name, start, end)
    }

    wg.Wait()

    out, err := os.Create(filename)
    if err != nil {
        return err
    }
    defer out.Close()

    for i := 0; i < spPart; i++ {
        name := fmt.Sprintf("part%d", i)
        file, err := ioutil.ReadFile(name)
        if err != nil {
            return err
        }
        out.WriteAt(file, int64(i*offset))

        if err := os.Remove(name); err != nil {
            return err
        }
    }

    return nil

}

func rangeDownload(url string, name string, start int, end int) {
    defer wg.Done()

    client := http.Client{}
    file, err := os.Create(name)
    if err != nil {
        fmt.Println("建立檔案失敗:", err)
        return
    }

    defer file.Close()

    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        fmt.Println("初始化request失敗:", err)
        return
    }

    rangeL := fmt.Sprintf("bytes=%d-%d", start, end)
    fmt.Println("字元範圍:", rangeL)
  // 獲取制定範圍的資料
    req.Header.Add("Range", rangeL)
    res, err := client.Do(req)

    if err != nil {
        fmt.Println("發起http請求失敗:", err)
        return
    }

    defer res.Body.Close()

    body, err := ioutil.ReadAll(res.Body)
    if err != nil {
        fmt.Println("讀取返回體失敗:", err)
        return
    }

    _, err = file.Write(body)
    if err != nil {
        fmt.Println("寫入檔案失敗:", err)
        return
    }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結