目录
不知道你在上网的时候是否想过,你看到的这些文字、图片还有视频等信息都是怎么出现在你的电脑屏幕上的?
像我们之前写的udp和tcp协议的客户端(client)和服务器(server)中,客户端会把对资源的请求发送给服务器,服务器会根据请求将客户端需要的数据发回。这种客户端与服务端相互发送数据的机制一般称为CS模式,我们目前市面上的各种app都使用这样的机制运行。

还记得我们之前实现的网络计算器吗?
它的执行流程如下:

我们之前说过,应用层包括应用层、表示层和会话层。
会话层负责建立连接,在我们之前的代码中对应了socket、bind等函数建立连接和发送信息的过程。
应用层负责转化不同形式类型的数据,在我们的代码中对应了加去报头和序列化反序列化过程。
会话层负责针对特定应用的协议,在我们的代码中对应了Request和Response结构体的处理。
这三层都需要我们自己实现,虽然我们经常把这三层看为一层,但三层的功能泾渭分明,每一层都自己的工作。
你可能还会说,这不对呀。服务器上的数据有视频、图片还有其他数据。那不同的数据又都是怎么发送到我们的电脑上的呢?这就要讲到HTTP了。
HTTP协议中文名为超文本传输协议,既是最经典的应用层协议,也是应用最广泛的协议。它可以将服务器上的任意类型的数据拉取到本地浏览器,浏览器对其进行解释可以得到网页文本 、图片 、视频、音频等资源。
我们上网都需要网址,通过网址我们就能跳转到某一个网页,比如说百度搜索的地址:百度一下,你就知道
在HTTP协议中,我们常说的网址被称为url。一个url字段的组成大致是这样的:

真实的url与示例上的会不太一样,有省略的或者新增的部分。
下面是通过百度搜索C#的搜索结果:

