Linux——多路复用之poll

news2024/9/19 6:39:05

目录

前言

一、poll的认识

二、poll的接口

三、poll的使用


前言

前面我们学习了多路复用的select,知道多路复用的原理与select的使用方法,但是select也有许多缺点,导致他的效率不算高。今天我们来学习poll的使用,看看poll较于select的优势。

一、poll的认识

poll与select一样,只负责IO的等的过程,只不过一次可以等待多个文件描述符,他的作用是让read和write不再阻塞。 

  1. 是用来监视多个文件描述符的状态变化的
  2. 程序会停在poll这里等待,直到被监视的文件描述符有一个或多个发生了状态改变

二、poll的接口

poll的接口如下,比select要轻量化很多,只有三个参数

参数1:struct pollfd *fds,pollfd数组首元素地址,

             pollfd是操作系统给我们提供的结构体,主要成员如下

             fd:文件描述符

             events:用户告诉内核,需要关心的fd,上面的事件

             revents:poll返回,内核告诉用户,关心的fd,那些事件就绪

参数2:nfds_t nfds,数组元素个数

参数3:int timeout,毫秒级的等待时间

             timeout > 0 等待timeout毫秒或者有fd就绪再返回。

             timeout == 0 非阻塞轮询。

             timeout == -1 阻塞等待,直到有fd就绪。

返回值:

  1. ret  >  0 :poll等待的多个fd中,已经就需要的fd个数
  2. ret == 0 :poll超时返回
  3. ret  <  0 :poll出错

poll的事件如下,这些值是bit位,可以通过  |(或运算)  的方式写入到events中,我们着重学习POLLIN和POLLOUT,

我们来思考一下这样设计的好处

  1. poll的调用将输入和输出分离,这样就不用一直设置参数。 
  2. 只要系统资源足够,就能一直创建pollfd,解决了等待fd的上限问题。
  3. 不用再自己组织结构,将fd放入其中,现在维护好这个pollfd的结构体数组即可。
  4. 参数变少了,通过或运算就可以添加自己关心的事件
  5. 时间参数timeout使用也很简单。

三、poll的使用

Log.hpp

#pragma once

#include <iostream>
#include <cstdarg>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <pthread.h>
using namespace std;

enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

enum
{
    Screen = 10,
    OneFile,
    ClassFile
};

string LevelToString(int level)
{
    switch (level)
    {
    case Debug:
        return "Debug";
    case Info:
        return "Info";
    case Warning:
        return "Warning";
    case Error:
        return "Error";
    case Fatal:
        return "Fatal";

    default:
        return "Unknown";
    }
}

const int default_style = Screen;
const string default_filename = "Log.";
const string logdir = "log";

class Log
{
public:
    Log(int style = default_style, string filename = default_filename)
        : _style(style), _filename(filename)
    {
        if (_style != Screen)
            mkdir(logdir.c_str(), 0775);
    }

    // 更改打印方式
    void Enable(int style)
    {
        _style = style;
        if (_style != Screen)
            mkdir(logdir.c_str(), 0775);
    }

    // 时间戳转化为年月日时分秒
    string GetTime()
    {
        time_t currtime = time(nullptr);
        struct tm *curr = localtime(&currtime);
        char time_buffer[128];
        snprintf(time_buffer, sizeof(time_buffer), "%d-%d-%d %d:%d:%d",
                 curr->tm_year + 1900, curr->tm_mon + 1, curr->tm_mday, curr->tm_hour, curr->tm_min, curr->tm_sec);
        return time_buffer;
    }

    // 写入到文件中
    void WriteLogToOneFile(const string &logname, const string &message)
    {
        FILE *fp = fopen(logname.c_str(), "a");
        if (fp == nullptr)
        {
            perror("fopen failed");
            exit(-1);
        }
        fprintf(fp, "%s\n", message.c_str());

        fclose(fp);
    }

    // 打印日志
    void WriteLogToClassFile(const string &levelstr, const string &message)
    {
        string logname = logdir;
        logname += "/";
        logname += _filename;
        logname += levelstr;
        WriteLogToOneFile(logname, message);
    }

    pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
    void WriteLog(const string &levelstr, const string &message)
    {
        pthread_mutex_lock(&lock);
        switch (_style)
        {
        case Screen:
            cout << message << endl; // 打印到屏幕中
            break;
        case OneFile:
            WriteLogToClassFile("all", message); // 给定all,直接写到all里
            break;
        case ClassFile:
            WriteLogToClassFile(levelstr, message); // 写入levelstr里
            break;
        default:
            break;
        }
        pthread_mutex_unlock(&lock);
    }

    // 提供接口给运算符重载使用
    void _LogMessage(int level, const char *file, int line, char *rightbuffer)
    {
        char leftbuffer[1024];
        string levelstr = LevelToString(level);
        string currtime = GetTime();
        string  idstr = to_string(getpid());

        snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%s][%s][%s:%d]", levelstr.c_str(), currtime.c_str(), idstr.c_str(), file, line);

        string messages = leftbuffer;
        messages += rightbuffer;
        WriteLog(levelstr, messages);
    }

    // 运算符重载
    void operator()(int level, const char *file, int line, const char *format, ...)
    {
        char rightbuffer[1024];
        va_list args;                                              // va_list 是指针
        va_start(args, format);                                    // 初始化va_list对象,format是最后一个确定的参数
        vsnprintf(rightbuffer, sizeof(rightbuffer), format, args); // 写入到rightbuffer中
        va_end(args);
        _LogMessage(level, file, line, rightbuffer);
    }

    ~Log()
    {
    }

private:
    int _style;
    string _filename;
};

Log lg;

class Conf
{
public:
    Conf()
    {
        lg.Enable(Screen);
    }
    ~Conf()
    {
    }
};

Conf conf;

// 辅助宏
#define lg(level, format, ...) lg(level, __FILE__, __LINE__, format, ##__VA_ARGS__)

Socket.hpp 

#pragma once

#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
using namespace std;
namespace Net_Work
{
    static const int default_backlog = 5;
    static const int default_sockfd = -1;
    using namespace std;

    enum
    {
        SocketError = 1,
        BindError,
        ListenError,
        ConnectError,
    };

    // 封装套接字接口基类
    class Socket
    {
    public:
        // 封装了socket相关方法
        virtual ~Socket() {}
        virtual void CreateSocket() = 0;
        virtual void BindSocket(uint16_t port) = 0;
        virtual void ListenSocket(int backlog) = 0;
        virtual bool ConnectSocket(string &serverip, uint16_t serverport) = 0;
        virtual int AcceptSocket(string *peerip, uint16_t *peerport) = 0;
        virtual int GetSockFd() = 0;
        virtual void SetSockFd(int sockfd) = 0;
        virtual void CloseSocket() = 0;
        virtual bool Recv(string *buff, int size) = 0;
        virtual void Send(string &send_string) = 0;

        // 方法的集中在一起使用
    public:
        void BuildListenSocket(uint16_t port, int backlog = default_backlog)
        {
            CreateSocket();
            BindSocket(port);
            ListenSocket(backlog);
        }

        bool BuildConnectSocket(string &serverip, uint16_t serverport)
        {
            CreateSocket();
            return ConnectSocket(serverip, serverport);
        }

        void BuildNormalSocket(int sockfd)
        {
            SetSockFd(sockfd);
        }
    };

    class TcpSocket : public Socket
    {
    public:
        TcpSocket(int sockfd = default_sockfd)
            : _sockfd(sockfd)
        {
        }
        ~TcpSocket() {}

