Huangjin007_头像
【Linux 系统篇(三十二)】通信(二):命名管道详解封面图

【Linux 系统篇(三十二)】通信(二):命名管道详解

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇

Linux 系统篇(三十二) —— 命名管道详解


一、为什么有了匿名管道,还需要命名管道?

  在学习 Linux 进程间通信时,我们已经接触过匿名管道。通过 pipe() 创建一个管道,再配合 fork() 创建子进程,就可以让父子进程之间交换数据。

  例如,父进程读取一份数据,然后通过管道发送给子进程,子进程接收到数据后再进行处理。

  这种方式很好用,但随着问题逐渐复杂,我们会发现一个限制:

  匿名管道通常用于具有亲缘关系的进程之间通信。如果两个进程互不相关,它们应该怎样交换数据?

  先来看一个具体场景。

  假设我们现在有两个独立运行的程序:

  • server:负责接收消息。
  • client:负责发送消息。

  我们希望先启动服务器,再启动客户端。客户端输入一段文字,服务器就能接收到并显示出来。

  问题来了:这两个程序不是通过 fork() 创建出来的父子进程,它们没有共同的父进程,也没有提前继承对方的文件描述符。

  那么,之前学习的匿名管道还能直接使用吗?

  通常不行。匿名管道没有一个供两个独立程序共同查找的文件系统路径,双方无法仅仅通过一个共同的文件名找到并打开它。

  既然如此,我们能不能给管道一个名字,让两个互不相关的进程都能找到它?

  答案是可以的。

  这就是我们今天要学习的主角——命名管道(Named Pipe),也叫 FIFO。


二、什么是命名管道?

2.1 给管道一个名字

  匿名管道和命名管道的核心思想其实相同:都可以让一个进程写入数据,再让另一个进程读取数据。

  不同的是,命名管道在文件系统中有一个对应的名字,两个独立运行的进程可以通过这个名字找到同一个管道。

  例如,我们可以创建一个名为 fifo 的命名管道:

mkfifo fifo

  创建完成后,执行:

ls -l

  可能看到类似这样的结果:

在这里插入图片描述

  注意最前面的字符:

p

  它表示这是一个 FIFO 类型的文件,也就是命名管道。

  普通文件通常以 - 开头,目录通常以 d 开头,而命名管道以 p 开头。

  这里有一个非常重要的知识点:

  命名管道虽然在文件系统中有一个名字,但它并不是用来保存普通文件内容的。

  普通文件通常将数据持久地保存到文件中,而命名管道主要用于进程之间传递数据。数据被读取后,就会从管道的数据流中消耗掉,并不是一直保存在这个路径对应的文件里。

  可以把它理解成一个有名字的传送通道:

  • client 找到管道,向里面写入消息。
  • server 找到同一个管道,从里面读取消息。
  • 数据经过管道传递,而不是作为普通文件内容永久保存。

  因此,命名管道本质上是一种特殊类型的文件,其实际通信能力由 Linux 内核提供。

2.2 命名管道能解决什么问题?

  继续看 client 和 server。

  我们只需要让两个进程约定好同一个命名管道路径,就能分别打开它:

client  ── write() ──>  命名管道  ── read() ──>  server

  双方不需要是父子进程,也不需要通过 fork() 相互继承文件描述符。


三、如何创建命名管道?

  理解了命名管道的作用,接下来就要解决实际问题:怎样创建它?

  Linux 中主要有两种方式:使用命令行创建,以及在程序中调用 mkfifo()。

3.1 通过命令行创建

  最简单的方法就是使用 mkfifo 命令:

mkfifo fifo

  这条命令会在当前目录下创建一个名为 fifo 的命名管道。

  我们可以通过下面的命令查看:

ls -l fifo

  如果想删除它,可以执行:

rm fifo

  这里要注意,rm fifo 删除的是文件系统中的管道名称,而不是把已经传输过的数据清空。对于仍然打开着该 FIFO 的进程,已经打开的引用在最后一个引用关闭之前仍然可以存在。

3.2 通过程序创建

  实际开发中,我们通常需要让程序自动创建命名管道,因此要使用系统调用 mkfifo()。

