【做中學】第一個 Go 語言程式:漫畫下載器

schaepher發表於2020-04-19

原文地址:

第一個 Go 語言程式:漫畫下載器:
https://schaepher.github.io/2020/04/11/golang-first-comic-downloader

之前學了點 Go 語言,但沒有寫出一個比較有用的工具,基本上算白學。得選一個又簡單又比較有有價值的功能來實現。

之前用 PHP + Laravel 寫的漫畫下載器不好用,這剛好是一個簡單又實用的功能,乾脆用 Go 語言重新寫一個。

所有程式碼在 GitHub 上:

https://github.com/schaepher/comic-downloader-example

實現的功能和獲得對應的實踐如下:

  1. hello world

    • 程式的結構
    • 包的引用
    • 編譯和執行程式碼
    • 函式/方法的可見性
    • fmt 庫輸出字串
  2. 請求網頁和寫入檔案

    • 變數定義和賦值
    • 字串
    • if 語句
    • 無返回值的函式
    • net/http 庫發起請求和接收響應
    • io/ioutil 庫將網頁內容寫入檔案
  3. 漫畫標題和下載 ID 的解析

    • 結構體的定義和初始化
    • 結構體的方法
    • 單返回值的函式
    • fmt 庫格式化輸出字串
    • regexp 庫正規表示式
      • 除了用正則,還可以用 goquery 來解析 html,但這裡不使用。
  4. 程式碼整理,抽取函式

    • 多返回值的函式
    • 自定義錯誤資訊
    • strconv 庫將字串轉為整數
  5. 程式碼整理,放到類裡面

    • 方法內部修改結構體的值(引用)
    • 空白識別符號
  6. 獲取漫畫的所有檔名

    • 陣列和切片的宣告
    • 字串轉 byte 切片
    • strings 庫替換字串
    • encoding/json 庫解析 Json
    • fmt 庫列印結構體
  7. 下載漫畫

    • 字串型別元素的切片的初始化
    • 字串拼接
    • for range 迴圈
    • 普通的 for 迴圈
    • os 庫獲取當前所在工作目錄的路徑、判斷檔案或資料夾是否存在、建立資料夾
    • strconv 庫將整數轉為字串
  8. 併發下載漫畫

    • Go協程(goroutines)和通道(channel)
    • 引用型別與 make()
    • 匿名函式(閉包)
    • defer
    • sync 庫等待 goroutines 執行結束
    • 介面型別
    • 型別轉換
  9. 再次執行時避免下載已有頁面

    • 判斷一個字串是否存在於字串切片中
    • 往切片中新增元素
    • io/ioutil 庫讀取資料夾裡的檔案列表
  10. 將配置抽取到配置檔案

    • 獲取程式所在的目錄
    • io/ioutil 庫讀取檔案內容
  11. 沒有全部下載成功時重試

    • 自定義錯誤型別

注,編譯和執行環境都是 Windows 10

一開始嘗試對每份程式碼做分析,寫了一些後發現很費時間,所以還未寫解析的部分主要列出相關資料,並作必要的補充。主要來源是 《The Way To Go》 的中文版:

https://github.com/Unknwon/the-way-to-go_ZH_CN/blob/master/eBook/directory.md

注意,在執行程式碼前需要確保已安裝 Go 環境。

v1: hello world

先從最簡單的開始。

建立專案 comic-downloader ,在目錄裡面建立 main.go 檔案。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v01-hello-world/main.go

main.go

package main

import "fmt"

func main() {
    fmt.Println("hello world")
}

需要說明的內容有兩點:

  • Go 的程式碼不需要在程式碼行結束後加分號 ;
  • Go 語言通過函式/方法名的首字母大小寫控制訪問許可權。大寫首字母代表 public,小寫首字母代表 private。

執行命令:

go run main.go

輸出:

hello world

go run main.go 會將程式碼編譯為可執行檔案,然後執行。

如果要分開,可以這樣執行:

go build -o main.exe main.go
./main.exe

v2: 請求網頁和寫入檔案

對於下載功能,我最關心的是如何傳送 http 請求和如何讀取結果。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v02-http-get-write-file/main.go

main.go

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func check(e error) {
    if e != nil {
        panic(e)
    }
}

func main() {
    var err error
    var url = "https://cn.bing.com"
    res, err := http.Get(url)
    check(err)
    data, err := ioutil.ReadAll(res.Body)
    check(err)

    ioErr := ioutil.WriteFile("cn.bing.com.html", data, 644)
    check(ioErr)

    fmt.Printf("Got:\n%q", string(data))
}

這裡展示了變數宣告和賦值的不同形式。

首先看 var err error ,這裡用到的語法是 var 變數名 變數型別,因此這一句定義了一個型別為 error 的變數 err

4.4 變數:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.4.md
注意:
當一個變數被宣告之後,系統自動賦予它該型別的零值:int 為 0,float 為 0.0,bool 為 false,string 為空字串,指標為 nil。記住,所有的記憶體在 Go 中都是經過初始化的。

