Go语言实战案例 | 字节青训营

120 阅读19分钟

使用Go语言进行HTTP通信

HTTP协议 与 TCP协议

TCP协议是传输层协议,主要解决数据如何在网络中传输,而HTTP是应用层协议,主要解决如何包装数据

TCP/IP和HTTP协议的关系,从本质上来说,二者没有可比性,我们在传输数据时,可以只使用( 传输层 TCP/IP协议,但是那样的话,如果没有应用层,便无法识别数据内容,如果想要使传输的数据有意义,则必须使用到 应用层协议,应用层协议有很多,比如HTTP、FTP、TELNET 等,也可以自己定义应用层协议。WEB使用HTTP协议作应用层协议,以封装HTTP 文本信息,然后使用TCP/IP做传输层协议将它发到网络上

Http协议是建立在TCP协议基础之上的,当浏览器需要从服务器获取网页数据的时候,会发出一次Http请求。Http会通过 TCP 建立起一个到服务器的连接通道,当本次请求需要的数据完毕后,Http会立即将TCP连接断开,这个过程是很短的,所以Http连接是一种短连接,是一种无状态的连接

所谓的无状态,是指浏览器每次向服务器发起请求的时候,不是通过一个连接,而是每次都建立一个新的连接。如果是一个连接的话,服务器进程中就能保持住这个连接并且在内存 中记住一些信息状态。

每次请求结束后,连接就关闭,相关的内容就释放了,所以记不住任何状态,称为无状态连接。而我们直接通过Socket编程使用TCP协议的时候,因为我们自己可以通过代码区控制什么时候打开连接什么时候关闭连接,只要我们不通过代码把连接关闭,这个连接就会在客户端和服务端的进程中一直存在,相关状态数据会一直保存着。

HTTP连接举例:

我们模拟一下TCP短连接的情况,

client向server发起连接请求,server接到请求,然后双方建立连接。

client向server发送消息,server回应client,然后一次读写就完成了,这时候双方任何一个都可以发起close操作,不过一般都是client先发起 close操作

为什么呢,一般的server不会回复完client后立即关闭连接的,当然不排除有特殊的情况。

从上面的描述看,短连接一般只会在 client/server间传递一次读写操作

短连接的操作步骤是:

建立连接——数据传输——关闭连接…建立连接——数据传输——关闭连接

解释一下几个常用词

多路复用:多路复用是一种在网络通信中使用的技术。它指的是通过一个单一的 HTTP/2 连接,可以同时发起多个请求和接收相应的多个响应消息。在这个过程中,多个请求流可以共享同一个 TCP 连接,从而实现多个数据流 并行 传输,而不必像以前那样依赖建立多个 TCP 连接。这样可以提高网络传输效率,减少连接建立和关闭的开销,提升系统的性能和响应速度。例如,在访问一个包含大量图片和其他资源的网页时,多路复用技术可以让这些资源的请求通过一个连接同时进行,加快网页的加载速度。

长连接:客户端和服务端建立连接后不进行断开,之后客户端再次访问这个服务器上的内容时,继续使用这一条连接通道。

短连接:客户端和服务端建立连接,发送完数据后立马断开连接。下次要取数据,需要再次建立连接

Http 长连接 TCP 长连接的区别

Http长连接 和 TCP长连接的区别在于: TCP 的长连接需要自己去维护一套心跳策略。而Http只需要在请求头加入Connection: keep-alive即可实现长连接

HTTP 长连接(Keep-Alive)的基本原理

HTTP 协议最初采用的是短连接方式,即客户端每发起一次请求,服务器响应后就会关闭连接。长连接(Keep-Alive)机制的出现是为了减少频繁建立和关闭连接带来的开销,允许在同一个 TCP 连接上进行多次 HTTP 请求和响应的交互

当客户端在请求头中设置Connection: keep-alive时,是在向服务器表明希望保持这个连接,以便后续继续使用它来发送其他请求,而不是请求结束后马上关闭连接。服务器如果支持长连接并且同意保持该连接,会在响应头中也返回相应的Connection: keep-alive标识,这样连接就处于保持状态,可以被后续请求复用

HTTP报文格式

HTTP特点

  • 无状态:协议对客户端没有状态存储,对事物处理没有“记忆”能力,比如访问一个网站需要反复进行登录操作
  • 无连接:HTTP/1.1之前,由于无状态特点,每次请求需要通过TCP 三次握手四次挥手,和服务器重新建立连接。比如某个客户机在短时间多次请求同一个资源,服务器并不能区别是否已经响应过用户的请求,所以每次需要重新响应请求,需要耗费不必要的时间和流量。(connection:keep-alive除外)
  • 基于请求和响应:基本的特性,由客户端发起请求,服务端响应
  • 简单快速、灵活
  • 通信使用明文、请求和响应不会对通信方进行确认、无法保护数据的完整性

HTTP通信传输

客户端输入URL回车,DNS**解析域名得到服务器的 IP 地址,服务器在80端口监听客户端请求,端口通过TCP/IP协议(可以通过Socket实现)建立连接。HTTP属于TCP/IP模型中的运用层协议,所以通信的过程其实是对应数据的** 入栈 出栈

http请求组成

由三部分组成,分别是:请求行、消息报头、请求正文

1、请求行

2、请求报头

3、请求正文

下面的内容都是请求行的内容

Method Request-URI HTTP-Version CRLF  

Method表示请求方法;

Request-URI是一个统一资源标识符;

HTTP-Version表示请求的HTTP协议版本;

CRLF表示回车和换行(除了作为结尾的CRLF外,不允许出现单独的CR或LF字符)。

请求方法(所有方法全为大写)有多种,各个方法的解释如下:

GET 请求获取Request-URI所标识的资源

POST 在Request-URI所标识的资源后附加新的数据

HEAD 请求获取由Request-URI所标识的资源的响应消息报头

PUT 请求服务器存储一个资源,并用Request-URI作为其标识

DELETE 请求服务器删除Request-URI所标识的资源

TRACE 请求服务器回送收到的请求信息,主要用于测试或诊断

CONNECT 保留将来使用

OPTIONS 请求查询服务器的性能,或者查询与资源相关的选项和需求

GET方法:在浏览器的地址栏中输入网址的方式访问网页时,浏览器采用GET方法向服务器获取资源,

eg1:GET /form.html HTTP/1.1 (CRLF)

POST方法:要求被请求服务器接受附在请求后面的数据,常用于提交表单。

eg2:POST /reg.jsp HTTP/ (CRLF)

Accept:image/gif,image/x-xbit,... (CRLF)...HOST:www.guet.edu.cn (CRLF)

Content-Length:22 (CRLF)

Connection:Keep-Alive (CRLF)

Cache-Control:no-cache (CRLF)

(CRLF) //该CRLF表示消息报头已经结束,在此之前为消息报头

user=jeffrey&pwd=1234 //此行以下为提交的数据

http响应组成

状态行、消息报头、响应正文(服务器返回的资源的内容)

HTTP-Version Status-Code Reason-Phrase CRLF

其中,HTTP-Version表示服务器HTTP协议的版本;Status-Code表示服务器发回的响应状态代码;Reason-Phrase表示状态代码的文本描述

HTTP 常见状态码总结(应用层)

HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。

  • 200 OK:请求被成功处理。比如我们发送一个查询用户数据的 HTTP 请求到服务端,服务端正确返回了用户数据。这个是我们平时最常见的一个 HTTP 状态码。
  • 201 Created:请求被成功处理并且在服务端创建了一个新的资源。比如我们通过 POST 请求创建一个新的用户。
  • 202 Accepted:服务端已经接收到了请求,但是还未处理。
  • 204 No Content:服务端已经成功处理了请求,但是没有返回任何内容。

这里格外提一下 204 状态码,平时学习/工作中见到的次数并不多。

简单来说,204 状态码描述的是我们向服务端发送 HTTP 请求之后,只关注处理结果是否成功的场景。也就是说我们需要的就是一个结果:true/false。

举个例子:你要追一个女孩子,你问女孩子:“我能追你吗?”,女孩子回答:“好!”。我们把这个女孩子当做是服务端就很好理解 204 状态码了。

3xx Redirection(重定向状态码)

  • 301 Moved Permanently:资源被永久重定向了。比如你的网站的网址更换了。
  • 302 Found:资源被临时重定向了。比如你的网站的某些资源被暂时转移到另外一个网址。

4xx Client Error(客户端错误状态码)

  • 400 Bad Request:发送的 HTTP 请求存在问题。比如请求参数不合法、请求方法错误。
  • 401 Unauthorized:未认证却请求需要认证之后才能访问的资源。
  • 403 Forbidden:直接拒绝 HTTP 请求,不处理。一般用来针对非法请求。
  • 404 Not Found:你请求的资源未在服务端找到。比如你请求某个用户的信息,服务端并没有找到指定的用户。
  • 409 Conflict:表示请求的资源与服务端当前的状态存在冲突,请求无法被处理。

5xx Server Error(服务端错误状态码)

  • 500 Internal Server Error:服务端出问题了(通常是服务端出 Bug 了)。比如你服务端处理请求的时候突然抛出异常,但是异常并未在服务端被正确处理。
  • 502 Bad Gateway:我们的网关将请求转发到服务端,但是服务端返回的却是一个错误的响应

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

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

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

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

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

trans_type: "en2zh"
{
    "trans_type": "en2zh",
    "source": "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

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

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。 不出意外的话,下面是打印的内容。 {"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

go
 代码解读
复制代码
var data = strings.NewReader(`{"trans_type":"en2zh","source":"pretty"}`)

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

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

  1. 定义struct
go
 代码解读
复制代码
type DictRequest struct {
	TransType string `json:"trans_type"`
	Source    string `json:"source"`
}

2. 创建并初始化DictRequest对象。

go
 代码解读
复制代码
request := DictRequest{TransType: "en2zh", Source: "pretty"}

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

  1. 将对象序列化为JSON字节流
go
 代码解读
复制代码
byteStream, err := json.Marshal(request)

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

  1. 将该JSON字节流读入data中。
go
 代码解读
复制代码
var data = bytes.NewReader(byteStream)

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

美观地呈现输出

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

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

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

  1. 打开浏览器,输入oktools.net/json2go,跳转到该网站。
  2. 将服务器返回的JSON数据粘贴到网站的输入框,点击转换-嵌套
  3. 点击复制,就得到了我们需要的struct了。
go
 代码解读
复制代码
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中。

go
 代码解读
复制代码
var dictResponse DictResponse
	err = json.Unmarshal(bodyText, &dictResponse)
	if err != nil {
		log.Fatal(err)
	}

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

go
 代码解读
复制代码
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)
	}

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

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

输入

cmd
 代码解读
复制代码
$ go run search.go pretty

输出

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

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

小结

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