Go语言实战 - 简单的查词客户端(二)| 青训营

59 阅读2分钟

承接上一篇文章:《Go语言实战 - 简单的查词客户端(一)》

在上一篇文章中,我们实现了一个可以向三家翻译网站查词的客户端。下一阶段的任务是实现并发请求三个翻译网站以缩短用户的等待时间。鉴于此,笔者在上一个v1版本的基础上利用了Goroutine加Channel控制并发查询三个翻译网站并且整合输出到命令行。实现出来的v2版本的客户端用时比v1版本节约了一半左右。v2版本的源码详见gitee.com/robbinyang/…

1 使用Goroutine

在Go语言中,并发一般是由Goroutine实现的。我们可以把三个客户端的调用写在三个匿名函数中。然后使用go func()的方式为三个函数调用创建Goroutine。因此,我们可以对main.go进行修改,修改后的效果如下:

//main.go
package main

import (
	"fmt"
	"os"
	"simpledict/dict"func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "%s\n", 
usage: simpledict <WORD>
example: simpledict hello`)
        os.Exit(1)
    }
    word := os.Args[1]
    go func(){
        fmt.Println("*******彩云小译*******")
        dict.CaiyunDictClient.Query(word)
    }()

    go func(){
        fmt.Println("*******金山词霸*******")
        dict.JinshanDictClient.Query(word)
    }()

    go func(){
        fmt.Println("*******有道词典*******")
        dict.YoudaoDictClient.Query(word)
    }()
}

编译运行程序,可以看到大多数时候程序的输出顺序是“彩云小译”、“金山词霸”、“有道词典”,与我们在程序中编码的顺序一致。但是少部分时候程序的输出会被打乱,尤其是CPU繁忙的时候,下面以限制使用一个线程(GOMAXPROCS=1)为例展示一下输出:

$ go build . && GOMAXPROCS=1 && ./simpledict good
*******有道词典*******
*******彩云小译*******
*******金山词霸*******
good UK: [gud] US: [gʊd]
准确匹配:
good
adj. 好的,优良的;能干的,擅长的;好的,符合心愿的;令人愉快的,合意的;(心情)愉快的;迷人的...
准确匹配:
a.好的;善良的;快乐的;真正的;宽大的;有益的;老练的;幸福的;忠实的;优秀的;完整的;彻底的;丰富的
形近词:
goods
n. 商品;动产,私人财产;<英>(公路、铁路等运输的)货物;<美,非正式>本领;<美,非正式>正要...
...其后的输出省略

可以看到,程序运行时三个Goroutine首先执行匿名函数的第一个语句fmt.Println(...),然后才分别执行第二个语句调用各个词典的Query方法。当多个Goroutine在一个线程并发运行,每个Goroutine执行的快慢不同,当它们同时向标准输出行写入数据时,有可能会出现一个Goroutine写到一半被另一个Goroutine抢占的问题。为了解决这个问题,我们需要对所有Goroutine的输出流进行管理,将它们的输入流整合之后再统一输出到命令行。

2 多个Goroutine写入输出流的管理

为了解决多个Goroutine写入输出流的问题,我们采用Go语言中Goroutine通信的标准解法-Channel(管道)。可以将Channel想象成一个队列,Goroutine可以通过它发送和接收消息。这种通信方式可以帮助不同的Goroutine之间进行同步,以便它们能够安全地共享数据。在我们这个案例中,使用Channel的好处在于它可以帮助我们在Goroutine中并发请求多个翻译网站时将响应的数据有条理地返回给程序的主进程(或者更准确地说是main goroutine)。

在接下来的例子中,我们会用到无缓冲区的Channel,这种Channel的特点是一旦一个Goroutine尝试向Channel发送或接收消息时,它会被阻塞,直到这条消息被另一个Goroutine接收或发送到Channel后。因此,接收者与发送者形成了类似于同步的管系。

下面的代码展示的是修改过的main.go,可以看到我们在main函数里定义了一个无缓冲区的Channel,Channel传入和接收的数据类型是字节切片[]byte。在使用Goroutine调用各个查词引擎的时候我们将这个bchan当作一个参数传入,目的是让Goroutine执行的函数在解析查词响应的过程中将输出整合成[]byte类型,然后将整合后的输出通过bchan传回main函数。而main函数则会在启动3个Goroutine之后,在持续从bchan中取值的过程中被临时阻塞直到其中一个Goroutine成功向bchan中发送查词响应。在for循环中这样的过程会循环三次,最后所有的Goroutine都成功地向Channel发送了响应并且退出,而main函数在打印完最后一个Goroutine返回的响应之后也结束。

//main.go
package main

import (
	"fmt"
	"os"
	"simpledict/dict"
)
func main() {
    //...省略了之前的参数判断逻辑
    bchan := make(chan []byte)
    go dict.CaiyunDictClient.Query(word, bchan)
    go dict.JinshanDictClient.Query(word, bchan)
    go dict.YoudaoDictClient.Query(word, bchan)
    for i := range []int{1, 2, 3} {
        fmt.Println(i, string(<-bchan))
    }
}    

与此同时,我们也要对三个词典的查词的过程稍微修改。这里以有道词典的查词过程,dict.YoudaoDictClient.Query方法为例。

//dict/youdao.go
package dict
//新增一条import
import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
)

type YoudaoDict struct {
	buf *bytes.Buffer
}

type YoudaoDictRequest struct {
    Num      int
    Version  string
    Doctype  string
    Cache    bool
    Language string
    Query    string
}
type YoudaoDictResponse struct {
    Result struct {
        Msg  string `json:"msg"`
        Code int    `json:"code"`
    } `json:"result"`
    Data struct {
        Entries []struct {
            Explain string `json:"explain"`
            Entry   string `json:"entry"`
        } `json:"entries"`
        Query    string `json:"query"`
            Language string `json:"language"`
            Type     string `json:"type"`
    } `json:"data"`
}

func (y *YoudaoDict) Query(word string, bchan chan<- []byte) {
    y.buf = new(bytes.Buffer)
    fmt.Fprintln(y.buf, "*******有道词典*******")
    reqBody := YoudaoDictRequest{
        Num:      5,
        Version:  "3.0",
        Doctype:  "json",
        Cache:    false,
        Language: "en",
        Query:    word,
    }

    u, _ := url.Parse("https://dict.youdao.com/suggest")
    queries := u.Query()
    queries.Set("num", fmt.Sprintf("%d", reqBody.Num))
    queries.Set("ver", reqBody.Version)
    queries.Set("doctype", reqBody.Doctype)
    queries.Set("cache", fmt.Sprintf("%t", reqBody.Cache))
    queries.Set("le", reqBody.Language)
    queries.Set("q", reqBody.Query)
    u.RawQuery = queries.Encode()

    reqURL := u.String()
    req, err := http.NewRequest("GET", reqURL, nil)
    if err != nil {
        fmt.Fprintf(y.buf, "Error getting %s: %#v", reqURL, err)
    }
    req.Header.Set("Accept", "application/json, text/plain, */*")
    //接下来一系列的req.Header.Set与v1一致
    //...

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        fmt.Fprintln(y.buf, err)
    }

    defer resp.Body.Close()
    bodyText, err := io.ReadAll(resp.Body)
    if resp.StatusCode != 200 {
        fmt.Fprintln(y.buf, "Bad status code: ", resp.StatusCode, "Body: ", string(bodyText))
    }
    if err != nil {
        fmt.Fprintln(y.buf, err)
    }

    var res YoudaoDictResponse
    err = json.Unmarshal(bodyText, &res)
    if err != nil {
        fmt.Fprintln(y.buf, err)
    }

    if len(res.Data.Entries) > 0 && res.Data.Entries[0].Entry == word {
        fmt.Fprintf(y.buf, "准确匹配:\n")
        fmt.Fprintf(y.buf, "%s\n%s\n", res.Data.Entries[0].Entry, res.Data.Entries[0].Explain)
        res.Data.Entries = res.Data.Entries[1:] // 删除第一个完全匹配的条目
    } else {
        fmt.Fprintf(y.buf, "没有查找到匹配的单词!\n")
    }
    fmt.Fprintln(y.buf, "形近词:")
    for _, entry := range res.Data.Entries {
        fmt.Fprintf(y.buf, "%s\n%s\n", entry.Entry, entry.Explain)
    }

    bchan <- y.buf.Bytes()
}

主要的改动在两个方面:

  1. YoudaoDict类型中加入了一个私有的bytes.Buffer变量buf,这个buf为请求响应体的写入提供了一个暂存区。等到所有的响应体信息解析并写入buf后,我们调用它的Bytes方法将它内部的数据转换成一个字节切片[]byte,而这个数据刚好是bchan管道的数据类型,所以我们可以把这个字节切片通过Channel发送给main函数;
  2. 在解析查词响应体并提取关键信息的过程中,我们通过fmt.Fprintffmt.Fprintln方法将写入的信息重定向到buf,方便整合所有输出的信息。

3 性能测试:时间效率

在上面一小节的代码实现后,我们就可以得到v2版本的客户端。为了在执行时间上对比第一篇文章中实现的v1版本,我们可以参考如下的基准测试案例。在项目根目录下创建一个test文件夹,创建一个名为dict_test.go的文件,内容如下:

package test

import (
	"fmt"
	"io"
	"simpledict/dict"
	"strings"
	"testing"
)

var (
	wordList = "abstain\nadulterate\nadvocate\nanomaly\nantipathy\napathy\nassuage\naudacious\nbolster\ncacophony\ncapricious\ncorroborate\nderide\ndesiccate\ndissonance\nenervate\nengender\nenigma\nephemeral\nequivocal\nerudite\neulogy\nfervid\ngarrulous\ngullible\nhomogenous\ningenuous\nlaconic\nlaudable\nlethargic\nloquacious\nlucid\nmalleable\nmisanthrope\nmitigate\nobdurate\nopaque\nostentation\nparadox\npedant\nphilanthropic\nplacate\npragmatic\nprecipitate\nprevaricate\nprodigal\npropriety\nvacillate\nvenerate\nvolatile"
)

func runDictConcurrent() {
	bchan := make(chan []byte)

	for _, word := range strings.Split(wordList, "\n") {

		go dict.CaiyunDictClient.Query(word, bchan)
		go dict.JinshanDictClient.Query(word, bchan)
		go dict.YoudaoDictClient.Query(word, bchan)

		for i := range []int{1, 2, 3} {
			fmt.Fprint(io.Discard, i, <-bchan)
		}
	}
}

func runDictSequential() {
	bchan := make(chan []byte, 1)

	for _, word := range strings.Split(wordList, "\n") {
		dict.CaiyunDictClient.Query(word, bchan)
		fmt.Fprint(io.Discard, <-bchan)
		dict.JinshanDictClient.Query(word, bchan)
		fmt.Fprint(io.Discard, <-bchan)
		dict.YoudaoDictClient.Query(word, bchan)
		fmt.Fprint(io.Discard, <-bchan)
	}
}

func BenchmarkDict(b *testing.B) {

	b.ResetTimer()
	b.N = 1
	b.StartTimer()
	for n := 0; n < b.N; n++ {
		runDictSequential()
	}
	b.StopTimer()
	b.Logf("顺序请求耗时:%#vms\n", b.Elapsed().Milliseconds())

	b.ResetTimer()
	b.N = 1
	b.StartTimer()
	for n := 0; n < b.N; n++ {
		runDictConcurrent()
	}
	b.StopTimer()
	b.Logf("并发请求耗时:%#vms\n", b.Elapsed().Milliseconds())
}

其中wordList包含了50个待查的单词,以换行符\n分开。在测试顺序请求的时候,我们通过bchan := make(chan []byte, 1)bchan设置成一个缓冲区长度为1的Channel,是为了防止在同一个Goroutine中执行各查词引擎的Query方法中向bchan发送数据时因为接收端一直不接收消息而造成死锁。

在项目根文件夹下,用如下命令运行这个基准测试:

$ go test -bench=BenchmarkDict -benchtime=1x -count=1 -v test/dict_test.go

以下是基准测试的一个结果:

goos: linux
goarch: amd64
cpu: AMD Ryzen 5 5500U with Radeon Graphics         
BenchmarkDict
    dict_test.go:54: 顺序请求耗时:7987ms
    dict_test.go:63: 并发请求耗时:4032ms
BenchmarkDict-4                1        4032070457 ns/op
PASS
ok      command-line-arguments  12.028s

可以看到并发请求的版本比串行请求的版本节约了一半左右的时间。简单的查词客户端通过Goroutine的优化方案卓有成效。读者可以在对应的Gitee仓库中找到改进过的v2版本的源码。

4 参考文档

  1. studygolang.com. "Channels · Go语言圣经 (studygolang.com)"