我们拿这个网站的url看一下:
首先,头部为https,表明该网站使用了https协议。紧接着的www.baidu.com,就是百度对应服务器的IP地址。再接着,在&之前wd=C%23,就涉及到了urlencode过程。
由于URL本身使用一些字符作为特殊字符(?/#等),但我们在搜索时难免会输入这些特殊字符。所以为了区分协议中的字符和搜索使用的字符,一些符号就会被转化为16进制格式数字,用来和URL本身的特殊字符进行区分。
urlencode会先将字符(字符本质也对应ASCII码)转为16进制数字,然后从右到左取4个比特位(不足4位按四位处理),每2个比特位做一位在前面加上%,编码成%XY格式。这个转化的过程就被称之为URL的encode编码,由浏览器(客户端)完成。
服务器在收到客户端发来的请求时会进行decode编码,将原来的数据恢复过来,继续处理请求。
在网上也有这种编码方式的转译工具。
HTTP的请求结构以行(hang)为单位,可分为四个部分:请求行、请求报头、空行、有效载荷。

(1)请求行
HTTP协议请求结构的第一行被称为请求行,以空格为分隔符包含请求方法、请求地址和协议版本。
比如:GET / HTTP/1.1,其中GET是请求方法,还有一个方法叫做POST。/表示请求地址,也就是我需要哪个目录下的文件,这里就表示网络的根目录。HTTP/1.1是协议版本,HTTP常用的有三个版本http/1.0、http/1.1和http/2.0,我们以后使用的都是1.1版本。
(2)请求报头
请求报头是由多个Key:Value结构构成的多行结构,请求报头中包含许多请求属性,每一条属性为一行,使用\r\n结尾。
(3)空行
只包含\r\n,有分隔请求报头和有效载荷的效果。
(4)有效载荷
这里储存的一般是用户可能提交的参数,这部分内容不是http协议必需的。
HTTP的相应结构也以行(hang)为单位,同样可分为四个部分:状态行、响应报头、空行和有效载荷。

(1)状态行
HTTP协议响应结构的第一行被称为请求行,以空格为分隔符包含协议版本状态码和状态码描述。
协议版本就不说了,而对状态码而言,你可能不知道http协议,但你一定在上网时遇到过打不开的网站,页面会告诉你404 not found。

这里的404就是状态码,而not found就是404状态码的描述。
(2)响应报头
响应报头与请求报头基本一致,只是二者存储的属性会略微不同。
(3)空行
只包含\r\n,有分隔请求报头和有效载荷的效果。
(4)有效载荷
有效载荷主要是需要传回的资源,可能是html/css的文件资源,也可能是请求对应的图片等等。
为了验证真正的HTTP请求是否和我们描述的一样,我们使用下面TCP通信服务端改造后的代码作为服务端,接收不同平台浏览器发来的http请求。接收后将请求打印在屏幕上。
使用该代码时,需要在云服务器官网将该机器中你需要使用的端口号设为开放,否则防火墙会拒绝所有的申请,你也不会收到http协议。
Protocol.hpp
- #pragma once
- #include
- enum
- {
- OK = 0,
- DIV_ZERO,
- MOD_ZERO,
- OP_ERROR
- };
-
- class HttpRequest
- {
- public:
- HttpRequest()
- {}
- public:
- std::string inbuffer;
- };
-
- class HttpResponse
- {
- public:
- HttpResponse()
- {}
-
- public:
- std::string outbuffer;
- };
HttpServer.hpp
- #pragma once
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include"Protocol.hpp"
-
- enum errorcode
- {
- USAGE_ERROR = 1,
- SOCKET_ERROR,
- BIND_ERROR,
- LISTEN_ERROR
- };
-
- static const uint16_t given_port = 8080;
- static const int given_backlog = 5;
-
- class Httpserver
- {
- typedef std::function<void(const HttpRequest&, HttpResponse&)> func_t;
- public:
- //构造函数
- Httpserver(func_t func, const uint16_t& port = given_port)
- :_func(func)
- ,_port(port)
- ,_listensock(-1)
- {}
-
- //初始化服务端进程
- void initserver()
- {
- _listensock = socket(AF_INET, SOCK_STREAM, 0);
- if(_listensock < 0)
- {
- exit(SOCKET_ERROR);
- }
-
- struct sockaddr_in local;
- local.sin_family = AF_INET;
- local.sin_port = htons(_port);
- local.sin_addr.s_addr = INADDR_ANY;
- if(bind(_listensock, (struct sockaddr*)&local, sizeof(local)) < 0)
- {
- exit(BIND_ERROR);
- }
-
- if(listen(_listensock, given_backlog) < 0)
- {
- exit(LISTEN_ERROR);
- }
- }
-
- //启动服务端进程,多进程版本
- void start()
- {
- while(1)
- {
- struct sockaddr_in peer;//储存本地网络信息
- socklen_t len = sizeof(peer);
- int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
- if(sock < 0)
- {
- continue;
- }
-
- //创建子进程
- pid_t id = fork();
- if(id == 0)
- {
- close(_listensock);
- if(fork() > 0)
- exit(0);
- //处理任务的入口
- handler_enter(sock);
- close(sock);
- exit(0);
- }
- }
- }
-
- void handler_enter(int sock)
- {
- HttpRequest req;
- HttpResponse resp;
- char buffer[4096];
- ssize_t n = recv(sock, buffer, sizeof(buffer)-1, 0);
- if(n > 0)
- {
- buffer[n] = 0;
- req.inbuffer = buffer;
- _func(req, resp);
- send(sock, resp.outbuffer.c_str(), resp.outbuffer.size(), 0);
- }
- }
-
- ~Httpserver()
- {}
- private:
- func_t _func;//处理函数
- uint16_t _port;//服务端进程的端口号
- int _listensock;//监听文件描述符
- };
HttpServer.cc
- #include"HttpServer.hpp"
- #include
- #include
- #include
-
- using namespace std;
-
- void Delreq(const HttpRequest& req, HttpResponse& resp)
- {
- cout << "------------------http start------------------" << endl;
- resp.outbuffer = req.inbuffer;
- cout << req.inbuffer;
- cout << "------------------http end------------------" << endl;
- }
-
- static void Usage(string proc)
- {
- printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
- }
- int main(int argc, char* argv[])
- {
- if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
- {
- Usage(argv[0]);
- exit(USAGE_ERROR);
- }
-
- uint16_t port = atoi(argv[1]);
- unique_ptr
p(new Httpserver(Delreq, port)) ; -
- p->initserver();
- p->start();
-
- return 0;
- }
我们首先打开电脑上的浏览器,输入url:http://+云服务器公网IP+:+端口号(例如:http:12.34.56.78:8080)
我们可以观察到进入网站后,Xshell屏幕上出现了按行打印的http请求(第一个)。
当然,我们也可以用手机上的浏览器输入这个url,服务端同样也会收到请求。(第二个请求是我使用iphone发送的,第三个请求是我用oppo手机发送的)

第一行为请求行,下面是请求报头,空行将有效载荷和请求报头隔开,只是这个请求没有有效载荷。

请求报头中包含许多请求属性,每个都采用name:val的键值对形式,并且都是一个字符串。每一条属性占一行,使用\r\n结尾。
后面就是一个空行,只有一个\r\n。
我们并不能打印浏览器接收到的响应,所以我们需要自己构造http请求和响应,理解HTTP的响应。
我们在handler函数中除了接收http请求,还要构建一个http的响应发回客户端
- #include"HttpServer.hpp"
- #include
- #include
- #include
-
- using namespace std;
-
- void Delreq(const HttpRequest& req, HttpResponse& resp)
- {
- cout << "------------------http start------------------" << endl;
- cout << req.inbuffer;
- cout << "------------------http end------------------" << endl;
-
- string resp_line = "HTTP/1.1 200 OK\r\n";//构造状态行
- string resp_hander = "Content-Type:text/html\r\n";//构造响应报头
- string resp_black = "\r\n";//构造空行
- //响应的正文也不是必要的,这里就不写了
-
- //响应序列化,即把它们按顺序拼接好
- resp.outbuffer += resp_line;
- resp.outbuffer += resp_hander;
- resp.outbuffer += resp_black;
- }
-
- static void Usage(string proc)
- {
- printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
- }
- int main(int argc, char* argv[])
- {
- if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
- {
- Usage(argv[0]);
- exit(USAGE_ERROR);
- }
-
- uint16_t port = atoi(argv[1]);
- unique_ptr
p(new Httpserver(Delreq, port)) ; -
- p->initserver();
- p->start();
-
- return 0;
- }
这次我们就不需要通过浏览器发送请求了,可以使用telnet+IP地址127.0.0.1+端口号的方式进行本地环回发送http请求。
但是使用telnet指令需要输入yum install telnet指令安装telnet工具。

首先,运行上述代码编译的程序,我使用8081作为端口号。
然后,输入telnet 127.0.0.1 8081向服务器发送请求。
接着,按Ctrl+ ]键会显示telent>,再按Enter键跳到下一行。
最后,手动输入请求行GET / HTTP/1.1,按Enter键。
此时我们就向服务端发送了请求,通过处理后我们也能收到服务器的响应。
下面红色框的部分就是我们构建的响应。可以看到状态行(HTTP/1.1 200 OK)、响应报头(Content-Type:text/html)和空行(\r\n),和我们学习的宏观响应一致。