        void CreateSocket() override
        {
            _sockfd = socket(AF_INET, SOCK_STREAM, 0);
            if (_sockfd < 0)
                exit(SocketError);
        }
        void BindSocket(uint16_t port) override
        {
            int opt = 1;
            setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

            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;

            int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
            if (n < 0)
                exit(BindError);
        }
        void ListenSocket(int backlog) override
        {
            int n = listen(_sockfd, backlog);
            if (n < 0)
                exit(ListenError);
        }
        bool ConnectSocket(string &serverip, uint16_t serverport) override
        {
            struct sockaddr_in addr;
            memset(&addr, 0, sizeof(addr));
            addr.sin_family = AF_INET;
            addr.sin_port = htons(serverport);
            // addr.sin_addr.s_addr = inet_addr(serverip.c_str());
            inet_pton(AF_INET, serverip.c_str(), &addr.sin_addr);
            int n = connect(_sockfd, (sockaddr *)&addr, sizeof(addr));

            if (n == 0)
                return true;
            return false;
        }
        int AcceptSocket(string *peerip, uint16_t *peerport) override
        {
            struct sockaddr_in addr;
            socklen_t len = sizeof(addr);
            int newsockfd = accept(_sockfd, (sockaddr *)&addr, &len);
            if (newsockfd < 0)
                return -1;

            // *peerip = inet_ntoa(addr.sin_addr);

            // INET_ADDRSTRLEN 是一个定义在头文件中的宏,表示 IPv4 地址的最大长度
            char ip_str[INET_ADDRSTRLEN];
            inet_ntop(AF_INET, &addr.sin_addr, ip_str, INET_ADDRSTRLEN);
            *peerip = ip_str;

            *peerport = ntohs(addr.sin_port);
            return newsockfd;
        }
        int GetSockFd() override
        {
            return _sockfd;
        }
        void SetSockFd(int sockfd) override
        {
            _sockfd = sockfd;
        }
        void CloseSocket() override
        {
            if (_sockfd > default_sockfd)
                close(_sockfd);
        }

        bool Recv(string *buff, int size) override
        {
            char inbuffer[size];
            ssize_t n = recv(_sockfd, inbuffer, size - 1, 0);
            if (n > 0)
            {
                inbuffer[n] = 0;
                *buff += inbuffer;
                return true;
            }
            else
                return false;
        }

        void Send(string &send_string) override
        {
            send(_sockfd, send_string.c_str(),send_string.size(),0);
        }

    private:
        int _sockfd;
        string _ip;
        uint16_t _port;
    };
}

 PollServer.hpp

#pragma once
#include <iostream>
#include <string>
#include <poll.h>
#include <memory>
#include "Log.hpp"
#include "Socket.hpp"

using namespace Net_Work;
const static int gdefaultport = 8888;
const static int gbacklog = 8;
const static int gnum = 1024;
class PollServer
{
public:
    PollServer(int port) : _port(port), _num(gnum), _listensock(new TcpSocket())
    {
    }
    void HandlerEvent()
    {
        for (int i = 0; i < _num; i++)
        {
            if (_rfds[i].fd == -1)
                continue;

            int fd = _rfds[i].fd;
            short revents = _rfds[i].revents;
            // 判断事件是否就绪
            if (revents & POLLIN)
            {
                // 读事件分两类,一类是新链接到来,一类是新数据到来
                if (fd == _listensock->GetSockFd())
                {
                    // 新链接到来
                    lg(Info, "get a new link");
                    // 获取连接
                    std::string clientip;
                    uint16_t clientport;
                    int sockfd = _listensock->AcceptSocket(&clientip, &clientport);
                    if (sockfd == -1)
                    {
                        lg(Error, "accept error");
                        continue;
                    }
                    lg(Info, "get a client,client info is# %s:%d,fd: %d", clientip.c_str(), clientport, sockfd);
                    // 此时获取连接成功了,但是不能直接read write,sockfd仍需要交给poll托管 -- 添加到数组_rfds中
                    int pos = 0;
                    for (; pos < _num; pos++)
                    {
                        if (_rfds[pos].fd == -1)
                        {
                            _rfds[pos].fd = sockfd;
                            _rfds[pos].events = POLLIN;
                            lg(Info, "get a new link, fd is : %d", sockfd);
                            break;
                        }
                    }
                    if (pos == _num)
                    {
                        // 1.扩容
                        // 2.关闭
                        close(sockfd);
                        lg(Warning, "server is full, be carefull...");
                    }
                }
                else
                {
                    // 普通的读事件就绪
                    char buffer[1024];
                    ssize_t n = recv(fd, buffer, sizeof(buffer-1), 0);
                    if (n > 0)
                    {
                        buffer[n] = 0;
                        lg(Info, "client say# %s", buffer);
                        std::string message = "你好,同志";
                        message += buffer;
                        send(fd, message.c_str(), message.size(), 0);
                    }
                    else
                    {
                        lg(Warning, "client quit ,maybe close or error,close fd: %d", fd);
                        close(fd);
                        // 还要取消poll的关心
                        _rfds[i].fd = -1;
                        _rfds[i].events = 0;
                        _rfds[i].revents = 0;
                    }
                }
            }
        }
    }
    void InitServer()
    {
        _listensock->BuildListenSocket(_port, gbacklog);
        _rfds = new struct pollfd[_num];
        for (int i = 0; i < _num; i++)
        {
            _rfds[i].fd = -1;
            _rfds[i].events = 0;
            _rfds[i].revents = 0;
        }
        // 最开始的时候,只有一个文件描述符,Listensock
        _rfds[0].fd = _listensock->GetSockFd();
        _rfds[0].events |= POLLIN;
    }