Go 語言和 C 語言或者 JAVA 把變數型別放在前面的形式不同,Go 語言總是把型別放在後面。這點在下面的例子中都可以看到,無論是變數名、函式引數(例如上面的 check 函式)還是函式返回值,型別都放在後面。

對於有弱型別語言(例如 PHP)程式設計經驗的人來說,這種順序會舒服很多。因為寫程式碼的時候不需要先想/查清楚返回值的型別再開始寫,或者寫完後面的函式呼叫再到前面補型別。

res, err := http.Get(url)

這裡涉及四個知識點:

下一行的 check(err) 用於檢查是否有錯誤:

func check(e error) {
    if e != nil {
        panic(e)
    }
}

涉及三個知識點:

回到主函式,再往下是讀取結果:

data, err := ioutil.ReadAll(res.Body)

然後是將結果寫到檔案裡面:

ioErr := ioutil.WriteFile("cn.bing.com.html", data, 644)

12.2 檔案讀寫
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/12.2.md

fmt.Printf("Got:\n%q", string(data))

這裡 string(data) 將 data 轉換為字串。

7.6.4 修改字串中的某個字元:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/07.6.md

v3: 解析標題和下載 ID

這個漫畫下載網站漫畫的 ID 和下載時 URL 的 ID 不一致,所以要將這個 ID 提取出來。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v03-regex-struct-method/main.go

main.go

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "regexp"
)

func check(e error) {
    if e != nil {
        panic(e)
    }
}

type ComicSite struct {
    MainPageUrl string
}

func (cs ComicSite) GetComicMainPageUrl(comicId int) string {
    return fmt.Sprintf("%s/cn/s/%d/", cs.MainPageUrl, comicId)
}

func main() {
    comicSite := ComicSite{
        MainPageUrl: "https://*****",
    }

    // 獲取漫畫頁
    comicMainPageUrl := comicSite.GetComicMainPageUrl(282526)
    res, err := http.Get(comicMainPageUrl)
    check(err)
    data, err := ioutil.ReadAll(res.Body)
    check(err)
    html := string(data)

    // 匹配標題
    titleR, err := regexp.Compile(`<title>(.+?)</title>`)
    check(err)
    titleMatches := titleR.FindStringSubmatch(html)
    if titleMatches == nil {
        panic("comic title not found")
    }
    title := titleMatches[1]
    fmt.Println(title)

    // 匹配下載 ID
    downloadR, err := regexp.Compile(`cn/(\d+)/1.(jpg|png)`)
    check(err)
    downloadMatches := downloadR.FindStringSubmatch(html)
    if downloadMatches == nil {
        panic("download id not found")
    }
    downloadIdStr := downloadMatches[1]
    fmt.Println(downloadIdStr)
}

這裡引入了結構體。

type ComicSite struct {
    MainPageUrl string
}

初始化和賦值:

comicSite := ComicSite{
    MainPageUrl: "https://*****",
}

10 結構(struct)與方法(method):
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/10.0.md
10.1 結構體定義:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/10.1.md
10.6 方法:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/10.6.md

結構體的方法:

func (cs ComicSite) GetComicMainPageUrl(comicId int) string {
    return fmt.Sprintf("%s/cn/s/%d/", cs.MainPageUrl, comicId)
}

注意與函式做比較:

func GetComicMainPageUrl(comicId int, mainPageUrl string) string {
    return fmt.Sprintf("%s/cn/s/%d/", mainPageUrl, comicId)
}

func 後面多了個 (cs ComicSite) 。在 Go 語言中,將其稱為接收者(receiver)。由於 Go 裡面沒有 this 關鍵字,所以這裡也可以寫成:

func (this ComicSite) GetComicMainPageUrl(comicId int) string {
    return fmt.Sprintf("%s/cn/s/%d/", this.MainPageUrl, comicId)
}

熟悉的味道。

再看看客戶端的呼叫:

comicSite := ComicSite{
    MainPageUrl: "https://*****",
}
comicSite.GetComicMainPageUrl(282526)

正則庫的使用:

titleR, err := regexp.Compile(`<title>(.+?)</title>`)
check(err)
titleMatches := titleR.FindStringSubmatch(html)
if titleMatches == nil {
    panic("comic title not found")
}
title := titleMatches[1]

這裡在編譯正規表示式的時候,用到了反引號,表示這是一個非解釋字串。

4.6 字串:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.6.md
9.2 regexp 包:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/09.2.md
正規表示式30分鐘入門教程:
https://deerchao.cn/tutorials/regex/regex.htm

由於只需要獲取 () 裡的內容,因此用 FindStringSubmatch。

假設 html 的值是 aaa<title>標題</title>bbb ,則 titleMatches 的值為:

[
  "<title>標題</title>",
  "標題"
]

v4-5: 程式碼整理

分為兩部分。

程式碼整理的第一部分是把匹配的程式碼放到函式裡面。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v04-function-error/main.go

main.go 部分程式碼:

func getDownloadId(html string) (int, error) {
    downloadR, err := regexp.Compile(`cn/(\d+)/1.(jpg|png)`)
    if err != nil {
        return 0, err
    }

    downloadMatches := downloadR.FindStringSubmatch(html)
    if downloadMatches == nil {
        err := errors.New("download id not found")
        return 0, err
    }

    downloadId, err := strconv.Atoi(downloadMatches[1])
    if err != nil {
        return 0, err
    }

    return downloadId, nil
}