在这里我们也确实能看到,HTTP是基于请求和响应的应用层协议。使用TCP套接字,客户端向服务端发送request请求,服务端接收到请求后经过处理返回response响应,实现了服务端和客户端的通信。

前面通过代码已经让大家认识了HTTP的请求和响应的结构,也看到了真实的请求和响应,接下来我们使用上面的代码构建一个基于HTTP协议的接收请求和发送响应的服务端程序。
首先还是和之前写的网络计算器一样的问题,既然http协议的请求和响应的每个信息都对应一行,那么我们怎么确定我们读到的信息就是一行呢?这就需要在一个新的头文件中构建工具类Until实现一个getoneline函数。
Until.h
- //提供getoneline方法
- #include
- class Util
- {
- public:
- //inbuffer是客户端发来的请求,sep是行分隔符,也就是'\r\n'
- static std::string getoneline(std::string& inbuffer, std::string sep)
- {
- auto pos = inbuffer.find(sep);
- if(pos == std::string::npos)
- return "";//没找到分割符,返回空字符
- std::string sub = inbuffer.substr(0, pos);//将请求的一行构造一个新的字符串
- inbuffer.erase(0, pos);//把已经读取的部分删掉
- return sub;
- }
- };
然后,我们在请求类中增加几个成员变量,包括请求的访问方法method,请求的http版本httpversion,以及请求路径path。
通过成员函数parse对请求进行反序列化,首先使用getoneLine读取请求中的请求行,然后将请求行中的三个字段分离出来。stringstream变量可以将字符串以空格进行分割,流提取可以将数据放入变量。
- #pragma once
- #include
- #include
- #include"Util.hpp"
- enum
- {
- OK = 0,
- DIV_ZERO,
- MOD_ZERO,
- OP_ERROR
- };
-
- class HttpRequest
- {
- public:
- HttpRequest()
- {}
-
- void prase()
- {
- std::string line = Util::getoneline(inbuffer, "\r\n");
- if(line.size() == 0)
- return;//没有获取到一行信息,出错
- //使用这一行信息构造一个stringstream变量
- std::stringstream ss(line);
- //从该变量中以空格为分隔符分别将信息放入变量中
- ss >> method >> url >> httpversion;
- }
- public:
- std::string inbuffer;//完整请求
- std::string method;//请求方法
- std::string url;//请求url
- std::string httpversion;//http协议的版本
- };
-
- class HttpResponse
- {
- public:
- HttpResponse()
- {}
-
- public:
- std::string outbuffer;
- };
服务器的处理入口handler_enter中使用parse对请求进行反序列化。

Delreq函数用于处理请求并构建响应,增加将请求行中的三个字段打印出来的代码。
这次构造响应正文的时候,我们将一段html代码以字符串的形式拼接到了响应上。关于html的使用我们不做教学,可以去网上搜一搜html构建网页,直接下载源码使用。
- void Delreq(const HttpRequest& req, HttpResponse& resp)
- {
- cout << "------------------http start------------------" << endl;
- cout << req.inbuffer << endl;
- //打印反序列化后的成员值
- cout << "反序列化后的成员值" << endl;
- cout << "method:" << req.method << endl;//请求方法
- cout << "url:" << req.url << endl;//请求路径
- cout << "httpversion:" << req.httpversion << endl;//http协议的版本
- cout << "------------------http end------------------" << endl;
-
- string resp_line = "HTTP/1.1 200 OK\r\n";//构造状态行
- string resp_hander = "Content-Type:text/html\r\n";//构造响应报头
- string resp_black = "\r\n";//构造空行
- string resp_body = "
Hello HTTP
";//响应正文 -
- //响应序列化,即把它们按顺序拼接好
- resp.outbuffer += resp_line;
- resp.outbuffer += resp_hander;
- resp.outbuffer += resp_black;
- resp.outbuffer += resp_body;
- }
使用telnet工具向服务端发起请求,此时就会得到服务端的响应,如上图所示,包括响应正文(html的代码)。

