Socket 是如何支持 HTTP 通信

1,771 阅读18分钟

1. 前言

前段时间,写了一篇 前端人必须掌握的抓包技能,主要讲解了 HTTP、HTTPS 的抓包原理以及如何使用抓包工具 Whistle,但没有说到如何抓取 Socket 包。

能否抓取 Socket 包呢?答案是可以的,本文结合抓包工具讲述 Socket 原理以及分析 Socket 是如何支持 HTTP 通信的。

2. Socket 通信

2.1 Socket 套接字

Socket 翻译为“插座”,在计算机通信领域,则称为“套接字”。套接字有很多种, 比如 DARPA Internet 地址(网际套接字)、本地节点的路径名(Unix套接字)、CCITT X.25地址(X.25 套接字)等。

其中使用以网际协议(Internet Protocol)为通信基础的网络套接字,称为网际套接字(Internet Socket),它是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。

好比现实生活中的插座,所有的家用电器要想工作都是通过插座接入获得电力供应,计算机应用进程之间想要进行网络通信就需要通过 Socket 这种约定方式,只是会基于不同的应用层协议比如 HTTP、WebSocket、DNS 对 Socket 包进行处理。

套接字 Socket=(IP地址:端口号),每一个传输层连接唯一地被通信两端的两个端点(即两个套接字)所确定。一台计算机上可以同时运行多个程序,传输层协议通过端口号识别目标应用进程。

image.png

根据数据的传输方式,可以将网际套接字分成两种类型:

  • 流格式套接字(Stream Sockets)也叫“面向连接的套接字”,使用的是 TCP 协议(The Transmission Control Protocol,传输控制协议),给应用提供了可靠、有序、基于字节流的传输服务。

  • 数据报格式套接字(SOCK_DGRAM)也叫“无连接的套接字”,使用的是 UDP 协议(User Datagram Protocol,用户数据报协议),无需建立连接,给应用提供了实时性强、高效的传输服务。

要通过互联网进行通信,至少需要一对套接字,一个运行在客户端 Client Socket,另一个运行在服务器端 Server Socket。根据连接启动的方式以及本地套接字要连接的目标,套接字之间的连接过程可以分为三个步骤:

1.服务器监听

服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。

2.客户端请求

在知道要连接的服务器端套接字的 IP 地址和端口号后,客户端的套接字提出连接请求服务端的套接字。

3.连接确认

当服务器端套接字监听到或者说接收到客户端套接字的连接请求,就会响应客户端套接字的请求,建立一个新线程,并会生成一个新的服务器端套接字描述发送给客户端。

一旦客户端确认了此描述,连接就建立好了,就可以互相发送数据。而服务器端套接字继续处于监听状态,接收其他客户端的连接请求。

以上是面向连接服务使用 TCP 协议的 Socket 数据传输通信流程。

而对于面向无连接使用 UDP 协议的 Socket 数据通信流程,这种方式无需确认对端是否存在,发送端可随时发送数据,不需要建立连接。

2.2 基于 TCP 面向连接的套接字通信

面向连接服务目标是数据能够实现可靠无差错、无重复发送,并按顺序接收,存在建立连接、维护连接和释放连接各个阶段。

2.2.1 TCP 通信示例

我们通过一个 Socket 编程写一个示例来了解具体的机制。

在操作系统中,为了统一对各种硬件的操作,简化接口,不同的硬件设备也都被看成一个文件,会分配一个 ID,这个 ID被称为文件描述符。对这些文件的操作,等同于对磁盘上普通文件的操作。其中网络连接也被看做一个文件,可以通过套接字接口(API)进行 I/O 操作。

在 mac 系统中,我们可以通过 socket() 函数来创建一个网络连接,打开一个网络文件,通过文件操作符:

  • read() 读取从远程计算机传来的数据
  • write() 向远程计算机写入数据

image.png

这里采用了 C++ 编写的实例,实现客户端发送一个消息给服务端

