c++实战篇(三) ——对socket通讯服务端与客户端的封装

前言

在前面几篇文章中我们介绍过一些有关于网络编程的相关知识,我们介绍了在tcp传输数据的时候发送缓冲区与接收缓冲区,我们介绍了在tcp发送与接收的过程中可能出现的分包与粘包的问题:
c++理论篇(一) ——浅谈tcp缓存与tcp的分包与粘包
我们介绍了在网络编程如何利用IO多路复用来实现服务端对大量客户端进行通讯:
c++高级篇(二) ——Linux下IO多路复用之select模型
c++高级篇(三) ——Linux下IO多路复用之poll模型
但是说了那么多我们好像还是不知道客户端和服务端之间的连接究竟是一个怎样的过程,而这就是我们今天的主题,通过对Tcp通讯中客户端与服务端的连接来探究一些网络编程的细节。

客户端类的编写

客户端通讯的过程

客户端连接的过程其实很好理解,主要就是以下几步:

  • 创建客户端socket
  • 基于客户端socket和服务端ip以及服务端开放的通讯端口与服务端建立连接
  • 读取/发送数据
  • 关闭socket,断开连接
    而我们的对客户端类的编写,也是基于上面的几步过程来展开的。

客户端的私有成员

在上面我们提到了客户端连接服务端所需的一些信息,例如客户端socket,服务端ip以及服务端开放的通讯端口(云服务器开放通讯端口需要设置安全组),所以我们可以这样定义客户端类:

private:
    int m_socket; // 客户端的socket
    unsigned int server_port;// 服务端的端口
    string server_ip; //服务端的ip

客户端的公共函数

我们上面说过客户端连接服务端以及相关工作的大致流程,所以在定义客户端类的函数时,大概是以下类型的函数:

public:
        ctcpclient(){m_socket=-1;}
        bool Connect(const unsigned int port,const string& ip);  //客户端连接服务端
        bool Read(string& buff,const int itimeout=0);  //接收文本数据
        bool Read(void* buff,const int bufflen,const int itimeout=0); //接收二进制数据
        bool Write(const string& buff); //发送文本数据
        bool Write(const void* buff,const int bufflen); //发送二进制数据
        void Close(); //关闭连接
        ~ctcpclient(){Close();}

这里的构造函数与析构函数无需多言,接下来我们主要对相关的工作函数进行探究。

Connect(客户端连接)函数

在讲解Connect函数之前我们先来看一下它的具体执行的逻辑:

  bool ctcpclient::Connect(const unsigned int port, const string &ip)
    {
        if (m_socket != -1)
        {
            Close();
        }
        // 忽略SIGPIPE信号,防止程序异常退出。
        // 如果send到一个disconnected socket上,内核就会发出SIGPIPE信号。这个信号
        // 的缺省处理方法是终止进程,大多数时候这都不是我们期望的。我们重新定义这
        // 个信号的处理方法,大多数情况是直接屏蔽它。
        signal(SIGPIPE, SIG_IGN);

        server_port = port;
        server_ip = ip;
        m_socket = socket(AF_INET, SOCK_STREAM, 0);
        if (m_socket < 0)
        {
            return false;
        }
        struct sockaddr_in server_addr;
        struct hostent *h;
        memset(&server_addr, 0, sizeof(server_addr));
        if ((h = gethostbyname(ip.c_str())) == NULL)
        {
            Close();
            return false;
        }
        server_addr.sin_family = AF_INET;//指定通讯协议
        server_addr.sin_port = htons(server_port); //指定通讯端口
        memset(h, 0, sizeof(h));
        memcpy(h->h_addr, &server_ip[0], server_ip.length());  //指定通讯IP地址
        if (connect(m_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) != 0)
        {
            return false;
        }
        return true;
    }

上述主要经过了一下几个步骤:

  • 检查socket,查看当前客户端是否处于未连接状态
  • 设置相关信号的处理方式,防止异常情况的出现
  • 初始化客户端socket
  • 定义server_addr struct hostent *h结构体配置相关信息
  • 与客户端建立连接

Read(接收)函数

Read函数在这里所起到的作用主要是接收数据的作用,接下来我们将从接收数据的不同作为开始来探究其中的细节。

