1. 前言
通过这个实践项目,我们可以学习到如何利用各类工具来实现对网络 API 的探索和调用,并且可以通过一些挑战更充分地练习自己的 Go 水平。
2. 实践需求
顾名思义,这个实践项目的目标是实现一个词典,可以在线查询单词并返回给用户。具体来说,我们需要做到:
- 实现对一个网络词典的 API 的调用
- 获取音标信息并输出
- 获取单词的所有释义并输出
- [挑战]实现多个网络词典的 API 调用,并且并行地向所有词典发出请求,并全部返回给用户
3. 代码编写
既然我们的目标中包含了对多个词典的支持,那么不如先对网络词典的调用进行一次抽象,用 interface 来表示所有具体的词典对象的统一特性。具体代码如下:
type DictResponse struct {
provider string
pronunciations map[string]string
explanations []string
}
type DictQuery interface {
dict_do_query(word string) (*DictResponse, error)
}
provider 标识了提供者的名称,pronunciations 与 explanations 分别用于表示音标与单词释义。在 dict_do_query 中,函数会接收一个单词作为输入,并返回 DictResponse。接下来,我们只需要建立具体的结构体,并为它们实现 dict_do_query 方法即可。我们打算实现两个词典的支持:彩云翻译、百度翻译,具体定义如下:
type CaiYunDict struct{}
type BaiduDict struct{}
这里由于暂不考虑具体的上下文等调用细节,不需要携带任何信息,故直接将它们设计为空结构体即可。接下来,我们来实现主函数。主函数负责循环接收用户的每行输入,并将其作为目标单词进行请求,故如下编写即可:
func main() {
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("输入新的单词: ")
if !scanner.Scan() {
break
}
word := scanner.Text()
query_word(word)
}
}
这里用到了 bufio.Scanner,其作用很简单:将输入的数据按 token 分割,并以 token 的形式提供处理后的结果。我们将 os.Stdin 作为其输入,然后通过 Scan()、Text() 循环来读取数据,由于 Scanner 默认是以行为单位划分 token 的,故每次调用我们都能获取到不包含换行符的整行数据,十分方便好用。拿到单词后,我们就将其传给 query_word 进行总请求。接下来,我们来编写 query_word 这个函数:
func query_word(word string) error {
fmt.Println("你输入了:", word)
var wg sync.WaitGroup
var result = make(chan *DictResponse)
dicts := []DictQuery{new(CaiYunDict), new(BaiduDict)}
for _, dict := range dicts {
wg.Add(1)
go func(dict DictQuery) {
defer wg.Done()
resp, err := dict.dict_do_query(word)
if err != nil {
fmt.Println("Request failed:", err)
return
}
result <- resp
}(dict)
}
go func() {
wg.Wait()
close(result)
}()
for resp := range result {
prons := make([]string, 0, len(resp.pronunciations))
for k, v := range resp.pronunciations {
prons = append(prons, fmt.Sprintf("%v: %v", k, v))
}
pron := strings.Join(prons, ", ")
explanation := strings.Join(resp.explanations, " | ")
fmt.Printf(
"Response from %v:\n音标: %v\n释义: %v\n",
resp.provider,
pron,
explanation,
)
}
return nil
}
这里可以看到,我们将所有字典实体统一放入了一个 []DictQuery 中,然后用范围 for 来启动新的 goroutine 进行实际的请求操作。之前设计的接口在这里就派上了用场,这样的写法使得添加一个新的字典实现变得非常容易——向切片中 append 一个新的字典实体即可。同时,我们也不再需要关心具体的字典到底是怎么实现请求和解析响应的,接口将数据进行了统一,我们可以非常方便地进行输出。在这段代码中,为了实现并发请求,我们使用了 sync.WaitGroup 和 channel,前者用于等待所有子 goroutine 运行完毕,后者用于接收 goroutine 请求得到的响应数据。WaitGroup 可以大致看作一个计数器,每当我们开启一个新 goroutine 时就将计数器加 1,并通过 defer wg.Done() 来将计数器减 1。当计数器归零时,所有堵塞在 wg.Wait() 上的 goroutine 就会被唤醒,并继续运行。这里 wg 是必要的,因为我们必须要关闭 channel,这样主 goroutine 才能知道不会再有更多数据了,从而可以跳出范围 for。这里你可能会觉得奇怪:为什么要在 wait 和 close 两句外面套上一层 goroutine 呢?这里其实对应了我摔进去的一个坑:我想当然地认为 wait 一句可以顺利地等待子任务完成,然后 close 就顺便关闭了通道。实际上,wait 根本不会完成——这里存在一个死锁!由于我们创建通道的方式是 make(chan *DictResponse),这意味着我们得到的通道是无缓冲、同步的,因此子 goroutine 在发送结果的时候会堵塞,等待主 goroutine 消费结果后才能继续,而主 goroutine 此时正在等待子任务完成,于是产生了互相依赖的情景,也就是死锁。解决方法其实非常简单:把等待和关闭的过程异步化即可,这样便打破了等待循环,不会再死锁。最后,我们来实现具体的词典本身。通过 Chrome 的 DevTools,我们可以捕获词典发送的网络请求和接收的响应,通过观察法确定数据在响应的 JSON 中的路径,然后通过 oktools.net/json2go 这个工具根据 JSON 生成对应的 Go 结构体(注意要选择 转换-嵌套)。两个词典的响应 JSON 格式分别如下(已删去部分用不到的字段):
// 彩云
type CaiYunDictApiRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
}
type CaiYunDictApiResponse struct {
Rc int `json:"rc"`
Dictionary Dictionary `json:"dictionary"`
}
type Prons struct {
EnUs string `json:"en-us"`
En string `json:"en"`
}
type Dictionary struct {
Prons Prons `json:"prons"`
Explanations []string `json:"explanations"`
Synonym []string `json:"synonym"`
Antonym []interface{} `json:"antonym"`
WqxExample [][]string `json:"wqx_example"`
Entry string `json:"entry"`
Type string `json:"type"`
Related []interface{} `json:"related"`
Source string `json:"source"`
}
// 百度
type BaiduDictApiResponse struct {
TransResult struct {
Data []struct {
Dst string `json:"dst"`
Src string `json:"src"`
} `json:"data"`
From string `json:"from"`
To string `json:"to"`
Status int `json:"status"`
Type int `json:"type"`
Phonetic []struct {
SrcStr string `json:"src_str"`
TrgStr string `json:"trg_str"`
} `json:"phonetic"`
} `json:"trans_result"`
DictResult struct {
Edict struct {
Item []struct {
TrGroup []struct {
Tr []string `json:"tr"`
Example []string `json:"example"`
SimilarWord []string `json:"similar_word"`
} `json:"tr_group"`
Pos string `json:"pos"`
} `json:"item"`
Word string `json:"word"`
} `json:"edict"`
From string `json:"from"`
SimpleMeans struct {
WordName string `json:"word_name"`
From string `json:"from"`
WordMeans []string `json:"word_means"`
Tags struct {
Core []string `json:"core"`
Other []string `json:"other"`
} `json:"tags"`
Exchange struct {
WordPl []string `json:"word_pl"`
} `json:"exchange"`
Symbols []struct {
PhEn string `json:"ph_en"`
PhAm string `json:"ph_am"`
Parts []struct {
Part string `json:"part"`
Means []string `json:"means"`
} `json:"parts"`
PhOther string `json:"ph_other"`
} `json:"symbols"`
} `json:"simple_means"`
Common struct {
Text string `json:"text"`
} `json:"common"`
Lang string `json:"lang"`
BaiduPhrase []struct {
Tit []string `json:"tit"`
Trans []string `json:"trans"`
} `json:"baidu_phrase"`
Sanyms []struct {
Tit string `json:"tit"`
Type string `json:"type"`
Data []struct {
P string `json:"p"`
D []string `json:"d"`
} `json:"data"`
} `json:"sanyms"`
} `json:"dict_result"`
Logid int `json:"logid"`
}
由于这些在线词典对于爬虫都有一定的反制操作,因此为方便起见,我们需要直接把浏览器的请求头在 Go 中模拟出来,这样才能正常进行请求。做法其实很简单:对着具体的请求右键,选中 Copy->Copy as cURL (bash),如图:
然后把结果粘贴到 curlconverter.com/go/ 中,便可轻松得到整个请求的完整代码。把得到的代码中的传入单词参数的部分替换为函数参数,然后用 json.Unmarshal 把响应解析为结构体,最后就是简单的结构体访问、赋值操作了。最终的代码如下:
// 彩云
type CaiYunDictApiRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
}
func (v *CaiYunDict) dict_do_query(word string) (*DictResponse, error) {
var result DictResponse
result.provider = "彩云"
client := http.Client{}
request := CaiYunDictApiRequest{"en2zh", word}
buf, err := json.Marshal(request)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest(
"POST",
"https://api.interpreter.caiyunai.com/v1/dict",
bytes.NewReader(buf),
)
if err != nil {
return nil, err
}
httpReq.Header.Set("authority", "api.interpreter.caiyunai.com")
httpReq.Header.Set("accept", "application/json, text/plain, */*")
httpReq.Header.Set("accept-language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-HK;q=0.6,ja;q=0.5")
httpReq.Header.Set("app-name", "xy")
httpReq.Header.Set("content-type", "application/json;charset=UTF-8")
httpReq.Header.Set("device-id", "06a29803fd220739c13d41884604b66e")
httpReq.Header.Set("dnt", "1")
httpReq.Header.Set("origin", "https://fanyi.caiyunapp.com")
httpReq.Header.Set("os-type", "web")
httpReq.Header.Set("os-version", "")
httpReq.Header.Set("referer", "https://fanyi.caiyunapp.com/")
httpReq.Header.Set("sec-ch-ua", `"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"`)
httpReq.Header.Set("sec-ch-ua-mobile", "?0")
httpReq.Header.Set("sec-ch-ua-platform", `"Windows"`)
httpReq.Header.Set("sec-fetch-dest", "empty")
httpReq.Header.Set("sec-fetch-mode", "cors")
httpReq.Header.Set("sec-fetch-site", "cross-site")
httpReq.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36")
httpReq.Header.Set("x-authorization", "token:qgemv4jr1y38jyq6vhvi")
httpResp, err := client.Do(httpReq)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
if httpResp.StatusCode != 200 {
return nil, fmt.Errorf(
"bad StatusCode %v, body: %v",
httpResp.StatusCode,
string(body),
)
}
var response CaiYunDictApiResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
result.pronunciations = make(map[string]string)
result.pronunciations["UK"] = response.Dictionary.Prons.En
result.pronunciations["US"] = response.Dictionary.Prons.EnUs
result.explanations = response.Dictionary.Explanations
return &result, nil
}
// 百度
func (v *BaiduDict) dict_do_query(word string) (*DictResponse, error) {
var result DictResponse
result.provider = "百度"
client := http.Client{}
var data = strings.NewReader(fmt.Sprintf("from=en&to=zh&query=%v&transtype=realtime&simple_means_flag=3&sign=901459.598626&token=503aad3aad9f5b315cf8ec9815b780e0&domain=common", word))
req, err := http.NewRequest("POST", "https://fanyi.baidu.com/v2transapi?from=en&to=zh", data)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Acs-Token", "1692640809280_1692718403555_x8n7PcVN3U4CO7Sw8MJ1+wmP1lyk0cmMRxDGD2M9q8/vaD8jAf0ePHk0+elXcZVMwY19oPeKpaRKynz/iVFqeagUp/7qW1Dae5I6ZVKl0PbGss6qaJflGZZt734eToKc8G3oO+v66/v6QaVvNjywEJGWNCHk6oYrnLGBKDEQWhQoUUhTJCzUUAyw4hKxum1GMI/+2JqYGGx99d+7Vl37HoKULCk/JrX287lWuD+3kAQjZK6lWD1+jhPdiD7ww5Lvf6ycGeFV7Njy61EjSTKHkrxQRTllktAYgXs7RvpFz1lMZPD+ZvJHV2seSpCZG6wORIflKNqhj+TrbXY2xnwRX4APhvul9yxzqcH9AdE/9m6KQk3fIrGu3+DxKsqcY7SIuxU5JJRyIEOQcjb7SiEPbf/ft4cIkB091tiIbuU0i67PAiNvBV+X00dYaS7UWwJOWrpPpSMRXALKcgfhevWzCXQcPwIAWAjJrlVn/JCN1Ixngp7rhfBZGiCK7oxWSDye")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("Cookie", "BAIDUID=ABA56DFB965060981398E8DAA6C7AC09:FG=1; BAIDUID_BFESS=ABA56DFB965060981398E8DAA6C7AC09:FG=1; REALTIME_TRANS_SWITCH=1; FANYI_WORD_SWITCH=1; HISTORY_SWITCH=1; SOUND_SPD_SWITCH=1; SOUND_PREFER_SWITCH=1; Hm_lvt_64ecd82404c51e03dc91cb9e8c025574=1692718403; Hm_lpvt_64ecd82404c51e03dc91cb9e8c025574=1692718403")
req.Header.Set("DNT", "1")
req.Header.Set("Origin", "https://fanyi.baidu.com")
req.Header.Set("Referer", "https://fanyi.baidu.com/")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("sec-ch-ua", `"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var response BaiduDictApiResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
result.pronunciations = make(map[string]string)
result.pronunciations["UK"] = response.DictResult.SimpleMeans.Symbols[0].PhEn
result.pronunciations["US"] = response.DictResult.SimpleMeans.Symbols[0].PhAm
for _, part := range response.DictResult.SimpleMeans.Symbols[0].Parts {
s := fmt.Sprintf("%v%v", part.Part, strings.Join(part.Means, ";"))
result.explanations = append(result.explanations, s)
}
return &result, nil
}
4. 总结
这次实践中,我学习了更多工具的使用,并且能够灵活地利用工具来实现在线词典的调用,同时也更加深刻地掌握了 Go 的 HTTP 编程与并发编程技巧。