【Linux网络】网络编程套接字(守护进程及TCP协议通讯流程)

目录

TCP英译汉服务器

守护进程

守护进程的概念

进程组和会话

守护进程化的方式 

TCP网络程序(守护进程化)

daemon函数创建守护进程 

TCP协议通讯流程

TCP协议通讯一般流程总览

三次握手的过程

数据传输的过程 

四次挥手的过程 

TCP 和 UDP 对比


TCP英译汉服务器

在上一篇博客中,我重点介绍了TCP服务器的实现。最初,我们实现了一个简单的单执行流TCP服务器,但很快发现它无法同时为多个客户端提供服务。因此,我们转向了多执行流的实现,其中包括多进程和多线程方法。为了进一步提高基于多线程的TCP服务器的性能,我们还引入了线程池。

当我们使用多执行流(如多进程或多线程)时,每个客户端都有自己的执行流为其提供服务,从而允许多个客户端同时享受服务器提供的服务。我们之前提到过,如果需要让TCP服务器处理其他任务,只需修改相应的处理函数。在我们的线程池版本TCP服务器中,这意味着修改任务类中的handler方法。

现在,让我们以实现一个简单的TCP英译汉服务器为例,看看经过修改后的TCP服务器是否能够正常地为客户端提供英译汉服务。这个例子将展示如何扩展我们的服务器以处理新的翻译任务,而无需对其他部分进行重大更改。

下面我们对handler函数进行修改

为了实现一个英译汉的TCP服务器,我们需要根据客户端发送的英文单词来查找其对应的中文翻译,并将这个中文翻译作为响应数据发送给客户端。

由于我们之前已经实现了基于线程池的TCP服务器,因此不需要更改与通信相关的代码。我们只需要修改Handler类中的重载operator()函数,这是线程池中的任务执行时调用的函数。 

为了执行翻译任务,我们需要建立一个映射表来存储英文单词及其对应的中文翻译。这个映射表可以用C++标准模板库(STL)中的unordered_map容器来实现,其中英文单词作为键(key),中文翻译作为值(value)。当客户端发送一个英文单词时,服务器将使用这个映射表来查找对应的中文翻译,并将结果发送回客户端。

封装Init函数

Init函数用于初始化建立中英文的映射关系。

首先我们建立了一个dict.txt存放中英文字典,下面是字典的部分截图。

然后再初始化函数中,我们建立一个Init类,在类的构造函数中进行初始化,首先我们用ifstream创建一个输入文件流对象in,并打开dict.txt。我们用unorderded_map来进行建立映射关系。使用getline函数不断从文件当中读取数据,每行我们根据“:”分隔符将其分文两部分,英文作为key,中文作为value。不断插入到unorderded_map容器当中。插入完毕后关闭输入文件流对象。

#pragma once

#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
#include "Log.hpp"

const std::string dictname = "./dict.txt";
const std::string sep = ":";

static bool Split(const std::string& line, std::string& part1, std::string& part2)
{
    auto pos = line.find(sep);
    if(pos == std::string::npos) return false;
    part1 = line.substr(0,pos);//[0,pos)
    part2 = line.substr(pos+1);

    return true;
}

class Init
{
public:
    Init()
    {
        std::ifstream in(dictname);//使用std::ifstream类创建一个输入文件流对象in,构造函数std::ifstream(const std::string& filename)被调用,尝试打开名为dictname的文件。
        if(!in.is_open())
        {
            lg(Fatal, "ifstream open %s error", dictname.c_str());//这里要调用c_str(),因为string类型不是以空字符串为结尾的。
            exit(1);
        }
        std::string line;
        while (getline(in,line))
        {
           std::string part1,part2;
           Split(line,part1,part2);
           dict.insert({part1,part2});
        }
        in.close();
    }

    std::string translation(const std::string &key)
    {
        auto iter = dict.find(key);
        if(iter == dict.end()) return "Unknown";
        else return iter->second;
    }
private:
    std::unordered_map<std::string,std::string> dict;
};

修改handler函数 

