欢迎光临
我们一直在努力

仿muduo库实现高并发服务器—Socket封装

Socket 封装详解

一、整体结构

#define MAX_LISTEN 1024

class Socket {
private:
int _sockfd;
public:
Socket();
Socket(int fd);
~Socket();

int Fd();
bool Create();
bool Bind(const std::string &ip, uint16_t port);
bool Listen(int backlog = MAX_LISTEN);
bool Connect(const std::string &ip, uint16_t port);
int Accept();
ssize_t Recv(void *buf, size_t len, int flag = 0);
ssize_t NonBlockRecv(void *buf, size_t len);
ssize_t Send(const void *buf, size_t len, int flag = 0);
ssize_t NonBlockSend(void *buf, size_t len);
void Close();
bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0", bool block_flag = false);
bool CreateClient(uint16_t port, const std::string &ip);
void ReuseAddress();
void NonBlock();
};

可以看到,这个类把 socket()、bind()、listen()、connect()、accept()、recv()、send()、close()、setsockopt()、fcntl() 这些系统调用全部封装了起来。


二、逐块详细讲解

1. 宏定义和成员变量

#define MAX_LISTEN 1024

MAX_LISTEN 是监听队列的最大长度,也就是 listen() 的第二个参数 backlog。
1024 表示内核为这个监听 socket 维护的已完成连接队列和未完成连接队列之和的最大值。简单说就是:最多允许有多少个客户端同时等待被 accept()。

private:
int _sockfd;

_sockfd 就是保存 socket 文件描述符的成员变量。
Linux 中 socket 也是一个文件,所以它用一个整数 fd 来表示。


2. 构造函数 / 析构函数 / Fd()

Socket() : _sockfd(1) {}
Socket(int fd) : _sockfd(fd) {}
~Socket() { Close(); }

  • 默认构造函数:把 _sockfd 初始化为 -1。
    -1 表示当前还没有有效的 socket,这是一个常见的"无效值"约定。
  • 带参构造函数:允许直接用一个已经存在的 fd 来构造对象。
    这通常用在 accept() 返回新连接后,我们想用 Socket 对象来管理它。
  • 析构函数:调用 Close()。
    这是 C++ 里非常重要的 RAII 思想:对象销毁时自动释放资源,避免忘记 close() 导致 fd 泄漏。

int Fd() { return _sockfd; }

Fd() 返回当前保存的 fd,方便其他模块使用。
这个函数很简单,但很常用,因为很多系统调用还需要裸 fd。


3. Create():创建套接字

bool Create() {
_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sockfd < 0) {
ERR_LOG("CREATE SOCKET FAILED!!");
return false;
}
return true;
}

int socket(int domain, int type, int protocol);

  • AF_INET:使用 IPv4 地址族。
  • SOCK_STREAM:使用面向连接的 TCP 协议。
  • IPPROTO_TCP:明确指定 TCP 协议。其实对于 SOCK_STREAM,第三个参数通常传 0 也可以,但这里写清楚更好。

成功返回一个非负整数 fd,失败返回 -1。
这里如果失败,打印错误日志,返回 false。


4. Bind():绑定地址和端口

bool Bind(const std::string &ip, uint16_t port) {
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
if (ret < 0) {
ERR_LOG("BIND ADDRESS FAILED!");
return false;
}
return true;
}

这里出现了一个重要的结构体:sockaddr_in,它表示一个 IPv4 地址。

  • sin_family = AF_INET:地址族,和 socket() 保持一致。
  • sin_port = htons(port):
    htons 是 Host TO Network Short 的缩写,把主机字节序的 16 位端口号转成网络字节序。
    网络传输要求统一使用大端字节序,而不同主机可能是小端,所以必须转换。
  • sin_addr.s_addr = inet_addr(ip.c_str()):
    inet_addr 把点分十进制字符串(如 "192.168.1.10")转换成 32 位网络字节序的二进制地址。
  • bind() 的第二个参数原本是 struct sockaddr*,所以要把 sockaddr_in* 强转过去。
    这是 C 语言时代遗留的多态设计,C++ 里只能强转。

注意:如果 ip 传的是 "0.0.0.0",inet_addr 会返回 INADDR_ANY,表示绑定到本机所有网卡地址。


5. Listen():开始监听

bool Listen(int backlog = MAX_LISTEN) {
int ret = listen(_sockfd, backlog);
if (ret < 0) {
ERR_LOG("SOCKET LISTEN FAILED!");
return false;
}
return true;
}

listen() 把一个 socket 从主动套接字变成被动套接字,开始接受连接请求。

  • backlog:我们已经说过,是内核维护的连接队列最大长度。
  • 默认参数 MAX_LISTEN,所以调用时可以省略。

6. Connect():客户端主动连接服务器

bool Connect(const std::string &ip, uint16_t port) {
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(struct sockaddr_in);
int ret = connect(_sockfd, (struct sockaddr*)&addr, len);
if (ret < 0) {
ERR_LOG("CONNECT SERVER FAILED!");
return false;
}
return true;
}

和 Bind() 几乎一样,区别是调用 connect()。
客户端不需要绑定自己的端口,内核会自动分配一个随机端口,所以这里直接填服务器地址。

注意:connect() 在非阻塞 socket 上会立即返回 -1,并且 errno 可能是 EINPROGRESS,表示连接正在建立中。


7. Accept():获取新连接