服务端程序:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main(){

    // 1. 初始化创建套接字
    // 参数 AF_INET 表示使用 IPv4 地址,SOCK_STREAM 表示使用面向连接的套接字,IPPROTO_TCP 表示使用 TCP 协议
    int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //

    //将套接字和IP、端口绑定
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr)); //每个字节都用0填充
    serv_addr.sin_family = AF_INET; //使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
    serv_addr.sin_port = htons(1234); //端口
    /**
    * 服务器端要用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,
    * 只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。
    **/
    bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    /**

    * 2. 进入监听状态,等待用户发起请求
    * 对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状
    * */
    listen(serv_sock, 20);
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size = sizeof(clnt_addr);
    char buffer[BUFSIZ];

    /**
    * 3. 建立连接
    * 当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。
    * 最后需要说明的是:listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,
    * 直到遇到 accept()。accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。
    * */

    int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
    read(clnt_sock, buffer, sizeof(buffer)); //接收客户端发来的数据

    // 4. 断开连接,关闭套接字
    close(clnt_sock);
    close(serv_sock);
    return 0;
}

客户端程序:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main(){

    // 1. 创建套接字
    int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    struct sockaddr_in serv_addr;

    memset(&serv_addr, 0, sizeof(serv_addr)); //每个字节都用0填充
    serv_addr.sin_family = AF_INET; //使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
    serv_addr.sin_port = htons(1234); //端口

    /**
    * 2. 建立连接
    * 通过 connect() 向服务器发起请求,服务器的IP地址和端口号保存在 sockaddr_in 结构体中。
    * 直到服务器传回数据后,connect() 才运行结束
    * */

    connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

    char bufferWrite[BUFSIZ];
    char bufferRead[BUFSIZ];
    // 获取用户输入的字符串并发送给服务器
    printf("Input a string: ");
    scanf("%s", bufferWrite);
    write(sock, bufferWrite, sizeof(bufferWrite));
    return 0;
}

完整源码tcp,运行步骤如下:

1.g++ 编译链接成可执行文件,

g++ server.cpp -o server
g++ client.cpp -o client

2.开启 Wireshark 抓包工具,

image.png

3.开启两个命令终端,先执行 server 执行文件,再执行 client 可执行文件

./server
./client

抓包工具截图如下:

image.png

WireShark 抓包是根据 TCP/IP 五层协议来的,可以看到 TCP 包:建立连接、数据传输、断开连接。

2.2.2 建立连接:TCP 三次握手

面向连接的套接字在正式通信之前要先确定一条路径,保证IP地址、端口、物理链路等正确无误。没有特殊情况的话,以后就固定地使用这条路径来传递数据包。

TCP 建立连接时要传输三个数据包,俗称三次握手。

  • [🤝 1] 套接字A:“你好,套接字 B,我这里有数据要传送给你,建立连接吧。”
  • [🤝 2] 套接字B:“好的,我这边已准备就绪。”
  • [🤝 3] 套接字A:“谢谢你受理我的请求。”

三次握手的关键是要确认对方收到了自己的数据包,这个目标就是通过“确认号(Ack)”字段实现的。

计算机会记录下自己发送的数据包序号 Seq,待收到对方的数据包的 Ack后, 检测Ack = Seq + 1是否成立,如果成立说明对方正确收到了自己的数据包,接下来从 ack 开始给对方传输数据

序列号是按顺序给发送数据的每一个字节(8 位字节)都标上编号。接收端检查接收数据 TCP 首部中的序列号和数据的长度,将自己下一步应该接收的序号作为确认应答返送回去。

image.png

因此,一个 TCP 数据包至少包含确认号和数据包序号,除此之外还包含以下标志位来表示数据包的作用,通过 0、1 表示有没有。

  • URG:紧急指针(urgent pointer)有效。
  • ACK:确认序号有效。
  • PSH:接收方应该尽快将这个报文交给应用层。
  • RST:重置连接。
  • SYN:建立一个新连接,标志连接的开始。
  • FIN:断开一个连接,标志连接的结束。

一个 TCP 头结构如下:

image.png

示例三次握手抓包如下所示:

image.png

第一次握手,客户端向服务端发送了一个激活的 SYN 标志位的数据包,表示希望建立连接。

可以看到有两个序列号,前者序号 0 是一个相对值,后者2454579144是客户端生成了一个随机数作为序号

image.png

  • 方向:客户端 -> 服务端
  • Sequence Number:
    • 相对值: 0
    • 绝对值是:2779939627
  • Acknowledgment Number:
    • 绝对值:0
    • 相对值:0
  • TCP Segment Len:0,表示没有传输数据

第二次握手,服务端返回激活的 SYN + ACK 标志位的数据包,ACK 基于客户端的 Seq 序号 + 1,表示服务端收到了连接请求,并把自己的数据包序列号发送给客户端。