接收文本数据

在对相关函数的执行逻辑与细节进行讲解之前,我们先来看一下相关的函数签名与函数实现:

 bool Read(string& buff,const int itimeout=0);  //接收文本数据
 bool tcpread(const int sockfd,string &buffer,const int itimeout=0); // 读取文本数据
 bool readn(const int sockfd, char *buffer, const size_t n);
  bool ctcpclient::Read(string &buff, const int itimeout)
    {
        if (m_socket < 0)
        {
            return false;
        }
        return (tcpread(m_socket, buff, itimeout));
    }


    bool tcpread(const int sock, string &buff, const int itimeout)
    {
        if (sock < 0)
        {
            return false;
        }
        if (itimeout > 0)
        {
            struct pollfd fds;
            fds.fd = sock;
            fds.events = POLLIN;
            int ret = poll(&fds, 1, itimeout * 1000);
            if (ret < 0)
            {
                return false;
            }
            if (ret == 0)
            {
                return false;
            }
        }
        if (itimeout < -1)
        {
            struct pollfd fds;
            fds.fd = sock;
            fds.events = POLLIN;
            int ret = poll(&fds, 1, 0);
            if (ret < 0)
            {
                return false;
            }
            if (ret == 0)
            {
                return false;
            }
        }
        int bufflen = 0;
        if (readn(sock, (char *)&bufflen, 4) == false) // 读取报文长度
        {
            return false;
        }
        buff.resize(bufflen);
        if (readn(sock, &buff[0], bufflen) == false) // 读取报文内容
        {
            return false;
        }
        return true;
    }

 bool readn(const int sockfd, char *buffer, const size_t n)
    {
        int nleft = n; // 剩余需要读取的字节数。
        int idx = 0;   // 已成功读取的字节数。
        int nread;     // 每次调用recv()函数读到的字节数。

        while (nleft > 0)
        {
            if ((nread = recv(sockfd, buffer + idx, nleft, 0)) <= 0)
                return false;

            idx = idx + nread;
            nleft = nleft - nread;
        }

        return true;
    }

我们可以看到上面有关于数据接收的函数一共有三个,这里主要是客户端与服务端接收/发送数据的方式基本一致,所以我们选择对相关函数进行封装避免多次书写重复函数使代码编的臃肿,下面来给大家解释主要函数的作用:

  • tcpread
    我们知道端到端的通讯其实不是每次都是立即进行的,所以接收数据的一方有时候要等待发送数据的一方将数据发送过来,而这里我们基于poll实现了一个超时机制,让我们可以手动设置接收数据方是否等待以及等待的最大时长
  • readn
    这个主要是实现对数据的读写,相对于直接调用recv函数,每次从socket读取指定数量的字节,即使recv函数不能一次读取所有字节。通过在循环中跟踪剩余需要读取的字节数,可以确保读取完整的数据,进而避免因为recv函数每次读取的字节数不固定而导致的数据读取不完整或错误。

二进制数据

二进制数数接收与文本数据的接收又有所不同,我们来看一下它的函数签名与具体逻辑:

  • 函数签名
bool Read(void* buff,const int bufflen,const int itimeout=0); //接收二进制数据
bool tcpread(const int sockfd, void *buffer, const int ibuflen, const int itimeout = 0);//接收二进制数据
bool readn(const int sockfd, char *buffer, const size_t n);
  • 函数逻辑
    bool ctcpclient::Read(void *buff, const int bufflen, const int itimeout)
    {
        if (m_socket < 0)
        {
            return false;
        }
        return (tcpread(m_socket, buff, bufflen, itimeout));
    }


 bool tcpread(int sock, void *buff, const int bufflen, const int itimeout)
    {
        if (sock < 0)
        {
            return false;
        }
        if (itimeout > 0)
        {
            struct pollfd fds;
            fds.fd = sock;
            fds.events = POLLIN;
            int ret = poll(&fds, 1, itimeout * 1000);
            if (ret <= 0)
            {
                return false;
            }
        }
        if (itimeout < -1)
        {
            struct pollfd fds;
            fds.fd = sock;
            fds.events = POLLIN;
            int ret = poll(&fds, 1, 0);
            if (ret <= 0)
            {
                return false;
            }
        }
        if (readn(sock, (char *)buff, bufflen) == false) // 读取报文内容
        {
            return false;
        }
        return true;
    }

	bool readn(const int sockfd, char *buffer, const size_t n)
    {
        int nleft = n; // 剩余需要读取的字节数。
        int idx = 0;   // 已成功读取的字节数。
        int nread;     // 每次调用recv()函数读到的字节数。

        while (nleft > 0)
        {
            if ((nread = recv(sockfd, buffer + idx, nleft, 0)) <= 0)
                return false;

            idx = idx + nread;
            nleft = nleft - nread;
        }

        return true;
    }