在服务端就可以看到telnet在发送请求是输入的请求行中的三个字段。
如果用windows上的浏览器来访问服务器,这段html代码就可以得到一个如图所示的网页。

我们可以发现正文中的Hello HTTP字段被显示到了网页中。响应正文中的html代码代表了一个网页,这个网页被服务端响应给客户端。
由于Linux中使用telnet得到响应正文并没有被解释,html代码并没有被处理。而Windows的浏览器是我们能使用到的软件中开发难度最大的,所以它本身就已经非常智能,浏览器得到响应正文会解释它的含义,解释后呈现给我们的结果就是一个网页。
首先,我们在保存服务器代码的目录中创建一个wwroot目录作为http访问的网络根目录,然后在内部创建两个html文件和一个test目录,index.html用于构建网站的首页,404.html用于构建非法访问返回的404页面,test目录下也储存两个构建网站的代码。

还是一样的,html作为前端知识我们这里不做介绍,大家直接使用这些代码即可。
当客户端发起的请求中的url为\时,此时客户端访问的就是web根目录,也就是./wwwroot目录。这时我们将index.html作为响应返回。就会将该文件中的内容作为响应正文返回给客户端。
index.html
- 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>
- body>
- html>
当客户端请求中url错误或者无效时,会将该文件中的内容作为响应正文返回给客户端,告知客户端访问资源不存在并显示404错误码。
404.html
- 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>你所访问的资源并不存在,404!h1>
- body>
- html>
当客户端发起的请求中url为/test/a.html或/test/b.html的时候,服务器就会将这两个文件的内容作为响应正文返回给客户端,客户端得到a或b网页。
a.html
- 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>我是网页ah1>
- body>
- html>
b.html
- 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>我是网页bh1>
- body>
- html>
首先,既然我们要分离网页和服务器,那我们就需要使用文件操作将储存在文件中的html代码读取到服务器进程中。
在Util类中增加一个readfile函数:

我们在httprequest类中增加两个成员变量string path和int size,分别标识请求路径和网络资源的大小。

既然增加了这两个变量,那么对反序列化成员函数prase也要增加对这两个变量的处理。
我们先处理path变量,需要在头部设计一些固定的字符串。比如,sep是分隔符,default_root是web根目录,home_page是首页网站html文件名,html_404是404页面的html文件名。

然后,先将目录设置为./wwwroot,此时再拼接传递过来的url,如果url为/则拼接出来的是./wwwroot/,如果是其他的内容比如a/b/c.html,最后得到的是./wwwroot/a/b/c.html。也就是说,只有对根目录的请求是不精确到某个文件的,所以如果path的最后一个字符是/就表明它是在对根目录做请求,所以我们只要是对根目录做请求我们就再在后面追加home_page,也就变相满足了对根目录的申请。

接着我们处理size变量,对于获取文件的大小,Linux中存在系统调用stat,可用与于获取文件大小。
int stat(const char* path, struct stat* buf);
头文件:sys/socket.h、sys/stat.h、unistd.h
功能:获取文件的大小。
参数:const char* path是表示目标文件的路径的字符串。 struct stat* buf是一个struct stat类型的结构体变量,它的成员变量off_t st_size就指示了文件的大小(以字节为单位)。
返回值:调用成功返回0,调用失败返回-1。
struct stat的定义:
- struct stat {
- mode_t st_mode; //文件对应的模式,文件,目录等
- ino_t st_ino; //inode节点号
- dev_t st_dev; //设备号码
- dev_t st_rdev; //特殊设备号码
- nlink_t st_nlink; //文件的连接数
- uid_t st_uid; //文件所有者
- gid_t st_gid; //文件所有者对应的组
- off_t st_size; //普通文件,对应的文件字节数
- time_t st_atime; //文件最后被访问的时间
- time_t st_mtime; //文件内容最后被修改的时间
- time_t st_ctime; //文件状态改变时间
- blksize_t st_blksize; //文件内容对应的块大小
- blkcnt_t st_blocks; //伟建内容对应的块数量
- };
如果我们获取文件的大小失败,则意味着这个文件很可能不存在,我们将大小设置为404.html的大小就可以了,以后返回的正文也会是404.html的内容。
所以最终代码为:

对于Delreq函数,除了需要增加两个新变量的打印,还要增加从文件中读取正文的代码。
resp_body用于储存响应正文,先给它开辟正文总字节数加一的空间,然后通过readfile读取文件,如果读取失败,就读取404文件作为正文。

我们输入url:http:公网ip:端口号,对web根目录进行请求,得到首页。