它的函数原型是:

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *filename, mode_t mode);

两个参数分别表示:

  • filename:命名管道的路径。
  • mode:创建时设置的权限,例如 0644、0666。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errno,可以使用 perror() 查看错误原因。

  下面来看一个简单的例子:

#include <sys/types.h>
#include <sys/stat.h>
#include <cstdio>
#include <cerrno>

int main()
{
    // 在当前目录下创建一个名为 p2 的命名管道
    if (mkfifo("p2", 0644) == -1)
    {
        // 如果管道已经存在,也会创建失败,errno 为 EEXIST
        perror("mkfifo");
        return 1;
    }

    printf("mkfifo success\n");
    return 0;
}

  如果创建成功,就能在目录中看到 p2。

  这里还有两个容易忽略的细节。

第一,mkfifo() 不负责打开管道。

  它只是在文件系统中创建命名管道。后续要使用它传输数据,还需要调用 open() 获取文件描述符。

第二,重复创建会失败。

  如果 p2 已经存在,mkfifo() 通常会返回 -1,并将 errno 设置为 EEXIST。实际程序中可以检查该错误,并判断已有路径是否确实是一个 FIFO,而不是直接认定创建失败就代表管道已经存在且可用。


四、匿名管道和命名管道有什么区别?

  现在我们已经知道,匿名管道和命名管道都可以传输数据,那么它们究竟有什么区别?

对比项匿名管道命名管道
创建方式pipe()mkfifo()
打开方式pipe() 创建时直接获得文件描述符使用 open() 打开
是否有文件系统名称通常没有可供双方共同打开的路径名有
常见通信对象父子进程等有亲缘关系的进程可以是互不相关的进程
数据传输方式内核提供的管道数据流内核提供的 FIFO 数据流
是否直接保存普通文件内容否否
是否具有先进先出的特性是是

  匿名管道由 pipe() 创建并直接返回读写文件描述符;命名管道则先通过 mkfifo() 创建,再通过 open() 获取文件描述符。

  当两种管道都完成创建和打开后,它们的基本读写语义是相同的。

  例如,两者都可以使用 read() 读取数据,使用 write() 写入数据,也都可能因为管道为空、缓冲区已满或对端关闭而发生阻塞、结束或报错。


五、命名管道最容易出问题的地方:open() 为什么会阻塞?

  创建好命名管道之后,我们就可以使用 open() 打开它。

  但是,在真正编写程序之前,还有一个必须理解的问题:

  为什么有时候程序执行到 open() 就不动了,看起来像卡死了一样?

  这通常不是程序出错,而是命名管道本身的打开规则导致的。

5.1 以只读方式打开 FIFO

  假设服务器准备读取客户端发送的数据:

int fd = open("fifo", O_RDONLY);

  这里的 O_RDONLY 表示以只读方式打开。

  如果没有使用 O_NONBLOCK,也就是默认的阻塞模式,那么当 FIFO 还没有任何进程以写方式打开时,这个 open() 会一直等待。

  为什么?

  因为服务器的目的是读取数据,但目前还没有发送方建立写入连接。Linux 会让服务器等待,直到有进程以写方式打开同一个 FIFO。

可以把这个过程理解成:

服务器:我要打开管道,准备接收数据
                    |
                    v
             等待写入端出现
                    |
                    v
客户端:打开同一个管道,准备发送数据
                    |
                    v
              open() 完成

  注意,这里的阻塞发生在 open() 阶段,而不一定是 read() 阶段。

5.2 以只写方式打开 FIFO

  再来看客户端:

int fd = open("fifo", O_WRONLY);

  这里的 O_WRONLY 表示以只写方式打开。

  如果没有使用 O_NONBLOCK,而当前没有任何进程以读方式打开 FIFO,那么客户端的 open() 同样会阻塞。

  原因也很简单:如果没有读取端,发送方就还没有建立起正常的管道通信关系。

  因此,默认阻塞模式下有两条规则:

  • 读端打开 FIFO:等待写端打开。
  • 写端打开 FIFO:等待读端打开。

  这意味着,如果我们先启动服务器,再启动客户端,通常会看到这样的现象:

  1. 服务器执行 open("fifo", O_RDONLY),暂时阻塞。
  2. 客户端执行 open("fifo", O_WRONLY)。
  3. 双方匹配成功,open() 返回。
  4. 服务器可以开始读取,客户端可以开始写入。

  这也解释了为什么命名管道的两个程序可以分开启动,却仍然能够配合工作。

