【网络编程】七、详解HTTP && 搭建HTTP服务器

【网络编程】七、详解HTTP && 搭建HTTP服务器

Ⅰ. HTTP协议的由来 – 万维网​ 这个要从万维网说起!万维网WWW(World Wide Web) 是一个大规模的、联机式的信息储藏所,英文简称为 Web,而不是什么特殊的计算机网络。它提供了一种链接的方式,能够非常方便地从互联网上的一个站点访问另一个站点,从而主动地按需获取丰富的信息,具体的细节我们这里不展开讲!

​ 同时万维网也是一个 分布式的 超媒体(hypermedia)系统,它是 超文本(hypertext)系统的扩充。所谓超文本是指包含指向其它文档的链接的文本,也就是说,一个超文本由多个信息源链接成,而这些信息源可以分布在世界各地,而且数量也是不受限制的!所以说,超文本是万维网的基础!超媒体和超文本的区别是文档内容不同,超文本文档仅包含文本信息,而超媒体文档还包含其它表示方式的信息,如图形、图像、声音、动画以及视频图像等。

​ 万维网以客户服务器方式工作,万维网文档所驻留的主机则运行服务器程序,因此这台主机也称为万维网服务器,客户程序向服务器程序发起请求,服务器程序向客户程序送回客户所要的万维网文档。

​ 看到这,心想,这和 HTTP 协议有啥关系呢❓❓❓

​ 关系来啦,我们前面在学协议定制的时候都知道,虽然我们依托于 TCP 建立连接,是可靠的,但是问题是应用层接收到了消息之后,该如何提取出一个完整的报文进行解析呢,并且要是不同地方的人,用不同的方式对收发该超文本信息进行定义,那么就会有五花八门的协议,这岂不是增加了很多维护成本?对不对,所以此时一个统一的协议非常的重要,所以 超文本传输协议 HTTP(Hyper Transfer Protocol)就此诞生,很明显它就是一个应用层协议,使用 TCP 连接进行可靠的传送!

​ 但是因为 HTTP 协议刚出来的时候,缺陷比较多,现在也在不断的完善,还引入了一些重要的机制,如 cookie、session 等,这些我们都会在下面谈到!

HTTP协议的特点包括:

简单:使用简单的请求-响应模型,请求由客户端发起,服务器进行响应。无状态:是无状态协议,即服务器不会保留客户端的状态信息。每个请求都是独立的,服务器不会记住之前的请求。可扩展:支持通过请求头部和响应头部传递额外的信息,以实现更多的功能和扩展。明文传输:默认使用明文传输,数据在网络上以纯文本形式传输,不加密。为了提供安全性,可以使用 **HTTPS(HTTP Secure)**协议进行加密传输。Ⅱ. 认识URL1、URL的格式​ 统一资源描述定位符 URL(Uniform Resource Locator) 是用来表示从互联网上得到的资源位置和访问这些资源的方法,说的简单直白一点,就是我们平时在访问页面时候输入的网址!

​ 为什么要有这个 URL 呢❓❓❓

​ 其实和 HTTP 协议的出现是差不多的情况,就是因为我们需要去统一怎么标志分布在整个互联网上的万维网文档,所以必须要有一个统一的格式去描述这些文档,那么万维网使用的就是 URL 来解决,并且它 在整个互联网范围内是唯一的!

​ 一个 URL 大致由如下几部分构成:

在这里插入图片描述协议方案名​ 上面例子中的 http 就是协议的名称,而其后面的 :// 是用来标识前面的内容就是协议名称,所以 在协议后的 :// 一定要加上!一般来说,我们在浏览器中不输入协议名称的话,默认使用的就是 http 协议来通信,会自动在外面输入的网址前补上!

​ 而当我们想使用其它应用层协议的时候,只需要改一下名称即可!一般还常用的协议有文本传输协议 FTP、HTTPS 等!

​ 除此之外,协议名称是不区分大小写!

登录信息 – 忽略​ usr:pass 表示的是登录认证信息,包括登录用户的用户名和密码。虽然登录认证信息可以在 URL 中体现出来,但 绝大多数 URL 的这个字段都是被省略的,因为登录信息可以通过其他方案交付给服务器。

服务器地址​ 服务器地址一般也称为 主机名,它可以是一个域名,也可以是一个 IP 地址。如果使用的是域名的话,通过 DNS 最终解析完就是你要访问的目的服务器的 IP 地址!我们可以用 ping 指令测试一下:

​ 一般来说,我们使用的都是域名,因为好记呀!!!

​ 除此之外,主机名中的字母是不区分大小写的!

服务器端口号​ 80表示的是服务器端口号。HTTP 协议和套接字编程一样都是位于应用层的,在进行套接字编程时我们需要给服务器绑定对应的IP和端口,而这里的应用层协议也同样需要有明确的端口号。

​ 常见协议对应的端口号:

协议名称

对应端口号

HTTP

80

HTTPS

443

SSH

22

​ 当我们使用某种协议时,该协议实际就是在为我们提供服务,现在这些常用的服务与端口号之间的对应关系都是明确的,所以我们在使用某种协议时实际是不需要指明该协议对应的端口号的,因此 在 URL 当中,服务器的端口号一般也是被省略的。

文件路径​ 访问服务器的目的是获取服务器上的某种资源,通过前面的域名和端口已经能够找到对应的服务器进程了,此时要做的就是指明该资源所在的路径。

​ /dir/index.htm 表示的是 要访问的资源所在的路径,其用 斜线 / 进行分割路径层级,可以看出服务器是一台 linux 系统,而最后要访问的资源就是 index.html。一般如果我们不带文件名的话,只是指示到了其的一个目录,那么可能有不同的响应,这要看服务器回响的是什么内容,可能是一个默认页面,也可能是错误页面!