我们首先实例化一个Init函数对象,它会自动调用我们自定义的默认构造函数进行初始化。然后在handler函数我们只需要调用init对象中的translation函数,将客户端输入的字符串传入,就能得到翻译的结果。

Init init;
class Handler
{
public:
    void operator()(int sockfd,const string& clientip,const uint16_t& clientport)
    {
        char buffer[4096];
        while (true)
        {
            ssize_t n = read(sockfd,buffer,sizeof(buffer));
            if(n > 0)
            {
                buffer[n] = 0;
                cout << "client say#" << buffer << endl;
                string echo_string = init.translation(buffer);

                write(sockfd,echo_string.c_str(),echo_string.size());
            }
            else if(n == 0)
            {
                lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
                break;
            }
            else
            {
                lg(Warning,"read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
                break;
            }
        
        }
    }
};

 运行结果:

可以看到我们实现了英译汉的功能了。 

守护进程

守护进程的概念

  • 守护进程,也称作精灵进程,是一种在后台独立运行的特殊进程。它不依赖于任何控制终端,并且能够周期性地执行特定任务或等待处理特定事件。在Linux系统中,守护进程扮演着至关重要的角色,许多服务器,如Web服务器httpd,都采用守护进程的形式运行。此外,守护进程还负责完成许多系统级任务。
  • 当Linux系统启动时,它会启动众多系统服务,这些服务进程没有与之关联的终端。即便用户关闭了终端,这些系统服务也会继续运行,它们就是所谓的守护进程。
  • 通常以服务器形式运行的进程,特别是那些对外提供服务的服务器,都是以守护进程的方式在后台持续运行的。除非用户主动关闭,否则这些服务器将始终保持运行状态。

进程组和会话

进程组的相关概念:

  • 进程除了有进程的PID之外还有一个进程组,进程组是由一个进程或者多个进程组成。通常他们与同一作业相关联可以收到同一终端的信号。
  • 每个进程组有唯一的进程组ID,每个进程组有一个进程组组长。如何判断一个进程是不是这个进程组的组长:通常进程组ID等于进程ID那么这个进程就是对应进程组的组长。
  • 多个任务(进程组),在同一个session内启动的sid是一样的

会话的相关概念:

  • 会话是有一个或者多个进程组组成的集合。
  • 一个会话可以有一个终端,建立与控制终端连接的会话首进程被成为控制进程,一个会话的几个进程组可以分为前台进程和后台进程而这些进程组的控制终端相同也就是sesion id是一样的当用户使用ctr +c 产生SIGINT信号时内核会发送信号给相应前台进程组的所有进程。

相关指令:

  • 如果我运行一个程序我们想要把他放到后台运行我们可以在可执行程序的后面加一个& 如:./test &
  • jobs指令:显示当前shell会话中已启动的作业(即后台进程)的状态和任务号
  • 如果我们想要把进程提到前台我们可以通过使用fg带上任务号指令如 fg 1
  • 如果我们想要把被暂停的进程重新放到前台我们可以使用bg带上任务号指令如 bg 1

进程组和任务有什么联系?

  • 在Linux系统中,所有运行的东西都可以称为一个进程,包括用户任务和系统管理守护进程。进程组方便了对这些进程的管理,特别是在涉及多个进程共同完成一个任务时。例如,一个任务可能需要并发运行多个进程,这些进程可以组织成一个进程组,从而方便地进行统一管理和信号传递。
  • 作业控制:在Shell中,前后台控制的不是进程而是作业(Job)或进程组。用户可以将一组相关的进程作为一个作业放到后台运行,或者使用jobs命令查看当前停止的作业,使用bg命令将作业放到后台继续执行。这里的作业实际上就是一个或多个进程的集合,即进程组。

综上所述,进程组是Linux系统中对多个进程进行统一管理和控制的一种机制,而任务最终是要指派给进程组的,是用户或系统需要完成的具体工作,可以由一个或多个进程共同完成。进程组和任务之间的关系体现在,进程组为任务的执行提供了组织和管理的框架,使得多个进程可以协同工作以完成复杂的任务。

我们使用如下监控脚本先观察一个现象:

ps axj | head -1 && ps axj | grep sshd

上述第一行以 -D 结尾的就是服务器(守护进程)。它的PPID是1。下面来介绍上述选项的意义:

  • COMMAND:启动的进程命令名称
  • TIME:进程启动的时长
  • UID:是谁启动的
  • STAT:状态
  • TPGID:当前进程组和终端的关系(如果是-1,则没有任何关系)
  • TTY:代表哪一个终端
  • SID:当前进程的会话ID
  • PGID:当前进程所属的进程组
  • PID:当前进程自己的ID
  • PPID:当前进程的父进程的ID

下面我把三个进程放到后台运行,并使用如下监控脚本观察现象:

ps ajx | head -1 && ps axj | grep sleep

  • 上述我创建了三个进程,可以确定的是这三个进程的PPID都是一样的,因为父进程都是Bash。上述三个进程是属于同一个进程组(PGID)的,且会发现启动的第一个进程是进程组的组长 

上述三个进程的会话ID(SID)为6821,即bash。我们通过下面的监控脚本观察: 

我们发现bash也属于这个会话组 

我们来看下面这个图:

一旦我们登陆linux,linux会我们创建一个会话。会话内部由多个进程组构成。登陆后会给我们加载bash,所以其内部必须有一个前台进程组(任何时刻,只能有一个前台进程组)。0个或多个后台进程组。 

再使用如下监控脚本观察上述的会话13160:

图中可以看出,bash自己就是一个进程,自己就是进程组的组长,也是会话中的老大(会话前台进程组)。 所以一开始创建的三个进程,它们的会话SID均为13160,即bash。

后续如果我们再自己启动新进程 && 启动进程组,它依旧属于bash自己的会话:

我们使用jobs命令查看系统中的任务:

前面的数字表示任务号。 

先前这三个进程是放到后台运行的。我们使用fg 1命令把此任务提到前台: 

当把后台进程提到前台后,会发现我shell命令用不起来了。因为我们只能有一个前台进程组。一个session,只能有一个前台进程在运行,键盘信号只能发给前台进程。当我们把刚才的后台进程组提到前台,那么我bash命令行解释器会自动退到后台进程。那么自然就没有办法接受你的输入了。 

我们可以通过ctrl+c结束掉当前的前台进程: 

可以看到1号任务被结束掉。 

总结:

  • 当你在命令行中启动一个进程时,这可以被视为在一个会话中启动了一个进程组,用于执行特定任务。进程通过fork创建的子进程,通常仍然属于当前的会话。
  • 以Windows系统为例,当你觉得系统卡顿时,可能会选择注销用户。注销实际上是结束当前用户的会话并重新建立一个新的会话。卡顿的原因通常是因为在当前会话中启动了大量的进程,而注销会终止这些进程组。

注意:

  • 在登录状态下启动的网络服务器及其派生的子进程,默认情况下也属于当前会话。这意味着服务器的运行可能会受到用户登录和注销的影响。
  • 为了确保网络服务器能够独立、稳定地运行,不受用户会话的影响,我们需要将其脱离当前会话,使其成为一个独立的进程组,并开启一个新的会话。这样,即使多个用户同时登录,他们各自的会话也是相互独立的,操作各自的bash时不会相互干扰。
  • 这种独立运行、自成进程组且能够持续运行的进程被称为守护进程。它们不依赖于特定的控制终端,能够在后台稳定地执行任务,确保系统服务的连续性和稳定性。

守护进程化的方式 

我们这里有三种方式让自己的进程守护进程化:

  • 自己写daemon函数,推荐使用这种方式(下面的TCP网络程序中的daemon函数就是自己模拟实现的)
  • 用系统的daemon函数
  • nohup命令

TCP网络程序(守护进程化)

为了实现TCP网络程序的守护进程化,我们需要确保服务器在后台稳定运行,不受前台终端的影响。为此,我们创建一个daemon.hpp文件,并在其中实现守护进程的主要逻辑。以下是修改后的代码逻辑描述:

  • 首先,我们通过调用signal函数来忽略SIGCLD、SIGPIPE和SIGSTOP信号。
  • 接下来,虽然不是必需的,但通常我们会更改守护进程的工作目录,以确保它以更安全的方式运行。通过调用chdir函数,我们可以将守护进程的工作目录更改为根目录(/),这样它就能以绝对路径的形式访问系统资源。
  • 然后,我们创建一个子进程通过fork函数,并立即让父进程退出。这是为了确保子进程不会成为进程组组长,从而能够创建自己的独立会话。(setsid函数要求当前进程不能是进程组组长。)子进程继续执行后续代码,而父进程由于已经完成了它的任务,所以选择退出。
  • 在子进程中,我们调用setsid函数来创建一个新的会话。这个步骤是关键,因为它确保子进程成为新会话的领导者,并且与之前的终端会话完全脱离。这使得守护进程能够独立于其他终端运行,不受其影响。
  • 最后,为了确保守护进程不会与任何终端交互,我们将它的标准输入、标准输出和标准错误都重定向到/dev/null/dev/null是一个特殊的设备文件,它会丢弃所有写入其中的数据,读取它会立即返回文件结束(EOF)。通过重定向这些文件描述符,我们确保守护进程不会意外地接收或发送任何用户输入或输出。(该操作不是必须的)

注意:

  • signal(SIGCLD, SIG_IGN);:SIGCLD(在较新的系统中可能是SIGCHLD)信号是在子进程终止或停止时发送给其父进程的。守护进程通常不关心其子进程的结束,因此可以选择忽略这个信号。
  • signal(SIGPIPE, SIG_IGN);:SIGPIPE信号是在进程尝试写入一个已经被关闭的管道或socket时发送的。对于服务器来说,如果客户端意外断开连接,服务器可能会尝试写入已关闭的socket,从而触发SIGPIPE。忽略这个信号可以防止服务器因此而终止。
  • signal(SIGSTOP, SIG_IGN);:SIGSTOP信号用于暂停进程的执行。防止守护进程被暂停。

dameon.hpp文件代码如下: 

#pragma once

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


const std::string nullfile = "/dev/null";

void Deamon(const std::string &cwd = " ")
{
    //1.忽略其它信号
    signal(SIGCLD,SIG_IGN);
    signal(SIGPIPE,SIG_IGN);
    signal(SIGSTOP,SIG_IGN);

    // 2. 将自己变成独立的会话
    if (fork()>0) exit(0);//父进程退出,setsid不能是进程组组长
    setsid();//子进程设置为独立会话

    // 3. 更改当前调用进程的工作目录
    if(!cwd.empty())
    {
        chdir(cwd.c_str());
    }
    //4.标准输入、标准输出,标准错误重定向至/dev/null
    int fd = open(nullfile.c_str(),O_RDWR);
    if(fd > 0)
    {
        dup2(fd,0);
        dup2(fd,1);
        dup2(fd,2);
        close(fd);
    }
}

上述我们将标准输入、标准输出、标准错误重定向到/dev/null,那么我运行后打印的日志也就不见了。

解决办法如下:

我们在log.hpp打印日志文件那封装了三种打印方法,其中使用Onefile或者Classfile模式可以将信息打印到当前可执行文件路径下的log文件夹下的log.txt文件。

class Log
{
public:
    Log()
    {
        printMethod = Onefile;
        path = "./log/";
    }
    void Enable(int method)
    {
        printMethod = method;
    }
    std::string levelToString(int level)
    {
        switch (level)
        {
        case Info:
            return "Info";
        case Debug:
            return "Debug";
        case Warning:
            return "Warning";
        case Error:
            return "Error";
        case Fatal:
            return "Fatal";
        default:
            return "None";
        }
    }

