theme: cyanosis
前言: 上次了解完 DNS 的解析过程以后,本篇将重点介绍应用层协议 http 和传输层协议 TCP 它们之间的关系,并且将重点介绍 socket。为后面讲解的 TCP 三次握手做准备🤝。
如果你还不了解 DNS 的原理,可以查阅以下文章:
传送门: 🎁 为了搞清楚DNS,我花了1.99 美金买了一个域名
一.前期准备
- 你需要大概了解过 tcp/ip 协议簇
二. 浏览器和 HTTP 之间的关系
-
HTTP(Hypertext Transfer Protocol)超文本传输协议。 我们不要忘了的字母 p 这个单词的含义,http 的本质是一个协议,它规定了客户端和服务端通信的规则(或者说是约定)。一个新协议的出现必然有它要解决的痛点,要想搞清楚它的用处我们就需要知道如果没有 http 协议会发生什么。
-
假如 A 是谷歌浏览器的开发人员,B 是火狐浏览器的开发人员,它们属于市场竞争关系,所以谁都不服谁,那么这两家平台就会都想着用自家产品的访问标准。(你可以先假设 A 规定服务端返回的消息必须用英文, B 为了和 A 不同,规定必须用中文)
-
现在有家购物平台想部署自己一台服务器,以方便大家可以网上购物,首先购物平台不可能限制用户必须使用xx浏览器才可以访问自家商场,这样会削减自家平台的流量。
-
那么此时购物平台的开发人员就得各自实现两套方案,一套给 A ,一套给 B。
-
这还仅仅只是两家,后面如果要发展更多业务, CDEF 家也有各自的规定,那相当于我一套代码要翻来覆去实现 N 多遍,且不说实现难度,光后期维护成本就极高。
-
-
这就好像我们上学的时候,假如没有课程表,英语老师和语文老师都抢着上一节课,那么整个教学模式都乱套了,不仅学生烦,老师也烦。那怎么办呢?此时有人提出了课程表的方案,“英语老师和语文老师都别吵,我们大家都按照这个表上的规则来上课”。
-
那么此时 http 就相当于课程表的方案来解决上述客户端和服务端的通讯问题。只要客户端和服务端都按照协议上制定好的规则办事,所有人皆大欢喜。
-
作为前端开发,因为我们每天都在和浏览器打交道,所以很容易陷入到一个误区:我们很容易将浏览器和 http 强绑定在一起,就好像当我们提到 http 协议的时候,就默认在说浏览器。其实不然,http 作为应用层的协议,它被设计出来的目的就是用来制定应用程序之间的通讯规则,而浏览器是选用 http 作为协议通讯规则的应用程序之一。更准确的说法应该是:
浏览器它选用了 http 协议,作为你的电脑(客户端)和你访问的站点(服务端)的 代理人 agent。当接收到服务端的资源后,它可以根据自己的内部实现,帮你正确处理这些资源。
这也是请求头中
User-Agent
字段的含义。
-
举个别的栗子🌰,我们在终端上输入
curl -v www.baidu.com
。这条命令会在终端输出 http 的请求头和百度服务器返回给你的内容,这也说明了浏览器并不是唯一选用 http 协议进行数据通讯的应用程序,于此同时,请求头中的User-Agent
就是curl
命令行工具本身。
-
但这种情况下,虽然我们能拿到服务器返回的数据,但是由于我的终端没有实现浏览器的渲染功能,所以无法正确解析主题内容中的标签信息,自然而然无法渲染成我们在浏览器直接访问百度的那个样子。
事实上这些字符串才是服务器真正返回给你的数据内容,至于页面能展示的这么优美,全是浏览器的功劳。
如果你还不了解背后的原理,建议浏览这篇文章:技术面☕️:前端代码是如何与服务器交互的]
三. HTTP 和 TCP 的关系
-
TCP (Transmission Control Protocol 传输控制协议) 是一种面向连接的、可靠的、基于字节流的传输层通信协议,在本文的主体内容中,我们主要讨论 http 和 TCP 它们之间是如何实现数据从应用层向传输层传递的。由于不是介绍 TCP 三次握手的过程,所以你无需关心上面提到的 TCP 的三个特点到底是什么含义。我们简化一下 TCP/IP 的分层模型,只观察应用层的 http 协议和传输层的 TCP 协议。
-
首先 http 是基于 TCP 的应用层协议,或者说 http 协议选择了 TCP 来负责其数据在传输层上该如何传递(因为还有 UDP 协议)。
-
这里有个很重要的点,就是 TCP 本身不产生任何数据内容,别忘了它的名字,传输、控制。它只负责如何切分应用层传递过来的数据包。客户端(或服务端)最终拿到的数据也不会包含传输层封装的任何信息。
四.* socket(套接字)
-
那么这两层到底是怎么沟通的呢?这里需要引入一个十分重要的角色—Socket(套接字)。(注意,这里并不是指前端的那个 WebSocket API)它是网络协议中的一个抽象封装📦,它作为了应用层和传输层沟通的桥梁,它封装了应用层以下网络通讯的底层原理。
-
有了 socket ,作为应用程序的开发者,只需要调用它提供的 api 就可以完成客户端到服务端的通讯,你无需关心数据在这几层之中到底是如何拆分、重组,也无需关心操作系统是如何调度这些数据最终到达网卡发送出去的。作为服务端的开发者亦是如此,无需关心数据如何从网卡传到应用层的。
-
⚠️注意:socket 其中一个核心概念就是端口号。在客户端开启 socket 的时候,往往不需要手动关心端口号,因为此时操作系统会帮你随机分配一个端口号来作为源端口,在连接断开后,这个端口会被正确释放。而服务器则必须手动指定一个监听端口。
这也很好解释,因为客户端是主动访问的一方,当服务器收到消息的时候,只需要顺便告诉服务端刚刚随机分配的端口号也不迟,如果服务器也用随机端口的机制,那么客户端在建立 socket 的时候,就无法填写目标端口 ,那么双方就无法建立通讯。
-
为什么需要端口?端口是应用程序在一台电脑上的唯一标识,如果没有端口号,只有 ip,那么就无法实现应用程序之间的沟通。设想你电脑上 QQ 发送消息的时候,即使对方电脑也登陆着 QQ,但是此时没有端口映射关系,那么当你的消息到达对方电脑后,操作系统就不知道将这个数据给对应的哪个应用程序。本来要你想要发给 QQ 结果却转发到了微信上,这不就麻烦大了吗?
所以说 ip 只实现主机到主机之间的数据传输,而 ip+ 端口号 确保了主机上应用程序之间的数据传输。
-
还是有点抽象?那么让我们换种方式来理解这个知识,下面的代码是我用前端的伪代码把 socket 提供的最典型的 API 给列举出来了。(注意:底层实现上远不止这么简单)
// 伪代码表示 socket 实现的功能 type TP = "TCP" | "UDP"; //=> transmission protocol 数据到传输层选用哪个协议 tcp 还是 udp type AF = "IPv4" | "IPv6"; //=> address family 代表数据包到网络层是选用 ipv4 还是 ipv6 export function socket(Trans_Protocol: TP,AddressFamily: AF) { //参数(ip):需要对应 socket 初始化时的 AF 的类型 //参数(port):指定服务的端口 function listen(ip: string, port: number) {} //仅服务端使用:参数1.ip地址 2.监听的端口号 function close() {} //仅服务端使用:断开服务 function connect(ip: string, port: number) {} //提供给客户端使用,参数1.服务器的ip 2.服务器的端口 function send(data){}//客户端服务的都可以使用,用来给对方<发送>数据 function receive(){}//客户端服务的都可以使用,用来接收<对方>发送的数据
-
那么对应的服务端伪代码就是:
// 上面是业务逻辑,最终获取到一个 data 数据包 const server = socket('TCP',"IPv4") const request= server.receive()//客户端的数据 if(request){ //如果接收到请求,就发送 data server.send(data) } server.listen("127.0.0.1",9876)
-
下面简单模拟一下客户端如果想要访问这个服务器的
data
数据,那么它的实现可能就是下面的伪代码。const server =socket("TCP","IPv4") server.connect("127.0.0.1",9876) const response = server.send("需要 data ") if(response){ const data= response.data //拿着 data 处理 }
-
可以看到,所有的代码都无需关心数据到底是如何传输的,你只需要确保正确的调用了 socket 提供的 api,后面的事全都由 socket 内部的底层逻辑帮你处理。
-
就好像你调取浏览器给你提供的那些 api 一样,你无需知道浏览器底层代码和编译器究竟是怎么处理的,只需要按照它告诉的使用方法去使用即可。
-
由上面的知识点可以隐隐约约感觉到,因为你无需关心应用层以下的网络协议,所以它做的事情就好像把两个电脑直接串联起来了一样,应用程序之间好似可以 “直接” 沟通,由此也对应了这个英文单词的意思,“插座🔌”。
这是我对 socket 英文的解读,并不是对 “套接字” 的解释,说实话我真的也不理解这个翻译。这个词真正的起源好像是国内早起引进国外期刊时再某论文上出现过,后来就沿用下来了,感兴趣可以去知乎搜索〈套接字的由来〉。
五. 验证 TCP 的源端口和目标端口
-
接下来让我们用
node
简单演示一下上面的过程。请复制下面的代码,然后在终端执行node ./server.js
这会开启一个本地http-server
服务。(本质上node
的http
模块帮我们封装了 socket 的调取步骤)//server.js const http = require("node:http"); const server = http.createServer((req, res) => { console.log("req", req); res.end("1"); }); //在 127.0.0.1:9876 端口开启一个 http 服务 server.listen(9876, () => { console.log("我开启了 9876"); });
-
让我们用浏览器去访问这个服务地址,那么此时的关系就是浏览器作为了客户端代理访问了这个 http 服务。
-
我们用 wireshark 来捕捉一下 tcp 连接的过程,源端口(source port) 为
62833
、目标端口(destination port)为9876
。
>关于 wireshark 我会在下一篇 TCP 三次握手详解中详细介绍,你目前只需要观察验证即可。
-
目标端口我们很明白如何得来的,因为就是我们手动设置的
9876
。
-
那源端口我们刚刚说了是操作系统分配的,我们该如何验证呢?很简单,我们只需要在连接断开之前使用
lsof -i:62833
看看谁开启的这个进程即可,可以看到我是 chrome 开启的,前面的 google 也验证了是 chrome 申请的 socoket 端口。
-
换 safari 测试。
-
wireshark 捕获到的源端口是
64987
-
然后终端
lsof -i:64987
查看结果,前面的 com.apple 证明了是 safari 开启的端口,再次验证我们的论述的没问题的。
-
而当四次挥手以后,端口也被正确释放了。(注意 FIN 标识)
-
所以此时
lsof -i:64987
也没有什么信息了。
六. 结语
如果你读到了这里,我希望你能结合🎁前端代码是如何与服务器交互的,然后重读本文的标题五,届时你会有新的感悟。
假如本文帮到了你,不妨赠人玫瑰手有余香🌹~