计算机网络(三) —— 简单Udp网络程序

目录

一,初始化服务器

1.0 辅助文件

1.1 socket函数

1.2 填充sockaddr结构体

1.3 bind绑定函数

1.4 字符串IP和整数IP的转换

二,运行服务器

2.1 接收

2.2 处理

2.3 返回

三,客户端实现

3.1 UdpClient.cc 实现

 3.2 Main.cc 实现

3.3 效果展示

3.4 代码分层

四,两种场景

4.1 发送部分命令给服务器并返回结果

4.2 实现Linux多终端窗口群聊

4.3 实现Windows做客户端,Linux做服务器群聊


一,初始化服务器

1.0 辅助文件

Log.hpp 日志打印文件:

#pragma once

#include <iostream>
#include <time.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

#ifndef _LOG_H_
#define _LOG_H_

#include <ctime>

#define Debug 0
#define Notice 1
#define Warning 2
#define Error 3

const std::string msg[] = {
    "Debug",
    "Notice",
    "Warning",
    "Error"};

std::ostream &Log(std::string message, int level)
{
    std::cout << " | " << (unsigned)time(nullptr) << " | " << msg[level] << " | " << message;
    return std::cout;
}

#endif

makefile文件:

.PHONY:all
all:udpserver udpclient
udpserver:Main.cc
	g++ -o $@ $^ -std=c++11
udpclient:UdpClient.cc
	g++ -o $@ $^ -lpthread -std=c++11

.PHONY:clean
clean:
	rm -f udpserver udpclient 

UdpServer.cc 部分代码:

#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <functional>
#include <unordered_map>

#include "Log.hpp"

enum
{
    SOCKET_ERR = 1,
    BIND_ERR
};

uint16_t defaultport = 8080;
std::string defaultip = "0.0.0.0"; // 设置为0,表示任意地址绑定
const int size = 1024;

class Udpserver
{
public:
    Udpserver(const uint16_t &port = defaultport, const std::string &ip = defaultip)
        : _sockfd(0), _port(port), _ip(ip), _isrunning(false)
    {
    }

   
    ~Udpserver()
    {
        if (_sockfd > 0)
            close(_sockfd);
    }

private:
    int _sockfd;                                                      // 网络文件描述符
    uint16_t _port;                                                   // 表明服务器进程的端口号
    std::string _ip;                                                  // 地址绑定
    bool _isrunning;                                                  // 表明服务器是否在运行状态
    std::unordered_map<std::string, struct sockaddr_in> _online_user; // 第一个键值是ip,第二个键值是ip对应的套接字结构体信息
};

1.1 socket函数

我们首先会把服务器封装成一个类,然后定义一个服务器对象之后做的第一件事就是初始化服务器,而初始化服务器的第一件事,就是创建套接字,下面介绍以下socket接口:

参数说明:

  • domain:表示创建套接字的类型,该参数相当于struct sockaddr结构体的前16个比特位。在man手册中往下滑有很多很多AF开头的选项,但是我们目前只要关心几个:如果是本地通信,该参数就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
  • type:表示创建套接字时所需的服务类型,最常见的就是:①SOCK_DGRAM,基于UDP的用户数据报服务    ②SOCK_STREAM,基于TCP的流式套接字服务
  • protocol:表示创建套接字的协议类别,可以指名为TCP或UDP,但该参数一般直接设置为0即可,表示默认,此时就会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议
  • 当套接字创建成功后,会直接返回一个文件描述符,创建失败返回-1,同时错误码被设置

问题:socket创建套接字时干了什么?

解答:上面说socket创建成功后会返回一个文件描述符,所以最简单的说法就是“socket创建套接字本质就是打开了一个文件”,以前的打开文件对应的一般是磁盘,把磁盘的文件加载到内存中,并且在进程内部构建files_struct,并且包括文件描述符表;而这里的打开“网络文件”,对应的就是网卡了,通过网卡的驱动层,在操作系统中构建“网卡文件”,通过操作“网卡文件”实现对网卡的宏观控制

