万字长文 | Go语言实战案例(中):在线词典 | 青训营

359 阅读21分钟

Go语言实战案例(中):在线词典

实践记录 · 2023/8/1 · 玉米哥

目录

猜数游戏
在线词典
SOCKS5代理

前言

在上一篇文章中,我总结了王克纯老师布置的第一个作业:猜数游戏,并详细介绍了Go语言中输入处理的一般准则,如果你还没有看这篇文章,请点击下方链接。

Go语言实战案例(上)

......

如此之多的函数,对输入的处理各有不同,什么时候保留\n,什么时候丢弃\n,读取字符串时什么规则,读取数字时又是什么规则。纷繁复杂,无法记忆。而且记了恐怕也难以用得上,最终只会为难自己。因此,我们可以制定一种读取输入的规范,来简化输入处理的流程。

......

本篇文章,咱们主要给小伙伴讲解第二个作业:在线词典。相信看过视频讲解的同学们都很清楚,这个程序与之前的示例有很大不同。最突出的地方就在于,这个程序断网了就没办法使用。

换句话说,这是个联网的程序。更进一步,程序的输入word由用户给出,我们需要将输入发送到网络中的某个神秘的地方(其实就是一台电脑),这个地方提供服务,查询word的含义,并将结果返回到我们的所在的地方,也就是我的电脑,然后我们再根据自己的需要,输出结果到控制台、文件、数据库等地方。

让我们即刻开始今天的学习吧!

以下内容主要总结于

人类社会的信息沟通

飞鸽传书

玉米哥是古代进京赶考的一名考生,到了放榜这天,他惊喜的得知自己考中了状元。眉飞色舞之际,玉米哥想尽快告知女朋友这个好消息。无奈的是,他跋山涉水迢迢千里不远万里风雨无阻地来到了京城,可是回去不知又要几个月,这该如何是好?

好在还有信鸽可以使用,于是玉米哥赶快写好了信的内容。

橘子美女:
娘子端午安好,恩情倍加。自饮恩泽,得见君临高堂,日夜念念难忘。  

今日欣闻风声传喜,崇光榜上,登峰造极。  
状元之荣耀,叩谢天恩,谢妻之德,念昔日同携晨夕,共绘家园之幸福。

自吾云开雾散之辰,遵彼先贤之训,持素秉真,苦心经营,君孜孜不倦,瞻仰榜文,求学切求。  
功底深厚,识见高妙,可谓学富五车,文采风华。凌霄之姿,乘风翱翔,焕发光华,气蒸云梦。

夫妻之间,相知相许,手足情深,义重于山。  
吾谨记妻旧日育才之艰辛,不遗微末,不忘切身之情。  
今竟脱颖而出,不胜欣喜之至,如沐春风,荣光满怀。
谨启
玉米哥

注意:情节需要,信件内容使用AI生成

就这样,信鸽携带着玉米哥写的信,飞回家报喜去了。

邮局寄信

一晃又过了几百年,转世之后的玉米哥,现在还保留着写信的习惯,只不过现在的人们不用鸽子传信了,而是通过邮局寄信。

不像鸽子一样认路,只要带上信的内容,就能把信送回家。邮局可不认识玉米哥家里的路。写信的时候,除了信件的内容,还需要再写一些附加的信息。

  • 收信人:这封信要给谁
  • 寄信人:谁寄的这封信
  • 收信人地址:这封信要送到那里
  • 寄信人地址:送不到的时候,原路退回
  • 正文:信的内容

有了这些信息,玉米哥就能把好消息及时地告诉女朋友了。

使用HTTP协议发送信息

兜兜转转一大圈,终于来到了我们最关心的部分。玉米哥最近在学习英语六级,有个单词不认识,但是手头又没有英译汉词典。幸运的是,他知道有个网站可以查单词,叫做彩云小译

玉米哥遵循以下的步骤发送消息。

  1. 打开彩云小译官网
  2. 输入需要查询的单词
  3. 点击翻译按钮
  4. 等待页面返回结果

