TCP服务器实现全流程解析(简易回声服务端):从套接字创建到请求处理

目录

一、服务端套接字创建(监听套接字)

1、套接字创建基础

2、参数配置详解

[2.1 协议家族(domain)设置](#2.1 协议家族(domain)设置)

[2.2 服务类型(type)设置](#2.2 服务类型(type)设置)

[2.3 协议类型(protocol)设置](#2.3 协议类型(protocol)设置)

3、TCP服务器套接字创建实现

二、服务端套接字绑定(监听套接字的绑定)

1、套接字绑定的必要性

2、绑定操作的详细步骤

[1. 准备地址结构体](#1. 准备地址结构体)

[2. 填充地址信息](#2. 填充地址信息)

[3. 内存清零(将整个结构体清零)](#3. 内存清零(将整个结构体清零))

[4. 执行绑定操作](#4. 执行绑定操作)

3、TCP服务器套接字绑定实现

4、关键点说明

[1. INADDR_ANY的使用](#1. INADDR_ANY的使用)

[2. 端口号转换](#2. 端口号转换)

[3. 错误处理](#3. 错误处理)

[4. UDP服务器的绑定](#4. UDP服务器的绑定)

5、扩展说明

bzero函数

地址结构体的其他形式

三、TCP服务器监听机制(将监听套接字设定为监听状态)

1、UDP与TCP服务器初始化对比

2、listen函数

参数说明

返回值

3、TCP服务器监听实现

4、关键点说明

5、监听状态的意义(了解,后面会详细讲解)

6、后续步骤

四、TCP服务器获取连接(通过监听套接字获取连接套接字)

1、TCP服务器初始化与连接获取流程

2、accept()函数详解

函数原型

参数说明

返回值

关键特性

3、监听套接字与连接套接字的分工(超级重要!!!重点记忆!!!)

[1. 监听套接字](#1. 监听套接字)

[2. 已连接套接字](#2. 已连接套接字)

[3. 区别与联系(总结)](#3. 区别与联系(总结))

[4. 联系](#4. 联系)

[5. 工作流程回顾(结合比喻)](#5. 工作流程回顾(结合比喻))

4、服务端获取连接的完整实现

5、服务端测试方法

测试程序

测试步骤

连接测试方法

方法1:使用telnet测试

[最可能的原因:TIME_WAIT 状态](#最可能的原因:TIME_WAIT 状态)

什么是TIME_WAIT?

函数参数详解

各参数含义

实际效果

[为什么需要 &(取地址)?](#为什么需要 &(取地址)?)

方法2:使用浏览器测试

方法3:使用nc(netcat)测试

6、关键注意事项

7、常见问题解答

五、TCP服务器请求处理

1、服务端处理请求概述

2、回声服务器实现

3、核心系统调用函数

[1. read函数(从套接字读出数据然后写到缓冲区里面)](#1. read函数(从套接字读出数据然后写到缓冲区里面))

[2. write函数(从缓冲区里拿出数据然后写到套接字当中)](#2. write函数(从缓冲区里拿出数据然后写到套接字当中))

4、服务端请求处理实现

处理流程要点

完整代码实现

5、关键实现细节

6、异常处理与健壮性考虑

7、测试步骤

[1. 启动你的服务器](#1. 启动你的服务器)

[2. 使用 Telnet 连接测试](#2. 使用 Telnet 连接测试)

[3. 测试回声功能](#3. 测试回声功能)

[4. 测试多行消息](#4. 测试多行消息)

[5. 查看服务器日志](#5. 查看服务器日志)

[6. 断开连接](#6. 断开连接)

8、测试要点说明

六、补充扩展:浏览器是什么?

1、浏览器的核心功能

[2、为什么 http://公网IP:8081 能访问你的服务?](#2、为什么 http://公网IP:8081 能访问你的服务?)

[通信流程:浏览器 → 公网IP → 云服务商网络 → 你的服务器 → 你的应用(8081端口)](#通信流程:浏览器 → 公网IP → 云服务商网络 → 你的服务器 → 你的应用(8081端口))

3、浏览器是"万能客户端"吗?

浏览器能处理的协议:

浏览器不能直接处理的:

4、关键理解:HTTP协议是基础

5、实际测试例子

如果你的服务返回纯文本:

如果你的服务返回HTML:浏览器会渲染成漂亮的页面。

6、总结


一、服务端套接字创建(监听套接字)

在TCP服务器实现中,我们将服务器功能封装为一个类结构。当实例化服务器对象后,首要任务就是进行初始化配置,而创建套接字(Socket)是整个初始化流程中的关键第一步。下面将详细阐述TCP服务器创建套接字的技术细节和实现要点。

1、套接字创建基础

套接字是网络通信的基石,它为应用程序提供了网络通信的端点。在TCP服务器实现中,我们通过调用系统级的socket()函数来创建套接字,该函数的原型通常如下:

cpp 复制代码
int socket(int domain, int type, int protocol);

2、参数配置详解

2.1 协议家族(domain)设置

cpp 复制代码
AF_INET
  • 选择依据 :我们选择AF_INET协议家族,因为它专门用于IPv4网络通信

  • 技术说明 :该参数指定套接字使用的地址族,AF_INET表示使用32位IPv4地址格式

  • 扩展知识 :对于IPv6网络,应使用AF_INET6;本地通信可使用AF_UNIX

2.2 服务类型(type)设置

cpp 复制代码
SOCK_STREAM
  • 选择依据:作为TCP服务器,必须选择面向连接的流式套接字

  • 特性说明

    • **有序传输:**保证数据按发送顺序接收

    • **可靠传输:**通过确认机制确保数据完整到达

    • **全双工:**支持双向同时通信

    • **面向连接:**需要建立连接(三次握手)和断开连接(四次挥手)

  • 对比说明 :与SOCK_DGRAM(UDP无连接数据报)形成对比

2.3 协议类型(protocol)设置

cpp 复制代码
0
  • 设置原理 :当type参数已明确指定服务类型时,protocol参数可设为0

  • 系统行为:操作系统会根据前两个参数自动选择合适的协议(TCP对应IPPROTO_TCP)

  • 特殊情况:若需显式指定协议(如使用原始套接字),则需设置具体协议号

**3、**TCP服务器套接字创建实现

调用socket()函数可创建网络通信端口,成功时返回文件描述符,类似于open()函数:

  • 应用程序可通过read/write像操作文件一样进行网络数据传输

  • 调用失败时返回-1错误码

  • 使用IPv4协议时需将family参数设为AF_INET

  • 采用TCP协议时,type参数应设为SOCK_STREAM(面向流的传输协议)

  • protocol参数通常设为0即可(其他情况可省略说明)

套接字创建失败处理优化:当创建套接字返回的文件描述符小于0时,表明套接字创建失败,此时应立即终止程序并跳过后续操作。

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>

class TcpServer
{
public:
	void InitServer()
	{
		//创建套接字
		_sock = socket(AF_INET, SOCK_STREAM, 0);
		if (_sock < 0){
			std::cerr << "socket error" << std::endl;
			exit(2);
		}
	}
	~TcpServer()
	{
		if (_sock >= 0){
			close(_sock);
		}
	}
private:
	int _sock; //套接字
};

补充说明:

  • 在实际应用中,TCP服务器和UDP服务器创建套接字的流程基本相同,区别仅在于套接字类型的选择:TCP需要指定为流式服务(SOCK_STREAM),而UDP则需指定为用户数据报服务(SOCK_DGRAM)。

  • 在服务器析构时,只需关闭对应的文件描述符即可。


二、服务端套接字绑定(监听套接字的绑定)

1、套接字绑定的必要性

在创建完套接字后,我们实际上只是在操作系统层面打开了一个文件描述符,这个套接字还没有与任何具体的网络地址或端口关联。因此,创建套接字后必须调用bind()函数将其绑定到特定的网络地址和端口上,这样才能使服务端能够接收来自客户端的连接请求。

2、绑定操作的详细步骤

1. 准备地址结构体

我们需要定义并填充一个struct sockaddr_in结构体,该结构体用于存储IPv4地址信息:

cpp 复制代码
struct sockaddr_in {
    short            sin_family;   // 地址族,如AF_INET
    unsigned short   sin_port;     // 16位端口号,网络字节序
    struct in_addr   sin_addr;     // 32位IPv4地址,网络字节序
    char             sin_zero[8];  // 未使用,通常填充为0
};

2. 填充地址信息

在填充地址信息时需要注意以下几点:

  • 协议家族 :必须设置为AF_INET表示IPv4协议

  • 端口号 :需要使用htons()函数将主机字节序转换为网络字节序

  • IP地址

    • 可以设置为127.0.0.1表示仅接受本地回环连接

    • 可以设置为具体的公网IP地址

    • 在云服务器环境中,通常设置为INADDR_ANY(0.0.0.0)表示本机接受集成在本机中的所有网络接口的连接(从本地(这个本地是指服务器的本地)任何一张网卡当中读取数据)。当服务器拥有多个网卡或单个网卡绑定多个IP时,使用该设置可以让服务器在所有IP地址上监听请求,直到与客户端建立连接时才确定具体使用的IP地址。

3. 内存清零(将整个结构体清零)

在填充结构体之前,建议先使用memset()bzero()将结构体内存清零:

cpp 复制代码
struct sockaddr_in local;
memset(&local, 0, sizeof(local));  // 或者使用 bzero(&local, sizeof(local));

4. 执行绑定操作

调用bind()系统调用将套接字与地址绑定:

绑定实际上是将文件与网络建立关联。若绑定失败,则无需继续后续操作,直接终止程序即可。

cpp 复制代码
if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
    std::cerr << "bind error" << std::endl;
    exit(3);
}

3、TCP服务器套接字绑定实现

服务器程序通常监听固定的网络地址和端口号,客户端在获知这些信息后即可发起连接请求。服务器需要通过bind()函数绑定指定的地址和端口。

关键点:

  • bind()成功返回0,失败返回-1

  • 该函数将socket文件描述符(sockfd)与指定地址(myaddr)绑定,使sockfd能够监听该地址和端口

  • 参数myaddr使用通用指针类型struct sockaddr*,可接受不同协议的地址结构

  • 由于各协议地址结构长度不同,需通过addrlen参数明确指定结构体长度

**在服务器类中需要引入端口号参数,因为TCP服务器初始化时必须指定监听端口。实例化服务器对象时需传入端口号参数。由于使用的是云服务器,绑定IP地址时可直接使用INADDR_ANY而无需指定公网IP,因此服务器类中未包含IP地址参数。**以下是TCP服务器绑定的完整实现示例:

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

class TcpServer {
public:
    TcpServer(int port)
        : _sock(-1)
        , _port(port)
    {}

    void InitServer() {
        // 1. 创建套接字
        _sock = socket(AF_INET, SOCK_STREAM, 0);
        if (_sock < 0) {
            std::cerr << "socket error" << std::endl;
            exit(2);
        }

        // 2. 准备地址结构体并清零
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));  // 或者使用 bzero(&local, sizeof(local));

        // 3. 填充地址信息
        local.sin_family = AF_INET;                     // IPv4协议
        local.sin_port = htons(_port);                  // 端口号,转换为网络字节序
        local.sin_addr.s_addr = INADDR_ANY;             // 绑定到所有网络接口

        // 4. 执行绑定操作
        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            std::cerr << "bind error" << std::endl;
            exit(3);
        }
    }

    ~TcpServer() {
        if (_sock >= 0) {
            close(_sock);
        }
    }

private:
    int _sock;  // 监听套接字
    int _port;  // 服务器端口号
};

4、关键点说明

1. INADDR_ANY的使用

  • 在云服务器环境中,通常使用INADDR_ANY(0.0.0.0)而不是特定IP地址

  • 这样服务器会监听所有网络接口上的连接请求

  • 不需要进行网络字节序转换,因为0在任何字节序下都是0

2. 端口号转换

  • 必须使用htons()将主机字节序转换为网络字节序

  • 这是因为不同CPU架构可能使用不同的字节序(大端/小端)

3. 错误处理

  • 绑定失败通常意味着端口已被占用或没有权限

  • 在Linux中,1024以下的端口需要root权限

4. UDP服务器的绑定

  • TCP和UDP服务器的绑定过程完全相同

  • 区别在于创建套接字时使用的类型(SOCK_STREAM vs SOCK_DGRAM)

5、扩展说明

bzero函数

bzero()是一个传统的BSD函数,用于将内存区域清零:

cpp 复制代码
void bzero(void *s, size_t n);

虽然在现代C++中更推荐使用memset(),但bzero()在某些代码库中仍然可见。它的功能与以下memset()调用等价:

cpp 复制代码
memset(s, 0, n);

地址结构体的其他形式

除了sockaddr_in(用于IPv4),还有:

  • sockaddr_in6:用于IPv6

  • sockaddr:通用地址结构体,用于接受多种类型的地址

在调用bind()等函数时,通常需要将特定类型的地址结构体强制转换为sockaddr*类型。通过以上详细的步骤和说明,我们可以清楚地理解服务端套接字绑定的全过程及其背后的原理。


三、TCP服务器监听机制(将监听套接字设定为监听状态)

1、UDP与TCP服务器初始化对比

UDP服务器和TCP服务器在初始化流程上有显著差异:

UDP服务器

  1. 创建套接字

  2. 绑定地址和端口

  3. 无需监听,直接进入数据接收/发送状态

TCP服务器

  1. 创建套接字

  2. 绑定地址和端口

  3. 必须设置为监听状态 才能接收客户端连接请求**(TCP服务器需要持续监听客户端的连接请求,这要求将服务器创建的套接字设置为监听状态。)**

  4. 然后才能进行后续的连接接受和数据交换

这种差异源于TCP的面向连接特性。TCP需要维护连接状态,而UDP是无连接的。

2、listen函数

listen()函数是将TCP服务器套接字设置为被动监听状态的关键函数:

cpp 复制代码
int listen(int sockfd, int backlog);

listen()函数将sockfd设置为监听状态,最多允许backlog个客户端处于连接等待队列。若超过该数量的连接请求将被自动忽略。通常建议将该值设置为较小的数值(如5)。

参数说明

sockfd

  • 需要设置为监听状态的套接字文件描述符

  • 必须是通过socket()创建的TCP套接字(SOCK_STREAM类型)

backlog(要重点理解!!!)

  • 定义全连接队列(已完成连接队列)的最大长度

  • 当多个客户端同时发起连接请求时,未被accept()处理的连接会暂存在此队列

  • 建议值:5-10,过大可能占用过多系统资源

  • 注意:现代Linux系统可能使用/proc/sys/net/core/somaxconn的值作为实际上限

listen() 函数的第二个参数指的是「完整连接队列」的长度,不是两个队列的总和。

1. TCP 连接的两种状态

  • 半连接队列(SYN Queue):收到 SYN,但未完成三次握手

  • 完整连接队列(Accept Queue) :已完成三次握手,等待 accept() 取出

2. listen() 的第二个参数

  • backlog只控制完整连接队列的最大长度

  • 不影响半连接队列(半连接队列大小由系统参数控制)

实际工作流程

复制代码
客户端 SYN ──→ 半连接队列 ──→ 三次握手完成 ──→ 完整连接队列 ──→ accept() 取出
            (SYN_RCVD状态)                 (ESTABLISHED状态)

重要区别

  • 半连接队列大小 :由 /proc/sys/net/ipv4/tcp_max_syn_backlog 控制

  • 完整连接队列大小 :由 listen()backlog 参数控制

总结:listen() 第二个参数只控制「完整连接队列」的长度!

返回值

  • 成功:返回0

  • 失败:返回-1,并设置errno表示具体错误

3、TCP服务器监听实现

**TCP服务器在完成套接字创建和绑定后,需将套接字设为监听状态以接收新连接。若监听失败,则服务器无法处理客户端连接请求,此时应立即终止程序。**以下是完整的TCP服务器监听实现示例:

cpp 复制代码
#include <iostream>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#define BACKLOG 5  // 连接队列长度

class TcpServer {
public:
    TcpServer(int port) : _port(port), _listen_sock(-1) {}
    
    ~TcpServer() {
        if (_listen_sock >= 0) {
            close(_listen_sock);
        }
    }
    
    void InitServer() {
        // 1. 创建套接字
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listen_sock < 0) {
            std::cerr << "socket creation error: " << strerror(errno) << std::endl;
            exit(EXIT_FAILURE);
        }
        
        // 2. 设置套接字选项(可选,用于解决地址已在使用中的问题)
        int opt = 1;
        if (setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
            std::cerr << "setsockopt error: " << strerror(errno) << std::endl;
            close(_listen_sock);
            exit(EXIT_FAILURE);
        }
        
        // 3. 绑定地址和端口
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;  // 监听所有网络接口
        
        if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            std::cerr << "bind error: " << strerror(errno) << std::endl;
            close(_listen_sock);
            exit(EXIT_FAILURE);
        }
        
        // 4. 设置为监听状态
        if (listen(_listen_sock, BACKLOG) < 0) {
            std::cerr << "listen error: " << strerror(errno) << std::endl;
            close(_listen_sock);
            exit(EXIT_FAILURE);
        }
        
        std::cout << "Server initialized successfully, listening on port " << _port << std::endl;
    }
    
    // 其他方法如AcceptConnection()等可以在此处添加
    
private:
    int _listen_sock;  // 监听套接字
    int _port;         // 服务器端口号
};

补充说明:

TCP服务器初始化时创建的套接字并非普通套接字,而是专用的监听套接字。为明确其用途,我们将代码中的变量名从"sock"改为"listen_socket"。

需要注意的是,TCP服务器的初始化必须完成以下三个步骤才算成功:(只有这三个步骤都完成后,TCP服务器才算初始化完成)

  1. 成功创建套接字

  2. 成功绑定端口

  3. 成功开启监听

4、关键点说明

监听套接字(后面会对比用于监听的套接字和接收的套接字)

  • 专门用于监听连接请求的套接字

  • 命名使用_listen_sock比通用sock更清晰表达意图

  • 通常一个TCP服务器只需要一个监听套接字

错误处理 :每个系统调用后都检查返回值、使用strerror(errno)输出具体错误信息、失败时清理资源并退出程序

资源管理:析构函数中确保套接字被关闭、使用RAII原则管理资源

可选优化SO_REUSEADDR选项允许快速重启服务器、避免"Address already in use"错误

5、监听状态的意义(了解,后面会详细讲解)

将套接字设置为监听状态后:

  1. 服务器可以接收客户端的连接请求(SYN包)

  2. 系统内核会维护两个队列:

    • 半连接队列(SYN队列):已收到SYN但未完成三次握手的连接

    • 全连接队列(ACCEPT队列) :已完成三次握手等待被accept()的连接

  3. 只有设置为监听状态的套接字才能使用accept()函数接受新连接

6、后续步骤

完成监听设置后,TCP服务器通常需要:

  1. 使用accept()接受新连接

  2. 为每个连接创建新的套接字进行数据通信

  3. 可能使用多路复用(select/poll/epoll)管理多个连接

这个监听状态是TCP服务器能够处理多个客户端连接请求的基础机制。


四、TCP服务器获取连接(通过监听套接字获取连接套接字)

1、TCP服务器初始化与连接获取流程

TCP服务器在完成初始化后,需要进入一个持续运行的循环来获取客户端的连接请求。这个过程主要依赖于accept()系统调用,它是TCP服务器实现并发处理的关键函数。

2、accept()函数详解

函数原型

cpp 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数说明

  1. sockfd:监听套接字的文件描述符,服务器通过这个套接字监听新的连接请求

  2. addr :指向sockaddr结构体的指针,用于返回客户端的地址信息(协议家族、IP地址、端口号等)

  3. addrlen:输入输出型参数:

    • 调用时:传入addr结构体的长度

    • 返回时:实际填充的addr结构体的长度

返回值

  • 成功:返回一个新的套接字文件描述符,用于与客户端通信(注意!!!不是监听套接字,而是使用和通过监听套接字来得到一个接收套接字!!!这两者不同!!!)

  • 失败:返回-1,并设置errno错误码

关键特性

  • 阻塞调用 :默认情况下,accept()会阻塞直到有新的连接到达

  • 返回新套接字:每次成功调用都会创建一个新的套接字,专门用于与该客户端通信

3、监听套接字与连接套接字的分工(超级重要!!!重点记忆!!!)

监听套接字

  • **职责:**仅用于监听和接受新的连接请求

  • **特点:**始终保持打开状态,处理完一个连接后可以继续接受新连接

  • **生命周期:**贯穿整个服务器运行期间

连接套接字(accept返回的套接字):

  • **职责:**用于与特定客户端进行双向通信

  • **特点:**每个连接套接字只服务于一个客户端连接

  • 生命周期:accept()返回开始,到连接关闭结束

我们可以用一个非常贴切的酒店比喻来讲解TCP的监听套接字和已连接套接字的区别与联系。如下:

  • 监听套接字 :就像酒店门口专门负责迎宾和引路的接待员

  • 已连接套接字 :就像酒店里为某张特定餐桌服务的专属服务员

1. 监听套接字

  • 角色 :酒店的接待员

  • 工作地点 :固定站在酒店大门口

  • 职责

    1. 等待任何想进店吃饭的客人(客户端连接请求)。

    2. 当有客人来时,确认有空位(服务器有能力处理),然后欢迎客人。

    3. 关键一步 :接待员自己不服务客人 ,而是呼叫一位空闲的服务员过来,把客人交给这位服务员。之后,接待员回到门口,继续等待下一位客人。

  • 特点

    • 长期存在:只要酒店营业,接待员就在门口。

    • 不处理具体事务:不负责点菜、上菜,只负责"建立初次联系"。

    • 端口固定 :酒店的地址和门牌号是固定的(例如 0.0.0.0:80)。

在TCP中 :监听套接字通过 socket(), bind(), listen() 系统调用创建。它绑定到一个众所周知的端口(如HTTP服务的80端口),并开始监听连接请求。它自己不用于收发数据

2. 已连接套接字

  • 角色 :酒店里的专属服务员

  • 工作地点 :在酒店内部,服务于某张特定的餐桌

  • 职责

    1. 从接待员手中接过一位特定的客人。

    2. 负责这位客人的所有具体需求:点菜(接收数据)、上菜(发送数据)、处理问题。

    3. 与客人建立一一对应的服务关系。

  • 特点

    • 动态创建:只有在有客人需要服务时才会被创建。

    • 处理具体I/O:所有数据的收发都通过它。

    • 地址不同:服务员的"位置"是餐桌号(本地IP:端口 + 客户端的IP:端口),这个四元组是唯一的,确保了多个客户端可以同时被正确服务。

在TCP中 :当监听套接字通过 accept() 接受一个连接请求时,内核会创建一个全新的套接字,这就是已连接套接字。这个新套接字用于与刚刚建立连接的客户端进行通信。

3. 区别与联系(总结)

特性 监听套接字 已连接套接字
比喻 酒店门口的接待员 酒店内的专属服务员
创建方式 socket() -> bind() -> listen() accept() 返回
用途 等待和接受新的连接 特定客户端进行数据交换
生命周期 长期存在,服务整个运行期间 临时存在,连接建立时创建,断开时销毁
数量 通常一个服务端口只有一个 可以同时存在很多个,服务多个客户端
通信对象 不直接与任何客户端通信 与一个特定的客户端通信
端口号 绑定到一个固定端口(如80) 使用同一个本地端口,但通过客户端IP:端口来区分

4. 联系

  • 父子关系:监听套接字是"父",已连接套接字是"子"。没有监听套接字,就不可能有已连接套接字。

  • 分工协作:监听套接字负责"接电话"(建立连接),已连接套接字负责"对话"(传输数据)。这种分工使得服务器能够高效地同时处理多个连接请求。

  • 共享端口 :所有已连接套接字都共享监听套接字的本地端口号。操作系统通过TCP四元组(源IP、源端口、目标IP、目标端口)来唯一标识一个连接,所以不会混淆。

5. 工作流程回顾(结合比喻)

  1. 酒店开业:服务器启动,创建监听套接字(接待员就位),绑定到80端口(站在酒店大门口),开始监听。

  2. 客人A到来:客户端向服务器的80端口发起连接请求(SYN包)。

  3. 接待员响应:监听套接字 accept() 接收到这个请求。它创建一个新的已连接套接字A(呼叫服务员A),并将客人A交给服务员A。

  4. 接待员归位:监听套接字立刻返回门口,继续等待下一位客人。

  5. 服务开始:服务员A(已连接套接字A)使用 send()recv() 与客人A进行点菜、上菜等所有数据交互。

  6. 同时,客人B到来:监听套接字再次 accept()创建另一个新的已连接套接字B(呼叫服务员B)来服务客人B。

  7. 此时,酒店有一个接待员(监听套接字)和两个服务员(已连接套接字A和B)在同时工作,互不干扰。

通过这个比喻,我们可以清晰地理解两者在TCP服务器编程中的不同角色和协作方式。

4、服务端获取连接的完整实现

三次握手完成后,服务器调用accept()接收连接:

  • 若服务器调用accept()时没有客户端连接请求,将阻塞等待直到有客户端连接;

  • addr是传出参数,accept()返回时会填充客户端的地址和端口信息;

  • 若addr参数设为NULL,表示不关心客户端地址信息;

  • addrlen是传入传出参数:

    • 传入时指定缓冲区addr的长度,防止溢出;

    • 传出时返回客户端地址结构体的实际长度(可能小于缓冲区长度)。

服务端获取连接时需注意以下要点:

  • accept函数可能获取连接失败,但TCP服务器不会因此退出。遇到失败时应继续尝试获取新连接。

  • 如需输出客户端IP和端口信息,需要:

    • 使用inet_ntoa将整数IP转换为字符串格式

    • 调用ntohs将端口号从网络字节序转换为主机字节序

  • inet_ntoa函数实际上完成了两个转换步骤:

    • 将IP地址从网络字节序转换为主机字节序

    • 将主机字节序的整数IP转换为点分十进制字符串格式

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>

class TcpServer {
public:
    TcpServer(int port) : _port(port), _listen_sock(-1) {}
    
    ~TcpServer() {
        if (_listen_sock >= 0) {
            close(_listen_sock);
        }
    }
    
    void InitServer() {
        // 创建套接字
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listen_sock < 0) {
            std::cerr << "socket error" << std::endl;
            exit(2);
        }
        
        // 绑定地址信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;
        
        if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            std::cerr << "bind error" << std::endl;
            exit(3);
        }
        
        // 设置监听
        if (listen(_listen_sock, 5) < 0) {
            std::cerr << "listen error" << std::endl;
            exit(4);
        }
    }
    
    void Start() {
        std::cout << "Server start listening on port " << _port << "..." << std::endl;
        
        while (true) {
            // 获取连接
            struct sockaddr_in peer;
            memset(&peer, 0, sizeof(peer));
            socklen_t len = sizeof(peer);
            
            int service_sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
            if (service_sock < 0) {
                std::cerr << "accept error, continue next" << std::endl;
                continue;
            }
            
            // 获取客户端信息
            std::string client_ip = inet_ntoa(peer.sin_addr);
            int client_port = ntohs(peer.sin_port);
            
            std::cout << "Get a new connection -> sock: " << service_sock 
                      << " [" << client_ip << "]:" << client_port << std::endl;
            
            // 这里可以创建线程或进程来处理这个连接
            // HandleConnection(service_sock);
            
            // 简单示例:关闭连接(实际中不会立即关闭)
            close(service_sock);
        }
    }

private:
    int _listen_sock; // 监听套接字
    int _port;        // 服务器端口号
};

5、服务端测试方法

测试程序

我们可以进行简单测试,验证服务器是否能正常接收请求连接。具体步骤如下:

  1. 运行服务端程序时需指定端口号

  2. 使用该端口号创建服务端对象

  3. 初始化服务端后启动服务

这样就可以完成服务端的部署测试。

cpp 复制代码
void Usage(std::string proc) {
    std::cout << "Usage: " << proc << " port" << std::endl;
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        Usage(argv[0]);
        exit(1);
    }
    
    int port = atoi(argv[1]);
    TcpServer* svr = new TcpServer(port);
    svr->InitServer();
    svr->Start();
    
    delete svr;
    return 0;
}

测试步骤

  1. 编译程序:

    cpp 复制代码
    g++ TcpServer.cc -o TcpServer
  2. 启动服务器(例如使用8081端口):

    cpp 复制代码
    ./TcpServer 8081
  3. **验证服务器状态:**当服务端启动后,使用netstat命令可以看到一个名为TcpServer的服务进程正在运行。该进程绑定在8081端口,并采用INADDR_ANY地址(显示为0.0.0.0),这意味着服务器能够监听本地所有网卡的数据传输。最关键的是,服务器当前处于LISTEN状态,表示它已准备好接收外部连接请求。

    bash 复制代码
    netstat -anp | grep TcpServer

    应该能看到类似输出:

连接测试方法

尽管尚未编写客户端代码,我们仍可通过telnet命令连接到服务器,因为telnet本质上使用的是TCP协议。如下,使用telnet连接服务器后可以看到,服务器成功接收了一个连接。该连接对应的套接字文件描述符为4。这是因为:

  • 0、1、2号文件描述符默认分配给标准输入、输出和错误流

  • 3号描述符在服务器初始化时分配给了监听套接字

因此,首个客户端连接请求会获得4号文件描述符的服务(接收)套接字。

方法1:使用telnet测试

一句话概括 :Telnet 是一个用于远程登录到其他计算机的网络协议和命令行工具。它允许你在一台机器上通过命令行控制另一台机器,就像你正坐在那台机器的键盘前一样。

核心功能与用途:

  1. 远程登录与管理:在个人电脑和服务器上,系统管理员常用它来远程管理服务器、网络设备(如交换机、路由器)等。

  2. 网络服务调试 :Telnet 不仅可以登录到远程主机,还可以作为一个简单的客户端**(想象成一个不用自己写的万能简单客户端)**,直接连接任何基于 TCP 的服务器端口,用来测试服务是否可用(例如,测试 Web 服务器、邮件服务器)。

基本语法:

bash 复制代码
telnet [主机名或IP地址] [端口号]
  • 主机名或IP地址:你想要连接的目标计算机的地址。

  • 端口号:(可选)指定要连接的服务端口。如果不指定,默认使用 23 端口(Telnet 服务默认端口)。

现代替代方案:SSH

正是因为 Telnet 的安全性缺陷,SSH 已经几乎完全取代了它。

  • SSH 提供了与 Telnet 类似的功能(远程命令行登录),但所有通信过程都是加密的,确保了安全和隐私。

  • 现在的生产环境和绝大多数系统中,强烈不建议也不应该再开启 Telnet 服务

现代等效命令:

bash 复制代码
ssh username@192.168.1.100

总结

  • Telnet 是什么:一个古老的、基于明文的远程登录和网络调试工具。

  • 现在还用吗基本不再用于实际的远程管理,因为太不安全。

  • 现在还怎么用 :主要作为一个简单的网络连通性和服务端口测试工具。例如,快速检查某个服务器的 80 端口或 443 端口是否能连通。

所以,当你今天再看到或使用 telnet 命令时,大概率不是在真正"登录",而是在做网络故障排查。

bash 复制代码
telnet 127.0.0.1 8081

此时我们返回服务器,可以看到会显示新连接信息,包括套接字描述符和客户端信息,如下:

我们还可以开多个终端窗口同时连接,观察每个连接分配的不同描述符。如下:

这时我们如果再通过其他窗口再次使用telnet命令向该TCP服务器发起连接请求时,会得到如下结果,这是因为在服务端代码中的Start函数最后的close(service_sock);,它在接收到了客户端的请求之后,服务端成功获取连接,处理完业务后直接关闭了连接套接字,所以分配的还是4号文件描述符(但是对应的客户端的端口号不同),除非删除close函数那一行代码:

删除后,我们这时如果再运行服务端,会发现服务端的监听套接字绑定失败!!!

然而,我们使用下面的命令查看端口号8081是否被占用时,却没有对应的输出,意思是8081这个端口号没有被占用!!!

最可能的原因:TIME_WAIT 状态
什么是TIME_WAIT?

当TCP连接关闭时,主动关闭的一方(服务器)会进入TIME_WAIT状态,通常持续60秒(2MSL)。在这期间,端口不能被立即重用。

场景重现:

  1. 第一次运行:程序启动,绑定8081成功

  2. 第一次退出:程序关闭,监听套接字关闭,进入TIME_WAIT

  3. 立即第二次运行:尝试绑定8081,但端口还在TIME_WAIT中,所以失败

  4. 等待一段时间后:TIME_WAIT结束,又可以绑定了

验证这个理论:(可以手动实操验证一下)

bash 复制代码
# 第一次运行程序
./TcpServer 8081

# 在另一个终端,快速检查端口状态
sudo netstat -tulpn | grep 8081
# 你会看到类似:tcp  0  0 0.0.0.0:8081  0.0.0.0:*  LISTEN  <pid>

# 现在Ctrl+C停止程序,然后立即检查
sudo netstat -tulpn | grep 8081
# 你会看到:tcp  0  0 0.0.0.0:8081  0.0.0.0:*  TIME_WAIT  -

如果想要彻底解决这个问题的话,我们可以在 InitServer() 方法中,在 bind() 调用之前添加以下代码:

cpp 复制代码
void InitServer() {
    // 创建套接字
    _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (_listen_sock < 0) {
        std::cerr << "socket error" << std::endl;
        exit(2);
    }
    
    // === 添加这几行代码解决TIME_WAIT问题 ===
    int reuse = 1;
    if (setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
        std::cerr << "setsockopt error" << std::endl;
        // 这里不退出,继续尝试绑定
    }
    // ====================================
    
    // 绑定地址信息
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = INADDR_ANY;
    
    if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
        std::cerr << "bind error" << std::endl;
        exit(3);
    }
    
    // 其余代码不变...
}

添加位置 :在 socket() 创建之后,bind() 绑定之前。

作用 :这几行代码设置 SO_REUSEADDR 选项,允许立即重新绑定处于 TIME_WAIT 状态的端口,彻底解决重启绑定失败的问题。

函数参数详解
cpp 复制代码
setsockopt(_listen_sock,   // 要设置哪个套接字(哪栋大楼)
           SOL_SOCKET,     // 要设置套接字本身的选项(大楼结构)
           SO_REUSEADDR,   // 具体选项:地址重用(快速重新开业)
           &reuse,         // 设置的值:1表示开启(&取地址)
           sizeof(reuse)); // 值的大小
各参数含义
  • _listen_sock:你创建的监听套接字,就像酒店大楼

  • SOL_SOCKET:表示要设置"套接字层面"的选项,就像设置大楼的基础属性

  • SO_REUSEADDR:具体的选项名称,意思是"允许地址重用"

  • &reuse :设置的值(1=开启,0=关闭),&表示取地址

  • sizeof(reuse):告诉系统这个值有多大(int类型通常是4字节)

实际效果

**没有设置 SO_REUSEADDR:**酒店关门 → 需要打扫60分钟(TIME_WAIT)→ 才能重新开业

**设置了 SO_REUSEADDR:**酒店关门 → 立即可以重新开业(跳过打扫等待)

为什么需要 &(取地址)?

因为 setsockopt 函数需要知道值在内存中的位置,就像:

  • 不说"数字1",而是说"1号保险箱里的东西"

  • 这样函数就能去那个位置读取数据

解决好了上面的问题之后,我们可以通过其他窗口再次使用telnet命令向该TCP服务器发起连接请求时,系统会为该客户端分配一个新的套接字,其对应的文件描述符为5:

方法2:使用浏览器测试

也可以直接通过浏览器访问这个TCP服务器。由于浏览器默认使用HTTP/HTTPS协议,而这些协议底层都基于TCP,因此浏览器同样能与该TCP服务器建立连接。但是我们想一下之前遇到的问题,就是:

  • 云服务器的公网IP是虚拟的(NAT后面)

  • 内网IP在公网不可用

  • 想在浏览器中测试服务,但无法直接访问

我们可以使用云服务商的安全组/防火墙规则来解决这个问题:

  1. 配置安全组

    • 登录云服务商控制台(阿里云、腾讯云等)

    • 找到你的云服务器实例

    • 配置安全组,放行8081端口

    • 通常需要添加规则:协议TCP,端口8081,源IP 0.0.0.0/0(或你的本地公网IP)

  2. 获取公网访问地址

    bash 复制代码
    # 在服务器上查看公网IP
    curl ifconfig.me
    # 或者
    curl ip.sb
  3. 在浏览器访问http://你的公网IP:8081

**然后在浏览器地址栏输入:**http://你的公网IP:8081

  • 浏览器会尝试建立TCP连接

  • 服务器会记录连接信息

  • 注意:浏览器可能会建立多个连接(如主连接和资源连接)

关于浏览器为何会向TCP服务器发送三次请求的问题,我们暂不深入探讨(后面会详细讲解!!!)。本文的重点在于验证TCP服务器能够正常接收并处理外部请求连接。

方法3:使用nc(netcat)测试

Netcat 是网络工具中的"瑞士军刀",它可以用简单的命令完成各种网络读写操作,比如创建 TCP/UDP 连接、端口扫描、文件传输等。nc 是比 telnet 更好的端口测试工具,因为它更简单直接!

nc 的主要用途:

  • 端口检测:快速检查端口是否开放

  • 网络调试:手动测试网络服务

  • 简单传输:临时文件传输或消息通信

  • 网络工具:替代telnet进行基本的网络测试

注意: nc 功能强大但传输不加密,不适合传输敏感信息。

bash 复制代码
nc 113.45.79.2 8081

上面的例子要开放端口号8081才行,否则如下:

6、关键注意事项

错误处理accept()可能失败(如被信号中断),服务器应继续运行、需要检查返回值并处理错误情况

地址转换inet_ntoa():将网络字节序的IP地址转换为点分十进制字符串、ntohs():将网络字节序的端口号转换为主机字节序

资源管理:每个连接套接字在使用后应正确关闭、监听套接字应在服务器退出时关闭

并发处理:示例中简单关闭了连接,实际服务器应为每个连接创建处理线程或进程、可以使用多线程、多进程或I/O多路复用(select/poll/epoll)处理并发

7、常见问题解答

Q: 为什么浏览器访问会建立多个连接?

A: 现代浏览器通常会:

  1. 建立主连接获取HTML

  2. 解析HTML后建立额外连接获取CSS、JS等资源

  3. 可能使用HTTP/1.1的连接复用或HTTP/2的多路复用

Q: 文件描述符分配规律是什么?

A: 在Linux系统中:

  • 0: 标准输入

  • 1: 标准输出

  • 2: 标准错误

  • 3: 通常分配给监听套接字

  • 后续连接套接字从4开始递增分配

Q: 如何验证服务器确实在接收连接?

A: 除了观察程序输出,还可以:使用netstatss命令查看连接状态、使用lsof命令查看打开的套接字、使用网络抓包工具(如Wireshark)观察TCP握手过程

通过以上详细的实现和测试方法,可以全面理解TCP服务器如何获取客户端连接,并为后续实现完整的网络通信功能打下基础。


五、TCP服务器请求处理

1、服务端处理请求概述

在TCP服务器成功获取连接请求后,接下来需要处理客户端连接。这里需要明确的是,监听套接字(listen_sock)仅用于接受新连接,而实际为客户端提供服务的任务则由accept()函数返回的"服务(接收)套接字"承担。这种设计使得监听套接字能够继续监听新的连接请求。

2、回声服务器实现

为了验证通信正常,我们将实现一个简单的回声TCP服务器。该服务器会:

  1. 接收客户端发送的数据

  2. 将接收到的数据输出到服务端控制台

  3. 将相同数据原样发回客户端

  4. 客户端收到响应后打印输出

这种设计可以确保服务端和客户端之间的双向通信正常工作。

3、核心系统调用函数

1. read函数(从套接字读出数据然后写到缓冲区里面)

cpp 复制代码
ssize_t read(int fd, void *buf, size_t count);

参数说明

  • **fd:**文件描述符,指定从哪个套接字读取数据

  • **buf:**缓冲区指针,用于存储读取到的数据

  • **count:**期望读取的最大字节数

返回值说明

  • **>0:**实际读取的字节数

  • **=0:**对端已关闭连接

  • **<0:**读取过程中发生错误

特殊情况处理

read()返回0时,表示客户端已关闭连接,这与本地进程间通信(如管道)的行为类似,类似管道通信中写端关闭后读端会读到0的情况,服务端此时应关闭对应的服务套接字:

  • 当写端进程停止写入而读端进程持续读取时,读端进程会被挂起,因为此时没有数据可供读取。

  • 反之,若读端进程停止读取而写端进程持续写入,当管道写满后,写端进程会被挂起,因为此时没有可用空间。

  • 当写端进程完成数据写入并关闭写端后,读端进程在读取完管道中剩余数据后会读到0值。

  • 若读端进程关闭读端,写端进程会被操作系统终止,因为其写入的数据已无法被读取。

在客户端-服务端模型中,写端对应客户端。当客户端关闭连接后,服务端读取完套接字中的信息会收到0值。此时若服务端read函数返回0,即可终止对该客户端的服务。

2. write函数(从缓冲区里拿出数据然后写到套接字当中)

cpp 复制代码
ssize_t write(int fd, const void *buf, size_t count);

参数说明

  • **fd:**文件描述符,指定向哪个套接字写入数据

  • **buf:**要发送的数据缓冲区

  • **count:**要发送的字节数

返回值说明

  • **>0:**实际写入的字节数

  • **=-1:**写入失败,可通过errno获取错误原因

4、服务端请求处理实现

当服务端通过read函数接收到客户端数据后,即可调用write函数将这些数据返回给客户端。

处理流程要点

  1. 双工通信:服务套接字既能读取也能写入数据,体现了TCP的全双工特性

  2. 资源管理 :处理完成后必须关闭服务套接字,避免文件描述符泄漏**(当从服务套接字读取客户端数据时,若read函数返回值为0或出现读取错误,应立即关闭对应的文件描述符。由于文件描述符本质上是数组索引,系统资源有限,若不及时释放,可用的文件描述符会逐渐耗尽。因此,完成客户端服务后必须及时关闭相关文件描述符,避免造成资源泄漏。)**

  3. 错误处理:需要处理读取失败和连接关闭的情况

完整代码实现

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

class TcpServer {
public:
    TcpServer(int port) : _port(port) {
        // 初始化监听套接字
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listen_sock < 0) {
            std::cerr << "socket create error" << std::endl;
            exit(1);
        }
        
        // 设置SO_REUSEADDR选项
        int opt = 1;
        setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        
        // 绑定端口
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;
        
        if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            std::cerr << "bind error" << std::endl;
            exit(2);
        }
        
        // 开始监听
        if (listen(_listen_sock, 5) < 0) {
            std::cerr << "listen error" << std::endl;
            exit(3);
        }
    }
    
    ~TcpServer() {
        if (_listen_sock >= 0) {
            close(_listen_sock);
        }
    }
    
    void Service(int sock, const std::string& client_ip, int client_port) {
        char buffer[1024];
        while (true) {
            // 读取客户端数据
            ssize_t size = read(sock, buffer, sizeof(buffer) - 1);
            if (size > 0) {
                buffer[size] = '\0'; // 确保字符串终止
                std::cout << "[" << client_ip << ":" << client_port << "] say: " << buffer << std::endl;
                
                // 回声数据回客户端
                ssize_t write_size = write(sock, buffer, size);
                if (write_size < 0) {
                    std::cerr << "[" << client_ip << ":" << client_port << "] write error" << std::endl;
                    break;
                }
            }
            else if (size == 0) {
                // 客户端关闭连接
                std::cout << "[" << client_ip << ":" << client_port << "] close connection" << std::endl;
                break;
            }
            else {
                // 读取错误
                std::cerr << "[" << client_ip << ":" << client_port << "] read error" << std::endl;
                break;
            }
        }
        
        // 关闭服务套接字
        close(sock);
        std::cout << "[" << client_ip << ":" << client_port << "] service completed" << std::endl;
    }
    
    void Start() {
        std::cout << "Server start on port: " << _port << std::endl;
        while (true) {
            // 接受新连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
            if (sock < 0) {
                std::cerr << "accept error, continue..." << std::endl;
                continue;
            }
            
            // 获取客户端信息
            std::string client_ip = inet_ntoa(peer.sin_addr);
            int client_port = ntohs(peer.sin_port);
            std::cout << "New connection from [" << client_ip << ":" << client_port << "], sock: " << sock << std::endl;
            
            // 处理客户端请求
            Service(sock, client_ip, client_port);
        }
    }

private:
    int _listen_sock; // 监听套接字
    int _port;        // 服务器端口
};

int main() {
    TcpServer server(8081); // 创建服务器实例,监听8081端口
    server.Start();        // 启动服务器
    return 0;
}

我们编译运行服务端,然后使用netstat命令查看查询网络连接状态,如下,可以看到服务端正处于监听状态:

bash 复制代码
netstat -tunlp
  • -t:TCP 连接

  • -u:UDP 连接

  • -n:显示数字地址(不解析主机名)

  • -l:仅显示监听中的连接

  • -p:显示进程信息

5、关键实现细节

初始化阶段:创建监听套接字、设置SO_REUSEADDR选项避免"Address already in use"错误、绑定到指定端口、开始监听连接

服务循环 :使用accept()接受新连接、获取客户端IP和端口信息、为每个连接创建独立的服务套接字

请求处理

  • 使用read()接收客户端数据

  • 处理三种情况:

    • 成功读取数据:输出并回声

    • 读取到0:客户端关闭连接

    • 读取错误:记录错误并关闭连接

  • 使用write()发送响应数据

  • 最终关闭服务套接字释放资源

资源管理:每次服务完成后关闭服务套接字、析构函数中确保监听套接字被关闭

6、异常处理与健壮性考虑

  • 错误处理:系统调用失败时的错误检测、网络中断等异常情况的处理

  • 资源泄漏防护:确保所有打开的文件描述符最终都会被关闭、使用RAII模式管理资源

  • 性能考虑:缓冲区大小的选择(1024字节)、字符串终止符的处理

这个实现提供了一个健壮的TCP回声服务器框架,可以作为更复杂网络应用的基础。

7、测试步骤

虽然现在我们并没有写客户端,但是通过上面的telnet命令可以完全当做客户端来进行连接测试!!!如下:

1. 启动你的服务器

bash 复制代码
# 编译并运行
g++ TcpServer.cc -o TcpServer
./TcpServer

你会看到输出:

2. 使用 Telnet 连接测试

打开新的终端窗口,执行:

bash 复制代码
telnet 127.0.0.1 8081

如果连接成功,你会看到:(光标在闪烁,等待输入)

3. 测试回声功能

现在你可以输入任何文本,服务器都会原样返回:(如果在telnet中输错了的话,我们可以按住Ctrl,然后再使用Backspace来进行删除刚刚输入的错误)

在telnet中输入: hello server!!!**你应该看到:**hello server!!!

再测试: this is a test message**输出:**this is a test message

4. 测试多行消息

每输入一行,服务器都会立即回声返回。

5. 查看服务器日志

在服务器终端,你会看到类似这样的输出:

此时我们再开一个新的终端窗口使用netstat命令来查看网络连接状态,如下:

bash 复制代码
netstat -tunp | grep :8081

使用上面这个命令,而不是之前的那个命令来查看的原因:

  • -l 参数:只显示监听状态的端口

  • 服务器 :在8081端口监听 ,所以会被 netstat -tunlp 显示

  • telnet连接 :是已建立的连接,不是监听端口,所以不会显示

通过上面的演示结果,我们可以知道这显示了一个完整的TCP连接对:

输出结果的每一列属性: 协议、接收队列、发送队列、本地地址:端口、远程地址:端口、状态、进程

第一条:Telnet客户端

  • 本地地址127.0.0.1:38680(客户端使用随机端口38680)

  • 远程地址127.0.0.1:8081 (连接到服务器8081端口(也就是TcpServer的端口))

  • 进程12586/telnet - Telnet客户端进程

  • 状态ESTABLISHED - 连接已建立

第二条:TCP服务器

  • 本地地址127.0.0.1:8081 (服务器监听8081端口**(也就是TcpServer的端口)**)

  • 远程地址127.0.0.1:38680 (连接到Telnet客户端)

  • 进程12295/./TcpServer - C++服务器程序

  • 状态ESTABLISHED - 连接已建立

关键信息

  1. 连接正常:双方都是ESTABLISHED状态

  2. 通信畅通:接收队列和发送队列都是0,说明数据正常传输

  3. 本地回环:使用127.0.0.1,说明是本地测试

  4. 端口使用:服务器固定端口:8081、客户端随机端口:38680

这是一个完美的TCP连接,说明:TcpServer程序运行正常、Telnet客户端成功连接、双方正在正常通信。现在我们可以在telnet窗口中测试发送消息,服务器会回声返回!

6. 断开连接

在telnet中按 Ctrl + ],然后输入 quit

服务器会显示:

因为当写端进程停止写入而读端进程持续读取时,读端进程会被挂起,因为此时没有数据可供读取。read会被阻塞,在telnet客户端退出后,此时当写端进程完成数据写入并关闭写端后,读端进程在读取完管道中剩余数据后会读到0值。然后输出第一行,最后输出第二行结果。

8、测试要点说明

  • 实时交互:telnet是双向通信,输入立即得到响应

  • 文本协议:服务器处理的是纯文本数据

  • 连接管理:可以测试正常断开和异常断开

  • 并发测试:可以开多个telnet窗口同时连接


六、补充扩展:浏览器是什么?

思考:通过上面的例子我们可以思考这样的一个问题------为什么浏览器能够使用http://你的服务器公网IP:8081这样的方式来访问我的服务端呢?浏览器究竟是什么?它是一个万能的客户端?是这样理解吗?

实际上,浏览器本质上是一个专门用于处理HTTP/HTTPS协议的客户端程序,但它确实可以看作是一个"多功能网络客户端"。

1、浏览器的核心功能

  1. 解析URL :理解 http://IP:端口/路径 这样的地址

  2. 建立TCP连接:与目标服务器建立网络连接

  3. 发送HTTP请求:按照HTTP协议格式发送请求

  4. 渲染响应:将服务器返回的HTML/CSS/JS内容渲染成可视化页面

2、为什么 http://公网IP:8081 能访问你的服务?

通信流程:浏览器 → 公网IP → 云服务商网络 → 你的服务器 → 你的应用(8081端口)

  1. URL解析 :浏览器解析 http://你的公网IP:8081

  2. DNS查询:如果是域名,会先解析为IP(这里是直接IP,跳过这步)

  3. TCP连接 :浏览器向 你的公网IP:8081 发起TCP连接请求

  4. 路由转发:云服务商的网络设备将公网IP映射到你的内网服务器

  5. 服务响应:你的服务器上的应用在8081端口接收请求并响应

3、浏览器是"万能客户端"吗?

上面的提问的思考问题理解部分正确,但有局限性:

浏览器能处理的协议:

  • HTTP/HTTPS:主要功能

  • WebSocket:实时通信

  • FTP:文件传输(部分支持)

  • mailto:邮件链接

  • file:本地文件

浏览器不能直接处理的:

  • 原始TCP连接(除了WebSocket)

  • UDP协议

  • SSH/Telnet等专用协议

  • 自定义二进制协议

4、关键理解:HTTP协议是基础

当你在浏览器输入 http://IP:8081 时:

  1. 浏览器默认使用HTTP协议

  2. 它向指定IP和端口发送HTTP请求

    bash 复制代码
    GET / HTTP/1.1
    Host: 你的公网IP:8081
    User-Agent: Mozilla/5.0...
  3. 只要你的服务能理解HTTP协议并返回有效响应,浏览器就能显示

5、实际测试例子

如果你的服务返回纯文本:

python 复制代码
# 简单Python HTTP服务
from http.server import BaseHTTPRequestHandler, HTTPServer

class SimpleHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        self.wfile.write(b'Hello from my server!')

HTTPServer(('0.0.0.0', 8081), SimpleHandler).serve_forever()

访问 http://公网IP:8081,浏览器会显示:Hello from my server!

如果你的服务返回HTML:浏览器会渲染成漂亮的页面。

python 复制代码
self.wfile.write(b'<h1>Welcome</h1><p>This is my service</p>')

6、总结

  • 浏览器是专门的HTTP客户端,但通过HTTP协议可以访问各种网络服务

  • http://IP:端口 的工作原理:浏览器通过TCP连接到指定端口,然后使用HTTP协议通信

  • 只要你的服务理解HTTP协议,浏览器就能与之交互

  • 浏览器不是真正的"万能客户端",它主要局限于Web相关协议

这就是为什么你可以在浏览器中测试你的后端服务 - 因为浏览器是最方便、最通用的HTTP测试工具!

相关推荐
Elias不吃糖1 小时前
LeetCode每日一练(189, 122)
c++·算法·leetcode
百***06011 小时前
服务器无故nginx异常关闭之kauditd0 kswapd0挖矿病毒 CPU占用200% 内存耗尽
运维·服务器·nginx
赖small强1 小时前
【Linux C/C++开发】第20章:进程间通信理论
linux·c语言·c++·进程间通信
赖small强1 小时前
【Linux C/C++开发】第24章:现代C++特性(C++17/20)核心概念
linux·c语言·c++·c++17/20
凯子坚持 c1 小时前
ToDesk深度评测:解析新一代远程控制软件的安全、性能与价值体系
网络·安全
-森屿安年-2 小时前
LeetCode 11. 盛最多水的容器
开发语言·c++·算法·leetcode
ouliten2 小时前
C++笔记:std::stringbuf
开发语言·c++·笔记
Robpubking2 小时前
elasticsearch 使用 systemd 启动时卡在 starting 状态 解决过程记录
linux·运维·elasticsearch
hlsd#3 小时前
我把自己的小米ax3000t换成了OpenWRT
linux·iot