我们可以发现二进制数据的接收与文本数据相比有所不同,相对于文本数据,二进制数据减少了一个接收数据长度的过程,这是因为我们在接收/二进制数据时,二进制数据通常会包含自身的大小信息。在通信双方约定好数据格式之后,发送方会在发送数据时先将数据的大小信息编码到数据中,接收方在接收数据时可以直接根据数据的大小信息来确定整个报文的大小,从而正确地解析和处理数据。

Write函数

write函数的细节与read函数类似,这里不做赘述,直接看函数签名与逻辑了:

  • 函数签名
 bool Write(const string& buff); //发送文本数据
 bool Write(const void* buff,const int bufflen); //发送二进制数据
 bool tcpwrite(const int sockfd, const void *buffer, const int ibuflen);//发送二进制数据
 bool tcpwrite(const int sockfd, const string &buffer);  //发送文本数据
 bool readn(const int sockfd, char *buffer, const size_t n);
  • 函数逻辑
 bool ctcpclient::Write(const string &buff)
    {
        if (m_socket < 0)
        {
            return false;
        }
        return (tcpwrite(m_socket, buff));
    }

    bool ctcpclient::Write(const void *buff, const int bufflen)
    {
        if (m_socket < 0)
        {
            return false;
        }
        return (tcpwrite(m_socket, (char *)buff, bufflen));
    }

	bool tcpwrite(const int sock, const string &buff)
    {
        if (sock < 0)
        {
            return false;
        }
        int bufflen = buff.length();
        if (writen(sock, (char *)&bufflen, 4) == false) // 发送报文长度
        {
            return false;
        }
        if (writen(sock, &buff[0], bufflen) == false) // 发送报文内容
        {
            return false;
        }
        return true;
    }

    bool tcpwrite(int sock, const void *buff, const int bufflen)
    {
        if (sock < 0)
        {
            return false;
        }
        if (writen(sock, (char *)buff, bufflen) == false) // 发送报文内容
        {
            return false;
        }
        return true;
    }

	 bool writen(const int sockfd, const char *buffer, const size_t n)
    {
        int nleft = n; // 剩余需要写入的字节数。
        int idx = 0;   // 已成功写入的字节数。
        int nwritten;  // 每次调用send()函数写入的字节数。

        while (nleft > 0)
        {
            if ((nwritten = send(sockfd, buffer + idx, nleft, 0)) <= 0)
                return false;

            nleft = nleft - nwritten;
            idx = idx + nwritten;
        }

        return true;
    }

Close函数

Close函数主要用来关闭已经打开的socket

void ctcpclient::Close()
{
   if (m_socket > 0)
   {
      close(m_socket);
      m_socket = -1;
   }
}

服务端类的编写

服务端类的工作流程

  • 初始化监听socket,指定端口与ip,将socket设置为监听状态
  • 从等待连接的队列中选取一个客户端进行连接
  • 发送/接收数据
  • 关闭socket,断开连接

服务端类的成员

class ctcpserver
    {
    private:
        int m_listensock;//服务端的监听socket
        int m_connsock; //已连接的客户端socket
        int sockaddr_len;//客户端地址的长度
        struct sockaddr_in server_addr;//服务端地址
        struct sockaddr_in client_addr;//客户端地址
    public:
        ctcpserver(){m_listensock=-1;m_connsock=-1;}
        bool Initserver(const unsigned int port,const int backlog=5);//初始化服务端
        bool Accept(); //从已连接队列中获取一个客户端连接
        bool Read(string& buff,const int itimeout=0); //接收文本数据
        bool Read(void* buff,const int bufflen,const int itimeout=0); //接收二进制数据
        bool Write(const string& buff); //发送文本数据
        bool Write(const void* buff,const int bufflen); //发送二进制数据
        char* getclientip(); //获取客户端的ip
        void Closelisten(); //关闭监听socket
        void Closeconn(); //关闭已连接的客户端socket
        ~ctcpserver(){Closeconn();Closelisten();}
    };

