原文地址:
第一個 Go 語言程序:漫畫下載器:
https://schaepher.github.io/2020/04/11/golang-first-comic-downloader
之前學了點 Go 語言,但沒有寫出一個比較有用的工具,基本上算白學。得選一個又簡單又比較有有價值的功能來實現。
之前用 PHP + Laravel 寫的漫畫下載器不好用,這剛好是一個簡單又實用的功能,乾脆用 Go 語言重新寫一個。
所有代碼在 GitHub 上:
實現的功能和獲得對應的實踐如下:
-
hello world
- 程序的結構
- 包的引用
- 編譯和運行代碼
- 函數/方法的可見性
fmt
庫輸出字符串
-
請求網頁和寫入文件
- 變量定義和賦值
- 字符串
- if 語句
- 無返回值的函數
net/http
庫發起請求和接收響應io/ioutil
庫將網頁內容寫入文件
-
漫畫標題和下載 ID 的解析
- 結構體的定義和初始化
- 結構體的方法
- 單返回值的函數
fmt
庫格式化輸出字符串regexp
庫正則表達式- 除了用正則,還可以用
goquery
來解析 html,但這裏不使用。
- 除了用正則,還可以用
-
代碼整理,抽取函數
- 多返回值的函數
- 自定義錯誤信息
strconv
庫將字符串轉爲整數
-
代碼整理,放到類裏面
- 方法內部修改結構體的值(引用)
- 空白標識符
-
獲取漫畫的所有文件名
- 數組和切片的聲明
- 字符串轉 byte 切片
strings
庫替換字符串encoding/json
庫解析 Jsonfmt
庫打印結構體
-
下載漫畫
- 字符串類型元素的切片的初始化
- 字符串拼接
- for range 循環
- 普通的 for 循環
os
庫獲取當前所在工作目錄的路徑、判斷文件或文件夾是否存在、創建文件夾strconv
庫將整數轉爲字符串
-
併發下載漫畫
- Go協程(goroutines)和通道(channel)
- 引用類型與 make()
- 匿名函數(閉包)
- defer
sync
庫等待 goroutines 執行結束- 接口類型
- 類型轉換
-
再次執行時避免下載已有頁面
- 判斷一個字符串是否存在於字符串切片中
- 往切片中添加元素
io/ioutil
庫讀取文件夾裏的文件列表
-
將配置抽取到配置文件
- 獲取程序所在的目錄
io/ioutil
庫讀取文件內容
-
沒有全部下載成功時重試
- 自定義錯誤類型
注,編譯和執行環境都是 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)
這裏涉及四個知識點:
- 變量省略類型聲明並賦值
4.4 變量:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.4.md - 發起一個 HTTP GET 請求
15.3 訪問並讀取頁面:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/15.3.md - 函數多個返回值,用逗號隔開
6.2 函數參數與返回值(第一部分):
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/06.2.md - Go 語言沒有“異常”這一設計,通常給函數多加一個返回值表示錯誤
Why does Go not have exceptions?
https://golang.org/doc/faq#exceptions
13 錯誤處理與測試:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/13.0.md
下一行的 check(err)
用於檢查是否有錯誤:
func check(e error) {
if e != nil {
panic(e)
}
}
涉及三個知識點:
-
Go 的 if 判斷不需要加括號。
5.1 if-else 結構:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/05.1.md -
當一個指針被定義後沒有分配到任何變量時,它的值爲 nil。
注意,只有指針才能爲 nil。假設有代碼:var str string = nil
編譯時會報錯:
cannot use nil as type string in assignment
4.9 指針:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.9.md -
panic()
用於終止程序13.2 運行時異常和 panic:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/13.2.md
回到主函數,再往下是讀取結果:
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
}
說明三個點:
-
自定義錯誤信息內容
err := errors.New("download id not found")
13.1 錯誤處理:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/13.1.md -
函數多返回值
6.2 函數參數與返回值:
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/06.2.md -
字符串轉整數
4.7.12 字符串與其它類型的轉換
https://github.com/unknwon/the-way-to-go_ZH_CN/blob/master/eBook/04.7.md
代碼整理的第二部分是把函數轉爲結構體的方法。
以下代碼在:
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
庫。
以下代碼在:
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: 下載漫畫
終於要下載漫畫了。
以下代碼在:
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
這篇寫得很好,感謝!
但是我當時沒理解過來,寫下了有問題的代碼。下面我先解析我改正後的代碼, 解析完再說說之前我哪裏理解錯了,以及基於錯誤理解寫的代碼。
併發示例
用一個簡單的例子來理解這部分內容,然後再將其改造成一個併發庫。
上正確版的代碼:
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 循環就會被阻塞。
一旦開始放數據,協程就可以從通道里拿數據了。
示例見:
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
裏面,或者抽取到一個專門的庫。這裏選擇另外寫一個庫,可以藉此演示引用項目中其他文件的方法。
下面先展示這個庫的使用示例,再解釋庫自身的內容。
以下代碼在:
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
的具體實現。
以下代碼在:
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 循環,因爲不會被阻塞。
基於錯誤的理解寫出的版本
以下代碼來自於:
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 裏面了。
這個思路是把任務先存起來,然後循環取出來並啓動協程來執行。啓動協程之前從通道獲取數據,以此控制併發數。
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)
}
至此已經實現了基本夠用的功能,等到需要實現更多功能的時候再繼續添加。