预备知识
std::bind
c
template <class Fn, class... Args>
/* unspecified */ bind (Fn&& fn, Args&&... args);
解释: std::bind(&TcpServer::Accepter, this, std::placeholders::_1)
这段代码使用了 C++11 中的
std::bind
函数,它用于创建函数对象(也称为函数包装器或绑定器),以便稍后可以将其用作回调函数或传递给其他函数。让我逐步解释这段代码:
std::bind
:这是std::bind
函数的调用,表示创建一个函数对象。
&TcpServer::Accepter
:这是一个成员函数指针,指向TcpServer
类中的名为Accepter
的成员函数。&
操作符用于获取函数的地址。
this
:这是一个指向当前对象的指针,通常在类的成员函数中使用,表示当前对象的实例。
std::placeholders::_1
:std::placeholders::_1
是一个占位符,用于表示稍后将传递的参数。在这种情况下,它表示将在调用函数对象时传递的第一个参数。std::placeholders::_1
可以与绑定函数的参数位置相对应,以将参数正确地传递给目标函数。所以,这行代码的作用是创建一个函数对象,它将在调用时执行
TcpServer
类中的Accepter
成员函数,传递当前对象指针this
作为第一个参数,并等待稍后传递的其他参数(如果有的话)。通常,这种技术用于创建回调函数,以便将成员函数用作事件处理程序或异步操作的回调。这允许您在使用 C++ 中的回调机制时更加灵活。
演示代码
c
// bind example
#include <iostream> // std::cout
#include <functional> // std::bind
// a function: (also works with function object: std::divides<double> my_divide;)
double my_divide (double x, double y) {return x/y;}
struct MyPair {
double a,b;
double multiply()
{
return a*b;
}
};
int main () {
using namespace std::placeholders; // adds visibility of _1, _2, _3,...
// binding functions:
auto fn_five = std::bind (my_divide,10,2); // returns 10/2
std::cout << fn_five() << '\n'; // 5
auto fn_half = std::bind (my_divide,_1,2); // returns x/2
std::cout << fn_half(10) << '\n'; // 5
auto fn_invert = std::bind (my_divide,_2,_1); // returns y/x
std::cout << fn_invert(10,2) << '\n'; // 0.2
auto fn_rounding = std::bind<int> (my_divide,_1,_2); // returns int(x/y)
std::cout << fn_rounding(10,3) << '\n'; // 3
// 初始化了它的两个成员变量a和b. a被设置为10,而b被设置为2。
MyPair ten_two {10,2};
// binding members:
// 绑定了MyPair中multiply成员函数(绑定类内成员)
// bound_member_fn(ten_two),需要将实例作为参数传递过去
// 如果不在调用时将实例传参,那么就在绑定时进行传参,如std::bind (&MyPair::a,ten_two)
auto bound_member_fn = std::bind (&MyPair::multiply,_1); // returns x.multiply()
std::cout << bound_member_fn(ten_two) << '\n'; // 20
// 绑定了ten_two实例中的a值
auto bound_member_data = std::bind (&MyPair::a,ten_two); // returns ten_two.a
std::cout << bound_member_data() << '\n'; // 10
return 0;
}
函数名被视为函数指针或地址
-
在大多数编程语言中,函数名本身可以被视为函数的地址或指针。这意味着您可以使用函数名来引用或传递函数,就像您可以使用指向函数的指针一样。这是因为函数名通常表示函数在内存中的起始地址。
-
在 C 和 C++ 中,函数名可以被视为指向函数的指针。您可以将函数名赋值给函数指针,然后通过函数指针来调用函数。例如:
c
#include <stdio.h>
void myFunction() {
printf("Hello, World!\n");
}
int main() {
void (*functionPointer)() = myFunction; // 函数指针指向 myFunction
functionPointer(); // 调用函数
return 0;
}
-
在上面的示例中,
myFunction
是函数的名称,但它可以被赋值给函数指针functionPointer
,然后通过functionPointer()
调用函数。 -
这种函数名作为函数地址的特性在许多编程语言中也适用,但在不同语言和编译器中的具体实现方式可能有所不同。值得注意的是,在某些语言和运行时环境中,这种行为可能不成立,但在 C 和 C++ 中,它通常是有效的。
EPOLLERR都会触发那些错误
在 Linux 中,EPOLLERR
用于表示发生异常事件的情况,这包括一系列错误和异常情况。以下是可能触发 EPOLLERR
的情况示例以及操作系统可能采取的处理方式:
-
套接字错误 :如果与套接字通信的过程中发生错误,如连接被重置、连接超时、对方关闭连接等,会触发
EPOLLERR
。操作系统通常会在EPOLLERR
事件上通知应用程序,应用程序可以在事件处理程序中关闭套接字、记录错误信息或采取其他适当的处理。 -
文件描述符关闭 :如果尝试在已关闭的文件描述符上进行 I/O 操作,将触发
EPOLLERR
。操作系统将通知应用程序,应用程序可以在事件处理程序中关闭文件描述符并释放资源。 -
管道错误 :在管道通信中,如果尝试读取或写入管道时发生错误,例如管道满或空,将触发
EPOLLERR
。操作系统通常会通知应用程序,应用程序可以在事件处理程序中处理管道错误,释放资源或重试操作。 -
信号中断 :某些操作可能被信号中断,例如
SIGPIPE
信号。当这些信号中断 I/O 操作时,会触发EPOLLERR
。操作系统会通知应用程序,应用程序可以处理信号、记录错误信息或执行相关操作。 -
套接字错误标志 :有时,可以使用
SO_ERROR
选项来获取套接字上的错误标志。如果套接字上发生错误,这将触发EPOLLERR
事件。操作系统会通知应用程序,并应用程序可以检查错误标志以获取详细信息。
操作系统不会自动解决这些异常情况,而是将它们通知给应用程序,以便应用程序可以采取适当的措施,如关闭套接字、释放资源、记录错误信息或重新尝试操作。这允许应用程序有更多的控制权,以适应不同的应用需求。通常,应用程序会在 EPOLLERR
事件的处理程序中根据具体情况采取必要的措施,以确保应用程序的稳定性。
EPOLLERR和EPOLLHUP的区别
EPOLLERR
和 EPOLLHUP
是两种不同的 epoll 事件标志,它们有以下区别:
-
EPOLLERR:
- 表示错误事件:
EPOLLERR
表示发生了错误事件,通常与文件描述符上的错误相关。 - 触发条件:
EPOLLERR
在以下情况下触发,如套接字连接失败、连接重置、连接超时、对方关闭连接等错误情况。 - 处理方式:通常,当
EPOLLERR
触发时,应用程序需要采取适当的错误处理措施,例如关闭套接字、记录错误信息或执行错误处理操作。
- 表示错误事件:
-
EPOLLHUP:
- 表示挂起事件:
EPOLLHUP
表示发生了挂起事件,通常与连接的一方关闭相关。 - 触发条件:
EPOLLHUP
在以下情况下触发,如连接的一方关闭连接,即连接的对方已关闭。 - 处理方式:通常,当
EPOLLHUP
触发时,应用程序需要采取适当的处理措施,如关闭套接字或执行资源清理。
- 表示挂起事件:
总结来说,EPOLLERR
主要用于检测文件描述符上的错误情况,而 EPOLLHUP
主要用于检测连接的一方已关闭。这两个事件标志可以帮助应用程序在 epoll 模型中处理异常情况和连接关闭。
三目运算符
三目运算符(也称为条件运算符)是一种在 C 语言中用于创建条件表达式的特殊语法结构。它的一般形式如下:
c
condition ? expression_if_true : expression_if_false
condition
是一个布尔表达式,如果为真(非零),则执行expression_if_true
;否则,执行expression_if_false
。
以下是一个简单的示例,演示如何使用三目运算符:
c
#include <stdio.h>
int main() {
int a = 5;
int b = 10;
// 使用三目运算符来比较 a 和 b 的大小
int max = (a > b) ? a : b;
printf("The maximum value is: %d\n", max);
return 0;
}
在上述示例中,我们比较了 a
和 b
的大小。如果 a
大于 b
,则 max
被赋值为 a
;否则,max
被赋值为 b
。最后,我们打印出了最大的值。
三目运算符是一种紧凑的方式来处理简单的条件情况,但在处理更复杂的条件逻辑时,可能更倾向于使用传统的 if
语句。
Reactor
Connection()
- socket套接字在通信的时候,每个sock在内核都会创建接收缓冲区和发送缓冲区,这样的缓冲区常常开辟在堆上,不会像临时变量char buffer[1024]随着栈帧空间的销毁而销毁,这能更好的存储网络中收到的数据 和 即将要发送到网络中的数据,如果用栈上的空间来存储网络收发的数据,则数据极有可能被销毁掉,因为只要变量所在栈帧销毁,则变量中的数据在下次变量重新开辟时,就会由原来存储的网络数据变为未初始化过的随机数据了。
- 所以为了让每个sock都有自己的收发缓冲区,不再使用原来编写服务器时,用一个char buffer[1024]来存储sock上的网络数据,而是改用一个Connection结构体来代表一个通信的sock,这个结构体内部包含通信的套接字描述符_sock,以及sock所对应的_inbuffer和_outbuffer。
- 除此之外,该结构体还包括了三个回调方法_recver,_sender,_excepter,分别表示sock对应的读方法,写方法,异常方法,func_t是一个包装器类型,包装内容为函数指针,返回值是void,参数是Connection指针类型。
- 该结构体还包括了一个额外的服务器类型的指针TcpServer *tsp_; 如果在Connection的回调方法中,想要调用一下TcpServer类中的方法时,这个回指指针会帮我们拿到TcpServer中的方法。
- Connection还实现了两个函数,一个是注册函数,一个是关闭sock的函数,注册函数用于将外部实现的sock对应的读方法,写方法,异常方法,注册到sock所在的结构体Connection中。
-
- 版权声明:本文为CSDN博主「rygttm」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
- 原文链接:https://blog.csdn.net/erridjsis/article/details/132548615
c
// 把从每个套接字读到的数据都放入到这个套接字对应的缓冲区当中
// 这样就可以将每次从套接字中保存的数据保存到缓冲区中(缓冲区是在堆上开辟空间)
// 避免了临时变量char buffer[1024]随着栈帧空间的销毁而销毁的问题
// 等检测到缓冲区(缓冲区是在堆上开辟空间)上有完整报文,再交付一个完整报文给应用层
class Connection
{
public:
Connection(int sock, TcpServer *tsp) : sock_(sock), tsp_(tsp)
{
}
void Register(func_t r, func_t s, func_t e)
{
recver_ = r;
sender_ = s;
excepter_ = e;
}
~Connection()
{
}
void Close()
{
close(sock_);
}
public:
int sock_; // 套接字文件描述符
std::string inbuffer_; // 输入缓冲区
std::string outbuffer_; // 输出缓冲区
// 对应这个套接字的读方法,写方法,异常方法
func_t recver_; // 从sock_读
func_t sender_; // 向sock_写
func_t excepter_; // 处理sock_ IO的时候上面的异常事件
TcpServer *tsp_;
// 代表对应sock最近被访问的时间(有兴趣可以实现一下)
// uint64_t lasttime;
};
InitServer()
- 给AddConnection传参时,用到了一个C++11的知识,就是bind绑定的使用,一般情况下,如果将包装器包装的函数指针类型传参给包装器类型时,是没有任何问题的,因为包装器本质就是一个仿函数,内部调用了被包装的对象的方法,所以传参是没有任何问题的。
- 但如果要是在类内传参,那就有问题了,会出现类型不匹配的问题。为什么是类型不匹配呢?因为在类内调用类内方法时,其实是通过this指针来调用的,如果你直接将Accepter方法传给AddConnection,两者类型是不匹配的。因为Accepter的第一个参数是this指针,正确的做法是利用包装器的适配器bind来进行传参,bind将Accepter进行绑定,前两个参数为绑定的对象类型 和 给绑定的对象所传的参数。因为Accepter第一个参数是this指针,所以第一个参数就可以固定传this,后面的一个参数不应该是现在传,而应该是调用Accepter方法的时候再传,只有这样才能在类内将类成员函数指针传给包装器类型。
c
void AddConnection(int sock, uint32_t events, func_t recver, func_t sender, func_t excepter)
{
// 1. 首先要为该sock创建Connection,并初始化,并添加到connections_
// 如果这个套接字是ET模式(也就是events & EPOLLET为真),那么我们需要将套接字文件描述符设置为非阻塞
if (events & EPOLLET)
Util::SetNonBlock(sock);
// 创建套接字文件描述符对应的Connection对象
Connection *conn = new Connection(sock, this);
// 2. 给对应的sock设置对应回调处理方法
conn->Register(recver, sender, excepter);
// 3. 其次将sock与它要关心的事件"写透式"注册到epoll中,让epoll帮我们关心
// 也就是将sock与它要关心的事添加到epoll模型中
bool r = epoller_.AddEvent(sock, events);
assert(r);
(void)r;
// 后续连接的每个文件描述符都有一个对应的Connection对象
// 使用connections_来管理这些Connection对象
// 4. 将kv添加到connections_
// 将sock作为key值,Connection对象作为value值
// 将其添加到std::unordered_map<int, Connection *> connections_中
// 将这个Connection管理起来,我们可以通过sock快速查找到对应的Connection对象
connections_.insert(std::pair<int, Connection *>(sock, conn));
logMessage(DEBUG, "add new sock : %d in epoll and unordered_map", sock);
}
void InitServer()
{
// 1. 创建socket
sock_.Socket();
// 1.1 将服务器的ip地址和port端口号都bind绑定
sock_.Bind(port_);
// 1.2 设置服务器为监听状态
sock_.Listen();
// 2. 构建Epoll模型
epoller_.Create();
// 3. a.将目前唯一的一个sock(也就是监听套接字),添加到epoller中,
// b.添加之前需要先将对应的fd设置成为非阻塞
// c.listensock_也是一个socket啊,也要看做成为一个Connection
// d.std::bind(&TcpServer::Accepter, this, std::placeholders::_1)
// 就是将成员函数Accepter与Connection进行绑定
// listensock对应的读方法就是Accepter,写方法和异常方法设置为nullptr
AddConnection(sock_.Fd(), EPOLLIN | EPOLLET,
std::bind(&TcpServer::Accepter, this, std::placeholders::_1), nullptr, nullptr);
// revs_这是一个指向 `struct epoll_event` 结构的指针,用于存储已触发事件的信息。
// epoll_event结构包含有关已触发事件的详细信息,包括文件描述符和事件类型。
// revs_指向struct epoll_event[num]数组,
// 因此可以传参给struct epoll_event revs[]
revs_ = new struct epoll_event[num];
num_ = num;
}
事件派发器
c
// 判断sock对应的Connection对象是否在unordermap中
// 因为如果发生异常,sock对应的Connection对象会被从unordermap中移除
// 所以为了安全起见,再进行读写之前,都要检查这个文件描述符是否还在unordermap中
bool IsConnectionExists(int sock)
{
auto iter = connections_.find(sock);
return iter != connections_.end();
}
void Loop(int timeout)
{
// 使用epoller_.Wait获取已经就绪的事件
int n = epoller_.Wait(revs_, num_, timeout);
for (int i = 0; i < n; i++)
{
// 从就绪数组中拿到对应的文件描述符
int sock = revs_[i].data.fd;
// 拿到对应的文件描述符对应的就绪事件
uint32_t events = revs_[i].events;
/*
if ((events & EPOLLIN) && sock == sock_.Fd())
{
// 如果sock_.fd()的返回值是监听套接字
// 因此如果满足这个条件就说明监听套接字的读事件已经就绪
// 那么调用对应的读取方法读取套接字中的数据
connections_[sock]->recver_(connections_[sock]);
// sock对应的Connection对象中就有读取方法,我们需要调用这个方法来进行读取
// 通过connections_[sock]就可以拿到unordermap中sock对应的Connection对象
// 再用过Connection对象调用recver_成员函数
// 而recver_成员函数,就是void Accepter(Connection *conn)传参过去的
// 因此recver_的参数为connections_[sock]
}
if ((events & EPOLLIN) && sock != sock_.Fd())
{
// 如果sock_.fd()的返回值是普通套接字
// 因此如果满足这个条件就说明普通套接字的读事件已经就绪
// 那么调用对应的读取方法读取套接字中的数据
connections_[sock]->recver_(connections_[sock]);
// 对应普通的成员函数,对应的读取方法是Recver函数
// 而recver_成员函数,就是Recver函数传参过去的
}
综上,只要是套接字就绪,不论是监听套接字还是普通套接字,我们都调用
connections_[sock]->recver_(connections_[sock]);读取套接字中的数据
*/
// 将所有的异常问题,全部转化 成为读写问题(因为读写过程中,本身就可能会有异常事件)
// 在recver_或者sender_函数中,检测到异常问题,则会调用对应处理异常的函数Excepter
// EPOLLERR表示对应的文件描述符发生错误
// EPOLLHUP表示对应的文件描述符被挂起
if (events & EPOLLERR)
events |= (EPOLLIN | EPOLLOUT);
if (events & EPOLLHUP)
events |= (EPOLLIN | EPOLLOUT);
// 如果要对套接字进行读取,要保证三点
// 1.读事件就绪,也就是events & EPOLLIN为真
// 2.套接字sock在unordermap中被管理,IsConnectionExists(sock)为真
// 3.确保程序员将这个套接字所有事件对应的回调方法传递到了sock对应的Connection对象中
// 如果connections_[sock]->recver_为真,那么recver_存在,recver_就是这个函数的地址
if ((events & EPOLLIN) && IsConnectionExists(sock) && connections_[sock]->recver_)
connections_[sock]->recver_(connections_[sock]);
if ((events & EPOLLOUT) && IsConnectionExists(sock) && connections_[sock]->sender_)
connections_[sock]->sender_(connections_[sock]);
}
}
// 事件派发器
void Dispatcher()
{
int timeout = 1000;
while (true)
{
Loop(timeout);
// logMessage(DEBUG, "time out ...");
}
}
回调函数
Accepter()
c
// Accepter就是套接字文件描述符对应的读方法
void Accepter(Connection *conn)
{
for (;;)
{
std::string clientip;
uint16_t clientport;
// err是一个输出型参数
// 获取新连接,如果发生错误将错误码输出到err
// 系统调用接口accept()
// 1.如果获取新连接成功,则返回值大于或等于0
// 2.出现错误,返回值为-1,(错误信息可能为EAGAIN 或 EWOULDBLOCK等)
// 3.被信号中断,它可能会返回 -1,并设置 errno 为 EINTR
int err = 0;
int sock = sock_.Accept(&clientip, &clientport, &err);
if (sock > 0)
{
// 代码运行到这里说明获取新连接成功,得到对应的套接字文件描述符
// 因此,使用 AddConnection()函数为套接字文件描述符
// 1.首先要为该sock创建Connection;
// 如果这个套接字是ET模式,那么我们需要将套接字文件描述符设置为非阻塞
// 2.给对应的sock设置对应回调处理方法;因此通过bind传递了三个方法
// 3.也就是将sock与它要关心的事添加到epoll模型中
// 4.将sock作为key值,Connection对象作为value值,将其添加到unordermap中
AddConnection(
sock, EPOLLIN | EPOLLET,
std::bind(&TcpServer::Recver, this, std::placeholders::_1),
std::bind(&TcpServer::Sender, this, std::placeholders::_1),
std::bind(&TcpServer::Excepter, this, std::placeholders::_1));
logMessage(DEBUG, "get a new link, info: [%s:%d]", clientip.c_str(), clientport);
}
else
{
// 如果代码运行到这里,那么说明出现了错误
// 出现错误,返回值为-1,(错误信息可能为EAGAIN 或 EWOULDBLOCK等)
// 1.如果错误码为err == EAGAIN || err == EWOULDBLOCK,
// 说明只是将文件描述符中的数据读取完了,非阻塞返回的错误码,只需要跳出循环即可
// 2.如果错误码为err == EINTR,说明读取时,被信号中断了,那么继续循环读取即可
if (err == EAGAIN || err == EWOULDBLOCK)
break;
else if (err == EINTR)
continue;
else
// 如果代码运行到了这里说明是真的发生了其他错误
// 错误信息已经在sock_.Accept(&clientip, &clientport, &err)中被打印了
break;
}
}
}
Recver()
c
void Recver(Connection *conn)
{
char buffer[1024];
while (true)
{
// conn->sock_读取对应套接字中的数据
// 0:通常表示阻塞接收,直到有数据可用
// 此时conn->sock_的读事件一定是就绪的
ssize_t s = recv(conn->sock_, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
// 返回值大于等于 0:表示成功接收的字节数,即接收操作成功完成。
buffer[s] = 0;
// 将读到的数据入队列,conn->inbuffer_是在堆上面开辟的空间
// 不会随栈空间的销毁,数据被销毁
conn->inbuffer_ += buffer;
// 在 C++ 中,std::string 类型是一个非平凡复制类型,
// 它包含动态分配的内存来存储字符串数据,因此无法直接通过可变参数列表传递。
// 可变参数列表通常用于传递基本数据类型(如整数、字符、指针等)和 POD(Plain Old Data)类型。
// 因此必须使用c_str()将其转化为c字符串
logMessage(DEBUG, "\n%s", conn->inbuffer_.c_str());
// 读取到数据之后,直到service_判断出conn->inbuffer_中至少有一个完整的报文
// 开始对完整的报文数据表进行处理
service_(conn); // 在main.cc中实现
}
else if (s == 0)
{
// 0:表示连接已关闭
if (conn->excepter_)
{
conn->excepter_(conn);
// 处理完异常,必须要返回
// 否则,处理完异常后,会接着循环,那么就会再一次处理异常
// 这样就会使同一块内存空间被释放两次,造成段错误
return;
}
}
else
{
// 读取发生错误
// errno == EAGAIN || errno == EWOULDBLOCK如果为真,
// 代表数据读取完毕,因为文件描述符是非阻塞的,所以超时返回了
// errno == EINTR 为真,代表读取被信号中断了,继续读取就可以
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
else if (errno == EINTR)
continue;
else
{
// 读取出现错误
// 调用conn->excepter_异常函数来处理
if (conn->excepter_)
{
conn->excepter_(conn);
return;
}
}
}
} // while
}
Sender()
c
// 将Connection *conn中对应的文件描述符,添加到epoll中,
// 让epoll帮我们关心它的读事件或者写事件是否就绪(根据我们的传参来决定)
void EnableReadWrite(Connection *conn, bool readable, bool writeable)
{
uint32_t event = (readable ? EPOLLIN : 0) | (writeable ? EPOLLOUT : 0) | EPOLLET;
epoller_.Control(conn->sock_, event, EPOLL_CTL_MOD);
}
void Sender(Connection *conn)
{
while (true)
{
// 第四个参数0:通常表示阻塞发送,直到数据被成功发送或发生错误。
// 对应的文件描述符conn->sock_是非阻塞的,可以循环执行send()函数,直到将发送缓冲区的数据发完
// 当发送缓冲区中没有数据向套接字发送时send()函数会自动返回的,
// 当非阻塞套接字上发送缓冲区已满,错误码EAGAIN会被返回
ssize_t s = send(conn->sock_, conn->outbuffer_.c_str(), conn->outbuffer_.size(), 0);
logMessage(DEBUG, "for fanhui value:%d", s);
if (s >= 0)
{
// 当返回值大于等于 0:表示成功发送的字节数,即发送操作成功完成。
// 如果conn->outbuffer_.empty()为真,说明发送缓冲区中的数据被发送完了
if (conn->outbuffer_.empty())
{
break;
}
else
conn->outbuffer_.erase(0, s);
}
else
{
// -1:表示发送操作失败。
// 在这种情况下,您可以使用 errno 来获取具体的错误信息,
// 例如连接重置、连接超时、套接字错误等。
// 常见的错误码包括 EAGAIN(非阻塞套接字上发送缓冲区已满)
// 和 EINTR(操作被信号中断)等
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
else if (errno == EINTR)
continue;
else
{
// 发送出现了真正的错误,调用处理异常的函数来进行处理
if (conn->excepter_)
{
conn->excepter_(conn);
return;
}
}
}
}
// 如果没有发送完毕,需要对对应的sock开启对写事件的关心
// 如果发完了,我们要关闭对写事件的关心
// conn->tsp_就是TcpServer对象,
// Connection对象中包含了指针TcpServer *tsp_
// 在tcpServer中,创建sock对应的Connection对象时,就将TcpServer *tsp_进行了初始化
if (!conn->outbuffer_.empty())
conn->tsp_->EnableReadWrite(conn, true, true);
else
conn->tsp_->EnableReadWrite(conn, true, false);
}
Excepter
c
void Excepter(Connection *conn)
{
logMessage(DEBUG, "Excepter begin");
// 将异常的文件描述符,从epoll模型中移除
epoller_.Control(conn->sock_, 0, EPOLL_CTL_DEL);
// 关闭这个套接字文件描述符
conn->Close();
// 将conn->sock_对应的键对值从unordermap中移除
connections_.erase(conn->sock_);
logMessage(DEBUG, "关闭%d 文件描述符的所有的资源", conn->sock_);
// 释放sock对应的Connection对象占用的资源
delete conn;
}
服务器代码
makefile
c
LD=-DMYSELF
TcpServer: main.cc
g++ -o $@ $^ -std=c++11 -ljsoncpp ${LD}
.PHONY:clean
clean:
rm -f TcpServer
Err.hpp
c
#pragma once
#include <iostream>
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR,
EPOLL_CREATE_ERR
};
Log.hpp
c
#pragma once
#include <iostream>
#include <string>
#include <cstdarg>
#include <ctime>
#include <unistd.h>
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
const char * to_levelstr(int level)
{
switch(level)
{
case DEBUG : return "DEBUG";
case NORMAL: return "NORMAL";
case WARNING: return "WARNING";
case ERROR: return "ERROR";
case FATAL: return "FATAL";
default : return nullptr;
}
}
void logMessage(int level, const char *format, ...)
{
#define NUM 1024
char logprefix[NUM];
snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid: %d]",
to_levelstr(level), (long int)time(nullptr), getpid());
char logcontent[NUM];
va_list arg;
va_start(arg, format);
vsnprintf(logcontent, sizeof(logcontent), format, arg);
std::cout << logprefix << logcontent << std::endl;
}
Protocol.hpp
c
#pragma once
#include <iostream>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <jsoncpp/json/json.h>
#define SEP " "
// 不敢使用sizeof(),因为其包含\0这个字符,而strlen()不会
#define SEP_LEN strlen(SEP)
#define LINE_SEP "\r\n"
#define LINE_SEP_LEN strlen(LINE_SEP) // 不敢使用sizeof()
enum
{
OK = 0,
DIV_ZERO,
MOD_ZERO,
OP_ERROR
};
// 将报头和报文的正文内容进行拼接,中间用\r\n进行分割
// "x op y" -> "content_len"\r\n"x op y"\r\n
// "exitcode result" -> "content_len"\r\n"exitcode result"\r\n
std::string enLength(const std::string &text)
{
// 报头中存放的就是报文正文内容所占字节的大小
// 需要将其转化为字符串,才可以与正文内容进行拼接
std::string send_string = std::to_string(text.size());
// 将报头和报文的正文部分进行拼接,它们之前只用分隔符进行分割
send_string += LINE_SEP;
send_string += text;
send_string += LINE_SEP;
// 将其返回
return send_string;
}
// 去掉完整报文的报头,通过输出参数std::string *text
// 返回报文的正文部分
// "content_len"\r\n"exitcode result"\r\n
bool deLength(const std::string &package, std::string *text)
{
// "content_len"\r\n"exitcode result"\r\n
// package.find(LINE_SEP)拿到报头右侧的分隔符的位置
auto pos = package.find(LINE_SEP);
if (pos == std::string::npos)
return false;
// package.substr(0, pos)拿到报头字符串
std::string text_len_string = package.substr(0, pos);
// 将报头字符串转化为数字,其对应的就是报文正文的长度,即text_len
int text_len = std::stoi(text_len_string);
// package.substr(pos + LINE_SEP_LEN, text_len)拿到报文正文的内容
*text = package.substr(pos + LINE_SEP_LEN, text_len);
// 去掉报头成功,则返回真
return true;
}
// 当服务器或者客户端收到请求消息时,需要将网络中的消息先进行反序列化
// 再进行处理
class Request
{
public:
// 无参构造
Request() : x(0), y(0), op(0)
{
}
Request(int x_, int y_, char op_) : x(x_), y(y_), op(op_)
{
}
// 序列化
// 1. 自己写序列化
// 2. 用现成的系统提供的序列化或者反序列化的接口
bool serialize(std::string *out)
{
#ifdef MYSELF
// 将*out进行清空
*out = "";
// 结构化 -> "x op y";
// x是数字,使用接口to_string()将数字转化为字符串
// op本身就是char类型,因此不需要进行转化
std::string x_string = std::to_string(x);
std::string y_string = std::to_string(y);
// 将所有的字符串进行拼接,且字符之间使用SEP(空格)进行分割
*out = x_string;
*out += SEP;
*out += op;
*out += SEP;
*out += y_string;
#else
// 定义一个json对象
Json::Value root;
// x是数字,将x与first配对,存储到json中
// 那么josn会将x转化为字符串
root["first"] = x;
root["second"] = y;
root["oper"] = op;
// 两种方法都可以将root中的数据进行序列化
Json::FastWriter writer;
// Json::StyledWriter writer;
// 将root传入,writer.write(root)会自动将root中的内容进行序列化
// writer.write(root)返回的是一个string类型的对象
*out = writer.write(root);
#endif
return true;
}
// 反序列化
// "x op y";
// const std::string &in, in里面存放的去掉报头的报文正文
// 将其进行反序列化
bool deserialize(const std::string &in)
{
#ifdef MYSELF
// "x op y" -> 结构化
// in.find(SEP),找到的是左操作数x右边的分隔符
auto left = in.find(SEP);
// in.rfind(SEP),找到的是右操作数y左边的分隔符
auto right = in.rfind(SEP);
if (left == std::string::npos || right == std::string::npos)
return false;
if (left == right)
return false;
// SEP_LEN 是分隔符SEP的长度
if (right - (left + SEP_LEN) != 1)
return false;
// in.substr(0, left),分割的字符串是左闭右开的区间
// [0, 2) [start, end) , start(起始位置),
// end - start(从起始位置分割end - start个字符串)
std::string x_string = in.substr(0, left); // 分割出来的做操作数
std::string y_string = in.substr(right + SEP_LEN); // 分割出来的右操作数
// 保证x_string不为空串
if (x_string.empty())
return false;
if (y_string.empty())
return false;
// 将左操作数和右操作数,从字符串转化为数字
x = std::stoi(x_string);
y = std::stoi(y_string);
// 操作符op的下标为left + SEP_LEN
op = in[left + SEP_LEN];
#else
Json::Value root;
Json::Reader reader;
// 将in这个流中读取的数据放入到root中
reader.parse(in, root);
// 根据键对值,拿到相应的操作数和操作符
// asInt()的返回值是一个数字
x = root["first"].asInt();
y = root["second"].asInt();
// 整数赋值给char类型的op,会自动转化为一个字符
op = root["oper"].asInt();
#endif
return true;
}
public:
// "x op y"
int x;
int y;
char op;
};
// 当服务器或者客户端处理完收到的请求的数据之后,
// 需要将处理后的结果构建成可以发送给网络的响应
class Response
{
public:
Response() : exitcode(0), result(0)
{
}
Response(int exitcode_, int result_) : exitcode(exitcode_), result(result_)
{
}
// 序列化
bool serialize(std::string *out)
{
#ifdef MYSELF
*out = "";
// 将退出码,和处理结果转化为字符串
std::string ec_string = std::to_string(exitcode);
std::string res_string = std::to_string(result);
// 将退出码,和处理结果转化的字符串进行拼接
*out = ec_string;
*out += SEP;
*out += res_string;
#else
Json::Value root;
root["exitcode"] = exitcode;
root["result"] = result;
Json::FastWriter writer;
*out = writer.write(root);
#endif
return true;
}
// 反序列化
bool deserialize(const std::string &in)
{
#ifdef MYSELF
// "exitcode result"
auto mid = in.find(SEP);
if (mid == std::string::npos)
return false;
std::string ec_string = in.substr(0, mid);
std::string res_string = in.substr(mid + SEP_LEN);
// 保证退出码和处理结果不是空字符串
if (ec_string.empty() || res_string.empty())
return false;
// 将退出码和处理结果从字符串转化为数字
exitcode = std::stoi(ec_string);
result = std::stoi(res_string);
#else
Json::Value root;
Json::Reader reader;
reader.parse(in, root);
exitcode = root["exitcode"].asInt();
result = root["result"].asInt();
#endif
// 反序列化成功,则返回真
return true;
}
public:
// 0:计算成功,!0表示计算失败,具体是多少,定好标准
int exitcode;
int result; // 计算结果
};
// 解析一个报文
// "content_len"\r\n"x op y"\r\n
bool ParseOnePackage(std::string &inbuffer, std::string *text)
{
// 将*text指向的空间置空
*text = "";
// 分析处理
auto pos = inbuffer.find(LINE_SEP);
if (pos == std::string::npos)
return false;
// text_len_string是正文长度
std::string text_len_string = inbuffer.substr(0, pos);
// 将正文长度对应的字符串转化为数字
int text_len = std::stoi(text_len_string);
// 报文的总长度 = 报头长度+正文长度+2*分割符长度
int total_len = text_len_string.size() + 2 * LINE_SEP_LEN + text_len;
// 如果inbuffer.size() < total_len,说明inbuffer此时没有一个完整的报文
if (inbuffer.size() < total_len)
return false;
// 至少有一个完整的报文
*text = inbuffer.substr(0, total_len);
// 将报文拿走后,从缓冲区inbuffer中删除
inbuffer.erase(0, total_len);
return true;
}
Util.hpp
c
#pragma once
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
class Util
{
public:
// 将文件描述符设置为非阻塞
static bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0) return false;
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return true;
}
};
Epoller.hpp
c
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/epoll.h>
#include "Err.hpp"
#include "Log.hpp"
const static int defaultepfd = -1;
const static int size = 128;
class Epoller
{
public:
Epoller() : epfd_(defaultepfd)
{
}
~Epoller()
{
if (epfd_ != defaultepfd)
close(epfd_);
}
public:
void Create()
{
// 创建epoll模型
epfd_ = epoll_create(size);
if (epfd_ < 0)
{
logMessage(FATAL, "epoll_create error, code: %d, errstring: %s", errno, strerror(errno));
exit(EPOLL_CREATE_ERR);
}
}
// 添加文件描述符到epoll模型中
// user -> kernel (从用户添加到内核)
bool AddEvent(int sock, uint32_t events)
{
struct epoll_event ev;
ev.events = events;
ev.data.fd = sock;
int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, sock, &ev);
// 添加成功,则返回值为0
return n == 0;
}
// 从epoll模型中拿就绪事件
// kernel -> user
// revs[]:这部分表示一个结构体数组。revs 是一个数组的名称,用于存储 struct epoll_event 类型的结构体。
// 这个数组通常用于保存多个事件的信息。
int Wait(struct epoll_event revs[], int num, int timeout)
{
// int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
// events指针,指向一个存储struct epoll_event的数组
// int maxevents,代表这个数组的大小
int n = epoll_wait(epfd_, revs, num, timeout);
return n;
}
// 给特定套接字修改读事件关心,或者写事件关心,
// 或者移除特定套接字
// EPOLL_CTL_MOD 是一个标志,用于告诉 epoll 实例要修改已注册事件的配置。
// EPOLL_CTL_DEL 是一个标志,用于告诉 epoll 实例要删除已注册的事件。
bool Control(int sock, uint32_t event, int action)
{
int n = 0;
if (action == EPOLL_CTL_MOD)
{
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock;
// 成功操作:如果 epoll_ctl 操作成功执行,它将返回 0。
// 这表示已成功添加、修改或删除事件,并且没有发生错误。
// 出现错误:如果 epoll_ctl 操作失败,它将返回 -1,
// 并且错误码将被设置为适当的错误值。
// 您可以使用 errno 全局变量来获取具体的错误信息。
n = epoll_ctl(epfd_, action, sock, &ev);
}
else if (action == EPOLL_CTL_DEL)
{
n = epoll_ctl(epfd_, action, sock, nullptr);
}
else
n = -1;
return n == 0;
}
// 关闭epoll模型对应的文件描述符
void Close()
{
if (epfd_ != defaultepfd)
close(epfd_);
}
private:
int epfd_;
};
Sock.hpp
c
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "Err.hpp"
// 表示全连接队列中最多有32+1个连接
// 具体请看tcp相关实验
const static int backlog = 32;
const static int defaultsock = -1;
class Sock
{
public:
Sock():_listensock(defaultsock)
{}
~Sock()
{
if(_listensock != defaultsock) close(_listensock);
}
public:
int Socket()
{
// 1. 创建socket文件套接字对象
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
logMessage(FATAL, "create socket error");
exit(SOCKET_ERR);
}
logMessage(NORMAL, "create socket success: %d", _listensock);
// int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
// sockfd:套接字描述符,指定要设置选项的套接字
// level:选项的级别,通常是 SOL_SOCKET 表示通用套接字选项。
// 设置 SO_REUSEADDR 选项,允许地址重用
// optval:一个指向包含选项值的缓冲区的指针。
// optlen:指定选项值的长度。
int opt = 1;
// 我们将 `SO_REUSEADDR` 选项设置为1,从而启用了地址重用功能。
// 这可以让套接字在绑定地址时可以重用之前被关闭的套接字的地址,
// 而不会因为 TIME_WAIT 状态而无法绑定。
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}
void Bind(int port)
{
// 2. bind绑定自己的网络信息
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind socket error");
exit(BIND_ERR);
}
logMessage(NORMAL, "bind socket success");
}
void Listen()
{
// 3. 设置socket 为监听状态
if (listen(_listensock, backlog) < 0)
{
logMessage(FATAL, "listen socket error");
exit(LISTEN_ERR);
}
logMessage(NORMAL, "listen socket success");
}
// 获取新链接
int Accept(std::string *clientip, uint16_t *clientport, int *err)
{
// 在struct sockaddr_in结构体中,存在端口号和IP地址
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 将获取的套接字的文件描述符返回
int sock = accept(_listensock, (struct sockaddr *)&peer, &len);
*err = errno;
// sock < 0, 获取套接字失败
if (sock < 0)
logMessage(ERROR, "accept error, next");
else
{
logMessage(NORMAL, "accept a new link success, get new sock: %d", sock); // ?
// 获取套接字成功,通过输入输出型参数来查看客户端的端口号和IP地址
*clientip = inet_ntoa(peer.sin_addr);
*clientport = ntohs(peer.sin_port);
}
return sock;
}
// 通过成员函数获取私有的成员变量
int Fd()
{
return _listensock;
}
// 关闭监听套接字
void Close()
{
if(_listensock != defaultsock) close(_listensock);
}
private:
int _listensock;
};
TcpServer.hpp
c
#pragma once
#include <iostream>
#include <cassert>
#include <functional>
#include <unordered_map>
#include "Log.hpp"
#include "Sock.hpp"
#include "Err.hpp"
#include "Epoller.hpp"
#include "Util.hpp"
#include "Protocol.hpp"
namespace tcpserver
{
// 因为这两个类,一直在交叉引用,所以一定要事先进行声明
class Connection;
class TcpServer;
static const uint16_t defaultport = 8080;
static const int num = 64;
using func_t = std::function<void(Connection *)>;
// 把从每个套接字读到的数据都放入到这个套接字对应的缓冲区当中
// 这样就可以将每次从套接字中保存的数据保存到缓冲区中(缓冲区是在堆上开辟空间)
// 避免了临时变量char buffer[1024]随着栈帧空间的销毁而销毁的问题
// 等检测到缓冲区(缓冲区是在堆上开辟空间)上有完整报文,再交付一个完整报文给应用层
class Connection
{
public:
Connection(int sock, TcpServer *tsp) : sock_(sock), tsp_(tsp)
{
}
void Register(func_t r, func_t s, func_t e)
{
recver_ = r;
sender_ = s;
excepter_ = e;
}
~Connection()
{
}
void Close()
{
close(sock_);
}
public:
int sock_; // 套接字文件描述符
std::string inbuffer_; // 输入缓冲区
std::string outbuffer_; // 输出缓冲区
// 对应这个套接字的读方法,写方法,异常方法
func_t recver_; // 从sock_读
func_t sender_; // 向sock_写
func_t excepter_; // 处理sock_ IO的时候上面的异常事件
TcpServer *tsp_;
// 代表对应sock最近被访问的时间(有兴趣可以实现一下)
// uint64_t lasttime;
};
///
///
class TcpServer // Reactor
{
private:
void Recver(Connection *conn)
{
char buffer[1024];
while (true)
{
// conn->sock_读取对应套接字中的数据
// 0:通常表示阻塞接收,直到有数据可用
// 此时conn->sock_的读事件一定是就绪的
ssize_t s = recv(conn->sock_, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
// 返回值大于等于 0:表示成功接收的字节数,即接收操作成功完成。
buffer[s] = 0;
// 将读到的数据入队列,conn->inbuffer_是在堆上面开辟的空间
// 不会随栈空间的销毁,数据被销毁
conn->inbuffer_ += buffer;
// 在 C++ 中,std::string 类型是一个非平凡复制类型,
// 它包含动态分配的内存来存储字符串数据,因此无法直接通过可变参数列表传递。
// 可变参数列表通常用于传递基本数据类型(如整数、字符、指针等)和 POD(Plain Old Data)类型。
// 因此必须使用c_str()将其转化为c字符串
logMessage(DEBUG, "\n%s", conn->inbuffer_.c_str());
// 读取到数据之后,直到service_判断出conn->inbuffer_中至少有一个完整的报文
// 开始对完整的报文数据表进行处理
service_(conn);
}
else if (s == 0)
{
// 0:表示连接已关闭
if (conn->excepter_)
{
conn->excepter_(conn);
// 处理完异常,必须要返回
// 否则,处理完异常后,会接着循环,那么就会再一次处理异常
// 这样就会使同一块内存空间被释放两次,造成段错误
return;
}
}
else
{
// 读取发生错误
// errno == EAGAIN || errno == EWOULDBLOCK如果为真,
// 代表数据读取完毕,因为文件描述符是非阻塞的,所以超时返回了
// errno == EINTR 为真,代表读取被信号中断了,继续读取就可以
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
else if (errno == EINTR)
continue;
else
{
// 读取出现错误
// 调用conn->excepter_异常函数来处理
if (conn->excepter_)
{
conn->excepter_(conn);
return;
}
}
}
} // while
}
void Sender(Connection *conn)
{
while (true)
{
// 第四个参数0:通常表示阻塞发送,直到数据被成功发送或发生错误。
// 对应的文件描述符conn->sock_是非阻塞的,可以循环执行send()函数,直到将发送缓冲区的数据发完
// 当发送缓冲区中没有数据向套接字发送时send()函数会自动返回的,
// 当非阻塞套接字上发送缓冲区已满,错误码EAGAIN会被返回
ssize_t s = send(conn->sock_, conn->outbuffer_.c_str(), conn->outbuffer_.size(), 0);
logMessage(DEBUG, "for fanhui value:%d", s);
if (s >= 0)
{
// 当返回值大于等于 0:表示成功发送的字节数,即发送操作成功完成。
// 如果conn->outbuffer_.empty()为真,说明发送缓冲区中的数据被发送完了
if (conn->outbuffer_.empty())
{
break;
}
else
conn->outbuffer_.erase(0, s);
}
else
{
// -1:表示发送操作失败。
// 在这种情况下,您可以使用 errno 来获取具体的错误信息,
// 例如连接重置、连接超时、套接字错误等。
// 常见的错误码包括 EAGAIN(非阻塞套接字上发送缓冲区已满)
// 和 EINTR(操作被信号中断)等
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
else if (errno == EINTR)
continue;
else
{
// 发送出现了真正的错误,调用处理异常的函数来进行处理
if (conn->excepter_)
{
conn->excepter_(conn);
return;
}
}
}
}
// 如果没有发送完毕,需要对对应的sock开启对写事件的关心
// 如果发完了,我们要关闭对写事件的关心
// conn->tsp_就是TcpServer对象,
// Connection对象中包含了指针TcpServer *tsp_
// 在tcpServer中,创建sock对应的Connection对象时,就将TcpServer *tsp_进行了初始化
if (!conn->outbuffer_.empty())
conn->tsp_->EnableReadWrite(conn, true, true);
else
conn->tsp_->EnableReadWrite(conn, true, false);
}
void Excepter(Connection *conn)
{
logMessage(DEBUG, "Excepter begin");
// 将异常的文件描述符,从epoll模型中移除
epoller_.Control(conn->sock_, 0, EPOLL_CTL_DEL);
// 关闭这个套接字文件描述符
conn->Close();
// 将conn->sock_对应的键对值从unordermap中移除
connections_.erase(conn->sock_);
logMessage(DEBUG, "关闭%d 文件描述符的所有的资源", conn->sock_);
// 释放sock对应的Connection对象占用的资源
delete conn;
}
// 判断sock对应的Connection对象是否在unordermap中
// 因为如果发生异常,sock对应的Connection对象会被从unordermap中移除
// 所以为了安全起见,再进行读写之前,都要检查这个文件描述符是否还在unordermap中
bool IsConnectionExists(int sock)
{
auto iter = connections_.find(sock);
return iter != connections_.end();
}
void Loop(int timeout)
{
// 使用epoller_.Wait获取已经就绪的事件
int n = epoller_.Wait(revs_, num_, timeout);
for (int i = 0; i < n; i++)
{
// 从就绪数组中拿到对应的文件描述符
int sock = revs_[i].data.fd;
// 拿到对应的文件描述符对应的就绪事件
uint32_t events = revs_[i].events;
/*
if ((events & EPOLLIN) && sock == sock_.Fd())
{
// 如果sock_.fd()的返回值是监听套接字
// 因此如果满足这个条件就说明监听套接字的读事件已经就绪
// 那么调用对应的读取方法读取套接字中的数据
connections_[sock]->recver_(connections_[sock]);
// sock对应的Connection对象中就有读取方法,我们需要调用这个方法来进行读取
// 通过connections_[sock]就可以拿到unordermap中sock对应的Connection对象
// 再用过Connection对象调用recver_成员函数
// 而recver_成员函数,就是void Accepter(Connection *conn)传参过去的
// 因此recver_的参数为connections_[sock]
}
if ((events & EPOLLIN) && sock != sock_.Fd())
{
// 如果sock_.fd()的返回值是普通套接字
// 因此如果满足这个条件就说明普通套接字的读事件已经就绪
// 那么调用对应的读取方法读取套接字中的数据
connections_[sock]->recver_(connections_[sock]);
// 对应普通的成员函数,对应的读取方法是Recver函数
// 而recver_成员函数,就是Recver函数传参过去的
}
综上,只要是套接字就绪,不论是监听套接字还是普通套接字,我们都调用
connections_[sock]->recver_(connections_[sock]);读取套接字中的数据
*/
// 将所有的异常问题,全部转化 成为读写问题(因为读写过程中,本身就可能会有异常事件)
// 在recver_或者sender_函数中,检测到异常问题,则会调用对应处理异常的函数Excepter
// EPOLLERR表示对应的文件描述符发生错误
// EPOLLHUP表示对应的文件描述符被挂起
if (events & EPOLLERR)
events |= (EPOLLIN | EPOLLOUT);
if (events & EPOLLHUP)
events |= (EPOLLIN | EPOLLOUT);
// 如果要对套接字进行读取,要保证三点
// 1.读事件就绪,也就是events & EPOLLIN为真
// 2.套接字sock在unordermap中被管理,IsConnectionExists(sock)为真
// 3.确保程序员将这个套接字所有事件对应的回调方法传递到了sock对应的Connection对象中
// 如果connections_[sock]->recver_为真,那么recver_存在,recver_就是这个函数的地址
if ((events & EPOLLIN) && IsConnectionExists(sock) && connections_[sock]->recver_)
connections_[sock]->recver_(connections_[sock]);
if ((events & EPOLLOUT) && IsConnectionExists(sock) && connections_[sock]->sender_)
connections_[sock]->sender_(connections_[sock]);
}
}
// Accepter就是套接字文件描述符对应的读方法
void Accepter(Connection *conn)
{
for (;;)
{
std::string clientip;
uint16_t clientport;
// err是一个输出型参数
// 获取新连接,如果发生错误将错误码输出到err
// 系统调用接口accept()
// 1.如果获取新连接成功,则返回值大于或等于0
// 2.出现错误,返回值为-1,(错误信息可能为EAGAIN 或 EWOULDBLOCK等)
// 3.被信号中断,它可能会返回 -1,并设置 errno 为 EINTR
int err = 0;
int sock = sock_.Accept(&clientip, &clientport, &err);
if (sock > 0)
{
// 代码运行到这里说明获取新连接成功,得到对应的套接字文件描述符
// 因此,使用 AddConnection()函数为套接字文件描述符
// 1.首先要为该sock创建Connection;
// 如果这个套接字是ET模式,那么我们需要将套接字文件描述符设置为非阻塞
// 2.给对应的sock设置对应回调处理方法;因此通过bind传递了三个方法
// 3.也就是将sock与它要关心的事添加到epoll模型中
// 4.将sock作为key值,Connection对象作为value值,将其添加到unordermap中
AddConnection(
sock, EPOLLIN | EPOLLET,
std::bind(&TcpServer::Recver, this, std::placeholders::_1),
std::bind(&TcpServer::Sender, this, std::placeholders::_1),
std::bind(&TcpServer::Excepter, this, std::placeholders::_1));
logMessage(DEBUG, "get a new link, info: [%s:%d]", clientip.c_str(), clientport);
}
else
{
// 如果代码运行到这里,那么说明出现了错误
// 出现错误,返回值为-1,(错误信息可能为EAGAIN 或 EWOULDBLOCK等)
// 1.如果错误码为err == EAGAIN || err == EWOULDBLOCK,
// 说明只是将文件描述符中的数据读取完了,非阻塞返回的错误码,只需要跳出循环即可
// 2.如果错误码为err == EINTR,说明读取时,被信号中断了,那么继续循环读取即可
if (err == EAGAIN || err == EWOULDBLOCK)
break;
else if (err == EINTR)
continue;
else
// 如果代码运行到了这里说明是真的发生了其他错误
// 错误信息已经在sock_.Accept(&clientip, &clientport, &err)中被打印了
break;
}
}
}
void AddConnection(int sock, uint32_t events, func_t recver, func_t sender, func_t excepter)
{
// 1. 首先要为该sock创建Connection,并初始化,并添加到connections_
// 如果这个套接字是ET模式,那么我们需要将套接字文件描述符设置为非阻塞
if (events & EPOLLET)
Util::SetNonBlock(sock);
// 创建套接字文件描述符对应的Connection对象
Connection *conn = new Connection(sock, this);
// 2. 给对应的sock设置对应回调处理方法
conn->Register(recver, sender, excepter);
// 3. 其次将sock与它要关心的事件"写透式"注册到epoll中,让epoll帮我们关心
// 也就是将sock与它要关心的事添加到epoll模型中
bool r = epoller_.AddEvent(sock, events);
assert(r);
(void)r;
// 4. 将kv添加到connections_
// 将sock作为key值,Connection对象作为value值
// 将其添加到std::unordered_map<int, Connection *> connections_中
// 将这个Connection管理起来,我们可以通过sock快速查找到对应的Connection对象
connections_.insert(std::pair<int, Connection *>(sock, conn));
logMessage(DEBUG, "add new sock : %d in epoll and unordered_map", sock);
}
public:
TcpServer(func_t func, uint16_t port = defaultport)
: service_(func), port_(port), revs_(nullptr)
{
}
void InitServer()
{
// 1. 创建socket
sock_.Socket();
sock_.Bind(port_);
sock_.Listen();
// 2. 构建Epoll
epoller_.Create();
// 3. a.将目前唯一的一个sock(也就是监听套接字),添加到epoller中,
// b.添加之前需要先将对应的fd设置成为非阻塞
// c.listensock_也是一个socket啊,也要看做成为一个Connection
// d.std::bind(&TcpServer::Accepter, this, std::placeholders::_1)
// 就是将成员函数Accepter与Connection进行绑定
AddConnection(sock_.Fd(), EPOLLIN | EPOLLET,
std::bind(&TcpServer::Accepter, this, std::placeholders::_1), nullptr, nullptr);
// revs_这是一个指向 `struct epoll_event` 结构的指针,用于存储已触发事件的信息。
// epoll_event结构包含有关已触发事件的详细信息,包括文件描述符和事件类型。
// revs_指向struct epoll_event[num]数组,
// 因此可以传参给struct epoll_event revs[]
revs_ = new struct epoll_event[num];
num_ = num;
}
// 将Connection *conn中对应的文件描述符,添加到epoll中,
// 让epoll帮我们关心它的读事件或者写事件是否就绪(根据我们的传参来决定)
void EnableReadWrite(Connection *conn, bool readable, bool writeable)
{
uint32_t event = (readable ? EPOLLIN : 0) | (writeable ? EPOLLOUT : 0) | EPOLLET;
epoller_.Control(conn->sock_, event, EPOLL_CTL_MOD);
}
// 事件派发器
void Dispatcher()
{
int timeout = 1000;
while (true)
{
Loop(timeout);
// logMessage(DEBUG, "time out ...");
}
}
~TcpServer()
{
// 关闭监听套接字
sock_.Close();
// 关闭epoll模型对应的文件描述符
epoller_.Close();
// 释放存储就绪事件的数组revs_
if (nullptr == revs_)
delete[] revs_;
}
private:
// Sock对象,通过其来调用sock的所有函数接口
Sock sock_;
// tcp服务器的端口号
uint16_t port_;
// epoll 模型的调用
Epoller epoller_;
// 我们将套接字文件描述符和其对应的缓冲区等做了封装
// 也就是说每一个套接字文件描述符都对应一个Connection对象
// 如果客户端有大量的人进行连接,那么就会存在大量的Connection
// 因此我们需要用unordered_map<int, Connection *>对大量的Connection对象进行管理
std::unordered_map<int, Connection *> connections_;
// revs_这是一个指向 `struct epoll_event` 结构的指针,用于存储已触发事件的信息。
// epoll_event结构包含有关已触发事件的详细信息,包括文件描述符和事件类型。
// revs_指向struct epoll_event[num]数组,
// 因此可以传参给struct epoll_event revs[]
struct epoll_event *revs_;
// 代表struct epoll_event[num] 数组的大小
int num_;
// 解析报文,并逻辑处理的回调函数
func_t service_;
};
}
main.cc
c
#include "TcpServer.hpp"
#include <memory>
using namespace tcpserver;
static void usage(std::string proc)
{
std::cerr << "Usage:\n\t" << proc << " port"
<< "\n\n";
}
bool cal(const Request &req, Response &resp)
{
// req已经有结构化完成的数据啦,你可以直接使用
resp.exitcode = OK;
resp.result = OK;
switch (req.op)
{
case '+':
resp.result = req.x + req.y;
break;
case '-':
resp.result = req.x - req.y;
break;
case '*':
resp.result = req.x * req.y;
break;
case '/':
{
if (req.y == 0)
resp.exitcode = DIV_ZERO;
else
resp.result = req.x / req.y;
}
break;
case '%':
{
if (req.y == 0)
resp.exitcode = MOD_ZERO;
else
resp.result = req.x % req.y;
}
break;
default:
resp.exitcode = OP_ERROR;
break;
}
return true;
}
void calculate(Connection *conn)
{
// 使用ParseOnePackage()函数
// 从inbuffer中读取一个完整的报文
std::string onePackage;
while (ParseOnePackage(conn->inbuffer_, &onePackage))
{
// 使用deLength()将这个完整报文的报头去掉
// 并将报文的正文输出到reqStr中
std::string reqStr;
if (!deLength(onePackage, &reqStr))
return;
std::cout << "去掉报头的正文:\n"
<< reqStr << std::endl;
// 2. 拿到正文后,对请求Request进行反序列化
// 2.1 得到一个结构化的请求对象
Request req;
if (!req.deserialize(reqStr))
return;
// cal()函数将数据处理后,将响应结果输出到resp中
Response resp;
cal(req, resp);
// 将响应正文进行序列化
// 将序列化的数据输出到respStr中
std::string respStr;
resp.serialize(&respStr);
// 5. 然后我们在发送响应
// 5.1 给正文添加报头构建成为一个完整的报文
conn->outbuffer_ += enLength(respStr);
std::cout << "--------------result: " << conn->outbuffer_ << std::endl;
}
// 调用sock对应的发送方法进行发送
if (conn->sender_)
conn->sender_(conn);
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);
std::unique_ptr<TcpServer> tsvr(new TcpServer(calculate, port));
tsvr->InitServer();
tsvr->Dispatcher();
return 0;
}
客户端代码
makefile
c
cc=g++
calclient:calClient.cc
$(cc) -o $@ $^ -std=c++11 -ljsoncpp
.PHONY:clean
clean:
rm -f calclient
protocol.hpp
c
#pragma once
#include <iostream>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#define SEP " "
// 不敢使用sizeof(),因为其包含\0这个字符,而strlen()不会
#define SEP_LEN strlen(SEP)
#define LINE_SEP "\r\n"
#define LINE_SEP_LEN strlen(LINE_SEP) // 不敢使用sizeof()
enum
{
OK = 0,
DIV_ZERO,
MOD_ZERO,
OP_ERROR
};
// 将报头和报文的正文内容进行拼接,中间用\r\n进行分割
// "x op y" -> "content_len"\r\n"x op y"\r\n
// "exitcode result" -> "content_len"\r\n"exitcode result"\r\n
std::string enLength(const std::string &text)
{
// 报头中存放的就是报文正文内容所占字节的大小
std::string send_string = std::to_string(text.size());
// 将报头和报文的正文部分进行拼接,它们之前只用分隔符进行分割
send_string += LINE_SEP;
send_string += text;
send_string += LINE_SEP;
// 将其返回
return send_string;
}
// 去掉完整报文的报头,通过输出参数std::string *text
// 返回报文的正文部分
// "content_len"\r\n"exitcode result"\r\n
bool deLength(const std::string &package, std::string *text)
{
// "content_len"\r\n"exitcode result"\r\n
// package.find(LINE_SEP)拿到报头右侧的分隔符的位置
auto pos = package.find(LINE_SEP);
if (pos == std::string::npos)
return false;
// package.substr(0, pos)拿到报头字符串
std::string text_len_string = package.substr(0, pos);
// 将报头字符串转化为数字,其对应的就是报文正文的长度,即text_len
int text_len = std::stoi(text_len_string);
// package.substr(pos + LINE_SEP_LEN, text_len)拿到报文正文的内容
*text = package.substr(pos + LINE_SEP_LEN, text_len);
// 去掉报头成功,则返回真
return true;
}
// 当服务器或者客户端收到请求消息时,需要将网络中的消息先进行反序列化
// 再进行处理
class Request
{
public:
// 无参构造
Request() : x(0), y(0), op(0)
{
}
Request(int x_, int y_, char op_) : x(x_), y(y_), op(op_)
{
}
// 序列化
// 1. 自己写序列化
// 2. 用现成的系统提供的序列化或者反序列化的接口
bool serialize(std::string *out)
{
// 将*out进行清空
*out = "";
// 结构化 -> "x op y";
// x是数字,使用接口to_string()将数字转化为字符串
// op本身就是char类型,因此不需要进行转化
std::string x_string = std::to_string(x);
std::string y_string = std::to_string(y);
// 将所有的字符串进行拼接,且字符之间使用SEP(空格)进行分割
*out = x_string;
*out += SEP;
*out += op;
*out += SEP;
*out += y_string;
return true;
}
// 反序列化
// "x op y";
// const std::string &in, in里面存放的去掉报头的报文正文
// 将其进行反序列化
bool deserialize(const std::string &in)
{
// "x op y" -> 结构化
// in.find(SEP),找到的是左操作数x右边的分隔符
auto left = in.find(SEP);
// in.rfind(SEP),找到的是右操作数y左边的分隔符
auto right = in.rfind(SEP);
if (left == std::string::npos || right == std::string::npos)
return false;
if (left == right)
return false;
// SEP_LEN 是分隔符SEP的长度
if (right - (left + SEP_LEN) != 1)
return false;
// in.substr(0, left),分割的字符串是左闭右开的区间
// [0, 2) [start, end) , start(起始位置),
// end - start(从起始位置分割end - start个字符串)
std::string x_string = in.substr(0, left); // 分割出来的做操作数
std::string y_string = in.substr(right + SEP_LEN); // 分割出来的右操作数
// 保证x_string不为空串
if (x_string.empty())
return false;
if (y_string.empty())
return false;
// 将左操作数和右操作数,从字符串转化为数字
x = std::stoi(x_string);
y = std::stoi(y_string);
// 操作符op的下标为left + SEP_LEN
op = in[left + SEP_LEN];
return true;
}
public:
// "x op y"
int x;
int y;
char op;
};
// 当服务器或者客户端处理完收到的请求的数据之后,
// 需要将处理后的结果构建成可以发送给网络的响应
class Response
{
public:
Response() : exitcode(0), result(0)
{}
Response(int exitcode_, int result_) : exitcode(exitcode_), result(result_)
{}
// 序列化
bool serialize(std::string *out)
{
*out = "";
// 将退出码,和处理结果转化为字符串
std::string ec_string = std::to_string(exitcode);
std::string res_string = std::to_string(result);
// 将退出码,和处理结果转化的字符串进行拼接
*out = ec_string;
*out += SEP;
*out += res_string;
return true;
}
// 反序列化
bool deserialize(const std::string &in)
{
// "exitcode result"
auto mid = in.find(SEP);
if (mid == std::string::npos)
return false;
std::string ec_string = in.substr(0, mid);
std::string res_string = in.substr(mid + SEP_LEN);
// 保证退出码和处理结果不是空字符串
if (ec_string.empty() || res_string.empty())
return false;
// 将退出码和处理结果从字符串转化为数字
exitcode = std::stoi(ec_string);
result = std::stoi(res_string);
// 反序列化成功,则返回真
return true;
}
public:
// 0:计算成功,!0表示计算失败,具体是多少,定好标准
int exitcode;
int result; // 计算结果
};
// 从套接字对应的文件描述符sock中进行读取
// 如下是多个报文的字节流
// "content_len"\r\n"x op y"\r\n"content_len"\r\n"x op y"\r\n"content_len"\r\n"x op
bool recvPackage(int sock, std::string &inbuffer, std::string *text)
{
// 定义应用层的缓冲区,来存放报文
char buffer[1024];
while (true)
{
// 从sock读取sizeof(buffer) - 1个字节并将其存放到buffer中
// 经过多次循环,就可以将sock中的内容全部读取
// 并返回读取内容的字节个数
ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
// 给字符串的末尾添加结束符,即\0
buffer[n] = 0;
// 将读取的所有数据放入到string类型的inbuffer中
inbuffer += buffer;
// 分析处理
// "content_len"\r\n"x op y"\r\n"content_len"\r\n"x op y"\r\n
// inbuffer.find(LINE_SEP)就是报头"content_len"右侧的分隔符的位置
auto pos = inbuffer.find(LINE_SEP);
if (pos == std::string::npos)
continue; // 如果没有找到,那么就返回继续读取,再重新找
// inbuffer.substr(0, pos),获取到报头
// 报头存放就是报文正文内容所占的字节数,即text_len_string
std::string text_len_string = inbuffer.substr(0, pos);
// 将报文正文内容所占的字节数,由字符串转为数字
int text_len = std::stoi(text_len_string);
// total_len 报文的总长度
// text_len_string.size()是报头的长度
// 2 * LINE_SEP_LEN + text_len 报文正文和两个分割符长度的总和
int total_len = text_len_string.size() + 2 * LINE_SEP_LEN + text_len;
// text_len_string + "\r\n" + text + "\r\n" <= inbuffer.size();
std::cout << "处理前#inbuffer: \n"<< inbuffer << std::endl;
// inbuffer.size()小于total_len,说明inbuffer没有一个完整的报文
// 那么就返回,继续从sock读取,直到inbuffer.size() > total_len
if (inbuffer.size() < total_len)
{
std::cout << "你输入的消息,没有严格遵守我们的协议,正在等待后续的内容, continue" << std::endl;
continue;
}
// 至少有一个完整的报文
// 分割出一个完整报文
*text = inbuffer.substr(0, total_len);
// 从inbuffer删除掉分割出的报文
inbuffer.erase(0, total_len);
std::cout << "处理后#inbuffer:\n " << inbuffer << std::endl;
break;
}
else
// 如果n小于等于0说明对方关闭了sock文件描述符对应的文件,因此返回错误
return false;
}
return true;
}
calClient.hpp
c
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "protocol.hpp"
#define NUM 1024
class calClient
{
public:
// 客户端的构造函数
calClient(const std::string &serverip, const uint16_t &serverport)
: _sock(-1), _serverip(serverip), _serverport(serverport)
{
}
// 初始化客户端
void initClient()
{
// 1. 创建套接字socket
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0)
{
// 创建套接字失败,打印错误信息
std::cerr << "socket create error" << std::endl;
exit(2);
}
// 2. tcp的客户端要不要bind?要的!
// 要不要显示的bind?不要!这里尤其是client port要让OS自定随机指定!
// 3. 要不要listen?不要!
// 4. 要不要accept? 不要!
// 5. 要什么呢? 要发起链接!
}
// 启动客户端
void start()
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
if (connect(_sock, (struct sockaddr *)&server, sizeof(server)) != 0)
{
// 客户端向服务器发起链接失败,则打印错误信息
std::cerr << "socket connect error" << std::endl;
}
else
{
// 链接成功,客户端和服务端进行通信
std::string line;
std::string inbuffer;
while (true)
{
std::cout << "mycal>>> ";
// 将输入流的数据,放入到应用层的缓冲区line中
std::getline(std::cin, line); // 1+1
// ParseLine(line)拿到了输入数据构建的请求
Request req = ParseLine(line); // "1+1"
// 将数据序列化之后,通过输出参数std::string content返回
// 此时content也就是要发送的报文的正文
std::string content; // 输出型参数
req.serialize(&content);
// 给报文添加报头并返回
std::string send_string = enLength(content);
std::cout << "sendstring:\n"<< send_string << std::endl;
// 将序列化之后的报文发送
send(_sock, send_string.c_str(), send_string.size(), 0);
// 读取服务器计算数据后的返回结果
// std::string package,输出型参数,返回完整的报文
// std::string text,输出型参数,返回报文正文
std::string package, text;
// 从套接字的文件描述符sock中读取一个完整的报文
// "content_len"\r\n"exitcode result"\r\n
if (!recvPackage(_sock, inbuffer, &package))
continue;
// 分离报文报头和报文正文
if (!deLength(package, &text))
continue;
// 报文正文包含如下内容
// "exitcode result"
// 从网络中拿到的数据,因此需要先进行反序列化
Response resp;
resp.deserialize(text);
std::cout << "exitCode: " << resp.exitcode << std::endl;
std::cout << "result: " << resp.result << std::endl;
}
}
}
// 客户端的析构函数
~calClient()
{
// 关闭客户端套接字对应的文件描述符
if (_sock >= 0)
close(_sock);
}
Request ParseLine(const std::string &line)
{
// 建议版本的状态机!
//"1+1" "123*456" "12/0"
int status = 0; // 0:操作符之前,1:碰到了操作符 2:操作符之后
int i = 0;
// line.size()是输入左操作数、右操作数、操作符和分隔符的总长度
int cnt = line.size();
std::string left, right;
char op;
while (i < cnt)
{
switch (status)
{
case 0:
{
// 状态码为0,表明当前位置是在操作符之前
// isdigit(line[i])判断line[i]是不是数字,是数字则返回真
if (!isdigit(line[i]))
{
// 此时line[i]一定是操作符
op = line[i];
// 1:碰到了操作符,因此将状态码改为1
status = 1;
}
else
// 此时line[i] 一定是左操作数
left.push_back(line[i++]);
}
break;
case 1:
// 状态码为1,说明line[i]一定是操作符,因此进行++
i++;
// ++ 之后line[i]一定为右操作数,因此将状态码变为2
status = 2;
break;
case 2:
// 此时line[i]一定为右操作数
right.push_back(line[i++]);
break;
}
}
std::cout << std::stoi(left) << " " << std::stoi(right) << " " << op << std::endl;
// 将拿到的数据,构建为一个请求并返回
return Request(std::stoi(left), std::stoi(right), op);
}
private:
int _sock;
std::string _serverip;
uint16_t _serverport;
};
calClient.cc
c
#include "calClient.hpp"
#include <memory>
using namespace std;
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " serverip serverport\n\n";
}
// ./tcpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
unique_ptr<calClient> tcli(new calClient(serverip, serverport));
tcli->initClient();
tcli->start();
return 0;
}
演示结果