下面是服务器初始化函数创建套接字代码: 


    void Init()
    {
        // 1,创建Udp套接字,Udp的socket是全双工的,允许被同时读写的
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); // 表示使用IPv4协议,类型为Udp用户数据报
        if (_sockfd < 0)                          // 创建失败
        {
            Log("socket create error", Error) << "\n";
            exit(SOCKET_ERR);
        }
        Log("socket create success", Debug) << "\n"; // 创建成功,输出日志
        
        // ...
    }

创建成功后,_sockfd会被赋值成3 

1.2 填充sockaddr结构体

在上一篇文章的 4.2 sockaddr结构的时候,讲到过,在代码部分我们会对该结构体进行填充,原因请参照上篇博客:计算机网络(二) —— 网络编程套接字-CSDN博客

创建完套接字后,服务器初始化第一步完成,接着第二步就是构建填充sockaddr结构体了,如下代码:

 // 2,创建和填充sockaddr结构体
 struct sockaddr_in local;
 // 一
 bzero(&local, sizeof(local)); // 把指定类型的指定大小初始化为0,功能类似于memset

 // 二
 // 然后就是将我们自己的服务器的一些信息填充进这个结构体里,方便socket API使用
 local.sin_family = AF_INET; // 表明自身的结构体类型为IPv4   这个family是在宏定义用##来实现的

 // 三
 // local.sin_port = _port; //表明服务器将来要绑定的端口号 -- 需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
 // 除了发正常消息外,我也要把我的端口号发给客户端,这样客户端发给我的时候才能找到我,所以端口号需要发送到网络里的,所以一开始我们把这个东东填充到结构体里时,必须是网络字节序
 local.sin_port = htons(_port); // 把主机序列转化成网络序列,大端不变,小端会转大端

 // 四
 // local.sin_addr = _ip;  //s表示socket,然后in表示inet,addr表示IP地址(ifconfig命令)
 // 我们需要先把string的ip --> uint32_t的ip,并且必须是网络序列的,而这样的各种转化都是有相应的接口的,不需要我们自己写了
 local.sin_addr.s_addr = inet_addr(_ip.c_str()); // inet_addr表示把字符串转为网络字节序列也就是uint32_t
 // 查看sockaddr_in的定义后可以发现,sin_addr其实是一个struct类型,这个类型里的s_addr才是要转化的类型
 //  local.sin_addr.s_addr = htonl(INADDR_ANY); // 这个宏表示任意ip地址,数值为0

1.3 bind绑定函数

上面两步操作完成之后,就是绑定了,下面介绍以下bind函数:

参数解释:

  • sockfd:就是之前socket函数返回的套接字
  • addr:这个就是我们前面填充的sockaddr结构体的指针,里面有绑定的所有必须信息
  • addrlen:传入的sockaddr结构体的长度

下面是初始化服务器的第三步绑定端口的代码:

// 3,绑定套接字
//  local是在地址空间的用户栈上定义的,上面三个参数的所有操作都是在栈上填好,并没有将local与网络套接字socket相关联,所以需要绑定bind函数
int n = bind(_sockfd, (const struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
    Log("port bind error", Error) << "\n";
    exit(BIND_ERR);
}
Log("port bind success", Debug) << "\n";
std::cout << "Waiting user to connect ... " << std::endl;

1.4 字符串IP和整数IP的转换

网络传输的数据是寸土寸金的,如果我们在传输IP时以字符串的点分十进制进行IP传输,那么一个IP就需要15字节,但实际上不需要消耗这么多

IP地址可以划分位四个区域,每个区域取值都是0~255,每个区域是8个比特位,我们就可以只用32比特位表示四个区域来表示IP:

所以完成上面的操作就需要将IP在整数二号字符串之间做转换

首先是数字IP转字符串IP:

然后是字符串IP转数字IP: 

 inet_addr函数与inet_ntoa函数

上面的步骤了解一下即可,而且实际实现起来比较麻烦,所以系统为我们提供了相应的转换函数,我们直接调用即可:

二,运行服务器

2.1 接收

服务器初始化完成后,紧接着就是启动辣,服务器的任务就是周而复始为我们提供服务,所以运行起来一般不会退出,因此服务器的运行代码应该是一个死循环。

服务器运行起来后有三个基础动作

  1. 接收来自客户端的信息
  2. 处理信息
  3. 将结果返回给客户端

第一步就是接收,用到的函数名称为:recvfrom函数,下面来介绍一下这个函数 :

参数有点多,但不复杂,解释一下:

  • sockfd:老朋友了,socket的返回值
  • buf:表示要将读取到的数据放到哪里
  • len:表示要读取数据的字节数
  • flags:表示读取的方式,一般设为0,表示阻塞读取,没数据来的时候就给我等着
  • src_addr输出型参数,会保存发送方的协议结组,IP地址,端口号等,简单来说就是,这个字段会告诉程序“谁发数据过来的”,是为了后面发回去的之后,知道对方是谁在哪(传入结构体地址时需要强转,代码会有)
  • addrlen:表示读取到的src_addr的长度,需要和src_addr的大小一致
  • 返回值:表示读取成功返回实际读取到的字节数,读取失败返回-1,错误码被设置
void Run()
{
    _isrunning = true;
    char inbuffer[size]; //
    while (_isrunning)   // 服务器应该能周而复始一直在运行的,所以是死循环
    {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        // 读取数据,从指定套接字里读取消息,然后把读取到的数据放到缓冲区中并指名长度,最后两个参数作为输出型参数,保存对方的IP和port等信息,方便后面我发消息回去
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&client, &len);
        if (n < 0)
        {
            Log("recvfrom error", Warning) << "\n";
            continue;
        }

        // ...
}

2.2 处理

上面拿到数据之后,就是要对数据进行处理辣

但是这个处理,就是根据具体的业务需要,由公司具体实施了,我们这里只用很简单的几行代码模拟处理过程,后面会有几种场景专门针对处理方法做调整,如下代码:

inbuffer[n] = 0;
std::string info = inbuffer;
std::string echo_string = "server echo# " + info;
//就是简单的字符串拼接,最后的echo_string就是处理完后最终形成的数据

 std::string echo_string = "server echo# " + info;

2.3 返回

当处理完数据后,紧接着就是最后一步辣,就是把结果返回给客户端,返回用到的socket API是sendto函数,下面来介绍下这个函数:

它的参数和recvfrom是一样的,这里就不过多介绍了

// 处理完后要再发送回对方
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&client, len); // 把数据发回给对方

三,客户端实现

3.1 UdpClient.cc 实现

在我们这个简单的Udp网络程序中,客户端的工作其实非常简单,就是“发送数据”,“接收数据”,“打印数据”,所以实现方面比服务器简单许多。

客户端一般是主动发数据给服务器的一方,所以客户端也要有相应的两个过程:

  • 创建套接字
  • 填写sockaddr结构体

问题:为什么客户端不需要我们手动绑定端口?

解答: 

  • 客户端都是最先发出请求的一方,所以服务器的IP地址和端口必须让客户端知道,因为服务器一旦启动,基本情况下不会关闭,所以端口号也不会更改了,所以服务器需要进行端口号绑定
  • 客户端是经常开启和关闭的,因此客户端的端口号是经常变化的,所以每次都绑定会加大成本,所以客户端的端口号只要能标识“唯一性”就可以了,只要客户端首次发送数据的时候,操作系统会自动帮我们绑定一个端口
// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }
    string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    // 构建服务器信息,因为客户端发给服务端需要知道服务端的ip和port
    struct ThreadData td;
    bzero(&td.server, sizeof(td.server));
    td.server.sin_family = AF_INET;
    td.server.sin_port = htons(serverport);                  // 转成网络序列
    td.server.sin_addr.s_addr = inet_addr(serverip.c_str()); // 点分十进制的字符串转化为数字

    td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (td.sockfd < 0)
    {
        cout << "socket error" << endl;
        return 1;
    }
    string message;
    char buffer[1024];
    socklen_t len = sizeof(td.server);
    while (true)
    {
        cout << "Please Enter@ ";
        getline(cin, message);
        // std::cout << message << std::endl;
        // 1. 数据 2. 给谁发
        sendto(td.sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&td.server, len);

        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t s = recvfrom(td.sockfd, buffer, 1023, 0, (struct sockaddr *)&temp, &len);
        if (s > 0)
        {
            buffer[s] = 0;
            cout << buffer << endl;
        }
    }
    close(td.sockfd); // 不用了就和关闭文件描述符一样关闭套接字
    return 0;
}

 3.2 Main.cc 实现

服务器的main函数所在的Main.cc如下:

#include "Udpserver.hpp"
#include "Log.hpp"
#include <memory>
#include <cstdio>
#include <vector>
#include <string>

void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << "port[1024+]\n"
              << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2) // argc表示命令行中命令个数
    {
        Usage(argv[0]);
        exit(0);
    }
    uint16_t port = std::stoi(argv[1]); // 字符串转整数
    std::unique_ptr<Udpserver> svr(new Udpserver(port));
    svr->Init(); // 初始化
    svr->Run(); // 服务器启动

    return 0;
}

3.3 效果展示

3.4 代码分层

 在实际开发场景中,其实很少会和 2.2 一样,直接把处理方法内置进服务器头文件中,所以我们可以在服务器启动前构建好处理函数,然后在服务器 Run 的时候,直接把方法传进去,实现代码分用,解耦,要更改的位置如下:

首先在Main.cc 的main函数前面加上处理方法:

// 代码分用
std::string Handler(const std::string &info, const std::string &clientip, uint16_t clientport)
{
    std::cout << "[" << clientip << ": " << clientport << "]#" << info << std::endl;
    std::string res = "Server get a message: ";
    res += info;
    std::cout << res << std::endl;

    return res;
}

然后就是利用C++的包装器,更改服务器的 Run 函数:

 加上包装器,也可以用typedef代替:

// using func_t = std::function<std::string(const std::string&, const std::string &, uint16_t)>; //c++11包装器
typedef std::function<std::string(const std::string &, const std::string &, uint16_t)> func_t;

 最后就是更改Run函数,代码如下:

void Run(func_t func)
{
    _isrunning = true;
    char inbuffer[size]; //
    while (_isrunning)   // 服务器应该能周而复始一直在运行的,所以是死循环
    {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&client, &len);
        if (n < 0)
        {
            Log("recvfrom error", Warning) << "\n";
            continue;
        }
        // 模拟群聊
        // ①拿到各客户端的ip和端口号
        uint16_t clientport = ntohs(client.sin_port);      // 拿到客户端的端口号,网络序列转主机序列
        std::string clientip = inet_ntoa(client.sin_addr); // 那搭配客户端ip地址,把inet_ntoa四字节ip转化为char*
        
        inbuffer[n] = 0;
        std::string info = inbuffer;

        // 充当了一次数据的处理,下面两条语句被第三条语句代替
        // std::string echo_string = "server echo# " + info;
        // std::cout << echo_string.c_str() << std::endl;
        std::string echo_string = func(info, clientip, clientport);
        // 处理完后要再发送回对方
        sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&client, len); // 把数据发回给对方
    }
}

 完成好后就可以直接实践了:

(可能各位有些混乱,如果遇到了不会了随时评论或私信哦~~,源代码在文章最后会附上 gitee 仓库链接) 