就这样,玉米哥查到了单词的意思,并成功通过了六级考试。那么在这一系列的过程中,究竟发生了什么呢?

查单词是一种服务,词典可以提供这项服务,英语老师可以提供这项服务,电脑也可以提供这项服务。你只要给定单词,就能得到释义。只不过你给出单词的形式不同,得到释义的形式也各不相同。

提供查单词服务的电脑,我们就把它叫做服务器。我们通过自己的电脑查单词,我们的电脑叫做客户端,毕竟客户是上帝。

现在客户端和服务器想要通信,怎么让客户端把单词发到服务器呢?

茫茫网海中,有不止一台服务器提供服务,也有不止一台客户端在消费服务。我们必须明确,需要和哪一台服务器交流。

聪明的你大致可以想一下,为了发送这一封电子“信件”,我们需要提供哪些信息。

信件封面(可能是):彩云小译服务器在网络上的地址
信件内容(可能是):你好,彩云小译服务器,我想查一下pretty这个单词是什么意思,谢谢。

说到这里,再也偷偷藏不住了,这时候不用HTTP已经没办法交流了。

HTTP协议是什么

HTTP协议是一种约定、一种规范、一种协议。就像你在寄快递时需要填写这些内容。

内容含义格式
姓名(发件人)你的姓名有效的姓名
电话(发件人)你的电话11位有效电话
地址(发件人)你的地址xx省xx市xx区xx门牌号
物品种类食品、衣物、电子产品[食品]、[衣物]、[电子产品]中选一个
姓名(收件人)别人的姓名有效的姓名
电话(收件人)别人的电话11位有效电话
地址(收件人)别人的地址xx省xx市xx区xx门牌号
快递物品

仔细观察表格,除了要快递的物品本身,我们还需要附加填写很多信息,并且这些信息都具有一定的格式和约束,归根结底,是为了促进快递的高效流通。那么HTTP协议中又有什么样的约定或者要求呢?

HTTP报文的内容

HTTP报文的结构由报文首部、空行、报文主体组成。

组成部分作用
报文首部传递信息时必不可少的一些附加信息
空行用于分割报文首部和报文主体
报文主体信息的内容

报文首部类似于快递包裹表面的快递纸,报文主体类似于快递的物品本身,空行类似于包裹,将快递纸和物品隔离开。

与看得见摸得着的快递包裹不同,HTTP报文的本质是一串字节序列,在网络流中由不同设备进行处理,因此HTTP报文的每一部分都有自己的格式要求,便于设备能够读懂。

请求报文与响应报文

回到查单词的例子,我的客户端想给彩云小译的服务器发送pretty这个单词,就需要按照HTTP协议的规定,发送一个HTTP报文。彩云小译的服务器收到请求报文并处理后,也会返回一个报文当作结果。

客户端发送到服务器的报文,叫做请求报文
服务器返回给客户端的报文,叫做响应报文

先来看一个请求报文的例子。

请求报文


POST /form/entry HTTP/1.1

Host: hackr.jp
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 16

name=ueno&age=37


根据我们刚刚介绍的报文结构,可划分为以下结构。

部分内容
报文首部POST /form/entry HTTP/1.1
Host: hackr.jp
Connection: keep-alive
Content-Type: application/
x-www-form-urlencoded
Content-Length: 16
空行
报文主体name=ueno&age=37

其中,报文首部又可以进一步划分。

请求报文的报文首部可划分为以下部分。

  • 请求行
  • 请求首部字段
  • 通用首部字段
  • 实体首部字段
  • 其他
请求行

在上面的示例中,请求行就是POST /form/entry HTTP/1.1,分别对应请求行的三个组成部分:方法URIHTTP版本

方法表示客户端访问服务器的类型,在本例中,方法是POST,除了该方法,常用的还有GET等,这些方法都是HTTP协议预先定义好的,只需要选择恰当的方法就可以。在本文中,我不对这些方法进行详细阐述,目前我们只需要暂时记住就可以。

URI表示需要访问的服务器上的资源地址,URI的全称是Uniform Resource Identifier,统一资源标识符,用于在浩瀚如烟的互联网中标记资源的位置,你可以暂时理解为查询单词的服务器所在的地址。