/ 客户端的析构函数
~calClient()
{
// 关闭客户端套接字对应的文件描述符
if (_sock >= 0)
close(_sock);
}
Request ParseLine(const std::string &line)
{
// 建议版本的状态机!
//"1+1" "123*456" "12/0"
int status = 0; // 0:操作符之前,1:碰到了操作符 2:操作符之后
int i = 0;
// line.size()是输入左操作数、右操作数、操作符和分隔符的总长度
int cnt = line.size();
std::string left, right;
char op;
while (i < cnt)
{
switch (status)
{
case 0:
{
// 状态码为0,表明当前位置是在操作符之前
// isdigit(line[i])判断line[i]是不是数字,是数字则返回真
if (!isdigit(line[i]))
{
// 此时line[i]一定是操作符
op = line[i];
// 1:碰到了操作符,因此将状态码改为1
status = 1;
}
else
// 此时line[i] 一定是左操作数
left.push_back(line[i++]);
}
break;
case 1:
// 状态码为1,说明line[i]一定是操作符,因此进行++
i++;
// ++ 之后line[i]一定为右操作数,因此将状态码变为2
status = 2;
break;
case 2:
// 此时line[i]一定为右操作数
right.push_back(line[i++]);
break;
}
}
std::cout << std::stoi(left) << " " << std::stoi(right) << " " << op << std::endl;
// 将拿到的数据,构建为一个请求并返回
return Request(std::stoi(left), std::stoi(right), op);
}
private:
int _sock;
std::string _serverip;
uint16_t _serverport;
};
## calClient.cc
```c
#include "calClient.hpp"
#include <memory>
using namespace std;
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " serverip serverport\n\n";
}
// ./tcpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
unique_ptr<calClient> tcli(new calClient(serverip, serverport));
tcli->initClient();
tcli->start();
return 0;
}
演示结果
外链图片转存中...(img-0W8siaSo-1743663033793)