說明三個點:

程式碼整理的第二部分是把函式轉為結構體的方法。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v05-reference-param/main.go

main.go 部分程式碼:

type Comic struct {
    Id         int
    Title      string
    DownloadId int
    ComicSite  ComicSite
}

func (comic *Comic) LoadMeta() error {
    var err error
    var mainPageHtml string

    comicMainPageUrl := comic.ComicSite.GetComicMainPageUrl(comic.Id)
    mainPageHtml, err = comic.getComicMainPageHtml(comicMainPageUrl)
    if err != nil {
        return err
    }

    comic.Title, err = comic.findTitle(mainPageHtml)
    if err != nil {
        return err
    }

    comic.DownloadId, err = comic.findDownloadId(mainPageHtml)
    if err != nil {
        return err
    }

    return nil
}

func (_ Comic) findTitle(html string) (string, error) {
    titleR, err := regexp.Compile(`<title>(.+?)</title>`)
    if err != nil {
        return "", err
    }

    titleMatches := titleR.FindStringSubmatch(html)
    if titleMatches == nil {
        err := errors.New("comic title not found")
        return "", err
    }
    title := titleMatches[1]

    return title, nil
}

對比以下兩段程式碼:

func (_ Comic) findTitle(html string) (string, error) {
    // ...
}
func (cs ComicSite) GetComicMainPageUrl(comicId int) string {
    // ...
}

有個不同的地方是這裡結構體變數設定為空白標識 _。因為 findTitle 這個函式不需要用到 Comic 這個結構體的內容。

再對比:

func (comic *Comic) LoadMeta() error {
    // ...
    comic.Title, err = comic.findTitle(mainPageHtml)
    // ...
}

多了個 * ,表示 comic 是一個 Comic 型別的指標,對其內容的修改會影響到外部的變數。

另外無論是值型別還是指標,其呼叫方式都是 obj.method(...) ,Go 會自動識別。

10.6.3 指標或值作為接收者:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/10.6.md

v6: 獲取漫畫的所有檔名

接下來要準備下載了。不過在此之前要先獲取下載連結。

漫畫主頁提供的預覽圖是縮小版的圖片,因此不能直接使用。

漫畫主頁還提供了頁面總數。雖然檔名是按照數字順序的,但是副檔名可能是 jpg 或者 png 或者其他的。

通過觀察,我發現在點選下載的時候,會去請求一個 js 檔案。內容格式如下:

var galleryinfo = [{"lan": "cn","name": "1.jpg"},]

把後面的陣列匹配出來然後做 json 解碼就行了。正好還能學習 encoding/json 庫和 strings 庫。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v06-decode-json-replace-string/main.go

main.go 部分程式碼:

type ComicFile struct {
    Name string `json:"name"`
}

type Comic struct {
    Id         int
    Title      string
    DownloadId int
    ComicSite  ComicSite
    ComicFiles []ComicFile
}

func (comic *Comic) LoadMeta() error {
    // ...
    comicIndexUrl := comic.ComicSite.GetComicIndexUrl(comic.Id)
    comic.ComicFiles, err = comic.readComicIndexes(comicIndexUrl)
    // ...
}

func (_ Comic) readComicIndexes(comicIndexUrl string) ([]ComicFile, error) {
    res, err := http.Get(comicIndexUrl)
    if err != nil {
        return nil, err
    }
    htmlByte, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return nil, err
    }

    html := string(htmlByte)

    r, err := regexp.Compile("\\[.+]")
    if err != nil {
        return nil, err
    }
    jsonStr := r.FindString(html)
    validJson := strings.Replace(jsonStr, ",]", "]", 1)

    var pages []ComicFile
    err = json.Unmarshal([]byte(validJson), &pages)
    if err != nil {
        return nil, err
    }

    return pages, nil
}

說明三個點:

第一:切片的宣告

var pages []ComicFile

7.2 切片:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/07.2.md

陣列的宣告呢?

var pages [100]ComicFile

二維陣列呢?

var pages [X][Y]ComicFile
// pages[x][y]

[Y]ComicFile 當成 COMICFILE 的話,上述宣告就變成了:

var pages [X]COMICFILE

第二:字串的替換

validJson := strings.Replace(jsonStr, ",]", "]", 1)

1 表示替換一次。

4.7.4 字串替換:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.7.md

第三:Json 解碼:

var pages []ComicFile
err = json.Unmarshal([]byte(validJson), &pages)

12.9 JSON 資料格式:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/12.9.md

由於 Unmarshal 第一個引數指定為 byte 型別的切片,所以要先做一次轉換。

第二個引數是傳指標, Unmarshal 直接在函式裡面修改這個變數。

還可以:

pages := new([]ComicFile)
err = json.Unmarshal([]byte(validJson), pages)

因為 new() 得到的是結構體的指標。

10.2.2 map 和 struct vs new() 和 make():
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/10.2.md

看一下 json 串和結構體:

[{"lan": "cn","name": "1.jpg"}]
type ComicFile struct {
    Name string `json:"name"`
}

ComicFile 這個結構體只定義了一個欄位,而且由於欄位名稱與 json 串裡面的大小寫不一樣,所以後面加一個補充說明 json:"name"

解碼的時候只會把 name 的值放到 Name 裡面,並且忽略掉 lan 。

如果 json 欄位本身就是大寫,則不需要加後面的補充。

要確保結構體的欄位以大寫字母開頭,否則 Json 解析後該欄位為空。

v7: 下載漫畫

終於要下載漫畫了。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v07-encode-json-log-create-dir-for-range/main.go

main.go 部分程式碼:

func (comic Comic) GetDirPath() string {
    pwd, _ := os.Getwd()

    return pwd + "/comics/" + strconv.Itoa(comic.Id)
}

func createDirIfNotExist(dir string) error {
    if _, err := os.Stat(dir); os.IsNotExist(err) {
        err = os.MkdirAll(dir, 0755)
        if err != nil {
            return err
        }
    }

    return nil
}

func download(comic Comic) error {
    log.Printf("Downloading: %s\n", comic.Title)

    err := createDirIfNotExist(comic.GetDirPath())
    if err != nil {
        return err
    }

    data, err := json.Marshal(comic)
    if err != nil {
        return err
    }
    err = ioutil.WriteFile(comic.GetMetaFilePath(), data, 0644)
    if err != nil {
        return err
    }
    log.Printf("Meta file saved: %s\n", comic.GetMetaFilePath())

    for _, comicFile := range comic.ComicFiles {
        log.Printf("Start downloading: %s\n", comicFile.Name)

        for i := 0; i < len(comic.ComicSite.DownloadSourceUrls); i++ {
            downloadUrl, err := comic.ComicSite.GetComicDownloadUrl(comic.DownloadId, comicFile.Name, i)
            if err != nil {
                break
            }

            log.Printf("Trying: %s\n", downloadUrl)
            resp, err := http.Get(downloadUrl)
            if err != nil || resp.StatusCode != 200 {
                log.Printf("Failed: %s\n", downloadUrl)
                continue
            }
            data, err := ioutil.ReadAll(resp.Body)

            err = ioutil.WriteFile(comic.GetFilePath(comicFile.Name), data, 0644)
            if err != nil {
                return err
            }

            log.Printf("Saved : %s\n", comic.GetFilePath(comicFile.Name))
        }
    }

    return nil
}

func main() {
    comicSite := ComicSite{
        MainPageUrl: "https://******",
        DownloadSourceUrls: []string{
            "https://******/img/cn",
        },
    }

    comic := &Comic{ComicSite: comicSite, Id: 282526}
    err := comic.LoadMeta()
    check(err)

    err = download(*comic)
    check(err)
}

該漫畫網站有兩種域名用於獲取漫畫圖片:

  • 線上閱讀時請求的域名
  • 下載時請求的域名

有時候線上閱讀請求不到圖片,但用於下載的域名可以獲取到。有時候反之。所以當下載出錯時,要換另一個域名試試。

先看 download 函式。

在下載前,要先建立資料夾。首先獲取資料夾路徑:

func (comic Comic) GetDirPath() string {
    pwd, _ := os.Getwd()

    return pwd + "/comics/" + strconv.Itoa(comic.Id)
}

這裡獲取的是當前的工作目錄,不是程式檔案所在的目錄。獲取程式檔案所在的目錄會在後面給出例子。

整數轉字串:

4.7.12 字串與其它型別的轉換
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.7.md

字串拼接:

pwd + "/comics/"

如果要在迴圈中拼接字串(例如將陣列每個元素用逗號拼接起來),用 + 號拼接不是高效的做法。

var strB strings.Builder
strB.WriteString("abc")
strB.WriteString("def")
fmt.Println(strB.String()) // abcdef

在 《The Way To Go》 裡面還會看到用 bytes.Buffer 。區別在於 Go 1.10 才引入的 strings.Builder 效率更高。

接下來建立資料夾的 createDirIfNotExist 就不做說明了。

接著是把漫畫的基本資訊儲存到檔案裡面。

前面介紹過 Json 字串解碼,現在要把結構體編碼成字串:

data, err := json.Marshal(comic)

寫完基本資訊,接下來就是下載漫畫圖片了。

下面用了巢狀迴圈,展示了 for 的兩種不同寫法。

首先是遍歷漫畫所有檔案用的 for range:

for _, comicFile := range comic.ComicFiles {
    // ...
}

和 PHP 的 foreach 很相似。

這裡會返回 index 和 value。 index 被我用 _ 忽略掉了。

接著是遍歷不同的下載域名連結:

for i := 0; i < len(comic.ComicSite.DownloadSourceUrls); i++ {
    // ...
}

注:該網站在某個線上閱讀方式中用到了 CDN, 圖片下載速度快很多。這個 CDN 用的是 HTTP/2 。由於 Go 的 http 庫預設開啟 HTTP/2 ,所以無需修改程式碼。
Starting with Go 1.6, the http package has transparent support for the HTTP/2 protocol when using HTTPS.
https://golang.org/pkg/net/http/

