Linux网络编程实战2:TCP Socket 基础与实践

复制代码

一、了解常用的TCP SocketAPI

仓库地址(同步更新开发中):GitHub - stellablack528/arcane-campus-online

前面我们从五层模型出发,理解了数据在网络中的传输过程。但这些内容更多是在解释'网络是怎么工作的'。

对于程序员来说,还需要继续回答一个更具体的问题:我们的 C++ 程序到底如何使用操作系统提供的网络能力?

这就要从 Socket 开始。

下面我们从 socket()bind()listen()accept() 等接口入手,看看应用程序如何通过 Linux Socket API 建立网络通信

以TCP服务器为例,Linux Socket 编程的基本流程如下:

cpp 复制代码
socket()
   ↓
bind()
   ↓
listen()
   ↓
accept()
   ↓
send() / recv()
   ↓
close()

1. socket()

cpp 复制代码
int fd = socket(
    AF_INET,      // IPv4 地址族
    SOCK_STREAM,  // 面向字节流,对应 TCP
    0             // 根据前面的参数由系统选择对应协议
);

作用:向操作系统申请一个 Socket,并返回对应的文件描述符 fd

成功时返回非负文件描述符,失败时返回 -1

可以理解为:

cpp 复制代码
socket()
   ↓
向内核申请网络通信资源
   ↓
得到 fd

2. bind()

创建 Socket 后,还需要确定这个 Socket 使用哪个本地 IP 和端口,因此需要调用 bind()

cpp 复制代码
bind(
    fd,                                      // 要绑定的目标 Socket 的文件描述符
    reinterpret_cast<sockaddr*>(&addr),      // 本地 IP + 端口信息
    sizeof(addr)                             // addr 地址结构体的大小
);

IPv4 地址通常使用 sockaddr_in 描述:

cpp 复制代码
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?

cpp 复制代码
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 客户端通常只需要:

cpp 复制代码
socket()
   ↓
connect()

操作系统会自动为客户端选择本地 IP 和临时端口。

例如:

cpp 复制代码
客户端:192.168.1.8:53124
              ↓
            connect
              ↓
服务器:192.168.1.10:8080

其中 53124 就可能是操作系统自动分配的临时端口。

因此,普通 TCP 客户端通常不需要主动调用 bind()


3. listen()

完成地址绑定之后,服务器需要让 Socket 进入监听状态:

cpp 复制代码
listen(
    fd,       // 监听 Socket 的文件描述符
    backlog   // 与等待处理的连接队列相关的参数
);

作用:将 Socket 设置为监听状态,使其能够等待客户端的连接请求。

cpp 复制代码
bind()
   ↓
绑定本地通信地址

listen()
   ↓
进入监听状态
等待客户端连接

需要注意:backlog 不简单等于服务器最多可以连接多少个客户端,它主要与连接等待队列有关。


4. accept()

服务器进入监听状态之后,通过 accept() 接受客户端连接:

cpp 复制代码
int clientFd = accept(
    serverFd,            // 监听 Socket 的文件描述符
    addr,                 // 用于接收客户端地址信息,指向客户端地址的指针
    addrlen               // addr 地址结构体长度
);

在目前的学习阶段,如果暂时不需要客户端地址信息,可以:

cpp 复制代码
int clientFd = accept(
    serverFd,   // 监听 Socket
    nullptr,    // 不获取客户端地址
    nullptr     // 不获取客户端地址长度
);

作用:接受一个已经到来的客户端连接,并返回一个新的客户端 Socket 文件描述符。

一定要区分监听 Socket 和连接 Socket:

cpp 复制代码
serverFd
    ↓
监听 Socket
    ↓
负责等待客户端连接


accept()
    ↓
得到新的 Socket


clientFd
    ↓
连接 Socket
    ↓
负责与具体客户端通信

例如:

cpp 复制代码
serverFd = 3

客户端 A
    ↓
accept()
    ↓
clientFd = 4

客户端 B
    ↓
accept()
    ↓
clientFd = 5

原来的 serverFd 继续负责监听,新产生的 clientFd 分别负责与对应客户端通信。