    void printLog(int level, const std::string &logtxt)
    {
        switch (printMethod)
        {
        case Screen:
            std::cout << logtxt << std::endl;
            break;
        case Onefile:
            printOneFile(LogFile, logtxt);
            break;
        case Classfile:
            printClassFile(level, logtxt);
            break;
        default:
            break;
        }
    }
    void printOneFile(const std::string &logname, const std::string &logtxt)
    {
        std::string _logname = path + logname;
        int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); // "log.txt"
        if (fd < 0)
            return;
        write(fd, logtxt.c_str(), logtxt.size());
        close(fd);
    }
    void printClassFile(int level, const std::string &logtxt)
    {
        std::string filename = LogFile;
        filename += ".";
        filename += levelToString(level); // "log.txt.Debug/Warning/Fatal"
        printOneFile(filename, logtxt);
    }

    ~Log()
    {
    }
    void operator()(int level, const char *format, ...)
    {
        time_t t = time(nullptr);
        struct tm *ctime = localtime(&t);
        char leftbuffer[SIZE];
        snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),
                 ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
                 ctime->tm_hour, ctime->tm_min, ctime->tm_sec);

        va_list s;
        va_start(s, format);
        char rightbuffer[SIZE];
        vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
        va_end(s);

        // 格式:默认部分+自定义部分
        char logtxt[SIZE * 2];
        snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);

        // printf("%s", logtxt); // 暂时打印
        printLog(level, logtxt);
    }

private:
    int printMethod;
    std::string path;
};

最后,我们只需要在启动服务端的时候调用此daemon函数,父进程进入daemon函数,出来就是子进程充当的守护进程:

测试结果:

现在我们运行服务端,通过下面的监控脚本辅助观察信息:

ps axj | head -1 && ps axj | grep tcpserverd
ps axj | head -1 && ps axj | grep sshd

运行代码,我们发现这次没有打印套接字创建成功和绑定成功等信息,因为我们将标准输出重定向到log.txt文件夹当中了。我们用ps命令查看该进程,会发现该进程的TPGID为-1,TTY显示的是?,也就意味着该进程已经与终端去关联了。此外PID、PGID、SID均是一样的,可以看出已经是守护进程了:

现在就相当于把代码部署到了Linux中,现在运行客户端,能够正常与服务端通信。即使我们把电脑关掉了,此服务端也是一直在运行的。除非我们kill -9杀掉此进程。 

daemon函数创建守护进程 

实际当我们创建守护进程时可以直接调用daemon接口进行创建,daemon函数的函数原型如下: 

参数说明:

  • 如果参数nochdir为0,则将守护进程的工作目录该为根目录,否则不做处理。
  • 如果参数noclose为0,则将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null,否则不做处理。

使用示例:

#include <unistd.h>
 
int main()
{
	daemon(0, 0);
	while (1);
	return 0;
}

测试结果: 

TCP协议通讯流程

TCP协议通讯一般流程总览

下图是基于TCP协议的客户端/服务器程序的一般流程:

下面我们结合TCP协议的通信流程,来初步认识一下三次握手和四次挥手,以及建立连接和断开连接与各个网络接口之间的对应关系。 

三次握手的过程

服务器初始化

当服务器完成套接字创建、绑定以及监听的初始化动作之后,就可以调用accept函数阻塞等待客户端发起请求连接了。

服务器初始化:

  • 调用socket,创建文件描述符。
  • 调用bind,将当前的文件描述符和IP/PORT绑定在一起,如果这个端口已经被其他进程占用了,就会bind失败。
  • 调用listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的accept做好准备。
  • 调用accept,并阻塞,等待客户端连接到来。

建立连接

而客户端在完成套接字创建后,就会在合适的时候通过connect函数向服务器发起连接请求,而客户端在connect的时候本质是通过某种方式向服务器三次握手,因此connect的作用实际就是触发三次握手。

建立连接的过程:

  • 调用socket,创建文件描述符。
  • 调用connect,向服务器发起连接请求。
  • connect会发出SYN段并阻塞等待服务器应答(第一次)。
  • 服务器收到客户端的SYN,会应答一个SYN-ACK段表示“同意建立连接”(第二次)。
  • 客户端收到SYN-ACK后会从connect返回,同时应答一个ACK段(第三次)。

这个建立连接的过程,通常称为三次握手。