​ 比如我们打开浏览器输入百度的域名后,此时浏览器就帮我们获取到了百度的首页,其实本质就是就是我们访问了百度的服务器,然后其服务器向我们响应了其首页的超文本内容,最后我们的浏览器帮我们将该内容解析成一个我们能看得懂的页面!

​ 我们可以将这种资源称为网页资源,此外我们还会向服务器请求视频、音频、网页、图片等资源。HTTP 之所以叫做超文本传输协议,而不叫做文本传输协议,就是因为有很多资源实际并不是普通的文本资源。

​ 要注意的是,这里最开始的斜线 / 不一定表示系统的根目录,而是服务器中对应项目资源的根目录,比如我们之前写过的在线版五子棋,其实我们就通过在项目目录下创建一个新目录 wwwroot,用于存放前端文件,而最后返回的也是这些资源文件,那么此时这些访问文件的根目录是那个 wwwroot,如下所示:

查询字符串​ 上面的 uid=1 表示的是请求时提供的额外的参数,这些参数是以键值对的形式,它们 起始于 ? 符号,而 通过&符号分隔开的。

​ 比如我们在浏览器上面搜索 HTTP,此时可以看到 URL 中有很多参数,而在这众多的参数当中有一个参数 wd(word),表示的就是我们搜索时的搜索关键字 wd = HTTP。

​ 因此双方在进行网络通信时,是能够通过 URL 进行用户数据传送的。

片段标识符​ 上面的 ch1 表示的是片段标识符,是对资源的部分补充。

​ 倘若有个名为 example.html 的文档中包含一个 id 属性值为 myelement 的元素,那么使用 example.html#myelement 这个 URL 即可直接导航至该元素,该 URL 中的 #myelement 即称为 URL 片段标志符!

2、URL的编码和解码​ 学到这我们可能就会有疑惑,上面介绍的格式里面,如果说查询字符串部分中,带有 /、?、: 等字符,那岂不是会被 URL 当作分隔符或者特殊字符也给处理了,那不就搞错了吗❓❓❓

​ 是的,所以 URL 也会有对应的处理方法,就是进行 编码转义!

​ 转义的规则如下:

​ 将需要转码的字符转为转成 十六进制,然后前面加上 百分号 即可。编码成 %XY 格式。

​ 比如说我们在百度搜索 “c++”,此时的关键字 wd 则变成了 c%2B%2B,其实就是将 + 号转义为了 %2B 而已!

这里分享一个在线编码工具:

http://tool.chinaz.com/tools/urlencode.aspx​ 服务器收到 URL 请求,将会对 %XY 进行解码,该过程称为解码。如果哪天需要手动写编码和解码了,直接在网上搜一下就行,网上有别人写好的现成的代码,直接复制粘贴就行,不过也要注意检错!

​ 说明一下: URL 当中除了会对这些特殊符号做编码,对中文也会进行编码。

Ⅲ. HTTP的报文结构​ 应用层常见的协议有 HTTP 和 HTTPS,传输层常见的协议有 TCP 和 UDP,网络层常见的协议是 IP,数据链路层对应就是 MAC 帧了。其中下三层是由操作系统或者驱动帮我们完成的,它们主要负责的是通信细节。如果应用层不考虑下三层,那么在应用层自己的心目当中,它就可以认为自己是在和对方的应用层在直接进行数据交互。

​ 下三层负责的是通信细节,由操作系统负责;而应用层负责的是如何使用传输过来的数据,两台主机在进行通信的时候,应用层的数据能够成功交给对端应用层,因为网络协议栈的下三层已经负责完成了这样的通信细节,而如何使用传输过来的数据就需要我们去定制协议,这里最典型的就是 HTTP 协议。

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

​ 这种基于 request & response 这样的工作方式,我们称之为 cs 或 bs 模式,其中 c 表示 client,s 表示 server,b 表示 browser。

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

1、请求协议格式在这里插入图片描述请求报文由以下四部分组成:

请求行:由【请求方法 + URL+ HTTP版本】组成,注意每个字段之间都有一个空格!请求报头:请求的属性,这些属性都是以键值对形式按行陈列的,注意 值的前面是有一个空格的!空行:遇到空行表示请求报头结束,用于分割报头和正文!请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个 Content-Length 属性来标识请求正文的长度。​ 其中,前面三部分是一般是 HTTP 协议自带的,是由 HTTP 协议自行设置的,而请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,此时请求正文就为空。

2、响应协议格式响应报文由以下四部分组成:

状态行:由【HTTP版本 + 状态码 + 状态码描述】组成,注意 每个字段之间都有一个空格!响应报头:响应的属性,这些属性都是以键值对形式按行陈列的,注意 值的前面是有一个空格的!空行:遇到空行表示响应报头结束,用于分割报头和正文!响应正文:响应正文允许为空字符串,如果响应正文存在,则响应报头中会有一个 Content-Length 属性来标识响应正文的长度。比如服务器返回了一个 html 页面,那么这个 html 页面的内容就是在响应正文当中的。 注意如果响应资源是图片等传输时候为二进制的文件的话,那我们在读取文件的时候放到缓冲区的时候,要用二进制的方式去读,而不能用文本形式去读,所以最好 统一用二进制的方式去读取资源文件!🎏 写代码的时候,怎么保证请求和响应完整读取完毕❓❓❓首先我们可以先读取完整的一行,得到请求行(如果需要特殊处理的话)接着我们可以用 while 循环,读取多行,将请求报文全都获取,直到遇到空行才读取结束去解析一下请求报文中是否有 Content-Length 字段,其存放的就是请求正文的内容长度,如果有的话则去读取,没有则不需要!(这也是为什么我们之前在学自定义协议的时候,要给报头加上正文长度的原因,其实也是为了方便这里的理解!)🎏 请求和响应需要做序列化和反序列化吗❓❓❓​ 因为 HTTP 其实已经在内部实现了序列化和反序列化,所以在传输请求和响应的时候 无需我们去做处理!除非说我们在正文中自定义了一些结构需要传输,那么此时可能就需要我们自己使用 json/protubuf/xml 等工具去进行序列化和反序列化!