5.3 如果使用 O_NONBLOCK,会发生什么?

  有时,我们不希望程序一直等待,而是希望 open() 立即返回。这时可以使用 O_NONBLOCK,即非阻塞模式。

例如:

int fd = open("fifo", O_RDONLY | O_NONBLOCK);

  对于 FIFO,非阻塞打开规则如下。

打开方式是否有对端结果
O_RDONLY没有写端阻塞等待
O_RDONLY | O_NONBLOCK没有写端立即成功
O_WRONLY没有读端阻塞等待
O_WRONLY | O_NONBLOCK没有读端立即失败,errno 为 ENXIO

  为什么读端非阻塞打开时,即使没有写端也能成功?

  因为只读打开本身不需要等待写端建立连接。此时如果立即调用 read(),并且当前没有数据、也没有写端,通常会读到 0。这与普通的阻塞读取不同:如果仍然存在写端,只是暂时没有数据,那么非阻塞读取通常会返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。

这两个场景要区分清楚:

  • 没有数据,但仍有写端: 暂时没有内容可读。
  • 没有数据,也没有写端: 当前没有发送方,read() 可以返回 0。

六、实战一:使用命名管道实现文件拷贝

  理论讲完了,我们来解决一个实际问题。

  假设当前目录下有一个文件 abc,里面存放着一段文本。我们想将它复制为 abc.bak。

  平常复制文件,可以使用 cp 命令。那么,如果不直接让一个程序从源文件读取后写入目标文件,而是让两个独立程序通过命名管道完成数据传输,该怎么做?

  我们可以把任务拆分成两部分:

  • 程序 A:读取 abc,将读取到的数据写入 FIFO。
  • 程序 B:从 FIFO 读取数据,将数据写入 abc.bak。

  整体流程如下:

在这里插入图片描述

6.1 第一个程序:读取源文件,写入命名管道

  先实现程序 A。

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cstdlib>
#include <cstdio>
#include <cerrno>

#define ERR_EXIT(m)                 \
    do                              \
    {                               \
        perror(m);                  \
        exit(EXIT_FAILURE);         \
    } while (0)

int main()
{
    // 创建命名管道;如果已经存在,后面还可以继续使用
    if (mkfifo("tp", 0644) == -1 && errno != EEXIST)
        ERR_EXIT("mkfifo");

    // 打开源文件,只读
    int infd = open("abc", O_RDONLY);
    if (infd == -1)
        ERR_EXIT("open abc");

    // 打开 FIFO,只写;没有读端时会阻塞等待
    int outfd = open("tp", O_WRONLY);
    if (outfd == -1)
        ERR_EXIT("open tp");

    char buf[1024];
    ssize_t n;

    // 每次读取一部分数据,再写入 FIFO
    while ((n = read(infd, buf, sizeof(buf))) > 0)
    {
        ssize_t sent = 0;

        // write 可能只写入部分数据,因此循环写完当前数据块
        while (sent < n)
        {
            ssize_t ret = write(outfd, buf + sent, n - sent);
            if (ret == -1)
            {
                if (errno == EINTR)
                    continue;
                ERR_EXIT("write tp");
            }
            sent += ret;
        }
    }

    // read 返回 -1 表示读取出错
    if (n == -1)
        ERR_EXIT("read abc");

    close(infd);
    close(outfd);

    return 0;
}

  首先,open("abc", O_RDONLY) 打开源文件。接着,open("tp", O_WRONLY) 打开命名管道的写端。如果此时还没有程序打开读端,当前程序就会阻塞等待。

  进入循环后,read() 每次最多读取 1024 字节。读取多少,就向管道写入多少。文件很大也没有关系,不需要一次性将整个文件读入内存。

  这里特意处理了一个容易忽略的问题:write() 不一定一次就能写完要求的数据,因此要检查实际写入的字节数,并继续写入剩余部分。