5. recv()

假设服务器通过 accept() 获得:

cpp 复制代码
int clientFd = 4;

客户端开始发送数据,此时服务器调用 recv()

cpp 复制代码
char buffer[4096];

ssize_t result = recv(
    clientFd,      // 客户端连接 Socket
    buffer,        // 用于接收数据的用户态缓冲区
    sizeof(buffer),// 本次最多接收多少字节
    0              // flags,常用场景下为 0
);

作用:从 Socket 的接收方向读取已经到达的数据,并将数据写入用户态提供的 Buffer。

cpp 复制代码
网络
  ↓
TCP 协议栈
  ↓
Socket 接收缓冲区
  ↓
recv()
  ↓
char buffer[4096]
  ↓
C++ 程序

为什么 recv() 得到的不一定是一条完整消息?

TCP 提供的是可靠、有序的字节流,TCP 本身并不知道应用层定义的"消息边界"。

例如 Hogwarts 项目规定:

cpp 复制代码
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()

如果服务器需要向客户端发送数据:

cpp 复制代码
std::string message = "Hello";

可以调用:

cpp 复制代码
ssize_t result = send(
    clientFd,        // 客户端连接 Socket
    message.data(),  // 要发送的数据所在的内存地址
    message.size(),  // 要发送的字节数
    0                // flags,常用场景下为 0
);

作用:将用户态内存中的数据交给 Socket 的发送路径,由操作系统网络协议栈继续处理。

cpp 复制代码
C++ 程序中的数据
       ↓
     send()
       ↓
Socket 发送缓冲区
       ↓
TCP 协议栈
       ↓
网络
       ↓
客户端

message.data() 是什么?

例如:

cpp 复制代码
std::string message = "Hello";

message.data() 用于获取 std::string 内部字符数据的地址。

所以:

cpp 复制代码
send(
    clientFd,
    message.data(),
    message.size(),
    0
);

可以理解为:

cpp 复制代码
message.data()
    → 告诉 send() 数据位于哪块内存

message.size()
    → 告诉 send() 需要发送多少字节

message.data() 本身不是一个独立的 Buffer,而是一个指向字符串内部数据的指针,可以作为 send() 的输入缓冲区。

send() 的返回值

cpp 复制代码
result > 0
    本次成功处理了 result 个字节

result < 0
    发生错误

需要注意:一次 send() 不一定能够把整个应用层消息全部发送出去。

例如:

cpp 复制代码
需要发送:10000 字节

第一次 send()
成功发送:5000 字节

剩余:5000 字节

实际网络程序通常需要继续处理剩余的数据,因此发送方向也可能需要 writeBuffer_ 保存尚未发送完成的数据。

recv() 和 send() 中 Buffer 的区别

虽然两者都涉及 Buffer,但作用不同。

cpp 复制代码
recv:
Socket → 用户态 Buffer

send:
用户态数据 → Socket

recv() 使用 Buffer 接收数据,之后应用层还需要对这些字节进行缓存和解析。

send() 使用用户态数据作为输入,告诉操作系统从哪块内存读取需要发送的数据。


7. close()

当 Socket 不再使用时:

cpp 复制代码
close(fd);

作用:关闭文件描述符,并释放与该 Socket 相关的系统资源。

在 C++ 封装中,可以利用 RAII 自动管理 Socket:

cpp 复制代码
Socket::~Socket()
{
    close(fd_);
}

这样:

cpp 复制代码
{
    Socket socket;

    // 使用 socket
}

离开作用域后:

cpp 复制代码
Socket 对象销毁
      ↓
析构函数执行
      ↓
close(fd_)
      ↓
释放 Socket 资源

RAII 的核心思想是:

将资源的生命周期与对象生命周期绑定起来。

对于 Socket 这种系统资源,非常适合使用 RAII 管理。


8. TCP 服务器完整流程

cpp 复制代码
socket()
   ↓
创建 Socket
   ↓
得到 serverFd
   ↓
bind()
   ↓
绑定本地 IP + 端口
   ↓
listen()
   ↓
