【网络编程】应用层协议——HTTP协议

一、HTTP协议基本认识

HTTP全称超文本传输协议,是一个简单的请求-响应协议,HTTP通常运行在TCP之上。

在上一章【网络编程】自定义协议+Json序列化与反序列化实现的网络计算机中对数据的处理计算就是我们自己手写的应用层协议。

在编写网络通信代码时,我们可以自己进行协议的定制,但实际有很多优秀的工程师早就已经写出了许多非常成熟的应用层协议,其中最典型的就是HTTP协议。

二、URL的认识

其实URL就是平时我们所说的网址。
在这里插入图片描述

使用浏览器访问URL:通过域名找到唯一一台网络主机,而域名后面就是文件路径,通过文件找到我们想要的资源,可能是图片或者文本,把资源返回给浏览器。

在这里插入图片描述

这个/就是web根目录,一般而言,可以是Linux下的任意一个目录。

http的本质就是通过http协议从服务端拿下文件资源,而因为文件资源的种类特别多,http都能搞定,所以叫做超文本传输协议

2.1 urlencode和urldecode

像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现.
比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义.
转义的规则如下:取出字符的ASCII码,转成16进制,然后前面加上百分号即可。
比如+被转成了%2B,这个过程就叫做encode,decode就是把特殊符号转回去。

这个过程不需要我们自己做,有需要解码的时候在网上查即可。
URL编码/解码

实际当服务器拿到对应的URL后,也需要对编码后的参数进行解码,此时服务器才能拿到你想要传递的参数,解码实际就是编码的逆过程。

三、HTTP协议格式

HTTP是基于请求和响应的应用层服务,作为客户端,你可以向服务器发起request,服务器收到这个request后,会对这个request做数据分析,得出你想要访问什么资源,然后服务器再构建response,完成这一次HTTP的请求,返回响应。

由于HTTP是基于请求和响应的应用层访问,因此我们必须要知道HTTP对应的请求格式和响应格式,这就是学习HTTP的重点。

3.1 HTTP请求与响应格式

HTTP请求与响应协议格式如下:
在这里插入图片描述

3.2 如何保证请求和响应被应用层完整读取?

首先要知道http所有请求字段都是按行为单位的字符串

比如说对于http请求,我们使用while循环按行读取,直到遇到空行为止,这样可以保证把请求行和请求报头读完。而报头的key: val结构就有一个属性是Content-Length: XXX,它表示的是正文的长度,由此,正文的也能完整读取了。

3.3 请求和响应如何做到序列化和反序列化?

如果现在我们想获得name的key值,怎么把数据从字符串中反序列化呢?

1️⃣ 对于报头部分,其实请求/响应报头布置包含name: val信息,后边还有字符串分隔符:name: val\r\n,序列化直接发送就行,想要反序列化就可以按照\r\n来按行提取所以http报头是用特殊字符进行信息分离
2️⃣ 对于正文部分,不用处理,如果需要的话,可以设计自定义序列化与反序列化方案。

3.4 代码验证请求格式

// Protocol.hpp
#pragma once
#include <iostream>
#include <string>
#include <vector>

class HttpRequest
{
    
    
public:
    std::string inbuf;// 缓冲区
    std::string req_line;// 请求行
    std::vector<std::string> req_header;// 请求报头
    std::string req_body;// 请求正文

    std::string req_method;// 请求方法
    std::string req_url;// url
    std::string http_version;// http版本
};

class HttpResponse
{
    
    
public:
    std::string outbuf;// 缓冲区
};

// HttpServer.hpp
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
#include <cstdlib>
#include <sys/wait.h>
#include <pthread.h>
#include "Protocol.hpp"

static const uint16_t gport = 8080;
static const int gbacklog = 10;

typedef std::function<void(const HttpRequest&, HttpResponse&)> func_t;

class HttpServer
{
    
    
public:
    HttpServer(func_t func, const uint16_t& port = gport)
        : _func(func)
        , _listensock(-1)
        , _port(port)
    {
    
    }

