从入门到精通:网络基础(二)

前言

在现代社会,网络技术已经成为我们日常生活和工作中不可或缺的一部分。从简单的网页浏览到复杂的分布式系统,网络技术都扮演着至关重要的角色。通过这篇文章,读者将从入门到精通,全面掌握网络编程的理论和实践。

重点摘要

网络编程是一个复杂且多样的领域,本节将重点关注以下几个方面:

  1. 理解应用层的作用,初识HTTP协议:应用层是网络协议栈的顶层,它负责处理特定的应用程序数据。
  2. 理解传输层的作用,深入理解TCP的各项特性和机制:传输层在网络协议栈中负责数据传输的可靠性和准确性。
  3. 对整个TCP/IP协议有系统的理解:TCP/IP协议是互联网的基础,理解它的工作原理是学习网络编程的关键。
  4. 对TCP/IP协议体系下的其他重要协议和技术有一定的了解:除了TCP和UDP,还有许多其他重要的协议,如HTTP、FTP、DNS等。
  5. 学会使用一些分析网络问题的工具和方法:如Wireshark、tcpdump等工具可以帮助我们分析和解决网络问题。

这些内容不仅是网络编程的理论基础,也是服务器开发程序员的重要基本功,更是各大公司笔试面试的核心考点。

应用层

应用层是网络协议栈的顶层,负责处理特定的应用程序数据。我们日常使用的许多网络应用程序,如浏览器、邮件客户端、聊天软件等,都是在应用层运行的。

再谈“协议”

协议是一种“约定”,是通信双方遵循的规则和标准。在网络编程中,socket API提供了读写数据的接口,这些接口通常以“字符串”的方式来发送接收数据。但如果我们需要传输“结构化的数据”怎么办呢?

网络版计算器

例如,我们需要实现一个服务器版的加法器。客户端需要发送两个加数,服务器进行计算后再返回结果给客户端。我们可以采用以下两种方案:

方案一

  • 客户端发送一个形如“1+1”的字符串;
  • 字符串中有两个操作数,都是整形;
  • 两个数字之间有一个字符是运算符,运算符只能是“+”;
  • 数字和运算符之间没有空格;

这种方法的优点是实现简单,但缺点是扩展性差。如果需要增加其他运算符或支持浮点数计算,就需要修改协议和解析逻辑。

方案二

  • 定义结构体来表示交互的信息;
  • 发送数据时将结构体按照一定规则转换成字符串,接收数据时再按照相同规则将字符串转化回结构体;
  • 这个过程叫做“序列化”和“反序列化”。
// proto.h 定义通信的结构体
typedef struct Request {
    int a;
    int b;
} Request;

typedef struct Response {
    int sum;
} Response;

// client.c 客户端核心代码
Request request;
Response response;

scanf("%d,%d", &request.a, &request.b);
write(fd, &request, sizeof(Request));
read(fd, &response, sizeof(Response));

// server.c 服务端核心代码
Request request;
read(client_fd, &request, sizeof(request));
Response response;
response.sum = request.a + request.b;
write(client_fd, &response, sizeof(response));

这种方法的优点是扩展性强,可以很容易地增加新的字段和功能,但缺点是需要额外的序列化和反序列化过程,增加了编程复杂度。

无论采用哪种方案,只要保证一端发送的数据能够在另一端正确解析,就可以认为协议是成功的。这种约定,就是应用层协议。

HTTP协议

虽然应用层协议是我们程序员自己定的,但实际上,已经有很多前辈定义了一些现成的、非常好用的应用层协议供我们直接使用。HTTP(超文本传输协议)就是其中之一。

认识URL

平时我们俗称的“网址”其实就是URL(Uniform Resource Locator,统一资源定位符)。URL是用来标识互联网上的资源的地址。一个完整的URL包括以下几个部分:

  1. 协议:如http、https、ftp等;
  2. 主机名:如www.example.com;
  3. 端口号:默认为80(HTTP)或443(HTTPS),但可以指定其他端口;
  4. 路径:如/index.html,表示资源在服务器上的位置;
  5. 查询参数:如?name=John&age=30,用于传递额外的信息;
  6. 片段标识符:如#section1,用于标识页面内的特定位置。
urlencode和urldecode

在URL中,某些字符(如“/”、“?”、“:”等)有特殊意义,因此不能随意出现。如果某个参数需要包含这些特殊字符,就必须先对其进行转义。转义规则如下:

  • 将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式。
  • 例如:“+”被转义成了“%2B”。