v8: 併發下載漫畫

上面下載的時候,用 for 迴圈一張張下載,必須得等一張下載結束才能繼續。這樣效率太低,要下載半天。

那麼就要想辦法併發下載。

但是要注意控制併發的數量。如果不做控制,有的漫畫兩百多頁一下子兩百多個併發請求,對源站不友好。

併發的程式碼一開始是參考下面連結中的方案二:

來,控制一下 Goroutine 的併發數量:
https://segmentfault.com/a/1190000017956396
這篇寫得很好,感謝!

但是我當時沒理解過來,寫下了有問題的程式碼。下面我先解析我改正後的程式碼, 解析完再說說之前我哪裡理解錯了,以及基於錯誤理解寫的程式碼。

併發示例

用一個簡單的例子來理解這部分內容,然後再將其改造成一個併發庫。

上正確版的程式碼:

https://github.com/schaepher/comic-downloader-example/blob/master/v08-channel-wait-group-go-func-defer/thread-v1-fix/thread.go

thread.go

package main

import (
    "log"
    "math/rand"
    "sync"
    "time"
)

var wg sync.WaitGroup

func main() {
    maxTask := 10

    maxThread := 3
    ch := make(chan int, maxThread)
    for i := 0; i < maxThread; i++ {
        threadId := i
        go func() {
            wg.Add(1)
            defer wg.Done()

            log.Printf("Worker [%d] started at %d\n", threadId, time.Now().Unix())

            for taskId := range ch {
                seconds := 1 + rand.Intn(9)
                log.Printf("Task [%d] will sleep %d seconds\n", taskId, seconds)
                time.Sleep(time.Second * time.Duration(seconds))
                log.Printf("Task [%d] finished", taskId)
            }

            log.Printf("Worker [%d] finished at %d\n", threadId, time.Now().Unix())
        }()
    }

    for i := 0; i < maxTask; i++ {
        ch <- i
    }

    close(ch)
    wg.Wait()
}

14.1 併發、並行和協程(前兩部分)
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/14.1.md
14.2 協程間的通道
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/14.2.md

大致知道五點:

  • go 關鍵字執行函式或方法時,會建立協程。
  • 通道(Channel)是一個先進先出的佇列。多個協程可使用同一個通道。通道里的一個資料只會被其中一個協程訪問到。
  • 當通道滿時,傳送者無法再傳送資料,只能阻塞並等待接收者消費通道的資料。如果通道已經空了,則接收者無法消費,只能阻塞並等待傳送者傳送資料。
  • 通道預設無緩衝,即只能一發一收。可以建立帶緩衝的通道,這樣可以同時傳送多個和接收多個。
  • close() 使得通道無法再接收資料,但剩下的資料可以被消費。用 for-range 消費時會自動檢測通道是否關閉且無剩餘資料。

回到程式碼中。

maxThread := 3
ch := make(chan int, maxThread)

這裡建立了帶緩衝的通道,允許通道里存放三個資料。接著啟動與通道數量對應的協程:

for i := 0; i < maxThread; i++ {
        threadId := i
        go func() {
            // ...
        }()
    }

6.8 閉包:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/06.8.md

先忽略匿名函式裡面的 WaitGroup 。

for taskId := range ch {
    seconds := 1 + rand.Intn(9)
    log.Printf("Task [%d] will sleep %d seconds\n", taskId, seconds)
    time.Sleep(time.Second * time.Duration(seconds))
    log.Printf("Task [%d] finished", taskId)
}

協程裡面不斷讀取通道的資料。但是由於剛啟動的時候通道里面沒有資料,所以這裡會阻塞。三個協程都阻塞了。

繼續往下走:

for i := 0; i < maxTask; i++ {
    ch <- i
}

這裡開始往通道傳送資料。由於通道在上面被設定為只能存三個資料,所以這裡一開始最多隻能放三個。一旦放滿又沒被消費, for 迴圈就會被阻塞。

一旦開始放資料,協程就可以從通道里拿資料了。

示例見:

https://github.com/schaepher/comic-downloader-example/blob/master/v08-channel-wait-group-go-func-defer/thread-v1-fix/thread.log

2020/04/16 01:59:52 Worker [0] started at 1586973592
2020/04/16 01:59:52 Task [0] will sleep 6 seconds
2020/04/16 01:59:52 Worker [1] started at 1586973592
2020/04/16 01:59:52 Task [1] will sleep 7 seconds
2020/04/16 01:59:52 Worker [2] started at 1586973592
2020/04/16 01:59:52 Task [2] will sleep 3 seconds
2020/04/16 01:59:55 Task [2] finished

當 maxTask 個任務傳送完畢後,for 迴圈就結束了。

但注意,此時協程裡的任務未必結束,但 for 迴圈後面的程式碼會繼續跑。

close(ch)

關閉通道入口,避免協程無限等待。

如果此時直接退出,會導致協程也被中斷。

那麼我們就要想辦法等待協程任務執行結束。這時就要用到 WaitGroup 了。