HTTP版本,显而易见,表示我们正在使用的HTTP协议的版本。在HTTP发展的历程中,HTTP的版本也在不断发生变化,不同的HTTP版本,使用的规范也有所不同。我们也暂时只需记住,无需深究。

首部字段

首部字段相当于寄快递所需的附加信息,这些信息绝大部分都不需要我们自己提供,浏览器会自动填写这一切,拿Host: hackr.jp举个例子,表明了HTTP报文前往的目的地,也就是域名为hackr.jp的服务器。该字段和URI结合,便能够准确地将信息发送到我们想要的那台服务器。

报文主体

报文主体就是我们要发送的内容本身,等同于包裹里的物品,信件中的正文,这里不过多解释。

响应报文

再看一个响应报文的例子。


HTTP1.1 200 OK

Date: Tue, 10 Jul 2023 15:39:30 GMT
Content-Type: text/html
Content-Length: 362

<html>...</html>


响应报文同样可划分为以下结构。

部分内容
报文首部HTTP1.1 200 OK
Date: Tue, 10 Jul 2023 15:39:30 GMT
Content-Type: text/html
Content-Length: 362
空行
报文主体<html>...</html>

响应报文的报文首部可划分为以下部分。

  • 状态行
  • 响应首部字段
  • 通用首部字段
  • 实体首部字段
  • 其他
状态行

状态行由协议版本、状态码(表示请求成功或失败的数字代码)、用以解释状态码的原因短语构成。

状态行开头的HTTP/1.1表示服务器对应的HTTP的版本。紧接着的200 OK表示请求的处理结果的状态码和原因短语。

首部字段

Date表示响应创建的日期和时间,是首部字段内的一个属性。

Content-Type: text/html表示响应报文的报文主体的内容格式,是文本类型中的html文本。除此之外,还可以是图片文件、视频文件、JSON、二进制类型等。

Content-Length: 362表示报文主体的大小。

报文主体

服务器接收到请求报文并处理后,需要返回的内容就在报文主体中,发送给客户端处理。

使用Go语言进行HTTP通信

耐心的你看到这里,相信已经对HTTP原理有一个感性的认识了,懂得了原理,就可以大刀阔斧地实战了。现在我们开始编码部分,请小伙伴接下来,一边看,一边做,加深对知识的理解。

第一步:获取请求报文和响应报文的格式和内容

  1. 打开彩云小译官网。
  2. 按下F12(或者在任意空白处单击右键,选择检查),此时,会打开浏览器的开发者工具栏
  3. 开发者工具栏的顶部区域,点击网络(如果没有,点击>>可展开更多选项卡,再点击网络)。
  4. 在网页找到单词翻译框,输入pretty
  5. 点击翻译按钮。
  6. 此时,开发者工具栏更新出许多文件,在筛选器一栏,点击Fetch/XHR,在剩下的文件中,选中dict文件。

此时,我们就找到了请求报文所在的文件,可是在实践中,该如何判断这个文件就是我们需要的文件呢?

选中dict文件后,在开发者工具栏的右侧区域,点击负载,可以看到类似下面的内容。

trans_type: "en2zh"
{
    "trans_type": "en2zh",
    "source": "pretty"
}

这是一个JSON格式的数据,竟然看到了我们想查找的单词pretty,那么这个文件很有可能就是请求报文。

再点击预览,我们能看到另一个JSON格式的数据。

{
    "rc": 0,
    "wiki": {},
    "dictionary": {
        "prons": {
            "en-us": "[ˈprɪtɪ]",
            "en": "[ˈpriti]"
        },
        "explanations": [
            "a.漂亮的,美丽的;温暖的;机灵的;优美的;好的;精致的",
            "[俗]大的",
            "ad.十分;很;颇;非常",
            "vt.使漂亮;使可爱;予以美化"
        ],
        "synonym": [
            "attractive",
            "lovely",
            "beautiful",
            "handsome",
            "good-looking"
        ],
        "antonym": [
            "ugly",
            "unattractive",
            "unsightly",
            "homely",
            "plain"
        ],
        "wqx_example": [
            [
                "a pretty penny",
                "一大笔钱,相当可观的一笔钱"
            ],
            [
                "a pretty kettle of fish",
                "一团糟,一塌糊涂"
            ],
            [
                "She is rather pretty . ",
                "她相当秀丽。"
            ]
        ],
        "entry": "pretty",
        "type": "word",
        "related": [],
        "source": "wenquxing"
    }
}

