您好,登錄后才能下訂單哦!
這篇文章主要介紹“Go語言網(wǎng)絡(luò)爬蟲實(shí)例代碼分享”,在日常操作中,相信很多人在Go語言網(wǎng)絡(luò)爬蟲實(shí)例代碼分享問題上存在疑惑,小編查閱了各式資料,整理出簡(jiǎn)單好用的操作方法,希望對(duì)大家解答”Go語言網(wǎng)絡(luò)爬蟲實(shí)例代碼分享”的疑惑有所幫助!接下來,請(qǐng)跟著小編一起來學(xué)習(xí)吧!
這篇通過網(wǎng)絡(luò)爬蟲的示例,來了解 Go 語言的遞歸、多返回值、延遲函數(shù)調(diào)用、匿名函數(shù)等方面的函數(shù)特性。
首先是爬蟲的基礎(chǔ)示例,下面兩個(gè)例子展示通過 net/http 包來爬取頁面的內(nèi)容。
下面的程序展示從互聯(lián)網(wǎng)獲取信息,獲取URL的內(nèi)容,然后不加解析地輸出:
// 輸出從 URL 獲取的內(nèi)容 package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { for _, url := range os.Args[1:] { url = checkUrl(url) resp, err := http.Get(url) if err != nil { fmt.Fprintf(os.Stderr, "ERROR fetch request %s: %v\n", url, err) os.Exit(1) // 進(jìn)程退出時(shí),返回狀態(tài)碼1 } _, err = io.Copy(os.Stdout, resp.Body) resp.Body.Close() if err != nil { fmt.Fprintf(os.Stderr, "ERROR fetch reading %s: %v\n", url, err) os.Exit(1) } } } func checkUrl(s string) string { if strings.HasPrefix(s, "http") { return s } return fmt.Sprint("http://", s) }
這個(gè)程序使用里使用了 net/http 包。http.Get 函數(shù)產(chǎn)生一個(gè) HTTP 請(qǐng)求,如果沒有出錯(cuò),返回結(jié)果存在響應(yīng)結(jié)構(gòu) resp 里面。其中 resp 的 Body 域包含服務(wù)器端響應(yīng)的一個(gè)可讀取數(shù)據(jù)流。這里可以用 ioutil.ReadAll 讀取整個(gè)響應(yīng)的結(jié)果。不過這里用的是 io.Copy(dst, src) 函數(shù),這樣不需要把整個(gè)響應(yīng)的數(shù)據(jù)流都裝到緩沖區(qū)之中。讀取完數(shù)據(jù)后,要關(guān)閉 Body 數(shù)據(jù)流來避免資源泄露。
這個(gè)程序和上一個(gè)一樣,獲取URL的內(nèi)容,并且是并發(fā)獲取的。這個(gè)版本丟棄響應(yīng)的內(nèi)容,只報(bào)告每一個(gè)響應(yīng)的大小和花費(fèi)的時(shí)間:
// 并發(fā)獲取 URL 并報(bào)告它們的時(shí)間和大小 package main import ( "fmt" "io" "io/ioutil" "net/http" "os" "strings" "time" ) func main() { start := time.Now() ch := make(chan string) for _, url := range os.Args[1:] { url = checkUrl(url) go fetch(url, ch) } for range os.Args[1:] { fmt.Println(<-ch) } fmt.Printf("總耗時(shí): %.2fs\n", time.Since(start).Seconds()) } func fetch(url string, ch chan<- string) { start := time.Now() resp, err := http.Get(url) if err != nil { ch <- fmt.Sprintf("get %s error: %v", url, err) return } nbytes, err := io.Copy(ioutil.Discard, resp.Body) resp.Body.Close() // 不要泄露資源 if err != nil { ch <- fmt.Sprintf("reading %s error: %v", url, err) return } secs := time.Since(start).Seconds() ch <- fmt.Sprintf("%.2fs %7d %s", secs, nbytes, url) } func checkUrl(s string) string { if strings.HasPrefix(s, "http") { return s } return fmt.Sprint("http://", s) }
io.Copy 函數(shù)讀取響應(yīng)的內(nèi)容,然后通過寫入 ioutil.Discard 輸出流進(jìn)行丟棄。Copy 返回字節(jié)數(shù)以及出現(xiàn)的任何錯(cuò)誤。只所以要寫入 ioutil.Discard 來丟棄,這樣就會(huì)有一個(gè)讀取的過程,可以獲取返回的字節(jié)數(shù)。
這章開始,介紹Go語言中函數(shù)的一些特性,用到的都是網(wǎng)絡(luò)爬蟲相關(guān)的示例。
函數(shù)可以遞歸調(diào)用,這意味著函數(shù)可以直接或者間接地調(diào)用自己。遞歸是一種實(shí)用的技術(shù),可以處理許多帶有遞歸特性的數(shù)據(jù)結(jié)構(gòu)。下面就會(huì)使用遞歸處理HTML文件。
下面的代碼示例使用了 golang.org/x/net/html 包。它提供了解析 HTML 的功能。下面會(huì)用到 golang.org/x/net/html API 如下的一些代碼。函數(shù) html.Parse 讀入一段字節(jié)序列,解析它們,然后返回 HTML 文檔樹的根節(jié)點(diǎn) html.Node。這里可以只關(guān)注函數(shù)簽名的部分,函數(shù)內(nèi)部實(shí)現(xiàn)細(xì)節(jié)就先了解到上面文字說明的部分。HTML 有多種節(jié)點(diǎn),比如文本、注釋等。這里只關(guān)注 a 標(biāo)簽和里面的 href 的值:
// golang.org/x/net/html package html // A NodeType is the type of a Node. type NodeType uint32 const ( ErrorNode NodeType = iota TextNode DocumentNode ElementNode CommentNode DoctypeNode scopeMarkerNode ) type Node struct { Parent, FirstChild, LastChild, PrevSibling, NextSibling *Node Type NodeType DataAtom atom.Atom Data string Namespace string Attr []Attribute } type Attribute struct { Namespace, Key, Val string } func Parse(r io.Reader) (*Node, error) { p := &parser{ tokenizer: NewTokenizer(r), doc: &Node{ Type: DocumentNode, }, scripting: true, framesetOK: true, im: initialIM, } err := p.parse() if err != nil { return nil, err } return p.doc, nil }
主函數(shù)從標(biāo)準(zhǔn)輸入中讀入 HTML,使用遞歸的 visit 函數(shù)獲取 HTML 文本的超鏈接,并且把所有的超鏈接輸出。
下面的 visit 函數(shù)遍歷 HTML 樹上的所有節(jié)點(diǎn),從 HTML 的 a 標(biāo)簽中得到 href 屬性的內(nèi)容,將獲取到的鏈接內(nèi)容添加到字符串切片中,最后再返回這個(gè)切片:
// 輸出從標(biāo)準(zhǔn)輸入中讀取的 HTML 文檔中的所有連接 package main import ( "fmt" "os" "golang.org/x/net/html" ) func main() { doc, err := html.Parse(os.Stdin) if err != nil { fmt.Fprintf(os.Stderr, "findlinks1: %v\n", err) os.Exit(1) } for _, link := range visit(nil, doc) { fmt.Println(link) } } // 將節(jié)點(diǎn) n 中的每個(gè)鏈接添加到結(jié)果中 func visit(links []string, n *html.Node) []string { if n.Type == html.ElementNode && n.Data == "a" { for _, a := range n.Attr { if a.Key == "href" { links = append(links, a.Val) } } } for c := n.FirstChild; c != nil; c = c.NextSibling { links = visit(links, c) } return links }
要對(duì)樹中的任意節(jié)點(diǎn) n 進(jìn)行遞歸,visit 遞歸地調(diào)用自己去訪問節(jié)點(diǎn) n 的所有子節(jié)點(diǎn),并且將訪問過的節(jié)點(diǎn)保存在 FirstChild 鏈表中。
分別將兩個(gè)程序編譯后,使用管道將 fetch 程序的輸出定向到 findlinks1。編譯后執(zhí)行:
PS H:\Go\src\gopl\output\http> go build gopl/output/http/fetch PS H:\Go\src\gopl\output\http> go build gopl/output/http/findlinks1 PS H:\Go\src\gopl\output\http> ./fetch studygolang.com | ./findlinks1 /readings?rtype=1 /dl # http://docs.studygolang.com http://docscn.studygolang.com /pkgdoc http://tour.studygolang.com /account/register /account/login /?tab=all /?tab=hot https://e.coding.net/?utm_source=studygolang
這是另一個(gè)版本,把 fetch 和 findLinks 合并到一起了,F(xiàn)indLInks函數(shù)自己發(fā)送 HTTP 請(qǐng)求。最后還對(duì) visit 進(jìn)行了修改,現(xiàn)在使用遞歸調(diào)用 visit (而不是循環(huán))遍歷 n.FirstChild 鏈表:
package main import ( "fmt" "net/http" "os" "golang.org/x/net/html" ) func main() { for _, url := range os.Args[1:] { links, err := findLinks(url) if err != nil { fmt.Fprintf(os.Stderr, "findlink2: %v\n", err) continue } for _, link := range links { fmt.Println(link) } } } // 發(fā)起一個(gè)HTTP的GET請(qǐng)求,解析返回的HTML頁面,并返回所有的鏈接 func findLinks(url string) ([]string, error) { resp, err := http.Get(url) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("getting %s: %s\n", url, resp.Status) } doc, err := html.Parse(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("parsing %s as HTML: %v\n", url, err) } return visit(nil, doc), nil } // 將節(jié)點(diǎn) n 中的每個(gè)鏈接添加到結(jié)果中 func visit(links []string, n *html.Node) []string { if n == nil { return links } if n.Type == html.ElementNode && n.Data == "a" { for _, a := range n.Attr { if a.Key == "href" { links = append(links, a.Val) } } } // 可怕的遞歸,非常不好理解。 return visit(visit(links, n.FirstChild), n.NextSibling) }
findLinks 函數(shù)有4個(gè)返回語句:
第一個(gè)返回語句中,錯(cuò)誤直接返回
后兩個(gè)返回語句則使用 fmt.Errorf 格式化處理過的附加上下文信息
如果函數(shù)調(diào)用成功,最后一個(gè)返回語句返回字符串切片,且 error 為空
關(guān)閉 resp.Body
這里必須保證 resp.Body 正確關(guān)閉使得網(wǎng)絡(luò)資源正常釋放。即使在發(fā)生錯(cuò)誤的情況下也必須釋放資源。
Go 語言的垃圾回收機(jī)制將回收未使用的內(nèi)存,但不能指望它會(huì)釋放未使用的操作系統(tǒng)資源,比如打開的文件以及網(wǎng)絡(luò)連接。必須顯示地關(guān)閉它們。
下面的程序使用遞歸遍歷所有 HTML 文本中的節(jié)點(diǎn)數(shù),并輸出樹的結(jié)構(gòu)。當(dāng)遞歸遇到每個(gè)元素時(shí),它都會(huì)講元素標(biāo)簽壓入棧,然后輸出棧:
package main import ( "fmt" "os" "golang.org/x/net/html" ) func main() { doc, err := html.Parse(os.Stdin) if err != nil { fmt.Fprintf(os.Stderr, "outline: %v\n", err) os.Exit(1) } outline(nil, doc) } func outline(stack []string, n *html.Node) { if n.Type == html.ElementNode { stack = append(stack, n.Data) // 把標(biāo)簽壓入棧 fmt.Println(stack) } for c := n.FirstChild; c != nil; c = c.NextSibling { outline(stack, c) } }
注意一個(gè)細(xì)節(jié),盡管 outline 會(huì)將元素壓棧但并不會(huì)出棧。當(dāng) outline 遞歸調(diào)用自己時(shí),被調(diào)用的函數(shù)會(huì)接收到棧的副本。盡管被調(diào)用者可能會(huì)對(duì)棧(切片類型)進(jìn)行元素的添加、修改甚至創(chuàng)建新數(shù)組的操作,但它并不會(huì)修改調(diào)用者原來傳遞的元素,所以當(dāng)被調(diào)用函數(shù)返回時(shí),調(diào)用者的棧依舊保持原樣。
現(xiàn)在可以找一些網(wǎng)頁輸出:
PS H:\Go\src\gopl\output\http> ./fetch baidu.com | ./outline [html] [html head] [html head meta] [html body] PS H:\Go\src\gopl\output\http>
許多編程語言使用固定長(zhǎng)度的函數(shù)調(diào)用棧,大小在 64KB 到 2MB 之間。遞歸的深度會(huì)受限于固定長(zhǎng)度的棧大小,所以當(dāng)進(jìn)行深度調(diào)用時(shí)必須謹(jǐn)防棧溢出。固定長(zhǎng)度的棧甚至?xí)斐梢欢ǖ陌踩[患。相比固定長(zhǎng)度的棧,Go 語言的實(shí)現(xiàn)使用了可變長(zhǎng)度的棧,棧的大小會(huì)隨著使用而增長(zhǎng),可達(dá)到 1GB 左右的上限。這使得我們可以安全地使用遞歸而不用擔(dān)心溢出的問題。
這里再換一個(gè)實(shí)現(xiàn)方式,使用函數(shù)變量??梢詫⒚總€(gè)節(jié)點(diǎn)的操作邏輯從遍歷樹形結(jié)構(gòu)的邏輯中分開。這次不重用 fetch 程序了,全部寫在一起了:
package main import ( "fmt" "net/http" "os" "golang.org/x/net/html" ) func main() { for _, url := range os.Args[1:] { outline(url) } } func outline(url string) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() doc, err := html.Parse(resp.Body) if err != nil { return err } forEachNode(doc, startElement, endElement) return nil } // 調(diào)用 pre(x) 和 post(x) 遍歷以n為根的樹中的每個(gè)節(jié)點(diǎn)x // 兩個(gè)函數(shù)都是可選的 // pre 在子節(jié)點(diǎn)被訪問前調(diào)用(前序調(diào)用) // post 在訪問后調(diào)用(后續(xù)調(diào)用) func forEachNode(n *html.Node, pre, post func(n *html.Node)) { if pre != nil { pre(n) } for c := n.FirstChild; c != nil; c = c.NextSibling { forEachNode(c, pre, post) } if post != nil { post(n) } } var depth int func startElement(n *html.Node) { if n.Type == html.ElementNode { fmt.Printf("%*s<%s>\n", depth*2, "", n.Data) depth++ } } func endElement(n *html.Node) { if n.Type == html.ElementNode { depth-- fmt.Printf("%*s</%s>\n", depth*2, "", n.Data) } }
這里的 forEachNode 函數(shù)接受兩個(gè)函數(shù)作為參數(shù),一個(gè)在本節(jié)點(diǎn)訪問子節(jié)點(diǎn)前調(diào)用,另一個(gè)在所有子節(jié)點(diǎn)都訪問后調(diào)用。這樣的代碼組織給調(diào)用者提供了很多的靈活性。
這里還巧妙的利用了 fmt 的縮進(jìn)輸出。%*s 中的 * 號(hào)輸出帶有可變數(shù)量空格的字符串。輸出的寬度和字符串由后面的兩個(gè)參數(shù)確定,這里只需要輸出空格,字符串用的是空字符串。
這次輸出的是要縮進(jìn)效果的結(jié)構(gòu):
PS H:\Go\src\gopl\ch6\outline2> go run main.go http://baidu.com <html> <head> <meta> </meta> </head> <body> </body> </html> PS H:\Go\src\gopl\ch6\outline2>
下面示例的兩個(gè)功能,建議通過延遲函數(shù)調(diào)用 defer 來實(shí)現(xiàn)。
直接使用 http.Get 請(qǐng)求返回的數(shù)據(jù),如果請(qǐng)求的 URL 是 HTML 那么一定會(huì)正常的工作,但是許多頁面包含圖片、文字和其他文件格式。如果讓 HTML 解析器去解析這類文件可能會(huì)發(fā)生意料外的狀況。這就需要首先判斷Get請(qǐng)求返回的是一個(gè)HTML頁面,通過返回的響應(yīng)頭的Content-Type來判斷。一般是:Content-Type: text/html; charset=utf-8。然后才是解析HTML的標(biāo)簽獲取title標(biāo)簽的內(nèi)容:
package main import ( "fmt" "net/http" "os" "strings" "golang.org/x/net/html" ) func forEachNode(n *html.Node, pre, post func(n *html.Node)) { if pre != nil { pre(n) } for c := n.FirstChild; c != nil; c = c.NextSibling { forEachNode(c, pre, post) } if post != nil { post(n) } } func title(url string) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() // 檢查返回的頁面是HTML通過判斷Content-Type,比如:Content-Type: text/html; charset=utf-8 ct := resp.Header.Get("Content-Type") if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") { return fmt.Errorf("%s has type %s, not text/html", url, ct) } doc, err := html.Parse(resp.Body) if err != nil { return fmt.Errorf("parseing %s as HTML: %v", url, err) } visitNode := func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil { fmt.Println(n.FirstChild.Data) } } forEachNode(doc, visitNode, nil) return nil } func main() { for _, url := range os.Args[1:] { err := title(url) if err != nil { fmt.Fprintf(os.Stderr, "get url: %v\n", err) } } }
使用Get請(qǐng)求一個(gè)頁面,然后保存到本地的文件中。使用 path.Base 函數(shù)獲得 URL 路徑最后一個(gè)組成部分作為文件名:
package main import ( "fmt" "io" "net/http" "os" "path" ) func fetch(url string) (filename string, n int64, err error) { resp, err := http.Get(url) if err != nil { return "", 0, err } defer resp.Body.Close() local := path.Base(resp.Request.URL.Path) if local == "/" { local = "index.html" } f, err := os.Create(local) if err != nil { return "", 0, err } n, err = io.Copy(f, resp.Body) // 關(guān)閉文件,并保留錯(cuò)誤消息 if closeErr := f.Close(); err == nil { // 如果 io.Copy 返回的 err 為空,才會(huì)報(bào)告 closeErr 的錯(cuò)誤 err = closeErr } return local, n, err } func main() { for _, url := range os.Args[1:] { local, n, err := fetch(url) if err != nil { fmt.Fprintf(os.Stderr, "fetch %s: %v\n", url, err) continue } fmt.Fprintf(os.Stderr, "%s => %s (%d bytes).\n", url, local, n) } }
示例中的 fetch 函數(shù)中,會(huì) os.Create 打開一個(gè)文件。但是如果使用延遲調(diào)用 f.Close 去關(guān)閉一個(gè)本地文件就會(huì)有些問題,因?yàn)?os.Create 打開了一個(gè)文件對(duì)其進(jìn)行寫入、創(chuàng)建。在許多文件系統(tǒng)中尤其是NFS,寫錯(cuò)誤往往不是立即返回而是推遲到文件關(guān)閉的時(shí)候。如果無法檢查關(guān)閉操作的結(jié)果,就會(huì)導(dǎo)致一系列的數(shù)據(jù)丟失。然后,如果 io.Copy 和 f.Close 同時(shí)失敗,我們更傾向于報(bào)告 io.Copy 的錯(cuò)誤,因?yàn)樗l(fā)生在前,更有可能記錄失敗的原因。示例中的最后一個(gè)錯(cuò)誤處理就是這樣的處理邏輯。
優(yōu)化defer的位置
在打開文件之后,在處理完打開的錯(cuò)誤之后就應(yīng)該立即寫上 defer 語句,保證 defer 語句和文件打開的操作成對(duì)出現(xiàn)。但是這里還插入了一條 io.Copy 的語句,在這個(gè)例子里只是一條語句,也有可能是一段代碼,這樣就會(huì)讓 defer 變得不那么清晰了。這里利用 defer 可以改變函數(shù)返回值的特性,將 defer 移動(dòng)到 io.Copy 執(zhí)行之前,又能夠保證返回的錯(cuò)誤值 err 可以記錄下 io.Copy 執(zhí)行后可能的錯(cuò)誤。
在原有代碼的基礎(chǔ)上,在 defer 中只需要把 if 的代碼塊封裝到匿名函數(shù)中就可以了:
f, err := os.Create(local) if err != nil { return "", 0, err } defer func() { if closeErr := f.Close(); err == nil { err = closeErr } }() n, err = io.Copy(f, resp.Body) return local, n, err
這里的做法就是在 defer 中改變返回給調(diào)用者的結(jié)果。
網(wǎng)絡(luò)爬蟲的遍歷。
在之前遍歷節(jié)點(diǎn)樹的基礎(chǔ)上,這次來獲取頁面中所有的鏈接。將之前的 visit 函數(shù)替換為匿名函數(shù)(閉包),現(xiàn)在可以直接在匿名函數(shù)里把找到的鏈接添加到 links 切片中,這樣的改變之后,邏輯上更加清晰也更好理解了。因?yàn)?Extract 函數(shù)只需要前序調(diào)用,這里就把 post 部分的參數(shù)值傳nil。這里做成一個(gè)包,后面要繼續(xù)使用:
// 提供解析連接的函數(shù) package links import ( "fmt" "net/http" "golang.org/x/net/html" ) // 向給定的URL發(fā)起HTTP GET 請(qǐng)求 // 解析HTML并返回HTML文檔中存在的鏈接 func Extract(url string) ([]string, error) { resp, err := http.Get(url) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("get %s: %s", url, resp.Status) } doc, err := html.Parse(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("parse %s: %s", url, err) } var links []string visitNode := func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "a" { for _, a := range n.Attr { if a.Key != "href" { continue } link, err := resp.Request.URL.Parse(a.Val) if err != nil { continue // 忽略不合法的URL } links = append(links, link.String()) } } } forEachNode(doc, visitNode, nil) // 只要前序遍歷,后續(xù)不執(zhí)行,傳nil return links, nil } func forEachNode(n *html.Node, pre, post func(n *html.Node)) { if pre != nil { pre(n) } for c := n.FirstChild; c != nil; c = c.NextSibling { forEachNode(c, pre, post) } if post != nil { post(n) } } /* 使用時(shí)寫的函數(shù) func main() { url := "https://baidu.com" urls, err := Extract(url) if err != nil { // 錯(cuò)誤處理隨便寫寫,不引入新的包 fmt.Printf("extract: %v\n", err) return } for n, u := range urls { fmt.Printf("%2d: %s\n", n, u) } } */
解析URL成為絕對(duì)路徑
這里不直接把href原封不動(dòng)地添加到切片中,而將它解析成基于當(dāng)前文檔的相對(duì)路徑 resp.Request.URL。結(jié)果的鏈接是絕對(duì)路徑的形式,這樣就可以直接用 http.Get 繼續(xù)調(diào)用。
網(wǎng)頁爬蟲的核心是解決圖的遍歷,使用遞歸的方法可以實(shí)現(xiàn)深度優(yōu)先遍歷。對(duì)于網(wǎng)絡(luò)爬蟲,需要廣度優(yōu)先遍歷。另外還可以進(jìn)行并發(fā)遍歷,這里不講這個(gè)。
下面的示例函數(shù)展示了廣度優(yōu)先遍歷的精髓。調(diào)用者提供一個(gè)初始列表 worklist,它包含要訪問的項(xiàng)和一個(gè)函數(shù)變量 f 用來處理每一個(gè)項(xiàng)。每一個(gè)項(xiàng)有字符串來識(shí)別。函數(shù) f 將返回一個(gè)新的列表,其中包含需要新添加到 worklist 中的項(xiàng)。breadthFirst 函數(shù)將在所有節(jié)點(diǎn)項(xiàng)都被訪問后返回。它需要維護(hù)一個(gè)字符串集合來保證每個(gè)節(jié)點(diǎn)只訪問一次。
在爬蟲里,每一項(xiàng)節(jié)點(diǎn)都是 URL。這里需要提供一個(gè) crawl 函數(shù)傳給 breadthFirst 函數(shù)最為f的值,用來輸出URL,然后解析鏈接并返回:
package main import ( "fmt" "log" "os" "gopl/ch6/links" ) // 對(duì)每一個(gè)worklist中的元素調(diào)用f // 并將返回的內(nèi)容添加到worklist中,對(duì)每一個(gè)元素,最多調(diào)用一次f func breadthFirst(f func(item string) []string, worklist []string) { seen := make(map[string]bool) for len(worklist) > 0 { items := worklist worklist = nil for _, item := range items { if !seen[item] { seen[item] = true worklist = append(worklist, f(item)...) } } } } func crawl(url string) []string { fmt.Println(url) list, err := links.Extract(url) if err != nil { log.Print(err) } return list } func main() { // 開始廣度遍歷 // 從命令行參數(shù)開始 breadthFirst(crawl, os.Args[1:]) }
接下來就是找一個(gè)網(wǎng)頁來測(cè)試,下面是一些輸出的鏈接:
PS H:\Go\src\gopl\ch6\findlinks3> go run main.go http://lab.scrapyd.cn/ http://lab.scrapyd.cn/ http://lab.scrapyd.cn/archives/57.html http://lab.scrapyd.cn/tag/%E8%89%BA%E6%9C%AF/ http://lab.scrapyd.cn/tag/%E5%90%8D%E7%94%BB/ http://lab.scrapyd.cn/archives/55.html http://lab.scrapyd.cn/archives/29.html http://lab.scrapyd.cn/tag/%E6%9C%A8%E5%BF%83/ http://lab.scrapyd.cn/archives/28.html http://lab.scrapyd.cn/tag/%E6%B3%B0%E6%88%88%E5%B0%94/ http://lab.scrapyd.cn/tag/%E7%94%9F%E6%B4%BB/ http://lab.scrapyd.cn/archives/27.html ......
整個(gè)過程將在所有可達(dá)的網(wǎng)頁被訪問到或者內(nèi)存耗盡時(shí)結(jié)束。
接下來是并發(fā)編程,使上面的搜索連接的程序可以并發(fā)運(yùn)行。這樣對(duì) crawl 的獨(dú)立調(diào)用可以充分利用 Web 上的 I/O 并行機(jī)制。
crawl 函數(shù)依然還是之前的那個(gè)函數(shù)不需要修改。而下面的 main 函數(shù)類似于原來的 breadthFirst 函數(shù)。這里也像之前一樣,用一個(gè)任務(wù)類別記錄需要處理的條目隊(duì)列,每一個(gè)條目是一個(gè)待爬取的 URL 列表,這次使用通道代替切片來表示隊(duì)列。每一次對(duì) crawl 的調(diào)用發(fā)生在它自己的 goroutine 中,然后將發(fā)現(xiàn)的鏈接發(fā)送回任務(wù)列表:
package main import ( "fmt" "log" "os" "gopl/ch6/links" ) func crawl(url string) []string { fmt.Println(url) list, err := links.Extract(url) if err != nil { log.Print(err) } return list } func main() { worklist := make(chan []string) // 從命令行參數(shù)開始 go func() { worklist <- os.Args[1:] }() // 并發(fā)爬取 Web seen := make(map[string]bool) for list := range worklist { for _, link := range list { if !seen[link] { seen[link] = true go func(link string) { worklist <- crawl(link) }(link) } } } }
注意,這里爬取的 goroutine 將 link 作為顯式參數(shù)來使用,以避免捕獲迭代變量的問題。還要注意,發(fā)送給任務(wù)列表的命令行參數(shù)必須在它自己的 goroutine 中運(yùn)行來避免死鎖。另一個(gè)可選的方案是使用緩沖通道。
現(xiàn)在這個(gè)爬蟲高度并發(fā),比原來輸出的效果更高了,但是它有兩個(gè)問題。先看第一個(gè)問題,它在執(zhí)行一段時(shí)間后會(huì)出現(xiàn)大量錯(cuò)誤日志,過一會(huì)后會(huì)恢復(fù),再之后又出現(xiàn)錯(cuò)誤日志,如此往復(fù)。主要是因?yàn)槌绦蛲瑫r(shí)創(chuàng)建了太多的網(wǎng)絡(luò)連接,超過了程序能打開文件數(shù)的限制。
程序的并行度太高了,無限制的并行通常不是一個(gè)好主要,因?yàn)橄到y(tǒng)中總有限制因素,例如,對(duì)于計(jì)算型應(yīng)用 CPU 的核數(shù),對(duì)于磁盤 I/O 操作磁頭和磁盤的個(gè)數(shù),下載流所使用的網(wǎng)絡(luò)帶寬,或者 Web 服務(wù)本身的容量。解決方法是根據(jù)資源可用情況限制并發(fā)的個(gè)數(shù),以匹配合適的并行度。該例子中有一個(gè)簡(jiǎn)單的辦法是確保對(duì)于 link.Extract 的同時(shí)調(diào)用不超過 n 個(gè),這里的 n 一般小于文件描述符的上限值。
這里可以使用一個(gè)容量為 n 的緩沖通道來建立一個(gè)并發(fā)原語,稱為計(jì)數(shù)信號(hào)量。概念上,對(duì)于緩沖通道中的 n 個(gè)空閑槽,每一個(gè)代表一個(gè)令牌,持有者可以執(zhí)行。通過發(fā)送一個(gè)值到通道中來領(lǐng)取令牌,從通道中接收一個(gè)值來釋放令牌。這里的做法和直觀的理解是反的,盡管使用已填充槽更直觀,但使用空閑槽在創(chuàng)建的通道緩沖區(qū)之后可以省掉填充的過程,并且這里的令牌不攜帶任何信息,通道內(nèi)的元素類型不重要。所以通道內(nèi)的元素就使用 struct{},它所占用的空間大小是0。
重寫 crawl 函數(shù),使用令牌的獲取和釋放操作限制對(duì) links.Extract 函數(shù)的調(diào)用,這里保證最多同時(shí)20個(gè)調(diào)用可以進(jìn)行。保持信號(hào)量操作離它約束的 I/O 操作越近越好,這是一個(gè)好的實(shí)踐:
// 令牌 tokens 是一個(gè)計(jì)數(shù)信號(hào)量 // 確保并發(fā)請(qǐng)求限制在 20 個(gè)以內(nèi) var tokens = make(chan struct{}, 20) func crawl(url string) []string { fmt.Println(url) tokens <- struct{}{} // 獲取令牌 list, err := links.Extract(url) <- tokens // 釋放令牌 if err != nil { log.Print(err) } return list }
現(xiàn)在來處理第二個(gè)問題,這個(gè)程序永遠(yuǎn)不會(huì)結(jié)束。雖然可能爬不完所有的鏈接,也就注意不到這個(gè)問題。為了讓程序終止,當(dāng)任務(wù)列表為空并且爬取 goroutine 都結(jié)束以后,需要從主循環(huán)退出:
func main() { worklist := make(chan []string) var n int // 等待發(fā)送到任務(wù)列表的數(shù)量 // 從命令行參數(shù)開始 n++ go func() { worklist <- os.Args[1:] }() // 并發(fā)爬取 Web seen := make(map[string]bool) for ; n > 0; n-- { list := <- worklist for _, link := range list { if !seen[link] { seen[link] = true n++ go func(link string) { worklist <- crawl(link) }(link) } } } }
在這個(gè)版本中,計(jì)數(shù)器 n 跟蹤發(fā)送到任務(wù)列表中的任務(wù)個(gè)數(shù)。在每次將一組條目發(fā)送到任務(wù)列表前,就遞增變量 n。在主循環(huán)中沒處理一個(gè) worklist 后就遞減1,見減到0表示再?zèng)]有任務(wù)了,于是可以正常退出。
之前的版本,使用 range 遍歷通道,只要通道關(guān)閉,也是可以退出循環(huán)的。但是這里沒有一個(gè)地方可以確認(rèn)再?zèng)]有任務(wù)需要添加了從而加上一句關(guān)閉通道的close語句。所以需要一個(gè)計(jì)數(shù)器 n 來記錄還有多少個(gè)任務(wù)等待 worklist 處理。
現(xiàn)在,并發(fā)爬蟲的速度大約比之前快了20倍,應(yīng)該不會(huì)出現(xiàn)錯(cuò)誤,并且能夠正確退出。
這里還有一個(gè)替代方案,解決過度并發(fā)的問題。這個(gè)版本使用最初的 crawl 函數(shù),它沒有技術(shù)信號(hào)量,但是通過20個(gè)長(zhǎng)期存活的爬蟲 goroutine 來調(diào)用它,這樣也保證了最多20個(gè)HTTP請(qǐng)求并發(fā)執(zhí)行:
func main() { worklist := make(chan []string) // 可能有重復(fù)的URL列表 unseenLinks := make(chan string) // 去重后的eURL列表 // 向任務(wù)列表中添加命令行參數(shù) go func() { worklist <- os.Args[1:] }() // 創(chuàng)建20個(gè)爬蟲 goroutine 來獲取每個(gè)不可見鏈接 for i := 0; i < 20; i++ { go func() { for link := range unseenLinks { foundLinks := crawl(link) go func() { worklist <- foundLinks }() } }() } // 主 goroutine 對(duì) URL 列表進(jìn)行去重 // 并把沒有爬取過的條目發(fā)送給爬蟲程序 seen := make(map[string]bool) for list := range worklist { for _, link := range list { if !seen[link] { seen[link] = true unseenLinks <- link } } } }
爬取 goroutine 使用同一個(gè)通道 unseenLinks 接收要爬取的URL,主 goroutine 負(fù)責(zé)對(duì)從任務(wù)列表接收到的條目進(jìn)行去重,然后發(fā)送每一個(gè)沒有爬取過的條目到 unseenLinks 通道,之后被爬取 goroutine 接收。
crawl 發(fā)現(xiàn)的每組鏈接,通過精心設(shè)計(jì)的 goroutine 發(fā)送到任務(wù)列表來避免死鎖。
這里例子目前也沒有解決程序退出的問題,并且不能簡(jiǎn)單的參考之前的做法使用計(jì)數(shù)器 n 來進(jìn)行計(jì)數(shù)。上個(gè)版本中,計(jì)數(shù)器 n 都是在主 goroutine 進(jìn)行操作的,這里也是可以繼續(xù)用這個(gè)方法來計(jì)數(shù)判斷程序是否退出,但是在不同的 goroutine 中操作計(jì)數(shù)器時(shí),就需要考慮并發(fā)安全的問題。要使用并發(fā)安全的計(jì)數(shù)器 sycn.WaitGroup,具體實(shí)現(xiàn)略。
回到使用令牌并能正確退出的方案。雖然有結(jié)束后退出的邏輯,但是一般情況下,一個(gè)網(wǎng)站總用無限個(gè)鏈接,永遠(yuǎn)爬取不完?,F(xiàn)在再增加一個(gè)功能,深度限制:如果用戶設(shè)置 -depth=3,那么僅最多通過三個(gè)鏈接可達(dá)的 URL 能被找到。另外還增加了一個(gè)功能,統(tǒng)計(jì)總共爬取的頁面的數(shù)量。現(xiàn)在每次打印 URL 的時(shí)候,都會(huì)加上深度和序號(hào)。
先說簡(jiǎn)單的,頁面計(jì)數(shù)的功能。就是要一個(gè)計(jì)數(shù)器,但是需要并發(fā)在不同的 goroutine 里操作,所以要考慮并發(fā)安全。通過通道就能實(shí)現(xiàn),在主 goroutine 中單獨(dú)再用一個(gè) goroutine 負(fù)責(zé)計(jì)數(shù)器的自增:
var count = make(chan int) // 統(tǒng)計(jì)一共爬取了多個(gè)頁面 func main() { // 負(fù)責(zé) count 值自增的 goroutine go func() { var i int for { i++ count <- i } }() flag.Parse() // 省略主函數(shù)中的其他內(nèi)容 } func crawl(url string, depth int) urllist { fmt.Println(depth, <-count, url) tokens <- struct{}{} // 獲取令牌 list, err := links.Extract(url) <-tokens // 釋放令牌 if err != nil { log.Print(err) } return urllist{list, depth + 1} }
然后是深度限制的核心功能。首先要為 worklist 添加深度的信息,把原本的字符串切片加上深度信息組成一個(gè)結(jié)構(gòu)體作為 worklist 的元素:
type urllist struct { urls []string depth int }
現(xiàn)在爬取頁面后先把返回的信息暫存在 nextList 中,而不是直接添加到 worklist。檢查 nextList 中的深度,如果符合深度限制,就向 worklist 添加,并且要增加 n 計(jì)數(shù)器。如果超出深度限制,就什么也不做。原本主函數(shù)的 for 循環(huán)里的每一個(gè) goroutine 都會(huì)增加 n 計(jì)數(shù)器,所以計(jì)數(shù)器的自增是在主函數(shù)里完成的?,F(xiàn)在需要在每一個(gè) goroutine 中判斷是否要對(duì)計(jì)數(shù)器進(jìn)行自增,所以這里要把計(jì)數(shù)器換成并發(fā)安全的 sync.WaitGroup 然后可以在每個(gè) goroutine 里來安全的操作計(jì)數(shù)器。這里要防止計(jì)數(shù)器過早的被減到0,不過邏輯還算簡(jiǎn)單,就是在向 worlist 添加元素之前進(jìn)行加1操作。
然后 n 計(jì)數(shù)器的減1的操作要上更加復(fù)雜。需要在 worklist 里的一組 URL 全部操作完之后,才能把 n 計(jì)數(shù)器減1,這就需要再引入一個(gè)計(jì)數(shù)器 n2。只有等計(jì)數(shù)器 n2 歸0后,才能將計(jì)數(shù)器 n 減1。這里還要防止程序卡死。向 worklist 添加元素額操作會(huì)一直阻塞,直到主函數(shù) for 循環(huán)的下一次迭代時(shí)從 worklist 接收數(shù)據(jù)位置。所以要仔細(xì)考慮每個(gè)操作的正確順序,具體還是看代碼吧:
package main import ( "flag" "fmt" "log" "sync" "gopl/ch6/links" ) var count = make(chan int) // 統(tǒng)計(jì)一共爬取了多個(gè)頁面 // 令牌 tokens 是一個(gè)計(jì)數(shù)信號(hào)量 // 確保并發(fā)請(qǐng)求限制在 20 個(gè)以內(nèi) var tokens = make(chan struct{}, 20) func crawl(url string, depth int) urllist { fmt.Println(depth, <-count, url) tokens <- struct{}{} // 獲取令牌 list, err := links.Extract(url) <-tokens // 釋放令牌 if err != nil { log.Print(err) } return urllist{list, depth + 1} } var depth int func init() { flag.IntVar(&depth, "depth", -1, "深度限制") // 小于0就是不限制遞歸深度,0就是只爬取當(dāng)前頁面 } type urllist struct { urls []string depth int } func main() { // 負(fù)責(zé) count 值自增的 goroutine go func() { var i int for { i++ count <- i } }() flag.Parse() worklist := make(chan urllist) // 等待發(fā)送到任務(wù)列表的數(shù)量 // 因?yàn)樾枰?nbsp;goroutine 里修改,需要換成并發(fā)安全的計(jì)數(shù)器 var n sync.WaitGroup starturls := flag.Args() if len(flag.Args()) == 0 { starturls = []string{"http://lab.scrapyd.cn/"} } // 從命令行參數(shù)開始 n.Add(1) go func() { worklist <- urllist{starturls, 0} }() // 等待全部worklist處理完,就關(guān)閉worklist go func() { n.Wait() close(worklist) }() // 并發(fā)爬取 Web seen := make(map[string]bool) for list := range worklist { // 處理完一個(gè)worklist后才能讓 n 計(jì)數(shù)器減1 // 而處理 worklist 又是很多個(gè) goroutine,所以需要再用一個(gè)計(jì)數(shù)器 var n2 sync.WaitGroup for _, link := range list.urls { if !seen[link] { seen[link] = true n2.Add(1) go func(url string, listDepth int) { nextList := crawl(url, listDepth) // 如果 depth>0 說明有深度限制 // 如果當(dāng)前的深度已經(jīng)達(dá)到(或超過)深度限制,則爬取完這個(gè)連接后,不需要再繼續(xù)爬取,直接返回 if depth >= 0 && listDepth >= depth { // 超出遞歸深度的頁面,在爬取完之后,也輸出 URL // for _, nextUrl := range nextList.urls { // fmt.Println(nextList.depth, "stop", nextUrl) // } n2.Done() // 所有退出的情況都要減計(jì)數(shù)器n2的值,但是一定要在向通道發(fā)送之前 return } n.Add(1) // 添加任務(wù)前,計(jì)數(shù)加1 n2.Done() // 先確保計(jì)數(shù)器n加1了,再減計(jì)數(shù)器n2的值 worklist <- nextList // 新的任務(wù)加入管道必須在最后,之后再一次for循環(huán)迭代的時(shí)候,才會(huì)接收這個(gè)值 }(link, list.depth) } } n2.Wait() n.Done() // 把計(jì)數(shù)器的操作也放到 goroutine 中,這樣可以繼續(xù)下一次 for 循環(huán)的迭代 // go func() { // n2.Wait() // n.Done() // }() } }
主函數(shù) for 循環(huán)最后對(duì)計(jì)數(shù)器 n 和 n2 的操作,也是可以放到一個(gè) goroutine 里的。現(xiàn)在會(huì)在 for 循環(huán)每次迭代的時(shí)候,等待直到一個(gè) worklist 全部處理完畢后,才會(huì)處理下一個(gè) worklist。所以這部分的邏輯還是串行的,不個(gè)這樣方面確認(rèn)程序的正確性。之后可以結(jié)單修改一下,也放到一個(gè) goroutine 中處理,讓 for 循環(huán)可以繼續(xù)迭代:
go func() { n2.Wait() n.Done() }()
最后測(cè)試程序的還有一個(gè)困擾的問題。不過仔細(xì)檢查之后,其實(shí)并不是問題。就是程序在所有的 URL 輸出之后,還會(huì)等待比較長(zhǎng)的一段時(shí)間才會(huì)退出。一個(gè)真正的爬蟲,不是要輸出 URL 而是要爬取頁面。程序是在每次準(zhǔn)備爬取頁面之前,先將頁面的 URL 打印輸出,然后去爬取并解析頁面的內(nèi)容。全部 URL 輸出完,程序退出之前,這段沒有任何輸出的時(shí)間里,就是在對(duì)剩余的頁面進(jìn)行爬取。原本爬完之后,檢查到深度超過限制就不會(huì)做任何操作。這里可以在檢查后,把返回的所有連接的 URL 和深度也進(jìn)行輸出。這段代碼已經(jīng)寫在例子中但是被注釋掉了,放開后,就能看到更多的輸出內(nèi)容,確認(rèn)退出前的這段時(shí)間里,程序依然在正確的執(zhí)行。
繼續(xù)添加功能,這次在任務(wù)開始后,可以通過鍵盤輸入,來終止任務(wù)。這類操作還是比較常見的,下面應(yīng)該是一種比較通用的做法。這里還包括一些額外的技巧的講解。
首先了解一下取消操作為什么需要一個(gè)廣播的機(jī)制,以及利用通道關(guān)閉的特性,實(shí)現(xiàn)廣播。
一個(gè) goroutine 無法直接終止另一個(gè),因?yàn)檫@樣會(huì)讓所有的共享變量狀態(tài)處于不確定狀態(tài)。正確的做法是使用通道來傳遞一個(gè)信號(hào),當(dāng) goroutine 接收到信號(hào)時(shí),就終止自己。這里要討論的是如何同時(shí)取消多個(gè) goroutine。
一個(gè)可選的做法是,給通道發(fā)送你要取消的 goroutine 同樣多的信號(hào)。但是如果一些 goroutine 已經(jīng)自己終止了,這樣計(jì)數(shù)就多了,就會(huì)在發(fā)送過程中卡住。如果某些 goroutine 還會(huì)自我繁殖,那么信號(hào)的數(shù)量又會(huì)太少。通常,任何時(shí)刻都很難知道有多少個(gè) goroutine 正在工作。對(duì)于取消操作,這里需要一個(gè)可靠的機(jī)制在一個(gè)通道上廣播一個(gè)事件,這樣所以的 goroutine 就都能收到信號(hào),而不用關(guān)心具體有多少個(gè) goroutine。
當(dāng)一個(gè)通道關(guān)閉且已經(jīng)取完所有發(fā)送的值后,接下來的接收操作都會(huì)立刻返回,得到零值。就可以利用這個(gè)特性來創(chuàng)建一個(gè)廣播機(jī)制。第一步,創(chuàng)建一個(gè)取消通道,在它上面不發(fā)送任何的值,但是它的關(guān)閉表明程序需要停止它正在做的事前。
還要定義一個(gè)工具函數(shù) cancelled,在它被調(diào)用的時(shí)候檢測(cè)或輪詢取消狀態(tài):
var done = make(chan struct{}) func cancelled() bool { select { case <-done: return true default: return false } }
如果需要在原本是通道操作的地方增加取消操作判斷的邏輯,那么就對(duì)原本要操作的通道和取消廣播的通道寫一個(gè) select 多路復(fù)用。
如果要判斷的位置原本沒有通道,那么就是一個(gè)非阻塞的只有取消廣播通道的 select 多路復(fù)用,就是這里的工具函數(shù)。簡(jiǎn)單來講,直接調(diào)用工具函數(shù)進(jìn)行判斷即可。
之后的代碼里就是這么做的。
接下來,創(chuàng)建一個(gè)讀取標(biāo)準(zhǔn)輸入的 goroutine,它通常連接到終端,當(dāng)用戶按回車后,這個(gè) goroutine 通過關(guān)閉 done 通道來廣播取消事件:
// 當(dāng)檢測(cè)到輸入時(shí),廣播取消 go func() { os.Stdin.Read(make([]byte, 1)) // 讀一個(gè)字節(jié) close(done) }()
把這個(gè)新的 goroutine 加在主函數(shù)的開頭就好了。
現(xiàn)在要讓所有的 goroutine 來響應(yīng)這個(gè)取消操作。在主 goroutine 中的 select 中,嘗試從 done 接收。如果接收到了,就需要進(jìn)行取消操作,但是在結(jié)束之前,它必須耗盡 worklist 通道,丟棄它所有的值,直到通道關(guān)閉。這么做是為了保證 for 循環(huán)里之前迭代時(shí)調(diào)用的匿名函數(shù)都可以執(zhí)行完,不會(huì)卡在向 worklist 通道發(fā)送消息上:
var list urllist var worklistok bool select { case <-done: // 耗盡 worklist,讓已經(jīng)創(chuàng)建的 goroutine 結(jié)束 for range worklist { n.Done() } // 執(zhí)行到這里的前提是迭代完 worklist,就是需要 worklist 關(guān)閉 // 關(guān)閉 worklist 則需要 n 計(jì)數(shù)器歸0。而 worklist 每一次減1,需要一個(gè) n2 計(jì)數(shù)器歸零 // 所以,下面的 return 應(yīng)該不會(huì)在其他 goroutine 運(yùn)行完畢之前執(zhí)行 return case list, worklistok = <-worklist: if !worklistok { break loop } }
之后的 for 循環(huán)會(huì)沒每個(gè) URL 開啟一個(gè) goroutine。在每一次迭代中,在開始的時(shí)候輪詢?nèi)∠麪顟B(tài)。如果是取消的狀態(tài),就什么都不做并且終止迭代:
for _, link := range list.urls { if cancelled() { break } // 省略之后的代碼 }
現(xiàn)在基本就避免了在取消后創(chuàng)建新的 goroutine。但是其他已經(jīng)創(chuàng)建的 goroutine 則會(huì)等待他們執(zhí)行完畢。要想更快的響應(yīng),就需要對(duì)程序邏輯進(jìn)行侵入式的修改。要確保在取消事件之后沒有更多昂貴的操作發(fā)生。這就需要更新更多的代碼,但是通??梢酝ㄟ^在少量重要的地方檢查取消狀態(tài)來達(dá)到目的。在 crawl 中獲取信號(hào)量令牌的操作也需要快速結(jié)束:
func crawl(url string, depth int) urllist { select { case <-done: return urllist{nil, depth + 1} case tokens <- struct{}{}: // 獲取令牌 fmt.Println(depth, <-count, url) } list, err := links.Extract(url, done) <-tokens // 釋放令牌 if err != nil && !strings.Contains(err.Error(), "net/http: request canceled") { log.Print(err) } return urllist{list, depth + 1} }
在 crwal 函數(shù)中,調(diào)用了 links.Extract 函數(shù)。這是一個(gè)非常耗時(shí)的網(wǎng)絡(luò)爬蟲操作,并且不會(huì)馬上返回。正常需要等到頁面爬取完畢,或者連接超時(shí)才返回。而我們的程序也會(huì)一直等待所有的爬蟲返回后才會(huì)退出。所以這里在調(diào)用的時(shí)候,把取消廣播的通道傳遞傳遞給函數(shù)了,下面就是修改 links.Extract 來響應(yīng)這個(gè)取消操作,立刻終止爬蟲并返回。
HTTP 請(qǐng)求可以通過關(guān)閉 http.Request 結(jié)構(gòu)體中可選的 Cancel 通道進(jìn)行取消。http.Get 便利函數(shù)沒有提供定制 Request 的機(jī)會(huì)。這里要使用 http.NewRequest 創(chuàng)建請(qǐng)求,設(shè)置它的 Cancel 字段,然后調(diào)用 http.DefaultClient.Do(req) 來執(zhí)行請(qǐng)求。對(duì) links 包中的 Extract 函數(shù)按上面說的進(jìn)行修改,具體如下:
// 向給定的URL發(fā)起HTTP GET 請(qǐng)求 // 解析HTML并返回HTML文檔中存在的鏈接 func Extract(url string, done <-chan struct{}) ([]string, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Cancel = done resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("get %s: %s", url, resp.Status) } // 僅修改開頭的部分,后面的代碼省略 }
期望的情況是,當(dāng)然是當(dāng)取消事件到來時(shí) main 函數(shù)可以返回,然后程序隨之退出。如果發(fā)現(xiàn)在取消事件到來的時(shí)候 main 函數(shù)沒有返回,可以執(zhí)行一個(gè) panic 調(diào)用。從崩潰的轉(zhuǎn)存儲(chǔ)信息中通常含有足夠的信息來幫助我們分析,發(fā)現(xiàn)哪些 goroutine 還沒有合適的取消。也可能是已經(jīng)取消了,但是需要的時(shí)間比較長(zhǎng)??傊?,使用 panic 可以幫助查找原因。
最后還有一個(gè)小例子,場(chǎng)景比較常見,并且有一個(gè) goroutine 泄露的問題需要注意。
下面的例子展示一個(gè)使用緩沖通道的應(yīng)用。它并發(fā)地向三個(gè)鏡像地址發(fā)請(qǐng)求,鏡像指相同但分布在不同地理區(qū)域的服務(wù)器。它將它們的響應(yīng)通過一個(gè)緩沖通道進(jìn)行發(fā)送,然后只接收第一個(gè)返回的響應(yīng),因?yàn)樗亲钤绲竭_(dá)的。所以 mirroredQuery 函數(shù)甚至在兩個(gè)比較慢的服務(wù)器還沒有響應(yīng)之前返回了一個(gè)結(jié)果。(偶然情況下,會(huì)出現(xiàn)像這個(gè)例子中的幾個(gè) goroutine 同時(shí)在一個(gè)通道上并發(fā)發(fā)送,或者同時(shí)從一個(gè)通道接收的情況。):
func mirroredQuery() string { responses := make(chan string, 3) // 有幾個(gè)鏡像,就要多大的容量,不能少 go func () { responses <- request("asia.gopl.io") }() go func () { responses <- request("europe.gopl.io") }() go func () { responses <- request("americas.gopl.io") }() return <- responses // 返回最快一個(gè)獲取到的請(qǐng)求結(jié)果 } func request(hostname string) (response string) { return "省略獲取返回的代碼" }
在上面的示例中,如果使用的是無緩沖通道,兩個(gè)比較慢的 goroutine 將被卡住,因?yàn)樵谒鼈儼l(fā)送響應(yīng)結(jié)果到通道的時(shí)候沒有 goroutine 來接收。這個(gè)情況叫做 goroutine 泄漏。它屬于一個(gè) bug。不像回收變量,泄漏的 goroutine 不會(huì)自動(dòng)回收,所以要確保 goroutine 在不再需要的時(shí)候可以自動(dòng)結(jié)束。
上面只是一個(gè)大致的框架,不過核心思想都在里面了?,F(xiàn)在來完成這里的 request 請(qǐng)求。并且 request 里會(huì)發(fā)起 http 請(qǐng)求,雖然可以讓每一個(gè)請(qǐng)求都執(zhí)行完畢。但是只要第一個(gè)請(qǐng)求完成后,其他請(qǐng)求就可以終止了,現(xiàn)在也已經(jīng)掌握了主動(dòng)關(guān)閉 http 請(qǐng)求的辦法了。
這里把示例的功能寫的更加完整一些,用上之前的頁面解析和獲取 title 的部分代碼。通過命令行參數(shù)提供的多個(gè) url 爬取頁面,解析頁面的 title,返回第一個(gè)完成的 title。完整的代碼如下:
package main import ( "errors" "fmt" "io" "net/http" "os" "strings" "sync" "golang.org/x/net/html" ) // 遞歸解析文檔樹獲取 title func forEachNode(n *html.Node, titleP *string, pre, post func(n *html.Node)) { if *titleP != "" { return } if pre != nil { pre(n) } for c := n.FirstChild; c != nil; c = c.NextSibling { forEachNode(c, titleP, pre, post) } if post != nil { post(n) } } // 使用上面的 forEachNode 函數(shù),遞歸文檔樹。返回找到的第一個(gè) title 或者全部遍歷返回空字符串 func soleTitle(doc *html.Node) string { var title string // 被下面的閉包引用了 visitNode := func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil { title = n.FirstChild.Data } } forEachNode(doc, &title, visitNode, nil) return title } // 解析返回 title 的入口函數(shù) // 把響應(yīng)體解析為文檔樹,然后交給 soleTitle 處理,獲取 title func title(url string, body io.Reader) (string, error) { doc, err := html.Parse(body) if err != nil { return "", fmt.Errorf("parseing %s as HTML: %v", url, err) } title := soleTitle(doc) if title == "" { return "", errors.New("no title element") } return title, nil } // 上面是解析返回結(jié)果的邏輯,不是這里的重點(diǎn) // 請(qǐng)求鏡像資源 func mirroredQuery(urls ...string) string { type respData struct { // 返回的數(shù)據(jù)類型 resp string err error } count := len(urls) // 總共發(fā)起的請(qǐng)求數(shù) responses := make(chan respData, count) // 有幾個(gè)鏡像,就要多大的容量,不能少 done := make(chan struct{}) // 取消廣播的通道 var wg sync.WaitGroup // 計(jì)數(shù)器,等所有請(qǐng)求返回后再結(jié)束。幫助判斷其他連接是否可以取消 wg.Add(count) for _, url := range urls { go func(url string) { defer wg.Done() resp, err := request(url, done) responses <- respData{resp, err} }(url) } // 等待結(jié)果返回并處理 var response string for i := 0; i < count; i++ { data := <-responses if data.err == nil { // 只接收第一個(gè)無錯(cuò)誤的返回 response = data.resp close(done) break } fmt.Fprintf(os.Stderr, "mirror get: %v\n", data.err) } wg.Wait() return response } // 負(fù)責(zé)發(fā)起請(qǐng)求并返回結(jié)果和可能的錯(cuò)誤 func request(url string, done <-chan struct{}) (response string, err error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return "", err } req.Cancel = done resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() // 檢查返回的頁面是HTML通過判斷Content-Type,比如:Content-Type: text/html; charset=utf-8 ct := resp.Header.Get("Content-Type") if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") { return "", fmt.Errorf("%s has type %s, not text/html", url, ct) } // 如果上面檢查響應(yīng)頭沒問題,把響應(yīng)體交給 title 函數(shù)解析獲取結(jié)果 return title(url, resp.Body) } func main() { response := mirroredQuery(os.Args[1:]...) fmt.Println(response) }
這里的 request 除了返回響應(yīng)消息還會(huì)返回一個(gè)錯(cuò)誤。在 mirroredQuery 函數(shù)內(nèi)需要處理這個(gè)錯(cuò)誤,從而可以獲取到第一個(gè)正確返回的響應(yīng)消息。也有可能所有的請(qǐng)求都沒有正確的返回,這里的做法也確保了所有的請(qǐng)求都返回錯(cuò)誤后程序可以正常執(zhí)行結(jié)束。
解析頁面獲取 title 的部分,基本參照了上面的獲取頁面的title這小節(jié)的實(shí)現(xiàn)。
到此,關(guān)于“Go語言網(wǎng)絡(luò)爬蟲實(shí)例代碼分享”的學(xué)習(xí)就結(jié)束了,希望能夠解決大家的疑惑。理論與實(shí)踐的搭配能更好的幫助大家學(xué)習(xí),快去試試吧!若想繼續(xù)學(xué)習(xí)更多相關(guān)知識(shí),請(qǐng)繼續(xù)關(guān)注億速云網(wǎng)站,小編會(huì)繼續(xù)努力為大家?guī)砀鄬?shí)用的文章!
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。