我们输入url:http:公网ip:端口号/test/a.html,对a.html文件进行请求,返回网页a。

我们输入url:http:公网ip:端口号/test/a/b/c.html,对一个不存在的文件进行请求,返回网页404。(其实我们应该将返回的状态码也改成404,但是我们只是简单模拟,就不在意这些细节了)

我们经常在网页中经常使用跳转,我们只要点击带有链接的文字就能跳转到另一个网页。其实很简单,只需要增加一些html的语句就能实现这样的操作。
比如说,我们构造了两个语句蓝字表示链接的html文件,黑字表示链接文字的内容。

我们启动测试:

我们点击a网页,就能跳转到a网页。

http作为超文本传输协议,当然可以支持图片、视频、音频等文件的传输,所以我们修改代码使得我们的服务器也支持图片的传递。
比如说,我们在wwwroot中创建一个image文件夹,文件夹中存储一个图片文件1.jpg。(这个图片不要太大,可以将图片拖拽进vscode保存在云服务器上)

此时我们想把这个图片也传递到首页中

首先要在index.html增加图片信息的代码,代码中的alt后面的文字表示图片加载失败时显示在屏幕上的文字。

由于发送http的request本质是在对某一个文件进行申请,而url指示了该文件的位置与名称,所以我们在httprequest类中再增加一个成员变量suffix储存标识文件类型的后缀。

然后,由于我们在prase函数中已经有了获取文件路径path的代码,所以我们只需要从path中获取资源后缀即可。(第四大块,代码后有注释说明)

首先,我们在Delreq中已经拿到了需求文件的后缀。
我们原先的代码中,http响应相关属性的Content-Type是写死的,我们在这里要增加一个suffixToDesc函数用于拼接不同文件的Content-Type属性字符串。
其他类型文件的Content-Type对应类型标识可以看下面的表格。

在Delreq函数中增加构造Content-Type和Content-Length两个属性字符串的代码。

我们再次打开服务器访问网页,可以看到图片显示出来了。

如果获取图片失败了,访问网页时只会显示一个损坏的图片和我们之前写上的“滨江道步行街”。