for i := 0; i < maxThread; i++ {
    // ...
    go func() {
        wg.Add(1)
        defer wg.Done()
        // ...
    }()
}
// ...
wg.Wait()

在匿名函式開始執行時,往裡面加了個 1。

緊接著用 defer 指定了一個方法呼叫(wg.Done 就是 wg.Add(-1)),這個方法會在匿名函式 return 後執行。

6.4 defer 和追蹤:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/06.4.md

然後在主函式的最後,用了 wg.Wait() 等到歸零時才繼續。

什麼時候歸零呢?在所有協程 return 後都執行了 wg.Done()。而協程要退出,就代表著任務已經執行結束了。

這樣就做到了等待所有任務執行完再退出程式。

併發庫

為了將上面這個思路應用到漫畫下載裡面,可以選擇將其直接分塊寫到 main.go 裡面,或者抽取到一個專門的庫。這裡選擇另外寫一個庫,可以藉此演示引用專案中其他檔案的方法。

下面先展示這個庫的使用示例,再解釋庫自身的內容。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v08-channel-wait-group-go-func-defer/thread-v2-fix/test/main.go

test/main.go

package main

import (
    "../../thread-v2-fix"
    "log"
    "math/rand"
    "time"
)

func main() {
    tp := Thread.Pool{MaxThread: 3}
    tp.Prepare(func(param interface{}) {
        taskId := param.(int)

        seconds := rand.Intn(9) + 1
        log.Printf("Task [%d] will sleep %d seconds", taskId, seconds)

        time.Sleep(time.Second * time.Duration(seconds))

        log.Printf("Task [%d] finished", taskId)
    })

    tasksCount := 10
    for i := 0; i < tasksCount; i++ {
        tp.RunWith(i)
    }

    tp.Wait()
}

總體上與前面的例子一致。我將 thread-v1-fix 分為三個部分:

  • 儲存協程執行的函式(Prepare)
  • 傳送任務(RunWith)
  • 等待任務結束(Wait)

匿名函式的引數是一個介面型別,這樣可以接收任何型別的入參。

匿名函式應首先將引數轉換為所需的型別,然後再執行下面的操作。例如上例中的 taskId := param.(int) 將 param 轉換為 int 型別。

甚至可以讓傳入的引數就是一個匿名函式,然後直接執行。例如:

func main() {
    tp := Thread.Pool{MaxThread: 3}
    tp.Prepare(func(param interface{}) {
        doSomething := param.(func())
        doSomething()
    })

    tasksCount := 10
    for i := 0; i < tasksCount; i++ {
        taskId := i
        tp.RunWith(func() {
            log.Println(taskId)
        })
    }

    tp.Wait()
}

接下來看 thread-v2-fix 的具體實現。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v08-channel-wait-group-go-func-defer/thread-v2-fix/thread.go

thread.go

package Thread

import (
    "log"
    "sync"
    "time"
)

type Pool struct {
    MaxThread int
    chParams  chan interface{}
    waitGroup sync.WaitGroup
    function  func(param interface{})
}

func (tp *Pool) Prepare(function func(item interface{})) {
    tp.chParams = make(chan interface{}, tp.MaxThread)
    tp.waitGroup = sync.WaitGroup{}
    tp.function = function

    for i := 0; i < tp.MaxThread; i++ {
        workerId := i
        go func() {
            tp.waitGroup.Add(1)
            defer tp.waitGroup.Done()

            log.Printf("Worker [%d] started at %d\n", workerId, time.Now().Unix())
            for param := range tp.chParams {
                tp.function(param)
            }
            log.Printf("Worker [%d] finished at %d\n", workerId, time.Now().Unix())
        }()
    }
}

func (tp *Pool) RunWith(param interface{}) {
    tp.chParams <- param
}

func (tp *Pool) Wait() {
    close(tp.chParams)
    tp.waitGroup.Wait()
}

在 Prepare 的時候將匿名函式儲存起來,然後在協程裡面獲取到通道資料之後呼叫。

上面這個庫算是一個簡化版的實現,因為還有很多內容沒有考慮到。例如最明顯的是沒有考慮到出錯的情況。

所以如果為了更實際的使用,應該去參考開源庫的實現:

https://github.com/go-playground/pool
https://github.com/nozzle/throttler
https://github.com/Jeffail/tunny
https://github.com/panjf2000/ants

接下來說說我是如何誤解下面這篇文章的方案二,並且基於錯誤的理解寫出自己的版本。

來,控制一下 Goroutine 的併發數量:
https://segmentfault.com/a/1190000017956396

如果不感興趣,請直接跳過【我是怎麼理解錯的】和【基於錯誤的理解寫出的版本】這兩部分,跳轉到 v9 。

我是怎麼理解錯的

一開始我會先驗證這個方案的程式碼是否可行,於是複製程式碼並執行。

以下程式碼來自於:

來,控制一下 Goroutine 的併發數量:
https://segmentfault.com/a/1190000017956396

package main

import (
    "fmt"
    "sync"
    "time"
)

var wg sync.WaitGroup