6.2 第二个程序:读取命名管道,写入目标文件

  程序 B 负责接收数据,并将数据写入 abc.bak。

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cstdlib>
#include <cstdio>
#include <cerrno>

#define ERR_EXIT(m)                 \
    do                              \
    {                               \
        perror(m);                  \
        exit(EXIT_FAILURE);         \
    } while (0)

int main()
{
    // 创建或复用已经存在的 FIFO
    if (mkfifo("tp", 0644) == -1 && errno != EEXIST)
        ERR_EXIT("mkfifo");

    // 创建目标文件;若已存在,则清空原内容
    int outfd = open("abc.bak",
                     O_WRONLY | O_CREAT | O_TRUNC,
                     0644);
    if (outfd == -1)
        ERR_EXIT("open abc.bak");

    // 打开 FIFO,只读;没有写端时会阻塞等待
    int infd = open("tp", O_RDONLY);
    if (infd == -1)
        ERR_EXIT("open tp");

    char buf[1024];
    ssize_t n;

    // 读取 FIFO,直到所有写端关闭且数据读完
    while ((n = read(infd, buf, sizeof(buf))) > 0)
    {
        ssize_t sent = 0;

        // 确保当前读到的数据全部写入目标文件
        while (sent < n)
        {
            ssize_t ret = write(outfd, buf + sent, n - sent);
            if (ret == -1)
            {
                if (errno == EINTR)
                    continue;
                ERR_EXIT("write abc.bak");
            }
            sent += ret;
        }
    }

    if (n == -1)
        ERR_EXIT("read tp");

    close(infd);
    close(outfd);

    // 删除 FIFO 的文件系统名称
    if (unlink("tp") == -1)
        perror("unlink tp");

    return 0;
}

open() 的几个选项:

  • O_WRONLY:只写。
  • O_CREAT:目标文件不存在时创建它。
  • O_TRUNC:目标文件已存在时,将其原有内容截断为零长度。

  为什么程序 B 要等到 read() 返回 0 才结束?

  因为 FIFO 是数据流。只要还有写端打开,读取端就不能仅仅因为当前暂时没有数据,就认定通信结束。

  当所有写端都关闭,并且 FIFO 中剩余的数据已经读取完毕时,read() 才会返回 0,告诉程序已经到达当前数据流的末尾。


七、实战二:使用命名管道实现 Server 和 Client 通信

  前面的文件拷贝让我们理解了命名管道的数据传递过程。但在实际开发中,更常见的学习场景是让两个程序持续交换信息。

  现在我们实现一个简单的通信程序:

  • server:服务器,负责接收并显示客户端消息。
  • client:客户端,负责从键盘读取输入,再发送给服务器。
  • fifo:双方共同使用的命名管道。

最终效果类似这样:

在这里插入图片描述

  这个例子仍然是单向通信:客户端发送,服务器接收。如果想让服务器也能主动回复客户端,可以再设计一个用于反向传输的 FIFO。

7.1 先设计公共代码 comm.hpp

  我们先把创建管道、打开管道、读写数据等操作封装成类,避免客户端和服务器各自重复编写相同的代码。

#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

// 负责创建和删除命名管道
class NamedFifo
{
public:
    NamedFifo(const std::string& path, const std::string& name)
        : _fifoname(path + "/" + name), _created(false)
    {
        // 如果管道已存在,不重复创建
        if (mkfifo(_fifoname.c_str(), 0666) == 0)
        {
            _created = true;
            std::cout << "mkfifo success: "
                      << _fifoname << std::endl;
        }
        else if (errno == EEXIST)
        {
            // 已存在的路径必须确实是 FIFO
            struct stat st;
            if (lstat(_fifoname.c_str(), &st) == -1 ||
                !S_ISFIFO(st.st_mode))
            {
                std::cerr << "path exists but is not a FIFO"
                          << std::endl;
                std::exit(EXIT_FAILURE);
            }

            std::cout << "FIFO already exists: "
                      << _fifoname << std::endl;
        }
        else
        {
            perror("mkfifo");
            std::exit(EXIT_FAILURE);
        }
    }