🎏 HTTP为什么要交互版本❓❓❓​ HTTP 请求当中的请求行和 HTTP 响应当中的状态行,当中都包含了 HTTP 的版本信息。其中 HTTP 请求是由客户端发的,因此 HTTP 请求当中表明的是客户端的 HTTP 版本,而 HTTP 响应是由服务器发的,因此 HTTP 响应当中表明的是服务器的 HTTP 版本。

​ 客户端和服务器双方在进行通信时会交互双方 HTTP 版本,主要还是 为了解决兼容性的问题。因为服务器和客户端使用的可能是不同的 HTTP 版本,为了让不同版本的客户端都能享受到对应的服务,此时就要求通信双方需要进行版本协商。(因为有些用户不升级为新版本的情况,就决定了需要这么做!)

​ 客户端在发起 HTTP 请求时告诉服务器自己所使用的 HTTP 版本,此时服务器就可以根据客户端使用的 HTTP 版本,为客户端提供对应的服务,而不至于因为双方使用的 HTTP 版本不同而导致无法正常通信。因此为了保证良好的兼容性,通信双方需要交互一下各自的版本信息。

Ⅳ. 搭建服务器框架​ 我们现在就搭建服务器的框架,其实是为了下面每个知识点,会做一点小测试使用的,那么框架要先搭起来才知道实验怎么做!其实非常简单,我们之前学习了 TCP 服务器的搭建和自定义协议,对于 HTTP 协议来说,它也是应用层的协议,所以它也是一种自定义协议,只不过不需要我们去做太多工作,其内部实现已经很完善了!

​ 我们搭建服务器的主要目的,是要通过 HTTP 的客户端比如说浏览器,来访问我们自己写的服务器,然后通过获取到的报文来分析现象,同时我们还能通过对报文里面的字段进行设置来达到不同的效果,所以下面一起搭建起来!

1、服务器类实现 – httpserver.hpp​ 首先不用说太多,就是 TCP 服务器框架,就那几个函数直接套就行!

​ 对于类似 HTTP 服务器这种会收到大量且短暂的请求以我们一般都是使用多线程来完成回调函数处理,这样子比起多进程来说开销会稍微小一些,不过最好还是用线程池比较好,但是这里因为学习重点不是这个,所以用多线程版本来解决即可!

​ 此外,我们一般都会有一个处理函数 handler,就像下面头文件中的 handlerHttp 一样,它的任务就是处理服务器要干的事情,所以一般将其写到类外来实现,保证解耦!

​ 在 handlerHttp 中首先会接收请求报文,然后进行解析,接着在 handlerHttp 中我们会调用 _func 回调函数,也就是业务处理函数,这个函数是在 httpserver.cc 中实现的,这个可以看后面其实现,其实就是将收到的请求报文打印出来,然后将响应报文进行填充后打印出来,方便我们的实验观察!最后填充了响应对象之后就将其返回给客户端!

代码语言:javascript代码运行次数:0运行复制#pragma once

#include

#include

#include

#include

#include

#include

#include

#include

#include

#include "protocol.hpp"

using namespace std;

const uint16_t gport = 8080;

const int gbacklog = 5;

using func_t = function; // 函数类型自定义命名

class ThreadData

{

public:

ThreadData(int sockfd, func_t func)

: _socketfd(sockfd), _func(func)

{}

public:

func_t _func;

int _socketfd;

};

void handlerHttp(ThreadData* td)

{

// 处理函数的步骤:

// 1. 读取http请求

// 2. 反序列化(这里省略)

// 3. 业务逻辑 -- 即打印请求报文、填充响应对象

// 4. 序列化(这里省略)

// 5. 发送http响应回去

char buffer[4096]; // 接收缓冲区

httpRequest req; // 请求对象

httpResponse resp; // 响应对象

// 读取http请求,因为这里没封装recvPackage函数,但我们可以认为大概率会收到一个完整的请求

ssize_t n = recv(td->_socketfd, buffer, sizeof(buffer)-1, 0);

if(n > 0)

{

buffer[n] = 0;

req.inbuffer = buffer; // 填充请求对象中的缓冲区

req.parse(); // 这个函数具体我们后面实现,主要功能就是将缓冲区的请求内容,拆分成多个变量等

td->_func(td->_socketfd, req, resp); // 调用业务处理函数,功能是打印请求内容,然后填充响应对象

send(td->_socketfd, resp.outbuffer.c_str(), resp.outbuffer.size(), 0); // 将填充完的响应对象返回给客户端

}

}

class httpserver

