【Linux网络】封装Socket
在前面几篇文章中,我们实现了Socket编程,也就是基于UDP和TCP进行了网络编程,通过这几次编程我们已经熟悉了Socket编程相关的系统调用,那么这篇文章我们就来使用模版方法模式封装一个Socket
文章目录
- 1. 模版方法模式
- 2. 封装Socket
- 2.1 Socket基类
- 2.2 TcpSocket子类
- recv系统调用
- send系统调用
- 3. 服务端
1. 模版方法模式
模板方法模式是一种行为型设计模式,它定义了一个算法的骨架,将某些步骤延迟到子类中实现,从而在不改变算法结构的情况下允许子类重新定义特定步骤。
核心结构
抽象类(Abstract Class):定义算法的框架(模板方法),并声明若干抽象方法或虚方法供子类实现。模板方法通常被声明为final以防止子类重写算法结构。
具体子类(Concrete Class):实现抽象类中定义的抽象方法,提供算法步骤的具体实现。
C++ 实现示例
以下是一个典型的模板方法模式示例,以制作饮料为例:
#include <iostream>
using namespace std;// 抽象类:定义饮料制作的模板方法
class DrinkTemplate {
public:// 模板方法(算法骨架)void makeDrink() {boilWater();brew();pourInCup();addCondiments();}virtual ~DrinkTemplate() {}protected:void boilWater() { cout << "煮水" << endl; }void pourInCup() { cout << "倒入杯子" << endl; }virtual void brew() = 0; // 子类实现冲泡步骤virtual void addCondiments() = 0; // 子类实现添加调料
};// 具体子类:茶
class Tea : public DrinkTemplate {
protected:void brew() override { cout << "泡茶" << endl; }void addCondiments() override { cout << "加柠檬" << endl; }
};// 具体子类:咖啡
class Coffee : public DrinkTemplate {
protected:void brew() override { cout << "冲泡咖啡" << endl; }void addCondiments() override { cout << "加糖和牛奶" << endl; }
};int main() {DrinkTemplate* tea = new Tea();tea->makeDrink(); // 输出:煮水、泡茶、倒入杯子、加柠檬DrinkTemplate* coffee = new Coffee();coffee->makeDrink(); // 输出:煮水、冲泡咖啡、倒入杯子、加糖和牛奶delete tea;delete coffee;return 0;
}
应用场景
- 固定流程可变实现:如文档处理(PDF/Word 解析)、编译流程、游戏循环等。
- 框架设计:父类控制整体逻辑,子类扩展细节(如 GUI 库、网络库)。
优点与缺点
- 优点:
- 提高代码复用性,将公共行为集中在父类。
- 允许子类扩展特定步骤,符合开闭原则。
- 缺点:
- 子类数量可能过多,导致系统复杂。
- 父类修改可能影响所有子类。
注意事项
- 模板方法应声明为非虚函数(如使用 final 或非虚函数),以保持算法结构稳定。
- 抽象方法(如 brew())应声明为 protected,限制外部直接调用。
此模式通过继承和多态实现算法的可变性与稳定性的平衡,是 C++ 中常用的设计模式之一。
2. 封装Socket
那我们就可以抽象一个Socket的基类,将创建套接字等需要的系统调用在基类中设为纯虚函数,然后我们可以定义两个模板方法,一个UDP的模板方法,一个TCP的模板方法,需要使用哪个传输层协议的网络服务就在主程序中调用哪个模板方法
具体子类UDP或TCP服务端可以实现抽象类中虚函数的具体实现
不过UDP相对TCP简单一点,所以我们具体子类主要实现TCP服务器
2.1 Socket基类
基类主要就是所需要的系统调用设为纯虚函数,然后定义一个TCP服务端所需要的系统调用的模板方法
代码如下:
#pragma once#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"namespace SocketModule
{using namespace LogModule;const static int gbacklog = 16;// 模板方法模式class Socket{protected:virtual ~Socket() {}virtual void SocketOrDie() = 0;virtual void BindOrDie(uint16_t port) = 0;virtual void ListenOrDie(int backlog) = 0;virtual std::shared_ptr<Socket> Accept(InetAddr *client) = 0;virtual void Close() = 0;virtual int Recv(std::string *out) = 0;virtual int Send(const std::string &message) = 0;virtual int Connect(const std::string &server_ip, uint16_t port) = 0;public:void BuildTcpSocketMethod(uint16_t port, int backlog = gbacklog){SocketOrDie();BindOrDie(port);ListenOrDie(backlog);}void BuildTcpClientSocketMethod(){SocketOrDie();}};
}
我们基类将TCP服务端需要的系统调用都设为虚函数,在前面的文章中,我们已经写过TCP网络编程,对于需要的系统调用我们已经熟悉了。两个模板方法分别为服务端和客户端调用,服务端通过子类TcpSocket多态调用基类中的模板方法来完成创建套接字,绑定,监听等连接操作
2.2 TcpSocket子类
这里我们设置两个构造函数,一个无参构造用于初始化listen套接字,一个用于将connect返回的文件描述符构造为套接字类型,而不是直接返回一个int类型的文件描述符,这样做的好处是,在后续使用该文件描述符时可以直接通过套接字来调用封装的函数,而如果是int类型的话,只能使用原始的系统调用,但我们已经封装了就尽量使用封装的系统调用,这样虽然也行但是有点挫
namespace SocketModule
{using namespace LogModule;const static int gbacklog = 16;// 模板方法模式class Socket{protected:virtual ~Socket() {}virtual void SocketOrDie() = 0;virtual void BindOrDie(uint16_t port) = 0;virtual void ListenOrDie(int backlog) = 0;virtual std::shared_ptr<TcpSocket> Accept(InetAddr *client) = 0;virtual void Close() = 0;virtual int Recv(std::string *out) = 0;virtual int Send(const std::string &message) = 0;virtual int Connect(const std::string &server_ip, uint16_t port) = 0;public:void BuildTcpSocketMethod(uint16_t port, int backlog = gbacklog){SocketOrDie();BindOrDie(port);ListenOrDie(backlog);}void BuildTcpClientSocketMethod(){SocketOrDie();}};const static int defaultfd = -1;class TcpSocket : public Socket{public:TcpSocket() // 无参构造listensockfd:_sockfd(defaultfd){}// 将connect返回的文件描述符构造为套接字类型TcpSocket(int fd):_sockfd(fd){}~TcpSocket() {}private:int _sockfd; // listensockfd, sockfd都可能};
}
对于创建,绑定,监听这三个必要的基本操作,我们已经熟悉了,不多说,代码如下
void SocketOrDie() override{_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";}void BindOrDie(uint16_t port) override{InetAddr local(port);int n = ::bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";}void ListenOrDie(int backlog) override{int n = ::listen(_sockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success";}
基本操作做完,接下来就是服务端接受连接了,下面我们就来实现Accept
std::shared_ptr<TcpSocket> Accept(InetAddr *client) override{struct sockaddr_in peer;socklen_t len = sizeof(peer);int fd = ::accept(_sockfd, (struct sockaddr*)&peer, &len);if (fd < 0){LOG(LogLevel::WARNING) << "accept warning ...";return nullptr;}client->SetAddr(peer);return std::make_shared<TcpSocket>(fd);}
注意:我们这里只是实现虚函数,将来是要在外部来调用,但是我们需要知道是哪个客户端发送的信息,可我们在定义时又不需要用到客户端的地址信息,那我们就可以通过输出型参数将地址信息让外部可以拿到。
不过我们是从网络中拿到的客户端地址信息,所以就需要从网络字节序转为主机字节序,那这步我们就可以在定义的时候来做。但是我们封装的 InetAddr
类只有构造的时候是将网络字节序转为主机字节序,我们这里是输出型参数,所以我们可以在 InetAddr
类中新增一个网络转主机的函数SetAddr,通过参数来调用SetAddr
我们在退出的时候最好还是需要将文件描述符关闭,我们之前没有说这个,这里提一下
void Close() override{if (_sockfd >= 0)::close(_sockfd);}
然后就是读写数据,tcp是面向字节流的,所以我们上篇文章中选择使用read/write来读写数据,这次我们介绍另一种tcp读写数据的系统调用
recv系统调用
recv 系统调用用于从一个已连接的面向连接的套接字(如 SOCK_STREAM,即 TCP 套接字)或已绑定的无连接套接字(如 SOCK_DGRAM,即 UDP 套接字)接收数据。
它类似于 read 系统调用,但提供了额外的 flags 参数来控制接收行为。
#include <sys/socket.h>ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数详解
int sockfd
-
描述: 这是一个由 socket() 创建,并经过 connect()(对于客户端)或 accept()(对于服务器端)处理后的套接字文件描述符。
-
要求: 套接字必须是已连接的(对于 TCP)或已绑定的(对于 UDP)。
void *buf
-
描述: 这是一个指向内存缓冲区的指针,用于存放接收到的数据。
-
要求: 应用程序必须确保这个缓冲区有足够的空间(至少 len 字节)来存放数据,否则会导致内存越界,引发未定义行为(如程序崩溃)。
size_t len
-
描述: 指定缓冲区 buf 的最大容量,即你希望一次最多接收多少字节的数据。
-
注意: recv 最多只会向你返回 len 字节的数据,即使对端发送了更多的数据。多余的数据会留在内核的套接字接收缓冲区中,等待下一次 recv 调用。
int flags
-
描述: 这是一个控制接收行为的标志位。它可以是一个或多个标志的按位或(OR)组合,最常用的标志是 0(表示默认行为,阻塞等待)。
-
常用标志:
-
0: 标准模式。调用将阻塞,直到有数据可用或连接关闭。
-
MSG_DONTWAIT: 以非阻塞方式操作。如果没有数据立即可用,recv 会立即返回失败,并设置错误码 EAGAIN 或 EWOULDBLOCK。这是实现高并发网络编程(如使用 epoll)的关键。
-
MSG_PEEK: "窥探"数据。将数据从内核缓冲区复制到应用缓冲区 buf,但不会将这些数据从内核缓冲区中移除。下一次调用 recv(不带 PEEK)还会看到这些相同的数据。
-
MSG_WAITALL: 阻塞等待,直到请求的完整数据(len 字节)全部到达、发生错误或连接关闭为止。但在某些情况下(如收到信号或连接被对端部分关闭),它返回的字节数可能仍少于请求的字节数。
-
返回值
recv 的返回值是理解其行为的关键:
-
成功时:
- > 0: 返回实际接收到的字节数。这个值可以小于你请求的 len。对于面向流的协议(如 TCP),这是非常正常的。
-
失败时:
- -1: 发生错误,并设置全局变量 errno 以指示具体的错误类型。
-
连接关闭时:
- 0: 这表示对端已经优雅地关闭了连接(对于 TCP 来说,就是收到了 FIN 包)。这是一个正常的关闭信号,不应被视为错误。返回 0 是判断对端是否已关闭连接的标准方法。
代码如下:
int Recv(std::string *out) override{char buffer[1024];ssize_t n = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);if (n > 0){buffer[n] = 0;*out += buffer; // 特意+=}return n;}
注意:
-
recv(sockfd, buf, len, 0) 基本等价于 read(sockfd, buf, len)。recv 只是多了一个 flags 参数。
-
recvfrom(): 是 recv 的增强版,主要用于无连接套接字(如 UDP)。它比 recv 多两个参数,可以获取发送方的地址信息。
这里我们同样也是需要从外部调用,如果外部要得到读取的缓冲区内容就需要通过输出型参数,而且输出型参数需要+=buffer,因为外部(上层)可能还没有将之前的数据拿完,那这个时候就不能直接覆盖掉上次的数据,所以特意+=buffer
send系统调用
send 系统调用用于向一个已连接的套接字(如 TCP 套接字)发送数据。它类似于 write 系统调用,但提供了额外的 flags 参数来控制发送行为。
注意:对于无连接的套接字(如 UDP),通常使用 sendto 或 sendmsg,因为它们允许指定目标地址。
#include <sys/socket.h>ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数详解
- i**
nt sockfd
**
-
描述: 这是一个由 socket() 创建,并经过 connect()(对于客户端)或 accept()(对于服务器端)处理后的套接字文件描述符。
-
要求: 套接字必须是已连接的(对于 TCP)或已绑定的(对于 UDP,但通常用 sendto)。
const void *buf
-
描述: 这是一个指向内存缓冲区的指针,该缓冲区包含要发送的数据。
-
要求: 应用程序必须确保这个缓冲区包含有效的数据,并且至少有 len 字节。
size_t len
- 描述: 指定要发送数据的字节数。
int flags
-
描述: 这是一个控制发送行为的标志位。它可以是一个或多个标志的按位或(OR)组合,最常用的标志是 0(表示默认行为)。
-
常用标志:
-
0: 标准模式。阻塞发送,直到所有数据被内核接管(但不一定被对端接收)。
-
MSG_DONTWAIT: 以非阻塞方式操作。如果数据不能立即被发送(比如套接字发送缓冲区已满),send 会立即返回失败,并设置错误码 EAGAIN 或 EWOULDBLOCK。这是实现高并发网络编程的关键。
-
MSG_OOB: 发送带外数据(Out-of-band data)。这用于发送紧急数据,但通常不推荐使用,因为不同的实现可能不一致。
-
MSG_MORE: 提示内核还有更多数据要发送。对于 TCP,这个标志会导致内核将数据缓存起来,等待后续没有 MSG_MORE 标志的 send 调用时再一起发送。这有助于减少小数据包的传输(类似于 TCP_CORK 或 TCP_NODELAY 的调整)。
-
返回值
send 的返回值是理解其行为的关键:
-
成功时:
- > 0: 返回实际发送的字节数。这个值可以小于你请求的 len,特别是在非阻塞模式下。
-
失败时:
- -1: 发生错误,并设置全局变量 errno 以指示具体的错误类型。
代码如下:
int Send(const std::string &message) override{return send(_sockfd, message.c_str(), message.size(), 0);}
这里我们不做过多介绍,多路转接时会详细介绍
接下来就是客户端发起连接Connect
int Connect(const std::string &server_ip, uint16_t port) override{InetAddr server(server_ip, port);return ::connect(_sockfd, server.NetAddrPtr(), server.NetAddrLen());}
3. 服务端
封装好之后就是使用封装的Socket来实现服务端,我们已经实现过了,这里就不再介绍了,只需要将原先的原生系统调用换成封装的Socket即可
#pragma once#include "Socket.hpp"
#include <memory>
#include <sys/wait.h>using namespace SocketModule;
using namespace LogModule;using ioservice_t = std::function<void(std::shared_ptr<TcpSocket>&, InetAddr&)>;class TcpServer
{
public:TcpServer(uint16_t port, ioservice_t service):_port(port), _listensockptr(std::make_unique<TcpSocket>()), _isrunning(false), _service(service){_listensockptr->BuildTcpSocketMethod(_port);}void Start(){_isrunning = true;while (_isrunning){InetAddr client;auto sock = _listensockptr->Accept(&client); // 1. 和client通信sockfd 2. client 网络地址if (sock == nullptr){continue;}LOG(LogLevel::DEBUG) << "accept success ..." << client.StringAddr();pid_t id = fork();if (id < 0){LOG(LogLevel::FATAL) << "fork error ...";exit(FORK_ERR);}else if (id == 0){// 子进程 -> listensock_listensockptr->Close();if (fork() > 0)exit(OK);// 孙子进程在执行任务,已经是孤儿了_service(sock, client);sock->Close();exit(OK);}else{// 父进程 -> socksock->Close();pid_t rid = ::waitpid(id, nullptr, 0);(void)rid;}}_isrunning = false;}~TcpServer() {}
private:uint16_t _port;std::unique_ptr<TcpSocket> _listensockptr;bool _isrunning;ioservice_t _service;
};
我们这里使用多进程分别接收连接和执行任务,这里任务我们需要在上层去实现,后面文章会详细介绍。
后面文章我们会再谈协议,然后自己来定义协议,然后顶层封装一个任务,通过我们自己定义的协议来完成序列化和反序列化,让对端拿到我们的任务去处理,所以客户端也放在后面实现