    void InitServer()
    {
    
    
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if(_listensock == -1)
        {
    
    
            exit(1);
        }
        struct sockaddr_in si;
        // 初始化结构体
        bzero(&si, sizeof si);
        si.sin_family = AF_INET;
        si.sin_port = htons(_port);// 主机转网络序列
        si.sin_addr.s_addr = INADDR_ANY;
        if(bind(_listensock, (struct sockaddr*)&si, sizeof si) < 0)
        {
    
    
            exit(1);
        }
        // 设置监听状态
        if(listen(_listensock, gbacklog) < 0)
        {
    
    
            exit(1);
        }
    }

    void HandlerHttp(int sock)
    {
    
    
        char buf[4096];
        HttpRequest req;
        HttpResponse resp;
        ssize_t n = recv(sock, buf, sizeof buf - 1, 0);
        if(n > 0)
        {
    
    
            buf[n] = '\0';
            req.inbuf = buf;
            // 通过请求获得响应
            _func(req, resp);
            send(sock, resp.outbuf.c_str(), resp.outbuf.size(), 0);
        }
        
    }

    void start()
    {
    
    
        while(1)
        {
    
    
            // 获取新链接
            struct sockaddr_in si;
            socklen_t len = sizeof si;
            int sock = accept(_listensock, (struct sockaddr*)&si, &len);
            if(sock < 0)
            {
    
    
                // 获取链接失败无影响,继续获取即可
                continue;
            }
            
            // 多进程
            pid_t id = fork();
            if(id == 0)// child
            {
    
    
                close(_listensock);
                if(fork() > 0) exit(1);
                HandlerHttp(sock);
                close(sock);
                exit(1);
            }
            close(sock);
            // father
            waitpid(id, nullptr, 0);
        }
    }

private:
    int _listensock;
    uint16_t _port;
    func_t _func;
};

// HttpServer.cc
#include <memory>
#include "HttpServer.hpp"

using namespace std;

bool Get(const HttpRequest& req, HttpResponse& resp)
{
    
    
    cout << "-------------http start-------------" << endl;
    cout << req.inbuf;
    cout << "-------------http end---------------" << endl;
    return true;
}

// ./HttpServer 8080
int main(int argc, char* argv[])
{
    
    
    if(argc != 2)
    {
    
    
        cerr << "Too few passed parameters\r\n\r\n";
        exit(0);
    }
    uint16_t port = atoi(argv[1]);
    unique_ptr<HttpServer> p(new HttpServer(Get, port));
    p->InitServer();
    p->start();
    return 0;
}

这里实现的就是一个简单的TCP服务器,而处理的任务就是把接收到的HTTP请求进行打印即可,服务器会把收到的数据全部放入请求缓冲区,然后直接打印出来。
客户端并不用我们自己实现,有一个现成的客户端就是浏览器。

在这里插入图片描述

  • 说明以下收到的请求:

对于请求行GET / HTTP/1.1
Get表示请求方法。
/ 表示url:url当中的/不能称之为我们云服务器上根目录,这个 /表示的是web根目录,这个web根目录可以是你的机器上的任何一个目录,这个是可以自己指定的,不一定就是Linux的根目录。
在这里插入图片描述
HTTP/1.1表示协议版本

  • 为什么请求要包含版本?

因为客户端的会存在更新的情况,但是有的客户端并没有更新,所以服务端要根据版本来提供不同的服务。

在这里插入图片描述

而请求报头进过验证也是name: val的格式。里面都是属性字段。

3.5 代码验证响应格式

bool Get(const HttpRequest& req, HttpResponse& resp)
{
    
    
    cout << "-------------http start-------------" << endl;
    cout << req.inbuf;
    cout << "-------------http end---------------" << endl;
    // 状态行
    std::string resp_line = "HTTP/1.1 200 OK\r\n";
    // 报头
    std::string resp_header = "Content-Type: text/html\r\n";
    // 空行
    std::string resp_blank = "\r\n";
    // 响应正文
    std::string resp_body = "<html><head></head><body><h1>Hello HTTP</h1></body></html>";
    resp.outbuf += resp_line;
    resp.outbuf += resp_header;
    resp.outbuf += resp_blank;
    resp.outbuf += resp_body;
    return true;
}

3.5.1 telnet命令