{

public:

httpserver(func_t func, const uint16_t& port = gport)

: _func(func), _port(port), _listenfd(-1)

{}

void start()

{

// 1. 创建套接字

_listenfd = socket(AF_INET, SOCK_STREAM, 0);

if(_listenfd == -1)

{

cout << "socket error" << endl;

exit(1);

}

cout << "socket success" << endl;

// 2. 绑定网络信息

struct sockaddr_in local;

local.sin_family = AF_INET;

local.sin_port = htons(_port);

local.sin_addr.s_addr = INADDR_ANY;

int n = bind(_listenfd, (struct sockaddr*)&local, sizeof(local));

if(n == -1)

{

cout << "bind error" << endl;

exit(1);

}

cout << "bind success" << endl;

// 3. 监听

n = listen(_listenfd, gbacklog);

if(n == -1)

{

cout << "listen error" << endl;

exit(1);

}

cout << "listen success" << endl;

}

void run()

{

while(true)

{

// 4. 获取客户端请求

struct sockaddr_in client;

socklen_t len = sizeof(client);

int serverfd = accept(_listenfd, (struct sockaddr*)&client, &len);

if(serverfd == -1)

{

cout << "accept error" << endl;

exit(1);

}

cout << "accept success" << endl;

// 5. 创建新线程去完成业务处理,即报文的发送和接收

pthread_t tid;

ThreadData* td = new ThreadData(serverfd, _func);

pthread_create(&tid, nullptr, thread_routine, (void*)td);

}

}

static void* thread_routine(void* args)

{

pthread_detach(pthread_self()); // 分离新线程

ThreadData* td = static_cast(args);

handlerHttp(td); // 调用回调函数处理

close(td->_socketfd); // 记得关闭文件描述符

return nullptr;

}

private:

func_t _func;

uint16_t _port;

int _listenfd;

};2、协议类实现 – protocol.hpp​ 协议类的框架如下所示,主要就是有两个类,请求类和响应类。

​ 我们这给请求类中的对象分了好几个,其实都是请求报文中的字段,而 parse() 函数的任务就是要通过 inbuffer 字符串也就是一个缓冲区,来解析拆分出我们想要的字段然后赋值给成员变量!

​ 而对于响应类来说,目前咱们只需要一个缓冲区,用来打印即可,至于填充缓冲区的任务,咱们放到主函数中去实现也行!

代码语言:javascript代码运行次数:0运行复制#pragma once

#include

#include

#include

#include

#include

#include

#include

#include "util.hpp"

using namespace std;

const string sep = "\r\n";

const string default_path = "./wwwroot";

const string home_page = "index.html";

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

class httpRequest

{

public:

void parse(); // 负责拆分inbuffer中的请求报文,分给其它的成员变量

public:

string inbuffer; // 缓冲区,存放的是请求报文

string method; // 请求方法

string url; // 请求资源路径

string httpversion; // 版本

string path; // 访问路径

string suffix; // 访问文件的后缀格式,用于解析响应何种文件

int size; // 请求资源大小

};

class httpResponse

{

public:

string outbuffer;

};​ 其中 parse() 函数的实现如下所示:

代码语言:javascript代码运行次数:0运行复制// 负责拆分inbuffer中的请求报文,分给其它的成员变量

void parse()

{

// 1. 从inbuffer中拿到第一行也就是请求行,其中分隔符为"\r\n"

string line = Util::getReqLine(inbuffer, sep);

if(line.empty())

return;

// 2. 从请求行中提取出三个字段

// 直接使用stringstream的流插入,它以空格为标识符进行分割,符合我们预期

stringstream sstr(line);

sstr >> method >> url >> httpversion;

// 3. 设置路径

path += default_path; // 先变成./wwwroot

path += url; // 然后加上url,其可能是某个目录比如根目录,也有可能是具体的路径

// 如果url指的是目录,则让其加载默认首页,即这里的./wwwroot/index.html

// 其实这里实现的不是很好,只是个样例!

if(path[path.size() - 1] == '/')

path += home_page;

// 4. 获取url中请求文件的后缀,注意包括这个分隔符"."

auto pos = path.rfind('.');

if(pos == string::npos)

suffix = ".html";

else

suffix = path.substr(pos);

// 5. 获取资源的大小,通过stat函数来获取,其会放到一个stat结构体中

struct stat st;

int n = stat(path.c_str(), &st);

if(n == 0)

size = st.st_size;

else

size = 0; // 注意设为0比较好,因为如果设为-1的话,整个文件大小就要减一,开辟空间的时候就要多加一,而0就不用

}

​ 其中用到了一个函数 stat,下面来介绍一下:

​ stat 函数是一个 用于获取文件或文件系统状态信息的系统调用函数。它通常用于获取文件的元数据,如文件大小、访问权限、修改时间等。

代码语言:javascript代码运行次数:0运行复制#include

#include

#include

int stat(const char *path, struct stat *buf);参数: path:是要获取状态信息的文件路径buf:是一个指向 struct stat 结构的指针,用于存储获取到的状态信息。返回值: 执行成功返回 0,负责执行失败​ 而其中的 struct stat 结构体定义如下所示:

代码语言:javascript代码运行次数:0运行复制struct stat

{

dev_t st_dev; // 包含文件的设备ID

ino_t st_ino; // inode号

mode_t st_mode; // 文件保护模式

nlink_t st_nlink; // 硬链接数

uid_t st_uid; // 文件所有者的用户ID

gid_t st_gid; // 文件所有者的组ID

dev_t st_rdev; // 设备ID(如果是特殊文件)

off_t st_size; // 总大小,以字节为单位

blksize_t st_blksize; // 文件系统I/O的块大小

blkcnt_t st_blocks; // 分配的512B块数

time_t st_atime; // 上次访问时间

time_t st_mtime; // 上次修改时间

time_t st_ctime; // 上次状态更改时间

};​ 其中有一个字段 st_size 就是我们要获取的文件的大小!

