【计算机网络】Linux环境中的TCP网络编程

news/2024/11/21 1:31:03/

文章目录

  • 前言
  • 一、TCP Socket API
    • 1. socket
    • 2. bind
    • 3. listen
    • 4. accept
    • 5. connect
  • 二、封装TCPSocket
  • 三、服务端的实现
    • 1. 封装TCP通用服务器
    • 2. 封装任务对象
    • 3. 实现转换功能的服务器
  • 四、客户端的实现
    • 1. 封装TCP通用客户端
    • 2. 实现转换功能的客户端
  • 五、结果演示
  • 六、多进程版服务器
  • 七、线程池版服务器


前言

TCP和UDP都是工作在传输层,用于程序之间传输数据。二者之间的区别是TCP是面向连接的,而UDP是面向数据报的。那就意味着,TCP能够进行可靠的数据传输,而UDP进行不可靠的数据传输。关于TCP协议和UDP协议的详细内容可见博主的后续文章,本文的主要内容是关于TCP socket的网络编程。

接下来我们将基于TCP网络编程实现一个将小写字母转换成大写字母的网络服务器。

一、TCP Socket API

以下是关于使用TCP协议用到的socket API,这些函数都包含在头文件sys/socket.h中。

1. socket

函数定义:

NAME//socket - create an endpoint for communication
SYNOPSIS#include <sys/socket.h>int socket(int domain, int type, int protocol);

功能:
socket()会打开一个网络通信端口,如果打开成功,则像open()函数一样返回一个文件描述符,如果失败则返回 -1。这样网络应用程序就可以像读写文件那样使用read/write在网络上读取和发送数据。

参数详解:

  1. 第一个参数domain用于设置网络通信的域,函数socket()根据这个参数选择通信协议的族。对于IPv4,domain参数指定为AF_INET,而IPv6则是AF_INET6。并且AF_INETPFINET的值是一致的。

  2. 第二个参数type用于设置通信协议的族,这些族也在文件sys/socket.h中定义,包含如下表所示的值。

类型说明
SOCK_STREAM用于TCP连接,提供序列化、可靠的、双向连接的字节流
SOCK_DGRAM用于UDP连接(无连接状态的消息)
SOCK_SEQPACKET序列化包,提供一个序列化的、可靠的、双向的基于连接的数据传输通道,数据长度定长。每次调用读系统调用时数据需要将全部数据读出
SOCK_RAWRAW类型,提供原始网络协议访问
SOCK_RDM提供可靠的数据报文,不过可能数据会有乱序
SOCK_PACKET这是一个专用类型,不能在通用程序中使用,用于直接从设备驱动接收数据

【补充说明】

  • 类型为SOCK_STREAM的套接字表示一个双向的字节流,与管道类似。流式的套接字在进行数据收发之前必须已经连接,连接使用connet()函数进行。一旦连接,可以使用read/write函数进行数据的传输。流式通信方式保证数据不会丢失或者重复接收,当数据在一段时间内仍然没有接收完毕,可以将这个连接认为已经断开。
  • SOCK_DGRAMSOCK_RAW这两种套接字可以使用函数sendto()来发送数据,使用recvfrom()函数接收数据,recvfrom()接收来自指定IP地址的发送方的数据。
  1. 第三个参数protocol用于指定某个协议的特定类型,即type类型中的某个类型。通常某个协议中只有一种特定类型,这样protocol参数仅能设置为0,但是有些协议有多种类型,就需要设置这个参数来选择特定的类型。

2. bind

函数定义:

NAME//bind - bind a name to a socketSYNOPSIS#include <sys/socket.h>int bind(int socket, const struct sockaddr *address, socklen_t address_len);

因为服务器程序所监听的网络地址和端口号通常都是固定不变的,客户端得知了服务器程序的地址和端口号后就可以向服务器发起连接,而服务器需要绑定一个固定的网络地址和端口号。因此bind()的作用是将参数sockfdmyaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听sockaddr所描述的地址和端口号。绑定成功返回0,失败则返回-1。

博主的上一篇文章【网络套接字编程】中提到过,struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。

