在第六届字节跳动青训营后端基础班中,示范了一个用Go语言写的查单词客户端,可以从命令行输入要查的单词,然后客户端会以JSON形式与翻译网站的API交互,并且将单词的释义打印在命令行里。
课上的示范中使用彩云小译的查词API。课后的作业题要求我们增加另一种翻译网站的支持。在这个案例中,笔者增加了两种翻译引擎:金山词霸和有道词典。实现后的客户端可以在三家网站查询同一个单词,并且将查词的结果有条理地聚合起来输出到命令行。本文接下来就将举例以代码块的方式说明完整的开发过程。完整的代码可以从gitee.com/robbinyang/… 拉取。
1 项目文件的初始化
首先创建一个名为simpledict的新文件夹,在里面打开终端,利用go mod init simpledict新建一个名为simpledict的模块。再创建一个名为main.go的源文件,向里面写入:
package main
import (
"fmt"
"os"
)
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]
fmt.Println(word)
}
这个简单的主函数从命令行读取一个必须的位置参数作为要查找的单词,并且将这个单词输出到命令行,如果用户遗漏了这个位置参数,程序会在命令行里面输出程序的用法。可以在做到这一步时保存main.go,然后用go build . && ./simple-dict hello查看程序的输出。
$ go build . && ./simpledict hello
hello
$ ./simpledict
usage: simpledict <WORD>
example: simpledict hello
2 查看词典网站的API
这里以有道词典为例。首先用浏览器访问www.youdao.com 。开启浏览器的开发人员工具,这里以Chrome浏览器为例,是按Ctrl+Shift+I开启DevTools。在DevTools中访问Network选项卡,确保顶部工具栏左端的录制按钮已是红色,即已经开始录制网络活动。
在首页的查词框中输入hello,然后返回DevTools里面的Network选项卡,可以看到有一条以"suggest?..."开头的XHR请求,点击检查那个请求。在打开的展板中选择Headers,找到Request URL一栏,它对应的是这个请求的路径,稍后会用到。
在打开的展板中选择Preview,可以看到返回的JSON结构体,其中data.entries键对应的数组已经包含了我们需要的单词释义。
我们已经在DevTools里面看到了浏览器查词的时候发送的请求,下一步我们将用Go语言模拟发送这个请求。
3 在Go语言中发送请求并接收响应
在项目文件夹中新建一个dict文件夹,再在dict文件夹下创建一个youdao.go源文件。
在DevTools中选中刚刚检查过的请求,右键选择"Copy as cURL(bash)"。
然后访问curlconverter.com/go/ ,这个网站可以帮我们把cURL语法的请求转换成Go代码。在这个网站上的"curl command"输入框中粘贴刚刚复制的cURL命令,可以看见已经生成了Go代码。将生成出来的Go代码复制到youdao.go中,修改第一行的模块名为dict,并且将main函数重构为可以接受一个字符串作为要查询单词的Query函数。重构后youdao.go的代码如下:
package dict
import (
"fmt"
"io"
"log"
"net/http"
)
func Query(word string) {
client := &http.Client{}
// 根据要查询单词定制url
reqURL := fmt.Sprintf("https://dict.youdao.com/suggest?num=5&ver=3.0&doctype=json&cache=false&le=en&q=%s", word)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Accept", "application/json, text/plain, */*")
//省略了一系列req.Header.Set操作,这些部分不需要修改
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
bodyText, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", bodyText)
}
返回main.go文件,在import头部加入一条simpledict/dict,并且把main函数最后一行fmt.Println(word)换成dict.Query(word)。 这时我们就可以从 main.go里面调用youdao.go里面的Query函数。
//添加一条import记录
import (
"simpledict/dict"
)
func main(){
// ...
//最后一行换成下面这个
dict.Query(word)
}
这时我们再编译运行,就可以看到类似下面的输出。这表示我们已经成功拿到了有道词典API的响应。
$ go build . && ./simpledict hello
{"result":{"msg":"success","code":200},"data":{"entries":[{"explain":"int. 喂,你好(用于问候或打招呼);喂,你好(打电话时的招呼语);喂,你好(引起别人注意的招呼语...","entry":"hello"},{"explain":"int. <非规范>(极其兴奋的人)打招呼时的用语","entry":"hellow"},{"explain":"你好,世界:一种用于表示问候或欢迎的短语,通常用于初次见面或表示友好。","entry":"hello world"},{"explain":"大家好:用于向在场的所有人表示问候或欢迎的一种友好的说法。","entry":"hello everyone"},{"explain":"凯蒂猫(品牌名)","entry":"Hello Kitty"}],"query":"hello","language":"en","type":"dict"}}
下一步,我们将从利用Go语言中JSON反序列化的方法,从JSON响应体中提取出单词的释义。
4 从JSON响应中提取单词释义
我们主要会用到encoding/json包中的Unmarshal方法以及结构体的JSON标签。首先,返回第二节DevTools中Preview响应JSON结构体的视窗,然后在响应体上右键"Copy Object"。访问oktools.net/json2go ,这个网站可以自动从JSON创建Go语言对应的结构体。在oktools.net界面上,左侧一栏是粘贴JSON的地方,右侧是响应的Go结构体会生成的地方。在左侧一栏粘贴从DevTools中复制的JSON,点击"转换-嵌套"按钮,将右侧生成的结构体复制到youdao.go中,复制后将最外层结构体改名为YoudaoDictResponse。
//文件dict/youdao.go
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"`
}
观察生成的结构体,每一个字段后都附有反引号围绕的JSON标签,这个标签指示将对应JSON含有这个键的键值对映射到结构体的该字段上,如Code int `json:"code"` 指示将JSON对象的"code"键映射到结构体的"Code"字段上。
接下来,我们修改youdao.go里面的Query方法,使用json.Unmarshal方法将响应体的JSON反序列化到一个YoudaoDictResponse对象上。 我们使用var res YoudaoDictResponse来定义一个YoudaoDictResponse对象,并且让它成为反序列化的目标。接下来,我们遍历res.Data.Entries这个可变长数组(Go语言中叫slice)。由于对API的了解,我们知道res.Data.Entries的第一个元素一般就是我们要查找的词,之后的元素一般是一些和请求查找的词相近的词。因此我们直接判断res.Data.Entries[0].Entry这个字段包含的单词是否跟我们要查的词一样,如果一样则输出“准确匹配:”和这个词的定义,如果不一样则输出“没有查找到匹配的单词!”。而对于之后的形近词,我们统一分组到“形近词”下面。
//import添加一条记录
import (
"encoding/json"
)
func Query(word string){
//...之前代码省略
//fmt.Printf("%s\n", bodyText)
var res YoudaoDictResponse
err = json.Unmarshal(bodyText, &res)
if len(res.Data.Entries) > 0 && res.Data.Entries[0].Entry == word {
fmt.Printf("准确匹配:\n")
fmt.Printf("%s\n%s\n", res.Data.Entries[0].Entry, res.Data.Entries[0].Explain)
res.Data.Entries = res.Data.Entries[1:] // 删除第一个完全匹配的条目
} else {
fmt.Printf("没有查找到匹配的单词!\n")
}
fmt.Println("形近词:")
for _, entry := range res.Data.Entries {
fmt.Printf("%s\n%s\n", entry.Entry, entry.Explain)
}
}
至此,我们已经完成了一个功能完整的查词逻辑。通过编译运行得出的效果如下:
$ go build . && ./simpledict good
准确匹配:
good
adj. 好的,优良的;能干的,擅长的;好的,符合心愿的;令人愉快的,合意的;(心情)愉快的;迷人的...
形近词:
goods
n. 商品;动产,私人财产;<英>(公路、铁路等运输的)货物;<美,非正式>本领;<美,非正式>正要...
Good
n. (美)古德(人名); adj. (good)好的;令人满意的;合情理的;赞同的;能干的; n....
goodwill
n. 友善,善意;(公司的)信誉; 【名】 (Goodwill)(英)古德威尔(人名)
goodness
n. 善良,美德;(尤指食物中的)营养,精华; int. 天哪,啊呀(用作“上帝”的替代语,表示吃惊...
5 代码优化:将API请求参数定义成结构体
现在的程序尽管功能上已经能跑通,但仍然有很多可以优化的地方。一个最显著的可以优化方面就是请求的参数。在之前的实现里,由于请求的方法是GET,请求相关的参数都编码到URL里面了,所以我们可直接从DevTools里面复制请求的URL,然后通过替换参数q对应的值替换要查找的单词。考虑到有其他参数可能需要自定义以及以后该API的参数可能会变更,更好的方法是我们将所有请求要用到的参数定义成一个结构体,然后当用户要发送请求的时候我们按照用户的需求更改结构体的某些字段的值,最后将结构体对象编码成GET格式的URL参数,附在base URL后面。鉴于此,我们可以向youdao.go中添加一个YoudaoDictRequest结构体。
type YoudaoDictRequest struct {
Num int
Version string
Doctype string
Cache bool
Language string //翻译的源语言,默认是"en"
Query string //要查询的单词
}
相应地,Query函数内生成reqURL的逻辑需要修改。
//添加一条import
import(
"net/url"
)
func Query(word string){
//reqURL := fmt.Sprintf("https://dict.youdao.com/suggest?num=5&ver=3.0&doctype=json&cache=false&le=en&q=%s", word)
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()
//下面代码省略
//...
}
至此,代码的实现告一段落。笔者实际除了有道词典还对应实现了金山词霸的查词。详细的代码在笔者的Gitee仓库里gitee.com/robbinyang/… 。
6 未完待续:客户端的进一步优化
下一步的打算是利用goroutine实现并发访问三个翻译网站,并且利用基准测试等手段对比并发前后的时间提效。敬请期待《Go语言实战 - 简单的查词客户端(二)》。