这个JSON文件竟然包含了音标、释义、反义词、近义词、例句等。看来这应该是响应报文的一部分了。

现在我们确信无疑,这个dict文件就是我们要找的文件。包含了响应报文和请求报文的信息。

第二步:构造请求报文和响应报文

在网页中查询单词,我们只需要输入单词,单击翻译就能看到结果了。

在这个过程中,浏览器帮我们做了下面的事情。

  1. 构造请求报文
  2. 将请求报文发送给服务器
  3. 接收服务器返回的响应报文
  4. 解析响应报文并将结果呈现在网页中

在我们自己的命令行程序中,步骤也很类似,只不过需要我们自己从命令行获取输入,构造请求报文,获取响应报文并将结果呈现在命令行中。

下面我们就来看看怎么做吧。

  1. 右键单击dict文件,选择复制-复制为cURL(bash)
  2. 浏览器的地址栏输入curlconverter.com/go/,转到该网站。
  3. 将步骤1复制的文本粘贴到该网站的输入框中,即可得到以下代码。
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
)

func main() {
	client := &http.Client{}
	var data = strings.NewReader(`{"trans_type":"en2zh","source":"pretty"}`)
	req, err := http.NewRequest("POST", "https://lingocloud.caiyunapp.com/v1/dict", data)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Accept", "application/json, text/plain, */*")
	req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
	req.Header.Set("Connection", "keep-alive")
	req.Header.Set("Content-Type", "application/json;charset=UTF-8")
	req.Header.Set("Cookie", "_gcl_au=1.1.757739313.1690550639; _ga_65TZCJSDBD=GS1.1.1690550638.1.0.1690550639.0.0.0; _ga_R9YPR75N68=GS1.1.1690550638.1.0.1690550639.59.0.0; _ga=GA1.2.2096512254.1690550639; _gid=GA1.2.1395040821.1690550642")
	req.Header.Set("Origin", "https://fanyi.caiyunapp.com")
	req.Header.Set("Referer", "https://fanyi.caiyunapp.com/")
	req.Header.Set("Sec-Fetch-Dest", "empty")
	req.Header.Set("Sec-Fetch-Mode", "cors")
	req.Header.Set("Sec-Fetch-Site", "same-site")
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36")
	req.Header.Set("X-Authorization", "token:qgemv4jr1y38jyq6vhvi")
	req.Header.Set("app-name", "xy")
	req.Header.Set("device-id", "02c95aaa9ec45900cfdd21cdaa76323b")
	req.Header.Set("os-type", "web")
	req.Header.Set("os-version", "")
	req.Header.Set("sec-ch-ua", `"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"`)
	req.Header.Set("sec-ch-ua-mobile", "?0")
	req.Header.Set("sec-ch-ua-platform", `"Windows"`)
	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)
}

这段代码虽然很长,我们来一段段解析。

client := &http.Client{}
var data = strings.NewReader(`{"trans_type":"en2zh","source":"pretty"}`)
	req, err := http.NewRequest("POST", "https://lingocloud.caiyunapp.com/v1/dict", data)
	if err != nil {
		log.Fatal(err)
	}

req表示我们构造的请求报文,我们使用了三个参数来初始化这个请求报文。

POST表明了请求方法,属于请求报文中报文首部的请求行。

https://lingocloud.caiyunapp.com/v1/dict表明了请求URI。也属于请求报文中报文首部的请求行。

data属于请求报文的报文主体。是一个reader,输入源是字符串构造的JSON数据,里面包含了我们的单词pretty

如果你还不知道reader是什么,请阅读我的上一篇文章Go语言实战案例(上),我从猜数游戏拓展了Go语言中有关输入处理的方方面面。

通过上面的代码,我们就能构造出一个请求报文,下面的代码是对请求报文的进一步完善。

req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zhq=0.9")
req.Header.Set("Connection","keep-alive")
req.Header.Set("Content-Type","application/json;charset=UTF-8")
......(此处省略)......
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform",`"Windows"`)

每一行代码的开头都是req.Header.Set(),过了英语六级的小伙伴,一定能看懂这是什么意思。这不就是设置请求报文的报文首部吗,和我文章中介绍HTTP部分的内容如出一辙,如果你认真看完了我文章的开头,一定会懂这是在做什么。

代码中设置的首部字段我不在这里讲解,感兴趣的小伙伴可以自己查阅,总之,这些字段就是为了客户端和服务器能够更好的通信,类似于快递包裹表面的快递纸包含的内容。

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)

接下来的操作就很简单了,客户端将构造好的请求报文发送给服务器,并从服务器返回的响应报文中找到报文主体,将报文主体的内容全部读取到bodyText中,然后以文本形式打印bodyText

这个例子很好地解释了reader的输入源不仅有键盘、文件、字符串,还有网络上的内容。我在文章Go语言实战案例(上)中详细解释了这几种不同类的输入源,想深入了解的小伙伴快去看看吧。

不出意外的话,下面是打印的内容。

{"rc":0,"wiki":{},"dictionary":{"prons":{"en-us":"[\u02c8pr\u026at\u026a]","en":"[\u02c8priti]"},"explanations":["a.\u6f02\u4eae\u7684,\u7f8e\u4e3d\u7684;\u6e29\u6696\u7684;\u673a\u7075\u7684;\u4f18\u7f8e\u7684;\u597d\u7684;\u7cbe\u81f4\u7684","[\u4fd7]\u5927\u7684","ad.\u5341\u5206;\u5f88;\u9887;\u975e\u5e38","vt.\u4f7f\u6f02\u4eae;\u4f7f\u53ef\u7231;\u4e88\u4ee5\u7f8e\u5316"],"synonym":["attractive","lovely","beautiful","handsome","good-looking"],"antonym":["ugly","unattractive","unsightly","homely","plain"],"wqx_example":[["a pretty penny","\u4e00\u5927\u7b14\u94b1,\u76f8\u5f53\u53ef\u89c2\u7684\u4e00\u7b14\u94b1"],["a pretty kettle of fish","\u4e00\u56e2\u7cdf,\u4e00\u584c\u7cca\u6d82"],["She is rather pretty . ","\u5979\u76f8\u5f53\u79c0\u4e3d\u3002"]],"entry":"pretty","type":"word","related":[],"source":"wenquxing"}}

看来这个响应报文的主体是JSON数据,但不幸的是,我们的黑框框目前还没有对数据做出美化,只能呈现原始的丑陋的数据。而浏览器则使用了HTML、CSS等工具呈现美观的数据。

第三步:特定于程序的改造

到这步,我们的任务已经完成了一大半了,在黑框框程序中上网,将想查询的单词发送给服务器,然后显示结果。

从用户获取输入

但是目前,要想修改查询的单词,我们只能在下面的代码中修改data

var data = strings.NewReader(`{"trans_type":"en2zh","source":"pretty"}`)

仔细分析,data是一个从字符串获取输入的reader,并且输入的内容是JSON结构的数据。而在Go语言中,struct和JSON数据是可以相互转换的。

因此,我们可以定义一个struct,在程序运行时由用户输入source,然后将该struct序列化为JSON数据,并读入data中。

  1. 定义struct
type DictRequest struct {
	TransType string `json:"trans_type"`
	Source    string `json:"source"`
}
  1. 创建并初始化DictRequest对象。
request := DictRequest{TransType: "en2zh", Source: "pretty"}

小伙伴可能会有疑惑,pretty不还是在代码中吗,其实这是一个对象初始化语句,只需要先创建一个变量word,然后使用word初始化该对象中的Source即可。

  1. 将对象序列化为JSON字节流
byteStream, err := json.Marshal(request)

如果你还不知道什么是序列化和反序列化,请阅读我的第二篇文章,Go语言入门指南:基础语法(下),这篇文章详细解释了序列化和反序列化的含义和来龙去脉。

  1. 将该JSON字节流读入data中。
var data = bytes.NewReader(byteStream)

上面的代码创建了一个reader,并且指定了输入源是字节流。现在我们已经准备好data了,后面的代码和示例就一摸一样了。

美观地呈现输出

上文提到,如果不做任何处理,那么服务器返回的JSON数据将会被原原本本的展现出来,很丑陋。那应该怎么办呢?

答案很简单,只要我们定义一个和JSON数据每一项都对应的struct,将JSON数据反序列化到这个struct中,然后操作这个struct控制输出就可以了。

但是输出的这个JSON数据又很多项,手工敲代码又累又容易出错,我们可以使用克纯老师介绍的工具来自动生成struct

  1. 打开浏览器,输入oktools.net/json2go,跳转到该网站。
  2. 将服务器返回的JSON数据粘贴到网站的输入框,点击转换-嵌套
  3. 点击复制,就得到了我们需要的struct了。
type DictResponse struct {
	Rc   int `json:"rc"`
	Wiki struct {
	} `json:"wiki"`
	Dictionary struct {
		Prons struct {
			EnUs string `json:"en-us"`
			En   string `json:"en"`
		} `json:"prons"`
		Explanations []string      `json:"explanations"`
		Synonym      []string      `json:"synonym"`
		Antonym      []string      `json:"antonym"`
		WqxExample   [][]string    `json:"wqx_example"`
		Entry        string        `json:"entry"`
		Type         string        `json:"type"`
		Related      []interface{} `json:"related"`
		Source       string        `json:"source"`
	} `json:"dictionary"`
}

如果你阅读过我的第二篇文章Go语言入门指南:基础语法(下),你应该熟悉服务器返回的JSON数据是字节流,现在我们可以将得到的bodyText字节流反序列化到新定义的DictResponse中。

var dictResponse DictResponse
	err = json.Unmarshal(bodyText, &dictResponse)
	if err != nil {
		log.Fatal(err)
	}

现在dictResponse就包含了我们需要的内容,单词的音标、释义等,我们使用基本的循环控制流优雅地输出这些内容。

fmt.Println(dictResponse.Dictionary.Entry, "en-us", dictResponse.Dictionary.Prons.EnUs, "en", dictResponse.Dictionary.Prons.En)
	for _, explanation := range dictResponse.Dictionary.Explanations {
		fmt.Println(explanation)
	}

输出代码根据对象的结构实现,可能不唯一

运行程序,可以看到下面的过程了。

输入

$ go run search.go pretty

输出

pretty en-us [ˈprɪtɪ] en [ˈpriti]
a.漂亮的,美丽的;温暖的;机灵的;优美的;好的;精致的
[俗]大的
ad.十分;很;颇;非常
vt.使漂亮;使可爱;予以美化

虽然依旧很丑陋,但是相比直接输出JSON数据,还是要优雅不少的,小伙伴,学会了吗?

小结

到目前为止,我们终于做出了一个在线查词的程序,能够构造请求报文,将报文发送给服务器,并以各种形式利用返回的响应报文,达成我们自己的目的,希望小伙伴看完后能有所收获。

下一步

呼,终于给文章收尾了,读完这一篇文章,希望你能够对互联网上数据的传输有更为深刻的认识,同时理解HTTP的基本原理,并能够用Go语言来完成客户端和服务器之间的通信。如果你想了解更多关于HTTP协议的内容,可以阅读《图解HTTP》,如果你对计算机网络感兴趣,可以阅读《网络是怎样连接的 》。想获取这两本书的电子版,小伙伴可以私聊我。

码字不易,如果您看到了这里,听我说谢谢你😀

如果您觉得本文还不错,请留下小小的赞😀

如果您有感而发,请留下宝贵的评论😀