telnet 是一种用于远程访问和管理计算机网络设备、服务器和服务的协议和命令行工具。它可以用于连接到运行 Telnet 服务器软件的任何计算机,并在远程计算机上执行命令和操作。

通常我们会使用该命令传参测试你的服务器与其他的服务器是不是能正常访问

  • 使用示例
telnet ip地址  端口

telnet 127.0.0.1 8080

当使用 Telnet 命令连接到远程 IP 地址和端口时,如果连接成功,则会返回类似以下的响应:

Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.

其中,“Trying 127.0.0.1…” 表示正在尝试连接指定的 IP 地址,“Connected to 127.0.0.1.” 表示连接已经建立,Escape character is ‘^]’.” 是提示信息,表示可以使用 Ctrl + ] 来退出 Telnet 命令。


在这里插入图片描述

3.6 解析状态行信息

// Util.hpp
#pragma once

#include <iostream>

class Util
{
    
    
public:
    static std::string getOneLine(std::string& buf, const std::string& sep)
    {
    
    
        auto pos = buf.find(sep);
        if(pos == std::string::npos) return "";
        std::string sub = buf.substr(0, pos);
        // 读取完就删掉
        buf.erase(0, sub.size() + sep.size());
        return sub;
    }
};

// Protocol.hpp
const std::string sep = "\r\n";

class HttpRequest
{
    
    
public:
    void parse()
    {
    
    
        //1、从请求结构体中的inbuf中拿到请求行(第一行),分隔符\r\n
        std::string line = Util::getOneLine(inbuf, sep);
        if(line.empty()) return;
        //2、从请求行中获取三个字段:请求方法、url、请求版本
        std::cout << "line:" << line << std::endl;
        std::stringstream ss(line);
        ss >> req_method >> req_url >> http_version;//自动以空格为分割读取其中的字段
    }
public:
    std::string inbuf;// 缓冲区
    // std::string req_line;// 请求行
    // std::vector<std::string> req_header;// 请求报头
    // std::string req_body;// 请求正文

    std::string req_method;// 请求方法
    std::string req_url;// url
    std::string http_version;// http版本
};

这些代码的目的是把请求状态行的信息解析出来:
在这里插入图片描述

3.7 web根目录

上图的url 的/是web根目录。
这个根目录可以我们自己设置,比如果我们就设置在当前路径下:
在这里插入图片描述

// 默认路径
const std::string default_root = "./wwwroot";
// 添加web默认路径
std::string path = default_root;
path += req_url;

以后我们想要访问的资源就从wwwroot目录下开始,未来的所有资源放在这个目录里,可以通过url请求,例如:./wwwroot/a/b/c

那么如果直接是./wwwroot/呢?此时就可以获得主页资源

// 网站首页
const std::string home_page = "index.html";
// 添加web默认路径
path = default_root;
path += req_url;
if(path[path.size() - 1] == '/') path += home_page;

为了看到路径多打印一行信息:
在这里插入图片描述
在这里插入图片描述
当我们配置了主页文件就可以在浏览器中看到我们的主页了,在下面介绍。

3.8 获取服务器资源

读取资源其实就是读取文件。
在这里插入图片描述
通过请求构建响应:读取请求发送过来的路径,看是否存在
如果不存在,就给一个资源不存在的信息:

const std::string html_404 = "wwwroot/404.html";

在这里插入图片描述
目前我们只有网站首页存在:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
资源不存在的原因是在web根目录里无./wwwroot/a/b/c文件。


我们也可以多添加几个资源文件来获取看看:

在这里插入图片描述

然后在首页让我们能跳转a和b
**加粗样式**
在这里插入图片描述


我们发送给客户端资源,那么首先我们自己就需要知道这是什么类型的资源,那么怎么才能知道呢?

通过后缀,比如说.html、.jpg

所以我们可以在请求结构体添加一个成员变量表示path的后缀资源。

std::string suffix;// 后缀

此时我们就可以在报头中添加对应信息了:

std::string SuffixToDesc(const std::string suffix)
{
    
    
    std::string ct = "Content-Type: ";
    if(suffix == ".html")
    {
    
    
        // 通过网上查后缀对应的类型
        ct += "text/html";
    }
    else if(suffix == ".jpg")
    {
    
    
        ct += "application/x-jpg";
    }
    ct += "\r\n";
    return ct;
}
// 报头
std::string resp_header = SuffixToDesc(req.suffix);// 通过后缀转成描述

为了让网页更美观,可以添加图片信息,其实图片也是文件。
一个用户看到的网页结果可能是多个资源组合而成的,所以要获取一张完整的网页效果,浏览器会发送多次http请求。

为了方便观察,可以在请求的结构体中加入正文长度size后缀信息suffix
在这里插入图片描述

// prtocol.hpp
class HttpRequest
{
    
    
public:
    void parse()
    {
    
    
        //从请求结构体中的inbuf中拿到请求行(第一行),分隔符\r\n
        std::string line = Util::getOneLine(inbuf, sep);
        if(line.empty()) return;
        //从请求行中获取三个字段:请求方法、url、请求版本
        std::cout << "line:" << line << std::endl;
        std::stringstream ss(line);
        ss >> req_method >> req_url >> http_version;//自动以空格为分割读取其中的字段
        // 添加web默认路径
        path = default_root;
        path += req_url;
        if(path[path.size() - 1] == '/') path += home_page;
        // 获取后缀资源 .jpg
        auto pos = path.rfind(".");
        if(pos == std::string::npos)
        {
    
    
            // 默认
            suffix = ".html";
        }
        else
        {
    
    
            suffix = path.substr(pos);
        }
        // 获得资源的大小
        struct stat st;
        int n = stat(path.c_str(), &st);
        if(n == 0)
        {
    
    
            size = st.st_size;
        }
        else size = -1;
    }
public:
    std::string inbuf;// 缓冲区
    // std::string req_line;// 请求行
    // std::vector<std::string> req_header;// 请求报头
    // std::string req_body;// 请求正文
    
    std::string path;// 路径
    std::string req_method;// 请求方法
    std::string req_url;// url
    std::string http_version;// http版本
    std::string suffix;// 后缀
    int size;// 正文资源的大小
};
// HttpServer.cc
bool Get(const HttpRequest& req, HttpResponse& resp)
{
    
    
    cout << "-------------request start-------------" << endl;
    cout << req.inbuf << endl;
    cout << "method: " << req.req_method << endl;
    cout << "url: " << req.req_url << endl;
    cout << "version: " << req.http_version << endl;
    cout << "path: " << req.path << endl;
    cout << "suffix: " << req.suffix << endl;
    cout << "size: " << req.size << "byte" << endl;
    cout << "-------------request end---------------" << endl;
    // 状态行
    std::string resp_line = "HTTP/1.1 200 OK\r\n";
    // 报头
    std::string resp_header = SuffixToDesc(req.suffix);// 通过后缀转成描述
    // 正文长度
    if(req.size > 0)
    {
    
    
        resp_header += "Content-Length: ";
        resp_header += std::to_string(req.size);
        resp_header += "\r\n";
    }
    // 空行
    std::string resp_blank = "\r\n";
    // 响应正文
    // std::string resp_body = "<html><head></head><body><h1>Hello HTTP</h1></body></html>";
    //std::string resp_body;
    // 从文件中读取
    std::string resp_body;
    resp_body.resize(req.size+1);
    if(!Util::readFile(req.path, (char*)resp_body.c_str(), req.size))
    {
    
    
        // 资源不存在
        Util::readFile(html_404, (char*)resp_body.c_str(), req.size);
    }
    resp.outbuf += resp_line;
    resp.outbuf += resp_header;
    resp.outbuf += resp_blank;
    cout << "-------------response start-------------" << endl;
    cout << resp.outbuf << endl;
    cout << "-------------response end---------------" << endl;
    resp.outbuf += resp_body;
    return true;
}

// ./HttpServer 8080
int main(int argc, char* argv[])
{
    
    
    if(argc != 2)
    {
    
    
        cerr << "Too few passed parameters\r\n\r\n";
        exit(0);
    }
    uint16_t port = atoi(argv[1]);
    unique_ptr<HttpServer> p(new HttpServer(Get, port));
    p->InitServer();
    p->start();
    return 0;
}