    void Loop()
    {
        _isrunning = true;
        // 循环重置select需要的rfds
        while (_isrunning)
        {
            // 定义时间
            int timeout = 1000;

            //PrintDebug();

            // rfds是输入输出型参数,rfds是在select调用返回时,不断被修改,所以每次需要重置rfds
            int n = poll(_rfds, _num, timeout);
            switch (n)
            {
            case 0:
                lg(Info, "select timeout...");
                break;
            case -1:
                lg(Error, "select error!!!");
            default:
                // 正常就绪的fd
                lg(Info, "select success,begin event handler");
                HandlerEvent();
                break;
            }
        }
        _isrunning = false;
    }

    void Stop()
    {
        _isrunning = false;
    }

    void PrintDebug()
    {
        // std::cout << "current select rfds list is :";
        // for (int i = 0; i < num; i++)
        // {
        //     if (_rfds_array[i] == nullptr)
        //         continue;
        //     else
        //         std::cout << _rfds_array[i]->GetSockFd() << " ";
        // }
        // std::cout << std::endl;
    }

private:
    std::unique_ptr<Socket> _listensock;
    int _port;
    bool _isrunning;

    struct pollfd *_rfds;
    int _num;
};

 Main.cc

#include <iostream>
#include <memory>
#include "PollServer.hpp"

void Usage(char* argv)
{
    
    std::cout<<"Usage: \n\t"<<argv<<" port\n"<<std::endl;
}
// ./select_server 8080
int main(int argc,char* argv[])
{
    // std::cout<<num<<std::endl;       1024
    if(argc!=2)
    {
        Usage(argv[0]);
        return -1;
    }
    uint16_t localport = std::stoi(argv[1]);
    std::unique_ptr<PollServer> svr = std::make_unique<PollServer>(localport);
    svr->InitServer();
    svr->Loop();

    return 0;
}

运行结果如下,由于我们poll第三个参数设置的是1000ms,因此每一秒poll都会返回,当发现有新链接的时候,就回去执行函数,在函数中调用write或者read变不会再阻塞了。 

四、poll的优缺点

优点

  1. 可以等待多个fd,效率高
  2. 输入输出函数分离,events和revents,不用再频繁对poll参数进行重置了
  3. poll关心的fd没有上线

缺点

  1. 用户到内核空间,要有数据拷贝                ——必要开销
  2. poll应用层,仍需要遍历(遍历查看哪个fd中哪个事件就绪,新链接需要交给poll,也需要遍历找到没有fd占用的地方)
  3. 在内核层面,OS也要遍历检测关心的fd是否有对应的事件就绪(在poll调用时候发生)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1936253.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

利用AI与数据分析优化招聘决策

一、引言 在竞争激烈的职场环境中&#xff0c;招聘是组织获取人才、实现战略目标的关键环节。然而&#xff0c;传统的招聘方式往往依赖人力资源部门的主观经验和直觉&#xff0c;难以准确预测招聘效果&#xff0c;评估招聘渠道的效率。随着人工智能&#xff08;AI&#xff09;…

Windows右键新建Markdown文件类型配置 | Typora | VSCode