import urllib.parse

original_string = "Hello World!"
encoded_string = urllib.parse.quote(original_string)
decoded_string = urllib.parse.unquote(encoded_string)

print("Original:", original_string)
print("Encoded:", encoded_string)
print("Decoded:", decoded_string)

urldecode就是urlencode的逆过程,用于将转义后的字符还原成原始字符。这个过程在实际应用中非常常见,特别是在处理GET请求的参数时。

HTTP协议格式

HTTP请求和响应的格式包括三部分:首行、Header和Body。

HTTP请求

  • 首行:[方法] + [URL] + [版本],例如“GET /index.html HTTP/1.1”;
  • Header:请求的属性,以冒号分割的键值对,每组属性之间用\n分隔,遇到空行表示Header部分结束;
  • Body:空行后面的内容都是Body,Body允许为空字符串,如果存在Body,则在Header中会有一个Content-Length属性来标识Body的长度。

HTTP响应

  • 首行:[版本号] + [状态码] + [状态码解释],例如“HTTP/1.1 200 OK”;
  • Header:响应的属性,以冒号分割的键值对,每组属性之间用\n分隔,遇到空行表示Header部分结束;
  • Body:空行后面的内容都是Body,Body允许为空字符串,如果存在Body,则在Header中会有一个Content-Length属性来标识Body的长度。
HTTP的方法

HTTP定义了一系列方法,用于指定对资源执行的操作。常见的方法包括:

  • GET:请求获取指定资源,通常用于获取网页内容;
  • POST:向指定资源提交数据进行处理,常用于提交表单数据;
  • PUT:向指定资源位置上传最新内容,常用于更新资源;
  • DELETE:删除指定资源,常用于删除服务器上的资源;
  • HEAD:类似GET方法,但只请求资源的头部信息,不返回实际内容;
  • OPTIONS:请求指定资源的通信选项和需求,通常用于跨域请求的预检;
  • PATCH:对指定资源进行部分修改,常用于更新资源的一部分。
HTTP的状态码

HTTP状态码用于表示服务器对请求的处理结果。常见的状态码包括:

  • 1xx(信息性状态码)
    • 100 Continue:客户端应继续请求。
  • 2xx(成功状态码)
    • 200 OK:请求成功。
    • 201 Created:请求成功并创建了新的资源。
  • 3xx(重定向状态码)
    • 301 Moved Permanently:资源永久移动到新位置。
    • 302 Found:资源临时移动到新位置。
  • 4xx(客户端错误状态码)
    • 400 Bad Request:请求无效。
    • 401 Unauthorized:未授权。
    • 403 Forbidden:禁止访问。
    • 404 Not Found:请求的资源不存在。
  • 5xx(服务器错误状态码)
    • 500 Internal Server Error:服务器内部错误。
    • 502 Bad Gateway:无效网关。
    • 503 Service Unavailable:服务不可用。
HTTP常见Header

Header是HTTP请求和响应的重要组成部分,用于传递请求和响应的元数据。常见的HTTP Header包括:

  • Content-Type:表示数据类型(如text/html、application/json等);
  • Content-Length:表示Body的长度;
  • Host:客户端告知服务器,请求的资源在哪个主机的哪个端口上;
  • User-Agent:声明用户的操作系统和浏览器版本信息;
  • Referer:当前页面是从哪个页面跳转过来的;
  • Location:搭配

3xx状态码使用,告诉客户端接下来要去哪里访问;

  • Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。

User-Agent里的历史故事:User-Agent最早是用于表示浏览器的版本信息,但随着浏览器的更新换代,这个字段也变得越来越复杂。例如,早期的浏览器如Netscape和Internet Explorer都有各自的User-Agent字符串,但随着时间的推移,许多浏览器开始模仿这些字符串以提高兼容性。

最简单的HTTP服务器

实现一个最简单的HTTP服务器,只在网页上输出“hello world”。只要我们按照HTTP协议的要求构造数据,就很容易实现。

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

void Usage() {
  printf("usage: ./server [ip] [port]\n");
}