因为图片是二进制文件,所以要用二进制读取:

static bool readFile(const std::string path, char* buf, int size)
{
    
    
    std::ifstream in(path);
    // 打开文件
    if(!in.is_open())
    {
    
    
        // 资源不存在
        return false;
    }
    else
    {
    
    
        std::ifstream in(path, std::ios::binary);
        if(!in.is_open()) return false;
        in.read(buf, size);
    }
    in.close();
    return true;
}

结果:
在这里插入图片描述

在这里插入图片描述

四、HTTP的请求方法

比方说我们在百度里搜索东西,要把数据提交到对应的框框里:
在这里插入图片描述
其实本质是前端通过form表单进行提交的,浏览器会自动将form表单里的内容转成GET/POST方法请求。

请求方法 说明 支持的http协议版本
GET 获取资源 1.0/1.1
POST 传输实体主体 1.0/1.1

4.1 GET方法

在这里插入图片描述

这一块整体就是个form表单,我们可以通过GET方法提交

在这里插入图片描述

所以可以看到GET方法可以把要提交的参数拼接到到url的后边。

在这里插入图片描述

4.2 POST方法

再来看看POST方法:
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
总结一下:

GET方法通过url传递参数
POST方法通过请求正文提交参数

因为POST通过请求正文传参,所以一般用户看不到,私密性(不等于安全性)更好,GET方法不私秘。
但是无论是GET或者是POST方法都不安全。 (http请求都是可以被抓到的,想要安全必须加密,使用https协议)

一般我们传递大字段或者较为私密的数据的时候使用POST方法,其他的使用GET方法


服务器收到参数怎么进行数据解析呢?

如果是POST方法,本身就是分离的,不用处理。
如果用的是GET方法,需要对url进行额外的处理,例如/a/b/c.py?xname=yyh&ypwd=123456,需要拆解出其中的路径(path),即"/a/b/c.py"。问号右侧则是参数(parm)。

然后我们path匹配到服务,再通过传递的参数parm提供服务。

if(req._path == "/a/b/c.py")
{
    
    
	// 利用parm参数
    // 使用我们自己写的C++方法提供服务,不走下面的网页显示
    //....
    return true;
}

比方说path路径下是我们写的python程序,我们就可以把参数传递过去,利用python语言来提供服务。

五、 HTTP状态码

// 状态行
std::string resp_line = "HTTP/1.1 200 OK\r\n";

前面我们自己定义的状态行中的200就是状态码。

状态码有五种类型,分别以1 ~ 5开头:

状态码 类别 原因短语
1XX Informational(信息性状态码) 接收的请求正在处理
2XX Success(成功状态码) 请求正常处理完毕
3XX Redirection(重定向状态码) 需要进行附加操作以完成请求
4XX Client Error(客户端错误状态码) 服务器无法处理请求
5XX Server Error(服务器错误状态码) 服务器处理请求出错

最常见的状态码,比如200(OK),404(Not Found),403(Forbidden请求权限不够),302(Redirect),504(Bad Gateway)。

5.1 重定向状态码(3XX)

重定向就是通过各种方法将各种网络请求重新定个方向转到其它位置(跳转网站),此时这个服务器相当于提供了一个引路的服务。

我们发送请求给服务端,服务端返回一个新的url,状态码是3,浏览器自动用这个新的url继续发送请求给新的地址。

所以重定向是由客户端完成的。

而重定向又分为临时重定向永久重定向。其中状态码301表示的就是永久重定向,而状态码302和307表示的是临时重定向。

临时重定向和永久重定向本质是影响客户端的标签,决定客户端是否需要更新目标地址。如果某个网站是永久重定向,那么第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时你访问的直接就是重定向后的网站。而如果某个网站是临时重定向,那么每次访问该网站时如果需要进行重定向,都需要浏览器来帮我们完成重定向跳转到目标网站。

  • 临时重定向演示
    在这里插入图片描述
    现在当我们访问浏览器的时候自动会跳转到CSDN网站:
    在这里插入图片描述
    在这里插入图片描述

六、HTTP常见的报头信息

