在前面的文章中我们约定了自定义的协议并实现了一个网络计算器,在这片文章中我们就要来进行一下http协议的分析与使用。
我们自定义的协议只要能保证一端发送时构造数据,在另一端能够正确的进行解析,这就是一个正确的应用层协议。HTTP就是应用层的协议之一。我们平时说的网址,其实就是说的URLhttp://www.example.jp:80/dir/index.hem?uid=1#ch1
因此有了URL之后,就有了唯一的ip + port,就可以在网络上确定唯一的一份资源吗,URL(Uniform Resource Locator)也被称为统一资源定位符
还有就是像? / #
这样的字符,已经被URL当做特殊意义理解,因此这些字符不能够随意的出现,因此当参数中带有这些特殊字符的时候,就必须对这些特殊字符进行转义。
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码%XY格式,例如? / #
就会被转义成%3F%2F%23
http本质上就是一个文本它的组成如下
HTTP Request | |
---|---|
请求行 | 请求方法 URL 协议版本 \r\n |
请求报头 | Key:Value \r\n(可以有多行) |
空行 | \r\n |
有效载荷 | 一般是用户可能提交的参数(可以没有) |
HTTP Resopnse | |
---|---|
状态行 | 协议版本 状态码 状态码描述 \r\n |
响应报头 | Key:Value \r\n(可以有多行) |
空行 | \r\n |
有效载荷 | 资源、html/css/js,图片、视屏、音频等 |
从上述的简易组成中可以看出http协议的序列化就是通过将每一行都连接起来成为一个大的字符串,要分离报头就可以通过读取到\r\n之后,对每一个需要的数据信息进行分离。
下面我们就来看一下对应的http请求报文具体是什么样子的:
GET / HTTP/1.1
Host: 120.26.50.214:8889
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
从上述的报文中就可以看出这是一个GET请求报文,协议使用的协议版本是HTTP/1.1,请求的目录是 / 这是对120.26.50.214:8889这个ip与port的请求,Connection表明这是一个长链接,User-Agent表明发起这个请求的是什么系统,使用的是什么样的浏览器,还有编写格式等等。
使用Fiddler抓包工具获取的百度网页的简易的响应报文如下:
HTTP/1.1 200 OK
Connection: keep-alive
// ...
Content-Length: 400014
// ...
下面我们就可以来自己实现一下简单的HTTP报文的响应
整体的网络服务器的构建与我们之前编写的TCP服务器是相似的,同样的日志模块等就不在进行展示
static const uint16_t defaultport = 8888;
using func_t = std::function<std::string(std::string &)>;
class HttpServer;
class ThreadData{
public:
ThreadData(int sock, const std::string &ip, const uint16_t &port, HttpServer *tsvrp)
: _sock(sock), _ip(ip), _port(port), _tsvrp(tsvrp)
{}
~ThreadData() {}
public:
int _sock;
std::string _ip;
uint16_t _port;
HttpServer *_tsvrp;
};
class HttpServer{
public:
HttpServer(func_t f, int port = defaultport) :func_(f), port_(port)
{}
void InitServer(){
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();
}
void HandlerHttpRequest(int sock){
char buffer[4096];
std::string request;
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); // 这里认为一次性读完
if (s > 0){
buffer[s] = 0;
request = buffer;
std::string response = func_(request);
send(sock, response.c_str(), response.size(), 0);
}
else{
logMessage(Info, "client quit...");
}
}
static void *threadRoutine(void *args){
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
td->_tsvrp->HandlerHttpRequest(td->_sock);
close(td->_sock);
delete td;
return nullptr;
}
void Start(){
for(;;){
std::string clientip;
uint16_t clientport;
int sock = listensock_.Accept(&clientip, &clientport);
if (sock < 0) continue;
pthread_t tid;
ThreadData* td = new ThreadData(sock, clientip, clientport, this);
pthread_create(&tid, nullptr, threadRoutine, td);
}
}
~HttpServer()
{}
private:
int port_;
Sock listensock_;
func_t func_;
};
#include "HttpServer.hpp"
const std::string SEP = "\r\n";
std::string HandlerHttp(std::string& s){ // 仿照上述内容,构建响应报文
std::cout << "-------------------------------------------------" << std::endl;
std::cout << request; // 打印http请求报文
std::cout << "-------------------------------------------------" << std::endl;
std::string response = "HTTP/1.0 200 OK" + SEP;
response += SEP;
response += "hello Http";
return response;
}
int main(int argc, char *argv[]){
if (argc != 2){
usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);
std::unique_ptr<HttpServer> tsvr(new HttpServer(HandlerHttp, port)); // TODO
tsvr->InitServer();
tsvr->Start();
return 0;
}
通过上述的代码,在浏览器中输入对应的ip地址与端口号,就可以你在终端中看到http协议的请求报文,然后在浏览器的界面上就能够读取到"hello Http",虽然浏览器可以对普通字符串进行解释,但是浏览器通常是以网页的形式进行展示的。那么我们就需要将我们回应的response转变成为HTML的形式。
response += "
this is a test
";
然后我们再次编译运行服务器,就可以从相关的软件上获取到我们自己写的HTML的信息。
在获取到报文之后,我们已经知道可以通过读取空行的方式将报头与有效载荷分离,那么该怎样获取完整的有效载荷呢?在KV结构的请求报头中就有一个重要的属性叫做Content-Length: body的长度
通过这个信息就能够找到有效载荷的长度,因此我们在我们自己的response中也添加入这个属性。使用postman进行查看同样可以获得这个参数,说明我们添加的这个属性是有意义的。
同样对于有效载荷中的数据到底是什么类型的资源,也需要有对应的属性来进行标识Content-Type: Body的种类
。不同的资源,例如:图片(.jpg,.png)、网页(.html)等文件都要有自己的后缀,网页对应的就是text/html,其余不同的资源还有着不同的对应类型。
我们不能将网页直接放入到代码中,需要放入到文件中,所有的与网页相关的资源都需要维护到一个文件夹中,在这里我们将这个文件命名为wwwroot,此时构建响应的时候就需要从HTML文件中进行读取,来获取对应的网页内容。因此我们设计一个工具类Util.hpp,其中编写一个读取文件的方法。
class Util{
public:
static bool ReadFile(const std::string& path, std::string* fileContent) {
// 1. 获取文件本身的大小
struct stat st;
int n = stat(path.c_str(), &st);
if (n < 0) return false;
int size = st.st_size; // 获取文件的二进制大小
// 2. 调整string的空间
fileContent->resize(size);
// 3. 读取
int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) return false;
read(fd, (char*)fileContent->c_str(), size);
close(fd);
logMessage(Info, "read file %s done", path.c_str());
return true;
}
static std::string ReadOneLine(std::string &message, const std::string &sep) {
auto pos = message.find(sep);
if(pos == std::string::npos) return "";
std::string s = message.substr(0, pos);
message.erase(0, pos + sep.size());
return s;
}
// GET /favicon.ico HTTP/1.1q
// GET /a/b/c/d/e/f/g/h/i/g/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z HTTP/1.1
static bool ParseRequestLine(const std::string &line, std::string *method, std::string *url, std::string *httpVersion){
std::stringstream ss(line);
ss >> *method >> *url >> *httpVersion; // 使用stringstream进行信息的分离
return true;
}
};
这里有一点需要注意的就是一般的网页文件都是文本的,但是如果是图片,视频,音频这些都是二进制的。
在上面的讲述中我们还了解到在请求报文中,除了请求方法,还有一个就是资源路径,前面的代码编写没有处理不同资源路径的情况,在下面我们就来对其进行一些对应的操作。
首先构建一个HttpRequest类来保存对应的请求信息
class HttpRequest{
public:
HttpRequest() : path_(webRoot){}
~HttpRequest() {}
public:
std::string method_; // 请求方法
std::string url_; // 请求资源链接
std::string httpVersion_; // 版本号
std::vector<std::string> body_; // 请求报头
std::string path_; // 添加了web根目录之后的资源路径
std::string suffix_; // 资源后缀名
};
那么我们就需要将获取到的请求信息进行分离
HttpRequest Deserialize(std::string &message){
HttpRequest req;
std::string line = Util::ReadOneLine(message, SEP); // 读取第一行请求行
Util::ParseRequestLine(line, &req.method_, &req.url_, &req.httpVersion_); // 分离请求行中的相关字段并保存在HttpRequest中
std::cout << message << std::endl;
while (!message.empty()){
line = Util::ReadOneLine(message, SEP); // 读取每一个请求报头,并保存在vector中
req.body_.push_back(line);
}
// req.path_保存的是web服务器的根目录 请求资源路径是/a/b/c.html 不想让其从Linux的根目录开始访问,因此将资源路径与web根目录进行融合
req.path_ += req.url_; // "./webRoot/a/b/c.html"
if (req.path_[req.path_.size()-1] == '/') // 若整个字符串最后一个字符是/,那么结合前面的路径融合就可以得到目前的路径是./webroot/ 但是一般一个webserver,不做特殊说明,如果用户默认访问'/',我们决不能将整站给对方,就需要在目录中添加一个默认首页
req.path_ += defaultHomePage; // 在进行默认HTML页面的添加
auto pos = req.path_.rfind("."); // 寻找到请求资源的后缀名中的'.'
if (pos == std::string::npos)
req.suffix_ = ".html"; // 没找到默认后缀名就是.html
else
req.suffix_ = req.path_.substr(pos); // 添加对应的后缀名
return req;
}
对于一张网页来说可能会包含很多的要素资源,每一个资源都要发起一次http request,那么对应的资源类型就需要进行申明,因此获取了后缀之后,就要根据相应的后缀添加上对应的类型。
std::string GetContentType(const std::string &suffix){
std::string content_type = "Content-Type: ";
if (suffix == ".html" || suffix == ".htm")
content_type + "text/html";
else if (suffix == ".css")
content_type += "text/css";
else if (suffix == ".js")
content_type += "application/x-javascript";
else if (suffix == ".png")
content_type += "image/png";
else if (suffix == ".jpg")
content_type += "image/jpeg";
else{}
return content_type + SEP;
}
std::string HandlerHttp(std::string& message){
// 1. 读取请求
// 确信request是一个完整的报文
// 给别人返回的是一个http response
std::cout << "-------------------------------------------------" << std::endl;
// 2. 反序列化和分析请求
HttpRequest req = Deserialize(message);
req.Print();
// 3. 使用请求
std::string body;
std::string path = webRoot;
path += req.url_; // "webRoot/a/b/c.html"
std::string response;
if (true == Util::ReadFile(req.path_, &body)){
response = "HTTP/1.0 200 OK" + SEP;
response += "Content-Length: " + std::to_string(body.size()) + SEP;
// response += "Content-Type: text/html" + SEP;
response += GetContentType(req.suffix_);
response += SEP;
response += body;
}
return response;
}
从下图中就能够看出对/
路径下的不同资源进行访问,需要进行不同的http请求,并且成功对请求方法、资源路径、后缀名等进行分离。
要在网页中进行跳转时,可以在HTML文件中使用Link text
来进行链接。所谓的跳转就是让html中特定的标签被浏览器解释,重新发起http请求
GET和POST方法是浏览器客户端发起的,他会构建一个http request, 携带的方法GET/POST。若我们要让浏览器使用不同的方法进行资源请求。
我们使用表单来进行GET与POST的方法的区分,首先在web服务器的index.html中输入相关的表单代码
<form action="/a/b/c.exe" method="get">
姓名: <input type="text" name="myname" value=""><br />
密码: <input type="password" name="mypasswd"><br />
<input type="submit" value="提交"><br />
form>
然后运行http服务器,使用浏览器发出请求报文,就可以看到出现了可以让填写的形式,
在表单中填写数据,点击提交就会发现在上方的会出现这样的变化,之前提交的参数以
KV结构显示在了请求资源链接的后面,同时可以发现读取的数据也可以在终端查看到。
GET能获取一个静态网页,也能提交参数,通过URL的方式提交参数
将上述的html中的代码提交方法转换成POST。POST方法是将提交的数据存放在body中进行上传,但是这里我们设计的这个服务器没有完整的处理读取的相关信息只读取了一次,可能会产生错误。产生的变化也与get方式提交的不同。
post请求提交数据,是通过正文部分提交参数的
GET方法提交参数,不私密,会将参数回显到URL的输入框中;POST方法提交参数更加私密一些,这两个方法都是不安全的。
URL:http请求行字符串,一般都会有大小的约束,正文理论上可以非常大。
分类 | 分类描述 |
---|---|
1** | 信息,服务器收到请求,需要请求者继续执行操作 |
2** | 成功,操作被成功接收并处理 |
3** | 重定向,需要进一步的操作以完成请求 |
4** | 客户端错误,请求包含语法错误或无法完成请求 |
5** | 服务器错误,服务器在处理请求的过程中发生了错误 |
当请求想要获取资源的时候,我们并没有这个资源时,我们就可以返回404页面提示错误,在读取文件的时候就可以进行判断读取成功就正常返回,读取失败就显示404页面。
if (true == Util::ReadFile(req.path_, &body)){
// ...
}
else{
response = "HTTP/1.0 404 NotFound" + SEP;
Util::ReadFile(page_404, &body);
response += "Content-Length: " + std::to_string(body.size()) + SEP;
response += GetContentType(req.suffix_);
response += SEP;
response += body;
}
状态码3xx表示重定向。
std::string response;
// response = "HTTP/1.0 301 Moved Permanently" + SEP;
response = "HTTP/1.0 302 Found" + SEP;
response += "Location: https://www.baidu.com/" + SEP;
response += SEP;
这样当我们再次在浏览器中输入网址发起请求时就会跳转到百度。
关于http的会话保持功能,http本身是无状态的,它是用来超文本传输的。但是我们会发现一个现象,例如使用B站的时候当我们进行了登录操作,不论我们是关闭了浏览器还是重启了电脑,再次打开B站的网页的时候,登录状态还是保持在网页上的,这个就是会话保持,cookie和session就是用来会话保持。
当我们需要访问VIP资源时,一般需要登录,登录认证通过之后就会将有关的信息通过Set-Cookie的方式发送到浏览器上,浏览器在之后每次访问的时候就会自动携带相关的cookie信息。
但是上述的过程很明显,用户的信息会被窃取,然后第三方就会使用窃取到的信息进行登录。为了避免出现用户信息的泄露,此时就可以使用session的方式。当服务器收到用户的账号密码的时候就形成session对象,这个对象保证唯一即可,后面用户登录的时候就可以使用唯一形成的session id来进行登录。这样可以避免用户的信息造成泄露。
下面我们自己来实现一下简单的Cookie设置,可以很明显的看到在浏览器端可以看到Cookie的存在。
std::string response;
response = "HTTP/1.0 200 OK" + SEP;
response += "Set-Cookie: sessionid=1234abcd" + SEP;
response += SEP;
每一次一个网页有很多的资源构成,客户端可能要发起很多次的请求,http的底层是tcp每一次访问资源的时候,都要重新建立连接,成本太高了,因此http1.0中有一个选项是Connection: kee-alive,基于一条建立好的链接,放入多个http请求。我们之前编写的网络版本的计算器就是长链接属性的。