Go 实践之在线词典 | 青训营

519 阅读5分钟

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 标识了提供者的名称,pronunciationsexplanations 分别用于表示音标与单词释义。在 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.WaitGroupchannel,前者用于等待所有子 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),如图:

image.png

然后把结果粘贴到 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 编程与并发编程技巧。