    ~NamedFifo()
    {
        // 只删除本对象创建的 FIFO,避免误删别人已有的路径
        if (_created && unlink(_fifoname.c_str()) == -1)
            perror("unlink FIFO");
    }

private:
    std::string _fifoname;
    bool _created;
};

// 负责打开、读取和写入命名管道
class FileOper
{
public:
    FileOper(const std::string& path, const std::string& name)
        : _fifoname(path + "/" + name), _fd(-1)
    {
    }

    void OpenForRead()
    {
        // 阻塞模式下,等待写端打开 FIFO
        _fd = open(_fifoname.c_str(), O_RDONLY);
        if (_fd == -1)
            perror("open FIFO for read");
        else
            std::cout << "open FIFO for read success"
                      << std::endl;
    }

    void OpenForWrite()
    {
        // 阻塞模式下,等待读端打开 FIFO
        _fd = open(_fifoname.c_str(), O_WRONLY);
        if (_fd == -1)
            perror("open FIFO for write");
        else
            std::cout << "open FIFO for write success"
                      << std::endl;
    }

    void Read()
    {
        if (_fd == -1)
        {
            std::cerr << "FIFO is not open" << std::endl;
            return;
        }

        char buffer[1024];

        while (true)
        {
            // 留一个字节存放字符串结束符
            ssize_t n = read(_fd, buffer, sizeof(buffer) - 1);

            if (n > 0)
            {
                buffer[n] = '\0';
                std::cout << "client says: " << buffer;
                std::cout.flush();
            }
            else if (n == 0)
            {
                // 所有写端关闭,且管道数据已经读完
                std::cout << "client closed the pipe"
                          << std::endl;
                break;
            }
            else if (errno == EINTR)
            {
                // 被信号打断,继续读取
                continue;
            }
            else
            {
                perror("read FIFO");
                break;
            }
        }
    }

    void Write()
    {
        if (_fd == -1)
        {
            std::cerr << "FIFO is not open" << std::endl;
            return;
        }

        std::string message;

        while (true)
        {
            std::cout << "Please enter#: ";

            if (!std::getline(std::cin, message))
                break;

            if (message == "quit")
                break;

            // 加上换行符,方便服务器显示消息
            message += '\n';

            // 保证当前消息全部写入 FIFO
            size_t sent = 0;
            while (sent < message.size())
            {
                ssize_t n = write(
                    _fd,
                    message.data() + sent,
                    message.size() - sent
                );

                if (n == -1 && errno == EINTR)
                    continue;

                if (n <= 0)
                {
                    perror("write FIFO");
                    return;
                }

                sent += static_cast<size_t>(n);
            }
        }
    }

    void Close()
    {
        if (_fd != -1)
        {
            close(_fd);
            _fd = -1;
        }
    }

    ~FileOper()
    {
        // 析构时兜底关闭文件描述符
        Close();
    }

private:
    std::string _fifoname;
    int _fd;
};

  这里使用了两个类,各自负责不同的工作。

  NamedFifo 负责管道的创建和清理。FileOper 负责打开管道以及通过文件描述符进行读写。

  将它们分开,是为了让职责更加清晰:管道的生命周期管理与数据传输操作不必混在一起。

  需要特别留意 NamedFifo 的析构函数:只有当前对象成功创建了 FIFO,才会尝试删除它。如果管道原本就存在,这个对象不会擅自删除它。

  不过,在多进程程序中,如果某个进程创建了 FIFO,另一个进程正在使用它,创建者退出时删除路径仍然可能影响后续进程重新打开它。因此,实际项目应当明确规定由谁负责创建和删除 FIFO,并协调好清理时机。

7.2 客户端 client.cc

  客户端不需要创建管道。它只需要打开服务器已经准备好的 FIFO,然后不断读取用户输入并发送消息。

#include "comm.hpp"