int main(int argc, char* argv[]) {
  if (argc != 3) {
    Usage();
    return 1;
  }
  int fd = socket(AF_INET, SOCK_STREAM, 0);
  if (fd < 0) {
    perror("socket");
    return 1;
  }
  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(argv[1]);
  addr.sin_port = htons(atoi(argv[2]));

  int ret = bind(fd, (struct sockaddr*)&addr, sizeof(addr));
  if (ret < 0) {
    perror("bind");
    return 1;
  }
  ret = listen(fd, 10);
  if (ret < 0) {
    perror("listen");
    return 1;
  }
  for (;;) {
    struct sockaddr_in client_addr;
    socklen_t len;
    int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
    if (client_fd < 0) {
      perror("accept");
      continue;
    }
    char input_buf[1024 * 10] = {0};  // 用一个足够大的缓冲区直接把数据读完。
    ssize_t read_size = read(client_fd, input_buf, sizeof(input_buf) - 1);
    if (read_size < 0) {
      return 1;
    }
    printf("[Request] %s", input_buf);
    char buf[1024] = {0};
    const char* hello = "<h1>hello world</h1>";
    sprintf(buf, "HTTP/1.0 200 OK\nContent-Length:%lu\n\n%s", strlen(hello), hello);
    write(client_fd, buf, strlen(buf));
  }
  return 0;
}

备注:

  • 此处使用9090端口号启动了HTTP服务器。虽然HTTP服务器一般使用80端口,但这只是一个通用的习惯,并不是说HTTP服务器就不能使用其他的端口号。
  • 使用Chrome测试我们的服务器时,可以看到服务器打出的请求中还有一个“GET /favicon.ico HTTP/1.1”这样的请求。读者可以自行查找资料,理解favicon.ico的作用。

实验:把返回的状态码改成404、403、504等,观察浏览器上的效果。

传输层

传输层负责确保数据能够从发送端可靠地传输到接收端。传输层的两个主要协议是TCP和UDP。

再谈端口号

端口号(Port)标识了一个主机上进行通信的不同应用程序。在TCP/IP协议中,用“源IP”、“源端口号”、“目的IP”、“目的端口号”、“协议号”这样一个五元组来标识一个通信。

端口号范围划分
  • 0 - 1023:知名端口号,HTTP、FTP、SSH等这些广为使用的应用层协议的端口号都是固定的。
  • 1024 - 65535:操作系统动态分配的端口号,客户端程序的端口号由操作系统从这个范围分配。
认识知名端口号(Well-Known Port Number)

有些服务器非常常用,为了使用方便,人们约定一些常用服务器使用以下固定的端口号:

  • SSH服务器,使用22端口;
  • FTP服务器,使用21端口;
  • Telnet服务器,使用23端口;
  • HTTP服务器,使用80端口;
  • HTTPS服务器,使用443端口。

我们自己写程序使用端口号时,要避开这些知名端口号。

两个问题
  1. 一个进程是否可以bind多个端口号?
  2. 一个端口号是否可以被多个进程bind?

这些问题可以通过实验和查阅相关文档来验证。

netstat

netstat是一个用来查看网络状态的重要工具。

常用选项

  • n:拒绝显示别名,能显示数字的全部转化成数字;
  • l:仅列出有在Listen(监听)的服务状态;
  • p:显示建立相关链接的程序名;
  • t:仅显示TCP相关选项;
  • u:仅显示UDP相关选项;
  • a:显示所有选项,默认不显示LISTEN相关。
pidof

pidof是一个在查看服务器的进程ID时非常方便的工具。

语法pidof [进程名]
功能:通过进程名查看进程ID。

UDP协议

UDP(User Datagram Protocol,用户数据报协议)是一种无连接、不可靠的传输层协议。

UDP协议端格式

UDP协议头包括以下字段:

  • 源端口号:16位,表示发送数据的应用程序的端口号;
  • 目的端口号:16位,表示接收数据的应用程序的端口号;
  • 长度:16位,表示整个UDP数据报的长度(包括UDP头和数据部分);
  • 校验和:16位,用于错误检测。
UDP的特点
  • 无连接:知道对端的IP和端口号就可以直接进行传输,不需要建立连接;
  • 不可靠:没有确认机制和重传机制,如果因为网络故障数据无法到达对方,UDP协议层不会返回任何错误信息;
  • 面向数据报:不能灵活控制读写数据的次数和数量,应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并。
UDP的缓冲区
  • UDP没有真正意义上的发送缓冲区。调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作;
  • UDP具有接收缓冲区,但不能保证收到的UDP报文的顺序和发送顺序一致。如果缓冲区满了,再到达的UDP数据就会被丢弃。
UDP使用注意事项
  • UDP协议首部中的最大长度为16位,即一个UDP报文的最大长度是64K(包含UDP首部)。
  • 如果需要传输的数据超过64K,就需要在应用层手动分包,多次发送,并在接收端手动拼装。