在程序中myaddr的定义及初始化如下:

struct sockaddr_in myaddr;
bzero(&myaddr, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(SERV_PORT);
  1. 定义myaddr
  2. 使用bzero函数将整个结构体清零
  3. 设置网络通信的域为AF_INET
  4. 将网络地址设置为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP 地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP 地址
  5. 最后填充端口号

虽然bind()中的第二个参数类型是sockaddr,但是我们真正填充信息使用的数据结构是sockaddr_in,这个结构里主要有三部分信息:地址类型、端口号、IP地址。最后在进行函数传参的时候只需要将sockaddr_in*强制类型转换成sockaddr即可。

3. listen

函数定义:

NAME//listen - listen for socket connections and limit the queue of incoming connectionsSYNOPSIS#include <sys/socket.h>int listen(int socket, int backlog);

listen()函数用于声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略。(详细内容可见博主的后续文章【TCP协议】)。listen()函数调用成功返回0,调用失败则返回 -1。

4. accept

函数定义:

NAMEaccept - accept a new connection on a socketSYNOPSIS#include <sys/socket.h>int accept(int socket, struct sockaddr *restrict_address, socklen_t *restrict_address_len);

accept()函数的作用是,当客户端与服务端的三次握手完成后,服务器调用accept()函数接受连接。如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端请求连接。
返回值:

  • 调用成功则返回客户端socket()返回的文件描述符,调用失败则返回 -1。

参数:

  • 第一个参数socket即是调用socket()函数返回的文件描述符。
  • 第二个参数restrict_address是输出型参数,用于获取客户端的网络地址和端口号,如果该参数为空,则表示当前服务端不关心客户端的地址。
  • 第三个参数restrict_address_len也是输出型参数,它表示的是缓冲区restrict_address的长度,以避免缓冲区溢出问题,最后传出客户端地址结构体的实际长度。

accept()函数在服务器程序中的使用结构如下:

while (true)
{sockaddr_in peer_addr;socklen_t len = sizeof(peer_addr);int peer_sock = accept(_fd, (sockaddr *)&peer_addr, &len);ssize_t read_size = read(peer_sock, buf, sizeof(buf));. . .close(peer_sock);
}

5. connect

函数定义:

NAME//connect - connect a socketSYNOPSIS#include <sys/socket.h>int connect(int socket, const struct sockaddr *address, socklen_t address_len);

作用与参数说明:
connect函数用于客户端连接服务器。其参数与bind()函数的参数一致,区别在于bind()函数绑定的参数是自己的地址,而connect()函数的连接是服务器的地址。
返回值:

  • 调用成功返回0,调用失败则返回 -1。

二、封装TCPSocket

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cassert>#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#define CHECK_RET(exp) \if (!(exp))        \{                  \return false;  \}class TcpSocket
{
public:TcpSocket() : _fd(-1) {}~TcpSocket() {}public:bool Socket(){_fd = socket(AF_INET, SOCK_STREAM, 0); // AF_INET表示采用IPv4, SOCK_STREAM表示采用tcp协议if (_fd < 0){std::cerr << "create socket error!" << std::endl;return false;}return true;}bool Close(){close(_fd);return true;}bool Bind(const std::string &ip, uint16_t port){sockaddr_in addr;// 填充addr信息addr.sin_family = AF_INET;addr.sin_port = htons(port);// addr.sin_addr.s_addr = ip.empty() ? htonl(INADDR_ANY) : inet_addr(ip.c_str());ip.empty() ? (addr.sin_addr.s_addr = INADDR_ANY) : inet_aton(ip.c_str(), &addr.sin_addr);if (bind(_fd, (const sockaddr *)&addr, sizeof(addr)) < 0){std::cerr << "bind error!" << std::endl;return false;}return true;}bool Listen(int num){if (listen(_fd, num) < 0){std::cerr << "listen error!" << std::endl;return false;}return true;}bool Accept(TcpSocket *peer, std::string *ip = nullptr, std::uint16_t *port = nullptr){sockaddr_in peer_addr;socklen_t len = sizeof(peer_addr);int peer_sock = accept(_fd, (sockaddr *)&peer_addr, &len);if (peer_sock < 0){std::cerr << "accept error!" << std::endl;return false;}peer->_fd = peer_sock;if (ip != nullptr){*ip = inet_ntoa(peer_addr.sin_addr);}if (port != nullptr){*port = ntohs(peer_addr.sin_port);}return true;}bool Recv(std::string *buf){buf->clear();char inbuf[1024 * 10] = {0};ssize_t read_size = recv(_fd, inbuf, sizeof(inbuf), 0);if (read_size < 0){std::cerr << "recv error!" << std::endl;return false;}if (read_size == 0){return false;}buf->assign(inbuf, read_size);return true;}bool Send(const std::string &buf){ssize_t write_size = send(_fd, buf.c_str(), buf.size(), 0);if (write_size < 0){std::cerr << "send error!" << std::endl;return false;}return true;}bool Connect(const std::string &ip, uint16_t port){sockaddr_in addr;// 填充addr信息addr.sin_family = AF_INET;addr.sin_port = htons(port);addr.sin_addr.s_addr = ip.empty() ? htonl(INADDR_ANY) : inet_addr(ip.c_str());if (connect(_fd, (sockaddr *)&addr, sizeof(addr)) < 0){std::cerr << "connect error!" << std::endl;return false;}return true;}int GetFd(){return _fd;}private:int _fd;
};

三、服务端的实现

1. 封装TCP通用服务器

#include "TcpSocket.hpp"
#include "Task.hpp"typedef std::function<void(TcpSocket, const std::string &, uint16_t)> Handler;// void transServer(TcpSocket sock, const std::string &ip, uint16_t port)
// {
//     std::string inbuf;//     while (true)
//     {
//         if (!sock.Recv(&inbuf))
//         {
//             // 如果读取失败,结束循环
//             printf("[client %s:%d] disconnect!\n", ip.c_str(), port);
//             break;
//         }//         if (strcasecmp(inbuf.c_str(), "quit") == 0)
//         {
//             printf("[client %s:%d] quit!\n", ip.c_str(), port);
//             break;
//         }//         printf("transform before: %s[%d]--> %s\n", ip.c_str(), port, inbuf.c_str());
//         fflush(stdout);//         for (int i = 0; i < inbuf.size(); ++i)
//         {
//             if (isalpha(inbuf[i]) && islower(inbuf[i]))
//             {
//                 inbuf[i] = toupper(inbuf[i]);
//             }
//         }//         printf("transform after: %s[%d]--> %s\n", ip.c_str(), port, inbuf.c_str());
//         fflush(stdout);//         sock.Send(inbuf);
//     }
//     sock.Close();
// }class TcpServer
{
public:TcpServer(int port, const std::string &ip = ""): _port(port), _ip(ip){_sock.Socket();}~TcpServer() {}public:bool Start(Handler handler){// 绑定IP和端口号CHECK_RET(_sock.Bind(_ip, _port));// 监听CHECK_RET(_sock.Listen(5));// 进入循环while (true){// 进行acceptTcpSocket peer_sock;std::string ip;uint16_t port = 0;if (!_sock.Accept(&peer_sock, &ip, &port)){continue;}printf("[client %s:%d] connect!\n", ip.c_str(), port);// 执行任务// TODOTask task(peer_sock, ip, port, handler);task();}}private:// tcp socket对象TcpSocket _sock;// 端口号uint16_t _port;// ip地址std::string _ip;
};

2. 封装任务对象

#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>#include "TcpSocket.hpp"class Task
{typedef std::function<void(TcpSocket, const std::string &, uint16_t)> callback_t;public:Task(TcpSocket sock, const std::string &ip, uint16_t port, callback_t func):_sock(sock), _ip(ip), _port(port), _func(func) {}~Task() {}void operator()(){printf("线程ID[%p]处理client[%s:%d]的请求开始了...\n", pthread_self(), _ip.c_str(), _port);fflush(stdout);_func(_sock, _ip, _port);printf("线程ID[%p]处理client[%s:%d]的请求结束了...\n", pthread_self(), _ip.c_str(), _port);fflush(stdout);}
private:TcpSocket _sock;std::string _ip;uint16_t _port;callback_t _func; // 处理任务的回调函数
};

3. 实现转换功能的服务器

#include <iostream>
#include "TcpSocket.hpp"
#include "TcpServer.hpp"void transServer(TcpSocket sock, const std::string &ip, uint16_t port)
{std::string inbuf;while (true){if (!sock.Recv(&inbuf)){// 如果读取失败,结束循环printf("[client %s:%d] disconnect!\n", ip.c_str(), port);break;}if (strcasecmp(inbuf.c_str(), "quit") == 0){printf("[client %s:%d] quit!\n", ip.c_str(), port);break;}printf("transform before: %s[%d]--> %s\n", ip.c_str(), port, inbuf.c_str());fflush(stdout);for (int i = 0; i < inbuf.size(); ++i){if (isalpha(inbuf[i]) && islower(inbuf[i])){inbuf[i] = toupper(inbuf[i]);}}printf("transform after: %s[%d]--> %s\n", ip.c_str(), port, inbuf.c_str());fflush(stdout);sock.Send(inbuf);}sock.Close();
}static void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " ip port" << std::endl;std::cout << "example:\n\t" << proc << " 127.0.0.1 8080\n"<< std::endl;
}int main(int argc, char *argv[])
{if (argc != 2 && argc != 3){Usage(argv[0]);return 1;}std::string ip;if (argc == 3){ip = argv[1];}uint16_t port = atoi(argv[2]);TcpServer server(port, ip);server.Start(transServer);return 0;
}

四、客户端的实现

1. 封装TCP通用客户端

#include "TcpSocket.hpp"class TcpClient
{
public:TcpClient(const std::string& ip, uint16_t port):_ip(ip), _port(port) {_sock.Socket();}~TcpClient(){_sock.Close();}
public:bool Connect(){return _sock.Connect(_ip, _port);}bool Recv(std::string* buf){return _sock.Recv(buf);}bool Send(const std::string& buf){_sock.Send(buf);}int GetFd(){return _sock.GetFd();}
private:TcpSocket _sock;std::string _ip;uint16_t _port;
};

2. 实现转换功能的客户端

#include <iostream>
#include "TcpClient.hpp"volatile bool quit = false;static void Usage(std::string proc)
{std::cerr << "Usage:\n\t" << proc << " serverIp serverPort" << std::endl;std::cerr << "Example:\n\t" << proc << " 127.0.0.1 8080\n"<< std::endl;
}
// ./clientTcp serverIp serverPort
int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);return 1;}std::string serverIp = argv[1];uint16_t serverPort = atoi(argv[2]);TcpClient client(serverIp, serverPort);// 建立连接if (!client.Connect()){std::cout << "connecte errer!" << std::endl;return 2;}std::cout << "connecte success! fd: " << client.GetFd() << std::endl;std::string message;while (!quit){message.clear();std::cout << "请输入您的内容# ";std::getline(std::cin, message);if (strcasecmp(message.c_str(), "quit") == 0){quit = true;}if (client.Send(message)){message.resize(message.size());if (client.Recv(&message)){std::cout << "Server Echo ---> " << message << std::endl;}}else{break;}}return 0;
}

五、结果演示

启动服务端:

启动客户端:

客户端连接服务端成功,此时服务端状态:

测试样例:
客户端输入样例,发现转换成功。

存在的问题:
此时,启动又一个客户端连接服务器,在此输入时,我们会发现输入的内容会卡住显示屏上。

此时我们关闭第一个客户端后发现有得出转换后的结果。


原因在于当前的服务器是单进程版本的,只能够同时为一个客户端服务,所以再来一个客户端会阻塞等待服务器结束对上一个客户端的服务。以下的改进的多进程和多线程版本的服务器。

六、多进程版服务器

改进思想是,父进程为每个客户端的请求都创建一个子进程去处理任务,父进程不做任何工作,但要注意的是父进程中要关闭不断创建的客户端的peer_sock,避免内存泄漏。子进程执行客户端的请求,在请求结束后调用exit函数退出,但不必单独释放_sock对象,因为会自动调用其析构函数。同时设置signal函数,对SIGCHLD做忽略动作,使得父进程不必等待子进程,只处理自己的任务。

#pragma once#include "TcpSocket.hpp"
#include "Task.hpp"
#include <cassert>
#include <signal.h>
#include <unistd.h>typedef std::function<void(TcpSocket, const std::string &, uint16_t)> Handler;class TcpProcessServer
{
public:TcpProcessServer(int port, const std::string &ip = ""): _port(port), _ip(ip){_sock.Socket();signal(SIGCHLD, SIG_IGN);}~TcpProcessServer() {}public:bool Start(Handler handler){// 绑定IP和端口号CHECK_RET(_sock.Bind(_ip, _port));// 监听CHECK_RET(_sock.Listen(5));// 进入循环while (true){// 进行acceptTcpSocket peer_sock;std::string ip;uint16_t port = 0;if (!_sock.Accept(&peer_sock, &ip, &port)){continue;}printf("[client %s:%d] connect!\n", ip.c_str(), port);// 执行任务// 多进程 v1.0pid_t id = fork();if (id > 0){// 父进程,不需要做什么peer_sock.Close(); // 父进程中需要关闭}else if (id == 0){// 子进程// 子进程的 socket 的关闭在析构函数中进行Task task(peer_sock, ip, port, handler);task();// 处理任务结束,退出子进程exit(0);}else{// fork失败std::cerr << "fork error!" << std::endl;return false;}}return true;}private:// tcp socket对象TcpSocket _sock;// 端口号uint16_t _port;// ip地址std::string _ip;
};

结果:

此时服务器便可以为多个客户端同时进行服务。

七、线程池版服务器

实现线程池:

#pragma once#include <iostream>
#include <queue>
#include <cassert>
#include <pthread.h>const uint32_t gDefaultThreadNum = 5; // 默认线程池中线程数量template <class T>
class ThreadPool
{
public:ThreadPool(uint32_t threadNum = gDefaultThreadNum): _isStart(false), _ThreadNum(threadNum){pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}~ThreadPool(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}public:// 启动线程池void start(){// 判断线程池是否启动assert(!_isStart); // 如果已经启动则失败for (int i = 0; i < _ThreadNum; ++i){pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, this);}// 线程池已经启动_isStart = true;}// 放入任务void push(const T &in){lockQueue();_taskQueue.push(in);handleTask();unlockQueue();}// 消费任务T pop(){T task = _taskQueue.front();_taskQueue.pop();return task;}private:static void *threadRoutine(void *args){ThreadPool<T> *ptp = static_cast<ThreadPool<T> *>(args);while (true){ptp->lockQueue();// 判断当前任务队列中有没有任务while (!ptp->hasTask()){// 没有任务,进行循环等待ptp->waitTask();}// 当前线程获取任务T t = ptp->pop();ptp->unlockQueue();// 当前线程处理任务t();}}void lockQueue(){pthread_mutex_lock(&_mutex);}void unlockQueue(){pthread_mutex_unlock(&_mutex);}void waitTask(){pthread_cond_wait(&_cond, &_mutex);}void handleTask(){pthread_cond_signal(&_cond);}bool hasTask(){return !_taskQueue.empty();}private:bool _isStart;            // 判断线程池是否开启uint32_t _ThreadNum;      // 线程池中的线程数量std::queue<T> _taskQueue; // 任务队列pthread_mutex_t _mutex;   // 保护任务队列的锁pthread_cond_t _cond;     // 线程池的条件变量
};

线程池版服务器:

#pragma once#include "TcpSocket.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"typedef std::function<void(TcpSocket, const std::string &, uint16_t)> Handler;class TcpThreadPoolServer
{
public:TcpThreadPoolServer(int port, const std::string &ip = ""): _port(port), _ip(ip){_sock.Socket();_tp.start();}~TcpThreadPoolServer() {}public:bool Start(Handler handler){// 绑定IP和端口号CHECK_RET(_sock.Bind(_ip, _port));// 监听CHECK_RET(_sock.Listen(5));// 进入循环while (true){// 进行acceptTcpSocket peer_sock;std::string ip;uint16_t port = 0;if (!_sock.Accept(&peer_sock, &ip, &port)){continue;}printf("[client %s:%d] connect!\n", ip.c_str(), port);// 执行任务// TODOTask task(peer_sock, ip, port, handler);_tp.push(task);}}private:// tcp socket对象TcpSocket _sock;// 端口号uint16_t _port;// ip地址std::string _ip;// 线程池ThreadPool<Task> _tp;
};

结果


http://www.ppmy.cn/news/24105.html

相关文章

[数据库]表的增删改查

●&#x1f9d1;个人主页:你帅你先说. ●&#x1f4c3;欢迎点赞&#x1f44d;关注&#x1f4a1;收藏&#x1f496; ●&#x1f4d6;既选择了远方&#xff0c;便只顾风雨兼程。 ●&#x1f91f;欢迎大家有问题随时私信我&#xff01; ●&#x1f9d0;版权&#xff1a;本文由[你帅…

【c语言】二叉树

主页&#xff1a;114514的代码大冒险 qq:2188956112&#xff08;欢迎小伙伴呀hi✿(。◕ᴗ◕。)✿ &#xff09; Gitee&#xff1a;庄嘉豪 (zhuang-jiahaoxxx) - Gitee.com 引入 我们之前已经学过线性数据结构&#xff0c;今天我们将介绍非线性数据结构----树 树是一种非线性的…

冒泡排序详解

冒泡排序是初学C语言的噩梦&#xff0c;也是数据结构中排序的重要组成部分&#xff0c;本章内容我们一起探讨冒泡排序&#xff0c;从理论到代码实现&#xff0c;一步步深入了解冒泡排序。排序算法作为较简单的算法。它重复地走访过要排序的数列&#xff0c;一次比较两个元素&am…

libVLC 视频裁剪

作者: 一去、二三里 个人微信号: iwaleon 微信公众号: 高效程序员 裁剪是指去除图像的外部部分,也就是从图像的左,右,顶部和/或底部移除一些东西。通常在视频中,裁剪是一种通过剪切不需要的部分来改变宽高比的特殊方式。 尤其是在做视频墙时,往往需要处理多个 vlc 实例…

WordPress网站日主题Ri主题RiProV2主题开启了验证码登录但是验证码配置不对结果退出登录后进不去管理端了

背景 WordPress网站日主题Ri主题RiProV2主题开启了验证码登录但是验证码配置不对结果退出登录后进不去管理端了;开启了腾讯云验证码防火墙但APPID,APPSecret没配置,结果在退出登录后,由于验证码验证失败管理端进不去了 提示如下:

(C语言)程序环境和预处理

问&#xff1a;1. 什么是C语言的源代码&#xff1f;2. 由于计算机只认识什么&#xff1f;因此它只能接收与执行什么&#xff1f;也就是什么&#xff1f;3. 在ANSI C的任何一种实现中&#xff0c;存在哪两个不同的环境&#xff1f;在这两种环境里面分别干什么事情&#xff1f;4.…

Linux 安装jenkins和jdk11

Linux 安装jenkins和jdk111. Install Jdk112. Jenkins Install2.1 Install Jenkins2.2 Start2.3 Error3.Awakening1.1 Big Data -- Postgres4. Awakening1. Install Jdk11 安装jdk11 sudo yum install fontconfig java-11-openjdk 2. Jenkins Install 2.1 Install Jenkins 下…

常见的内存操作函数

&#x1f466;个人主页&#xff1a;Weraphael ✍&#x1f3fb;作者简介&#xff1a;目前是C语言学习者 ✈️专栏&#xff1a;C语言航路 &#x1f40b; 希望大家多多支持&#xff0c;咱一起进步&#xff01;&#x1f601; 如果文章对你有帮助的话 欢迎 评论&#x1f4ac; 点赞&a…