Util.hpp
- #include
- #include
- class Util
- {
- public:
- //inbuffer是客户端发来的请求,sep是行分隔符,也就是'\r\n'
- static std::string getoneline(std::string& inbuffer, std::string sep)
- {
- auto pos = inbuffer.find(sep);
- if(pos == std::string::npos)
- return "";//没找到分割符,返回空字符
- std::string sub = inbuffer.substr(0, pos);//将请求的一行构造一个新的字符串
- inbuffer.erase(0, pos);//把已经读取的部分删掉
- return sub;
- }
-
- //resourse文件路径,缓冲区buffer,文件大小size
- static bool readfile(const std::string resource, char *buffer, int size)
- {
- //C++文件操作
- std::ifstream in(resource, std::ios::binary);
- if(!in.is_open())
- {
- return false;
- }
- in.read(buffer, size);
- in.close();
- return true;
- }
- };
-
Protocol.hpp
- #pragma once
- #include
- #include
- #include
- #include
- #include
- #include"Util.hpp"
- enum
- {
- OK = 0,
- DIV_ZERO,
- MOD_ZERO,
- OP_ERROR
- };
-
- const std::string sep = "\r\n";
- const std::string default_root = "./wwwroot";
- const std::string home_page = "index.html";
- const std::string html_404 = "./wwwroot/404.html";
-
- class HttpRequest
- {
- public:
- HttpRequest()
- {}
-
- void prase()
- {
- //从完整报文中拿出第一行
- std::string line = Util::getoneline(inbuffer, "\r\n");
- if(line.size() == 0)
- return;//没有获取到一行信息,出错
-
- //使用第一行的信息构造一个stringstream变量
- std::stringstream ss(line);
- //从该变量中以空格为分隔符分别将申请行的三个信息放入变量中
- ss >> method >> url >> httpversion;
-
- //添加路径
- path = default_root; //先设置网络根目录:./wwwroot
- path += url;
- //url表示从根目录开始后的目录,需要将url拼接到根目录后:
- //./wwwroot/a/b/c.html
- if (path[path.size() - 1] == '/')
- path += home_page;
-
- //获取path对应的资源后缀
- //比如./wwwroot/index.html
- // ./wwwroot/test/a.html
- // ./wwwroot/image/1.jpg
- auto pos = path.rfind(".");//从后往前找.
- if (pos == std::string::npos)
- suffix = ".html";//找不到,默认它的后缀为html
- else
- suffix = path.substr(pos);//找到了从.向后构造字符串储存后缀
-
- //得到网络资源的大小
- struct stat st;
- int n = stat(path.c_str(), &st);
- if(n == 0)//如果获取资源大小的返回值为0,获取成功,直接赋值
- size = st.st_size;
- else//如果获取资源大小失败,则设置资源大小为404.html的大小
- {
- stat(html_404.c_str(), &st);
- size = st.st_size;
- }
- }
- public:
- std::string inbuffer;//完整请求
- std::string method;//请求方法
- std::string url;//请求的url
- std::string httpversion;//http协议的版本
- std::string path;//请求路径
- std::string suffix;//文件后缀
- int size;//网络资源的大小
- };
-
- class HttpResponse
- {
- public:
- HttpResponse()
- {}
-
- public:
- std::string outbuffer;
- };
HttpServer.hpp
- #pragma once
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include"Protocol.hpp"
-
- enum errorcode
- {
- USAGE_ERROR = 1,
- SOCKET_ERROR,
- BIND_ERROR,
- LISTEN_ERROR
- };
-
- static const uint16_t given_port = 8080;
- static const int given_backlog = 5;
-
- class Httpserver
- {
- typedef std::function<void(const HttpRequest&, HttpResponse&)> func_t;
- public:
- //构造函数
- Httpserver(func_t func, const uint16_t& port = given_port)
- :_func(func)
- ,_port(port)
- ,_listensock(-1)
- {}
-
- //初始化服务端进程
- void initserver()
- {
- _listensock = socket(AF_INET, SOCK_STREAM, 0);
- if(_listensock < 0)
- {
- exit(SOCKET_ERROR);
- }
-
- struct sockaddr_in local;
- local.sin_family = AF_INET;
- local.sin_port = htons(_port);
- local.sin_addr.s_addr = INADDR_ANY;
- if(bind(_listensock, (struct sockaddr*)&local, sizeof(local)) < 0)
- {
- exit(BIND_ERROR);
- }
-
- if(listen(_listensock, given_backlog) < 0)
- {
- exit(LISTEN_ERROR);
- }
- }
-
- //启动服务端进程,多进程版本
- void start()
- {
- while(1)
- {
- struct sockaddr_in peer;//储存本地网络信息
- socklen_t len = sizeof(peer);
- int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
- if(sock < 0)
- {
- continue;
- }
-
- //创建子进程
- pid_t id = fork();
- if(id == 0)
- {
- close(_listensock);
- if(fork() > 0)
- exit(0);
- //处理任务的入口
- handler_enter(sock);
- close(sock);
- exit(0);
- }
- }
- }
-
- void handler_enter(int sock)
- {
- HttpRequest req;
- HttpResponse resp;
- char buffer[4096];
- ssize_t n = recv(sock, buffer, sizeof(buffer)-1, 0);
- if(n > 0)
- {
- buffer[n] = 0;
- req.inbuffer = buffer;
- req.prase();//反序列化
- _func(req, resp);
- send(sock, resp.outbuffer.c_str(), resp.outbuffer.size(), 0);
- }
- }
-
- ~Httpserver()
- {}
- private:
- func_t _func;//处理函数
- uint16_t _port;//服务端进程的端口号
- int _listensock;//监听文件描述符
- };
HttpServer.cc
- #include"HttpServer.hpp"
- #include
- #include
- #include
-
- using namespace std;
-
- std::string suffixToDesc(const std::string suffix)
- {
- std::string ct = "Content-Type: ";
- if (suffix == ".html")
- ct += "text/html";
- else if (suffix == ".jpg")
- ct += "application/x-jpg";
- ct += "\r\n";
- return ct;
- }
-
- void Delreq(const HttpRequest& req, HttpResponse& resp)
- {
- cout << "------------------http request start------------------" << endl;
- //打印完整报文
- cout << req.inbuffer << endl;
- //打印反序列化后的成员值
- cout << "反序列化后的成员值:" << endl;
- cout << "method:" << req.method << endl;//请求方法
- cout << "url:" << req.url << endl;//请求路径
- cout << "httpversion:" << req.httpversion << endl;//http协议的版本
- cout << "path:" << req.path << endl;//请求路径
- cout << "size:" << req.size << endl;//网络资源的大小
- cout << "------------------http request end------------------" << endl;
-
- string resp_line = "HTTP/1.1 200 OK\r\n";//构造状态行
- string resp_hander = suffixToDesc(req.suffix);//根据文件类型构造Content-Type
- if (req.size > 0)//正文必须有内容才构造Content-Length
- {
- resp_hander += "Content-Length:";
- resp_hander += std::to_string(req.size);
- resp_hander += "\r\n";
- }
- string resp_black = "\r\n";//构造空行
- string resp_body;//响应正文
- resp_body.resize(req.size+1);
- if(!Util::readfile(req.path, (char*)resp_body.c_str(), req.size))
- {
- //如果读取失败,我们就返回404
- Util::readfile(html_404, (char*)resp_body.c_str(), req.size);
- }
-
- //响应序列化,即把它们按顺序拼接好
- resp.outbuffer += resp_line;
- resp.outbuffer += resp_hander;
- resp.outbuffer += resp_black;
- //resp.outbuffer += resp_body;
-
- cout << "------------------http response start------------------" << endl;
- //打印完整报文
- cout << resp.outbuffer << endl;
- cout << "------------------http response end------------------" << endl;
-
- resp.outbuffer += resp_body;
- }
-
- static void Usage(string proc)
- {
- printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
- }
- int main(int argc, char* argv[])
- {
- if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
- {
- Usage(argv[0]);
- exit(USAGE_ERROR);
- }
-
- uint16_t port = atoi(argv[1]);
- unique_ptr
p(new Httpserver(Delreq, port)) ; -
- p->initserver();
- p->start();
-
- return 0;
- }
http的请求方法有很多,其中最常用的就是GET和POST这两种方法。
不知道你们在浏览器中是否在网页中是否使用过浏览器的开发者工具,在网页中点击F12即可打开。
比如说,我打开搜狗搜索并点击F12,右侧点击元素就可以查看构造该网页的html代码。

