喜欢的点赞,收藏,关注一下把!
目前基本socket写完,一般服务器设计原则和方式(多进程、多线程、线程池)+常见的各种场景,自定义协议+序列化和反序列化都已经学过了。
那有没有人已经针对常见场景,早就已经写好了常见的协议软件,供我们使用呢?
当然了,最典型的HTTP。未来它们做的事情和我们以前做的事情是一样的!只不过HTTP是结合它的应用场景来谈的。
虽然我们现在关于http协议不知道它是什么。但我们知道你的http协议里面必有套接字,必有序列化和反序列的机制,也必须添加报头和分离报头的过程等等。
在说这个http协议之前,我们先做一个预备工作,在网络基础一我们知道OSI分七层前三层分别是应用层,表示层,会话层。在TCP/IP协议这三层合起来算一层应用层。
在上篇文章说过我们写的网络版计数器软件分成三层。第一层获取链接多进程或者多线程或线程池进行处理,第二层handlerEntery进行读取完整报文、提取有效载荷、序列化反序列化等一系列工作,第三层进行业务处理callback。其实我们自己写的的第一层就是会话层,第二层就是表示层,第三层callback进行对应的业务逻辑处理就是应用层。
OSI定义成七层,原因就是后面写代码时每一层都少不了。OSI为什么没把这三层压成一层呢?在于表示层有自定义的方案、Json方案、protobuf方案、xml方案等等,如果它某种方案写到内核里就固定下来了,而实际上我们并没有固定。
http作为应用层协议它也要解决刚才说的三个工作。
1.认识URL
平时我们俗称的 “网址” 其实就是说的 URL
https://blog.csdn.net/fight_p/article/details/137103487
https -> 协议
blog.csdn.net -> 域名,域名等价于IP,这里会有一个域名解析的工作(把域名这个字符串结构转化成IP地址),IP标识一台网络主机(Linux系统)
/fight_p/article/details/137103487 -> 文件路径
URL的作用就是,浏览器通过拿着这个URL,找到这台Linux机器然后在这台机器上找对应的文件。把文件打开返回给浏览器。
实际上URL有多种格式。
为什么我们刚才URL没有端口呢?
我们对应的协议是和端口号强相关的。服务器端口号是一个众所周知的端口号,一般不能随便改变,刚才URL没有写出来并不代表没有,因为客户端访问服务器端一定要知道服务端的是IP地址和端口号。这里没有但浏览器在真正请求时会给我们填上,浏览器结合我们用的协议就知道用的端口号是多少。默认一些协议对应的端口号:
http:80
https:443
这里圈起来的是什么东西呢?
其实它并不是根目录,而是web根目录,一般而言,可以是Linux下的任意一个目录。这个任意目录必须要有对应请求的资源。(后面写代码的时候具体解释)
http是文本传输协议。说白了http协议就是从服务器拿下来对应的 “资源”。
什么是资源呢?
凡是你在网络中看到的都是资源!(如:刷的短视频是视频文件、淘宝上看到的图片是图片文件,网易云听的音乐是音乐文件。。。)
所有的资源都可以看做资源文件,在服务器中都是以文件形式存在磁盘中的文件系统中的某一个路径下,所以需要Linux系统的路径结构。当要的时候把磁盘中对应的路径所标识的资源返回给客户端。所以http协议本质上是从服务器上拿文件资源。
因为文件资源的种类特别多,http都能搞定,所以http是 “超文本传输协议”。
2.urlencode和urldecode
像 / ? : 等这样的字符, 已经被url当做特殊意义理解了。因此这些字符不能随意出现。比如, 某个参数中需要带有这些特殊字符,就必须先对特殊字符进行转义。
如果原封不动会干扰url的正确解析,所以浏览器这端必须先编码然后提给服务端。
那如何编码&&如何解码,需要自己做吗?
编码转义的规则如下:
将需要转码的字符转为16进制。一个字符占8个比特位,然后从右到左,取4个比特位转成16进制数(不足4位直接处理),每2位16机制数前面加上%,编码成%XY格式
解码就是把收到的这些东西转成2进制的格式
我们写服务端如果从0开始写这个解码工作一定要自己做,但是我们在网上搜索url decode C++源码,当一个CV工程师。
如何验证这个过程?
urlencode工具
3.HTTP协议格式
接下面我们先从宏观方面说说http请求和响应的格式
http请求是以行为单位的一种协议。
第一行
第一列:请求的方法
第二列:url
第三列:请求的http的版本
常见的版本:http 1.0 、1.1、 2.0版本
行以\r\n为分隔符,或者以\n
第二大块也是以行为单位的,只不过这一大块会存在多行,多行包含http请求各种属性,属性几乎都以name:value的样子
而我们又把第一行称为请求行,第二大快称为请求报头
第三大块,特别要强调一下是http请求的空行,相当重要
最后一块是请求正文,可以没有也可以有,如果未来想把自己要登录账号可以把账号和密码放在正文,也就是说想给服务器提上去的参数就放在正文
上面四大块就是http request请求的完整报文。它会通过tcp链接,向服务器发送过去。
http响应格式几乎和请求格式是一样的,也分四部分。
第一行是状态行,也有三列构成,中间由空格分开。
第一列:是http版本
第二列:状态码,如200、400、302、307、500、404,如404我们常见的页面不存在。状态码用来表示请求结果是否正确,就如网络版本计数器我们写的exitcode。
第三列:状态码描述 如404 -> Not Found、200 -> OK
第二大块也是由多行构成的,叫做响应报文
第三块同样也是空行
第四大块,在响应里会高频的出现,叫做响应正文(有效载荷),通常带html/css/js/图片/视频/音频等
这四大部分构成了响应报文。在根据tcp链接socket,向客户端返回响应。未来所有http通信都采用的是这种方案进行通信的。
现在我们在谈一谈通信细节问题
1.请求和响应怎么保证应用层完整读取完毕了呢?
首先我们发现http请求都是字符串按行为单位,所以
- 我可以读取完整的一行
- while(读取完整一行) --> 所有的请求行+请求行报文全部读完 --> 直到空行!
- 我们没说正文也是按行为单位分开的没有办法保证把正文读完,但是我们能保证把报头读完,而报头里有一个Content-Length:xxx(代表正文长度)
- 解析出来内容长度,在根据内容长度,读取正文即可!
2.请求和响应是怎么做到序列化和反序列化的?
http是用的特殊字符自己实现的。http序列化什么都不做直接发就好了,反序列化 :第一行+请求/响应报头,只要按照\r\n将字符串1->n即可!不用借助任何东西如Json
protobuf等。而正文序列化反序列也不用做直接发送就行了。如果你的正文携带结构化数据就要自己处理了。
接下来我们写代码的方式,验证上面说的东西。
以前写udp和tcp我们都写过服务端用过套接字,这里还是直接拿过来用。
4.HTTP协议基本工作流程
Protocol.hpp
#pragma once
#include <iostream>
#include <string>
using namespace std;
class httpRequest
{
public:
httpRequest(){};
~httpRequest(){};
public:
string inbuffer;//缓冲区
//下面我们先不管,未来都可以细分,序列化反序列也都可以写到类中,我们这里写简单一点主要看一下http的细节
// string reqline;//请求行
// vector<std::string> reqheader;//报头
// string body;//请求正文
//第一行细分
//string method;
//string url;
//string httpversion;
};
class httpResponse
{
public:
string outbuffer;//缓冲区
};
httpServer.hpp
#pragma once
#include "protocol.hpp"
#include <iostream>
#include <string>
#include <stdlib.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <functional>
using namespace std;
enum
{
USAGG_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
const int backlog = 5;
typedef function<void(const httpRequest&,httpResponse&)> func_t;
void handlerEntery(int sock,func_t callback)
{
// 1. 读到完整的http请求
// 2. 反序列化
// 3. httprequst, httpresponse, callback(req, resp)
// 4. resp序列化
// 5. send
}
class httpServer
{
public:
httpServer(const uint16_t port) : _port(port), _listensock(-1)
{
}
void initServer()
{
// 1.创建socket文件套接字对象
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
exit(SOCKET_ERR);
}
// 2.bind 绑定自己的网络消息 port和ip
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; // 任意地址bind,服务器真实写法
if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
exit(BIND_ERR);
}
// 3.设置socket为监听状态
if (listen(_listensock, backlog) < 0) // backlog 底层链接队列的长度
{
exit(LISTEN_ERR);
}
}
void start(func_t func)
{
// 子进程退出自动被OS回收
signal(SIGCHLD, SIG_IGN);
for (;;)
{
// 4.获取新链接
struct sockaddr_in peer;
socklen_t len = (sizeof(peer));
int sock = accept(_listensock, (struct sockaddr *)&peer, &len); // 成功返回一个文件描述符
if (sock < 0)
{
continue;
}
// 5.通信 这里就是一个sock,未来通信我们就用这个sock,tcp面向字节流的,后序全部都是文件操作!
// version2 多进程信号版
int fd = fork();
if (fd == 0)
{
close(_listensock);
handlerEntery(sock,func);
close(sock);
exit(0);
}
close(sock);
}
}
~httpServer()
{
}
private:
uint16_t _port;
int _listensock;
};
httpServer.cc
#include "httpServer.hpp"
#include <memory>
void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}
void Get(const httpRequest &req, httpResponse &resp)
{
}
// ./httpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(USAGG_ERR);
}
uint16_t serverport = atoi(argv[1]);
unique_ptr<httpServer> tsv(new httpServer(serverport));
tsv->initServer();
tsv->start(Get);
return 0;
}
我们发现udp、tcp、http所有的底层逻辑都是差不多的,而我们只要写上层逻辑就好了。
这里我们主要说原理,下面1-5的工作我们都不做了,所以httpRequest,httpResponse也都给一个缓冲区就行了。
void handlerEntery(int sock,func_t callback)
{
// 1. 读到完整的http请求
// 2. 反序列化
// 3. httprequst, httpresponse, callback(req, resp)
// 4. resp序列化
// 5. send
char buffer[4096];
httpRequest req;
httpResponse resp;
ssize_t n=recv(sock,buffer,sizeof(buffer)-1,0);//大概率我们直接就能读取到完整的http请求
if(n>0)
{
buffer[n]=0;
req.inbuffer=buffer;
callback(req,resp);
send(sock,resp.outbuffer.c_str(),resp.outbuffer.size(),0);
}
}
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "----------------http end-----------------" << endl;
}
下面我们用浏览器充当客户端发起请求看一下结果
有人可能说就发起一次请求那后面怎么收到这么多,浏览器内部请求资源可能是一个多线程版的客户端,并且它请求可能不仅仅请求这个网站的网页,还可能获取网站的图标其他一些资源,它可能并行的向服务器发起请求所以看到这么多请求。
因为我们什么都没有返回,所以网页什么都看不到。
下面是我们的请求报文,服务器在打印的时候我们多打印了一个endl,所以会看到两个空行。实际应该是一个空行。
第一行第一列是请求方法,默认是GET方法。
第二列url,这里是/,是因为我们只是告诉浏览器要访问某个ip的某个port并没有告诉请求什么资源,默认是/(web根目录),它并不是把所有资源都给用户。http请求如果没有请求指定的资源,web server 会有默认的首页!
比如说,我们默认请求百度www.baidu.com,后面什么都没带没告诉要访问百度那个网页,按回车默认就把百度首页返回了。
第三列是http请求的版本,这里浏览器默认用的1.1的版本,未来响应返回的时候也要写版本。这里有个细节,http请求会交换bs通信双方的协议版本。
我们在用一些软件的时候,有的软件会提醒你就行升级更新,有的人会升级有的人不会,一定会存在多种客户端版本的情况,所以服务器可能面临多种不同版本的客户端,如果是老版本的客户端一定用的是老版本的服务,服务端给它提供老版本的服务,同理是新版本客户端服务端给它提供的是新版本的服务,因此需要服务器提供对应的版本服务。
接下面一堆东西就是请求报头。
第一行Host:是这个请求未来是要发给那个服务端的。未来客户端这个请求去到一个服务器,但这个服务器不提供服务,然后该服务器拿着我的Host知道我要请求谁,它替我去请求。这个中间服务器叫做代理服务器。
第二行Connection:关于后面说长短链接的时候再说,目前这个是支持长链接的。
第三行是支持协议升级,http是可以被升级的,http是cs/bs模式请求响应的模式,也就是说必须是客户端主动发起请求,服务器才能给它响应。可能在特殊场景下让服务器和客户端互发消息,客户端没有发任何消息,服务器主动发信息给客户端。
第四行User-Agent:是客户端的信息,如操作系统是什么,浏览版本等
下面几行Accept:是告诉服务器,客户端能接收什么文档格式、支持压缩格式、支持编码格式等等
请求部分我们验证完了,下面我们再看一个响应。
报头我们暂时不要后面慢慢填,正文部分我们搞一个网页。
网页不会写,可以搜一下w3cschool html教程
这里我们先写到Get函数里,后面我们在分离
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "----------------http end-----------------" << endl;
string respline = "HTTP/1.1 200 OK\r\n";
// string respheader;
string respblank = "\r\n";
//随便搞个网页
string body="<html lang=\"en\"><head><meta charset=\"UTF-8\"><title>for test</title><h1>hello world</h1></head><body><p>北京交通广播《一路畅通》“交通大家谈”节目,特邀北京市交通委员会地面公交运营管理处处长赵震、北京市公安局公安交通管理局秩序处副处长 林志勇、北京交通发展研究院交通规划所所长 刘雪杰为您解答公交车专用道6月1日起社会车辆进出公交车道须注意哪些?</p></body></html>";
//序列化
resp.outbuffer += respline;
resp.outbuffer += respblank;
resp.outbuffer += body;
}
虽然我们在响应的时候没有带响应报头,但是我们的浏览器依旧是能识别的,这里想说的是现在浏览器很智能了,可以不用告诉它正文是什么也可以根据正文内容识别这是什么东西,但是有的浏览器做不到。这里我们用的是chrome浏览器。
但是你浏览器能识别是你的事情,我们还是要把自己的事做好,告诉浏览器我给你发回的是什么东西。
所以我们要加一个报头里面可以带一些属性。如Content-Type ,告诉别人返回的是什么资源。网上可以搜一下Content-Type 对照表
这里我们返回的是一个网页,类型是text/html
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "----------------http end-----------------" << endl;
string respline = "HTTP/1.1 200 OK\r\n";
//报头
string respheader = "Content-Type: text/html\r\n";
string respblank = "\r\n";
//随便搞个网页
string body="<html lang=\"en\"><head><meta charset=\"UTF-8\"><title>for test</title><h1>hello world</h1></head><body><p>北京交通广播《一路畅通》“交通大家谈”节目,特邀北京市交通委员会地面公交运营管理处处长赵震、北京市公安局公安交通管理局秩序处副处长 林志勇、北京交通发展研究院交通规划所所长 刘雪杰为您解答公交车专用道6月1日起社会车辆进出公交车道须注意哪些?</p></body></html>";
//序列化
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
resp.outbuffer += body;
}
可以看到这就是我们的响应
现在我们大概就知道了http的请求和响应它的格式了,下面我们继续完善这个代码,继续挖出核心的东西。
首先解决两个问题:
1.服务器和网页分离,然后通过服务器把网页返回
2.前面我们说过,我们请求没带路径url会给我们一个/(默认web根目录),这个根目录并不是liunx服务器下的根目录,而是web服务器自己的根目录,怎么理解呢?
再说前面的问题,我们在加一个方法类,未来可以把收到的报文做切割。
#pragma once
#include <iostream>
#include <string>
using namespace std;
class Util
{
public:
// xxx yyy zzz\r\naaa
static string GetOneline(string &buffer, const string &sep)
{
auto pos = buffer.find(sep);
if (pos == string::npos)
return "";
string sub = buffer.substr(0, pos);
return sub;
}
};
const string sep = "\r\n";//切割符
class httpRequest
{
public:
httpRequest(){};
~httpRequest(){};
void parse()
{
// 1. 从inbuffer中拿到第一行,分隔符\r\n
string line = Util::GetOneline(inbuffer, sep);
if (line.empty())
return;
// 2. 从请求行中提取三个字段
istringstream iss(line);
iss >> method >> url >> httpversion;
}
public:
string inbuffer;
// string reqline;
// vector<std::string> reqheader;
// string body;
string method;
string url;
string httpversion;
};
void handlerEntery(int sock,func_t callback)
{
// 1. 读到完整的http请求
// 2. 反序列化
// 3. httprequst, httpresponse, callback(req, resp)
// 4. resp序列化
// 5. send
char buffer[4096];
httpRequest req;
httpResponse resp;
ssize_t n=recv(sock,buffer,sizeof(buffer)-1,0);// 大概率我们直接就能读取到完整的http请求
if(n>0)
{
buffer[n]=0;
req.inbuffer=buffer;
req.parse();
callback(req,resp);
send(sock,resp.outbuffer.c_str(),resp.outbuffer.size(),0);
}
}
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "method: " << req.method << endl;
cout << "url: " << req.url << endl;
cout << "httpversion: " << req.httpversion << endl;
cout << "----------------http end-----------------" << endl;
string respline = "HTTP/1.1 200 OK\r\n";
//报头
string respheader = "Content-Type: text/html\r\n";
string respblank = "\r\n";
//随便搞个网页
string body="<html lang=\"en\"><head><meta charset=\"UTF-8\"><title>for test</title><h1>hello world</h1></head><body><p>北京交通广播《一路畅通》“交通大家谈”节目,特邀北京市交通委员会地面公交运营管理处处长赵震、北京市公安局公安交通管理局秩序处副处长 林志勇、北京交通发展研究院交通规划所所长 刘雪杰为您解答公交车专用道6月1日起社会车辆进出公交车道须注意哪些?</p></body></html>";
//序列化
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
resp.outbuffer += body;
}
经过测试,一行能拿到,未来用个while循环,请求行+请求报文都可以拿到了。
现在我们解释一下什么是web根目录。
实际上未来一个web服务器写好之后,可不仅仅有这些代码。每一个web服务器都有web根目录,未来所有图片、视频、音频等各种web资源都在这个目录下,按照目录结构组织号好,未来想请求资源就从url请求。那如何保证按照我们的需求在指定路径下去寻找呢?
const string sep = "\r\n";
//这里我们默认写一个web根目录
const string default_root = "./wwwroot";//因为请求url默认会加上/开始,所以./wwwroot后面不要/
const string home_page = "index.html";//默认首页
class httpRequest
{
public:
//。。。
void parse()
{
// 1. 从inbuffer中拿到第一行,分隔符\r\n
string line = Util::GetOneline(inbuffer, sep);
if (line.empty())
return;
// 2. 从请求行中提取三个字段
istringstream iss(line);
iss >> method >> url >> httpversion;
// 3. 添加web默认路径
path = default_root;// ./wwwroot
path += url;// ./wwwroot/a/b/c.html 请求具体资源
//刚才我们看到url只有/的样子,这里也要拼 ./wwwroot/
//但是这里就遭了,还是不知道访问的那个具体的资源
//其实对于一个服务器来说,它有自己的主页信息
//如果访问的是根目录,就把首页给拼上
if (path[path.size() - 1] == '/')//判断是否是根目录
path += home_page;
}
public:
string inbuffer;
string method;
string url;
string httpversion;
string path;
};
在url是这样请求的,但是实际上web服务器它会自己拼前缀,带着这个路径去找对应资源文件,如果有就返回,没有就返回404。
接下来我们做服务器和网页分离
我们让body从文件中读取,因此添加一个readFile接口,把文件内容全部读到body里。
class Util
{
public:
// xxx yyy\r\nzzz
static string GetOneline(string &buffer, const string &sep)
{
auto pos = buffer.find(sep);
if (pos == string::npos)
return "";
string sub = buffer.substr(0, pos);
return sub;
}
static bool readFile(const string &path, string &body)
{
ifstream ofs(path,ios_base::binary);//二进制方式读
if (!ofs.is_open())
return false;
string line;
while(getline(path, line))
{
body += line;
}
ofs.close();
return true;
}
};
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "method: " << req.method << endl;
cout << "url: " << req.url << endl;
cout << "httpversion: " << req.httpversion << endl;
cout << "----------------http end-----------------" << endl;
string respline = "HTTP/1.1 200 OK\r\n";
//报头
string respheader = "Content-Type: text/html\r\n";
string respblank = "\r\n";
string body;
if (!Util::readFile(req.path, body))
{
Util::readFile(html_404, body); // 读取失败返回404,一定能成功
}
//序列化
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
resp.outbuffer += body;
}
现在我们给网页添加一下功能,比如说网页是支持点击然后跳转链接的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的首页</title>
</head>
<body>
<h1>我是网站的首页</h1>
<a href="/test/a.html">新闻</a>
<a href="/test/b.html">电商</a>
</body>
</html>
点击新闻链接,http自动拼接然后重新请求
这也就是前端和后端。
这里我们在首页在加一张图片。
先把图片上传到web根目录下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的首页</title>
</head>
<body>
<h1>我是网站的首页</h1>
<a href="/test/a.html">新闻</a>
<a href="/test/b.html">电商</a>
<img src="/image/1.jpg" alt="向日葵">
</body>
</html>
未来我们请求网页,它要做两件事情,第一要把这个网页本身加载出来,第二浏览器识别到网页还有一个资源叫做图片,所以它一要把网页给下载下来,二还要把网页中要用的图片下载下来,两个资源一合并组合才能给我们形成一个完整的网页信息!
一个用户看到的网页结果,可能是多个资源组合而成的!所以要获取一张完整的网页效果,我们的浏览器一定会发起多次http请求
是网页Content-Type 类型text/html,图片呢?
所以我们需要不同的path所标定文件的类型返回特定不同的资源。
我们要正确的给客户端返回资源类型,我们首先要自己知道!所有的资源都有后缀!!
class httpRequest
{
public:
httpRequest(){};
~httpRequest(){};
void parse()
{
// 1. 从inbuffer中拿到第一行,分隔符\r\n
string line = Util::GetOneline(inbuffer, sep);
if (line.empty())
return;
// 2. 从请求行中提取三个字段
istringstream iss(line);
iss >> method >> url >> httpversion;
// 3. 添加web默认路径
path = default_root + url;
if (path[path.size() - 1] == '/')
path += home_page;
// 4. 获取path对应的资源后缀
// ./wwwroot/index.html
// ./wwwroot/test/a.html
// ./wwwroot/image/1.jpg
auto pos=path.rfind(".");
if(pos == string::npos) suffix=".html";
suffix=path.substr(pos);
}
public:
string inbuffer;
string method;
string url;
string httpversion;
string path;
string suffix;
};
string suffixtodes(const string &suff)
{
string type = "Content-Type: ";
if (suff == ".html")
type += "text/html";
else if (suff == ".jpg")
type += "application/x-jpg";
type += "\r\n";
return type;
}
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "method: " << req.method << endl;
cout << "url: " << req.url << endl;
cout << "httpversion: " << req.httpversion << endl;
cout << "suffix: " << req.suffix << endl;
cout << "----------------http end-----------------" << endl;
string respline = "HTTP/1.1 200 OK\r\n";
//报头
//string respheader = "Content-Type: text/html\r\n";
string respheader = suffixtodes(req.suffix);
string respblank = "\r\n";
string body;
if (!Util::readFile(req.path, body))
{
Util::readFile(html_404, body); // 读取失败返回404,一定能成功
}
//序列化
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
resp.outbuffer += body;
}
但是这个readFile还像刚才那样使用while循环读一行的方式读就有问题了,因为图片是二进制文件。可能图片太大都没读完整。所以上面代码目前不完整。我把下面问题解决了然后就可以完整把图片显示出来了。
这里有个问题,你要请求的资源大小是多少?所以我们在报头再加一个
Content-Length。
这里在介绍一个接口
path:你要访问的资源
buf:一个结构体,这个结构体包含很多属性,其中有文件大小。
成功时返回0,失败返回-1错误码被设置
class httpRequest
{
public:
httpRequest(){};
~httpRequest(){};
void parse()
{
// 1. 从inbuffer中拿到第一行,分隔符\r\n
string line = Util::GetOneline(inbuffer, sep);
if (line.empty())
return;
// 2. 从请求行中提取三个字段
istringstream iss(line);
iss >> method >> url >> httpversion;
// 3. 添加web默认路径
path = default_root + url;
if (path[path.size() - 1] == '/')
path += home_page;
// 4. 获取path对应的资源后缀
// ./wwwroot/index.html
// ./wwwroot/test/a.html
// ./wwwroot/image/1.jpg
auto pos=path.rfind(".");
if(pos == string::npos) suffix=".html";
suffix=path.substr(pos);
// 5. 得到资源的大小
struct stat sif;
if(stat(path.c_str(),&sif) == 0)
size=sif.st_size;
else
size=-1;
}
public:
string inbuffer;
string method;
string url;
string httpversion;
string path;
string suffix;
int size;
};
string suffixtodes(const string &suff)
{
string type = "Content-Type: ";
if (suff == ".html")
type += "text/html";
else if (suff == ".jpg")
type += "application/x-jpg";
type += "\r\n";
return type;
}
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "method: " << req.method << endl;
cout << "url: " << req.url << endl;
cout << "httpversion: " << req.httpversion << endl;
cout << "suffix: " << req.suffix << endl;
cout << "size: " << req.size << endl;
cout << "----------------http end-----------------" << endl;
string respline = "HTTP/1.1 200 OK\r\n";
//报头
//string respheader = "Content-Type: text/html\r\n";
string respheader = suffixtodes(req.suffix);
if (req.size > 0)
{
respheader += "Content-Length: ";
respheader += to_string(req.size);
respheader += "\r\n";
}
string respblank = "\r\n";
string body;
body.resize(req.size + 1);
if (!Util::readFile(req.path, body))
{
// 找不到文件,文件大小是-1,要返回404.html,因此重新计算大小
//否则body大小是-1,404.html文件内容就读不到body里
struct stat sif;
if (stat(html_404.c_str(), &sif) == 0)
body.resize(sif.st_size + 1);
Util::readFile(html_404, body); // 一定能成功
}
//序列化
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
resp.outbuffer += body;
}
这里我们让body开辟好空间,然后readFile使用read读文件。这样就能把文件所有内容读完整。
class Util
{
public:
// xxx yyy\r\nzzz
static string GetOneline(string &buffer, const string &sep)
{
auto pos = buffer.find(sep);
if (pos == string::npos)
return "";
string sub = buffer.substr(0, pos);
return sub;
}
static bool readFile(const string &path, string &body)
{
ifstream ofs(path,ios_base::binary);
if (!ofs.is_open())
return false;
ofs.read((char *)body.c_str(), body.size());
ofs.close();
return true;
}
};
实际http协议基本工作流程就差不多了。
5.HTTP的方法
当我们在进行网络访问的时候,实际是有两种行为的。
- 获取资源
- 上传资源
上面都是获取资源的,可是我们想上传资源呢?完成登录等。。怎么做呢?
实际上我们一般在进行网站交互的时候,其实是通过表单的方式进行提交的
比如qq新闻
所以我们进行数据提交的的时候,本质前端要通过form表单提交的,浏览器会自动将form表单中的内容转换成GET/POST方法请求
默认是GET方法。
接下来我们在自己的首页加个表单
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的首页</title>
</head>
<body>
<h1>我是网站的首页</h1>
<a href="/test/a.html">新闻</a>
<a href="/test/b.html">电商</a>
<img src="/image/1.jpg" alt="向日葵">
<form action="/a/b/c.py" method="GET">
姓名:<br>
<input type="text" name="xname">
<br>
密码:<br>
<input type="password" name="ypwd">
<br><br>
<input type="submit" value="登陆">
</form>
</body>
</html>
form是表单的整体结构,未来我们给服务器提供参数的话,为了很好提供参数的提取所以输入框它是KV的,未来不是在输入框写内容吗,可是我怎么知道你是想那个输入框写的内容呢,所以name就是这个输入框的名字。
其实它后面还跟着value是预设内容,就像你登录时某一行输入框提醒你这一行输入什么内容,不过暂时我们这里不要。
还有input type=“password”,会把我们输入的内容全部变成黑点,text我们输入什么就显示什么。
action代表你想把你的表单的数据提交给后端的哪一个服务或者网页或者路径下。这里我们随便写个,假设这个表单未来提交给一个py的脚本,你想提交给C++程序里这都是可以的。
method当你提交表单时你想采用什么方法提交,默认是GET
<form action="/a/b/c.py" method="GET">
姓名:<br>
<input type="text" name="xname" value="用户名">
<br>
密码:<br>
<input type="password" name="ypwd" value="="密码"">
<br><br>
<input type="submit" value="登陆">
</form>
当我们登录时这个/a/b/c.py是不存在的,所以返回的是404,但不重要。
我们可以看到GET方法提参的时候,它会把我们要提交的参数拼到url的后面,b把参数以url方式进行提交,然后以?为分割符,左侧是要访问网站的资源,右侧是我们提上来的参数。
当我们用POST方法提参时,url后面只有我们要访问什么资源,后面没有提交的参数,我们的参数是以请求的正文提交参数的。
GET和POST提参区别:
GET通过url传递参数,具体:http://ip:port/xxx/yy?name1=value1&name2=value2
POST提交参数通过http请求正文提交参数!
那用那个呢?
POST方法通过正文提交参数,所以一般用户看不到,私密性更好,但私密性!=安全性。
GET方法不私密。
但无论是GET和POST方法,都不安全!要谈安全,必须加密 —> https。
并且通过URL传递参数,注定不能太大,但是POST方法,通过正文,正文可以很大,甚至可以是其他的东西。
现在思考这样的问题:
1.我们提交给指定的路径,有什么意义??
不管是url提参还是正文提参都是把参数给服务器,服务器未来拿着对应的参数实现登录注册等,凭什么?服务器首先能拿到这个数据,可是怎么让服务器处理这个数据?其次服务器怎么知道未来想到对这个数据进行怎样的处理,就是说你只是提交请求而服务器怎么知道登录还是注册呢?
这一切的玄机都在这里,不管是GET和POST都要求在表单里把数据提交给某一个资源。
下面我们搞伪代码来说明,以GET为例
class httpRequest
{
public:
httpRequest(){};
~httpRequest(){};
void parse()
{
// 1. 从inbuffer中拿到第一行,分隔符\r\n
string line = Util::GetOneline(inbuffer, sep);
if (line.empty())
return;
// 2. 从请求行中提取三个字段
istringstream iss(line);
iss >> method >> url >> httpversion;
//考虑提参的情况
// 2.1 /search?name=zhangsan&pwd=12345
// 通过?将左右进行分离
// 如果是POST方法,本来就是分离的!
// 左边PATH, 右边parm
// 3. 添加web默认路径
path = default_root + url;
if (path[path.size() - 1] == '/')
path += home_page;
// 4. 获取path对应的资源后缀
// ./wwwroot/index.html
// ./wwwroot/test/a.html
// ./wwwroot/image/1.jpg
auto pos=path.rfind(".");
if(pos == string::npos) suffix=".html";
suffix=path.substr(pos);
// 5. 得到资源的大小
struct stat sif;
if(stat(path.c_str(),&sif) == 0)
size=sif.st_size;
else
size=-1;
}
public:
string inbuffer;
string method;
string url;
string httpversion;
string path;
string suffix;
int size;
string parm;
};
然后在Get方法里,进行处理,如果path是/search就不走下面显示网页的逻辑,直接使用我们自己写的C++的方法,提供服务。
其次还可以把请求到其他语言写的脚本中。
void Get(const httpRequest &req, httpResponse &resp)
{
if(req.path == "test.py")
{
//建立进程间通信,pipe
//fork创建子进程,子进程执行这个脚本 execl("/bin/python", test.py)
// 父进程,将req.parm 通过管道写给某些后端语言,py,java,php等语言
//这也是为什么服务器是c++写的,后端是其他语言写的
}
if(req.path == "/search")
{
// req.parm
// 使用我们自己写的C++的方法,提供服务
}
//。。。
}
这里我们还可以把服务器做更多的设计。
服务器做一个功能路由的选择,不同路径调用不同的服务
typedef function<void(const httpRequest&,httpResponse&)> func_t;
class httpServer
{
//。。。
void registerCb(string servicename, func_t cb)
{
funcs.insert(make_pair(servicename, cb));
}
void handlerEntery(int sock)
{
// 1. 读到完整的http请求
// 2. 反序列化
// 3. httprequst, httpresponse, callback(req, resp)
// 4. resp序列化
// 5. send
char buffer[4096];
httpRequest req;
httpResponse resp;
ssize_t n=recv(sock,buffer,sizeof(buffer)-1,0);// 大概率我们直接就能读取到完整的http请求
if(n>0)
{
buffer[n]=0;
req.inbuffer=buffer;
req.parse();
funcs[req.path](req, resp);
send(sock,resp.outbuffer.c_str(),resp.outbuffer.size(),0);
}
}
private:
uint16_t _port;
int _listensock;
unordered_map<string,func_t> funcs;
};
// ./httpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(USAGG_ERR);
}
uint16_t serverport = atoi(argv[1]);
unique_ptr<httpServer> tsv(new httpServer(serverport));
// 功能路由!
tsv->registerCb("/", Get); //假设/ 默认是网站
tsv->registerCb("/search", Search);//假设是/search 默认注册
tsv->registerCb("/test.py", Other);//假设test.py 默认登录
tsv->initServer();
tsv->start(Get);
return 0;
}
2.除了GET和POST,还有那些方法,重要吗??
有,但常用的是GET和POST方法。
单纯把资源从远端获取 —> GET方法
提参GET,POST都可以,提交的参数很小没有私密性可以用GET,参数大想有私密性用POST。
6.HTTP的状态码
状态码有五大类
最常见的状态码, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway)
以4开头是客户端错误。5开头是服务端错误。
以3开头的状态码都是重定向
什么叫做重定向呢?
就是访问百度,但是它会自动跳转出QQ新闻首页。
访问一个url,但是服务器响应会带以3开头的状态码还会带一个新的url,然后浏览器看到这个状态码会自动重新访问这个新的url。
重定向分为临时重定向和永久重定向。
假如有一个www.a.com网站有100w用户,但是这个网站现在进行重新设计升级,搞了一个新网站www.b.com,但是老网站100w用户我还想要,因此在老网站这里用重定向,当你还在访问老网站时,老网站告诉你重定向,因此你的浏览器自动访问这个新网站。永久重定向从技术角度是更新你本地的书签。 临时重定向就是每次都做临时跳转如打开一个网站有时候给我跳转到淘宝,有时候跳转京东等(金钱的力量)。
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "method: " << req.method << endl;
cout << "url: " << req.url << endl;
cout << "httpversion: " << req.httpversion << endl;
cout << "path :" << req.path << endl;
cout << "suffix: " << req.suffix << endl;
cout << "size: " << req.size << endl;
cout << "----------------http end-----------------" << endl;
//临时重定向
string respline = "HTTP/1.1 307 Temporary Redirect\r\n";
string respheader = suffixtodes(req.suffix);
if (req.size > 0)
{
respheader += "Content-Length: ";
respheader += to_string(req.size);
respheader += "\r\n";
}
//Location配套重定向,告诉浏览器去哪里访问
respheader += "Location: https://www.baidu.com/\r\n";
string respblank = "\r\n";
string body;
body.resize(req.size + 1);
if (!Util::readFile(req.path, body))
{
// 找不到文件,文件大小是-1,要返回404.html,因此重新计算大小
struct stat sif;
if (stat(html_404.c_str(), &sif) == 0)
body.resize(sif.st_size + 1);
Util::readFile(html_404, body); // 一定能成功
}
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
cout << "----------------------http response start---------------------------" << endl;
cout << resp.outbuffer << endl;
cout << "----------------------http response end---------------------------" << endl;
resp.outbuffer += body;
}
7.HTTP常见Header
- Content-Type: 数据类型(text/html等)
- Content-Length: Body的长度
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
- User-Agent: 声明用户的操作系统和浏览器版本信息;
- referer: 当前页面是从哪个页面跳转过来的;
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
- Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能
8.长连接
我们知道其实一张我们看到的网页,实际上可能由多种元素构成,也就是说一张完整的网页需要多次http请求然后浏览器进行组合和渲染,这是我们在前面都见过的。
这里就会有这样一个问题,http网页中可能会包含多个元素,如果频繁发起http请求,http是基于tcp的,tcp是面向链接的,所以会导致频繁创建链接的问题。
如果有100张图片,http就要发起100请求,tcp建立100次链接,这成本就太大了。
所以这就需要client和server都要支持,长连接,客户端向服务器发起建立一次链接(这里并不是说与服务器建立好链接之后请求所有资源就永远用的是这一个链接 ,而是说我们请求服务器看到一张网页,这个网页里有很多元素),与服务器这张网页建立好一条链接,获取一大份资源的时候,通过同一条链接完成。
这表示客户端和服务器协商好支持长连接。
Connection:close -----> 短链接(有多少请求,建立多少次tcp链接)
9.cookie&&session会话保持
严格来说会话保持并不属于http协议天然所具备的,是后面使用发现需要的。
http定位是一个超文本传输协议,它只要把资源从服务器拿到本地就可以了。
但依旧要一个会话保持。
什么是会话保持呢?
简单来说就是你打开bilibili网站,然后登录一次账号。以后你在网站里做网页跳转的时候都不需要重新登录了,然后在重新打开bilibili这个账号依旧在,关闭浏览器然后依旧从这个浏览器在打开bilibili你的账号信息还在!如果换成其他浏览器进入bilibili网站这次你的消息才不会存在了。这就是会话保持。
http协议是无状态的!也就是说第一次第二次第三次请求,第二次请求不知道第一次请求过,第三次请求不知道第二次第一次请求过。换句话浏览器三次请求一样的图片,按照道理来说,浏览器每一次都帮我发起http请求。因为http协议无状态它不会记录历史曾经所涉及的状态信息、所有曾经的请求、不会猜测下一次请求该做什么。它只复杂自己的功能我要什么你给我拿什么就可以了。
但实际上现实往往和理论是不符的,虽然我们发现http协议无状态,但是我们发现我曾经登录过,但http协议在我们通信的时候依旧能记住我。这虽然和http协议无直接关系,但间接有关。
http协议无状态!但用户需要,因为用户查看新的网页是常规操作,如果发生网页跳转,那么新的页面也就无法识别是哪一个用户了,为了让用户一经登录,可以在整个网站,按照自己的身份进行随意访问 -------> 会话保持(保持一个用户始终在线的状态)
那如何实现呢?
老方法
首先通过http协议进行登录(输入用户名和密码),登录成功服务器返回首页让你使用。当发生页面跳转时,http是不记录我的状态的就构建请求发给服务端,服务器此时说你谁啊?我不认识你,必须是登录用户才能请求这个页面,这时你还必须重新登录才可以,那就扯犊子了。
所以解决方案的设计者这样做,一旦首次登录成功后,浏览器会帮我们维持一个东西。把我们用户输入的信息:用户名&&密码保存起来。然后往后只要访问同一个网站浏览器会自动推送历史保留的信息。
而服务端对凡是与网页访问有权限要求的网页,在被获取之前,全部都要做判断!!-----> 身份认证
这两个搭配起来之后,只要当首次登录成功之后浏览器并将信息保存起来,往后就由浏览器和服务器双方配合,就不用用户频繁输入用户名和密码了,每一次http请求浏览器都会把曾经保存的这份信息推送给服务器。虽然服务器每一次都做身份认证,此时在用户看来只在一次需要登录,往后就不需要再登录了,因为都由浏览器和服务器在配合着进行会话保持。
其中浏览器把我们账号密码信息保存起来的技术叫做cookie技术。
cookie技术分为:cookie文件级别,cookie内存级别,怎么理解呢?
浏览器本质也是一个进程,如果关闭然后重新打开浏览器,也就是进程退出然后又运行了,进程退出进程保存的东西都随进程退出而释放掉,但当我在访问b站的时候我依旧是登录状态,这个浏览器采用的是cookie文件级别也就是说浏览器会在本地给我维持一个cookie文件是在磁盘上真真实实存在的。甚至关机,但当我访问b站我还是登录状态。
如果随着浏览器退出然后再重新打开浏览器,再打开b站的时候我的登录信息就没有了,此时cookie采用的是cookie内存级别,也就是随进程关闭所有进程保存的东西都没有了。
cookie是文件的还是内存的在浏览器是可以匹配的。
但是这个方法有问题!
浏览器虽然会在我们第一次登录会保存我们登录信息,往后每次账号密码都要进行推送给服务器,服务器鉴权后返回资源。然后以后都是重复这个工作。但某一天你的电脑中了木马病毒,它并不破坏你的电脑,而只是盗取你的消息如cookie文件。然后这个盗取你消息的人拿着浏览器去访问你所访问的网站会造成什么后果?
不需要登录浏览器自动推送服务器自动做鉴权,服务器会误认这个非法用户是你,这个危害对你还不是最大的,对社会影响最大。比如用你的身份进行诈骗等。
对你来说你的信息泄漏了。
上面的问题并不是信息放在文件中的问题,而是把文件放在客户端出了问题,用户对自己信息保护能力有限。
接下来为了解决上面的问题,我们有一个新的方案。这个消息不要保存在浏览器上,而保存在服务器上。
浏览器首先还是登录输入用户名和密码,服务端把用户形成的认证信息浏览痕迹在服务端形成一个session文件,服务器可能有成百上千万用户,而每个用户只要曾经登录过都要形成session文件,所以给每一个session文件起一个唯一名称,通常是一个大大的字符串我们称之为session id具有唯一性。然后服务器在登录成功的时候把这个session id返回给浏览器,浏览器只保存session id,依旧保存在cookie文件中。以后浏览器在访问服务器的时候构建请求里面必须把session id带上,这个请求到了服务端结合在这个session id在session id列表中对这个session id做认证,只要有并且内容没有异常服务器就直接认为这个用户处于登录状态,然后把资源返回去。每次请求都会做这个动作。
这个方法将用户的私密信息通过session id保存在服务器端,所以可以直接认为用户信息的泄漏,已经大大改善了!我们这些信息由那些公司进行保护。
但是这个黑客还是把木马安装到你的电脑里,虽然拿不到账号密码的私密信息,但能,把这个session id拿到自己的浏览器是对服务器进行访问,服务器还是会认为这个非法用户是你,但是这些公司会配合其他策略缓解这类问题。如即使session id一样,但是上次你在北京登录,过一会你就跑广东登录了,区域ip地址不一样,就会让你下线让你重新登录等等方法。这些策略只要识别不是你要session id失效即可。
那server如何写入cookie信息?如何验证client会携带cookie?
服务器可以在报头在加一条属性。不过这条属性内容是我们随便写的为了验证,别的服务器有自己的一套算法形成唯一的session id。
// 1. 服务器和网页分离,html
// 2. url -> / : web根目录
// 3. 我们要正确的给客户端返回资源类型,我们首先要自己知道!所有的资源都有后缀!!
void Get(const httpRequest &req, httpResponse &resp)
{
cout << "----------------http start---------------" << endl;
cout << req.inbuffer << endl;
cout << "method: " << req.method << endl;
cout << "url: " << req.url << endl;
cout << "httpversion: " << req.httpversion << endl;
cout << "path :" << req.path << endl;
cout << "suffix: " << req.suffix << endl;
cout << "size: " << req.size << endl;
cout << "----------------http end-----------------" << endl;
//string respline = "HTTP/1.1 200 OK\r\n";
string respline = "HTTP/1.1 307 Temporary Redirect\r\n";
//string respheader="Content-Type: text/html\r\n";
string respheader = suffixtodes(req.suffix);
if (req.size > 0)
{
respheader += "Content-Length: ";
respheader += to_string(req.size);
respheader += "\r\n";
}
respheader += "Location: https://www.baidu.com/\r\n";
respheader+="Set-Cookie: 123456abc\r\n";//
string respblank = "\r\n";
// string body="<html lang=\"en\"><head><meta charset=\"UTF-8\"><title>for test</title><h1>hello world</h1></head><body><p>北京交通广播《一路畅通》“交通大家谈”节目,特邀北京市交通委员会地面公交运营管理处处长赵震、北京市公安局公安交通管理局秩序处副处长 林志勇、北京交通发展研究院交通规划所所长 刘雪杰为您解答公交车专用道6月1日起社会车辆进出公交车道须注意哪些?</p></body></html>";
string body;
body.resize(req.size + 1);
if (!Util::readFile(req.path, body))
{
// 找不到文件,文件大小是-1,要返回404.html,因此重新计算大小
struct stat sif;
if (stat(html_404.c_str(), &sif) == 0)
body.resize(sif.st_size + 1);
Util::readFile(html_404, body); // 一定能成功
}
resp.outbuffer += respline;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
cout << "----------------------http response start---------------------------" << endl;
cout << resp.outbuffer << endl;
cout << "----------------------http response end---------------------------" << endl;
resp.outbuffer += body;
}
Cookie还可以设置到期时间都可以试一下
respheader += "Set-Cookie: name=1234567abcdefg; Max-Age=120\r\n";
当一旦设置好cookie之后,往后,每次http请求,都会自动携带曾经设置的所有的cookie,帮服务器进行鉴权行为 —> http会话保持
10.基本工具(postman,fiddler)
postman,不是抓包工具,模拟客户端 ----> 浏览器行为
fiddler,是一个抓包工具,专门用来抓http的,抓的是本地的,可以用来调试的
文章来源:https://www.toymoban.com/news/detail-853450.html
fiddler原理就是,未来浏览器在发起请求时是把请求交给fiddler,由fiddler代替你去请求服务器,服务器响应也是给fiddler,然后fiddler再把响应转发给浏览器。
而postman可以认为就是把浏览器换成它发起请求然后服务器给响应。
文章来源地址https://www.toymoban.com/news/detail-853450.html
到了这里,关于【Linux网络编程】HTTP协议的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!