Initserver函数

在讲解前我们来看一下函数的具体逻辑:

bool ctcpserver::Initserver(const unsigned int port, const int backlog) // backlog:等待连接队列的最大长度
    {
        if (m_listensock != -1)
        {
            Closelisten();
        }

        signal(SIGPIPE, SIG_IGN);

        // 打开SO_REUSEADDR选项,当服务端连接处于TIME_WAIT状态时可以再次启动服务器,
        // 否则bind()可能会不成功,报:Address already in use。
        int opt = 1;
        setsockopt(m_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

        m_listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (m_listensock < 0)
        {
            return false;
        }
        memset(&server_addr, 0, sizeof(server_addr));
        server_addr.sin_family = AF_INET;

        m_listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (m_listensock < 0)
        {
            return false;
        }
        struct sockaddr_in server_addr;
        memset(&server_addr, 0, sizeof(server_addr));
        server_addr.sin_family = AF_INET;
        server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
        server_addr.sin_port = htons(port);
        if (bind(m_listensock, (struct sockaddr *)&server_addr, sizeof(server_addr)) != 0)
        {
            Closelisten();
            return false;
        }
        if (listen(m_listensock, backlog) != 0)
        {
            Closelisten();
            return false;
        }
        return true;
    }

我们梳理一下它这个的工作流程;

  • 检查服务端是否已经被初始化
  • 设置相关信号的处理方式,防止异常情况的出现
  • 初始化服务端的监听socket
  • 设置相关参数,并指定其为用于通信的ip与端口(bind)
  • 将socket设置为监听状态

Accept函数

一个时间段可能会有多个客户端连接服务端,这时候就形成了一个等待队列,服务单会在这个等待队列里面利用accept函数选取一个客户端进行连接:

  bool ctcpserver::Accept()
    {
        if (m_listensock < 0)
        {
            return false;
        }

        sockaddr_len = sizeof(struct sockaddr_in);
        if ((m_connsock = accept(m_listensock, (sockaddr *)&client_addr, (socklen_t *)&sockaddr_len)) < 0)
        {
            return false;
        }
        return true;
    }

Read与Write函数

服务端接收与发送数据与客户端基本功一致,这里就不做赘述,基本的这一点什么都已经提出来了,我们直接看代码:

bool ctcpserver::Read(string &buff, const int itimeout)
    {
        if (m_listensock < 0)
        {
            return false;
        }
        return (tcpread(m_connsock, buff, itimeout));
    }

    bool ctcpserver::Read(void *buff, const int bufflen, const int itimeout)
    {
        if (m_listensock < 0)
        {
            return false;
        }
        return (tcpread(m_connsock, buff, bufflen, itimeout));
    }

    bool ctcpserver::Write(const string &buff)
    {
        if (m_listensock < 0)
        {
            return false;
        }
        return (tcpwrite(m_connsock, buff));
    }

    bool ctcpserver::Write(const void *buff, const int bufflen)
    {
        if (m_listensock < 0)
        {
            return false;
        }
        return (tcpwrite(m_connsock, (char *)buff, bufflen));
    }

getclientip()函数

该函数主要的作用是获取连接的客户端的ip:

  char *ctcpserver::getclientip()
    {
        return inet_ntoa(client_addr.sin_addr);
    }

Close函数

 void ctcpserver::Closelisten()
    {
        if (m_listensock > 0)
        {
            close(m_listensock);
            m_listensock = -1;
        }
    }

    void ctcpserver::Closeconn()
    {
        if (m_connsock > 0)
        {
            close(m_connsock);
            m_connsock = -1;
        }
    }

结语

Cpp不同于其他语言,像Go,Python等语言对上述的细节其实已经封装好了,但是cpp则是需要我们去一点点的实现,为了避免重复的书写代码,我们可以将它们封装成类来供我们去使用,以上就是这篇文章的全部内容了,大家下篇见!

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C语言 | Leetcode C语言题解之第74题搜索二维矩阵

题目&#xff1a; 题解&#xff1a; bool searchMatrix(int** matrix, int matrixSize, int* matrixColSize, int target) {int m matrixSize, n matrixColSize[0];int low 0, high m * n - 1;while (low < high) {int mid (high - low) / 2 low;int x matrix[mid /…

Python中使用嵌套for循环读取csv文件出现问题

如果我们在使用嵌套循环来读取 CSV 文件时遇到了问题&#xff0c;可以提供一些代码示例和出现的具体错误&#xff0c;这样我可以更好地帮助大家解决问题。不过&#xff0c;现在我可以给大家一个基本的示例&#xff0c;演示如何使用嵌套循环来读取 CSV 文件。 问题背景 我需要读…

ShowMeAI | 这是我们知道的,关于〖Suno 〗和〖AI音乐〗的一切

&#x1f440;日报&周刊合集 | &#x1f3a1;生产力工具与行业应用大全 | &#x1f9e1; 点赞关注评论拜托啦&#xff01; Suno 是一款AI音乐创作工具&#xff0c;可以通过提示词和设置生成一段音乐&#xff0c;而且可以包含歌词和人声 (这非常难得)。在经历了两年探索之后…

java spring 09 Bean的销毁过程 上 在docreatebean中登记要销毁的bean

1.Bean销毁是发送在Spring容器关闭过程中的 AnnotationConfigApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class);UserService userService (UserService) context.getBean("userService");userService.test();// 容器关闭cont…