3、工具类实现 – util.hpp​ 这里实现的函数都是服务它们函数的,也比较简单实现。要注意的是 readFile 函数中,对于文件的读取,一定要用二进制方式,因为对于图片等资源文件来说,它读取的时候就得用二进制方式读取,使用文本读取方式的话可能会出错!

​ 还要注意的是,工具类函数 最好用 static 修饰,此时这些函数就不依赖于该类的实例,就能直接通过 Util::readFile() 直接调用了!

代码语言:javascript代码运行次数:0运行复制#pragma once

#include

#include

#include

using namespace std;

class Util

{

public:

// 获取请求行字符串的函数

static string getReqLine(string& buffer, const string& sep)

{

auto pos = buffer.find(sep);

if(pos == string::npos)

return "";

string tmp = buffer.substr(0, pos);

return tmp;

}

// 读取资源文件的函数

static bool readFile(const string& resource, char* buffer, int size)

{

ifstream in(resource, ios::binary);

if(!in.is_open())

return false; // resource not found

in.read(buffer, size);

// 注意不能按下面这种形式读取图片,因为图片是二进制形式

// string line;

// while(getline(in, line)) // 行读取

// *out += line;

in.close();

return true;

}

// 将请求文件的格式转化为http对应的响应格式

static string suffixHash(const string& suffix)

{

std::string ct = "Content-Type: ";

// 这里只处理两种文件类型

if (suffix == ".html")

ct += "text/html";

else if (suffix == ".jpg")

ct += "application/x-jpg;image/jpeg";

ct += "\r\n"; // 最后记得加个回车换行

return ct;

}

};4、创建简单的前端页面​ 因为我们是通过浏览器充当客户端来向服务器发送请求的,所以我们也可以让服务器返回页面给浏览器进行解析!

​ 下面我们给出最简单的两个页面,一个是默认首页,一个是错误页面,将它们两个放在当前项目的一个新目录 wwwroot 下保存,当服务器要响应信息的时候,我们就将其作为正文响应回去!

在这里插入图片描述​ 它们的实现如下所示,首先是 index.html:

代码语言:javascript代码运行次数:0运行复制

主页

我是主页呀!

​ 然后是 404.html:

代码语言:javascript代码运行次数:0运行复制

404

当前页面资源找不到,404!

5、服务器主函数的实现 – httpserver.cc​ 主函数是主要任务就是完成一个业务处理函数 Get,我们主要实现其请求报文的打印、响应报文的填充和打印!并且对于响应的资源文件,我们是通过文件读取的方式存放到字符串中,最后将各部分拼接而成!

代码语言:javascript代码运行次数:0运行复制#include "httpserver.hpp"

#include "util.hpp"

#include

#include

void Usage(string proc)

{

cout << "Usage: \n\t" << proc << " port\n\r";

}

// 请求资源的函数

bool Get(int socketfd, const httpRequest& req, httpResponse& resp)

{

// 1. 请求处理 -- 因为已经处理完毕,只需要打印即可

cout << "+++++++++++++++++http start++++++++++++++++++" << endl;

cout << req.inbuffer << endl;

cout << "\r\n下面是解析出来的一些变量:" << endl;

cout << "path: " << req.path << endl;

cout << "suffix: " << req.suffix << endl;

cout << "size: " << req.size << "字节" << endl;

cout << "++++++++++++++++++http end+++++++++++++++++++" << endl;

// 2. 响应处理

string respline = "HTTP/1.1 200 Accept\r\n"; // 响应行,这里设为成功

string resphead = Util::suffixHash(req.suffix); // 这里响应报头通过suffixHash函数获取其对应的返回资源,如html

if(req.size > 0)

{

resphead += "Content-Length: ";

resphead += to_string(req.size);

resphead += sep;

}

string empty = "\r\n"; // 空行

string body;

body.resize(req.size); // 记得要先开辟空间

if(!Util::readFile(req.path, (char*)body.c_str(), req.size)) // 读取对应的资源文件

{

// 若读取不到读取资源,则读取错误页面,这个肯定会成功

// 注意要计算错误页面的大小,然后重新开辟空间

struct stat st;

stat(html_404.c_str(), &st);

resphead += "Content-Length: ";

resphead += to_string(st.st_size);

resphead += sep;

body.resize(st.st_size);

Util::readFile(html_404, (char*)body.c_str(), st.st_size);

}

resp.outbuffer = respline + resphead + empty; // 将响应的各部分组织起来成为一个完整点的报文进行打印(正文我们先不打印)

cout << "----------------------http response start---------------------------" << endl;

std::cout << resp.outbuffer << std::endl;

cout << "----------------------http response end-----------------------------" << endl;

resp.outbuffer += body;

return true;

}

int main(int argc, char* argv[])

{

if(argc != 2)

{

Usage(argv[0]);

exit(1);

}

uint16_t port = atoi(argv[1]);

unique_ptr httper(new httpserver(Get, port));

httper->start();

httper->run();

return 0;

}6、运行结果​ 如下图所示,我们可以看到其实客户端发来的首部字段还是挺多的,后面我们会讲各字段的含义,最重要的是我们已经能看到效果了!而我们响应的报文的内容就相对的比较少了!

​ 此外,我们可以看到,虽然浏览器只访问了服务端一次,但是我们可以看到有多次请求,URL 是 /favicon.ico,其实就是一个页面的图标,这也证明了,客户端其实不只会向服务器申请一次资源,如果存在多个资源的话,那么客户端是会发送多个请求的!但是在我们的浏览感受中,就好像一步到位!

