溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

Go語言網(wǎng)絡(luò)爬蟲實(shí)例代碼分享

發(fā)布時(shí)間:2021-09-13 14:59:34 來源:億速云 閱讀:167 作者:chen 欄目:編程語言

這篇文章主要介紹“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)容。

獲取一個(gè) URL

下面的程序展示從互聯(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ù)流來避免資源泄露。

并發(fā)獲取多個(gè) URL

這個(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ù)。

遞歸-處理HTML

這章開始,介紹Go語言中函數(shù)的一些特性,用到的都是網(wǎng)絡(luò)爬蟲相關(guān)的示例。  
函數(shù)可以遞歸調(diào)用,這意味著函數(shù)可以直接或者間接地調(diào)用自己。遞歸是一種實(shí)用的技術(shù),可以處理許多帶有遞歸特性的數(shù)據(jù)結(jié)構(gòu)。下面就會(huì)使用遞歸處理HTML文件。

解析 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)樹

下面的程序使用遞歸遍歷所有 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)心溢出的問題。

遍歷 HTML 節(jié)點(diǎn)樹2

這里再換一個(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>

延遲函數(shù)調(diào)用(defer)

下面示例的兩個(gè)功能,建議通過延遲函數(shù)調(diào)用 defer 來實(shí)現(xiàn)。

獲取頁面的title

直接使用 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é)果。

匿名函數(shù)

網(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ā)的 Web 爬蟲

接下來是并發(fā)編程,使上面的搜索連接的程序可以并發(fā)運(yùn)行。這樣對(duì) crawl 的獨(dú)立調(diào)用可以充分利用 Web 上的 I/O 并行機(jī)制。

并發(fā)的修改

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è)可選的方案是使用緩沖通道。

限制并發(fā)

現(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è)方案

這里還有一個(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í)行。

支持手動(dòng)取消操作

繼續(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)閉表明程序需要停止它正在做的事前。

查詢狀態(tài)

還要定義一個(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)行判斷即可。  
之后的代碼里就是這么做的。

發(fā)送取消廣播

接下來,創(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ù)的開頭就好了。

響應(yīng)取消操作

現(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è)取消操作,立刻終止爬蟲并返回。

關(guān)閉HTTP請(qǐng)求

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)
    }
    // 僅修改開頭的部分,后面的代碼省略
}

測(cè)試的技巧

期望的情況是,當(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 可以幫助查找原因。

并發(fā)請(qǐng)求最快的鏡像資源

最后還有一個(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 "省略獲取返回的代碼" }

goroutine 泄露

在上面的示例中,如果使用的是無緩沖通道,兩個(gè)比較慢的 goroutine 將被卡住,因?yàn)樵谒鼈儼l(fā)送響應(yīng)結(jié)果到通道的時(shí)候沒有 goroutine 來接收。這個(gè)情況叫做 goroutine 泄漏。它屬于一個(gè) bug。不像回收變量,泄漏的 goroutine 不會(huì)自動(dòng)回收,所以要確保 goroutine 在不再需要的時(shí)候可以自動(dòng)結(jié)束。

請(qǐng)求并解析資源

上面只是一個(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í)用的文章!

向AI問一下細(xì)節(jié)

免責(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)容。

AI