image.png

  • 方向:服务端 -> 客户端
  • Sequence Number:
    • 相对值: 0
    • 绝对值:3269840988
  • Acknowledgment Number:
    • 相对值:1
    • 绝对值:2779939628
  • TCP Segment Len:0,表示没有传输数据

第三次握手,客户端在服务端的 Seq 基础上 + 1,把 ACK 发送给服务端,表示收到服务端的确认。

此时,客户端和服务端都发送了 SYN 请求,并且都收到对方的确认,表示 TCP 连接建立成功。

image.png

  • 方向:客户端 -> 服务端
  • Sequence Number:
    • 相对值:0
    • 绝对值:2779939628
  • Acknowledgment Number:
    • 相对值:1
    • 绝对值:3269840989
  • TCP Segment Len:0,表示没有传输数据

为了简单起见,如果没有特殊说明,后续讨论中出现的序号均为相对序号。

三次握手流程图如下:

image.png

总结一下三次握手的过程:

  • 起始包的 seq 都等于0
  • 三次握手中的 ack=对方上一个的seq+1
  • seq 等于对方上次的 ack号

2.2.3 数据传输

建立连接后,两台计算机就可以相互传输数据了。为了确保正常传输数据,发送端每次都要校验:

确认号 ack = tcp segment len + seq number

示例抓包如下:

image.png

1.客户端发送请求的 TCP 数据包给服务端

image.png

  • 方向:客户端 -> 服务端
  • Sequence Number: 1
  • Acknowledgment Number: 1
  • 荷载大小:1024(bytes)

2.服务端响应 TCP 数据包:

image.png

  • 方向:服务端 -> 客户端
  • Sequence Number: 1
  • Acknowledgment Number: 1025

数据传输的交互流程图如下:

image.png

数据传输过程时,还会有超时重传、滑动控制窗口提升传输效率,本文不展开,感兴趣看参考资料进一步阅读。

2.2.4 断开连接: 4 次挥手

在数据传输完毕后,通信双方要断开连接,经历 4 次挥手:

  • [👋 1] 套接字A:“任务处理完毕,我希望断开连接。”
  • [👋 2] 套接字B:“哦,是吗?请稍等,我准备一下。”
  • 等待片刻后……
  • [👋 3] 套接字B:“我准备好了,可以断开连接了。”
  • [👋 4] 套接字A:“好的,谢谢合作。”

示例抓包如下:

image.png

1.第一次挥手:当数据传输完毕后,要关闭连接,事实上,不一定非要客户端主动关闭连接,示例这里便是服务端发送FIN报文通知客户端关闭连接。

image.png

  • 方向:服务端->客户端
  • Sequence Number: 1
  • Acknowledgment Number: 1025

2.第二次挥手:客户端收到报文后,发送 ACK 给服务端端,客户端还要准备一下。

image.png

  • 方向:客户端->服务端
  • Sequence Number: 1025
  • Acknowledgment Number: 2

3.第三次挥手:客户端发送 FIN通知服务端,客户端已准备断开连接好了。

image.png

  • 方向:客户端->服务端
  • Sequence Number: 1025
  • Acknowledgment Number: 2

4.第四次挥手:服务端发送 ACK 通知客户端,客户端端收到后关闭,服务端进入 TIME_WAIT 状态,等待 2MSL 后关闭。

image.png

  • 方向:服务端->客户端
  • Sequence Number: 2
  • Acknowledgment Number: 1026

4 次挥手流程图如下:

image.png

为什么要 4 次挥手呢?

  1. 因为 tcp 是全双工模式,服务端和客户端都能发送和接收数据,在断开连接时,需要服务端和客户端都确定对方将不再发送数据,双方都需要发送 FIN 和 ACK 报文段才能断开 TCP 连接。

  2. 当服务端发送 FIN 报文段时,只是表示服务端已经没有数据要发送了,但是服务端还可以接收客户端的数据。

  3. 当客户端发送 ACK 报文段时,表示它已经知道服务端没有数据发送了,但是客户端还是可以发数据到服务端。

  4. 当客户端也发送 FIN 报文段时,表示客户端没有数据发送了。

  5. 服务端收到 FIN 报文段,发送 ACK 表示已经知道客户端没有数据发送了。

