【Linux网络编程】应用层协议 - HTTP
目录
初识HTTP协议
认识URL
HTTP协议的宏观格式
Socket封装
TcpServer
HttpServer
整体设计
接收请求
web根目录与默认首页
发送应答
完善页面
HTTP常见Header
HTTP状态码
HTTP请求方法
Connection
抓包
初识HTTP协议
应用层协议一定是基于UDP/TCP的,HTTP协议是基于TCP的。只要基于TCP,就就是全双工、面面向字节流、在应用层就需要进行协议定制和序列化。
虽然我们说,应用层协议是我们程序员自己定的。但实际上,已经有大佬们针对各种应用场景定义了一些现成的,又非常好用的应用层协议,供我们直接参考使用.HTTP(超文本传输协议)就是其中之一。.在互联网世界中,HTTP是一个至关重要的协议。它定义了客户端(如浏览器)与服务器之间如何通信,以交换或传输超文本(如HTML文档)。也就是说,TP协议是进行网页交换的,之前我们的协议是在交换Request、Response。
HTTP协议是客户端与服务器之间通信的基础。客户端通过HTTP协议向服务器HTTP初议是一个无连接无状态的协议,即每次请求都需要建立新的连接,且服务器不会保存客户端的状态信息。
认识URL
URL就是统一资源定位符。就是我们俗称的网址。现如今互联网中应用的较广泛的已经是https了
https是服务端与客户端通信所采用的协议;接下来是域名,未来会被解析成公网IP地址,用于标识服务端所在的主机;接下来就是目标文件在服务端所在主机的位置;再接下来就是目标文件的名称。目标文件是HTML、CSS、JS等。所以:
- 网络请求的资源本质上就是一个文件
- 上面的路径是通过”/"进行分隔的,就是Linux下的路径结构
所谓HTTP请求,就是将指定主机下的指定文件的内容发送给客户端。当然文件的种类很多,有图片、视频、音频、脚本文件等等。
前置背景理解:
1. 我们上网的所有行为其实就是将我的数据给别人,或者将别人的数据给我,就是IO。我们刷短视频时,就是将服务器上的短视频推送到手机上,在本地播放;浏览购物平台时,就是将网页、图片、视频等资源推送到手机上。登录、注册就是在将我们自己的信息推送给服务器。
2 .作为获取数据的、视频、音频、文本等,现在,我们将这些图片、视频、音频、文本等,统一称为资源。只要是有用的,且是有限的,就叫做资源。
3. 对于这些资源,一定是在互联网的某一些机器上放着的,当我们要获取这些资源时,第一步肯定是先要知道这些资源在那一台服务器上。而要在网络中确定一台服务器,就是要知道它的IP地址。另外,除了要知道资源文件在那一台服务器上,还要知道在这个服务器的哪一个路径下。所以,确定一份资源,就需要IP地址+路径,这就是URL。服务器通过URL找到目标资源后,就会将目标资源打开,并通过端口号推送给客户端。
4. 我们会发现URL中路径是从/开始的,但是这里的/不一定是Linux中的根目录。叫做web根目录,两者不一定一样。
服务器通过URL找到目标资源后,打开目标资源,然后需要通过端口号推送给客户端,但是URL中并没有体现出端口号。这是因为很多成熟的应用层协议,往往和端口号是强关联的。也就是只要知道应用层协议名,它的端口号就是默认的。HTTP的端口号默认就是80,HTTPS的端口号默认就是443。并且端口号一般都是1024以内的数。所以,URL中并不需要体现端口号。
这里的登录信息在现在是省略的。端口号也是省略的。我们知道,要访问目标服务器一定要有端口号,URL中没有端口号只是我们看到的,未来浏览器会根据协议名将端口号添加上的。HTTP是可以传参的,?的左边是要访问的资源,右边是要传递的参数,参数是格式是key=value。#是片段标识符,不用管。
假设我们现在在浏览器上搜索"CSDN",得到的网址是:
https://cn.bing.com/search?q=CSDN&qs=n&form=QBRE&sp=-1&lq=0&pq=csdn&sc=12-4&sk=&cvid=DDBF6D513D3C49C8B465378F2F67B52D
可以看到,有一个q = CSDN,表示的就是搜索时传递的参数。
urlencode和urldecode
URL中为了保证格式,是有非常多的特殊字符的,如果我们搜索的关键词中就包含这些特殊字符呢?我们现在搜索一下"://=?/&",得到的网址是:
https://cn.bing.com/search?q=%3A%2F%2F%3D%3F%2F%26&qs=n&form=QBRE&sp=-1&lq=0&pq=%3A%2F%2F%3D%3F%2F%26&sc=12-7&sk=&cvid=770AE2C40DF34124BE46EC996A163494
会发现我们搜索的东西变成了这个,这是将我们搜索的关键词进行了编码,为了避免我们搜索的关键词中的特殊字符与URL中的特殊字符互相影响,导致URL格式解析失败。这个过程称为urlencode。服务器端收到URL后,会先将URL解析出来,得到编码后的URL,需要将编码后的URL转换成原先的格式,将编码后的URL转换成原先的格式的过程称为urldecode。
uelencode和urldecode是如何转换的呢?每一个字符都有ASCII值,实际上就是将其转成这个特殊字符对应的ASCIl的十六进制。然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式。对于汉字的转换,就不是使用ASCII值了,可能是根据utf8等进行转换的
HTTP协议的宏观格式
http协议是应用层协议,是基于TCP协议的。对于http协议,需要知道两个问题:
- 协议的格式是什么?
- 如何保证收发完整性?因为TCP是面向字节流的。
请求方法表示的是想向服务器上传数据,还是从服务器中获取数据,毕竟我们通过服务器既可以访问东西,也可以下载东西。请求行的url一般是请求路径,就是/后面的内容。这里的换行符一般是\r\n。这个HTTP REQUEST实际上就是一个结构体或类。这个结构体或类要进行序列化时,只需要一行一行进行拼接即可。大字符串的分隔符是\r\n。反序列化时,只需要一行一行读,直到读到空行,就代表报头部分读完了,接下来就是正文了。不过请求正文部分并不一定是/r/n结束,反序列化时要怎么知道正文部分有多长呢?在请求报头中有一行是Content-Length:XXX\r\n,代表的就是正文部分的长度。所以,HTTP协议自己就能完成序列化和反序列化。HTTP协议为什么要自己完成,而不使用jsoncpp等库呢?因为HTTP协议是一个独立协议,它不想依赖任何库。
响应的格式与请求是十分类似的,可能有些字段不一样,但是整体的结构是一样的,这样两者可以使用一套序列化和反序列化方法。这个响应正文就是html/css/js、图片、视频、音频等资源!!!
无论什么请求,都会有应答,是有可能请求的资源根本就不存在的,状态码表示的是请求时的一些状态。404就表示请求的资源不存在。404的状态码描述就是Not Found。浏览器就是一个HTTP协议,或者HTTPS协议的客户端。未来我们可以写一个服务器,并按照HTTP的宏观格式来构建请求和应答,就可以把我们想要的信息直接构建到浏览器上了。
Socket封装
因为套接字有TCP、UDP,有Linux、Windows的,所以,我们不仅仅简单地封装成类,而是使用模板方法模式封装。
// 基类:提供创建socket的方法
class Socket
{
public:virtual ~Socket() = default;virtual void SocketOrDie() = 0; // 创建套接字virtual void SetSocketOpt() = 0; // 设置套接字选项virtual bool BindOrDie(int port) = 0; // 绑定virtual bool ListenOrDie() = 0; // 设置套接字为监听状态virtual int Accept() = 0; // 接受连接virtual void Close(int fd) = 0; // 关闭套接字virtual int Recv(std::string* out) = 0; // virtual int Send(const std::string& in) = 0; // 发送消息
#ifdef WIN// 提供一个创建listensockfd的固定套路void BuildTcpSocket(int port){SocketOrDie();SetSocketOpt();BindOrDie(port);ListenOrDie();}// 提供一个创建listensockfd的固定套路void BuildUdpSocket(){}
#else // Linux// 提供一个创建listensockfd的固定套路void BuildTcpSocket(int port){SocketOrDie();SetSocketOpt();BindOrDie(port);ListenOrDie();}// 提供一个创建listensockfd的固定套路void BuildUdpSocket(){}
#endif
};
后序由子类自己实现创建套接字的细节,然后统一调用基类中创建套接字的固定模板接口。我们今天就简单一点,我们只创建Linux下的TCP套接字。
// 基类:提供创建socket的方法
class Socket
{
public:virtual ~Socket() = default;virtual void SocketOrDie() = 0; // 创建套接字virtual void SetSocketOpt() = 0; // 设置套接字选项virtual bool BindOrDie(int port) = 0; // 绑定virtual bool ListenOrDie() = 0; // 设置套接字为监听状态virtual int Accept() = 0; // 接受连接virtual void Close() = 0; // 关闭套接字virtual int Recv(std::string* out) = 0; // 接收消息virtual int Send(const std::string& in) = 0; // 发送消息// 提供一个创建listensockfd的固定套路void BuildTcpSocket(int port){SocketOrDie();SetSocketOpt();BindOrDie(port);ListenOrDie();}
};
const int gdefaultsockfd = -1;
const int gbacklog = 8;
class TcpSocket : public Socket
{
public:TcpSocket(int sockfd = gdefaultsockfd):_sockfd(sockfd){}virtual ~TcpSocket(){}virtual void SocketOrDie() override{_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (_sockfd < 0){LOG(LogLevel::ERROR) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::DEBUG) << "socket create success: " << _sockfd;}virtual void SetSocketOpt() override{// 暂时为空}virtual bool BindOrDie(int port) override{if (_sockfd == gdefaultsockfd) return false;InetAddr addr(port);int n = ::bind(_sockfd, addr.NetAddr(), addr.NetAddrLen());if (n < 0){LOG(LogLevel::ERROR) << "bind error";exit(SOCKET_ERR);}LOG(LogLevel::DEBUG) << "bind create success: " << _sockfd;return true;}virtual bool ListenOrDie() override{if (_sockfd == gdefaultsockfd) return false;int n = ::listen(_sockfd, gbacklog);if (n < 0){LOG(LogLevel::ERROR) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::DEBUG) << "listen create success: " << _sockfd;return true;}virtual int Recv(std::string* out) override{char buffer[1024 * 8];auto size = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);if (size > 0){buffer[size] = '\0';*out = buffer;}return size;}virtual int Send(const std::string& in) override{auto size = ::send(_sockfd, in.c_str(), in.size(), 0);return size;}virtual int Accept() override{return 0;}virtual void Close() override{if (_sockfd == gdefaultsockfd) return;::close(_sockfd);}
private:int _sockfd;
};
int main(int argc, char* argv[])
{if(argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;return 1;}Socket* sk = new TcpSocket();sk->BuildTcpSocket(std::stoi(argv[1]));return 0;
}
我们会发现,对于套接字,有时候绑定能成功,有时候会失败。因为我们在退出服务器时,浏览器作为客户端可能还连着,服务器作为主动退出的哪一方,在TCP协议处会进行四次挥手,在挥手时,服务器端就会处于TIME_WAIT,持续时间一般是60秒到120秒,要想解决这个问题,可以使用系统调用setsockopt。
#include <sys/types.h> /* 基本系统数据类型 */
#include <sys/socket.h> /* Socket 相关头文件 */int setsockopt(int sockfd, // 套接字文件描述符int level, // 选项的协议层(如 SOL_SOCKET、IPPROTO_TCP)int optname, // 选项名称(如 SO_REUSEADDR、TCP_NODELAY)const void *optval, // 指向选项值的指针socklen_t optlen // 选项值的长度(字节数)
);
virtual void SetSocketOpt() override
{// 保证我们的服务器,在异常断开之后,可以立即重启,不会有Bind问题int opt = 1;int n = ::setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}
未来创建时,统一使用Socket,这种模式成为模板方法模式。我们这里并没有实现Accept,后面实现。因为HTTP是基于TCP的,所以要有一个TCP服务器。
TcpServer
TcpServer只负责接收来自客户端的请求,对请求进行处理通过回调的方法交给HttpServer处理。因为客户端发过来的报文是根据HTTP的协议的格式的,所以将IO处理交给HttpServer更好。也就是说,TcpServer只负责接收请求,接收到了请求就直接通知HttpServer,由HttpServer去接收客户端的消息,处理完成后再发送回客户端。
namespace TcpServerModule
{using namespace SocketModule;using namespace LogMoudule;class TcpServer{private:TcpServer(int port):_listensockp(std::make_unique<TcpSocket>()), _port(port), _running(false){_listensockp->BuildTcpSocket(port);}void Loop(){_running = true;while(_running){// 1. Accept_listensockp->Accept();// 2. 通过回调让HttpServer去处理请求}_running = false;}~TcpServer(){_listensockp->Close();}private:std::unique_ptr<Socket> _listensockp;int _port;bool _running;};
}
接下来完成Accept接口,我们让这个接口返回套接字类型。因为有一些套接字是负责获取新连接的,有一些套接字是负责进行IO的。通过Accept接口要获取两个信息,一个是进行lO的文件描述符,一个是客户端的信息。
class Socket; // 声明using SockPtr = std::shared_ptr<Socket>;
因为类Socket中有使用到SockPtr,所以要先声明,然后将类型SockPtr定义出来。
virtual SockPtr Accept(InetAddr* client) override
{if (!client) return nullptr;struct sockaddr_in peer;socklen_t len = sizeof(peer);int newsockfd = ::accept(_sockfd, CONV(&peer), &len);if (newsockfd < 0){LOG(LogLevel::WARNING) << "accept error";return nullptr;}client->SetAddr(peer, len);return std::make_shared<TcpSocket>(newsockfd);
}
void Loop()
{_running = true;while (_running){// 1. AcceptInetAddr clientaddr; // 从Accept接口中获取客户端的信息auto sockfd = _listensockp->Accept(&clientaddr);if (sockfd == nullptr) continue;LOG(LogLevel::DEBUG) << "get a new client, info is: " << clientaddr.Addr();// 2. 通过回调让HttpServer去处理请求}_running = false;
}
需要给类InetAddr增加一个使用sockaddr_in构造InetAddr的接口
void SetAddr(const sockaddr_in& client, socklen_t& len)
{_net_addr = client;IpNet2Host();
}
接下来就来完成Loop中的回调,因为TcpServer只负责接收请求,所以需要有一个回调函数
namespace TcpServerModule
{using namespace SocketModule;using namespace LogMoudule;// 第一个参数是客户端的套接字,第二个参数是客户端信息using tcphandler_t = std::function<bool(SockPtr, InetAddr)>;class TcpServer{public:TcpServer(int port):_listensockp(std::make_unique<TcpSocket>()), _port(port), _running(false){}void InitServer(tcphandler_t handler){_handler = handler;_listensockp->BuildTcpSocket(_port);}void Loop(){_running = true;while(_running){// 1. AcceptInetAddr clientaddr; // 从Accept接口中获取客户端的信息auto sockfd = _listensockp->Accept(&clientaddr);if(sockfd == nullptr) continue;LOG(LogLevel::DEBUG) << "get a new client, info is: " << clientaddr.Addr();// 2. 通过回调让HttpServer去处理请求// 多进程pid_t id = fork();if(id == 0){_listensockp->Close();if(fork() > 0) exit(0);// 将客户端的文件描述符和信息交给了上层,通过回调由上层进行处理_handler(sockfd, clientaddr);exit(0);}sockfd->Close();waitpid(id, nullptr, 0);}_running = false;}~TcpServer(){_listensockp->Close();}private:std::unique_ptr<Socket> _listensockp;int _port;bool _running;tcphandler_t _handler; // 回调方法};
}
HttpServer
整体设计
class HttpServer
{
public:HttpServer(int port):_tsvr(std::make_unique<TcpServer>(port)){}// 处理HTTP请求,这就是回调bool HandlerHttpRequest(SockPtr sockfd, InetAddr client){LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();return true;}// 启动HTTP服务器void Start(){_tsvr->InitServer([this](SockPtr sockfd, InetAddr client){return this->HandlerHttpRequest(sockfd, client);});_tsvr->Loop();}~HttpServer() {}
private:std::unique_ptr<TcpServer> _tsvr;
};
给类Sockfd增加一个成员函数,用于获取套接字
virtual int Fd() override
{return _sockfd;
}
int main(int argc, char* argv[])
{if(argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;return 1;}auto httpserver = std::make_unique<HttpServer>(std::stoi(argv[1]));httpserver->Start();return 0;
}
这样,未来只需要创建好HTTP服务器,然后启动,就会将HTTP请求处理函数作为TCP服务器的处理函数,然后进入到TCP内部的循环,获取新连接,一旦有连接了,就会回调HTTP内部处理请求的方法。我们使用浏览器去访问我们的HTTP服务器。
此时http请求处理函数只是简单打印一下,但是确实是可以看到接收到了请求。
TCP服务器接收到请求后,就会调用HTTP服务器的请求处理函数后,请求处理函数接收客户端的消息,首先应该检查报文的完整性,然后再反序列化。但是这里确保完整性之前已经做过了,不是重点,所以我们这里不实现了,只进行反序列化。也就是说,我们直接认为接收到的就是一个完整的请求。在进行反序列化之前,我们先看看TCP服务器接收到的来自客户端的请求序列化后是什么样的。
// 处理HTTP请求,这就是回调
bool HandlerHttpRequest(SockPtr sockfd, InetAddr client)
{LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收客户端消息,并打印std::string http_request;sockfd->Recv(&http_request);std::cout << http_request; // 字节流请求return true;
}
虽然看起来有很多行,但是实际上Http服务器接收到的就是一个大字符串。其中有一个空行,并且请求正文是空的。现在对里面的一些字段做出简略的叙述。第一行是请求行,第一个字段是请求方法,常见的就是GET/POST,GET表示请求指定资源,POST表示向服务器提交数据。URI表示的是请求的服务器的资源的路径,HTTP版本格式一般是HTTP/1.0,HTTP/1.1等。Host表示的是这个请求发给的是那一台主机上的哪一个端口。Connection表示长链接。Upgrade-Insecure-Requests我们不关心。Accept:我们发起HTTP请求是浏览器发的,浏览器就告诉服务器能接收
什么。我们重点看User-Agent,User-Agent表示的是发起请求的客户端的信息。使用Windows计算机搜索微信时,看到的就是Windows版的,正是因为有User-Agent。
我们现在先来尝试返回给客户端一些信息,也就是服务器作出响应。我们这里直接返回固定格式,无论客户端请求什么,都返回一个hello world。正确的应该是请求什么返回什么,所以需要对HTTP协议进行定制,这个工作我们后面再做。
const std::string Sep = "\r\n"; // 换行符
const std::string BlankLine = Sep; // 空行class HttpServer
{
public:HttpServer(int port) : _tsvr(std::make_unique<TcpServer>(port)){}// 处理HTTP请求,这就是回调bool HandlerHttpRequest(SockPtr sockfd, InetAddr client){LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收TCP服务器接收到的客户端消息,并打印std::string http_request;sockfd->Recv(&http_request);std::cout << http_request; // 字节流请求std::string status_line = "HTTP/1.1 200 OK" + Sep + BlankLine;// 直接返回一个html网页std::string body = "<!DOCTYPE html>\<html>\<head>\<meta charset = \"UTF-8\">\<title> Hello World</title>\</head>\<body>\<p> Hello World</ p>\</body> </html>";// 将报头与正文进行拼接std::string httpresponse = status_line + body;// 发送给客户端sockfd->Send(httpresponse);return true;}// 启动HTTP服务器void Start(){_tsvr->InitServer([this](SockPtr sockfd, InetAddr client){ return this->HandlerHttpRequest(sockfd, client); });_tsvr->Loop();}~HttpServer() {}private:std::unique_ptr<TcpServer> _tsvr;
};
这样,就完成了一个http的请求和应答。实际上前端的代码肯定不会混合到C++的代码中,会将其写到一个文件当中,然后C++的代码再去读取文件。
可以看到,此时客户端就能够拿到一个Hello World了。但是现在无论客户端发送什么请求,都是都是得到一个固定的应答,若想要根据客户端的要求返回应答,就需要进行协议定制。
接收请求
现在来进行协议定制,我们先来看实现HttpRequest,这个类首先需要提供一个反序列化的函数,因为我们接收到的来自客户端的消息是字节流的,需要将其反序列化后才能进行处理。并且要注意,我们在反序列化时,除了要进行一行一行分离,对于某些行,特别是第一行内的详细信息,也是需要分离出来的。我们定义一个字符串截取函数。
// 字符串截取函数,根据sep去截取str,并将截取得到的结果放到out中
// 1. 正常字符串 2. out空串&&返回值是true 3. out空串&&返回值是false
bool ParseOneLine(std::string& str, std::string* out, const std::string& sep)
{auto pos = str.find(sep);if(pos == std::string::npos) return false;*out = str.substr(0, pos);str.erase(0, pos + sep.size());return true;
}
这个截取函数主要用它来截取一行。这个字符串截取函数有3种返回结果, 当out不为空串时,说明截取是成功的,当out为空串时,若返回值为true,说明截取到空行了,若返回值为false,说明截取出错了。
const std::string Sep = "\r\n"; // 换行符
const std::string BlankLine = Sep; // 空行
const std::string LineSep = " "; // 空格
const std::string HeaderLineSep = ": "; // 报头中k、v的分隔符
class HttpRequest
{
private:// 细化请求行的字段void ParseReqLine(std::string& _req_line, const std::string sep){std::stringstream ss(_req_line);ss >> _method >> _uri >> _version;}bool SplistString(const std::string& header, const std::string& sep, std::string* key, std::string* value){auto pos = header.find(sep);if(pos == std::string::npos) return false;*key = header.substr(0, pos);*value = header.substr(pos + sep.size());return true;}bool ParseHeaderkv(){std::string key, value;for(auto& header : _req_header){if(SplistString(header, HeaderLineSep, &key, &value)){_headerkv.insert({key, value});}}return true;}// 解析请求报头,这里是将每一行数据提取出来bool ParseHeader(std::string& request_str){std::string line;while(true){bool r = ParseOneLine(request_str, &line, Sep);if(r && !line.empty()){_req_header.push_back(line);}else if(r && line.empty()) // 读到空行了{_blank_line = Sep;break;}else{return false;}}// 现在_req_header中保存的是一行一行的请求报头,我们要对其进行细化ParseHeaderkv();return true;}
public:HttpRequest() {}~HttpRequest() {}void Deserialize(std::string& request_str) // 反序列化{// 提取出第一行,并细化解析出的字段if(ParseOneLine(request_str, &_req_line, Sep)){// 提取请求行中的详细字段ParseReqLine(_req_line, LineSep);// 提取出请求报头中的详细字段ParseHeader(request_str);// 请求报头和空行提取完成之后,剩下的就是正文了_body = request_str; }}void Print(){std::cout << "_method: " << _method << std::endl;std::cout << "_uri: " << _uri << std::endl;std::cout << "_version: " << _version << std::endl;for(auto& kv : _headerkv){std::cout << kv.first << " # " << kv.second << std::endl;}std::cout << "_blank_line: " << _blank_line << std::endl;std::cout << "_body: " << _body << std::endl;}
private:std::string _req_line; // 保存请求行std::vector<std::string> _req_header; // 保存请求报头,这里是一行一行保存std::string _blank_line; // 保存空行std::string _body; // 保存请求正文// 细化我们解析出来的字段std::string _method; // 请求方法std::string _uri; // uristd::string _version;// HTTP版本std::unordered_map<std::string, std::string> _headerkv; // 请求行的k、v结构
};
// 处理HTTP请求,这就是回调
bool HandlerHttpRequest(SockPtr sockfd, InetAddr client)
{LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收客户端消息std::string http_request;sockfd->Recv(&http_request); // 字节流消息// 对接收到的字节流消息进行反序列化,并打印HttpRequest req;req.Deserialize(http_request);req.Print();return true;
}
现在我们使用浏览器访问我们的服务器,看看能否反序列化成功。
可以看到,反序列化成功了。
web根目录与默认首页
一个HTTP协议要被具体实现的话,是需要有web根目录和默认首页的。现在,HTTP服务器已经成功地将拿到的请求进行了反序列化。请求的资源是uri的路径决定的,之前说过,uri中的/称为web根目录。对于HTTP协议,如果有人想将HTTP协议写成服务,就需要构建一个HttpServer自己的家目录。然后将属于这个服务的网页信息放到家目录里面。
我们在HttpServer下面创建一个目录wwwroot,这个wwwroot就是这个HttpServer所对应的家目录。任何的网站,或者站点形式的后端服务,若想被别人访问,这个站点就必须要有一个默认首页,叫做index.html。这里的wwwroot就是web根目录,是被隐藏的,名字可以随便取。index.html这个名字一般是约定俗成的。会发现,www.baidu.com和www.baidu.com/index.html进入的都是百度的首页所以,百度的首页就叫index.html。
我们之前在进行响应时,直接将前端代码写这肯定是不对的,我们需要一些专门的网页内容。我们给index.html中写入HTML的代码:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Hello World</title>
</head>
<body><h1>Hello World</h1>
</body>
</html>
当客户端发起请求时,只请求/,或请求首页,就需要将文件中的内容发过去。现在完成了反序列化,用户需要的东西在uri中。所以,在反序列化完成之后,我们将uri打印出来。
// HttpRequest成员函数
std::string Uri()
{return _uri;
}
// 处理HTTP请求,这就是回调
bool HandlerHttpRequest(SockPtr sockfd, InetAddr client)
{LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收客户端消息std::string http_request;sockfd->Recv(&http_request); // 字节流消息// 对接收到的字节流消息进行反序列化,并打印HttpRequest req;req.Deserialize(http_request);std::cout << "用户想要: " << req.Uri() << std::endl;return true;
}
此时可以在浏览器中 IP地址:端口号/...,加入想要资源的路径。所以,这个uri就是客户端想通过这次HTTP请求获取服务器上的什么资源。如果只有/,就是请求默认首页,也就是wwwroot/index.html,如果传入的路径是/a/b/c.html的话,需要将wwwroot/a/b/c.html交给客户端。注意:返回的是网页的内容。所以,我们需要给uri拼接上web根目录的路径,在这里就是wwwroot。
const std::string defaulthomepage = "wwwroot"; // web根目录名称
// 细化请求行的字段
void ParseReqLine(std::string& _req_line, const std::string sep)
{std::stringstream ss(_req_line);ss >> _method >> _uri >> _version;// 给uri添加上web根目录_uri = defaulthomepage + _uri;
}
这样,我们的服务往后找所有的资源,都不会到Linux根目录下找了,而是到wwwroot下面找了
发送应答
想要响应客户端的请求,HttpRequest就需要有一个获取客户端想要的资源的接口。
// 读取_uri路径下的网页信息
std::string GetContent()
{std::string content;std::ifstream in(_uri);if (!in.is_open()) return std::string();std::string line;while (std::getline(in, line)){content += line;}in.close();return content;
}
const std::string http_version = "HTTP/1.0"; // HTTP版本
对于HTTP版本一般是固定的
class HttpResponse
{
public:HttpResponse():_version(http_version), _blank_line(Sep){}~HttpResponse() {}// 建立应答void Build(HttpRequest& req){// 获取用户想要的资源std::string content = req.GetContent();if(content.empty()){// 用户请求的资源不存在}else{}}
private:std::string _resp_line; // 状态行std::vector<std::string> _resp_header; // 响应报头std::string _blank_line; // 空行std::string _body; // 响应正文// 细化我们解析出来的字段std::string _version; // HTTP版本int _status_code; // 状态码std::string _status_desc; // 状态码描述
};
无论用户请求的资源是否存在,都需要设置状态码,所以我们需要先了解一下状态码。
对于具体的状态码,我们这里只看两个,后序再详细介绍。
打开文件成功,就是200;打开文件失败,可能会有多个原因,今天我们就认为是资源不存在,状态码是404。
class HttpResponse
{
private:std::string Code2Desc(int code){switch(code){case 200:return "OK";case 404:return "Not Found";default:return std::string();}}
public:HttpResponse():_version(http_version), _blank_line(Sep){}~HttpResponse() {}// 建立应答void Build(HttpRequest& req){// 获取用户想要的资源std::string content = req.GetContent();if(content.empty()){// 用户请求的资源不存在_status_code = 404;}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);}
private:std::string _resp_line; // 状态行std::vector<std::string> _resp_header; // 响应报头std::string _blank_line; // 空行std::string _body; // 响应正文// 细化我们解析出来的字段std::string _version; // HTTP版本int _status_code; // 状态码std::string _status_desc; // 状态码描述
};
可以看到,请求和应答中都要HTTP版本,它们分别是什么意思呢?请求中的HTTP版本指的是浏览器中采用的HTTP协议的版本,应答中的HTTP版本指的是服务器中采用的HTTP协议的版本。HTTP作为一个成熟的协议,双方在进行请求和应答交换时,也要交换一下双方的版本信息,因为双方客户端和服务器的版本可能不一致。以微信举例,假设微信1.0的客户端只有聊天功能,2.0的客户端有朋友圈功能,3.0的客户端有语言聊天功能,微信有非常多的用户群体,这些用户的版本必然不可能完全一致,服务器在更新的过程中,一定要保证新老版本的客户端的兼容性。所谓兼容性,就是1.0的客户端向服务器发出请求时,服务器不应该给它提供朋友圈和语言聊天功能。所以,双方交换一下版本,就能让服务器知道客户端的这个请求是否合法。所以,双方在协议中交换一下版本是对客户端版本进行保护的一个非常重要的做法。当然,版本对我们今天来说并不重要。
我们现在需要一个404的页面,就是在目录wwwroot下面创建一个文件404.html。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>404 Not Found</title><style>body {font-family: Arial, sans-serif;text-align: center;padding: 50px;background-color: #f9f9f9;color: #333;}h1 {font-size: 50px;margin-bottom: 20px;}p {font-size: 20px;margin-bottom: 30px;}a {color: #0066cc;text-decoration: none;}a:hover {text-decoration: underline;}</style>
</head>
<body><h1>404</h1><p>Oops! The page you're looking for doesn't exist.</p><p>You may have mistyped the address or the page has been moved.</p><p><a href="/">Go back to the homepage</a></p>
</body>
</html>
当访问资源不存在时,我们就将要访问资源的路径改为404页面的路径。给HttpRequest增加一个成员函数,用于修改_uri。
void SetUri(const std::string newuri)
{_uri = newuri;
}
const std::string page404 = "wwwroot/404.html";// 404页面的路径
给HttpResponse增加一个成员变量,表示要给客户端返回的内容。
std::string _content; // 要给客户端返回的内容
// 建立应答
void Build(HttpRequest& req)
{// 获取用户想要的资源_content = req.GetContent();if (_content.empty()){// 用户请求的资源不存在_status_code = 404;req.SetUri(page404);// 重新获取一次资源_content = req.GetContent();}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);
}
HttpResponse需要的内容基本上都有了,现在就可以来完成序列化的工作了。
void Serialize(std::string* resp_str)
{// 拼接状态行_resp_line = _version + LineSep + std::to_string(_status_code) + LineSep + _status_desc + Sep;_body = _content;// 序列化*resp_str = _resp_line;for (auto& line : _resp_header){*resp_str += (line + Sep);}*resp_str += _blank_line;*resp_str += _body;
}
有了应答,并且序列化完成后,就可以发送给客户端了。
// 处理HTTP请求,这就是回调
bool HandlerHttpRequest(SockPtr sockfd, InetAddr client)
{LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收客户端消息std::string http_request;sockfd->Recv(&http_request); // 字节流消息// 对接收到的字节流消息进行反序列化,并打印HttpRequest req;req.Deserialize(http_request);HttpResponse resp;resp.Build(req);std::string resp_str;resp.Serialize(&resp_str);// 将序列化后的应答发送给客户端sockfd->Send(resp_str);return true;
}
现在,我们使用浏览器访问一下我们的服务器。
此时就可以拿到网页信息了。所以,拿到的所有网页信息,都是从文件中来的。当用户访问的资源路径是/时,其实就是访问首页,所以我们要对/进行特殊处理。另外,在wwwroot这个目录中,除了有网页之外,还可能有图片、目录等,对于每一个子目录,里面也应该要有index.html。所以,我们只要判断一下_uri的最后使用是/,若是,即可在后面加上一个index.html。
const std::string firstpage = "index.html"; // 默认首页名称
// 建立应答
void Build(HttpRequest& req)
{// 对_uri末尾是 / 进行特殊处理std::string uri = req.Uri();if (uri.back() == '/'){uri += firstpage;req.SetUri(uri);}// 获取用户想要的资源_content = req.GetContent();if (_content.empty()){// 用户请求的资源不存在_status_code = 404;req.SetUri(page404);// 重新获取一次资源_content = req.GetContent();}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);
}
完善页面
在这里,我们会使用一些前端的代码让我们的Http服务器更加完善。
现在已经使用代码将HTTP一个完整的过程走完了。前端开发就是在写wwwroot里面的内容,后端开发是写wwroot外面的内容。wwwroot里面的内容虽然上传到了Linux服务器上,但是最终是要发送给浏览器,由浏览器对页面进行解释或渲染呈现给用户的。我们在访问一个网站时,并不会在搜索框内搜索uri,而是点击网页上的内容,浏览器会根据点击的内容形成新的uri,然后向目标服务器发送请求。这是怎么完成的呢?我们使用我们的代码模拟一下这个过程HTML会指导浏览器做出很多的解释动作,我们现在来看看HTML中的A标签。
<a href="目标URL">可点击的文本或图像</a>
这个URL将来填的就是/后面的内容,其实就是uri。浏览器会对这个HTML语句做解释,变成一个可以点击的链接。点击后,浏览器会将当前网页的目标服务器的IP地址和端口号拼在前面,URL写在后面,形成一个完整的请求,并发送给服务器。就可以拿到另一个网页了。所以我们要访问其他网页时,不需要一直输入。所以,所有的跳转就是向HTTP服务发起HTTP请求。
我们将我们的首页修改一下,让其变成一个电商网站的首页。
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>简单电商网站</title><style>body {font-family: Arial, sans-serif;margin: 0;padding: 0;background-color: #f7f7f7;}.header {background-color: #333;color: #fff;padding: 10px 20px;display: flex;justify-content: space-between;align-items: center;}.header h1 {margin: 0;font-size: 2em;}.header nav ul {list-style: none;margin: 0;padding: 0;display: flex;}.header nav ul li {margin-left: 20px;}.header nav ul li a {color: #fff;text-decoration: none;font-size: 1.2em;}.header nav ul li a:hover {text-decoration: underline;}.main {padding: 20px;}.product-grid {display: grid;grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));gap: 20px;}.product-card {background-color: #fff;padding: 10px;border: 1px solid #ddd;border-radius: 5px;text-align: center;}.product-card img {max-width: 100%;height: auto;border-radius: 5px;}.product-card h3 {margin: 10px 0;font-size: 1.2em;}.product-card p {color: #666;font-size: 0.9em;margin-bottom: 10px;}.product-card button {padding: 5px 10px;background-color: #007bff;color: #fff;border: none;border-radius: 5px;cursor: pointer;font-size: 1em;}.product-card button:hover {background-color: #0056b3;}.footer {background-color: #333;color: #fff;padding: 10px 20px;text-align: center;}</style>
</head>
<body><header class="header"><h1>简单电商网站</h1><nav><ul><li><a href="#">首页</a></li><li><a href="#">产品分类</a></li><li><a href="#">登录</a></li><li><a href="#">注册</a></li></ul></nav></header><main class="main"><h2>热门产品</h2><div class="product-grid"><div class="product-card"><img src="#" alt="产品1"><h3>产品1</h3><p>这是产品1的描述信息。</p><button>加入购物车</button></div><div class="product-card"><img src="#" alt="产品2"><h3>产品2</h3><p>这是产品2的描述信息。</p><button>加入购物车</button></div><div class="product-card"><img src="#" alt="产品3"><h3>产品3</h3><p>这是产品3的描述信息。</p><button>加入购物车</button></div><!-- 可以继续添加更多产品卡片 --></div></main><footer class="footer"><p>版权所有 © 2025 简单电商网站</p></footer>
</body>
</html>
再设计一个登录页面和一个注册页面,分别保存在login.html和register.html。
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>登录页面</title><style>body {font-family: Arial, sans-serif;background-color: #f7f7f7;margin: 0;padding: 0;}.login-container {width: 300px;margin: 100px auto;padding: 20px;background-color: #fff;border: 1px solid #ddd;border-radius: 5px;box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);}.login-container h2 {text-align: center;margin-bottom: 20px;}.login-container form {display: flex;flex-direction: column;}.login-container form label {margin-bottom: 5px;}.login-container form input[type="text"],.login-container form input[type="password"] {padding: 10px;margin-bottom: 10px;border: 1px solid #ddd;border-radius: 5px;}.login-container form button {padding: 10px;background-color: #007bff;color: #fff;border: none;border-radius: 5px;cursor: pointer;}.login-container form button:hover {background-color: #0056b3;}.register-link {text-align: center;margin-top: 20px;}.register-link a {color: #007bff;text-decoration: none;}.register-link a:hover {text-decoration: underline;}</style>
</head>
<body><div class="login-container"><h2>登录</h2><!-- http://8.137.19.140:8999/login --><form action="/login" method="POST"><label for="username">用户名:</label><input type="text" id="username" name="username" required><label for="password">密码:</label><input type="password" id="password" name="password" required><button type="submit">登录</button></form><div class="register-link">没有账号?<a href="/register.html">立即注册</a></br><a href="/">回到首页</a></div></div>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>注册页面</title><style>body {font-family: Arial, sans-serif;background-color: #f7f7f7;margin: 0;padding: 0;}.register-container {width: 300px;margin: 100px auto;padding: 20px;background-color: #fff;border: 1px solid #ddd;border-radius: 5px;box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);}.register-container h2 {text-align: center;margin-bottom: 20px;}.register-container form {display: flex;flex-direction: column;}.register-container form label {margin-bottom: 5px;}.register-container form input[type="text"],.register-container form input[type="password"],.register-container form input[type="email"] {padding: 10px;margin-bottom: 10px;border: 1px solid #ddd;border-radius: 5px;}.register-container form button {padding: 10px;background-color: #007bff;color: #fff;border: none;border-radius: 5px;cursor: pointer;}.register-container form button:hover {background-color: #0056b3;}.login-link {text-align: center;margin-top: 20px;}.login-link a {color: #007bff;text-decoration: none;}.login-link a:hover {text-decoration: underline;}</style>
</head>
<body><div class="register-container"><h2>注册</h2><form action="/register" method="post"><label for="username">用户名:</label><input type="text" id="username" name="username" required><label for="email">邮箱:</label><input type="email" id="email" name="email" required><label for="password">密码:</label><input type="password" id="password" name="password" required><label for="confirm-password">确认密码:</label><input type="password" id="confirm-password" name="confirm-password" required><button type="submit">注册</button></form><div class="login-link">已有账号?<a href="/login">立即登录</a><br/><a href="/">回到首页</a></div></div>
</body>
</html>
现在,我们修改一下上面的部分代码,让它们能够通过A标签实现页面转换。
<h1>简单电商网站< / h1>
<nav><ul><li><a href = "#">首页< / a>< / li><li><a href = "#">产品分类< / a>< / li><li><a href = "/login.html">登录< / a>< / li><li><a href = "/register.html">注册< / a>< / li></ul>
</nav>
</form>
<div class="register-link">没有账号?<a href="/register.html">立即注册</a></br><a href="/">回到首页</a>
</div>
</form>
<div class="login-link">已有账号?<a href="/login.html">立即登录</a><br/><a href="/">回到首页</a>
</div>
修改了网页信息后,不需要重启服务器,只需要刷新一下页面即可。现在,我们就可以点击页面的内容进行页面跳转了。每次点击后,浏览器就会向服务器发送一个新页面的请求。HTTP协议叫做超文本传输协议,其实就是将一个特定目录下的内容进行返回。
HTTP常见Header
- Content-Type:数据类型(text/html 等)
- Content-Length:Body的长度
- Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上;
- User-Agent:声明用户的操作系统和浏览器版本信息;
- referer:当前页面是从哪个页面跳转过来的;
- Location:搭配3xx状态码使用,告诉客户端接下来要去哪里访问;
- Cookie:用于在客户端存储少量信息.通常用于实现会话(session)的功能;
接下来,我们要结合代码,对HTTP进行细化认识了。实际上就是完善应答中的报头。我们给Response增加一个成员变量和成员函数。
std::unordered_map<std::string, std::string> _header_kv; // 保存报头
void SetHeader(const std::string& k, const std::string& v)
{_header_kv[k] = v;
}
往后只要我们定义好了一个报头,就调用SetHeader函数将其放到_header_kv中,然后在构建应答时,也就是Build函数中,再统一将所有的报头放到_resp_header中。
Content-Length
当有正文时,一定要带Content-Length,无论是请求,还是应答。我们刚刚的代码中,应答中并没有这个字段,那浏览器是怎么成功读取到服务器上的网页的呢?浏览器是一个非常大,非常完善的项目,所以,即使应答时不带上正文长度的字段,浏览器也是可以将正文全部读完的。但是我们还是要尽量规范,所以,我们给我们的应答加上Content-Length字段。
// 建立应答
void Build(HttpRequest& req)
{// 对_uri末尾是 / 进行特殊处理std::string uri = req.Uri();if (uri.back() == '/'){uri += firstpage;req.SetUri(uri);}// 获取用户想要的资源_content = req.GetContent();if (_content.empty()){// 用户请求的资源不存在_status_code = 404;req.SetUri(page404);// 重新获取一次资源_content = req.GetContent();}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);// 构建报头if (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}// 将报头放到_resp_header中for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
Content-Type
我们会发现,我们的首页是加载不出来图片的。这是因为我们的服务器中并没有图片,我们给我们的服务器添加上几张图片,看看不能把显示出来。在wwwroot中创建一个目录image,将图片放到image中。然后,我们只需要在首页的HTML代码中找到img标签,将图片的路径填入即可。
<img src="/image/1.jpg" alt="产品1"><img src="/image/2.jpg" alt="产品2"><img src="/image/3.jpg" alt="产品3">
未来浏览器除了要请求网页,还会请求图片,请求图片的请求由浏览器自己发起。此时会发现还是无法显示出来。
浏览器客户端向服务器发起请求后,得到了一个网页信息,然后浏览器就会根据网页信息进行渲染,也就是对网页中的标签进行解释,当这个网页中有图片时,也就是说这张图片是浏览器需要的,但是浏览器本地并没有这几张图片,所以浏览器会自动地发起请求图片的请求,因为有3张图片,所以要构建3个请求。
http://8.137.19.140:8888/image/1.jpg
http://8.137.19.140:8888/image/2.jpg
http://8.137.19.140:8888/image/3.jpg
然后将请求得到的3张图片加上原先的网页,构成一个新的网页,然后显示出来。所以,一张网页,不是一个简单的html文件,而是可能有多张资源构成(html+图片视频等!)。
可是有了请求,为什么看不到图片呢?
1. 在GetContent中,是以文本方式直接读的而图片是所以对于二进制的数据,不能按照字符串来读。
// 读取_uri路径下的网页信息, 以二进制形式读取
std::string GetContent()
{std::string content;std::ifstream in(_uri, std::ios::binary);if (!in.is_open()) return std::string();in.seekg(0, in.end);int filesize = in.tellg();in.seekg(0, in.beg);content.resize(filesize);in.read((char*)content.c_str(), filesize);in.close();return content;
}
此时就可以看到图片了。通过发送图片,我们知道了浏览器可能接收到的内容包括:
- html、css、js
- 图片
- 视频、可执行程序(下载任务)
所以,HTTP叫做超文本协议,意思就是不仅仅可以发送文本。既然浏览器可能受到这么多的内容,刚刚的应答中只有一个Content-Length,正文的长度,并没有告诉客户端正文是什么东西。浏览器比较强,它能够识别出发送过来的内容,但是作为HTTP服务器,还是告诉客户端发送过去的是图片,还是视频,还是html。所以,应答中需要带Content-Type字段。当然,如果没有正文,Content-Length和Content-Type都可以不用带。HTTP服务器是根据打开的文件的后缀知道的。
Content-Type中的内容需要根据HTTP Content-type对照表来填写,我们这里就看几个常见的。
Http服务器是根据要发送的资源的后缀来决定Content-Tyoe中填写什么的。所以,我们可以定义一个函数,来获取要访问资源的后追。
// 获取要访问资源的后缀
std::string Suffix()
{auto pos = _uri.rfind(".");if (pos == std::string::npos) return std::string(".html");else return _uri.substr(pos);
}
// 确定Content-Type要填写什么
std::string Suffix2Desc(const std::string& suffix)
{if (suffix == ".html")return "text/html";else if (suffix == ".jpg")return "application/x-jpg";elsereturn "text/html";
}
// 建立应答
void Build(HttpRequest& req)
{// 对_uri末尾是 / 进行特殊处理std::string uri = req.Uri();if (uri.back() == '/'){uri += firstpage;req.SetUri(uri);}// 获取用户想要的资源_content = req.GetContent();if (_content.empty()){// 用户请求的资源不存在_status_code = 404;req.SetUri(page404);// 重新获取一次资源_content = req.GetContent();}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);// 构建报头// Content-Lengthif (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}// Content-Typestd::string mime_type = Suffix2Desc(req.Suffix());SetHeader("Content-Type", mime_type);// 将报头放到_resp_header中for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
Host
当客户端向一个Http服务器发送请求时,有可能请求的这个资源并不在接收到请求的这个Http服务器上,而在另一台Http服务器上,此时接收到请求的Http服务器就会有客户端的功能,给提供资源的Http服务器发送请求,拿到资源后再发送给客户端。我们称接收到请求的这个服务器称为代理服务器。代理服务器在这个过程中,只负责接收客户端发过来的请求,以及最后将应答发送回去。所以,Host这个Header是有必要存在的,有了它才能让代理服务器找到提供资源的服务器。在一些大公司中,提供资源的Http服务器可能会有非常多,此时就可以弄一台代理服务器,再弄一台服务器专门保存提供资源的这些服务器的信息,如IP地址和端口号等,当提供资源的服务器上线了,就将自己的端口号等信息交给保存信息的服务器,这样代理服务器需要资源时,就可以采用一些策略,有选择性地从保存信息的服务器中获取提供资源的服务器的IP地址和端口号,就可以拿到资源了。这样可以实现转发和负载均衡。这些服务器统称为集群或机房。这与进程池是类似的,代理服务器就相当于进程池的父进程,进程池中父进程与子进程通信是基于管道的,管道就有文件描述符,现在代理服务器与提供资源的服务器通信时,是基于套接字的,也是文件描述符,所以,是可以复用之前进程池的代码的。
结论:Http服务器可以给别人提供服务,也可以作为代理服务器发起请求。
当我们向文件当中写入和读取结构化数据时,其实也是可以使用序列化和反序列化的。管道也是一个文件,所以也是可以进行序列化和反序列化的。对于数据块,就是将数据保存到文件当中,当要使用到里面的数据时,就会将数据从文件中重新读出来,所以,数据库软件是需要设计自己的序列化和反序列化方案的。这就是数据库的原理。
这个代理服务器不仅仅可以在公司的后端,也可以在客户端。正常客户端请求时直接向服务器发送请求,当代理服务器在客户端时,客户端发出的请求会被这个代理服务器劫持,由这个代理服务器去请求。代理服务器只做业务处理,不做转发,所以代 理服务器的压力将较于做业务处理的服务器会小一些,但是一些大公司仍然可能会有多个代理服务器。www.baidu.com是百度的域名,未来域名解析后,会随机获得一个代理服务器的IP地址和端口号。Host存在的意义就是当接收到请求的这个Http服务器可能不做业务处理,此时就会根据客户端发过来的Host进行二次请求。
Referer
假设我们当前在首页,然后我们点击登录,此时就会向服务器发起请求,请求登录页面的页面信息,在这个请求中,请求报文中就会有Referer,内容就是/wwwroot/index.html,表示上一个页面是首页。有了Referer之后,就可以做一些权限管理了,比方说某一些页面只允许从某些特定的页面跳转过去。这个字段只有请求报头中有。
Location
只有在应答报头中有。因为它需要搭配3xx的状态码使用,所以我们看看状态码。
HTTP状态码
Http的状态码是服务器应答回去的一个数字,表示本次请求的情况。Http请求无论结果如何,都会有状态码。为了表示各种错误,所以状态码分为了5个类别。这里只看一些常见的状态码。
状态码 | 含义 | 应用样例 |
---|---|---|
100 | Continue | 上传大文件时,服务器告诉客户端可以继续上传 |
200 | OK | 访问网站首页,服务器返回网页内容 |
201 | Created | 发布新文章,服务器返回文章创建成功的信息 |
204 | No Content | 删除文章后,服务器返回"无内容"表示操作成功 |
301 | Moved Permanently | 网站换域名后,自动跳转到新域名;搜索引擎更新网站链接时使用 |
302 | Found 或 See Other | 用户登录成功后,重定向到用户首页 |
304 | Not Modified | 浏览器缓存机制,对未修改的资源返回 304 状态码 |
307 | Temporary Redirect | 临时重定向资源到新的位置(较少使用) |
308 | Permanent Redirect | 永久重定向资源到新的位置(较少使用) |
400 | Bad Request | 填写表单时,格式不正确导致提交失败 |
401 | Unauthorized | 访问需要登录的页面时,未登录或认证失败 |
403 | Forbidden | 尝试访问你没有权限查看的页面 |
404 | Not Found | 访问不存在的网页链接 |
500 | Internal Server Error | 服务器崩溃或数据库错误导致页面无法加载 |
502 | Bad Gateway | 使用代理服务器时,代理服务器无法从上游服务器获取有效响应 |
503 | Service Unavailable | 服务器维护或过载,暂时无法处理请求 |
1xx:假设浏览器要向服务器上传一个大文件,不可能将大文件和请求一起发送给服务器,而是先告诉服务器自己要上传一个大文件,服务器就会有应答,比分说服务器的应答是同意客户端上传,应答中的状态码就可以设置为100。浏览器受到应答,发现状态码是100后,就会上传大文件。
Http规定的状态码非常详细,但是很多情况下,请求正常处理完毕时,返回的都是200,即使文章发布成功,也可能并不会使用专门的201,而是使用200。
重定向的场景:
- 一些视频网站在试看结束时,可能会自动跳转到付费页面
- 没有登录就去访问某个网站时,当点击了某些选项后,会自动跳转到登录页面
- 有时候我们在访问A网站,会突然跳转到B网站
4xx::服务端的资源肯定是有限的,当客户端提出了一个非法请求时,这时候错误是在于客户端的。因为并不是服务端不给客户端提供服务。
5xx:服务器的错误可能就是构建应答失败了、序列化失败了、创建进程失败了、打开文件失败了等等。
为什么浏览器,或者说前端对于这些状态码的遵守并不是特别好?现在我们上网基本上都是使用APP,每个人通过自己的APP就可以定向地访问到自己需要的服务了。在以前,大部分人上网使用的都是浏览器,打开浏览器后,打开的第一个软件是搜索引擎,并且使用的也是搜索引擎。所以,以电脑为主要上网方式的情况下,浏览器就是一个非常重要的软件。在全球范围内,拥有流量入口的服务叫做搜索引擎。在当时,浏览器所带来的流量是仅次于OS的。所以当时很多的互联网公司都会做自己的浏览器,等到浏览器有了一定量的用户规模之后,再做自己的搜索引擎。 浏览器的主要功能是对html、css、js、http请求等做出解释的一个客户端,既然是一个客户端,所以它也要参与网络协议。 网络协议是需要比较权威的公司去制定的, 但是在浏览器这里,并没有比较权威的公司,所以对于http的状态码定制不同浏览器是不完全相同的。所以前端工程师在写完前端代码(HTML)后都要做一个工作,兼容性测试。测试不同浏览器之下这份代码是否都能达到预期。也可能会根据不同的浏览器写出不同的代码。对于HTML都是如此,对于http的状态码就更不用说了。所以,有一些后端工程师并不关心状态码,可能返回的状态码都是200,因为浏览器并不关心状态码,只关心应答的正文部分。
我们重点看重定向状态码。因为只要重定向状态码会搭配报头Location使用。
状态码 | 含义 | 是否为临时重定向 | 应用场景 |
---|---|---|---|
301 | Moved Permanently | 否 (永久重定向) | 网站换域名后,自 动跳转到新域名; 搜索引擎更新网站 链接时使用 |
302 | Found或See Other | 是 (临时重定向) | 用户登录成功后, 重定向到用户首页 |
307 | Temporary Redirect | 是 (临时重定向) | 临时重定向资源到 新的位置(较少使 用) |
308 | Permanent Redirect | 否 (永久重定向) | 永久重定向资源到 新的位置(较少使 用) |
有一批状态码是所有浏览器都要支持的,即3开头的状态码。我们重点看301和302。
什么是临时重定向,什么是永久重定向呢?
我们举一个例子帮助理解。假设现在学校的东门有一家包子店,从学校到这家包子店要经过一条马路,现在,马路在维修,有很多烟尘,所以包子店的老板临时将包子店搬到了西门,并且在原来包子店的门口贴了一个告示,说明包子店临时搬到西门。过了2个月,东门的路修好了,包子店老板发现搬到西门后生意比原先在东门时还要好,所以就又到原先东门的点门口贴了一张告示,说明包子店永久搬到西门。包子店临时搬到西门时,想吃包子的同学第一时间去的店肯定是东门的店,因为只说了是临时,不确定什么时候搬回来,这叫临时重定向,也就是提供服务的人只是临时搬过去了,每次请求时还是要请求老的服务,如果老服务恢复了就直接进行,否则就跳转过去即可;而永久搬到西门后,想吃包子的同学第一时间肯定是去西门的店,这叫永久重定向。
我们来验证一下重定向的功能。之前代码中的Build函数是根据客户端的请求HttpRequest构建应答HttpResponse的。HttpResponse中有很多字段,现在我们不那么麻烦,对于任何请求,我们都直接添加一个重定向的报头。
std::string Code2Desc(int code)
{switch (code){case 200:return "OK";case 404:return "Not Found";case 301:return "Moved Permanently";case 302:return "Found";default:return std::string();}
}
HTTP状态码301和302都依赖于Location选项。
// 建立应答
void Build(HttpRequest& req)
{// 不管req是什么,直接构建一个重定向的应答_status_code = 302;_status_desc = Code2Desc(_status_code);SetHeader("Location", "https://www.baidu.com");for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
会发现,此时无论是访问这个服务端之下的哪一个网页,都会直接跳转到百度的首页。所以,当浏览器发现接收到的应答中状态码是302时,就会自动跳转到Location对应的地址处。原理:当浏览器向我的服务器发送请求时,接收到的应答中的状态码是302,此时浏览器会自动发起二次请求,这是根据Location发送的,此时是请求百度的服务器上的数据,所以看到的就是百度的首页。
我们也可以重定向到我们自己的网页,但是要注意,重定向时网页的路径一定要带全。
// 建立应答
void Build(HttpRequest& req)
{// 不管req是什么,直接构建一个重定向的应答_status_code = 302;_status_desc = Code2Desc(_status_code);SetHeader("Location", "http://47.113.120.114:8080/register.html");for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
重定向到我们自己的网页这样写是有问题的。这样会导致重定向次数太多。这里重定向到我们自己的服务器是会造成类似于递归的错误的,因为重定向到register.html后,就会建立应答,建立应答时又会重定向到register.html,导致重定向次数太多。
若想让其重定向到我们服务器自己的页面,可以这样:
// 建立应答
void Build(HttpRequest& req)
{// 只有当请求的是首页时,才进行重定向,并且重定向后直接returnstd::string uri = req.Uri();if (uri.back() == '/'){_status_code = 302;_status_desc = Code2Desc(_status_code);SetHeader("Location", "http://47.113.120.114:8080/register.html");for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}return;}// 对_uri末尾是 / 进行特殊处理uri = req.Uri();if (uri.back() == '/'){uri += firstpage;req.SetUri(uri);}// 获取用户想要的资源_content = req.GetContent();if (_content.empty()){// 用户请求的资源不存在_status_code = 404;req.SetUri(page404);// 重新获取一次资源_content = req.GetContent();}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);// 构建报头// Content-Lengthif (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}// Content-Typestd::string mime_type = Suffix2Desc(req.Suffix());SetHeader("Content-Type", mime_type);// 将报头放到_resp_header中for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
此时当我们访问我们服务器的首页时,就可以重定向到注册页面了。并且我们会发现,301和302使用起来是没有区别的。
HTTP状态码301(永久重定向):
- 当服务器返回HTTP 301状态码时,表示请求的资源已经被永久移动到新的位置。
- 在这种情况下,服务器会在响应中添加一个Location 头部,用于指定资源的新位置。这个Location 头部包含了新的URL 地址,浏览器会自动重定向到该地址。
HTTP状态码302(临时重定向):
- 当服务器返回HTTP302状态码时,表示请求的资源临时被移动到新的位置。
- 同样地,服务器也会在响应中添加一个Location头部来指定资源的新位置。浏览器会暂时使用新的URL进行后续的请求,但不会缓存这个重定向。
总结:无论是HTTP 301还是HTTP 302 重定向,都需要依赖Location 选项来指定资源的新位置。这个Location选项是一个标准的HTTP响应头部,用于告诉浏览器应该将请求重定向到哪个新的URL地址。
注意上面的应用场景。永久重定向主要是给搜索引擎使用的。
HTTP请求方法
对于HTTP服务器而言,有静态资源和动态资源之分。
- 文件内容在服务器上预先存在,直接返回给客户端,无需服务器端实时处理或计算。
- 内容由服务器端程序实时生成,通常依赖数据库查询、用户输入或业务逻辑处理。
从最先开始,我们的HTTP服务器返回的都是网页、图片,也可以是视频。但是无论是图片,网页,css,js,视频等,都是我获取的静态资源!!!因为这些资源都是预先放到服务器上,客户端请求时,只需要将这些文件打开,发送给客户端即可,这些资源称为静态资源。如果资源需要服务器实时生成,那么就是动态资源。
我们之前说过,上网的行为就两种,获取资源(input)、上传数据(output),而我们现在做的所有操作都是在获取资源,。如果我们想上传数据,将数据上传到服务器,那么服务器就要对数据进行处理,一个网站能对用户上传的数据进行处理,那么这个网站就称为交互式网站。例如百度首页,我们搜索一个内容,能够得到相应的内容,这就是交互式。那要如何将数据上传到服务器呢?
客户端在访问某个网站时,会向这个网站的服务器发送请求,服务器会返回一个页面,如果这是一个交互式网站,可能会返回一个登录页面,这个页面当中是有输入框的,并且会有一个提交的按钮。填完输入框后,点击提交的按钮,信息就会提交到服务器上。在手机上会直接显示一个二维码,扫码成功之后就可以登录上了,提交之后的过程都是一样的。客户端要想向服务器上传数据时,需要先拿到服务器带有输入框的一个网页。我们之前的登录和注册页面就是有输入框的。我们看一下我们的登录页面的部分HTML代码。
<form action="/login" method="POST"><label for="username">用户名:</label><input type="text" id="username" name="username" required><label for="password">密码:</label><input type="password" id="password" name="password" required><button type="submit">登录</button>
</form>
这是一个from表单,action就表示点击登录后要将填入的信息提交给谁。点击登录后,会自动拼接到http://IP地址:端口号/的后面,即http://IP地址:端口号/login,然后将填入的数据作为参数的一部分,然后构建HTTP请求,就可以完成数据上传了。这个method表示的是这次HTTP请求使用的是什么方法。GET也是可以上传数据的,只是功能不如POST。我们先来试试使用GET上传数据。
<form action="/login" method="GET"><label for="username">用户名:</label><input type="text" id="username" name="username" required><label for="password">密码:</label><input type="password" id="password" name="password" required><button type="submit">登录</button>
</form>
我们在构建应答时,将用户想要获取的资源打印出来。看看GET和POST两种请求方式发送给服务器的请求有什么不同。
// 建立应答
void Build(HttpRequest& req)
{// 对_uri末尾是 / 进行特殊处理std::string uri = req.Uri();if (uri.back() == '/'){uri += firstpage;req.SetUri(uri);}LOG(LogLevel::DEBUG) << "-------客户端请求-------";req.Print();LOG(LogLevel::DEBUG) << "-----------------------";// 获取用户想要的资源_content = req.GetContent();if (_content.empty()){// 用户请求的资源不存在_status_code = 404;req.SetUri(page404);// 重新获取一次资源_content = req.GetContent();}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);// 构建报头// Content-Lengthif (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}// Content-Typestd::string mime_type = Suffix2Desc(req.Suffix());SetHeader("Content-Type", mime_type);// 将报头放到_resp_header中for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
到达登录页面后,填入登录信息,点击登录,会跳转到这个页面:
http://47.113.120.114:8888/login?username=zhangsan&password=1234567
HTTP服务器接收到的请求是:
我们再将请求方法改为POST。
到达登录页面后,填入登录信息,点击登录,会跳转到这个页面:
http://47.113.120.114:8888/login
HTTP服务器接收到的请求是:
所以,GET方法会将用户输入的参数拼接到url的后面,以?为分隔符。然后交给HTTP服务。而POST方法的传参是通过正文传参的,此时请求报头中就会有Content-Length和Content-Type了
总结:区别:
- GET方法通常用于获取网页,POST方法通常用于上传数据
- GET方法也可用于传参,它的传参通过uri,POST方法的传参通过正文传参
所以,我们现在已经可以通过一个网页将数据提交给服务器了,只是现在服务器并没有进行处理。我们上传数据时,最好使用POST,使用GET容易将用户名和密码暴露出来。另外,URL的长度肯定是有上限的,所以使用GET没办法传太长的数据,而正文的长度可以非常长。一定不能说POST方法比GET方法更安全。只能说POST方法传参比因为当前都是明文传参,即使在HTTP请求的正文部分,也是可以通过一些抓包工具拿到的其实无论是使用GET,还是POST,都是在传参就是GET。
当客户端将参数提交给服务器,服务器应该如何处理这个参数呢?目前我们的服务器只能处理静态网页的返回,没有动态的功能,如何让服务器能够支持动态功能呢?
我们的代码是TcpServer接收到客户端发来的请求后,就通过回调的方式调用HttpServer中的Http请求处理函数处理请求,这个Http请求处理函数是接收客户端发来的字节流信息、反序列化、构建应答、序列化,发送应答给客户端。这是之前没有考虑动态资源时的处理方式,现在考虑了动态资源,就不能这样了。
在反序列化时,要判断一下这个请求是否带参数。所以,给HttpRequest增加三个成员函数。
bool _isexec = false; // 是否有参数,默认没有
std::string _path; // 请求资源的路径
std::string _args; // 请求资源的参数
bool IsHasArgs() { return _isexec; }
std::string Path() { return _path; }
std::string Args() { return _args; }
void Deserialize(std::string& request_str) // 反序列化
{// 提取出第一行,并细化解析出的字段if (ParseOneLine(request_str, &_req_line, Sep)){// 提取请求行中的详细字段ParseReqLine(_req_line, LineSep);// 提取出请求报头中的详细字段ParseHeader(request_str);// 请求报头和空行提取完成之后,剩下的就是正文了_body = request_str;// 分析请求中是否含有参数if (_method == "POST"){// 请求方法是POST一定有参数,且参数位于正文_isexec = true;_args = _body;_path = _uri;}else if (_method == "GET"){auto pos = _uri.find("?");if (pos != std::string::npos){// 参数在uri当中_path = _uri.substr(0, pos);_args = _uri.substr(pos + 1);}}}
}
这里要说明一下,POST也可能没有正文,这就是请求出错了,这里我们直接不管了。
现在反序列化已经能够判断出请求是否携带参数了,在HttpServer中,构建应答时就需要对齐进行判断,若没有请求,走原来的Build,直接构建应答;当有请求时,根据路径,将参数交给上层业务。所以,我们需要在HttpServer中,增加一个成员变量。用来进行路由。
using http_handler_t = std::function<void (HttpRequest&, HttpResponse&)>;class HttpServer
{
public:HttpServer(int port) : _tsvr(std::make_unique<TcpServer>(port)){}// 处理HTTP请求,这就是回调bool HandlerHttpRequest(SockPtr sockfd, InetAddr client){LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收客户端消息std::string http_request;sockfd->Recv(&http_request); // 字节流消息// 对接收到的字节流消息进行反序列化,并打印HttpRequest req;req.Deserialize(http_request);HttpResponse resp;resp.Build(req);std::string resp_str;resp.Serialize(&resp_str);// 将序列化后的应答发送给客户端sockfd->Send(resp_str);// std::cout << "用户想要: " << req.Uri() << std::endl;return true;}// 注册服务void Resgiter(std::string funcname, http_handler_t func){_route[funcname] = func;}// 启动HTTP服务器void Start(){_tsvr->InitServer([this](SockPtr sockfd, InetAddr client){ return this->HandlerHttpRequest(sockfd, client); });_tsvr->Loop();}~HttpServer() {}private:std::unique_ptr<TcpServer> _tsvr;std::unordered_map<std::string, http_handler_t> _route; // 路由功能
};
这样,我们未来在创建好Http服务器后,可以先向这个服务器注册服务,然后再启动服务器。注册的这些服务,就是根据传入的HttpRequest,构建出HttpResponse。
void Login(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入登录模块" << req.Path() << ", " << req.Args();
}
void Register(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入注册模块" << req.Path() << ", " << req.Args();
}
void Search(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入搜索模块" << req.Path() << ", " << req.Args();
}
void Test(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入测试模块" << req.Path() << ", " << req.Args();
}int main(int argc, char* argv[])
{if(argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;return 1;}auto httpserver = std::make_unique<HttpServer>(std::stoi(argv[1]));// 向服务器注册服务httpserver->Resgiter("/login", Login);httpserver->Resgiter("/register", Register);httpserver->Resgiter("/search", Search);httpserver->Resgiter("/test", Test);httpserver->Start();return 0;
}
我们这里先采用打印日志的形式,因为我们待会想看到的是可以进入到登录模块。
现在就需要改一下HttpServer中处理Http请求的函数了,根据是否携带参数,采用不同的处理方案
// 判断服务是否注册过
bool SafeCheck(const std::string& service)
{auto iter = _route.find(service);return iter != _route.end();
}
// 处理HTTP请求,这就是回调
bool HandlerHttpRequest(SockPtr sockfd, InetAddr client)
{LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();// 接收客户端消息std::string http_request;sockfd->Recv(&http_request); // 字节流消息// 对接收到的字节流消息进行反序列化,并打印HttpRequest req;req.Deserialize(http_request);HttpResponse resp;// 根据是否携带参数,将请求分为两类if (req.IsHasArgs()){// 携带参数std::string service = req.Path();if (SafeCheck(service))_route[req.Path()](req, resp); // 方法注册过了,直接调用elseresp.Build(req); // 方法未注册,通过Build拿到404页面}else{// 没有携带参数resp.Build(req);}std::string resp_str;resp.Serialize(&resp_str);// 将序列化后的应答发送给客户端sockfd->Send(resp_str);// std::cout << "用户想要: " << req.Uri() << std::endl;return true;
}
现在代码中还有一个问题,我们之前都是处理静态资源,这些静态资源都在wwwroot目录下,所以我们细化请求行字段时,在uri前面添加上了wwwroot,现在不能这样了,只有到Build中才需要加
// 细化请求行的字段
void ParseReqLine(std::string& _req_line, const std::string sep)
{std::stringstream ss(_req_line);ss >> _method >> _uri >> _version;// 给uri添加上web根目录// _uri = defaulthomepage + _uri;
}
// 读取path路径下的网页信息, 以二进制形式读取
std::string GetContent(const std::string path)
{std::string content;std::ifstream in(path, std::ios::binary);if (!in.is_open()) return std::string();in.seekg(0, in.end);int filesize = in.tellg();in.seekg(0, in.beg);content.resize(filesize);in.read((char*)content.c_str(), filesize);in.close();return content;
}
// 建立应答
void Build(HttpRequest& req)
{// 对_uri末尾是 / 进行特殊处理std::string uri = defaulthomepage + req.Uri();if (uri.back() == '/'){uri += firstpage;// req.SetUri(uri);}LOG(LogLevel::DEBUG) << "-------客户端请求-------";req.Print();LOG(LogLevel::DEBUG) << "-----------------------";// 获取用户想要的资源_content = req.GetContent(uri);if (_content.empty()){// 用户请求的资源不存在_status_code = 404;// req.SetUri(page404);// 重新获取一次资源_content = req.GetContent(page404);}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);// 构建报头// Content-Lengthif (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}// Content-Typestd::string mime_type = Suffix2Desc(req.Suffix());SetHeader("Content-Type", mime_type);// 将报头放到_resp_header中for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}
}
可以看到,此时已经能够进入到注册模块了。无论是GET还是POST都可以。接下来就是要在路由方法中,通过req构建出resp。需要对HttpResponse做出一些调整。增加2个成员函数:
void SetCode(int code)
{_status_code = code;_status_desc = Code2Desc(_status_code);
}
void SetBody(const std::string& body)
{_body = body;
}
将遍历从Build调整到序列化当中,并将拼接正文从序列化放到Build的最后。
// 建立应答
void Build(HttpRequest& req)
{// 对_uri末尾是 / 进行特殊处理std::string uri = defaulthomepage + req.Uri();if (uri.back() == '/'){uri += firstpage;// req.SetUri(uri);}// LOG(LogLevel::DEBUG) << "-------客户端请求-------";// req.Print();// LOG(LogLevel::DEBUG) << "-----------------------";// 获取用户想要的资源_content = req.GetContent(uri);if (_content.empty()){// 用户请求的资源不存在_status_code = 404;// req.SetUri(page404);// 重新获取一次资源_content = req.GetContent(page404);}else{// 用户请求的资源存在_status_code = 200;}_status_desc = Code2Desc(_status_code);// 构建报头// Content-Lengthif (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}// Content-Typestd::string mime_type = Suffix2Desc(req.Suffix());SetHeader("Content-Type", mime_type);_body = _content;
}
void Serialize(std::string* resp_str)
{// 将报头放到_resp_header中for (auto& header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}// 拼接状态行_resp_line = _version + LineSep + std::to_string(_status_code) + LineSep + _status_desc + Sep;// 序列化*resp_str = _resp_line;for (auto& line : _resp_header){*resp_str += (line + Sep);}*resp_str += _blank_line;*resp_str += _body;
}
void Login(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入登录模块" << req.Path() << ", " << req.Args();std::string req_args = req.Args();// 1. 解析参数格式,得到想要的参数// 2. 访问数据块,验证对应的用户是否是合法的用户,其他工作...// 3. 登录成功(构建应答)std::string body = "<html><body><p>Login Success!</p></body></html>";resp.SetCode(200);resp.SetHeader("Content-Length", std::to_string(body.size()));resp.SetHeader("Content-Type", "text/html");resp.SetBody(body);
}
正常来说,登录成功一般是跳转到某个页面,这里就直接展现出一个登录成功的页面。
此时就可以看到登录成功的页面了。但是,我们现在只能够看到页面,看不到服务器给客户端返回的正文等信息,因为会被浏览器解释,此时可以使用一个软件postman。postman是一个模拟HTTP客户端的工具。当然也可以使用telnet模拟。
可以看到,此时就可以拿到服务器发送过来的信息了。
现在,客户端将请求提交上来,HTTP服务器已经能够执行注册的服务了。未来就可以基于这个HTTP服务器再写很多的应用,比方说也可以再定义协议,对提交的数据定义协议。我们这种以功能路由执行服务的形式,称为restfuI风格的网络请求接口。如果将各个服务分别放到不同的服务器当中,这就是微服务了。我们登录成功时,最好是直接跳转到首页。
void Login(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入登录模块" << req.Path() << ", " << req.Args();std::string req_args = req.Args();// 1. 解析参数格式,得到想要的参数// 2. 访问数据块,验证对应的用户是否是合法的用户,其他工作...// 3. 登录成功(构建应答)resp.SetCode(302);resp.SetHeader("Location", "/");
}
cookie与session
当客户端向服务器发送请求时,服务器除了会返回应答,还可能想向客户端写入一些内容。这涉及到一个概念叫会话保持。
我们会发现,我们登录了一次B站后,下一次访问B站就不需要登录了。这是因为客户端在登录服务器时,服务器会对登录时输入的信息进行认证,认证成功之后,服务器会向客户端写入一些登录有关的信息,比如说用户名和密码,写入到了客户端的某一个位置,我们以浏览器为例。后序浏览器向HTTP服务器发送请求时,会自动携带上服务器曾经写入的消息。服务器写入浏览器的这部分信息,浏览器会进行保存,我们将这部分信息称为cookie。cookie在浏览器中会有两种存在形式,一种是内存级的,一种是文件级的。可以查看cookie:
将这些cookie全部删除后,刷新网页,B站就需要登录了。
我们没删除时,每次发送请求都会携带上cookie,所以实际上每次请求都会有认证,不只有登录时才进行认证。当我们删除后,请求时就没办法再携带上cookie了,服务器就不认识这个客户端了。这个功能就称为基于cookie的会话保持功能。
服务端怎么向客户端写入cookie呢?在应答报头中添加Set-Cookie,就会将后面的内容添加到浏览器的cookie中。
void Login(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入登录模块" << req.Path() << ", " << req.Args();std::string req_args = req.Args();// 1. 解析参数格式,得到想要的参数// 2. 访问数据块,验证对应的用户是否是合法的用户,其他工作...// 3. 登录成功(构建应答)resp.SetCode(302);resp.SetHeader("Location", "/");resp.SetHeader("Set-Cookie", "usrname=zhangsan");resp.SetHeader("Set-Cookie", "password=1234567");
}
此时我们登录我们的服务器,就可以看到cookie了。请求和应答中都是可以有多条Set-Cookie的,但是我们今天保存这些键值使用的是unordered_map,所以没办法弄多条。可以使用一个vector来保存键值,这样就可以弄多条了,但是这个工作我们今天就不做了。我们修改成一条Set-Cookie。
void Login(HttpRequest& req, HttpResponse& resp)
{LOG(LogLevel::DEBUG) << "进入登录模块" << req.Path() << ", " << req.Args();std::string req_args = req.Args();// 1. 解析参数格式,得到想要的参数// 2. 访问数据块,验证对应的用户是否是合法的用户,其他工作...// 3. 登录成功(构建应答)resp.SetCode(302);resp.SetHeader("Location", "/");resp.SetHeader("Set-Cookie", "usrname=zhangsan&password=1234567");
}
当然,我们这里是硬编码的,实际上可以根据输入来设置。
HTTP协议是无连接、无状态的协议。它是直接发送请求,直接发送应答的,链接由TCP来做。连续请求两次首页,第二次HTTP请求时,HTTP客户端是不知道刚刚才请求过一次的,这叫无状态。正因为HTTP协议是无状态的,所以我们访问某一个网站时,登录后,又想访问这个网站的其他网页时,就需要再登录,这样每访问一个网页就需要登录一次,显然是不合理的。所以,cookie的存在是很有必要的。
将我们的个人信息保存在了cookie当中,如果这个cookie泄漏了,我们的个人信息也就泄漏了。所以光有一个cookie是不够的。真实的情况是当客户端登录时,若登录成功,服务器会为这个客户端创建一个session对象,并维护在服务器内部,这个对象中有session_id,以及用户的一些私密信息。然后服务器通过应答,Set-Cookie返回一个session_id。浏览器会将这个session_id写到浏览器的cookie文件当中。现在,客户端在请求时总是会携带session_id。服务端就会根据session_id对用户进行认证。这样,客户端就再也不需要保存用户的私密信息了。
可是即使是这样,也还是避免不了cookie信息被盗取啊!这种做法:
- 不会再造成用户信息的泄漏了
- 现在私密信息保存在了服务器,服务器就可以设计各种策略,来防止黑客进行恶意操作。例如让服务器每次认证时都检查一下IP地址,若IP地址发生了变化,就将服务器上的session对象释放掉。另外,也可以对用户的行为进行判断等。
所以,cookie+session是HTTP中会话保持的一个常见做法。
在代码中要如何设计这个session呢?
class Session
{
private:std::string name;bool islogin;uint64_t session_id;// 其他信息
};class SessionManager
{
public:void CreateSession(uint64_t session_id){}void DeleteSession(uint64_t session_id){}void SearchSession(uint64_t session_id){}
private:std::unordered_map<uint64_t, Session*> _session;
};
Connection
请求报头中还有一个字段Connection。我们现在的服务器,发送一个请求只会获得一个资源,例如首页中有3张图片,浏览器会向服务器发送4个请求,每个请求只获取一个资源。建立一次链接,只帮助用户获取一个资源,就叫做短链接。这种短链接肯定是不好的,如果一个网页内有非常多资源呢?这样服务器的压力是非常大的。HTTP在1.0及以前时,只支持短链接,因为那时候资源比较少。1.1之后,新增了一个字段Connection。HTTP中的connection字段是HTTP报头的一部分,它主要用于控制和管理客户端与服务器之间的连接状态。在HTTP1.1之后,默认使用的就是长连接
核心作用:管理持久连接。Connection 字段还用于管理持久连接(也称为长连接)。持久连接允许客户端和服务器在请求/响应完成后不立即关闭TCP连接,以便在同一个连接上发送多个请求和接收多个响应。
语法格式:
- Connection:keep-alive:表示希望保持连接以复用TCP连接。
- Connection:close:表示请求/响应完成后,应该关闭TCP连接。
我们当时写网络版本计算器时,使用的就是长连接。建立连接之后,一直允许客户端发送计算的请求。我们现在也是很容易实现的,只要读取时一行一行读,直到读到空行,再根据报头中指明的报文长度,读取报文,根据这个构建HttpResponse,序列化之后,发送,再读取另外一个即可。所以,要实现长连接,关键问题是服务器能否处理TCP的字节流问题。
抓包
Fiddler是一个本地抓包工具。我们之前都是浏览器或其他应用向服务器发送请求。若是在浏览器所在的主机上装一个Fiddler,Fiddler会劫取浏览器发出的请求,相当于浏览器将请求发送给了Fiddler,然后由Fiddler再向服务器发送请求,服务器会将应答发送给Fiddler,Fiddler再将应答发送给浏览器。所以,Fiddler就是一种代理。Fiddler是专门抓取HTTP请求的。
只要开启Fiddler,并使用浏览器去访问服务器,就会被抓包。
所以,无论是GET,还是POST,都是明文传参,在网络通信中都是不安全的。所以,HTTP本身是不安全的,因为它没有加密。
HTTPS协议就是在HTTP协议的基础上,增加了一个加密层,发送请求时,HTTP处理好的HttpResquest,会交给这个加密层,主要是对正文部分加密,再发送给服务器,服务器收到后,也需要先解密,然后再进行处理。
我们讲HTTP协议主要是以后可以仿照HTTP自定义一些协议。其次,HTTPS有加密和解密,这就意味着发送请求、接收请求、发送应答、接收应答等都会比较慢,所以在公司内网中,是可以保证数据安全的,此时是可以使用HTTP协议进行通信的。