进入监听状态
   ↓
accept()
   ↓
得到 clientFd
   ↓
recv()
   ↓
读取客户端字节流
   ↓
应用层解析消息
   ↓
业务处理
   ↓
send()
   ↓
向客户端发送数据
   ↓
close()
   ↓
释放 Socket 资源

在 HogwartsOnline 项目中,这些 Linux Socket API 后续会进一步封装为:

cpp 复制代码
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 的创建、地址绑定、监听、连接接受和资源释放集中管理,再由更高层的 TcpServerTcpConnection 等模块继续向上封装

1.封装Socket的好处

首先是资源管理,Socket对象内部持有文件描述符fd_,通过构造函数和析构函数管理它的生命周期,对象创建后负责管理fd,对象销毁时自动释放资源,从而减少忘记close()或重复释放资源的可能

其次是同一错误处理,Linux Socket API 通常通过返回值表示操作是否成功,失败时还需要结合 errno 获取错误原因。在 Socket 类中可以统一处理这些错误,而不是让上层代码重复编写相同的判断和日志逻辑。

例如

cpp 复制代码
if (fd_ < 0)
{
    std::cerr << "socket failed: "
              << std::strerror(errno)
              << std::endl;

    return false;
}

这样上层调用就不需要反复处理底层系统调用的细节。

2.将 Linux Socket API 封装为 C++ 类

Socket 类本身并不负责业务逻辑,它主要负责管理一个 Socket 资源,并对 Linux API 做一层简单封装。

大概是这样:

cpp 复制代码
Socket
├── create()
├── bind()
├── listen()
├── accept()
├── close()
└── fd()

每个成员函数对应一个主要的Linux Socket API,这样API就能被限制在较底层的网络模块中,上层模块通过Socket提供的接口使用它,实现了解耦

那具体如何封装呢?

核心是结合C++的对象和资源管理机制,把底层Socket的几个关键问题同意处理:

cpp 复制代码
Linux Socket API
      ↓
Socket 类
      ├── 资源管理
      ├── 错误处理
      ├── 地址构造
      └── 底层 fd 访问
      ↓
上层网络模块

例如,create() 负责调用 socket() 创建 Socket 并保存返回的文件描述符;bind() 负责组织 sockaddr_in 地址结构并完成本地地址绑定;listen()accept() 分别负责监听和接受客户端连接;close() 统一管理文件描述符的释放。

通过这种方式,上层代码不需要重复处理底层系统调用,只需要根据函数返回值判断操作是否成功。

下面结合 实践项目中 的实际代码,看看这一层封装是如何实现的。

cpp 复制代码
// 把我们在 .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
cpp 复制代码
Socket::Socket()
    : fd_(-1)
{
}

因为fd_ 用来保存当前 Socket 对象对应的文件描述符,刚创建Socket对象的时候还没有真正创建Linux Socket,这个时候fd的状态就是fd_ = -1 于是-1就成为我们约定的:当前对象没有有效文件描述符

这样,后面的

cpp 复制代码
if(fd_ >=0)

才有明确意义

2.为什么析构函数调用自己的close()
cpp 复制代码
Socket::~Socket()
{
    close();
}

而不用::close(fd_)呢

因为 Socket::close() 不只是调用 Linux 的 close(),还负责检查当前 fd_ 是否有效,并在关闭后将 fd_ 重置为 -1

cpp 复制代码
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 地址类型。

但这里一定要注意:sockaddrsockaddr_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 字节)。所以我们在写代码时,流程是这样的:

  1. 自己玩自己的: 用结构清晰的 sockaddr_in 舒舒服服地填好 IP 和 端口。

  2. 交作业时伪装: 强转成统一的 sockaddr* 类型,传给 bind() 函数。

cpp 复制代码
::bind(
    fd_, 
    (struct sockaddr*) &addr, // 向上转型(Upcasting)
    sizeof(addr)
)

这就相当于一次 父类指针指向子类对象。只不过 C 语言没有类,只能靠暴力的指针强转来实现这种"多态"。

4.inet_pton()------如何正确地写错误处理?