基于UDP的应用层协议
  • NFS:网络文件系统
  • TFTP:简单文件传输协议
  • DHCP:动态主机配置协议
  • BOOTP:启动协议(用于无盘设备启动)
  • DNS:域名解析协议

TCP协议

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接、可靠的传输层协议。

TCP协议段格式

TCP协议头包括以下字段:

  • 源/目的端口号:16位,表示数据从哪个进程来,到哪个进程去;
  • 序号:32位,用于标识数据段的顺序;
  • 确认号:32位,表示已成功接收的数据段序号;
  • 头部长度:4位,表示TCP头部的长度;
  • 标志位:6位,包括URG、ACK、PSH、RST、SYN、FIN;
  • 窗口大小:16位,用于流量控制;
  • 校验和:16位,用于错误检测;
  • 紧急指针:16位,标识紧急数据的位置;
  • 选项:可选字段,用于扩展TCP功能。
确认应答(ACK)机制

TCP将每个字节的数据都进行了编号,即为序列号。每一个ACK都带有对应的确认序列号,告诉发送者哪些数据已被接收,下一次从哪里开始发送。

超时重传机制

如果发送的数据在特定时间内没有收到确认应答,发送端会进行重发。这个时间间隔根据网络环境动态计算。

连接管理机制

TCP要经过三次握手建立连接,四次挥手断开连接。连接建立和断开的具体流程如下:

服务端状态转换

  • CLOSED -> LISTEN:服务器端调用listen后进入LISTEN状态,等待客户端连接;
  • LISTEN -> SYN_RCVD:监听到连接请求后,进入SYN_RCVD状态,并向客户端发送SYN确认报文;
  • SYN_RCVD -> ESTABLISHED:收到客户端确认报文后,进入ESTABLISHED状态,可以进行数据读写;
  • ESTABLISHED

-> CLOSE_WAIT:客户端主动关闭连接,服务器收到结束报文段后进入CLOSE_WAIT状态;

  • CLOSE_WAIT -> LAST_ACK:服务器调用close关闭连接后,发送FIN报文,进入LAST_ACK状态,等待客户端确认;
  • LAST_ACK -> CLOSED:收到客户端的确认后,彻底关闭连接。

客户端状态转换

  • CLOSED -> SYN_SENT:客户端调用connect,发送同步报文段;
  • SYN_SENT -> ESTABLISHED:connect调用成功后进入ESTABLISHED状态,开始数据读写;
  • ESTABLISHED -> FIN_WAIT_1:客户端主动调用close时,向服务器发送结束报文段,进入FIN_WAIT_1;
  • FIN_WAIT_1 -> FIN_WAIT_2:收到服务器确认后进入FIN_WAIT_2,等待服务器的结束报文段;
  • FIN_WAIT_2 -> TIME_WAIT:收到服务器的结束报文段后,进入TIME_WAIT,发送最后的ACK;
  • TIME_WAIT -> CLOSED:等待2MSL(Max Segment Life,报文最大生存时间)后,进入CLOSED状态。
理解TIME_WAIT状态

TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。这是为了保证最后一个报文可靠到达,防止重启服务器时收到错误的迟到数据。

解决TIME_WAIT状态引起的bind失败的方法

在某些情况下,服务器需要处理大量客户端连接,每个连接的生存时间很短,但连接请求频繁。此时,可以使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,允许创建端口号相同但IP地址不同的多个socket描述符。

理解CLOSE_WAIT状态

如果服务器上出现大量CLOSE_WAIT状态,说明服务器没有正确关闭socket,导致四次挥手没有正确完成。只需要在合适的位置加上close()即可解决问题。

滑动窗口

滑动窗口机制允许发送端在未收到确认应答前发送多个数据段,大大提高了传输效率。窗口大小表示无需等待确认应答而可以继续发送数据的最大值。窗口越大,网络的吞吐率越高。

流量控制

接收端通过TCP头部中的窗口大小字段告知发送端当前缓冲区的剩余空间,发送端根据窗口大小调整发送速度,防止缓冲区溢出。

拥塞控制

TCP引入慢启动机制,在不清楚网络状态的情况下,先发少量数据探测,逐步增加发送量。拥塞窗口(cwnd)初始为1,每次收到ACK后增加1,超过慢启动阈值后,按线性增长。

延迟应答和捎带应答

延迟应答通过稍微延迟ACK的发送时间,增加窗口大小,提高传输效率。捎带应答则利用应用层的响应数据捎带ACK一起发送。