int Accept() {
int newfd = accept(_sockfd, NULL, NULL);
if (newfd < 0) {
ERR_LOG("SOCKET ACCEPT FAILED!");
return 1;
}
return newfd;
}

accept() 从监听队列里取出一个已完成的连接,返回一个新的 fd,这个新 fd 用来和客户端通信。

  • 第 2、3 个参数传 NULL 表示不关心客户端的地址信息。
  • 如果失败返回 -1。

if (newfd < 0) {
if (errno == EAGAIN || errno == EINTR) return 1; // 不算错误
ERR_LOG("SOCKET ACCEPT FAILED!");
return 1;
}


8. Recv() 和 NonBlockRecv()

ssize_t Recv(void *buf, size_t len, int flag = 0) {
ssize_t ret = recv(_sockfd, buf, len, flag);
if (ret <= 0) {
if (errno == EAGAIN || errno == EINTR) {
return 0;
}
ERR_LOG("SOCKET RECV FAILED!!");
return 1;
}
return ret;
}

recv() 从 socket 接收数据。

  • buf:接收缓冲区。
  • len:缓冲区大小。
  • flag:默认 0,表示阻塞接收。

返回值情况:

  • ret > 0:实际收到的字节数。
  • ret == 0:对端已经关闭连接(正常关闭)。
  • ret < 0:出错,需要看 errno。

代码里 if (ret <= 0) 把 ret == 0 和 ret < 0 都进来了。
然后判断:

  • errno == EAGAIN:在非阻塞模式下,表示当前没有数据可读,不是错误。
  • errno == EINTR:系统调用被信号打断,也不是错误。

9. Send() 和 NonBlockSend()

ssize_t Send(const void *buf, size_t len, int flag = 0) {
ssize_t ret = send(_sockfd, buf, len, flag);
if (ret < 0) {
if (errno == EAGAIN || errno == EINTR) {
return 0;
}
ERR_LOG("SOCKET SEND FAILED!!");
return 1;
}
return ret;
}

send() 发送数据。

  • ret > 0:实际发送的字节数。
  • ret == 0:一般不会出现,除非 len == 0。
  • ret < 0:出错。

这里同样处理了 EAGAIN 和 EINTR。
在非阻塞模式下,EAGAIN 表示发送缓冲区已满,暂时无法发送,不是错误。
返回 0 表示"这次没有发送出去",调用者需要稍后重试。

ssize_t NonBlockSend(void *buf, size_t len) {
if (len == 0) return 0;
return Send(buf, len, MSG_DONTWAIT);
}

先判断 len == 0,避免无意义调用。
然后使用 MSG_DONTWAIT 非阻塞发送。

注意:Send() 返回 0 表示没有发送数据,但如果你真的发送了 0 字节,send() 也会返回 0。


10. Close():关闭 socket

void Close() {
if (_sockfd != 1) {
close(_sockfd);
_sockfd = 1;
}
}

这个函数很关键:

  • 先判断 _sockfd != -1,防止重复关闭。
  • 调用系统 close()。
  • 把 _sockfd 重新设为 -1,表示已经关闭。

这是 RAII 的核心:析构函数调用 Close(),保证对象销毁时 fd 一定被关闭。


11. CreateServer():创建服务端连接

bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0", bool block_flag = false) {
//1. 创建套接字,2. 绑定地址,3. 开始监听,4. 设置非阻塞, 5. 启动地址重用
if (Create() == false) return false;
ReuseAddress();
if (block_flag) NonBlock();
if (Bind(ip, port) == false) return false;
if (Listen() == false) return false;

return true;
}

这个函数把服务端的初始化流程封装了起来。


12. CreateClient():创建客户端连接

bool CreateClient(uint16_t port, const std::string &ip) {
if (Create() == false) return false;
if (Connect(ip, port) == false) return false;
return true;
}

客户端流程简单:创建 socket,然后连接服务器。


13. ReuseAddress():地址端口重用

void ReuseAddress() {
int val = 1;
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, (void*)&val, sizeof(int));
val = 1;
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, (void*)&val, sizeof(int));
}

setsockopt() 用来设置 socket 选项。

  • SO_REUSEADDR:允许重用 TIME_WAIT 状态下的本地地址。
    服务器重启时,如果之前的连接还处于 TIME_WAIT,没有这个选项会导致 bind() 失败。
  • SO_REUSEPORT:允许多个 socket 绑定到完全相同的地址和端口。
    这在多进程/多线程服务器中很常用,可以让多个进程同时监听同一个端口,内核负责负载均衡。

注意:SO_REUSEPORT 是 Linux 较新内核才支持的,且必须在 bind() 之前设置。


14. NonBlock():设置非阻塞

void NonBlock() {
int flag = fcntl(_sockfd, F_GETFL, 0);
fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);
}

fcntl() 是一个非常强大的文件控制函数。

  • F_GETFL:获取当前文件状态标志。
  • F_SETFL:设置文件状态标志。
  • O_NONBLOCK:非阻塞标志。

这里先获取原来的 flag,然后加上 O_NONBLOCK,再设置回去。
这样不会覆盖其他已经存在的标志。

为什么需要非阻塞?
在高并发服务器中,如果 socket 是阻塞的,那么一次 recv() 或 send() 可能会阻塞整个线程,导致其他连接无法处理。
非阻塞 socket 配合 IO 多路复用(如 epoll)才能高效处理大量连接。

赞(0)
未经允许不得转载:171主机测评 » 仿muduo库实现高并发服务器—Socket封装
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址