我们在封装 bind() 时,有一步是将字符串形式的 IP(比如 "127.0.0.1")转换成网络字节序的二进制格式。这里用到了 inet_pton

cpp 复制代码
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) = 负责你这桌的专职服务员。 当你坐下后,迎宾员会分配一个服务员专门负责给你点菜、上菜(recvsend)。

如果迎宾员(serverFd)自己跑去给你上菜了,那在此期间,餐厅门口就算排起了长队,也没人接待了。 所以,TCP 的设计非常明确:监听和通信必须职责分离。 serverFd 只负责处理新连接,连接建立后,立刻抛出一个新的 clientFd 去进行后续的一对一数据传输,而 serverFd 继续回去监听。

这样我们的程序里的迎宾员和专职服务员的职责也划分好了

顺藤摸瓜:从职责分离到"回调函数(Callback)"的引入

既然 serverFdclientFd 的职责已经彻底分开了,我们再深入思考一个实际场景:

作为专职服务员的 clientFd,他应该怎么为客人服务呢? 最笨的方法

最笨的方法是:服务员一直死死盯着客人,什么都不干,就等客人开口点菜(这在网络编程中叫做 阻塞式调用 ,程序会卡在 recv() 那里动弹不得)。 如果餐厅里有 1000 个客人,难道我们要雇佣 1000 个服务员盯着吗?这显然太消耗资源了(对应每个连接开一个线程,开销极大)。

高效的现代化餐厅是怎么做的?给每张桌子装一个"呼叫铃"。

服务员平时可以去忙别的,只要客人按响了呼叫铃(触发了事件 ),服务员就会立刻过来,根据客人的需求执行特定的服务操作(执行回调函数)。

这就是现代 TCP 服务器(包括我们后续要封装的系统)普遍采用的事件驱动 + 回调机制

当大门(serverFd)进来新客人,触发"新连接事件",服务器内部自动调用预先写好的 onConnection() 回调函数。

当某张桌子(clientFd)按响呼叫铃,发来数据时 ,触发"数据可读事件" 服务器内部自动调用预先写好的 onMessage(clientFd, msg) 回调函数。

当客人(clientFd)买单离开时 ,触发"连接断开事件" 自动调用 onDisconnect() 回调函数,清理这桌的餐具(释放资源)。

为什么要用回调函数?为了"解耦"

想一想,底层网络模块(Socket 封装)知道客人发来的 chat|Hello 是什么意思吗?它不知道,它只负责把字节流收上来。 真正知道怎么处理这些聊天数据的,是上层的业务逻辑模块(比如 Hogwarts 项目的聊天广播中心)。

通过回调函数,我们可以完美地把 "网络通信""业务逻辑" 拆分开来:

cpp 复制代码
// 伪代码演示:上层业务模块只需关心"收到消息后该做什么"
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 源码中!我们下期见!

相关推荐
蒸蒸yyyyzwd1 小时前
机试和Redis学习笔记
c++·笔记·面试
竣达技术1 小时前
UPS 供电风险保护方案网络远程控制:免代理 SSH/Telnet UPS 安全关机方案
网络·安全·ssh
小程序设计1 小时前
工业园区、企业、公司网络规划与设计
网络
Splashtop高性能远程控制软件2 小时前
AI 辅助端点运维先接手补丁分级、合规可视和同台处置
运维·网络·人工智能·自动化·远程工作·splashtop
严谨的麻辣烫2 小时前
AI Agent为什么越来越需要固定出口IP?从API白名单到生产环境网络管理
服务器·网络·tcp/ip·代理服务器
DigitalOcean2 小时前
Omarchy 将研发基础设施迁移至 DigitalOcean 云平台
linux·agent
POMAGTOR2 小时前
【5G 通讯 POGOPIN 弹簧顶针连接器解决方案】
网络·嵌入式硬件·5g
牢姐与蒯2 小时前
Linux文件(一).前传之基础IO
linux·运维·服务器
tdtsmt2 小时前
2026年SMT贴片加工选型指南:天地通电子推荐参考
c++