面向字节流

TCP连接在内核中维护一个发送缓冲区和接收缓冲区,数据在缓冲区中进行处理,保证传输的可靠性和顺序性。TCP连接是全双工的,既可以读数据,也可以写数据。

粘包问题

粘包问题是指应用层看到的一串连续字节数据中无法区分出每个完整的数据包。解决方法包括:

  • 对于定长包,按照固定大小读取;
  • 对于变长包,在包头约定一个包总长度字段或使用明确的分隔符。

TCP异常情况

  • 进程终止:释放文件描述符,发送FIN,正常关闭连接。
  • 机器重启:与进程终止相同。
  • 机器掉电/网线断开:接收端发现连接已断开,进行重置或通过保活定时器释放连接。

TCP小结

TCP通过一系列复杂的机制保证传输的可靠性和高效性。这些机制包括校验和、序列号、确认应答、超时重发、连接管理、流量控制和拥塞控制等。

基于TCP的应用层协议

  • HTTP
  • HTTPS
  • SSH
  • Telnet
  • FTP
  • SMTP

当然,还包括我们自己定义的应用层协议。

TCP/UDP对比

TCP和UDP各有优缺点,适用于不同的场景。TCP适用于可靠传输,如文件传输和重要状态更新;UDP适用于对高速传输和实时性要求较高的通信,如视频传输和广播。

用UDP实现可靠传输(经典面试题)

参考TCP的可靠性机制,在应用层实现类似的逻辑,包括序列号、确认应答和超时重传等。

TCP相关实验

通过实验理解listen的第二个参数、使用Wireshark分析TCP通信流程等,进一步掌握TCP协议的工作原理和实际应用。

理解 listen 的第二个参数

在TCP服务器编程中,listen函数的第二个参数表示监听队列的最大长度。这个参数的值决定了内核为该套接字维护的两个队列的长度:

  • 半连接队列(SYN Queue):存放处于SYN_RECV状态的连接请求。
  • 全连接队列(Accept Queue):存放已完成三次握手,但应用程序尚未调用accept取走的连接。

当一个新的连接请求到达时,内核首先将其放入半连接队列。如果客户端发送的SYN报文被确认,连接进入全连接队列。如果全连接队列已满,新的连接请求将被拒绝。

#include "tcp_socket.hpp"

int main(int argc, char* argv[]) {
  if (argc != 3) {
    printf("Usage ./test_server [ip] [port]\n");
    return 1;
  }
  TcpSocket sock;
  bool ret = sock.Bind(argv[1], atoi(argv[2]));
  if (!ret) {
    return 1;
  }
  ret = sock.Listen(2);
  if (!ret) {
    return 1;
  }
  // 客户端不进行 accept
  while (1) {
    sleep(1);
  }
  return 0;
}

使用 Wireshark 分析 TCP 通信流程

Wireshark是一个强大的网络协议分析工具,可以抓取和分析网络通信数据包。以下是使用Wireshark分析TCP通信流程的步骤:

下载和安装 Wireshark

可以从Wireshark官方网站下载并安装最新版本的Wireshark。

启用 telnet 客户端

在Windows上启用telnet客户端,参考百度经验

启动 Wireshark 并设置过滤器

启动Wireshark后,在过滤器栏中输入ip.addr == [服务器 ip]tcp.port == 9090,以只抓取指定IP或端口的数据包。

观察三次握手过程

启动服务器后,使用telnet客户端连接服务器,抓取数据包并观察三次握手过程。可以看到三个报文各自的序列号和确认序号的规律。

telnet [ip] [port]
观察确认应答

在telnet中输入一个字符,可以看到客户端发送一个长度为1字节的数据,服务器返回ACK和响应数据,然后客户端反馈ACK。

观察四次挥手

在telnet中输入ctrl + ]回到控制界面,输入quit退出,可以观察四次挥手的过程。

结语

网络基础是现代计算机科学的重要组成部分,掌握网络编程的理论和实践,不仅有助于理解计算机系统的运行机制,还能提高开发高性能、可靠网络应用的能力。希望本篇文章能帮助读者从入门到精通,全面掌握网络基础知识。
嗯,就是这样啦,文章到这里就结束啦,真心感谢你花时间来读。
觉得有点收获的话,不妨给我点个吧!
如果发现文章有啥漏洞或错误的地方,欢迎私信我或者在评论里提醒一声~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

每天进步亿丢丢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值