int main()
{
    // 创建文件操作对象,指定当前目录下的 fifo
    FileOper writerfifo(".", "fifo");

    // 打开 FIFO 写端;服务器需要先打开读端
    writerfifo.OpenForWrite();

    // 如果打开失败,就不要继续发送
    writerfifo.Write();

    // 关闭文件描述符
    writerfifo.Close();

    return 0;
}

  客户端代码很短,因为实际读写逻辑已经封装在 FileOper 中。

  其中,"." 表示当前目录,"fifo" 表示管道名称,两者组合后得到 ./fifo。

  当客户端输入普通文本时,消息会被写入管道;输入 quit 时,客户端退出输入循环,关闭写端。

  这里的 quit 只是客户端自己约定的退出命令,并不会作为消息发送给服务器。

7.3 服务器 server.cc

  服务器负责创建命名管道,并打开读端等待客户端消息。

#include "comm.hpp"

int main()
{
    // 创建 FIFO;如果已经存在,则验证并复用
    NamedFifo fifo(".", "fifo");

    // 创建文件操作对象
    FileOper readerfifo(".", "fifo");

    // 打开读端,等待客户端打开写端
    readerfifo.OpenForRead();

    // 循环接收并显示客户端发送的消息
    readerfifo.Read();

    // 关闭文件描述符
    readerfifo.Close();

    return 0;
}

  服务器启动后,先创建 fifo,再调用 OpenForRead()。

  如果客户端还没有启动,服务器会在打开读端时等待。客户端启动后,打开同一个 FIFO 的写端,双方就能建立通信关系。

  服务器接收到数据后,通过 Read() 输出消息。当所有写端关闭且管道中的数据已经读完时,read() 返回 0,服务器退出读取循环,随后关闭文件描述符。

  这里有一个需要明确的细节:服务器的 NamedFifo 对象在 main() 结束时析构。如果它创建了 FIFO,就会尝试删除 FIFO 的名称。因此,这份示例适合展示单个客户端完成一轮通信的基本过程,不是完整的多客户端服务器实现。

7.4 Makefile

  我们可以通过 Makefile 一次编译客户端和服务器。

.PHONY: all clean

all: client server

client: client.cc comm.hpp
	g++ -o $@ client.cc -std=c++11

server: server.cc comm.hpp
	g++ -o $@ server.cc -std=c++11

clean:
	rm -f client server

这里有几个细节:

  • all 是默认目标,执行 make 时会编译 client 和 server。
  • $@ 表示当前目标名称,例如 client。
  • 将 comm.hpp 加入依赖列表后,修改头文件也会触发对应程序重新编译。
  • clean 用于删除编译生成的可执行文件,不会删除 FIFO。

八、把整个过程串起来:命名管道到底是怎么工作的?

  前面分别学习了创建命名管道、打开规则、文件拷贝以及客户端和服务器通信。现在我们把这些知识点串联起来,形成一个完整的理解。

  假设服务器和客户端分别运行在两个终端中。

8.1 命名管道通信的完整流程

在这里插入图片描述


九、使用命名管道时,还有哪些问题需要注意?

9.1 命名管道是文件,为什么不能像普通文件一样读写?

  因为它属于特殊文件。

  普通文件的主要用途是保存数据,而 FIFO 的主要用途是提供进程间的数据传输通道。虽然它们都可以使用 open()、read()、write() 和 close() 等接口,但底层行为并不完全相同。

  例如,FIFO 通常不能像普通文件那样使用 lseek() 随意移动读写位置。

9.2 命名管道中的数据会一直保存吗?

  不会。

  FIFO 中的数据由内核管理,读取端读走数据后,这些数据就从管道的数据流中消耗掉了。

  如果希望数据能够长期保存,就应该使用普通文件或其他持久化存储方式,而不是把 FIFO 当作文件仓库。

9.3 为什么服务器有时收不到完整的一条消息?

  因为 FIFO 是字节流,而不是自动划分消息的队列。

  例如,客户端执行两次写入:

write(fd, "Hello", 5);
write(fd, "World", 5);

  服务器可能一次读取 10 个字节,也可能先读到 "Hello",再读到 "World",还可能在不同的读取操作中取得部分数据。

  因此,不能简单地认为:

一次 write() = 一次 read()

  如果要传输具有明确结构的消息,就需要设计协议,而不能依赖每次读取都恰好对应一次写入。


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Huang_jin007/article/details/168976427

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--