&#x1f64b;大家好&#xff01;我是毛毛张! &#x1f308;个人首页&#xff1a; 神马都会亿点点的毛毛张 今天毛毛张分享的是如何在右键的新建菜单中添加新建MarkdownFile文件&#xff0c;这是毛毛张分享的关于Typora软件的相关知识的第三期 文章目录 1.前言&#x1f3dd;…

Android车载MCU控制音量和ARM控制音量的区别和优缺点—TEF6686 FM/AM芯片

不要嫌前进的慢&#xff0c;只要一直在前进就好 文章目录 前言一、系统架构图1.MCU控制音量的架构图&#xff08;老方法&#xff09;2.ARM控制音量的架构图&#xff08;新方法&#xff09; 二、为啥控制音量不是用AudioManager而是执着去直接控制TDA7729&#xff1f;三、MCU控制…

[数据集][目标检测]婴儿车检测数据集VOC+YOLO格式1073张5类别

数据集格式&#xff1a;Pascal VOC格式YOLO格式(不包含分割路径的txt文件&#xff0c;仅仅包含jpg图片以及对应的VOC格式xml文件和yolo格式txt文件) 图片数量(jpg文件个数)&#xff1a;1073 标注数量(xml文件个数)&#xff1a;1073 标注数量(txt文件个数)&#xff1a;1073 标注…

Matlab基础语法篇(下)

Matlab基础语法&#xff08;下&#xff09; 一、逻辑基础&#xff08;一&#xff09;逻辑运算符&#xff08;二&#xff09;all、any、find函数&#xff08;三&#xff09;练习 二、结构基础&#xff08;一&#xff09;条件结构&#xff08;1&#xff09;if-elseif-else-end&am…

通过albumentation对目标检测进行数据增强(简单直接)

albumentation官方文档看不懂&#xff1f;xml文件不知道如何操作&#xff1f;下面只需要修改部分代码即可上手使用 要使用这个方法之前需要按照albumentation这个库还有一些辅助库,自己看着来安装就行 pip install albumentation pip install opencv-python pip install json…

<数据集>蛋壳裂缝检测数据集<目标检测>

数据集格式&#xff1a;VOCYOLO格式 图片数量&#xff1a;2520张 标注数量(xml文件个数)&#xff1a;2520 标注数量(txt文件个数)&#xff1a;2520 标注类别数&#xff1a;2 标注类别名称&#xff1a;[crack, egg] 序号类别名称图片数框数1crack245128352egg25142514 使…

揭秘饲料制粒机:生产颗粒料加工的利器

随着现代畜牧业的发展&#xff0c;饲料的质量和加工效率成为了养殖业者关注的焦点。在这个背景下&#xff0c;饲料制粒机——这一饲料加工设备的核心&#xff0c;凭借其稳定、环保的特点&#xff0c;逐渐加入养殖行业中。 一、饲料制粒机的工作原理 饲料制粒机主要通过挤压、切…

msyql (8.4,9.0) caching_sha2_password 转换 mysql_native_password用户认证

mysql 前言 caching_sha2_password 主要特性 用于增强用户账户密码的存储和验证安全性。这种插件利用 SHA-256 散列算法的变体来存储和验证密码 安全的密码散列&#xff1a; caching_sha2_password 使用基于 SHA-256 的算法来生成密码的散列值。这意味着即使数据库被未授权访…

【JS特效之手风琴效果】基于jquery实现手风琴网页特效(附源码)

HTMLCSSJS手风琴效果目录 &#x1f354;涉及知识&#x1f964;写在前面&#x1f367;一、网页主题&#x1f333;二、网页效果&#x1f40b;三、网页架构与技术3.1 脑海构思3.2 实现原理 &#x1f308;四、网页源码4.1 手风琴模块4.2 完整源码获取方式 &#x1f305; 作者寄语 &…

机械学习—零基础学习日志(高数05——函数概念与特性)

零基础为了学人工智能&#xff0c;真的开始复习高数 本小节讲解隐函数&#xff0c;有点神奇&#xff0c;我竟然完全没有隐函数记忆了。 隐函数 隐函数&#xff0c;我个人通俗理解就是&#xff0c;在复杂的环境里&#xff0c;发现纯净天地。例如&#xff0c;在外太空的某个大陆…