在这里插入图片描述​ 可以看出请求报头当中全部都是以 key: value 形式按行陈列的各种请求属性,请求属性陈列完后紧接着的就是一个空行,空行后的就是本次 HTTP 请求的请求正文,此时请求正文为空字符串,因此这里有两个空行。

​ 另外,我们还可以尝试着在网址后输入不存在的文件或者目录的话,结果就会返回一个错误页面:

7、使用 telnet 命令访问服务器​ 我们之前讲过一个指令 telnet,这个时候就可以耍一耍了!我们只需要在 telnet 后加上我们的服务器地址以及端口号即可访问:

在这里插入图片描述​ 由于只是作为示例,我们在构建 HTTP 响应时,在响应报头当中只添加了一个属性信息 Content-Length,表示响应正文的长度,实际 HTTP 响应报头当中的属性信息还有很多。

8、添加跳转链接和图片元素​ 我们可以在主页或者其它页面,都添加上一些跳转链接,只要一点击就能跳转到其它的资源文件中,其实就是一个前端 html 的标签 a 中的属性 herf,而插入图片则使用标签 img 即可,如下所示:

代码语言:javascript代码运行次数:0运行复制

主页

我是主页呀!

cat

电视

阿斯顿

​ 然后我们就能测试一下效果怎么样了:

Ⅴ. HTTP的请求方法​ 这里的名词 “方法” 是面向对象技术中使用的专门名词。所谓 “方法” 就是对所请求的对象进行的操作,这些方法实际上也就是一些命令。因此,请求报文的类型是由它所采用的方法决定的。

​ HTTP常见的方法如下:

方法

说明

支持的HTTP协议版本

GET

获取由 URL 所标志的资源

1.0、1.1

POST

传输实体主体(而不是写在请求报头中)

1.0、1.11.0、1.1

HEAD

获得由 URL 所标志的资源的首部

1.0、1.1

DELETE

删除由 URL 所标志的文件

1.0、1.1

OPTIONS

询问支持的方法

1.1

TRACE

用来进行换回测试的请求报文

1.1

CONNECT

要求用隧道协议连接代理,用于代理服务器

1.1

LINK

建立和资源之间的联系

1.0

PUT

在指明的 URL 下存储一个文档

1.0、1.1

​ 其中最常用的就是 GET 方法和 POST 方法!

1、GET方法和POST方法的区别​ GET 方法一般用于 获取某种资源信息,而 POST 方法一般用于 将数据上传给服务器。但实际我们上传数据时也有可能使用 GET 方法,比如百度提交数据时实际使用的就是 GET 方法。

​ 其实两种方法都可以带参,但是它们带参方式不太一样:

GET 方法通过 URL 传参POST 方法通过请求正文传参​ 从这两种方法的传参形式可以看出,POST 方法能传递更多的参数,因为 URL 的长度是有限制的,而 POST 方法通过正文传参就可以携带更多的数据。

​ 此外,使用 POST 方法传参 更加私密,因为 POST 方法不会将你的参数回显到 URL 当中,此时也就不会被别人轻易看到。但不能说 POST 方法就很安全了,私密性不等于安全性!因为两种方法本质上还是明文传输,这些请求都是可以被抓到的,想要安全必须加密得使用 https 协议!(后面会讲)

2、演示GET和POST的区别​ 首先我们要知道,客户端进行数据提交时,是通过前端的标签 from 表单进行提交的,浏览器会将 from 表单中的内容转换为 GET 或者 POST 方法,这取决于我们的选择,默认是使用 GET 方法提交表单。

代码语言:javascript代码运行次数:0运行复制

主页

用户名:


密码:



​ 其中上面的 action 属性指的是我们申请的资源文件路径,而 method 就是我们说的提交方法!而上面的 input 标签是一种输入框,我们分别用文本输入框、密码输入框以及提交点击框来进行测试!

​ 如果我们将 method 改为 POST 的话,也就是这样子:

在这里插入图片描述​ 那么结果会这样子:

​ 所以我们可以总结一下,GET 方法提交的表单内容,是直接拼在 URL 后面的;而 POST 方法提交的表单内容,则是放在正文中的,相对会私密一点,但是并不妨碍它们都被抓包后泄露的问题!

​ 注意:如果用的是 GET 方法,需要对 url 进行额外的处理,例如 /test.py?name=zhangsan&pwd=12345,需要拆解出其中的路径 path,即 test.py。问号右侧则是参数 parm。而 POST 方法则不需要这么麻烦!

​ 伪代码如下所示:

代码语言:javascript代码运行次数:0运行复制if(req.path == "test.py")

{

// 建立进程间通信:pipe

// 然后fork创建子进程,execl("/bin/python", test.py)

// 父进程,将req.parm 通过管道写给某些后端语言,python,java,php等语言

}

if(req.path == "/search")

{

// 表示是要查找,则通过req.parm,并且使用我们自己写的C++的方法,提供服务

}Ⅵ. HTTP状态码及状态码描述

1xx(信息性状态码),是协议处理中的一种中间状态,实际用到的比较少。

比如 http 在升级为 websocket 协议的时候,返回的状态码就是 1xx 类型的! 2xx(成功状态码):表示请求已成功被服务器接收、理解、并接受。

200 OK:请求成功。201 Created:请求已经被实现,资源已经被创建。204 No Content:请求成功,但响应报文不含实体的主体部分。 3xx(重定向状态码):客户端发送请求,服务器返回 3XX 状态码和一个新的 URL,客户端拿着这个新的 URL 再次请求服务器,这就是重定向。

301 Moved Permanently:永久性重定向。302 Found:临时性重定向。304 Not Modified:客户端已经执行了 GET,但文件未变化。307 Temporary Redirect:临时性重定向。 4xx(客户端错误状态码):表示客户端请求出错,服务器无法处理请求。