我们点击弹窗左上角的箭头图标(或按快捷键 Ctrl+Shift+C)进入选择元素模式,从页面中选择需要查看的元素,可以在开发者工具元素(Elements)一栏中定位到该元素源代码的具体位置。
我们将鼠标挪动到搜索框,它对应的html代码对应了下图黑色框的内容,而这块内容就叫做表单。

其实,我们之所以能够搜索信息,是因为搜索框本质是一个form表单。我们搜索的关键字会被填入这个表单中,然后浏览器会通过HTTP协议发送包含该表单的请求到服务端,服务端再处理发回响应,从而实现搜索。
而当我们进行数据提交的时候,推送数据的方法一般使用这两种:GET和POST。
我们在index.html中也增加表单代码,其中action="/test.py"表示使用网络根目录下的test.py处理表单,method="GET"就表明申请的发送使用GET方法。
- 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>
- <a href="/test/b.html">b网页a>
- <img src="/image/1.jpg" alt="滨江道步行街">
- <form action="/test.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>
运行,姓名输入zhangsan,密码输入123456

因为我们并没有创建对应的python文件,所以给我们返回了404。

我们再将GET修改为method="POST",此时申请的发送就改为了POST方法。
重复上述动作也可以实现同样的效果。

GET方法中,form表单中我们输入的姓名和密码以xname=zhangsan&ypwd=123456的形式拼接在了url中。
而使用POST方法提交form表单时,表单的内容并没有拼接到url中。
通过上面对比我们发现,GET和POST请求方法表单信息的存储位置是不同的:GET方法通过url提交参数,POST方法通过HTTP请求的正文提交参数。
如果客户端使用GET方法,在提交form表单的时候,内容就会拼接到url中,在浏览器的网址栏中可以看到。而如果使用POST方法,在提交form表单的时候,内容是通过请求正文提交的,在浏览器的网址栏中看不到。
既然POST方式通过正文传递信息,而正文信息人们一般看不到,那么是否可以说POST方法是安全的呢?
答案是否定的,POST和GET两种方法中信息都是暴露在外的,这些封装后的数据还可以通过抓包的方式被他人获取。在我们后面讲到的https协议才能实现真正的安全信息传输。
在http请求属性中有一行属性表示长链接:Connection: keep-alive
我们构建首页的index.html文件中有文字、网页跳转链接、图片、表单这些代码内容。浏览器申请根目录时,收到的网页中也出现了这么多的内容,而且需要传输的这些文件存储位置不同。而我打开网页时,只在浏览器上输入了一次网址。

也就是说一个网页中有多种类型的资源,而一次请求只能获取一种类型的资源。
那么一个网页的形成就大概率需要浏览器发起多次http请求,而服务器根据请求返回的多个响应共同组成了这个网页。
我们观察服务器也确实发现该网站的建立的确发送了多个http请求。

在底层HTTP协议使用的是TCP套接字,所以客户端每发送一个请求,服务器都会经历一次创建套接字的流程。
如果客户端真的每发送一次数据都创建一个套接字,一方面系统调用的开销回增加程序的运行时间,还有这样的逻辑使得访问一个网页需要创建多个套接字,会导致套接字资源紧张,所以就出现了长连接,对应属性就是上面的Connection: keep-alive。
长连接:一个客户端对应一个套接字,客户端的一个请求响应完后,套接字不关闭,只有客户端退出了,或者指定关闭时,套接字才关闭。
也就是说,一个客户端无论有多少个请求,都通过一个套接字和服务器进行网络通信。
当然http中也存在短链接(Connection: close),短链接仅支持客户端每发送一次信息就重新建立TCP链接,一般用于大量用户使用的资源。这个属性我们并不能直观地看到。
我们在使用浏览器访问CSDN等网页时往往需要登陆账号,比如说我在CSDN首页登陆账号。

那么我只需要输用户名和密码登录这一次,之后我就可以在浏览该网站的其他网页时登录也不会退出。