《JavaSE》---14.<面向对象系列之(附:this和super关键字)>

目录 系列文章目录 前言 一、为什么要有this引用 1. 用代码看有this与无this的区别 1.1 代码示例 1.2 输出结果&#xff1a; 1.3 代码示例&#xff1a; 1.4 输出结果&#xff1a; 2. this深度理解 3. 什么是this引用 3.1 this引用的概念 4. this引用的特性 二、th…

【TAROT学习日记】韦特体系塔罗牌学习(5)——皇帝 THE EMPEROR IV

韦特体系塔罗牌学习&#xff08;5&#xff09;——皇帝 THE EMPEROR IV 目录 韦特体系塔罗牌学习&#xff08;5&#xff09;——皇帝 THE EMPEROR IV牌面分析1. 基础信息2. 图片元素 正位牌意1. 关键词/句2.爱情婚姻3. 学业事业4. 人际财富5. 其他象征意 逆位牌意1. 关键词/句2…

大盘周期性复苏之际,英诺赛科的新叙事如何开讲?

半导体行业已经开始周期性复苏。 据同花顺iFinD统计&#xff0c;截至目前&#xff0c;已有37只半导体个股披露了上半年业绩预告&#xff0c;其中27股预喜&#xff0c;或扭亏或预增&#xff0c;预喜率超七成&#xff0c;长川科技净利预增幅度位居第一&#xff0c;暂列上半年半导…

Cadence OrCAD Capture原理图搭建及仿真方法

1 安装Cadence Allegro 略。 2 搭建原理图 2.1 打开软件 1、点击Capture CIS。 2、在弹出的产品选择栏点击OrCAD Capture&#xff0c;点击OK。 打开的软件界面如下&#xff1a; 2.2 新建工程 1、点击左上角File&#xff0c;New&#xff0c;Project&#xff0c;或者直接点…

王权与自由Steam国际服下载教程分享

王权与自由&#xff0c;一款集合了所有mmorpg游戏内玩法的游戏&#xff0c;包含爬塔、小队副本、团队副本、休闲竞技、装备打造、人物养成、攻城战、大型的pvp团战等多种游戏玩法&#xff0c;在游戏中只有我们想不到的内容&#xff0c;没有游戏不包含的内容&#xff0c;而且有意…

【Git远程操作】克隆远程仓库 https协议 | ssh协议

目录 前言 克隆远程仓库https协议 克隆远程仓库ssh协议 前言 这四个都是Git给我们提供的数据传输的协议&#xff0c;最常使用的还是https和ssh协议。本篇主要介绍还是这两种协议。 ssh协议&#xff1a;使用的公钥加密和公钥登录的机制&#xff08;体现的是实用性和安全性&am…

AIGC工具:IPAdapter和ControlNet 指导控制生成工具

ControlNet强调对生成过程的直接控制,如通过线条、边缘、形状等信息;而IPAdapter侧重于风格迁移和内容的间接引导。 IPAdapter 它专注于通过迁移图片风格来生成新的图像内容。IPAdapter的强项在于能够将一张图片的风格迁移到另一张图片上,实现风格融合,甚至可以进行多图风格…

【Android】ConstrainLayout约束布局基本操作

文章目录 介绍约束条件添加方式外边距设置删除方式 添加约束条件父级位置对齐方式基线对齐引导线约束屏障约束 约束偏差使用链控制线性组 介绍 约束布局ConstraintLayout是 Android Studio 2.3 起创建布局后的默认布局 主要是为了解决布局多层嵌套问题&#xff0c;以灵活的方式…

【你也能从零基础学会网站开发】 SQL Server 2000企业管理器基本介绍

&#x1f680; 个人主页 极客小俊 ✍&#x1f3fb; 作者简介&#xff1a;程序猿、设计师、技术分享 &#x1f40b; 希望大家多多支持, 我们一起学习和进步&#xff01; &#x1f3c5; 欢迎评论 ❤️点赞&#x1f4ac;评论 &#x1f4c2;收藏 &#x1f4c2;加关注 SQL Server 20…