func main() {
    userCount := 10
    ch := make(chan int, 5)
    for i := 0; i < userCount; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for d := range ch {
                fmt.Printf("go func: %d, time: %d\n", d, time.Now().Unix())
                time.Sleep(time.Second * time.Duration(d))
            }
        }()
    }

    for i := 0; i < 10; i++ {
        ch <- 1
        ch <- 2
        //time.Sleep(time.Second)
    }

    close(ch)
    wg.Wait()
}

我認為限制了通道的緩衝區長度為 5,那麼應該是控制最多五個任務併發。結果一執行就傻了,居然同時開了十個任務。

現在當然可以知道是因為開了十個協程。就算一開始通道滿了,當被其中五個協程獲取後,位置就會空出來。然後 for 迴圈繼續傳送,剩下的五個協程也可以獲取,直到每個協程都正在執行任務。所以實際上控制任務數的是 userCount。

這時就會奇怪了,限制通道緩衝區長度為 5 的意義是什麼?為什麼不設定和 userCount 一致?以下是我的看法:

  • 限制通道長度,減少記憶體資源消耗
  • 不需要很快執行完 for 迴圈
    因為如果通道一直處於滿的狀態(協程獲取到的任務一直沒執行完),那麼就沒法往通道傳送資料。而 for 迴圈必須等到所有資料傳送完才結束。
    如果通道設定得大一些,就能加快 for 迴圈的結束。例如這裡將通道設定為 20 ,就會很快結束 for 迴圈,因為不會被阻塞。

基於錯誤的理解寫出的版本

以下程式碼來自於:

https://github.com/schaepher/comic-downloader-example/blob/master/v08-channel-wait-group-go-func-defer/thread-v1/thread.go

thread.go

package main

import (
    "log"
    "math/rand"
    "sync"
    "time"
)

var wg sync.WaitGroup

func main() {
    maxThread := 3
    ch := make(chan int, maxThread)

    taskCount := 10
    for i := 0; i < taskCount; i++ {
        tmpId := i
        go func(taskId int) {
            wg.Add(1)
            defer wg.Done()

            log.Printf("Task id is [%d]\n", taskId)

            workerId := <-ch
            log.Printf("Worker [%d] started at %d, task id is [%d]\n", workerId, time.Now().Unix(), taskId)

            seconds := 1 + rand.Intn(9)
            log.Printf("Task [%d] will sleep %d seconds\n", taskId, seconds)
            time.Sleep(time.Second * time.Duration(seconds))
            log.Printf("Task [%d] finished", taskId)

            log.Printf("Worker [%d] finished at %d\n", workerId, time.Now().Unix())

            ch <- workerId
        }(tmpId)
    }

    for i := 1; i <= maxThread; i++ {
        ch <- i
    }

    wg.Wait()
    close(ch)
}

這樣的程式碼仍然可以按照限制的個數執行任務。

這裡我為每個任務都開啟一個協程,但是隻有從通道里獲取資料之後才正式執行任務。執行完任務後把資料放回通道,讓其他協程獲取並執行。

這種做法有好處也有壞處。

壞處是如果任務量很大,例如一萬個,會導致開啟了一萬個協程。這點從日誌中可以看出來。

好處是如果亂序執行任務比順序執行任務更符合業務要求的話,能夠達到亂序的效果。

當然,好處和壞處都是在一定場景下才能判斷的。例如我這裡下載漫畫的時候就希望它按照順序來下載,所以顯然這種方式不符合我的要求。上面在展示修復後的版本時,就用的是順序執行的方法。

我也有以這個錯誤版本為基礎寫了個順序的版本。本來是作為版本 11 寫的,但後來覺得這個版本沒有必要,合併到版本 8 裡面了。

https://github.com/schaepher/comic-downloader-example/blob/master/v08-channel-wait-group-go-func-defer/thread-v2-1/thread.go

這個思路是把任務先存起來,然後迴圈取出來並啟動協程來執行。啟動協程之前從通道獲取資料,以此控制併發數。

v9: 再次執行時避免下載已有頁面

這個下載站有時候下載一張漫畫圖的時候會失敗,然後再請求幾次就能成功了。

(每次都能給我整出新花樣).jpg

但我不想總因為中間的某幾張下載不了花太多時間重試,於是就放到整個漫畫其他檔案下載完之後再執行一次程式進行補充下載(後續改為自動重試)。

這樣就帶來一個問題,直接重試會導致一些已經下載的漫畫頁面再次被下載。所以我得列出已經下載的漫畫頁面,然後只下載那些缺失的頁面。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v09-list-dir-files/main.go

main.go

type DownloadParam struct {
    Comic     Comic
    ComicFile ComicFile
}

func downloadComic(comic Comic, maxThread int) error {
    // ...
    existFiles, err := ListDirFiles(comic.GetDirPath())
    if err != nil {
        return err
    }

    log.Println("Downloading comic files")
    tp := Thread.Pool{MaxThread: maxThread}
    tp.Prepare(func(param interface{}) {
        downloadParam := param.(DownloadParam)
        downloadImg(downloadParam.Comic, downloadParam.ComicFile)
    })

    for _, comicFile := range comic.ComicFiles {
        if InArray(comicFile.Name, existFiles) {
            continue
        }
        tp.RunWith(DownloadParam{Comic: comic, ComicFile: comicFile})
    }
    // ...
}