四,两种场景

4.1 发送部分命令给服务器并返回结果

上面的是最简单的客户端服务器的通讯结果,下面我们来搞一点好玩的

出了发送字符串,我们也可以发送部分命令给服务器,服务器处理好命令后再把结果返回给客户端,最后客户端打印

下面我们来更改一下代码,客户端代码不变,要变的就是服务器处理客户端信息的那部分代码,还是和上面代码分用一样,直接在Main.cc里实现处理函数,然后再传给Run函数

下面是Mani.cc 的处理命令的方法,用到的新函数是 popen,作用是直接执行命令并返回结果,有兴趣可以自行了解下:

Main.cc:

// 场景一,实现命令
bool SafeCheck(const std::string &cmd)
{
    std::vector<std::string> key_word = {
        "rm",
        "mv",
        "cp",
        "kill",
        "sudo",
        "unlink",
        "uninstall",
        "yum",
        "tcp",
        "while"};
    for (auto &word : key_word)
    {
        auto pos = cmd.find(word);
        if (pos != std::string::npos)
            return false; // 在你的命令中找到上面任意一个的话,就是不合法的,直接返回falsse
    }
    return true;
}
std::string ExcuteCommand(const std::string &cmd)
{
    std::cout << "get a request cmd: " << cmd << std::endl;
    if (!SafeCheck(cmd))
        return "Bad man";

    FILE *fp = popen(cmd.c_str(), "r");
    if (fp == NULL)
    {
        perror("popen");
        return "error";
    }
    std::string result;
    char buffer[4096];
    while (true)
    {
        char *ok = fgets(buffer, sizeof(buffer), fp);
        if (ok == nullptr)
            break; // 为空了,说明读完了
        result += buffer;
    }
    pclose(fp);
    return result;
}

 然后是UdpServer.hpp 的Run函数的更改:

先更改一下包装器函数的参数数量:

下面是效果展示:

 

4.2 实现Linux多终端窗口群聊

实现群聊我们要做下面几点工作:

  • 能够保存连接服务器的IP和端口,一个人发消息后遍历保存的IP和端口,把消息往所有连接服务器的IP都发送一次
  • 能够让群聊所有人知道是谁发的,也就是消息前要带上IP和端口
  • 利用dup2函数,实现两个窗口,一个窗口只负责发消息,一个窗口只负责收消息

先看效果演示: 

 有点复杂,但是不用担心,我们一步一步来

①首先是我们能够保存发起连接的用户的IP和端口

现在就要用到我们最开始就定义好的一个unordered_map了:

 然后我们在UdpServer.hpp里直接实现一个添加用户的函数:

void CheckUser(const struct sockaddr_in &client, const std::string clientip, uint16_t clientport) // 检查是否为新用户
{
    auto iter = _online_user.find(clientip); // 去哈希表去找对应IP的信息
    if (iter == _online_user.end())          // 如果上面这个查找的迭代器走到了结尾,说明哈希表里还没有这个ip,添加
    {
        _online_user.insert({clientip, client}); // 把ip和对应的套接字结构体插入
        std::cout << "[" << clientip << ": " << clientport << "]add to online user" << std::endl;
    }
    else // 如果存在了,则什么都不做
    {
    }
}

更改Run函数,先将Run函数参数去掉,因为群聊场景不需要传处理方法:

 ②遍历哈希表,对每一个IP都发送

上面的Run函数已经出现了,下面是实现Broadcast发送函数的代码:

void Broadcast(const std::string &info, const std::string clientip, uint16_t clientport)
{
    for (const auto &user : _online_user) // 遍历在线用户,遍历发送
    {
        // 编辑发送形式
        std::string message = "[";
        message += clientip;
        message += ":";
        message += std::to_string(clientport);
        message += "]#";
        message += info;
        socklen_t len = sizeof(user.second);
        sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)(&user.second), len); // sendto发送回给对方
    }
}

 ③最后就是客户端的调整了,我们可以使用dup2重定向函数,实现两个终端,一个终端窗口只发消息,一个窗口接收消息,就和上面的演示一样

 

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

