一、了解常用的TCP SocketAPI
仓库地址(同步更新开发中):GitHub - stellablack528/arcane-campus-online
前面我们从五层模型出发,理解了数据在网络中的传输过程。但这些内容更多是在解释‘网络是怎么工作的’。
对于程序员来说,还需要继续回答一个更具体的问题:我们的 C++ 程序到底如何使用操作系统提供的网络能力?
这就要从 Socket 开始。
下面我们从 socket()、bind()、listen()、accept() 等接口入手,看看应用程序如何通过 Linux Socket API 建立网络通信
以TCP服务器为例,Linux Socket 编程的基本流程如下:
socket()
↓
bind()
↓
listen()
↓
accept()
↓
send() / recv()
↓
close()
1. socket()
int fd = socket(
AF_INET, // IPv4 地址族
SOCK_STREAM, // 面向字节流,对应 TCP
0 // 根据前面的参数由系统选择对应协议
);
作用:向操作系统申请一个 Socket,并返回对应的文件描述符 fd。
成功时返回非负文件描述符,失败时返回 -1。
可以理解为:
socket()
↓
向内核申请网络通信资源
↓
得到 fd
2. bind()
创建 Socket 后,还需要确定这个 Socket 使用哪个本地 IP 和端口,因此需要调用 bind()。
bind(
fd, // 要绑定的目标 Socket 的文件描述符
reinterpret_cast<sockaddr*>(&addr), // 本地 IP + 端口信息
sizeof(addr) // addr 地址结构体的大小
);
IPv4 地址通常使用 sockaddr_in 描述:
sockaddr_in addr{};
addr.sin_family = AF_INET; // IPv4
addr.sin_port = htons(8080); // 端口,需要转换为网络字节序
inet_pton(AF_INET, "127.0.0.1", // 将点分十进制 IP 转换为网络地址
&addr.sin_addr);
服务端为什么经常绑定 INADDR_ANY?
addr.sin_addr.s_addr = htonl(INADDR_ANY);
INADDR_ANY 表示监听本机所有可用的 IPv4 网络接口地址。
例如:
127.0.0.1
192.168.1.10
10.0.0.5
如果服务器绑定:
0.0.0.0:8080
表示监听本机各个 IPv4 网络接口上的 8080 端口。
这里的“任意 IP”指的是本机任意网络接口地址,并不是任意远程客户端 IP。
客户端为什么通常不需要主动 bind()?
普通 TCP 客户端通常只需要:
socket()
↓
connect()
操作系统会自动为客户端选择本地 IP 和临时端口。
例如:
客户端:192.168.1.8:53124
↓
connect
↓
服务器:192.168.1.10:8080
其中 53124 就可能是操作系统自动分配的临时端口。
因此,普通 TCP 客户端通常不需要主动调用 bind()。
3. listen()
完成地址绑定之后,服务器需要让 Socket 进入监听状态:
listen(
fd, // 监听 Socket 的文件描述符
backlog // 与等待处理的连接队列相关的参数
);
作用:将 Socket 设置为监听状态,使其能够等待客户端的连接请求。
bind()
↓
绑定本地通信地址
listen()
↓
进入监听状态
等待客户端连接
需要注意:backlog 不简单等于服务器最多可以连接多少个客户端,它主要与连接等待队列有关。
4. accept()
服务器进入监听状态之后,通过 accept() 接受客户端连接:
int clientFd = accept(
serverFd, // 监听 Socket 的文件描述符
addr, // 用于接收客户端地址信息,指向客户端地址的指针
addrlen // addr 地址结构体长度
);
在目前的学习阶段,如果暂时不需要客户端地址信息,可以:
int clientFd = accept(
serverFd, // 监听 Socket
nullptr, // 不获取客户端地址
nullptr // 不获取客户端地址长度
);
作用:接受一个已经到来的客户端连接,并返回一个新的客户端 Socket 文件描述符。
一定要区分监听 Socket 和连接 Socket:
serverFd
↓
监听 Socket
↓
负责等待客户端连接
accept()
↓
得到新的 Socket
clientFd
↓
连接 Socket
↓
负责与具体客户端通信
例如:
serverFd = 3
客户端 A
↓
accept()
↓
clientFd = 4
客户端 B
↓
accept()
↓
clientFd = 5
原来的 serverFd 继续负责监听,新产生的 clientFd 分别负责与对应客户端通信。
5. recv()
假设服务器通过 accept() 获得:
int clientFd = 4;
客户端开始发送数据,此时服务器调用 recv():
char buffer[4096];
ssize_t result = recv(
clientFd, // 客户端连接 Socket
buffer, // 用于接收数据的用户态缓冲区
sizeof(buffer),// 本次最多接收多少字节
0 // flags,常用场景下为 0
);
作用:从 Socket 的接收方向读取已经到达的数据,并将数据写入用户态提供的 Buffer。
网络
↓
TCP 协议栈
↓
Socket 接收缓冲区
↓
recv()
↓
char buffer[4096]
↓
C++ 程序
为什么 recv() 得到的不一定是一条完整消息?
TCP 提供的是可靠、有序的字节流,TCP 本身并不知道应用层定义的“消息边界”。
例如 Hogwarts 项目规定:
chat|Hello\n
表示一条完整消息。
但是 TCP 只认识:
c h a t | H e l l o \n
这些连续的字节。
因此一次 recv() 可能得到:
chat|he
也可能得到:
chat|hello\nchat|world\n
所以应用层需要自己定义消息边界,并通过 Buffer 处理半包和粘包。
在 Hogwarts 项目中,我们使用:
\n
作为一条消息的结束标志。
整体处理过程:
recv()
↓
临时 buffer
↓
readBuffer_
↓
寻找 '\n'
↓
提取完整消息
↓
交给回调函数
recv() 返回值
result > 0
本次读取到了 result 个字节
result == 0
对端正常关闭连接
result < 0
发生错误
例如:
if (result > 0)
{
// 本次确实读取到了数据
}
6. send()
如果服务器需要向客户端发送数据:
std::string message = "Hello";
可以调用:
ssize_t result = send(
clientFd, // 客户端连接 Socket
message.data(), // 要发送的数据所在的内存地址
message.size(), // 要发送的字节数
0 // flags,常用场景下为 0
);
作用:将用户态内存中的数据交给 Socket 的发送路径,由操作系统网络协议栈继续处理。
C++ 程序中的数据
↓
send()
↓
Socket 发送缓冲区
↓
TCP 协议栈
↓
网络
↓
客户端
message.data() 是什么?
例如:
std::string message = "Hello";
message.data() 用于获取 std::string 内部字符数据的地址。
所以:
send(
clientFd,
message.data(),
message.size(),
0
);
可以理解为:
message.data()
→ 告诉 send() 数据位于哪块内存
message.size()
→ 告诉 send() 需要发送多少字节
message.data() 本身不是一个独立的 Buffer,而是一个指向字符串内部数据的指针,可以作为 send() 的输入缓冲区。
send() 的返回值
result > 0
本次成功处理了 result 个字节
result < 0
发生错误
需要注意:一次 send() 不一定能够把整个应用层消息全部发送出去。
例如:
需要发送:10000 字节
第一次 send()
成功发送:5000 字节
剩余:5000 字节
实际网络程序通常需要继续处理剩余的数据,因此发送方向也可能需要 writeBuffer_ 保存尚未发送完成的数据。
recv() 和 send() 中 Buffer 的区别
虽然两者都涉及 Buffer,但作用不同。
recv:
Socket → 用户态 Buffer
send:
用户态数据 → Socket
recv() 使用 Buffer 接收数据,之后应用层还需要对这些字节进行缓存和解析。
send() 使用用户态数据作为输入,告诉操作系统从哪块内存读取需要发送的数据。
7. close()
当 Socket 不再使用时:
close(fd);
作用:关闭文件描述符,并释放与该 Socket 相关的系统资源。
在 C++ 封装中,可以利用 RAII 自动管理 Socket:
Socket::~Socket()
{
close(fd_);
}
这样:
{
Socket socket;
// 使用 socket
}
离开作用域后:
Socket 对象销毁
↓
析构函数执行
↓
close(fd_)
↓
释放 Socket 资源
RAII 的核心思想是:
将资源的生命周期与对象生命周期绑定起来。
对于 Socket 这种系统资源,非常适合使用 RAII 管理。
8. TCP 服务器完整流程
socket()
↓
创建 Socket
↓
得到 serverFd
↓
bind()
↓
绑定本地 IP + 端口
↓
listen()
↓
进入监听状态
↓
accept()
↓
得到 clientFd
↓
recv()
↓
读取客户端字节流
↓
应用层解析消息
↓
业务处理
↓
send()
↓
向客户端发送数据
↓
close()
↓
释放 Socket 资源
在 HogwartsOnline 项目中,这些 Linux Socket API 后续会进一步封装为:
TcpServer
↓
TcpConnection
↓
Socket
↓
Linux Socket API
到这里,我们已经熟悉了 TCP Socket 的基本接口,也理解了它们各自解决的问题。
但直接调用 Linux API 只是第一步。真正写项目时,还需要考虑资源生命周期、错误处理、数据缓冲以及模块之间的职责划分。
接下来,就把这些 Socket API 放回 Arcane Campus Online,开始进行 C++ 封装实践。
二、使用这些API去实现业务场景
前面介绍了TCP Socket变成中常用的Linux API,以及它们各自承担的职责,但实际项目中,我们不会直接把这些系统调用直接散落在各个业务模块中,因为随着项目规模扩大,直接调用系统 API 会逐渐暴露出一些问题:Socket 文件描述符的生命周期需要手动管理,错误处理容易重复,网络地址的组织方式也比较分散。同时,TCP 本身只提供连续的字节流,应用层还需要自己处理消息边界、半包和粘包等问题。
因此,在上一节提到的项目实践中(基于 C++/Qt 和 Linux Socket/Epoll 的 C/S 架构多人在线沉浸式文字冒险系统),我们首先将 Linux Socket API 封装为 Socket 类,把底层 Socket 的创建、地址绑定、监听、连接接受和资源释放集中管理,再由更高层的 TcpServer、TcpConnection 等模块继续向上封装
1.封装Socket的好处
首先是资源管理,Socket对象内部持有文件描述符fd_,通过构造函数和析构函数管理它的生命周期,对象创建后负责管理fd,对象销毁时自动释放资源,从而减少忘记close()或重复释放资源的可能
其次是同一错误处理,Linux Socket API 通常通过返回值表示操作是否成功,失败时还需要结合 errno 获取错误原因。在 Socket 类中可以统一处理这些错误,而不是让上层代码重复编写相同的判断和日志逻辑。
例如
if (fd_ < 0)
{
std::cerr << "socket failed: "
<< std::strerror(errno)
<< std::endl;
return false;
}
这样上层调用就不需要反复处理底层系统调用的细节。
2.将 Linux Socket API 封装为 C++ 类
Socket 类本身并不负责业务逻辑,它主要负责管理一个 Socket 资源,并对 Linux API 做一层简单封装。
大概是这样:
Socket
├── create()
├── bind()
├── listen()
├── accept()
├── close()
└── fd()
每个成员函数对应一个主要的Linux Socket API,这样API就能被限制在较底层的网络模块中,上层模块通过Socket提供的接口使用它,实现了解耦
那具体如何封装呢?
核心是结合C++的对象和资源管理机制,把底层Socket的几个关键问题同意处理:
Linux Socket API
↓
Socket 类
├── 资源管理
├── 错误处理
├── 地址构造
└── 底层 fd 访问
↓
上层网络模块
例如,create() 负责调用 socket() 创建 Socket 并保存返回的文件描述符;bind() 负责组织 sockaddr_in 地址结构并完成本地地址绑定;listen() 和 accept() 分别负责监听和接受客户端连接;close() 统一管理文件描述符的释放。
通过这种方式,上层代码不需要重复处理底层系统调用,只需要根据函数返回值判断操作是否成功。
下面结合 实践项目中 的实际代码,看看这一层封装是如何实现的。
// 把我们在 .hpp 里声明的抽象接口,落到具体的
// Linux API、数据结构、错误处理和资源管理上
#include "Socket.hpp"
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <iostream>
namespace Hogwarts
{
// 无参构造:创建 Socket 对象时,当前还没有有效的文件描述符
Socket::Socket()
: fd_(-1)
{
}
// 有参构造:接管一个已经存在的文件描述符
Socket::Socket(int fd)
: fd_(fd)
{
}
// 析构时释放 Socket 资源
Socket::~Socket()
{
close();
}
bool Socket::create()
{
fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd_ < 0)
{
std::cerr << "socket failed: "
<< std::strerror(errno)
<< std::endl;
return false;
}
return true;
}
bool Socket::bind(const std::string& ip, uint16_t port)
{
// IPv4 地址结构
sockaddr_in addr{};
// 指定地址族为 IPv4
addr.sin_family = AF_INET;
// 端口转换为网络字节序
addr.sin_port = htons(port);
// 将字符串形式的 IPv4 地址转换为网络地址
int result = inet_pton(
AF_INET,
ip.c_str(),
&addr.sin_addr
);
if (result == 0)
{
std::cerr << "invalid IPv4 address"
<< std::endl;
return false;
}
if (result < 0)
{
std::cerr << "inet_pton failed: "
<< std::strerror(errno)
<< std::endl;
return false;
}
// 将具体的 IPv4 地址结构转换为通用的 sockaddr*
if (::bind(
fd_,
(struct sockaddr*) &addr,
sizeof(addr)
) < 0)
{
std::cerr << "bind failed: "
<< std::strerror(errno)
<< std::endl;
return false;
}
return true;
}
bool Socket::listen(int backlog)
{
if (::listen(fd_, backlog) < 0)
{
std::cerr << "listen failed: "
<< std::strerror(errno)
<< std::endl;
return false;
}
return true;
}
int Socket::accept()
{
int clientFd = ::accept(
fd_,
nullptr,
nullptr
);
if (clientFd < 0)
{
std::cerr << "accept failed: "
<< std::strerror(errno)
<< std::endl;
return -1;
}
return clientFd;
}
void Socket::close()
{
if (fd_ >= 0)
{
::close(fd_);
fd_ = -1;
}
}
int Socket::fd() const
{
return fd_;
}
}
以下为对上述代码的详解的几个点:(笔者本人第一次写没想到的一些细节):
1.为什么Socket()要初始化fd_ = -1
Socket::Socket()
: fd_(-1)
{
}
因为fd_ 用来保存当前 Socket 对象对应的文件描述符,刚创建Socket对象的时候还没有真正创建Linux Socket,这个时候fd的状态就是fd_ = -1 于是-1就成为我们约定的:当前对象没有有效文件描述符
这样,后面的
if(fd_ >=0)
才有明确意义
2.为什么析构函数调用自己的close()
Socket::~Socket()
{
close();
}
而不用::close(fd_)呢
因为 Socket::close() 不只是调用 Linux 的 close(),还负责检查当前 fd_ 是否有效,并在关闭后将 fd_ 重置为 -1:
void Socket::close()
{
if (fd_ >= 0)
{
::close(fd_);
fd_ = -1;
}
}
因此,让析构函数直接调用 close(),可以把“关闭 Socket”相关的逻辑集中到一个地方。
例如以后需要修改关闭逻辑时,只需要修改 Socket::close(),析构函数不需要跟着修改。这样可以减少重复代码,也能避免不同地方的关闭逻辑不一致。析构函数只负责出发资源释放,具体如何释放由close()统一处理。
3.关于bind()—— Socket 如何与本地地址建立联系
3.1 sockaddr 到底是什么?
相信很多人和笔者一样,第一次看到 sockaddr 和 (struct sockaddr*)&addr 这种写法时,都会觉得特别别扭,理解起来也比较吃力。
大白话来说:
sockaddr可以理解为 Linux Socket API 用来统一表示不同类型通信地址的一种通用形式。
为什么需要这样一个“通用形式”?
因为 Socket 不只可以用于我们熟悉的网络通信,还可以用于本地进程间通信等不同场景。而不同通信方式的地址长得并不一样。
例如:
IPv4
→ IP 地址 + 端口
IPv6
→ IPv6 地址 + 端口
Unix Domain Socket
→ 本地通信地址
如果每一种通信方式都设计一套完全不同的 API,那么 bind()、connect()、accept() 等操作都要针对不同地址类型重新设计一遍,使用起来会非常麻烦。
所以 Linux Socket API 提供了一种统一的地址表示方式:
sockaddr
具体使用哪一种通信方式,则由对应的地址结构负责保存真正的信息。
例如 IPv4 使用:
sockaddr_in
IPv6 使用:
sockaddr_in6
Unix Domain Socket 使用:
sockaddr_un
因此可以先建立这样一个关系:
不同通信方式
↓
不同的具体地址结构
↓
sockaddr_in
sockaddr_in6
sockaddr_un
↓
统一通过 sockaddr* 交给 Socket API
如果用 C++ 的思维来类比,可以暂时把 sockaddr 想成一个“统一的基类接口”,把 sockaddr_in 理解成具体的 IPv4 地址类型。
但这里一定要注意:sockaddr 和 sockaddr_in 并不是 C++ 中真正的继承关系。
Linux Socket API 主要使用 C 语言接口,而 C 本身没有 C++ 那样的类继承和多态机制。因此,Linux 通过统一的 sockaddr* 指针接口,让不同的具体地址结构能够被同一套 Socket API 接收。
例如我们现在使用 IPv4:
sockaddr_in addr{};
这里的 addr 是一个具体的 IPv4 地址结构。
而调用 bind() 时:
::bind(
fd_,
(struct sockaddr*) &addr,
sizeof(addr)
);
就是把这个具体的 IPv4 地址结构的地址,以 sockaddr* 这种通用形式交给 bind()。
所以这里真正需要理解的不是“sockaddr_in 继承了 sockaddr”,而是:
sockaddr_in
↓
IPv4 的具体地址结构
sockaddr*
↓
Socket API 统一接收地址的形式
这样一来,bind() 就不需要关心你具体使用的是 IPv4、IPv6 还是其他通信方式,只需要按照统一的 sockaddr* 接口来处理。
这个设计也就是我们前面提到的接口复用。
我们把底层的源码扒出来对比一下:
| 字段用途 | sockaddr(通用基类) | sockaddr_in(IPv4 派生类) | 字节大小 |
| 地址族 | sa_family (告诉系统这是什么地址) | sin_family (固定填 AF_INET) | 2 字节 |
| 端口号 | - | sin_port | 2 字节 |
| IP 地址 | - | sin_addr | 4 字节 |
| 占位符 | - | sin_zero (纯占位,为了和基类对齐) | 8 字节 |
| 通用数据 | sa_data[14] (剩下的 14 字节随便塞) | - | (前三项加起来刚好 14) |
| 总计 | 16 字节 | 16 字节 | 16 字节 |
这下是不是清晰多了? 这两个结构体的总大小是完全一样的(都是 16 字节)。所以我们在写代码时,流程是这样的:
-
自己玩自己的: 用结构清晰的
sockaddr_in舒舒服服地填好 IP 和 端口。 -
交作业时伪装: 强转成统一的
sockaddr*类型,传给bind()函数。
::bind(
fd_,
(struct sockaddr*) &addr, // 向上转型(Upcasting)
sizeof(addr)
)
这就相当于一次 父类指针指向子类对象。只不过 C 语言没有类,只能靠暴力的指针强转来实现这种“多态”。
4.inet_pton()——如何正确地写错误处理?
我们在封装 bind() 时,有一步是将字符串形式的 IP(比如 "127.0.0.1")转换成网络字节序的二进制格式。这里用到了 inet_pton:
int result = inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);
笔者第一次写的时候只判断了if (result < 0),但这在 inet_pton 这里是不够的。我们需要严格区分两种错误:
-
result == 0(业务错误): 这说明传入的 IP 字符串格式不对(比如传了"999.999.999.999"或"hello")。此时errno是不会被设置的。这是用户输入导致的错误,属于正常的业务校验失败。 -
result < 0(系统错误): 这说明底层的地址族参数传错了(比如第一个参数不是AF_INET)。此时errno会被设置。这是真正的系统级错误。
5.accept()为什么返回新的fd?
我已经有了一个 serverFd,为什么接受连接后还要返回一个 clientFd?直接用 serverFd 通信不行吗?
我们可以用 “餐厅服务员” 的模型来理解:
-
监听 Socket (
serverFd) = 餐厅门口的迎宾员。 他的唯一职责就是站在门口(listen),看到有新客人来了,就把客人迎进来(accept)。 -
连接 Socket (
clientFd) = 负责你这桌的专职服务员。 当你坐下后,迎宾员会分配一个服务员专门负责给你点菜、上菜(recv和send)。
如果迎宾员(serverFd)自己跑去给你上菜了,那在此期间,餐厅门口就算排起了长队,也没人接待了。 所以,TCP 的设计非常明确:监听和通信必须职责分离。 serverFd 只负责处理新连接,连接建立后,立刻抛出一个新的 clientFd 去进行后续的一对一数据传输,而 serverFd 继续回去监听。
这样我们的程序里的迎宾员和专职服务员的职责也划分好了
顺藤摸瓜:从职责分离到“回调函数(Callback)”的引入
既然 serverFd 和 clientFd 的职责已经彻底分开了,我们再深入思考一个实际场景:
作为专职服务员的 clientFd,他应该怎么为客人服务呢? 最笨的方法
最笨的方法是:服务员一直死死盯着客人,什么都不干,就等客人开口点菜(这在网络编程中叫做 阻塞式调用,程序会卡在 recv() 那里动弹不得)。 如果餐厅里有 1000 个客人,难道我们要雇佣 1000 个服务员盯着吗?这显然太消耗资源了(对应每个连接开一个线程,开销极大)。
高效的现代化餐厅是怎么做的?给每张桌子装一个“呼叫铃”。
服务员平时可以去忙别的,只要客人按响了呼叫铃(触发了事件),服务员就会立刻过来,根据客人的需求执行特定的服务操作(执行回调函数)。
这就是现代 TCP 服务器(包括我们后续要封装的系统)普遍采用的事件驱动 + 回调机制:
当大门(serverFd)进来新客人,触发“新连接事件”,服务器内部自动调用预先写好的 onConnection() 回调函数。
当某张桌子(clientFd)按响呼叫铃,发来数据时,触发“数据可读事件” 服务器内部自动调用预先写好的 onMessage(clientFd, msg) 回调函数。
当客人(clientFd)买单离开时,触发“连接断开事件” 自动调用 onDisconnect() 回调函数,清理这桌的餐具(释放资源)。
为什么要用回调函数?为了“解耦”
想一想,底层网络模块(Socket 封装)知道客人发来的 chat|Hello 是什么意思吗?它不知道,它只负责把字节流收上来。 真正知道怎么处理这些聊天数据的,是上层的业务逻辑模块(比如 Hogwarts 项目的聊天广播中心)。
通过回调函数,我们可以完美地把 “网络通信” 和 “业务逻辑” 拆分开来:
// 伪代码演示:上层业务模块只需关心“收到消息后该做什么”
tcpServer.setMessageCallback([](int clientFd, const std::string& message) {
// 业务逻辑:判断如果是 "chat|..." 就转发给其他玩家
if (message.find("chat|") == 0) {
broadcast(message);
}
});
底层的 Socket 负责死死监听网卡,一旦拿到数据,就去调用上层注册好的 MessageCallback。上层写业务的人,根本不需要去关心底层的 recv() 是怎么调用的、出错了怎么重试。
这就完成了从“面向过程的系统调用”到“现代面向对象网络框架”的关键跨越!
三、从 Socket 走向 TcpConnection总结)
到这里,我们已经把底层的“脏活累活”梳理了一遍:从零碎的 Linux Socket API,到利用 RAII 思想将其封装为一个干净、安全的 Socket 类;并且我们通过“迎宾员与服务员”的模型,理清了 accept() 为什么要拆分出新的 clientFd,顺水推舟地引出了现代网络框架中最为核心的事件驱动与回调机制。
但故事到这里并没有结束。
既然我们已经有了“专职服务员”(clientFd),也有了“呼叫铃”(事件驱动),那这位服务员在整个工作期间,应该如何管理自己手头的事情呢? 他需要记下客人的点单进度(接收缓冲区)、还没上完的菜(发送缓冲区),还得随时知道客人是不是已经买单走人了(连接状态管理)。
单纯一个 Socket 类,显然已经装不下这么多复杂的业务状态了。
因此,在下一章《从 Socket 封装到 TcpConnection——如何管理一次完整的 TCP 连接》中,我们将以面向对象的思维,把这个“专职服务员”正式升级为一个完整的 TcpConnection 类。它将彻底接管底层的收发逻辑,让上层业务写起来就像点外卖一样简单。
💻 留个小作业(动手试一试)
在进入下一章之前,给大家留一个非常经典的实战小任务:
我们在前面 recv() 的部分提到过:TCP 是面向字节流的,它会发生“粘包”和“半包”现象。假设你的服务器规定:每条完整的消息都以换行符 \n 结尾。
现在,底层的 recv() 陆陆续续收到了一堆没有明确边界的字节,你把它们存进了一个临时缓存 std::string readBuffer; 里。
假设此刻 readBuffer 里的内容是这样的: "chat|Hello\nchat|World\nchat|Hel" (注意:这里包含了完整的两条消息,以及第三条消息的一半)
你的任务是: 写一小段 C++ 代码(主要是写一个 while 循环),从 readBuffer 中把完整的消息一条条提取出来,打印在屏幕上,并把没接收完的半条消息("chat|Hel")继续留在 readBuffer 里,等下一次数据到来时拼接。
提示: 你可以利用 std::string::find('\n') 来寻找换行符的位置,再用 substr() 截取字符串,最后用 erase() 把提取走的部分从 Buffer 里删掉。
逻辑其实非常简单,建议大家可以在 IDE 里敲一敲找找感觉。下一章的开头,我们会直接公布这段代码的标准答案,并且把它顺理成章地塞进我们崭新的 TcpConnection 源码中!我们下期见!

8407

被折叠的 条评论
为什么被折叠?