一季度盈利大增65.62%,神州泰岳游戏表现抢眼

易采游戏网5月8日消息&#xff0c;近日国内知名游戏上市公司神州泰岳公布了其2023年一季度的财务报告&#xff0c;报告显示&#xff0c;公司一季度盈利大增65.62%&#xff0c;这一数字远超过市场预期&#xff0c;引发了业界的广泛关注。 神州泰岳此次盈利大增&#xff0c;主要得…

韩国站群服务器在全球网络架构中的重要作用?

韩国站群服务器在全球网络架构中的重要作用? 在全球互联网的蓬勃发展中&#xff0c;站群服务器作为网络架构的核心组成部分之一&#xff0c;扮演着至关重要的角色。韩国站群服务器以其卓越的技术实力、优越的地理位置、稳定的网络基础设施和强大的安全保障能力&#xff0c;成…

武汉星起航:跨境电商平台拓展全球市场,打造国际品牌的更优选择

随着全球化的加速和互联网的普及&#xff0c;跨境电商平台与国内电商平台成为了现代商业领域的两大重要支柱。它们在商业模式、运营策略、市场覆盖等方面均呈现出显著的区别&#xff0c;为商家提供了多样化的销售渠道和市场拓展机会。武汉星起航旨在深入探讨跨境电商平台与国内…

指针再学习笔记

概念 示例 类型 示例 作用 注意&#xff1a;有些内存地址可能系统不会允许任意访问 运算 示例 空指针

启明智显分享|国产RISC-V@480MHz“邮票孔”工业级HMI核心板,高品质低成本,仅34.9元!

「Model系列」芯片是启明智显针对工业、行业以及车载产品市场推出的系列HMI芯片&#xff0c;主要应用于工业自动化、智能终端HMI、车载仪表盘、串口屏、智能中控、智能家居、充电桩显示屏、储能显示屏、工业触摸屏等领域。此系列具有高性能、低成本的特点&#xff0c;支持工业宽…

硬件基础——晶振(复试被问到)

1.什么是晶振 石英晶体振荡器&#xff0c;是芯片的心脏&#xff0c;主要用于提供给芯片稳定、精确的时钟频率信号。其主要利用石英晶体的压电效应&#xff0c;从而实现振荡。 一般晶振会在芯片的旁边&#xff0c;不能远离晶振&#xff0c;因为振荡时会受外界电磁干扰的影响。 我…

扣子+kimi实现微信公众号智能助理