HTTP常见的Header如下:

  • Content-Type:数据类型(text/html等)。
  • Content-Length:正文的长度。
  • Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
  • User-Agent:声明用户的操作系统和浏览器的版本信息。
  • Referer:当前页面是哪个页面跳转过来的。
  • Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
  • Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。

七、HTTP长连接

http请求是基于tcp协议的,而tcp是需要进行连接的。对于一个网页,可能包含多种元素,则需要发起多次connect。为了减少连接次数,需要客户端和服务器均支持长链接,建立一条连接,传输完后不断开连接,一直传递资源,不用频繁创建连接。如果是短连接请求了一份资源后就会自动关闭连接。

那么客户端和服务端怎么知道是否是长连接呢?

在报头信息中会有Connection字段。
在这里插入图片描述

Connection: keep-alive// 支持长连接
Connection: close

八、HTTP会话保持(Cookie和Session)

HTTP实际上是一种无状态协议每次请求并不会记录它曾经请求了什么。HTTP的每次请求/响应之间是没有任何关系的,但你在使用浏览器的时候发现并不是这样的。

比如当你登录一次CSDN后,就算你把CSDN网站关了甚至是重启电脑,当你再次打开CSDN网站时,CSDN并没有要求你再次输入账号和密码,这实际上是通过cookie技术实现的,点击浏览器当中锁的标志就可以看到对应网站的各种cookie数据。

在这里插入图片描述
在这里插入图片描述
这些cookie数据实际都是对应的服务器方写的,如果你将对应的某些cookie删除,那么此时可能就需要你重新进行登录认证了,因为你删除的可能正好就是你登录时所设置的cookie信息。

得出结论:会话保持不是http协议天然具备的特点,而是浏览器为了满足用户的使用需求,做了相应的工作。

  • 如何做到的呢?

用户在第一次输入账号和密码时,浏览器会进行保存(Cookie),近期再次访问同一个网站(发送http请求),浏览器会自动将用户信息添加到报头中推送给服务器。这样只要用户首次输入密码,一段时间内将不用再做登录操作了。

这种把用户名和密码保存起来的技术叫做Cookie技术

而Cookie又分为Cookie内存Cookie文件

  • 内存级别与文件级别

cookie就是在浏览器当中的一个小文件,文件里记录的就是用户的私有信息。cookie文件可以分为两种,一种是内存级别的cookie文件,另一种是文件级别的cookie文件。
1️⃣ 将浏览器关掉后再打开,访问之前登录过的网站,如果需要你重新输入账号和密码,说明你之前登录时浏览器当中保存的cookie信息是内存级别的。
2️⃣ 将浏览器关掉甚至将电脑重启再打开,访问之前登录过的网站,如果不需要你重新输入账户和密码,说明你之前登录时浏览器当中保存的cookie信息是文件级别的(真实的文件,保存在磁盘,进程退出也不影响)。

  • Cookie安全问题

我们本地的Cookie如果被不法分子拿到了,那么此时这个非法用户就可以用你的cookie信息,以你的身份去访问你曾经访问过的网站,我们将这种现象称为cookie被盗取了。

为了保证安全,我们可以把信息保存在服务端,在服务端形成一个文件:session文件,而因为有很多session文件,所以给每个文件一个名字:session id。并将其返回给浏览器,浏览器存到Cookie的其实是session id。接下来我们把session id放到请求中,然后发送到服务端,在服务端获取登录信息(鉴权)。目前只保证了用户信息的泄漏,接下来只能靠服务端的安全策略保障安全,例如账号被异地登录了,服务端察觉后只要让session id失效即可,这样异地登录将会使用户重新验证账号密码或手机或人脸信息(尽可能确保是本人),一定程度上保障了信息的安全。

在这里插入图片描述

  • 写入Cookie信息

其实就是向发送给浏览器的响应中写入报头中。

//写入Cookie
resp_header += "Set-Cookie: name=12345abc; Max-Age=180\r\n";//设置Cookie响应报头,有效期3分钟

在这里插入图片描述

在这里插入图片描述



猜你喜欢

转载自blog.csdn.net/qq_66314292/article/details/131525386