但是HTTP是一种无状态协议,每次请求并不会记录它曾经请求了什么。所以,在第一次登录CSDN后,在站内进行网页跳转(从一篇文章跳转到另一篇文章)时,你打开了一个新的网页,理论上需要再次输入账号密码登录,浏览器发送表单验证身份信息,但现实是我们不需要第二次登录就可以浏览站内的各个网页。
由于http本身不支持上述保持登录的功能,所以Cookie技术就应运而生了。
要想实现登陆状态的保持效果,浏览器就需要在我们第一次登录CSDN时,将我们的账号密码等登录信息保存下来。我们每次打开CSDN站内的网站时,浏览器会自动将已保存的用户登录信息添加到了请求报头中,通过HTTP协议发送给服务器。服务器会根据拿到的信息进行登陆状态的鉴别并返回对应的响应。
所以说,进入新网页后的登录还是需要的,只是支持Cookie技术的浏览器帮我们做了这件事,我们一直没注意到而已。
如图所示,点击网址前面的锁,就可以查看当前浏览器正在使用的Cookie。

我们将图中和CSDN有关的Cookie数据删除。
刷新页面后,你就会发现你的登录状态失效了。
我们在登录CSDN后,关掉浏览器后再次打开CSDN你还是能保持登录状态。
这又是怎么实现的呢?
Cookie又分为内存级和文件级
根据日常使用浏览器的情况,大部分网站在你登陆后,关闭浏览器再次打开时登陆状态依旧保持,所以大部分情况下的Cookie都是文件级别的,而且这些文件是可以从我们的计算机中找到的。
既然Cookie文件储存了许多我们的隐私信息,那么一旦这些Cookie文件被不法份子盗取,他们就可以冒用我们的身份进行一些非法操作,并且进行一些非法操作。(比如说QQ盗号)
所以为了保证信息安全,现在的很多公司都会将用户的账号密码以及浏览痕迹等信息保存在服务器中。每个用户对应在服务器上创建一个Session文件储存信息。由于服务器上的Session文件有很多,所以每个文件名都会设置为一个独一无二的Session id。
服务器将这个Session id放入响应返回给用户,此时用户浏览器的Cookie中保存的就是这个id值而不再是储存信息的文件。
这种服务端存储用户信息的技术就叫做Session技术。
为什么会话保持使用的Session技术能够提高用户信息的安全性呢?
这是因为,互联网公司的服务器都是由专业的人员维护的,服务器中存在木马病毒的可能性相比我们的计算机而言更小,所以用户信息在服务端会更加安全。
如果客户端储存的Cookie中的Session id被盗用,当不法分子使用该id向服务端发起请求时,因为不法分子的IP地址与你常用IP地址大都不一样,所以服务端就会将所有登录该账号的设备强制下线,此时只有手里真正有账号密码的人才能够再次登录。
当然,保证Session安全的策略非常多,有兴趣的小伙伴可以自行了解。
写入Cookie信息:
我们知道,浏览器的Cookie信息是服务端响应返回的,所以在我们构建响应的时候也可以构建Cookie信息让浏览器去保存。
在DealReq函数中构建响应时,设置Cookie信息,内容是name=123456abc,有效时间是三分钟,然后加到响应报头中返回给客户端,如上图所示。
使用浏览器访问根目录的时候,如上图所示,会得index.html文件表示的网页,查看该网页的Cookie信息,可以看到name是123456abc,有效时间是3分钟,和我们在服务端构建响应时写的内容一模一样。
浏览器将我们在响应中设置的Cookie内容当作了Session id。
真正生成Session id是有一套复杂的算法的,它能够保证每一个Session文件的id都是独一无二的。
Cookie和Session两种技术共同实现了HTTP的会话保持。
在相应的报头中,除了正常响应的200,资源不存在的404,还有很多的错误码,基本上可分为五种类型,分别以1~5开头:
| 状态码 | 类别 | 原因短语 |
| 1XX | informa(信息性状态码) | 接收的请求正在处理 |
| 2XX | Success(成功状态码) | 请求正常且处理完毕 |
| 3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
| 4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
| 5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
我们可以这样理解这几类状态码:
相信大家都有过这样的经历,打开一个网址后,网站在白屏加载时突然就跳转到了一些无关的广告网页,其实这就是重定向的应用。
将服务端发送的网络请求资源转为其他无关的网络资源即为重定向,浏览器发送请求给服务端,服务端返回一个新的url,并且状态码是3XX,浏览器会自动用这个新的url向新地址的服务端发起请求。而我们看到的表现就是打开一个网页时突然又跳转到了另一个完全不相干的网站,此时服务器相当于提供了引路的服务。
所以说,重定向是由客户端完成的,当客户端浏览器收到的响应中状态码是3XX后,它就会自动从响应中寻找返回的新的url并再次发送请求。
重定向又有两种:
临时重定向和永久重定向本质是影响客户端的标签,决定客户端是否需要更新目标地址。
如果某个网站是永久重定向,那么第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时直接访问的就是重定向后的网站。
而如果某个网站是临时重定向,那么每次访问该网站时都需要浏览器来帮我们完成重定向跳转到目标网站。
永久重定向无法演示出来,效果和临时重定向一样。