昨天偶然看到一个微信公众号智能客服助理的文章然后自己尝试了一下。基于字节跳动的扣子kimi大模型&#xff0c;然后通过授权公众号实现AI智能助理。 一、AI是什么&#xff1f; AI是人工智能&#xff08;Artificial Intelligence&#xff09;的英文缩写&#xff0c;它是计算机科…

MYSQL8.0.20安装教程

一&#xff1a;下载mysql MySQL :: Download MySQL Installer (Archived Versions) 二&#xff1a;选中server only&#xff0c;点击next 三&#xff1a;点击server 选项&#xff0c;点击Execute 弹窗点击安装 四&#xff1a;安装项为绿色后&#xff0c;点击next 五&#xf…

Java 对象和类

Java 对象和类 Java作为一种面向对象语言。支持以下基本概念&#xff1a; 多态 继承 封装 抽象 类 对象 实例 方法 重载 本节我们重点研究对象和类的概念。 对象&#xff1a;对象是类的一个实例&#xff08;对象不是找个女朋友&#xff09;&#xff0c;有状态和行为。例如&am…

【牛客】【模板】二维前缀和

原题链接&#xff1a;登录—专业IT笔试面试备考平台_牛客网 目录 1. 题目描述 2. 思路分析 3. 代码实现 1. 题目描述 2. 思路分析 二维前缀和板题。 二维前缀和&#xff1a;pre[i][j]a[i][j]pre[i-1][j]pre[i][j-1]-pre[i-1][j-1]; 子矩阵 左上角为(x1,y1) 右下角(x2,y2…

洛谷官方提单——【入门4】数组——python

洛谷官方提单——【入门4】数组 小鱼比可爱题目描述输入格式输出格式样例 #1样例输入 #1样例输出 #1 提示代码 小鱼的数字游戏题目描述输入格式输出格式样例 #1样例输入 #1样例输出 #1 提示数据规模与约定 代码 【深基5.例3】冰雹猜想题目描述输入格式输出格式样例 #1样例输入 …

嵌入式C语言高级教程:实现基于STM32的人工智能语音识别系统

在嵌入式系统中实现语音识别技术可以极大地增强设备的交互性。本教程将指导您如何在STM32微控制器上使用TensorFlow Lite for Microcontrollers实现基本的语音识别功能。 一、开发环境准备 硬件要求 微控制器&#xff1a;STM32F746NG&#xff0c;支持足够的运算能力和内存来…

传神论文中心|本周人工智能领域论文推荐

在人工智能领域的快速发展中&#xff0c;我们不断看到令人振奋的技术进步和创新。近期&#xff0c;开放传神&#xff08;OpenCSG&#xff09;社区发现了一些值得关注的成就。在当今数字化时代&#xff0c;人工智能&#xff08;AI&#xff09;已经成为了许多领域的核心驱动力。o…

手拿滑块撕瑞数 我叫超弟你记住!!什么腾讯滑块、数美、顶象、阿里通通拿下!最新版2024.5.8号

本文章非标题党&#xff0c;可提供主流验证码解决方案及成品、补环境框架、逆向教学 不论你是逆向小白、亦或是需求方都可通过本文章各取所需&#xff01;&#xff01; 废话不多说&#xff0c;老规矩&#xff0c;附上腾讯旗下验证码程序运行图&#xff0c;附程序运行时间 &…

微信在线投票送礼物票选小程序源码系统 带完整的安装代码包以及安装搭建教程

在数字化时代&#xff0c;互动与参与成为吸引用户的关键。为了满足广大用户对于在线投票和礼物赠送的需求&#xff0c;我们特别推出了这款微信在线投票送礼物票选小程序源码系统。该系统不仅提供完整的安装代码包&#xff0c;还附带详细的安装搭建教程&#xff0c;让用户轻松搭…

Stable Diffusion:AI绘画的新纪元

摘要&#xff1a; Stable Diffusion&#xff08;SD&#xff09;作为AI绘画领域的新星&#xff0c;以其开源免费、强大的生成能力和高度的自定义性&#xff0c;正在引领一场艺术与技术的革命。本文旨在为读者提供Stable Diffusion的全面介绍&#xff0c;包括其原理、核心组件、安…
最新文章