2.3 基于 UDP 无连接的套接字通信

无连接套接字遵循的是「尽最大努力交付」的原则,就是尽力而为,实在做不到了也没办法,无连接套接字提供的没有质量保证的服务。

UDP 是非连接的传输协议,没有建立连接和断开连接的过程,它只是简单的把数据丢到网络中。该服务并不能保证数据传输的可靠性,在传输过程中有可能出现数据丢失或重复,且无法保证顺序地接收到数据。

这里有一个简单的 UDP 案例:

image.png

服务端

#include <iostream>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
using namespace std;
void error(char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main()
{
    // 1. 初始化创建套接字
    int sockfd;
    sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    struct sockaddr_in serv, client;

    //将套接字和IP、端口绑定
    serv.sin_family = AF_INET;
    serv.sin_port = htons(1234);
    serv.sin_addr.s_addr = inet_addr("127.0.0.1");
    bind(sockfd, (struct sockaddr*)&serv, sizeof(serv));
    char buffer[256];
    socklen_t l = sizeof(client);
    cout << "\ngoing to recv\n";

    // 2. 接收
    int rc = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&client, &l);
    if (rc < 0)
    {
        cout << "ERROR READING FROM SOCKET";
    }
    cout << "\n the message received is : " << buffer << endl;
    // 3. 发送
    int rp = sendto(sockfd, "hi", 2, 0, (struct sockaddr *)&client, l);
    if (rp < 0)
    {
        cout << "ERROR writing to SOCKET";
    }
    close(sockfd);
}

客户端