std::string terminal = "/dev/pts/2";

int OpenTerminal()
{
    int fd = open(terminal.c_str(), O_WRONLY);
    if (fd < 0) // 打开失败
    {
        std::cerr << "open termial error";
        return 1;
    }

    dup2(fd, 2);
    // 测试
    //  printf("hello world\n"); // 把即将打印在当前终端的内容往特定路径的终端打

    // close(fd);
    return 0;
}

(可能有点复杂,如果看到这里的小伙伴有不懂或者有问题的,欢迎随时评论和私聊)

4.3 实现Windows做客户端,Linux做服务器群聊

如标题一样,Linux做服务器,Windows做客户端是非常常见的事情

我们可以在Windows本地实现一个客户端,然后和Linux服务器做通信,因为网络基础入门说过,虽然操作系统不一样,但是网络协议栈是一样的,因为这时规定,生产厂家必须遵守规则

下面是VS2022在Windows环境下的客户端代码:

#define _WINSOCK_DEPRECATED_NO_WARNINGS 1
#include <iostream>
#include<WinSock2.h> //这两个W开头的头文件顺序必须是这样,反过来编译时就会报错
#include<Windows.h>
#include <cstdlib>
#include <string>
#include<cstdio>
#pragma comment(lib, "ws2_32.lib")

#include<thread>
uint16_t serverport = 8080;
std::string serverip = "58.87.91.241";

struct ThreadData
{
	struct sockaddr_in server;
	SOCKET sockfd;
	std::string serverip;
};

void* send_message(void* args)
{
	ThreadData* td = static_cast<ThreadData*>(args);
	std::string message;
	std::cout << td->serverip << " coming... " << std::endl;

	while (true)
	{
		std::cout << "Please Enter: ";
		std::getline(std::cin, message);
		sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&(td->server), sizeof(td->server));
	}
}
void* recv_message(void* args)
{
	ThreadData* td = static_cast<ThreadData*>(args);
	char buffer[1024];
	while (true)
	{
		memset(buffer, 0, sizeof(buffer)); //每次接收消息前清空缓冲区
		int len = sizeof(td->server);
		int s = recvfrom(td->sockfd, buffer, 1023, 0, (struct sockaddr*)&(td->server), &len);
		if (s > 0)
		{
			buffer[s] = 0;
			std::cout << buffer << std::endl;
		}
	}
}

int main()
{
	WSADATA wsd;
	WSAStartup(MAKEWORD(2, 3), &wsd);
	struct ThreadData td;

	// 构建服务器信息,因为客户端发给服务端需要知道服务端的ip和port
	memset(&td.server, 0, sizeof(td.server));
	td.server.sin_family = AF_INET;
	td.server.sin_port = htons(serverport);                  // 转成网络序列
	td.server.sin_addr.s_addr = inet_addr(serverip.c_str()); // 点分十进制的字符串转化为数字

	td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
	if (td.sockfd < 0)
	{
		std::cout << "socket error" << std::endl;
		return 1;
	}
	td.serverip = serverip;
	std::thread sender(send_message, &td);
	std::thread recver(recv_message, &td);

	sender.join();
	recver.join()
		;
	WSACleanup();
	return 0;
}

代码和Linux差不多,也是多线程,只是Windows对于库的处理有点不一样,下面是效果演示:

两边打印中文时会乱码,其实是因为两边的编码不一致,我们暂时不考虑,反正能达到Windows和Linux实现网络通信的目的就行了莫

代码gitee链接:计算机网络/网络编程套接字/Udp · 小堃学编程/Linux学习 - 码云 - 开源中国 (gitee.com) 

  • 8
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值