func ListDirFiles(root string) ([]string, error) {
    var files []string
    fileInfo, err := ioutil.ReadDir(root)
    if err != nil {
        return files, err
    }
    for _, file := range fileInfo {
        files = append(files, file.Name())
    }
    return files, nil
}

func InArray(item string, items []string) bool {
    for _, tmpItem := range items {
        if tmpItem == item {
            return true
        }
    }
    return false
}

ListDirFiles 來自於:

List directory in Go:
https://stackoverflow.com/a/49196644

這個函式建立了一個型別為 string 的切片,然後用 append 不斷往切片裡新增檔名。

7.5 切片的複製與追加:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/07.5.md

InArray 函式是自己實現的判斷當前檔名是否在檔案列表中。

Go 沒有判斷元素是否在一個切片中的方法(比如 PHP 中的 in_array),因此每次都需要自己寫。

v10: 將配置抽取到配置檔案

該版本的程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v10-config/main.go

到目前為止,網站和要下載的漫畫 ID 都是放在程式碼裡面的。這樣導致要下載新漫畫的時候,都得重新編譯。因此要把配置抽取出來。

分析 v9 的程式碼,可以找到以下配置項:

  • 漫畫主頁 URL
  • 下載地址的域名
  • 漫畫 ID
  • 儲存漫畫的資料夾位置
  • 併發數量

這裡打算使用 Json 檔案作為配置檔案。

因此定義以下結構體:

type Config struct {
    MainPageUrl        string   `json:"mainPageUrl"`
    DownloadSourceUrls []string `json:"downloadSourceUrls"`
    MaxThread          int      `json:"maxThread"`
    ComicIds           []int    `json:"comicIds"`
    ComicsRootDir      string   `json:"comicsRootDir"`
}

那麼從哪裡讀取這個 Json 配置檔案呢?預設跟可執行檔案同一個目錄吧。

前面使用 os.Getwd() 獲取的是執行時所在的目錄,而這裡則是可執行檔案所在的目錄。

首先用 os.Args[0] 獲取執行檔案時使用的路徑。

如果在 Windows 10 上執行,會得到絕對路徑。就算使用 ./main.exe,也會得到完整路徑。
如果在 Linux 上執行,會得到執行時的路徑。例如使用 ./main 執行時,會得到 ./main

然後用 filepath.Dir() 獲取到該檔案的資料夾。

最後用 filepath.Abs() 得到絕對路徑。上文有提到系統之間的差異,所以用這個函式來確保獲取到正確的路徑。

接著是讀取這個檔案,這裡用了 ioutil.ReadFile(filePath)

v11: 沒有全部下載成功時重試

v9: 再次執行時避免下載已有頁面 這一節碰到下載不了的漫畫頁面時,會先下載其他的,然後手動再次執行程式進行補充下載。

重試這種能交給程式做的事情為什麼要手動執行?

如何做?

在一個漫畫下載完後,再次獲取資料夾內部的檔案列表。如果檔案數量和漫畫數量對不上,則丟擲錯誤。外部獲得這個錯誤後執行重試。

此時有個問題:外部如何判斷丟擲的錯誤是漫畫沒有全部下載的錯誤?因為還可能出現其他型別的錯誤。

用錯誤的文字資訊做比較是一種方法,不過容易出問題,而且不優雅。我們更希望能像 try-catch 那樣自定義異常型別,然後根據型別做處理。

其實之前在轉換變數型別的時候,會返回兩個值:

  • 轉換後的變數
  • 轉換是否成功(bool 型別的變數)

那麼就可以通過自定義錯誤型別 NotAllComicDownloadedError ,實現 error 介面。然後在外面嘗試將錯誤轉換為 NotAllComicDownloadedError 。如果成功,就表示出現這個錯誤,進入重試。

以下程式碼在:

https://github.com/schaepher/comic-downloader-example/blob/master/v11-custom-error-retry/main.go

main.go 部分程式碼:

for retries := 0; ; {
    err = downloadComic(*comic, config.MaxThread)
    if err == nil {
        break
    }

    if _, ok := err.(NotAllComicDownloadedError); !ok {
        panic(err)
    }

    if retries++; retries > config.MaxRetry {
        break
    }

    log.Printf("Retrying, comic [%d]: %d", comic.Id, retries)
}

當 if 有兩個表示式的時候,第一個是初始化,第二個才是判斷。

5.1 if-else 結構:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/05.1.md

接下來就是定義錯誤型別 NotAllComicDownloadedError ,它需要實現 error 介面。

11.1 介面是什麼:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/11.1.md

先看看 error 介面的定義:

$GOROOT/src/builtin/builtin.go

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

實現:

type NotAllComicDownloadedError struct {
    Comic Comic
}

func (err NotAllComicDownloadedError) Error() string {
    return fmt.Sprintf("download error: not all Comic images of [%d] are downloaded", err.Comic.Id)
}

至此已經實現了基本夠用的功能,等到需要實現更多功能的時候再繼續新增。

相關文章