400 Bad Request:请求报文存在语法错误。401 Unauthorized:未经授权,需要身份验证。403 Forbidden:服务器拒绝请求。404 Not Found:服务器无法找到请求的资源。(属于客户端错误,客户端请求资源在服务器不存在) 5xx(服务器错误状态码):表示服务器处理请求出错。

500 Internal Server Error:服务器内部错误。502 Bad Gateway:网关错误。表示服务器自身工作正常,访问后端服务器发生了错误。503 Service Unavailable:表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。504 Gateway Timeout:网关超时。​ 这里我们来讲一下重定向状态码,其实就是服务器告诉客户端,跳转到其它网站上去,多半是出现在广告比较多的网站的情况下,我们只需要设置一下响应字段,在响应字段中添加一个 Location 字段,后面跟上想要跳转的链接即可做到,如下图所示:

临时重定向和永久重定向本质是影响客户端的标签,决定客户端是否需要更新目标地址。如果某个网站是 永久重定向,那么第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时你访问的直接就是重定向后的网站。如果某个网站是 临时重定向,那么每次访问该网站时如果需要进行重定向,都需要浏览器来帮我们完成重定向跳转到目标网站。​ 我们这里要演示临时重定向,可以将 HTTP 响应当中的状态码改为 307,然后跟上对应的状态码描述,此外还需要在 HTTP 响应报头当中添加 Location 字段,这个 Location 后面跟上新链接:

Ⅶ. HTTP常见的首部字段常见的首部字段如下:

Content-Type:数据类型(text/html 等)。

Content-Length:正文的长度。

HTTP 协议通过设置回车符、换行符作为 HTTP 报头的边界,通过 Content-Length 字段作为 HTTP 正文的边界,这两个方式本质都是为了解决 “粘包” 的问题 Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上,就可以将请求发往「同一台」服务器上的不同网站。

User-Agent:声明客户端的操作系统和浏览器的版本信息。

Connection:最常用于客户端要求服务器使用「HTTP 长连接」机制,以便其他请求复用,常见的值有 Keep-Alive 和 close。

其中 Keep-Alive 就表示长连接,而 close 表示不进行长连接,也就是发完数据就断开连接!注意,这和 TCP 连接中的 Keep-Alive 是不一样的,不要搞混了,TCP 中的 Keep-Alive 是为了 保活机制。 Referer:当前页面是哪个页面跳转过来的。

Location:搭配 3xx 状态码使用,告诉客户端接下来要去哪里访问。

Set-Cookie:指定响应中的 Cookie 信息,通常用于实现会话(session)的功能。

Content-Encoding:说明数据的压缩方法。表示服务器返回的数据使用了什么压缩格式,常见的方式有 gzip。

客户端在请求时,用 Accept-Encoding 字段说明自己可以接受哪些压缩方法。 Cache-Control:指定缓存控制策略,例如 no-cache 表示不缓存,max-age=3600 表示缓存 1 小时等。

Expires:指定响应过期时间,通常与 Cache-Control 一起使用。

Last-Modified:指定资源的最后修改时间,用于协商缓存。

ETag:指定资源的唯一标识符,用于协商缓存。

X-Powered-By:指定服务器使用的编程语言和框架。

Server:指定服务器软件的名称和版本号。

Ⅷ. HTTP缓存技术1、HTTP 缓存有哪些实现方式❓❓❓​ 对于一些具有重复性的 HTTP 请求,比如每次请求得到的数据都一样的,我们可以把这对「请求-响应」的数据都 缓存在本地,那么下次就直接读取本地的数据,不必在通过网络获取服务器的响应了,这样的话 HTTP/1.1 的性能肯定肉眼可见的提升。

​ 所以,避免发送 HTTP 请求的方法就是通过 缓存技术,HTTP 设计者早在之前就考虑到了这点,因此 HTTP 协议的头部有不少是针对缓存的字段。

​ HTTP 缓存有两种实现方式,分别是 强制缓存 和 协商缓存。

2、强制缓存​ 强缓存指的是只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存,决定是否使用缓存的主动性在于浏览器这边。

​ 如下图中,返回的是 200 状态码,但在 size 项中标识的是 from disk cache,就是使用了强制缓存。

​ 强缓存是利用下面这两个响应头部字段实现的,它们都用来表示资源在客户端缓存的有效期:

Cache-Control, 是一个相对时间;Expires,是一个绝对时间;​ 如果 HTTP 响应头部同时有 Cache-Control 和 Expires 字段的话,Cache-Control 的优先级高于 Expires 。因为 Cache-control 选项更多一些,设置更加精细,所以 建议使用 Cache-Control 来实现强缓存。具体的实现流程如下:

当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 Cache-Control,其中就设置了过期时间大小。浏览器再次请求访问服务器中的该资源时,会先通过请求资源的时间与 Cache-Control 中设置的过期时间大小,来计算出该资源是否过期,如果没有,则使用该缓存,否则重新请求服务器。服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。3、协商缓存​ 当我们在浏览器使用开发者工具的时候,你可能会看到过某些请求的响应码是 304,这个是告诉浏览器可以使用本地缓存的资源,通常这种通过服务端告知客户端是否可以使用缓存的方式被称为协商缓存。

​ 上图就是一个协商缓存的过程,所以 协商缓存就是与服务端协商之后,通过协商结果来判断是否使用本地缓存。

协商缓存可以基于两种头部来实现。

第一种:请求头部中的 If-Modified-Since 字段与 响应头部中的 Last-Modified 字段实现:

响应头部中的 Last-Modified 表示这个响应资源的最后修改时间。请求头部中的 If-Modified-Since 表示当资源过期了,发现响应头中具有 Last-Modified 声明,则再次发起请求的时候带上 Last-Modified 的时间,服务器收到请求后发现有 If-Modified-Since 则与被请求资源的最后修改时间进行对比(Last-Modified),如果最后修改时间较新(大),说明资源又被改过,则返回最新资源,HTTP 200 OK;如果最后修改时间较旧(小),说明资源无新修改,响应 HTTP 304 走缓存。 第二种:请求头部中的 If-None-Match 字段与 响应头部中的 ETag 字段:

响应头部中 Etag:唯一标识响应资源;请求头部中的 If-None-Match:当资源过期时,浏览器发现响应头里有 Etag,则再次向服务器发起请求时,会将请求头 If-None-Match 值设置为 Etag 的值。服务器收到请求后进行比对,如果资源没有变化返回 304 并走缓存,如果资源变化了返回 200 并返回最新资源。​ 第一种实现方式是基于时间实现的,第二种实现方式是基于一个唯一标识实现的,相对来说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。

​ 如果在第一次请求资源的时候,服务端返回的 HTTP 响应头部同时有 Etag 和 Last-Modified 字段,那么客户端再下一次请求的时候,如果带上了 ETag 和 Last-Modified 字段信息给服务端,这时 Etag 的优先级更高,也就是服务端先会判断 Etag 是否变化了,如果 Etag 有变化就不用在判断 Last-Modified 了,如果 Etag 没有变化,然后再看 Last-Modified。

为什么 ETag 的优先级更高❓❓❓ 这是因为 ETag 主要能解决 Last-Modified 几个比较难以解决的问题:

在没有修改文件内容情况下文件的最后修改时间可能也会改变,这会导致客户端认为这文件被改动了,从而重新请求。可能有些文件是在秒级以内修改的,If-Modified-Since 能检查到的粒度是秒级的,使用 Etag 就能够保证这种需求下客户端在 1 秒内能刷新多次。有些服务器不能精确获取文件的最后修改时间。​ 注意,协商缓存这两个字段都需要配合强制缓存中 Cache-Control 字段来使用,只有在未能命中强制缓存的时候,才能发起带有协商缓存字段的请求。

​ 下图是强制缓存和协商缓存的工作流程:

当使用 ETag 字段实现的协商缓存的过程:

当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 ETag 唯一标识,这个唯一标识的值是根据当前请求的资源生成的。当浏览器再次请求访问服务器中的该资源时,首先会 先检查强制缓存是否过期: 如果没有过期,则直接使用本地缓存。如果缓存过期了,会在 Request 头部加上 If-None-Match 字段,该字段的值就是 ETag 唯一标识。服务器再次收到请求后,会根据请求中的 If-None-Match 值与当前请求的资源生成的唯一标识进行比较: 如果值相等,则返回 304 Not Modified,不会返回资源,浏览器从本地缓存中加载资源。如果不相等,则返回 200 状态码和返回资源,并在 Response 头部加上新的 ETag 唯一标识。Ⅸ. HTTP/1.1 特性1、优点HTTP 最突出的优点就是「简单、灵活和易于扩展、应用广泛和跨平台」。

简单

HTTP 基本的报文格式就是 header + body,头部信息也是 key-value 简单文本的形式,易于理解,降低了学习和使用的门槛。 灵活和易于扩展

HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。同时 HTTP 由于是工作在应用层( OSI 第七层),则它下层可以随意变化,比如: HTTPS 就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层;HTTP/1.1 和 HTTP/2.0 传输协议使用的是 TCP 协议,而到了 HTTP/3.0 传输协议改用了 UDP 协议。 应用广泛和跨平台

互联网发展至今,HTTP 的应用范围非常的广泛,从台式机的浏览器到手机上的各种 APP,从看新闻、刷贴吧到购物、理财、吃鸡,HTTP 的应用遍地开花,同时天然具有跨平台的优越性。2、缺点 无状态

无状态的好处,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。

无状态的坏处,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。

例如【登录 -> 添加购物车 -> 下单 -> 结算 -> 支付】,这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?别问,问就是酸爽!

对于无状态的问题,解法方案有很多种,其中比较简单的方式用 Cookie 技术。

Cookie 通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。

相当于,在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了。

明文传输

明文意味着在传输过程中的信息,是可方便阅读的,比如 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那你号没了。 不安全

通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,那你号没了。不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,那你钱没了。无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了。HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。3、性能HTTP 协议是基于 TCP/IP,并且使用了「请求 - 应答」的通信模式,所以性能的关键就在这 两点 里。

长连接

早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。

为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。

持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。

当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。

管道网络传输 – 流水线

HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。

举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,如下图:

但是 服务器必须按照接收请求的顺序发送对这些管道化请求的响应。如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。

所以,HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞。

实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,所以后面所有文章讨论 HTTP/1.1 都是建立在没有使用管道化的前提。大家知道有这个功能,但是没有被使用就行了。

队头阻塞

「请求 - 应答」的模式会造成 HTTP 的性能问题。为什么呢❓❓❓

因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」,好比上班的路上塞车。

​ 总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。

🌸 相关推荐 🌸

[流言板]现代快报:一男子因被苏超各种梗逗笑,导致3根肋骨骨折
吕布的生肖和简介
bte365娱乐场

吕布的生肖和简介

📅 06-28 👀 8445
契诃夫短篇小说集作者:契诃夫
365结束投注什么意思

契诃夫短篇小说集作者:契诃夫

📅 07-26 👀 4335