#include <iostream>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
using namespace std;
void error(char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main()
{
    // 初始化创建套接字
    int sockfd;
    sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    struct sockaddr_in serv, client;
    serv.sin_family = AF_INET;
    // 绑定目标ip和端口
    serv.sin_port = htons(1234);
    serv.sin_addr.s_addr = inet_addr("127.0.0.1");
    char buffer[256];
    char readBuffer[2];
    socklen_t l = sizeof(client);
    socklen_t m = sizeof(serv);
    cout << "\ngoing to send\n";
    cout << "\npls enter the mssg to be sent\n";
    fgets(buffer, 256, stdin);
    // 发送
    sendto(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&serv, m);
    // 接收
    recvfrom(sockfd, readBuffer, sizeof(readBuffer), 0, (struct sockaddr *)&client, &l);
    cout << "\n the message received is : " << readBuffer << endl;
    close(sockfd);
}

抓包如下:

image.png

1.不需要建立连接,客户端直接发送数据到指定的服务端 IP 和端口

image.png

2.服务端接收到数据后,回复了 Hi 给客户端。

image.png

TCP 和 UDP 两种套接字各有优缺点:

  • 无连接套接字传输效率高,但是不可靠,有丢失数据包、捣乱数据的风险;
  • 有连接套接字非常可靠,万无一失,但是传输效率低,耗费资源多。

两种套接字的特点决定了它们的应用场景,有些服务对可靠性要求比较高,数据包必须能够完整无误地送达,那就得选择有连接的套接字(TCP 服务),比如 HTTP、FTP 等。

而另一些服务,并不需要那么高的可靠性,效率和实时才是它们所关心的,那就可以选择无连接的套接字(UDP 服务),比如 DNS、即时聊天工具等。

3. Socket 在 HTTP 中

3.1 OSI 参考模型和 TCP/IP 概念层

前面我们所说的 Socket 通信,使用了 TCP/UDP 协议,它们是传输层的通信协议,不能干「访问网页」这样的事情,因为访问网页所需要的 HTTP 协议位于应用层。

image.png

协议分层就如同计算机软件中的模块化开发,每一层的数据通信都要符合这一层的协议, 计算机分层本质上是为了更好的解耦、标准化。

通过标准化操作设计成开放式系统大有好处, 比如路由器用来完成 IP 层的交互任务,某个网络原来使用 A 公司的路由器,现要将其替换成 B 公司的,也没有问题,这是因为所有生产商都会按照 IP 层标准制造。

两台计算机进行通信时,必须遵守以下原则:

  • 必须是同一层次进行通信,每一层都有对应的协议。比如,A 计算机的应用层和 B 计算机的传输层就不能通信,因为它们不在一个层次,数据的拆包会遇到问题。
  • 每一层的功能都必须相同,也就是拥有完全相同的网络模型。
  • 数据只能逐层传输,不能跃层。
  • 每一层可以使用下层提供的服务,并向上层提供服务。

image.png

OSI 参考模型注重“通信协议必要的功能是什么”,而 TCP/IP 强调“计算机上实现协议应该开发哪种程序”。

TCP /IP 的分层中,TCP/IP 的应用程序功能不仅实现 OSI 模型中应用层的内容,还要实现会话层与表示层的功能。这些功能可由一个单一的程序实现,也可由多个程序实现。

TCP/IP 应用的架构绝大多数属于客户端/服务端模型,一般情况下,我们只需关注 TCP/IP 概念层。提供服务的程序叫做服务端,接受服务端的程序叫做客户端。在这种通信模式中,提供服务的程序会预先被部署到主机上,等待接收任何时刻客户可能发送的请求。

3.2 HTTP 示例

我们使用 Node.js 写一个简单的 HTTP 示例:

服务端:

const express = require('express')
const app = express()
    // 一次 http 请求响应结束就断开 TCP 链接  res.end('hello world');
    app.get('/', function (req, res) {  res.setHeader('Connection', 'close’); 
})
app.listen(4000)

启动服务器监听后,在浏览器中输入 http://127.0.0.1:4000 发起一个 HTTP 请求。

抓包如下:

image.png

我们使用 Node.js 写一个简单的 HTTP 示例。

服务端代码如下:

const express = require('express')
const app = express()
    // 一次 http 请求响应结束就断开 TCP 链接  res.end('hello world');
    app.get('/', function (req, res) {  res.setHeader('Connection', 'close’); 
})
app.listen(4000)

启动服务器监听后,在浏览器中输入 http://127.0.0.1:4000 发起一个 HTTP 请求。

抓包如下:

image.png

HTTP 先通过下层基于 TCP 协议的 Socket 套接字进行三次握手建立连接,然后处理 HTTP 请求与响应,最后是四次挥手,断开连接。

数据传输阶段的四个分组可以分为两组:

  1. 客户端发送的携带 HTTP 请求数据的 TCP 分组和服务端接收到该分组返回的 TCP 分组。
  2. 服务端发送的携带 HTTP 响应数据的 TCP 分组和客户端接收到该分组返回的 TCP 分组。

第一个分组是客户端发送 HTTP 请求: image.png

  • 方向:客户端 -> 服务端
  • TCP Segment Len:689
  • Sequence Number:1

第二个分组是服务端确认收到 HTTP 请求:

image.png

  • 方向:服务端 -> 客户端
  • Acknowledgemtn Number:690,即上一个 TCP 携带大小 + Seq。

第三个分组是服务端发送 HTTP 响应:

image.png

  • 方向:服务端 -> 客户端
  • Acknowledgemtn Number:690
  • TCP Segment Len:129
  • Sequence Number:1

第四个分组是客户端确认收到响应

image.png

  • 方向:客户端 -> 服务端
  • Acknowledgemtn Number:130

携带 HTTP 请求或响应数据的 TCP 分组, 通过 Seq number 和 Ack number的作用,就算同一个 TCP 链接并行发送多个 HTTP 的请求和响应,它们也能找到各自对应的那个。

另外,如果开启 TCP Keep-Alive 的情况下:

app.get('/', function (req, res) {
    res.setHeader('Connection', 'keep-alive')
    res.setHeader('Keep-alive', 'timeout=10')
    res.end('hello world');
})

重启启动服务器,不断刷新浏览器发送请求,可以看到在一个 TCP 连接内发送了多次 http 请求响应。

image.png

HTTP1.1 对请求过程做了优化,TCP 连接建立之后,我们可以进行多次 HTTP 通信,等到一个时间段无 HTTP 请求发起 TCP 才会断开连接,这就是 HTTP/1.1 带来的长连接技术。

4. 总结

本文描述什么是 Socket,怎么使用 Socket 通信,基于 Socket 常见的两种数据传输方式 TCP 和 UDP,最后说明通过计算机网络分层分析 Socket 是怎样支持 HTTP 通信的。理清 TCP 和 HTTP 的关系,理解 Socket 的通信原理,对于前端人来说是非常有必要的。对于 TCP 和 UDP,本文只是简单的说明,更多细节可以查看参考资料的相关书籍以及 RFC 文档。

相关文章

参考资料