开篇介绍:
hello 大家,那么在本篇博客中,我们就将对socket、TcpSocket进行封装(秉承着我们的一贯风格),但是在封装的同时,我们也将学习一个新的模式------模版方法类,我们之前已经学习过了策略模式,相信大家也能在本篇博客中收获多多。
引言:设计模式的魅力与网络编程的挑战
在软件开发的漫长历程中,设计模式作为一种经验的沉淀,为我们提供了一套解决特定问题的通用方案。它们就像是编程世界里的 "武功秘籍",让开发者能够站在巨人的肩膀上,快速构建出优雅、可维护且高性能的系统。其中,模板方法模式(Template Method Pattern) 以其简洁的结构和强大的复用能力,成为了行为型设计模式中的佼佼者。
当我们将目光聚焦于网络编程领域,尤其是 TCP/IP 协议的实现时,会发现一个有趣的现象:无论是服务端的 listen、accept,还是客户端的 connect、send,都遵循着一套相似的流程。然而,这些流程又存在着细微的差异,比如服务端需要绑定端口,而客户端不需要;服务端需要监听连接,而客户端需要主动发起连接。这种 "相似中的不同" 正是模板方法模式发挥作用的绝佳场景。
模板方法模式的核心概念与实现
1. 什么是模板方法模式?
模板方法模式是一种行为型设计模式,它定义了一个算法的骨架,将可变的步骤延迟到子类中实现。这种模式使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。
想象一下,您需要制作一杯咖啡和一杯奶茶。虽然它们的制作流程有相似之处,但也有明显的不同:
- 咖啡:烧开水 → 冲泡咖啡粉 → 倒入杯子 → 加糖
- 奶茶:烧开水 → 冲泡奶茶粉 → 倒入杯子 → 加珍珠
在这个例子中,"烧开水" 和 "倒入杯子" 是所有饮品都必须遵循的固定步骤,而 "冲泡原料" 和 "添加调料" 则是不同饮品的特有步骤。模板方法模式正是利用了这种 "固定流程 + 可变步骤" 的结构,将不变的部分抽象到一个基类中,将可变的部分留给子类实现。
2. 模板方法模式的结构
一个典型的模板方法模式包含以下几个核心角色:
抽象类(Abstract Class)
抽象类定义了算法的骨架,它包含:
- 模板方法(Template Method) :定义算法的整体流程,按固定顺序调用各个步骤。通常将模板方法声明为
final,以防止子类修改算法结构。 - 抽象步骤(Primitive Operations):算法中必须由子类实现的步骤,通常是纯虚函数。
- 具体步骤(Concrete Steps):在抽象类中已经实现的步骤,所有子类共享。
- 钩子方法(Hook Operations):算法中的可选步骤或扩展点,通常有默认实现,子类可以选择覆盖。
具体类(Concrete Class)
具体类继承自抽象类,实现抽象步骤和可选的钩子方法,提供具体的业务逻辑。
3. 模板方法模式的实现步骤
以制作饮品为例,我们来详细说明模板方法模式的实现步骤:
第一步:定义抽象类
cpp
// 抽象类:饮品制作模板
class DrinkTemplate {
public:
// 模板方法:固定制作流程(final防止子类修改流程)
final void makeDrink() {
boilWater(); // 固定步骤1:烧开水
brew(); // 抽象步骤2:冲泡原料(子类实现)
pourInCup(); // 固定步骤3:倒入杯子
addCondiment();// 抽象步骤4:加调料(子类实现)
}
// 固定步骤:烧开水(所有饮品都一样)
void boilWater() {
cout << "1. 烧开水(100℃)" << endl;
}
// 固定步骤:倒入杯子(所有饮品都一样)
void pourInCup() {
cout << "3. 将饮品倒入杯子" << endl;
}
// 抽象步骤:冲泡原料(子类自己实现)
virtual void brew() = 0;
// 抽象步骤:加调料(子类自己实现)
virtual void addCondiment() = 0;
};
第二步:实现具体子类
cpp
// 具体实现类1:咖啡
class Coffee : public DrinkTemplate {
public:
// 实现"冲泡原料":咖啡粉
void brew() override {
cout << "2. 冲泡咖啡粉" << endl;
}
// 实现"加调料":方糖
void addCondiment() override {
cout << "4. 加方糖" << endl;
}
};
// 具体实现类2:奶茶
class MilkTea : public DrinkTemplate {
public:
// 实现"冲泡原料":奶茶粉
void brew() override {
cout << "2. 冲泡奶茶粉" << endl;
}
// 实现"加调料":珍珠+椰果
void addCondiment() override {
cout << "4. 加珍珠和椰果" << endl;
}
};
第三步:使用模板方法
cpp
int main() {
// 泡一杯咖啡
DrinkTemplate* coffee = new Coffee();
cout << "===== 制作咖啡 =====" << endl;
coffee->makeDrink();
// 泡一杯奶茶
DrinkTemplate* milkTea = new MilkTea();
cout << "\n===== 制作奶茶 =====" << endl;
milkTea->makeDrink();
delete coffee;
delete milkTea;
return 0;
}
运行结果:
cpp
===== 制作咖啡 =====
1. 烧开水(100℃)
2. 冲泡咖啡粉
3. 将饮品倒入杯子
4. 加方糖
===== 制作奶茶 =====
1. 烧开水(100℃)
2. 冲泡奶茶粉
3. 将饮品倒入杯子
4. 加珍珠和椰果
通过这个简单的例子,我们可以看到模板方法模式的核心思想:定框架、填细节。基类定义了算法的固定流程,子类负责实现可变的细节。这种结构不仅减少了代码冗余,还保证了算法流程的一致性。
模板方法模式的优势与适用场景
1. 模板方法模式的优势
代码复用
在模板方法模式中,固定的步骤只需要在抽象类中实现一次,所有子类都可以共享这些代码,避免了重复编写相同逻辑的麻烦。
流程统一
模板方法模式确保了所有子类都遵循相同的算法流程,避免了因流程混乱导致的错误。例如,在饮品制作的例子中,我们可以确保所有饮品都先烧开水,再冲泡原料,最后倒入杯子并添加调料,不会出现先加调料再烧开水的情况。
扩展方便
当需要新增一种饮品时,只需要创建一个新的子类并实现抽象步骤,无需修改现有代码。这符合 "开闭原则",即对扩展开放,对修改关闭。
控制流程
模板方法中的模板方法通常用final修饰,防止子类修改算法的整体流程。这保证了核心逻辑的稳定性,同时允许子类灵活地调整细节。
2. 模板方法模式的适用场景
模板方法模式在实际开发中应用广泛,以下是一些常见的适用场景:
框架生命周期
许多框架都使用模板方法模式来定义组件的生命周期。例如,Spring 框架中的 Bean 生命周期:
实例化 → 初始化 → 使用 → 销毁
其中,"实例化" 和 "销毁" 是固定步骤,而 "初始化" 和 "使用" 则是由具体 Bean 子类实现的。
报表生成
在报表系统中,通常有一个固定的流程:
表头 → 数据填充 → 计算统计 → 生成文件
其中,"表头" 和 "计算统计" 是固定步骤,而 "数据填充" 则由不同的报表子类实现。
支付流程
在支付系统中,支付流程通常包括:
参数验证 → 调用支付接口 → 处理结果 → 通知用户
其中,"参数验证" 和 "通知用户" 是固定步骤,而 "调用支付接口" 则由不同的支付方式(如微信支付、支付宝)实现。
游戏角色技能
在游戏开发中,角色技能的释放通常遵循固定流程:
释放前摇 → 技能效果 → 释放后摇
其中,"释放前摇" 和 "释放后摇" 是固定步骤,而 "技能效果" 则由不同的技能子类实现。
模板方法模式在 Socket 封装中的应用
现在,让我们将目光转向网络编程领域,看看模板方法模式是如何在 Socket 封装中发挥作用的。
1. 问题分析:传统 Socket 编程的痛点
在传统的 Socket 编程中,我们通常需要编写大量重复的代码。例如,无论是服务端还是客户端,都需要创建 Socket、绑定地址、监听连接等操作。这些操作不仅重复,而且容易出错。
以服务端为例,传统的 Socket 编程流程如下:
cpp
// 创建Socket
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
// 设置端口复用
int opt = 1;
setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(server_socket, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 监听连接
listen(server_socket, 16);
// 接受连接
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int client_socket = accept(server_socket, (struct sockaddr*)&client_addr, &client_addr_len);
// 发送数据
send(client_socket, "Hello, client!", 13, 0);
// 接收数据
char buf[1024];
recv(client_socket, buf, sizeof(buf)-1, 0);
// 关闭Socket
close(server_socket);
close(client_socket);
可以看到,这段代码包含了大量重复的操作。如果我们需要为不同的服务创建多个 Socket,就需要重复编写这些代码,不仅繁琐,而且容易出错。
2. 解决方案:使用模板方法模式封装 Socket
为了解决这个问题,我们可以使用模板方法模式来封装 Socket 操作。通过将不变的步骤抽象到一个基类中,将可变的步骤留给子类实现,我们可以大大简化 Socket 编程。
基类设计:Socket
我们首先定义一个抽象基类Socket,它包含了所有 Socket 操作的固定步骤和抽象步骤:
cpp
namespace SocketModule
{
class TcpSocket;
const static int DEFAULT_BACKLOG = 16; // 监听队列默认长度
const static int RECV_BUF_SIZE = 1024; // 接收缓冲区大小
const static int SOCKET_INVALID = -1; // 无效套接字标识
//最基础的基类
class Socket
{
public:
//虚析构函数
virtual ~Socket()
{}
//要声明一系列网络通信所用到的函数
//注意都得是虚函数,这样子才能重写,实现多态
//使用socket函数创建套接字的函数:
//不用返回套接字,套接字直接就在子类中成为子类的成员变量
virtual void CreateSocketOrNot()=0;
//使用bind函数将socket和程序绑定的函数
//那么就需要传入端口号,至于ip地址就不用了,因为客户端不用显式bind,在发消息的时候
//OS就会自动给客户端bind绑定上随机端口
//而服务端虽然要显式bind,但是ip地址也是设为INADDR_ANY去增大接收范围
//但是服务端需要绑定指定端口号,这样子客户端才能找的到服务端
virtual void BindSocketOrNot(const uint16_t port)=0;
//关闭自身套接字的函数
//那么我们知道,套接字是存放在子类中的成员变量的,而要是外界想关闭套接字的话
//是没办法直接访问到子类的套接字成员变量的
//所以我们就得提供一个接口用于外界调用去close套接字,避免内存泄露
virtual void CloseSocket()=0;
//使用listen函数去实现监听的函数,用于服务端
//那么由于listen函数需要传入参数去指定监听队列要有多少
//所以我们该函数也需要传入该参数,虽然我们也会设置缺省值!!!
virtual void ListenSocketOrNot(const int backlog=DEFAULT_BACKLOG)=0;
//使用accept函数去让服务端接收客户端发起的链接函数
//那么我们知道,accept函数是不需要什么外界传入的参数的
//它就是会返回accept所创建的新的socket套接字用于一对一的沟通交流
//但是我们又想把这一些都都封装起来,不想让外界可以直接修改
//所以,我们可以把accept函数封装为返回TcpSocket类对象指针
//毕竟TcpSocket类里面是可以存储socketfd套接字文件描述符的
//那么我们返回TcpSocket类对象指针,其实就是创建一个新的TcpSocket类对象指针
//然后我们将accept函数的返回值丢给返回的新的TcpSocket类对象指针
//如此一来就实现了解耦封装
virtual std::shared_ptr<TcpSocket> AcceptSocketOrNot()=0;
//使用connect函数去进行链接的封装函数
//那么我们知道,connect函数是需要外界传入要进行链接的对方主机的ip地址和端口号
//所以我们要设置参数哦
//返回值依旧是不需要
//同样的,connect函数是客户端使用的,
//所以客户端也就只能调用TcpSocket类中的创建socket、connect等函数
//是不能调用accept、listen函数的
//我们本文件只负责把这些函数都封装起来
//但是使用还是需要使用者自己规范
//比如客户端要做的就是创建socket,然后connect
//本质上就是调用该类的CreateSocketOrNot、ConnectSocketOrNot函数
virtual void ConnectSocketOrNot(const std::string& ip,const uint16_t port)=0;
//客户、服务端要用的向服务、客户端发送信息的函数的封装,即对send函数的封装
//那么肯定需要传入要发送的字符串吧,所以要设置字符串形参
//那么我们还得知道发送信息有木有成功,木有就得再发送
//总不能终止通信了吧,所以要把返回值设置为bool
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
//那么客户端在调用他所创建的TcpSocket类的CreateSocketOrNot、ConnectSocketOrNot函数之后,
//自然就是调用他所创建的TcpSocket类中的发送信息的函数
//因为客户端的socket套接字就存储在它所创建的TcpSocket类中!!!
//至于服务端要发送信息给客户端,就肯定是要用accept之后的和每个客户端一对一的socket套接字文件描述符
//所以服务端调用send函数的就是要用服务端调用了AcceptSocketOrNot锁返回的TcpSocket对象指针中的send函数!!!
//因为accept函数返回的和每个客户端一对一的socket套接字文件描述符就是存储在它返回的TcpSocket对象指针中
//依旧是那句话:我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
virtual bool Send(const std::string& send_message)=0;
//封装服务端、客户端接收信息的函数,其实也就是对recv函数的封装
//那么服务端我们要把至于守护进程,所以是用不到recv函数的
//而客户端是肯定要接收信息的,所以它就需要recv函数
//那么同样的,你要接收信息,那么你就得传入你要接收信息的字符串吧
//然后我把recv获取到字符串+到你所传入的字符串里
//这样子你就能获取到recv函数所获取的信息了
//那么我们还得知道接收信息有木有成功,木有就得再接收
//总不能终止通信了吧,所以要把返回值设置为int
//返回recv函数的返回值,由外界去判断是什么情况,我们这里没必要做的太具体!!!
//可不要再问我客户端的socket套接字在哪里了,去看看我对Send函数的解析吧
virtual int Recv(std::string& recv_message)=0;
//提供给外界可以一次性调用的接口
//服务端一键创建listen套接字的函数
//那么就是先创建套接字,然后bind,然后listen
void BulidListenSocket(const uint16_t port,const int backlog=DEFAULT_BACKLOG)
{
CreateSocketOrNot();
BindSocketOrNot(port);
ListenSocketOrNot(backlog);
}
//客户端一键connect的函数
//那么就是先创建socket套接字,然后connect
void BulidConnectSocket(const std::string& ip,const uint16_t port)
{
CreateSocketOrNot();
ConnectSocketOrNot(ip,port);
}
//使用这些函数的前提都是服务/客户端创建基类对象/指针
//然后将TcpSocket类对象/指针赋值给基类,然后再去调用这些函数,从而实现多态
//也能直接调用TcpSocket里面的成员函数
//因为在基类中都虚函数了,所以不用担心切片
};
在 Socket 抽象基类中,我们不仅声明了所有核心操作的抽象接口,还设计了两个关键的 "模板方法"------BulidListenSocket和BulidConnectSocket,这两个方法正是模板方法模式的核心体现。
BulidListenSocket是为服务端设计的一键式创建监听套接字的模板方法,它按固定顺序调用CreateSocketOrNot(创建套接字)、BindSocketOrNot(绑定端口)、ListenSocketOrNot(启动监听)三个抽象步骤,将服务端创建监听套接字的完整流程固化下来。外界使用者无需关心这三个步骤的调用顺序,只需调用这一个方法,传入端口号和监听队列长度即可完成监听套接字的创建,从根本上避免了因步骤顺序错误导致的编程错误。
而BulidConnectSocket则是为客户端设计的一键式连接服务端的模板方法,它按固定顺序调用CreateSocketOrNot(创建套接字)和ConnectSocketOrNot(发起连接)两个抽象步骤,同样将客户端连接服务端的核心流程固化,使用者只需传入服务端的 IP 地址和端口号,就能完成连接的建立,极大降低了客户端编程的复杂度。
需要特别说明的是,Socket 基类将所有核心操作声明为纯虚函数(抽象步骤),这是因为 TCP 和 UDP 的套接字操作虽然流程框架一致,但具体实现细节存在差异(比如 UDP 无需 listen 和 accept,TCP 则必须)。将这些操作抽象为纯虚函数,既保证了流程的统一性,又为不同协议的套接字子类(如 TcpSocket、UdpSocket)预留了自定义实现的空间,完美契合模板方法模式 "定框架、填细节" 的核心思想。
此外,Socket 基类的析构函数被声明为虚析构函数,这是 C++ 多态编程的基本规范 ------ 当使用基类指针指向子类对象时,虚析构函数能确保子类的析构函数被正确调用,避免内存泄漏。这一设计细节看似微小,却是保证整个套接字封装体系健壮性的关键。
3. 子类设计:TcpSocket
TcpSocket 作为 Socket 基类的具体实现子类,负责完成 TCP 协议下所有抽象步骤的具体实现。它的核心设计思路是将 TCP 套接字的文件描述符(socketfd)封装为私有成员变量,对外隐藏底层实现细节,仅通过基类暴露的接口提供服务,既保证了数据的安全性,又符合 "封装" 的面向对象设计原则。
(1)构造与析构:资源的安全管理
TcpSocket 设计了两个构造函数:无参构造函数和带套接字文件描述符的构造函数。
无参构造函数将私有成员_socketfd初始化为SOCKET_INVALID(-1),表示初始状态下套接字无效,这是一种防御性编程策略 ------ 通过初始值明确套接字的状态,避免后续操作中使用未初始化的无效套接字。
带套接字文件描述符的构造函数则专门为AcceptSocketOrNot方法设计:当服务端调用 accept 函数接收到客户端连接时,会返回一个新的套接字文件描述符(用于与该客户端一对一通信),此时通过这个构造函数可以快速创建一个包含该新套接字的 TcpSocket 对象,让服务端能够通过这个对象与客户端进行数据交互。
TcpSocket 的析构函数会检查_socketfd的有效性:如果套接字有效(≥0),则调用系统的 close 函数关闭套接字,并将_socketfd置为无效。这一设计实现了套接字资源的自动释放,使用者无需手动调用 close 函数,大大降低了因忘记关闭套接字导致的资源泄漏风险。
cpp
public:
//无参构造函数
TcpSocket()
:_socketfd(SOCKET_INVALID)
{}
//传入套接字的构造函数
//用于accept函数返回一个新的TcpSocket对象指针
//里面放accept函数的返回值,也就是新的一对一的socket套接字文件描述符
TcpSocket(int socketfd)
:_socketfd(socketfd)
{}
//析构函数
//将本类的socket套接字文件描述符close
~TcpSocket()
{
if(_socketfd>=0)
{
::close(_socketfd);
}
}
(2)CreateSocketOrNot:TCP 套接字的创建
CreateSocketOrNot方法是对系统 socket 函数的封装,其核心逻辑分为三步:
第一步,调用系统 socket 函数创建 TCP 套接字:指定地址族为 AF_INET(IPv4)、套接字类型为 SOCK_STREAM(流式套接字,对应 TCP 协议)、协议为 0(由系统自动匹配对应类型的默认协议)。如果创建失败,会输出错误信息并退出程序(实际项目中可改为返回错误码,增强容错性)。
第二步,设置端口复用:这是服务端开发中必不可少的配置。当服务端程序意外退出后,端口可能会处于 TIME_WAIT 状态,此时新启动的程序无法立即绑定该端口。通过 setsockopt 函数设置 SO_REUSEADDR 选项,可以让端口快速复用,避免服务端重启时的端口占用问题。
第三步,将创建成功的套接字文件描述符赋值给 TcpSocket 的私有成员_socketfd,完成套接字的创建。
需要注意的是,该方法中所有系统函数调用前都添加了作用域解析符(::),这是为了明确调用的是系统全局函数,而非类的成员函数,避免因函数名冲突导致的编译错误 ------ 这是 C++ 编程中容易被忽略但至关重要的细节。
cpp
//创建套接字
//即调用socket函数
//那么其实这个函数所得出来的socket套接字在服务端中也是拿来做listen套接字
virtual void CreateSocketOrNot() override
{
int socketfd=::socket(AF_INET,SOCK_STREAM,0);
if(socketfd<0)
{
std::cerr<<"socket failed!!!"<<strerror(errno)<<std::endl;
exit(SOCKET_ERR);//在Common头文件中
}
//端口复用(服务端必须)
int opt = 1;
if (::setsockopt(socketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0)
{
std::cerr << "setsockopt SO_REUSEADDR failed: " << strerror(errno) << std::endl;
}
//创建socket套接字成功,赋值给成员变量
_socketfd=socketfd;
//至此创建套接字成功,但是要注意是在当前这个类变量中,有着socket套接字
}
(3)BindSocketOrNot:TCP 套接字的绑定
BindSocketOrNot方法是对系统 bind 函数的封装,仅服务端需要调用,核心逻辑如下:
首先,初始化 sockaddr_in 结构体:该结构体用于存储套接字的地址信息,包括地址族(AF_INET)、端口号(需要转换为网络字节序,通过 htons 函数)、IP 地址(设置为 INADDR_ANY,即监听所有网卡的 IP 地址,扩大服务端的接收范围)。
然后,调用系统 bind 函数将套接字与指定的端口号绑定:如果绑定失败,输出错误信息并退出程序。
这里需要解释两个关键的字节序转换函数:htons(主机字节序转网络字节序,短整型)和 htonl(主机字节序转网络字节序,长整型)。由于不同主机的字节序(大端 / 小端)不同,网络传输必须遵循统一的网络字节序(大端),因此端口号和 IP 地址在绑定前必须完成字节序转换,否则会出现地址解析错误。
此外,方法的参数仅设计为端口号,而未包含 IP 地址,这是因为客户端无需显式绑定 IP 和端口(系统会在发送数据时自动分配),而服务端绑定 INADDR_ANY 即可监听所有 IP,无需指定具体 IP------ 这一设计简化了接口,符合 "最小接口原则"。
cpp
//绑定bind函数,也就服务端需要
virtual void BindSocketOrNot(const uint16_t port) override
{
//老样子,socketaddr_in结构体
struct sockaddr_in server;
::memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=::htons(port);
//不用担心我们把形参设置为const是不是不可以这样子,htons只是读取它的值,不会进行修改哦
server.sin_addr.s_addr=::htonl(INADDR_ANY);
socklen_t len=sizeof(server);
int ret_bind=::bind(_socketfd,(struct sockaddr*)(&server),len);
if(ret_bind<0)
{
std::cerr<<"bind failed!!!"<<strerror(errno)<<std::endl;
exit(BIND_ERR);//在Common头文件中
}
}
(4)CloseSocket:套接字的安全关闭
CloseSocket方法是对系统 close 函数的封装,核心逻辑是:先检查_socketfd的有效性,若有效则调用 close 函数关闭套接字,并将_socketfd重置为SOCKET_INVALID。
将_socketfd重置为无效是关键细节:如果仅关闭套接字而不重置状态,后续可能会重复调用 close 函数(比如析构函数再次调用),而重复关闭无效的套接字会导致系统调用错误。通过重置状态,能有效避免这类问题,保证方法的幂等性(多次调用与一次调用的效果一致)。
cpp
//关闭自身套接字的函数
//那么我们知道,套接字是存放在子类中的成员变量的,而要是外界想关闭套接字的话
//是没办法直接访问到子类的套接字成员变量的
//所以我们就得提供一个接口用于外界调用去close套接字,避免内存泄露
virtual void CloseSocket() override
{
if(_socketfd>=0)
{
::close(_socketfd);
_socketfd = SOCKET_INVALID; //标记为无效,避免重复关闭
}
//就这么简单
}
(5)ListenSocketOrNot:TCP 套接字的监听
ListenSocketOrNot方法是对系统 listen 函数的封装,仅服务端需要调用,核心逻辑是:
调用系统 listen 函数,将套接字设置为监听状态,参数包括套接字文件描述符和监听队列长度(默认值为 DEFAULT_BACKLOG=16)。监听队列用于存储已完成三次握手但尚未被 accept 的客户端连接,队列长度过小将导致客户端连接被拒绝,过大则会浪费系统资源,16 是兼顾性能和资源的默认值。
需要强调的是,调用该方法的 TcpSocket 对象对应的是服务端的 "监听套接字",而非与客户端通信的 "通信套接字"------ 监听套接字仅负责接收客户端的连接请求,不参与实际的数据交互,这是 TCP 服务端编程的核心概念。
cpp
//使用listen函数去实现监听的函数,用于服务端
//那么由于listen函数需要传入参数去指定监听队列要有多少
//所以我们该函数也需要传入该参数,虽然我们也会设置缺省值!!!
virtual void ListenSocketOrNot(const int backlog=DEFAULT_BACKLOG) override
{
//传入本类变量的socket套接字成员变量即可
//虽然我们本类也要实现accept函数的封装
//但是listen函数和accept函数的调用是分开的,且accept函数是返回新的TcpSocket类对象指针
//我们是不可能用accept函数返回的TcpSocket对象指针去执行listen函数的
//所以调用了listen函数的TcpSocket的本质其实是listensocketfd哦!!!
int ret_listen=::listen(_socketfd,backlog);
if(ret_listen<0)
{
std::cerr<<"listen failed!!!"<<strerror(errno)<<std::endl;
exit(LISTEN_ERR);//在Common头文件中
}
}
(6)AcceptSocketOrNot:客户端连接的接收
AcceptSocketOrNot方法是对系统 accept 函数的封装,仅服务端需要调用,核心逻辑如下:
首先,初始化 sockaddr_in 结构体:用于存储发起连接的客户端的地址信息(IP 和端口)。
然后,调用系统 accept 函数:该函数会阻塞等待客户端的连接请求,当接收到请求后,返回一个新的套接字文件描述符(通信套接字),用于与该客户端进行一对一的数据交互。
最后,创建一个包含该通信套接字的 TcpSocket 智能指针(std::shared_ptr)并返回:使用智能指针而非裸指针,是为了实现套接字资源的自动管理 ------ 当智能指针的引用计数为 0 时,会自动调用 TcpSocket 的析构函数关闭套接字,彻底避免内存泄漏。
这里需要解释为什么返回智能指针:如果返回裸指针,使用者需要手动管理内存,容易出现忘记释放的情况;而 std::shared_ptr 是共享智能指针,能自动跟踪对象的引用计数,保证资源在不再使用时被释放,是 C++ 现代编程中管理动态资源的首选方式。
cpp
//使用accept函数去让服务端接收客户端发起的链接函数
//那么我们知道,accept函数是不需要什么外界传入的参数的
//它就是会返回accept所创建的新的socket套接字用于一对一的沟通交流
//但是我们又想把这一些都都封装起来,不想让外界可以直接修改
//所以,我们可以把accept函数封装为返回TcpSocket类对象指针
//毕竟TcpSocket类里面是可以存储socketfd套接字文件描述符的
//那么我们返回TcpSocket类对象指针,其实就是创建一个新的TcpSocket类对象指针
//然后我们将accept函数的返回值丢给返回的新的TcpSocket类对象指针
//如此一来就实现了解耦封装
virtual std::shared_ptr<TcpSocket> AcceptSocketOrNot() override
{
//老样子,创建socketaddr_in结构体
//用于存储和服务端链接的客户端信息
struct sockaddr_in client;
::memset(&client,0,sizeof(client));
socklen_t len=sizeof(client);
int accept_socketfd=::accept(_socketfd,(struct sockaddr*)(&client),&len);
if(accept_socketfd<0)
{
std::cerr<<"accept failed!!!"<<strerror(errno)<<std::endl;
exit(ACCEPT_ERR);//在Common头文件中
}
//接下来就返回存有accept函数返回值,也就是新的套接字文件描述符的TcpSocket指针
//所以我们要把新的套接字文件描述符传入新的TcpSocket指针中
//这也是为什么我们上面要实现单参数的TcpSocket构造函数的原因
std::shared_ptr<TcpSocket> ret(std::make_shared<TcpSocket>(accept_socketfd));
return ret;
}
(7)ConnectSocketOrNot:客户端连接的发起
ConnectSocketOrNot方法是对系统 connect 函数的封装,仅客户端需要调用,核心逻辑分为四步:
第一步,前置检查:确保套接字有效(_socketfd≥0),若无效则输出错误信息并退出程序 ------ 这是防御性编程,避免调用 connect 函数时传入无效套接字。
第二步,初始化 sockaddr_in 结构体:存储服务端的地址信息,包括地址族(AF_INET)、端口号(转换为网络字节序)、IP 地址(通过 inet_pton 函数转换为网络字节序,该函数是线程安全的,优于传统的 inet_addr 函数)。
第三步,IP 地址转换:调用 inet_pton 函数将字符串形式的 IP 地址(如 "127.0.0.1")转换为网络字节序的二进制形式。该函数会检查 IP 地址的有效性,若转换失败(如 IP 格式错误),则输出错误信息并退出程序。
第四步,调用系统 connect 函数:向服务端发起 TCP 连接(三次握手),若连接失败则输出错误信息并退出程序。
需要注意的是,connect 函数的阻塞特性:默认情况下,connect 函数会阻塞直到连接建立或失败(超时时间由系统决定)。在实际项目中,可将套接字设置为非阻塞模式,结合 select/poll/epoll 实现异步连接,提升程序的并发性能 ------ 这是扩展优化的方向,基础封装中暂不涉及,避免增加复杂度。
cpp
//使用connect函数去进行链接的封装函数
//那么我们知道,connect函数是需要外界传入要进行链接的对方主机的ip地址和端口号
//所以我们要设置参数哦
//返回值依旧是不需要
//同样的,connect函数是客户端使用的,
//所以客户端也就只能调用TcpSocket类中的创建socket、connect等函数
//是不能调用accept、listen函数的
//我们本文件只负责把这些函数都封装起来
//但是使用还是需要使用者自己规范
//比如客户端要做的就是创建socket,然后connect
//本质上就是调用该类的CreateSocketOrNot、ConnectSocketOrNot函数
virtual void ConnectSocketOrNot(const std::string& ip,const uint16_t port) override
{
// 前置检查:套接字必须有效
if (_socketfd < 0)
{
std::cerr << "connect failed: socket not created" << std::endl;
exit(CONNECT_ERR);
}
//老样子,创建socketaddr_in结构体
//用于存储客户端要链接的服务端的信息,然后才能connect
struct sockaddr_in server;
::memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=::htons(port);
//使用线程安全函数去将ip地址转换为网络字节序
int ret_inet_pton=::inet_pton(AF_INET,ip.c_str(),&(server.sin_addr));
if (ret_inet_pton == 0)
{
std::cerr << "inet_pton failed: invalid IP address (" << ip << ")" << std::endl;
exit(CONNECT_ERR);
}
else if (ret_inet_pton == -1)
{
std::cerr << "inet_pton failed: " << strerror(errno) << std::endl;
exit(CONNECT_ERR);
}
socklen_t len=sizeof(server);
int ret_connect=::connect(_socketfd,(struct sockaddr*)(&server),len);
if(ret_connect<0)
{
std::cerr<<"connect failed!!!"<<strerror(errno)<<std::endl;
exit(CONNECT_ERR);//在Common头文件中
}
}
(8)Send:TCP 数据的发送
Send方法是对系统 send 函数的封装,服务端和客户端都需要调用,核心设计思路是解决 TCP "粘包" 和 "部分发送" 问题,核心逻辑如下:
第一步,空字符串检查:如果待发送的字符串为空,直接返回成功,避免无意义的系统调用。
第二步,循环发送:由于 TCP 是面向字节流的协议,send 函数可能无法一次性发送所有数据(即 "部分发送"),因此需要通过循环确保所有数据都被发送。具体来说,定义sentlen变量记录已发送的字节数,每次调用 send 函数发送 "未发送的剩余部分"(通过指针偏移实现),并将发送成功的字节数累加到sentlen,直到sentlen等于待发送字符串的长度。
第三步,错误处理:如果 send 函数调用失败(返回值 < 0),输出错误信息并返回 false;若所有数据发送完成,返回 true。
这里需要解释指针偏移的原理:字符串的底层是字符数组,data+sentlen表示从已发送字节的下一个位置开始发送,确保数据不重复、不遗漏。例如,若待发送字符串长度为 10,第一次发送了 4 个字节,第二次则从第 5 个字节开始发送剩余的 6 个字节。
此外,Send 方法的返回值设计为 bool 类型,目的是让使用者快速判断发送是否成功,而无需关心具体的发送字节数 ------ 这符合 "接口易用性" 原则,将复杂的底层细节封装起来,对外提供简单清晰的接口。
cpp
//客户、服务端要用的向服务、客户端发送信息的函数的封装,即对send函数的封装
//那么肯定需要传入要发送的字符串吧,所以要设置字符串形参
//那么我们还得知道发送信息有木有成功,木有就得再发送
//总不能终止通信了吧,所以要把返回值设置为bool
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
//那么客户端在调用他所创建的TcpSocket类的CreateSocketOrNot、ConnectSocketOrNot函数之后,
//自然就是调用他所创建的TcpSocket类中的发送信息的函数
//因为客户端的socket套接字就存储在它所创建的TcpSocket类中!!!
//至于服务端要发送信息给客户端,就肯定是要用accept之后的和每个客户端一对一的socket套接字文件描述符
//所以服务端调用send函数的就是要用服务端调用了AcceptSocketOrNot所返回的TcpSocket对象指针中的send函数!!!
//因为accept函数返回的和每个客户端一对一的socket套接字文件描述符就是存储在它返回的TcpSocket对象指针中
//依旧是那句话:我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
virtual bool Send(const std::string& send_message) override
{
// 空字符串直接返回成功,避免无意义循环
if (send_message.empty())
{
return true;
}
int sentlen=0;//定义send函数成功发送的字节数
//要是小于我们要它发送的字符串字节数的话,就一直while循环发送
size_t send_message_len=send_message.size();//总待发送长度
const char* data = send_message.c_str();//要发送的信息的字符串形式
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
while(sentlen<send_message_len)
{
//发送 "未发送的剩余部分"(data+sentlen,长度send_message_len-sentlen)
//因为要是只发送了一部分,那么我们下次发送肯定就得从上次发送结束的地方再去往后发送
//不可能每次都发送全部内容吧!!!
//那么我们怎么知道上次发送结束的地方呢???不就是用sentlen记录着吗
//我们用data加上sentlen,不就是定位到了上次发送结束
//(指针可以++哦,一个字符的大小是一个字节哦)
//那么我们发送的字符长度就应该是send_message_len-sentlen才对
//这一点要注意一下
int ret_send=::send(_socketfd,data+sentlen,send_message_len-sentlen,0);
if(ret_send<0)
{
std::cerr<<"send failed!!!"<<strerror(errno)<<std::endl;
return false;//发送信息失败,自然返回fasle
}
//然后我们要给send函数成功发送的字节数的sentlen加上send函数的返回值
//因为要是一次没有发送完,那么我们得加上上次发送成功的字节数!!!
sentlen+=ret_send;
}
return true;//发送信息成功,自然返回true
}
(9)Recv:TCP 数据的接收
Recv方法是对系统 recv 函数的封装,服务端(通过通信套接字)和客户端都需要调用,核心设计思路是解决 TCP "粘包" 和 "部分接收" 问题,核心逻辑如下:
第一步,初始化接收缓冲区:创建固定大小的字符数组(RECV_BUF_SIZE=1024),用于存储接收到的数据。
第二步,调用 recv 函数:从套接字中读取数据到缓冲区,返回值为实际读取的字节数。需要注意的是,recv 函数的第三个参数设置为sizeof(buf)-1,这是为了预留一个字节的空间存储字符串终止符('\0'),避免缓冲区溢出。
第三步,数据拼接:如果 recv 函数返回值 > 0(成功接收数据),则将缓冲区中的数据追加到传入的字符串参数中。这里使用 string 的 append 方法而非直接赋值,是为了处理 "部分接收" 问题 ------ 如果一次 recv 未能读取完整的数据包,后续的 recv 调用可以将新数据追加到字符串末尾,最终拼接出完整的数据包。
第四步,返回 recv 函数的返回值:让使用者根据返回值判断接收状态(>0 表示成功接收字节数,0 表示对方关闭连接,<0 表示接收失败),保留了底层的灵活性。
需要重点解释 TCP "粘包" 问题:由于 TCP 是面向字节流的协议,数据会被拆分成多个数据包发送,也可能将多个小数据包合并为一个发送,因此接收端无法保证一次 recv 就能读取到完整的数据包。通过将数据追加到字符串中,使用者可以在外部根据数据包的边界(如固定长度、分隔符)解析完整数据,这是解决粘包问题的基础思路。
cpp
//封装服务端、客户端接收信息的函数,其实也就是对recv函数的封装
//那么服务端我们要把至于守护进程,所以是用不到recv函数的
//而客户端是肯定要接收信息的,所以它就需要recv函数
//那么同样的,你要接收信息,那么你就得传入你要接收信息的字符串吧
//然后我把recv获取到字符串+到你所传入的字符串里
//这样子你就能获取到recv函数所获取的信息了
//那么我们还得知道接收信息有木有成功,木有就得再接收
//总不能终止通信了吧,所以要把返回值设置为int
//返回recv函数的返回值,由外界去判断是什么情况,我们这里没必要做的太具体!!!
//可不要再问我客户端的socket套接字在哪里了,去看看我对Send函数的解析吧
virtual int Recv(std::string& recv_message) override
{
char buf[RECV_BUF_SIZE]={0};
ssize_t ret_recv=::recv(_socketfd,buf,sizeof(buf)-1,0);
// if(ret_recv<0)
// {
// std::cerr<<"recv failed!!!"<<strerror(errno)<<std::endl;
// return false;//接收信息失败,自然返回fasle
// }
// else if(ret_recv==0)//发送信息的对方关闭了
// {
// std::cerr<<"a stream socket peer has performed an orderly shutdown(服务端关闭)"<<std::endl;
// return false;
// }
// else
// {
// buf[ret_recv]='\0';//在字符串末尾添加字符串终止符,符合C语言字符串
// }
//接收信息成功
//recv_message=recv_message + static_cast<std::string>(buf);
//使用string里的append函数去将收到的字符串追加进去
if(ret_recv>0)//成功了才去追加
{
recv_message.append(buf,ret_recv);
}
//为什么要累加???
//因为TCP是面向字节流,因为我们怕收到的数据少于一个完整报头!!!
//所以,要是少了的话,我们就得去再recv序列化字符串!!!
//可是之前recv的呢???
//加上啊!!!!!!!!!!!!!!!!!!!!!
//返回recv函数的返回值,由外界去判断是什么情况
return ret_recv;
}
4. 模板方法模式在 TCP Socket 封装中的核心价值
将模板方法模式应用于 TCP Socket 封装后,整个网络编程体系呈现出以下核心优势:
(1)流程标准化,降低出错概率
Socket 基类通过BulidListenSocket和BulidConnectSocket两个模板方法,将服务端和客户端的核心流程固化:
- 服务端:创建套接字 → 绑定端口 → 启动监听(一键完成)
- 客户端:创建套接字 → 发起连接(一键完成)
使用者无需记忆复杂的步骤顺序,只需调用对应的模板方法即可,从根本上避免了因步骤颠倒(如先监听后绑定)导致的编程错误。
(2)代码复用,减少冗余
TCP 套接字的创建、关闭、数据发送 / 接收等核心操作,仅在 TcpSocket 子类中实现一次,所有使用者都可以通过基类接口调用,避免了传统编程中重复编写这些代码的问题。例如,服务端和客户端都需要发送数据,只需调用Send方法即可,无需各自实现发送逻辑。
(3)封装底层细节,提升易用性
所有系统调用(socket、bind、listen 等)都被封装在类的内部,使用者无需了解底层的系统函数参数、字节序转换、错误处理等细节,只需关注业务逻辑(如发送什么数据、接收后如何处理)。例如,使用者无需手动调用 htons 转换端口号,也无需手动关闭套接字,这些都由封装层自动完成。
(4)扩展灵活,符合开闭原则
如果需要扩展 UDP 套接字封装,只需创建 UdpSocket 子类,继承 Socket 基类并实现对应的抽象步骤即可,无需修改基类的任何代码。例如,UdpSocket 只需实现CreateSocketOrNot(创建数据报套接字 SOCK_DGRAM)、BindSocketOrNot、Send、Recv等方法,无需改变基类的模板方法 ------ 这完全符合 "开闭原则",对扩展开放,对修改关闭。
(5)多态特性,便于统一管理
通过基类指针(Socket*)指向子类对象(TcpSocket),可以实现对不同协议套接字的统一管理。例如,一个网络框架可以通过 Socket 基类指针管理所有的 TCP/UDP 套接字,调用统一的CloseSocket方法关闭所有套接字,无需区分具体的协议类型。
5. 封装后的使用方式
(1)服务端使用方式
- 创建 Socket 基类指针,指向 TcpSocket 子类对象;
- 调用
BulidListenSocket方法,传入端口号(如 8080),一键创建监听套接字; - 循环调用
AcceptSocketOrNot方法,接收客户端连接,获取通信套接字的智能指针; - 通过通信套接字的智能指针调用
Recv方法接收客户端数据,调用Send方法向客户端发送数据; - 通信完成后,调用
CloseSocket方法关闭套接字(或依赖智能指针自动释放)。
(2)客户端使用方式
- 创建 Socket 基类指针,指向 TcpSocket 子类对象;
- 调用
BulidConnectSocket方法,传入服务端 IP(如 "127.0.0.1")和端口号(如 8080),一键连接服务端; - 调用
Send方法向服务端发送数据,调用Recv方法接收服务端数据; - 通信完成后,调用
CloseSocket方法关闭套接字。
可以看到,封装后的使用方式极其简洁,使用者只需关注业务逻辑,无需处理复杂的底层细节,大大提升了开发效率。
6. UDP 套接字封装的扩展思路(补充)
虽然本文的核心是 TCP 套接字封装,但模板方法模式同样适用于 UDP 套接字封装,其扩展思路如下:
(1)UdpSocket 子类的核心差异
UDP 是无连接的数据包协议,与 TCP 的核心差异体现在:
- 创建套接字时,类型为 SOCK_DGRAM(数据报套接字);
- 无需 listen 和 accept 操作;
- 发送数据时需要指定目标地址(sendto 函数),接收数据时需要获取发送方地址(recvfrom 函数)。
(2)扩展步骤
- 创建 UdpSocket 子类,继承 Socket 基类;
- 实现
CreateSocketOrNot方法:创建 SOCK_DGRAM 类型的套接字; - 实现
BindSocketOrNot方法:与 TCP 一致,绑定端口号(客户端可选,服务端必须); - 重写
Send和Recv方法:分别封装 sendto 和 recvfrom 函数; - 将
ListenSocketOrNot和AcceptSocketOrNot方法实现为空(或抛出异常),因为 UDP 无需监听和接收连接。
通过这种方式,UDP 套接字可以复用 Socket 基类的模板方法(如BulidListenSocket,仅服务端绑定端口),同时自定义 UDP 特有的实现细节,充分体现了模板方法模式的灵活性。
模板方法模式与 Socket 封装的深度总结
1. 模板方法模式的核心本质
模板方法模式的本质是 "流程固化 + 细节定制":
- 固化:将算法的核心流程(如套接字创建、连接、数据交互)抽象到基类中,保证所有子类遵循统一的流程;
- 定制:将流程中的可变细节(如 TCP/UDP 的套接字创建、数据发送方式)延迟到子类中实现,满足不同场景的需求。
它不是 "限制",而是 "规范"------ 在保证核心流程正确的前提下,给予子类足够的灵活性,这也是设计模式的核心价值:在混乱中建立秩序,在约束中保留灵活。
2. Socket 封装的核心价值
基于模板方法模式的 Socket 封装,解决了传统网络编程的三大痛点:
- 流程混乱:通过模板方法固化核心流程,避免步骤错误;
- 代码冗余:将重复的系统调用封装为类方法,提升代码复用率;
- 门槛过高:隐藏底层细节,降低网络编程的学习和使用门槛。
对于新手开发者来说,无需深入理解系统调用、字节序、粘包等底层概念,就能快速实现 TCP 客户端 / 服务端的开发;对于资深开发者来说,封装后的代码更易维护、更易扩展,符合大型项目的工程化要求。
3. 设计模式的落地思考
模板方法模式的应用,给我们带来了关于设计模式落地的重要启示:
- 设计模式不是 "炫技",而是解决实际问题的工具:本文中模板方法模式的应用,核心是解决 Socket 编程的流程混乱和代码冗余问题,而非单纯为了使用设计模式而使用;
- 面向抽象编程,而非面向具体实现:通过基类定义接口,子类实现细节,提升代码的灵活性和可维护性;
- 封装细节,暴露接口:好的封装应该隐藏复杂的底层细节,对外提供简单、清晰、易用的接口,这是所有设计模式的共同追求。
完整示例代码:
cpp
#pragma once
#include <iostream>
#include <memory>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <functional>
#include <cerrno>
#include <cstring>
#include <sys/wait.h>
//#include "Log.hpp"
#include "Common.hpp"
//#include "InetAddr.hpp" //可以利用该头文件做优化,但这里向突出最原始的,所以没有使用,在翻译这里,我们就进行使用简化
// OK,那么在本文件中,我们就秉承着我们之前的封装大法
// 对TCPsocket等等系列的函数都去进行一个封装,方便于外部的使用,大大提高效率
// 但是嘞,在这里我们换一种方式来进行封装
// 使用模版方法类方式,和策略模式有那么一丢丢类似
// 本质也是利用多态!!!
// 想想看,TCP和UDP都是用类似的一些函数,socket创建套接字,bind绑定,liseten监听,accept接受等等
// 虽然UDP没有后面那么些函数,但是我们是主用TCP的呀
// 那么对于UDP和TCP而言,它们都要进行socket创建套接字,bind绑定
// 那么难道要在TCP和UDP中把两个都各自实现???
// 这未免代码冗余
// 所以我们可以把这些相同的步骤,都放在基类中实现,而不用的步骤,再在各自的子类中去实现
// 大大简洁
// 其实在这里我们直接封装也可以,但使用模版方法类方式会更显得我们专业
// 具体要如何实现呢???
// 1. 模式结构
// 模板方法模式包含以下核心角色:
// 抽象类(Abstract Class):定义算法的骨架(模板方法),
// 声明抽象步骤(primitive operations),由子类实现。
// 可以提供默认实现的钩子方法(hook operations),子类可选覆盖。
// 通常将模板方法声明为 final,防止子类改变算法结构。
// 具体类(Concrete Class):实现抽象步骤,提供具体的业务逻辑,可覆盖钩子方法,调整算法的行为。
// 2. 关键概念
// 模板方法(Template Method):定义算法的流程,按固定顺序调用各个步骤。
// 抽象步骤(Primitive Operations):算法中必须由子类实现的步骤(函数),
// 即基类只负责申明这一些抽象步骤(函数),可用于多态时调用
// 钩子方法(Hook Operations):算法中可选的步骤或扩展点,通常有默认实现。
// 不变部分:在抽象类中实现,所有子类共享。
// 可变部分:在子类中实现,不同子类有不同实现。
/*************************************************************************
* 模板方法模式核心理解:
* 核心比喻:就像老师给的作文模板 ------ 开头、结尾、结构是固定的,中间的具体内容由你自己填。
* 专业定义:模板方法模式是一种「行为型设计模式」,核心是:定义一个算法的骨架(固定流程),
* 把其中可变的步骤延迟到子类中实现。这样子类可以在不改变整体流程的前提下,只修改细节步骤。
*************************************************************************/
/*************************************************************************
* 一、为什么需要模板方法模式?
* 假设要做两款饮品:泡咖啡和泡奶茶,制作流程对比:
* 泡咖啡 泡奶茶
* 1. 烧开水(100℃) 1. 烧开水(100℃)
* 2. 冲泡咖啡粉 2. 冲泡奶茶粉
* 3. 倒入杯子 3. 倒入杯子
* 4. 加方糖 4. 加珍珠 / 椰果
*
* 关键发现:步骤 1、3 是完全一样的(固定流程),步骤 2、4 是不同的(可变细节)。
* 无模板方法的问题:需要给咖啡和奶茶各写一套完整流程,重复写「烧开水」「倒杯子」的代码;
* 有模板方法的优势:把固定流程抽出来,只让子类实现可变步骤 ------ 既省代码,又保证流程不会乱
* (比如不会有人先加方糖再烧开水)。
*************************************************************************/
/*************************************************************************
* 二、模板方法模式的核心角色(通俗版)
* 不用记专业术语,记住 3 个关键部分:
* 角色 通俗解释
* 抽象模板类 定义「固定流程」(比如饮品制作的 4 步),包含:
* 1. 模板方法(固定流程)
* 2. 固定步骤(比如烧开水)
* 3. 抽象步骤(留给子类填的细节)
* 具体实现类 继承抽象模板类,只实现「抽象步骤」(比如咖啡实现 "冲泡咖啡粉",
* 奶茶实现 "冲泡奶茶粉")
* 模板方法(核心) 抽象类中固定流程的方法,通常加 final 防止子类改流程(比如不让人乱改
* "先烧开水" 的顺序)
*************************************************************************/
/*************************************************************************
* 三、实战例子:用代码实现「泡饮品」
* 极简C++版本,核心逻辑一目了然
*************************************************************************/
/*************************************************************************
* 第一步:定义抽象模板类(饮品制作模板)
* 把固定流程(烧开水、倒杯子)写死,可变步骤(冲泡、加调料)留空让子类实现
*************************************************************************/
// 抽象模板类:饮品制作模板
// class DrinkTemplate {
// public:
// // 模板方法:固定制作流程(final防止子类修改流程)
// final void makeDrink() {
// boilWater(); // 固定步骤1:烧开水
// brew(); // 抽象步骤2:冲泡原料(子类实现)
// pourInCup(); // 固定步骤3:倒入杯子
// addCondiment();// 抽象步骤4:加调料(子类实现)
// }
// // 固定步骤:烧开水(所有饮品都一样)
// void boilWater() {
// cout << "1. 烧开水(100℃)" << endl;
// }
// // 固定步骤:倒入杯子(所有饮品都一样)
// void pourInCup() {
// cout << "3. 将饮品倒入杯子" << endl;
// }
// // 抽象步骤:冲泡原料(子类自己实现)
// virtual void brew() = 0;
// // 抽象步骤:加调料(子类自己实现)
// virtual void addCondiment() = 0;
// };
/*************************************************************************
* 第二步:实现具体子类(咖啡 / 奶茶)
* 只需要补全「抽象步骤」,不用管整体流程
*************************************************************************/
// 具体实现类1:咖啡
// class Coffee : public DrinkTemplate {
// public:
// // 实现"冲泡原料":咖啡粉
// void brew() override {
// cout << "2. 冲泡咖啡粉" << endl;
// }
// // 实现"加调料":方糖
// void addCondiment() override {
// cout << "4. 加方糖" << endl;
// }
// };
// 具体实现类2:奶茶
// class MilkTea : public DrinkTemplate {
// public:
// // 实现"冲泡原料":奶茶粉
// void brew() override {
// cout << "2. 冲泡奶茶粉" << endl;
// }
// // 实现"加调料":珍珠+椰果
// void addCondiment() override {
// cout << "4. 加珍珠和椰果" << endl;
// }
// };
/*************************************************************************
* 第三步:使用模板方法
* 调用时只需要创建具体子类,执行模板方法即可
*************************************************************************/
// int main() {
// // 泡一杯咖啡
// DrinkTemplate* coffee = new Coffee();
// cout << "===== 制作咖啡 =====" << endl;
// coffee->makeDrink();
// // 泡一杯奶茶
// DrinkTemplate* milkTea = new MilkTea();
// cout << "\n===== 制作奶茶 =====" << endl;
// milkTea->makeDrink();
// delete coffee;
// delete milkTea;
// return 0;
// }
/*************************************************************************
* 运行结果:
* ===== 制作咖啡 =====
* 1. 烧开水(100℃)
* 2. 冲泡咖啡粉
* 3. 将饮品倒入杯子
* 4. 加方糖
*
* ===== 制作奶茶 =====
* 1. 烧开水(100℃)
* 2. 冲泡奶茶粉
* 3. 将饮品倒入杯子
* 4. 加珍珠和椰果
*************************************************************************/
/*************************************************************************
* 四、模板方法模式的核心好处(为什么要用?)
* 1. 代码复用:固定步骤(烧开水、倒杯子)只写一次,子类不用重复写,减少冗余;
* 2. 流程统一:所有子类都遵循相同的算法流程,避免混乱(比如不会有人先加调料再烧开水);
* 3. 扩展方便:新增饮品(比如泡红茶),只需要新建子类,实现brew()和addCondiment()即可,
* 不用改模板类(符合「开闭原则」);
* 4. 控制流程:模板方法用final修饰,子类只能改细节,不能改整体流程,保证核心逻辑不被破坏。
*************************************************************************/
/*************************************************************************
* 五、常见适用场景(哪里能用到?)
* 模板方法模式在实际开发中非常常见,比如:
* 1. 框架生命周期:Spring 的 Bean 生命周期(固定:初始化→使用→销毁,初始化细节由你写);
* 2. 报表生成:固定流程(表头→填充数据→生成文件),填充数据的逻辑由不同报表子类实现;
* 3. 支付流程:固定流程(验证参数→调用接口→处理结果),不同支付方式(微信 / 支付宝)只改「调用接口」步骤;
* 4. 游戏角色技能:固定流程(释放前摇→技能效果→释放后摇),不同技能只改「技能效果」。
*************************************************************************/
/*************************************************************************
* 六、总结
* 模板方法模式的本质就是:定框架、填细节。
* 把不变的「流程骨架」抽成模板,保证一致性;
* 把变化的「细节步骤」留给子类,保证灵活性。
* 就像你做手工模型:说明书(模板)固定了拼接顺序,你只需要按顺序把不同零件(子类细节)
* 拼上去就行,既不会拼错顺序,又能做出不同样式的模型。
*************************************************************************/
// 我们可以创建一个最基础的基类,那么这个基类要抽象出一系列的虚函数,这些也就是抽象步骤
//那么这些虚函数就是要在子类中去具体的重写实现,父类只负责声明
//那么这个时候就有问题了,为什么要在父类中去声明了,然后再去子类中去重载呢??
//不能直接子类吗???
//其实这是因为我们要把基类的部分接口去开放给外界用户使用,而这些函数就可能要调用多个子类重写的虚函数,
//比如TCP子类要重写实现listen、socket、accept、connect等函数
//那么我们在基类中可以就开放一个buildlistensocket的函数,而这个函数就是连续执行socket、listen函数
//这样子就不需要外界自己去调用子类实现的socket、listen函数,可以用这一个函数接口直接实现
//极大的便利了用户的使用,也使得我们将底层隐藏起来
//除此之外,我们把socket形成的套接字放在子类中去作为它的成员变量
//由此我们也可以去通过子类去访问到socket套接字,同时外界无法修改,大大提高安全性
//那么我们知道,模版方法类方式的本质其实是多态
//外界就创建一个基类指针可以去接收子类的指针
//然后再去调用基类指针中build等等函数,然后build函数中调用的那些重写虚函数就会自动更替为
//该基类指针所接受的子类的中的那些重写虚函数,由此实现多态,
//同时也可以通过该基类指针去调用子类和基类都重写的函数
//下面我们就按照上面的思路来实现一下模版方法类方式去封装TCPsocket
namespace SocketModule
{
class TcpSocket;
const static int DEFAULT_BACKLOG = 16; // 监听队列默认长度
const static int RECV_BUF_SIZE = 1024; // 接收缓冲区大小
const static int SOCKET_INVALID = -1; // 无效套接字标识
//最基础的基类
class Socket
{
public:
//虚析构函数
virtual ~Socket()
{}
//要声明一系列网络通信所用到的函数
//注意都得是虚函数,这样子才能重写,实现多态
//使用socket函数创建套接字的函数:
//不用返回套接字,套接字直接就在子类中成为子类的成员变量
virtual void CreateSocketOrNot()=0;
//使用bind函数将socket和程序绑定的函数
//那么就需要传入端口号,至于ip地址就不用了,因为客户端不用显式bind,在发消息的时候
//OS就会自动给客户端bind绑定上随机端口
//而服务端虽然要显式bind,但是ip地址也是设为INADDR_ANY去增大接收范围
//但是服务端需要绑定指定端口号,这样子客户端才能找的到服务端
virtual void BindSocketOrNot(const uint16_t port)=0;
//关闭自身套接字的函数
//那么我们知道,套接字是存放在子类中的成员变量的,而要是外界想关闭套接字的话
//是没办法直接访问到子类的套接字成员变量的
//所以我们就得提供一个接口用于外界调用去close套接字,避免内存泄露
virtual void CloseSocket()=0;
//使用listen函数去实现监听的函数,用于服务端
//那么由于listen函数需要传入参数去指定监听队列要有多少
//所以我们该函数也需要传入该参数,虽然我们也会设置缺省值!!!
virtual void ListenSocketOrNot(const int backlog=DEFAULT_BACKLOG)=0;
//使用accept函数去让服务端接收客户端发起的链接函数
//那么我们知道,accept函数是不需要什么外界传入的参数的
//它就是会返回accept所创建的新的socket套接字用于一对一的沟通交流
//但是我们又想把这一些都都封装起来,不想让外界可以直接修改
//所以,我们可以把accept函数封装为返回TcpSocket类对象指针
//毕竟TcpSocket类里面是可以存储socketfd套接字文件描述符的
//那么我们返回TcpSocket类对象指针,其实就是创建一个新的TcpSocket类对象指针
//然后我们将accept函数的返回值丢给返回的新的TcpSocket类对象指针
//如此一来就实现了解耦封装
virtual std::shared_ptr<TcpSocket> AcceptSocketOrNot()=0;
//使用connect函数去进行链接的封装函数
//那么我们知道,connect函数是需要外界传入要进行链接的对方主机的ip地址和端口号
//所以我们要设置参数哦
//返回值依旧是不需要
//同样的,connect函数是客户端使用的,
//所以客户端也就只能调用TcpSocket类中的创建socket、connect等函数
//是不能调用accept、listen函数的
//我们本文件只负责把这些函数都封装起来
//但是使用还是需要使用者自己规范
//比如客户端要做的就是创建socket,然后connect
//本质上就是调用该类的CreateSocketOrNot、ConnectSocketOrNot函数
virtual void ConnectSocketOrNot(const std::string& ip,const uint16_t port)=0;
//客户、服务端要用的向服务、客户端发送信息的函数的封装,即对send函数的封装
//那么肯定需要传入要发送的字符串吧,所以要设置字符串形参
//那么我们还得知道发送信息有木有成功,木有就得再发送
//总不能终止通信了吧,所以要把返回值设置为bool
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
//那么客户端在调用他所创建的TcpSocket类的CreateSocketOrNot、ConnectSocketOrNot函数之后,
//自然就是调用他所创建的TcpSocket类中的发送信息的函数
//因为客户端的socket套接字就存储在它所创建的TcpSocket类中!!!
//至于服务端要发送信息给客户端,就肯定是要用accept之后的和每个客户端一对一的socket套接字文件描述符
//所以服务端调用send函数的就是要用服务端调用了AcceptSocketOrNot锁返回的TcpSocket对象指针中的send函数!!!
//因为accept函数返回的和每个客户端一对一的socket套接字文件描述符就是存储在它返回的TcpSocket对象指针中
//依旧是那句话:我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
virtual bool Send(const std::string& send_message)=0;
//封装服务端、客户端接收信息的函数,其实也就是对recv函数的封装
//那么服务端我们要把至于守护进程,所以是用不到recv函数的
//而客户端是肯定要接收信息的,所以它就需要recv函数
//那么同样的,你要接收信息,那么你就得传入你要接收信息的字符串吧
//然后我把recv获取到字符串+到你所传入的字符串里
//这样子你就能获取到recv函数所获取的信息了
//那么我们还得知道接收信息有木有成功,木有就得再接收
//总不能终止通信了吧,所以要把返回值设置为int
//返回recv函数的返回值,由外界去判断是什么情况,我们这里没必要做的太具体!!!
//可不要再问我客户端的socket套接字在哪里了,去看看我对Send函数的解析吧
virtual int Recv(std::string& recv_message)=0;
//提供给外界可以一次性调用的接口
//服务端一键创建listen套接字的函数
//那么就是先创建套接字,然后bind,然后listen
void BulidListenSocket(const uint16_t port,const int backlog=DEFAULT_BACKLOG)
{
CreateSocketOrNot();
BindSocketOrNot(port);
ListenSocketOrNot(backlog);
}
//客户端一键connect的函数
//那么就是先创建socket套接字,然后connect
void BulidConnectSocket(const std::string& ip,const uint16_t port)
{
CreateSocketOrNot();
ConnectSocketOrNot(ip,port);
}
//使用这些函数的前提都是服务/客户端创建基类对象/指针
//然后将TcpSocket类对象/指针赋值给基类,然后再去调用这些函数,从而实现多态
//也能直接调用TcpSocket里面的成员函数
//因为在基类中都虚函数了,所以不用担心切片
};
//在这里统一说明一下,我们下面之所以在调用的函数前面加::,
//是为了让编译器知道,我调用的函数是系统的函数,而不是我们自己可能实现的同名函数
//避免编译器混淆报错!!!
//TcpSocket子类,要重写listen、connect等函数
class TcpSocket:public Socket
{
public:
//无参构造函数
TcpSocket()
:_socketfd(SOCKET_INVALID)
{}
//传入套接字的构造函数
//用于accept函数返回一个新的TcpSocket对象指针
//里面放accept函数的返回值,也就是新的一对一的socket套接字文件描述符
TcpSocket(int socketfd)
:_socketfd(socketfd)
{}
//析构函数
//将本类的socket套接字文件描述符close
~TcpSocket()
{
if(_socketfd>=0)
{
::close(_socketfd);
}
}
//创建套接字
//即调用socket函数
//那么其实这个函数所得出来的socket套接字在服务端中也是拿来做listen套接字
virtual void CreateSocketOrNot() override
{
int socketfd=::socket(AF_INET,SOCK_STREAM,0);
if(socketfd<0)
{
std::cerr<<"socket failed!!!"<<strerror(errno)<<std::endl;
exit(SOCKET_ERR);//在Common头文件中
}
//端口复用(服务端必须)
int opt = 1;
if (::setsockopt(socketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0)
{
std::cerr << "setsockopt SO_REUSEADDR failed: " << strerror(errno) << std::endl;
}
//创建socket套接字成功,赋值给成员变量
_socketfd=socketfd;
//至此创建套接字成功,但是要注意是在当前这个类变量中,有着socket套接字
}
//绑定bind函数,也就服务端需要
virtual void BindSocketOrNot(const uint16_t port) override
{
//老样子,socketaddr_in结构体
struct sockaddr_in server;
::memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=::htons(port);
//不用担心我们把形参设置为const是不是不可以这样子,htons只是读取它的值,不会进行修改哦
server.sin_addr.s_addr=::htonl(INADDR_ANY);
socklen_t len=sizeof(server);
int ret_bind=::bind(_socketfd,(struct sockaddr*)(&server),len);
if(ret_bind<0)
{
std::cerr<<"bind failed!!!"<<strerror(errno)<<std::endl;
exit(BIND_ERR);//在Common头文件中
}
}
//关闭自身套接字的函数
//那么我们知道,套接字是存放在子类中的成员变量的,而要是外界想关闭套接字的话
//是没办法直接访问到子类的套接字成员变量的
//所以我们就得提供一个接口用于外界调用去close套接字,避免内存泄露
virtual void CloseSocket() override
{
if(_socketfd>=0)
{
::close(_socketfd);
_socketfd = SOCKET_INVALID; //标记为无效,避免重复关闭
}
//就这么简单
}
//使用listen函数去实现监听的函数,用于服务端
//那么由于listen函数需要传入参数去指定监听队列要有多少
//所以我们该函数也需要传入该参数,虽然我们也会设置缺省值!!!
virtual void ListenSocketOrNot(const int backlog=DEFAULT_BACKLOG) override
{
//传入本类变量的socket套接字成员变量即可
//虽然我们本类也要实现accept函数的封装
//但是listen函数和accept函数的调用是分开的,且accept函数是返回新的TcpSocket类对象指针
//我们是不可能用accept函数返回的TcpSocket对象指针去执行listen函数的
//所以调用了listen函数的TcpSocket的本质其实是listensocketfd哦!!!
int ret_listen=::listen(_socketfd,backlog);
if(ret_listen<0)
{
std::cerr<<"listen failed!!!"<<strerror(errno)<<std::endl;
exit(LISTEN_ERR);//在Common头文件中
}
}
//使用accept函数去让服务端接收客户端发起的链接函数
//那么我们知道,accept函数是不需要什么外界传入的参数的
//它就是会返回accept所创建的新的socket套接字用于一对一的沟通交流
//但是我们又想把这一些都都封装起来,不想让外界可以直接修改
//所以,我们可以把accept函数封装为返回TcpSocket类对象指针
//毕竟TcpSocket类里面是可以存储socketfd套接字文件描述符的
//那么我们返回TcpSocket类对象指针,其实就是创建一个新的TcpSocket类对象指针
//然后我们将accept函数的返回值丢给返回的新的TcpSocket类对象指针
//如此一来就实现了解耦封装
virtual std::shared_ptr<TcpSocket> AcceptSocketOrNot() override
{
//老样子,创建socketaddr_in结构体
//用于存储和服务端链接的客户端信息
struct sockaddr_in client;
::memset(&client,0,sizeof(client));
socklen_t len=sizeof(client);
int accept_socketfd=::accept(_socketfd,(struct sockaddr*)(&client),&len);
if(accept_socketfd<0)
{
std::cerr<<"accept failed!!!"<<strerror(errno)<<std::endl;
exit(ACCEPT_ERR);//在Common头文件中
}
//接下来就返回存有accept函数返回值,也就是新的套接字文件描述符的TcpSocket指针
//所以我们要把新的套接字文件描述符传入新的TcpSocket指针中
//这也是为什么我们上面要实现单参数的TcpSocket构造函数的原因
std::shared_ptr<TcpSocket> ret(std::make_shared<TcpSocket>(accept_socketfd));
return ret;
}
//使用connect函数去进行链接的封装函数
//那么我们知道,connect函数是需要外界传入要进行链接的对方主机的ip地址和端口号
//所以我们要设置参数哦
//返回值依旧是不需要
//同样的,connect函数是客户端使用的,
//所以客户端也就只能调用TcpSocket类中的创建socket、connect等函数
//是不能调用accept、listen函数的
//我们本文件只负责把这些函数都封装起来
//但是使用还是需要使用者自己规范
//比如客户端要做的就是创建socket,然后connect
//本质上就是调用该类的CreateSocketOrNot、ConnectSocketOrNot函数
virtual void ConnectSocketOrNot(const std::string& ip,const uint16_t port) override
{
// 前置检查:套接字必须有效
if (_socketfd < 0)
{
std::cerr << "connect failed: socket not created" << std::endl;
exit(CONNECT_ERR);
}
//老样子,创建socketaddr_in结构体
//用于存储客户端要链接的服务端的信息,然后才能connect
struct sockaddr_in server;
::memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=::htons(port);
//使用线程安全函数去将ip地址转换为网络字节序
int ret_inet_pton=::inet_pton(AF_INET,ip.c_str(),&(server.sin_addr));
if (ret_inet_pton == 0)
{
std::cerr << "inet_pton failed: invalid IP address (" << ip << ")" << std::endl;
exit(CONNECT_ERR);
}
else if (ret_inet_pton == -1)
{
std::cerr << "inet_pton failed: " << strerror(errno) << std::endl;
exit(CONNECT_ERR);
}
socklen_t len=sizeof(server);
int ret_connect=::connect(_socketfd,(struct sockaddr*)(&server),len);
if(ret_connect<0)
{
std::cerr<<"connect failed!!!"<<strerror(errno)<<std::endl;
exit(CONNECT_ERR);//在Common头文件中
}
}
//客户、服务端要用的向服务、客户端发送信息的函数的封装,即对send函数的封装
//那么肯定需要传入要发送的字符串吧,所以要设置字符串形参
//那么我们还得知道发送信息有木有成功,木有就得再发送
//总不能终止通信了吧,所以要把返回值设置为bool
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
//那么客户端在调用他所创建的TcpSocket类的CreateSocketOrNot、ConnectSocketOrNot函数之后,
//自然就是调用他所创建的TcpSocket类中的发送信息的函数
//因为客户端的socket套接字就存储在它所创建的TcpSocket类中!!!
//至于服务端要发送信息给客户端,就肯定是要用accept之后的和每个客户端一对一的socket套接字文件描述符
//所以服务端调用send函数的就是要用服务端调用了AcceptSocketOrNot所返回的TcpSocket对象指针中的send函数!!!
//因为accept函数返回的和每个客户端一对一的socket套接字文件描述符就是存储在它返回的TcpSocket对象指针中
//依旧是那句话:我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
virtual bool Send(const std::string& send_message) override
{
// 空字符串直接返回成功,避免无意义循环
if (send_message.empty())
{
return true;
}
int sentlen=0;//定义send函数成功发送的字节数
//要是小于我们要它发送的字符串字节数的话,就一直while循环发送
size_t send_message_len=send_message.size();//总待发送长度
const char* data = send_message.c_str();//要发送的信息的字符串形式
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
while(sentlen<send_message_len)
{
//发送 "未发送的剩余部分"(data+sentlen,长度send_message_len-sentlen)
//因为要是只发送了一部分,那么我们下次发送肯定就得从上次发送结束的地方再去往后发送
//不可能每次都发送全部内容吧!!!
//那么我们怎么知道上次发送结束的地方呢???不就是用sentlen记录着吗
//我们用data加上sentlen,不就是定位到了上次发送结束
//(指针可以++哦,一个字符的大小是一个字节哦)
//那么我们发送的字符长度就应该是send_message_len-sentlen才对
//这一点要注意一下
int ret_send=::send(_socketfd,data+sentlen,send_message_len-sentlen,0);
if(ret_send<0)
{
std::cerr<<"send failed!!!"<<strerror(errno)<<std::endl;
return false;//发送信息失败,自然返回fasle
}
//然后我们要给send函数成功发送的字节数的sentlen加上send函数的返回值
//因为要是一次没有发送完,那么我们得加上上次发送成功的字节数!!!
sentlen+=ret_send;
}
return true;//发送信息成功,自然返回true
}
//封装服务端、客户端接收信息的函数,其实也就是对recv函数的封装
//那么服务端我们要把至于守护进程,所以是用不到recv函数的
//而客户端是肯定要接收信息的,所以它就需要recv函数
//那么同样的,你要接收信息,那么你就得传入你要接收信息的字符串吧
//然后我把recv获取到字符串+到你所传入的字符串里
//这样子你就能获取到recv函数所获取的信息了
//那么我们还得知道接收信息有木有成功,木有就得再接收
//总不能终止通信了吧,所以要把返回值设置为int
//返回recv函数的返回值,由外界去判断是什么情况,我们这里没必要做的太具体!!!
//可不要再问我客户端的socket套接字在哪里了,去看看我对Send函数的解析吧
virtual int Recv(std::string& recv_message) override
{
char buf[RECV_BUF_SIZE]={0};
ssize_t ret_recv=::recv(_socketfd,buf,sizeof(buf)-1,0);
// if(ret_recv<0)
// {
// std::cerr<<"recv failed!!!"<<strerror(errno)<<std::endl;
// return false;//接收信息失败,自然返回fasle
// }
// else if(ret_recv==0)//发送信息的对方关闭了
// {
// std::cerr<<"a stream socket peer has performed an orderly shutdown(服务端关闭)"<<std::endl;
// return false;
// }
// else
// {
// buf[ret_recv]='\0';//在字符串末尾添加字符串终止符,符合C语言字符串
// }
//接收信息成功
//recv_message=recv_message + static_cast<std::string>(buf);
//使用string里的append函数去将收到的字符串追加进去
if(ret_recv>0)//成功了才去追加
{
recv_message.append(buf,ret_recv);
}
//为什么要累加???
//因为TCP是面向字节流,因为我们怕收到的数据少于一个完整报头!!!
//所以,要是少了的话,我们就得去再recv序列化字符串!!!
//可是之前recv的呢???
//加上啊!!!!!!!!!!!!!!!!!!!!!
//返回recv函数的返回值,由外界去判断是什么情况
return ret_recv;
}
private:
int _socketfd;
//套接字,这个套接字即可以是socket的套接字,
//也可以是accept的套接字,更可以是listen、send、recv的套接字
//就是看哪个函数调用,然后哪个函数将该值赋值
//因为不同的使用端,就是创建不同的TcpSocket类对象
//所以对象里面存储的_socketfd肯定也是不一样的
//比如客户端创建的TcpSocket类对象里面就是存储要用来send、recv、connect的
//而服务端创建的TcpSocket类对象里面就是存储要用来listen、accept的
//而accpet返回的新的TcpSocket类对象里面就是存储服务端要用来recv和send的
//我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
//由此才能实现完美的解耦封装!!!
};
}
//至于UdpSocke的封装,由于我们其实很少使用UDP,所以在这里就不实现了,并且它会比TcpSocket封装要简单!!!
结语:模板方法模式与 Socket 封装的深度总结
亲爱的读者朋友们,当我们以模板方法模式为蓝图,亲手构建起这个基于 C++ 的 TCP Socket 封装体系时,我们不仅完成了一次对网络编程技术的深入探索,更完成了一次对设计模式魅力的深刻体悟。
回望这一路,我们从对模板方法模式的理论认知,到将其精准应用于 Socket 的封装实践,每一步都凝聚着对设计原则的尊重与对工程实践的思考。我们将 TCP Socket 编程中那些看似独立的系统调用 ------socket、bind、listen、accept、send、recv等,通过抽象基类与具体子类的分层设计,构建出一个既统一又灵活的架构。
模板方法模式的核心本质
模板方法模式的本质是 "流程固化 + 细节定制"。在我们的 Socket 封装中,抽象基类Socket扮演了模板方法的角色,它定义了算法的整体流程:服务端的CreateSocketOrNot→BindSocketOrNot→ListenSocketOrNot,以及客户端的CreateSocketOrNot→ConnectSocketOrNot。这些固定的步骤如同制作饮品时的 "烧开水" 和 "倒入杯子",确保了所有 Socket 操作都遵循统一的规范。
而子类TcpSocket则负责实现那些可变的细节:服务端的AcceptSocketOrNot和ListenSocketOrNot,以及客户端的ConnectSocketOrNot。这种设计让我们能够在不改变整体流程的前提下,灵活地为不同场景提供定制化的实现。就像我们可以制作咖啡和奶茶,它们都遵循相同的基本流程,但原料和调料各不相同。
Socket 封装的核心价值
基于模板方法模式的 Socket 封装,为我们解决了传统网络编程中的三大核心痛点:
流程标准化 :通过BuildListenSocket和BuildConnectSocket两个模板方法,我们将服务端和客户端的核心流程固化下来。服务端只需调用BuildListenSocket传入端口号,就能一键完成套接字的创建、绑定和监听;客户端只需调用BuildConnectSocket传入服务端 IP 和端口号,就能建立连接。这种设计避免了因步骤顺序错误导致的编程错误,大大降低了开发难度。
代码复用 :TCP Socket 的创建、关闭、数据发送和接收等核心操作,在TcpSocket子类中只实现一次,所有使用者都可以通过基类接口调用,避免了传统编程中重复编写这些代码的问题。例如,服务端和客户端都需要发送数据,只需调用Send方法即可,无需各自实现发送逻辑。
封装底层细节:所有系统调用都被封装在类的内部,使用者无需了解底层的系统函数参数、字节序转换、错误处理等细节,只需关注业务逻辑。这种封装让我们能够:
- 隐藏套接字文件描述符的管理细节,通过私有成员变量
_socketfd实现数据安全 - 自动处理字节序转换(
htons、htonl、inet_pton) - 统一处理错误输出和退出机制
- 提供智能指针
std::shared_ptr<TcpSocket>实现资源的自动管理
设计模式的落地思考
在我们的 Socket 封装实践中,模板方法模式的应用给我们带来了关于设计模式落地的重要启示:
设计模式不是 "炫技",而是解决实际问题的工具。本文中模板方法模式的应用,核心是解决 Socket 编程的流程混乱和代码冗余问题,而非单纯为了使用设计模式而使用。当我们面临一个具体问题时,应该先分析问题的本质,再选择合适的设计模式,而不是为了使用模式而创造问题。
面向抽象编程,而非面向具体实现 。通过基类定义接口,子类实现细节,我们提升了代码的灵活性和可维护性。在我们的封装体系中,使用者通过Socket基类指针与具体的TcpSocket子类交互,这种多态特性让我们能够:
- 统一管理不同类型的套接字
- 轻松扩展 UDP 套接字的封装
- 保持代码的一致性和可预测性
封装细节,暴露接口 。好的封装应该隐藏复杂的底层细节,对外提供简单、清晰、易用的接口。在我们的设计中,Socket基类暴露了Send、Recv、CloseSocket等简洁的接口,而将所有底层实现细节都封装在内部。这种设计让使用者能够专注于业务逻辑,而不是底层的网络编程细节。
希望大家持续努力,一步一步的向前进!!!诸君共勉!!!