需要注意的是,连接并不是立马建立成功的,由于TCP属于传输层协议,因此在建立连接时双方的操作系统会自主进行三次协商,最后连接才会建立成功

我们通过下面这个例子来加强理解: 

数据传输的过程 

数据交互

一旦连接在TCP协议层面成功建立,并通过accept函数在用户层被捕获,客户端和服务器就可以开始数据交互。重要的是要理解,accept函数并不参与TCP的三次握手过程;这是由底层TCP/IP协议栈自动处理的。accept的作用是将已经由底层TCP/IP协议栈建立好的连接“提取”到用户层以供应用程序使用。如果此时没有现成的连接可用,accept函数会阻塞,直到有新的连接建立。

在数据传输阶段,应用程序主要使用read和write函数。write用于向连接写入数据,也就是发送数据;而read用于从连接读取数据,也就是接收数据。当调用write函数时,应用程序的数据会被复制到操作系统的内核缓冲区中,至于何时发送这些数据以及每次发送多少,是由TCP协议根据网络状况和接收方的接收窗口大小等因素来决定的。相应地,当调用read函数时,操作系统会从内核缓冲区中读取数据并传递给应用程序。

数据传输的过程:

  • 建立连接后,TCP协议提供全双工的通信服务,所谓全双工的意思是,在同一条连接中,同一时刻,通信双方可以同时写数据,相对的概念叫做半双工,同一条连接在同一时刻,只能由一方来写数据。
  • 服务器从accept返回后立刻调用read,读socket就像读管道一样,如果没有数据到达就阻塞等待。
  • 这时客户端调用write发送请求给服务器,服务器收到后从read返回,对客户端的请求进行处理,在此期间客户端调用read阻塞等待服务器端应答。
  • 服务器调用write将处理的结果发回给客户端,再次调用read阻塞等待下一条请求。
  • 客户端收到后从read返回,发送下一条请求,如此循环下去。

四次挥手的过程 

断开连接

当双方通信结束之后,需要通过四次挥手的方案使双方断开连接,当客户端调用close关闭连接后,服务器最终也会关闭对应的连接。而其中一次close就对应两次挥手,因此一对close最终对应的就是四次挥手。

断开连接的过程:

  • 如果客户端没有更多的请求了,就调用close关闭连接,客户端会向服务器发送FIN段(第一次)。
  • 此时服务器收到FIN后,会回应一个ACK,同时read会返回0(第二次)。
  • read返回之后,服务器就知道客户端关闭了连接,也调用close关闭连接,这个时候服务器会向客户端发送一个FIN(第三次)。
  • 客户端收到FIN,再返回一个ACK给服务器(第四次)。
  • 这个断开连接的过程,通常称为四次挥手。

我们通过下面图片这个例子来加深理解:

注意通讯流程与socket API之间的对应关系

在学习socket API时要注意应用程序和TCP协议是如何交互的:

  • 应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect会发出SYN段。
  • 应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read返回0就表明收到了FIN段。

为什么要断开连接?

  • 建立连接的核心目的是确保通信双方之间有一个专门的通信通道,这使得可以实施各种传输策略,从而确保数据的可靠传输。然而,如果通信结束后连接不被断开,系统资源会逐渐耗尽。
  • 服务器需要处理大量的连接,因此操作系统必须对这些连接进行有效的管理。管理连接时,通常采用“先描述后组织”的策略。每当一个新连接建立,服务器会为该连接创建一个数据结构,并将这些数据结构组织成一个链表或其他数据结构。这样,操作系统对连接的管理就转化为对链表的增、删、查、改操作。
  • 如果一个连接在通信结束后仍然保持不断开,操作系统就必须持续为该连接维护其数据结构,这不仅消耗时间,还占用宝贵的内存空间。因此,为了避免系统资源的浪费,通信结束后应该及时断开连接。这也是TCP协议相较于UDP协议更为复杂的原因之一,因为TCP需要管理连接状态,确保数据的可靠传输。

TCP 和 UDP 对比

  • 可靠传输 vs 不可靠传输
  • 有连接 vs 无连接
  • 字节流 vs 数据报 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值