请求读取与解析
一个完整的请求报文如下
我们读取请求行只需要读取第一行即可
void RecvRequestLine() { Util::ReadLine(_sock ,http_request._request_line); }
那么 我们应该如何读取行数不确定的请求报头呢?
我们现在知道的有两点
- 请求报头是一行一行发送的
- 请求报头读取完毕之后下一个一定是空行
有了上面这两点之后我们就能很轻松的读取完所有的请求报头
只需要按行读取并且读取到\n结束即可
void RecvRequestLine() { Util::ReadLine(_sock ,http_request._request_line); } void RecvRequestHeader() { std::string line; while(true) { line.clear(); Util::ReadLine(_sock , line); if (line == "\n") { break; } line.resize(line.size()-1); // remove \n http_request._request_header.push_back(line); } if (line == "\n") { http_request._blank = line; } }
解析请求行
读取到请求行和请求报头之后我们就开始解析它们
首先是请求行 格式如下
即
std::string method; std::string uri; std::string version;
所以说我们只需要按照空格作为分隔符 将请求行的代码分隔为三部分即可
而我们这里推荐使用stringstream 这个类中重载了流插入运算符默认会按照空格来进行分隔给字符串赋值 用法代码示例如下
void ParseHttpRequestLine() { auto& line = http_request._request_line; std::stringstream ss(line); ss >> http_request.method >> http_request.uri >> http_request.version; }
解释下上面这段代码 我们首先使用line拷贝构造一个stringstream对象 之后让这个对象分别以空格为分隔符将它的内容赋值给http_request.method http_request.uri http_request.version
解析请求报头
经过观察我们不难发现 这里其实就是一个键值对结构 所以说我们使用哈希表来存储即可
有关于哈希表部分知识不理解的同学可以参考我的这篇博客
但是首先我们需要写一个方法让一个字符串按照冒号分隔为两部分
具体实现方法如下
static bool CutString(const std::string& target , std::string& key_out , std::string& value_out , std::string sep){ size_t pos = target.find(sep); if (pos != std::string::npos) { key_out = target.substr(0 ,pos); value_out = target.substr(pos + sep.size()); return true; } return false; }
之后我们只需要遍历整个请求报头 一个个分割key和value之后插入哈希表中就可以
代码表示如下
void ParseHttpRequestHeader() { std::string key; std::string value; for (auto& iter : http_request._request_header) { Util::CutString(iter , key , value , SEP); http_request.header_kv.insert({key,value}); } }
处理请求正文
处理完上面的两个部分之后我们再回过来看请求报文的图
此时我们就面临两个问题了
- 是否存在正文
- 如果存在正文 那么正文有多少个字节
关于第一个问题
一般来说我们的请求方法如果是GET 则一般没有正文
如果我们的请求方法是POST 则一般有正文
关于第二个问题
我们可以由请求报头中的 Content–legth字段来知晓
判断是否存在正文的代码如下
bool IsRecvBody() { auto method = http_request.method; if (method == "POST") { auto &header_kv = http_request.header_kv; auto iter = header_kv.find("Content-Length"); if (iter != header_kv.end()) { http_request.content_length = atoi(iter->second.c_str()); return true; } } return false; }
读取正文代码如下
void RecvRequestBody() { if (IsRecvBody()) { int content_length = http_request.content_length; auto& body = http_request._request_body; char ch = 0; while(content_length) { ssize_t s = recv(_sock , &ch , 1, 0); if (s > 0) { body.push_back(ch); content_length--; } else { break; } } } }
至此 我们的请求和解析报文全部完成
处理请求并且构建响应
在处理这个请求之前 我们首先要考虑这个请求是否是合法的请求
如果是非法的请求 我们应该怎么操作
假设我们现在的Http服务器只接收GET和POST方法的请求 其他的请求只给予一个错误的响应即可 (我们这里最开始默认为404 NOT FOUND)
代码如下
void BuildResponse() { if (http_request.method != "GET" && http_request.method != "POST") { } }
对于响应报文来说 最重要的一部分就是我们的状态码了
HTTP状态码
HTTP的状态码如下:
编号 | 类别 | 意义 |
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
其中我们要记住的有下面这几个
101 信息请求中
101表示客户端发送的请求正在处理中 但是因为网速变快 这种状态已经不怎么常见了
200 OK
这是最常见的一个状态码 也就是我们访问网页成功的时候网页返回的响应行
301 永久重定向
比如果一个老的网站废弃不用了使用一个新的网站 那么此时这个网站就可以使用永久重定向 如果有人还在访问这个网站就会跳转到新网站上
此外如果收藏夹中收藏了老的网站 新的网站会覆盖收藏夹中老的网站
302 307 临时重定向
从名字看就更好理解了 和301永久重定向相比一个是永久的一个是临时的 它并不会覆盖掉收藏夹中的老网站
403 权限不足
这个常见于我们去实习的时候 自己的权限特别低 如果leader丢给你一个文档而你没有观看的权限就会出现这个状态码
404 NOT FOUND
常见于资源消失不见(被删除或过期) 又或者说资源根本不存在
比如说你访问一个网站的时候带上一个不存在的资源路径你就会看到这个状态码
504 Bad Gateway
常见于服务器出现问题 和客户端无关
Redirection(重定向状态码)
除了上面那些要记住的状态码之外 我们还需要更深入的理解重定向状态码
重定向又分为永久重定向和临时重定向 其中301表示永久重定向 302 307表示临时重定向
临时重定向和永久重定向本质是影响客户端的标签 决定客户端是否需要更新目标地址
如果某个网站是永久重定向 那么第一次访问该网站时由浏览器帮你进行重定向 但后续再访问该网站时就不需要浏览器再进行重定向了 此时你访问的直接就是重定向后的网站
而如果某个网站是临时重定向 那么每次访问该网站时如果需要进行重定向 都需要浏览器来帮我们完成重定向跳转到目标网站
获取参数
其实目前从宏观的角度上来说 我们的上网行为可以分为两种
- 浏览器向服务器上传数据
- 浏览器向服务器请求数据
其中向浏览器上传数据的时候我们有两种方法
- 使用GET方法 通过url来进行传参
- 使用POST方法 通过正文来进行传参
客户端为什么要将数据上传到服务器呢?
当然是为了让服务器对客户端传输上来的数据进行处理
这里根据GET和POST传参方式的不同 我们处理请求的方式也需要发生一点变化
由于POST方法传参是通过正文传递参数的 而正文我们已经获取到了 所以说不用关心
但是GET方法传参是通过url传参 而url在请求行中 需要我们特殊处理一下
处理的代码如下 其实就是复用了我们前面剪切字符串的功能函数
auto& code = http_response.response_code; if (http_request.method != "GET" && http_request.method != "POST") { // waring request code = NOT_FOUND; goto END; } if (http_request.method == "GET") { size_t pos = http_request.uri.find("?"); if (pos != std::string::npos) { Util::CutString(http_request.uri,http_request.path, http_request.query_string , "?"); } else { http_request.path = http_request.uri; } } std::cout << "debug url: " << http_request.uri << std::endl; std::cout << "debug path: " << http_request.path << std::endl; std::cout << "debug query_string: " << http_request.query_string << std::endl;
下面是运行结果
Web根目录
我们得到rul之后可以看到这样的一串标识符
- 这里的路径表明了是请求Linux服务器上的某种资源 那么这种资源是从哪里开始的呢?是根目录嘛?
- 这个路径对应的资源是如何判断存在的
问题一:资源是从哪里开始的
这个资源不一定是从根目录开始的
一般来说我们会自己指定一个web根目录 所有的资源都在这个web根目录当中
一般来说web根目录的名称是wwwroot如下
如果说访问我们服务器的客户端没有指明想要获取什么资源的话我们肯定不可能将web根目录下的所有资源全部发出去
所以说这个时候我们就要指定一个默认的资源 一般来说这个资源就是index.html
所以说此时我们的path就不能简单的是/a/b/c了
我们要在前面加上web根目录在Linux服务器中的定位 比如说像这样
wwwroot(web根目录的路径) 加上 /a/b/c
定位代码如下
#define WEB_ROOT "wwwroot/" std::string _path = http_request.path; http_request.path = WEB_ROOT; http_request.path += _path; std::cout << "debug: " << http_request.path << std::endl;
演示效果如下
我们前面也说过了 如果客户端请求的是Web根目录的话我们不可能将整个Web根目录的所有资源全部给他 所以说针对于请求web根目录的情况我们要做一些特殊处理
如果请求的是我们的web根目录我们就返回index.html页面给它
代码表示如下
#define HOME_PAGE "index.html" http_request.path = WEB_ROOT; http_request.path += _path; if (http_request.path[http_request.path.size()-1] == '/') { http_request.path += HOME_PAGE; } std::cout << "debug: " << http_request.path << std::endl;
演示效果如下
问题二 : 如何确认这个资源是存在的
我们首先使用百度来试验下 如果资源不存在会怎么样
可以发现 百度服务器直接给我们返回了一个404告知我们该资源不存在
所以说我们在返回给客户端请求之前需要确认一个资源是否存在
这里使用确认资源是否存在的函数是stat函数
stat函数
函数原型如下
int stat(const char* path , struct stat *buf)
参数说明:
- const char* path 是我们要寻找的路径(是一个字符串)
- struct stat *buf 这是一个结构体 我们通过该结构体来查看文件的信息
返回值说明:
如果找到该文件返回0 如果没找到返回-1
struct stat st; if (stat(http_request.path.c_str() , &st) == 0) { // exist } else { // not exist code = NOT_FOUND; goto END; }
所有的目录中都有一个index.html嘛?
是的 因为一个目录中可能有着大量的网页资源 如果我们不设置一个默认的index.html则系统就不知道应该返回哪个资源给客户端了
在回答了问题二之后便会衍生出一个问题三
存在的资源就是可以读取的资源嘛?
- 存在的资源可能是一个目录
- 存在的资源可能是一个可执行程序
请求的资源是目录
我们可以使用下面的方法来判断是否请求的资源是一个目录
S_ISDIR(st.st_mode)
S_ISDIR是一个宏 而st_mode则是stat结构体中的一个成员变量
使用这个宏我们就能够判断当前请求的资源是否是一个目录
如果是一个目录 我们前面介绍过 每个目录都会有一个index.html 所以我们直接在请求的路径上加上即可
请求的资源是可执行程序
一般来说 客户端请求可执行程序是被允许的
但是我们这里要对于这种情况做一下特殊处理
我们通过下面的方法来确定文件是否是一个可执行文件
if ((st.st_mode&S_IXUSR) || (st.st_mode&S_IXGRP) || (st.st_mode&S_IXOTH)) { // special treatment }
关于如何特殊处理 本文后面会详细讲解
HTTP CGI机制简单介绍
CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一 有着不可替代的重要地位
CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准 是在CGI程序和Web服务器之间传递信息的过程
其实 要真正理解CGI并不简单 首先我们从现象入手
浏览器除了从服务器下获得资源(网页 图片 文字等) 有时候还有能上传一些东西(提交表单 注册用户之类的) 看看我们目前的http只能进行获得资源 并不能够进行上传资源所以目前http并不具有交互式 为了让我们的网站能够实现交互式 我们需要使用CGI完成 时刻记着 我们目前是要写一个http 所以 CGI的所有交互细节 都需要我们来完成
理论上 可以使用任何语言来编写CGI程序
需要注意的是 http提供CGI机制 和CGI程序是两码事 就好比学校(http)提供教学(CGI机制)平台 学生(CGI程序)来学习
反应到具体的web服务器中 什么是CGI机制呢
我们首先创建一个handerdate.exe
作为一个可执行文件
之后的过程如下
- 浏览器传输数据给服务器中的HTTPSERVER
- HTTPSERVER接收到数据之后不做处理 将输出传递给HANDERDATE
- HANDERDATE处理完数据之后再将处理完的数据传递给SERVER
- HTTPSERVER接收到数据之后将处理过的数据传递给浏览器
调用目标程序 传递目标数据 拿到目标结果 这中间用到的就是CGI技术
那么我们什么时候需要用到CGI技术呢?
答案是只要用户上传上来数据此时我们就要用到CGI技术 此时我们只需要将cgi标志位设置为开启即可
最后我们通过判断cgi标志位是否开启来判断使用什么方法 代码表示如下
if (http_request.cgi) { ProcessCgi(); } else { ProcessNonCgi(); // return html }
关于大小写转化的问题
因为我们对于GET和POST方法并没有做出严格的大小写规定 而我们在项目中却使用了大写作为判定条件
这就有可能会导致一些错误的发生 所以说我们保证我们接收的method要转化为大写
C++提供了一个函数来实现这个功能
OutputIterator transform (InputIterator first1, InputIterator last1, OutputIterator result, UnaryOperator op)
参数说明:
- 第一个参数是要转化起始位置的迭代器
- 第二个参数是要转化末尾位置的迭代器
- 第三个参数是最后要存放结果的位置的迭代器
- 第四个参数是要转化的方式(大写或者小写)
代码表示如下
auto& method = http_request.method; std::transform(method.begin() , method.end() , method.begin() , ::toupper );
构建响应
构建响应之前我们首先来回顾下响应的报文是什么样子的
它要有状态行 响应报头 空行 响应正文
所以说我们不单单要只返回一个静态网页(正文) 还需要加上前面的请求行 报头等信息
添加状态行
状态行由http版本 状态码 状态码描述符组成
其中我们默认http版本就是1.0版本
默认的状态码是200(OK)
状态码描述需要和状态码相匹配
此时我们只要设置一个函数 传入code输出一个状态码的string对象就可以 代码如下
static std::string Code2Desc(int code) { std::string desc; switch(code) { case 200: desc = "OK"; break; case 404: desc="Not Found"; break; default: break; } return desc; }
之后一步步写好状态行就好了
http_response._response_line = HTTP_VERSION; http_response._response_line += " "; http_response._response_line += std::to_string(http_response.response_code); http_response._response_line += " "; http_request._request_line += Code2Desc(http_response.response_code);
我们设置默认的响应行分隔符为 \r\n
#define LINE_END "\r\n"
之后响应报头的内容我们这里暂时跳过
添加响应正文
在前面我们已经获取了请求读取的路径
一般来说现在我们只需要根据那个路径打开对应的资源 之后将资源写到报文的正文中即可
但是在实际填写正文的过程中我们会遇到这样子的问题
- 我们写的body是用户层的缓冲区
- 我们需要的网页html是磁盘中的文件
- 磁盘中的文件要到用户层必须要经历内核层
- 所以说如果我们使用read write等函数则IO效率较低
这里给大家介绍一个函数 sendfile
它的作用是不用经历用户层 直接在内核缓冲区拷贝数据 从而提高效率
它的函数原型如下
ssize_t sendfile(int out_fd, int in_fd , off_t* set , size_t count)
参数说明:
- out_fd 是我们要往这里写数据的文件描述符
- in_fd 是我们要从这里读数据的文件描述符
- set 我们不用管 设置为空即可
- count 表示我们要拷贝的数据大小 以字节为单位
返回值说明:
如果成功拷贝则返回成功拷贝的字节数 失败返回-1
发送响应
我们一步步将状态行 响应报头 空行 正文发送即可
void SendResponse() { write(_sock ,http_response._response_line.c_str() , http_response._response_line.size()); for(auto it : http_response._response_header) { write(_sock, it.c_str() , it.size()); } write(_sock,http_response._blank.c_str() , http_response._blank.size()); sendfile(_sock , http_response.fd , nullptr , http_response.size); close(http_response.fd); }
最后我们将index.html中写上hello world
编译运行后使用浏览器尝试接收响应
运行结果如下
由于博主并没有系统的学习前端知识 所以说这里就不写网页的前端了
如果有同学感兴趣可以自己写一些前端的代码放到web根目录下
添加响应报头
响应报头有很多字段可以填充 我们这里只填充两个比较重要的字段
一个是Content-length 即正文的大小
一个是Content-type 即正文的类型
正文的大小其实我们之前已经有过了 这里我们只需要插入到报头中即可 代码如下
std::string content_length_string = "Content-Length: "; content_length_string += std::to_string(size);
而正文的类型则是我们比较难判断的一点
一般来说我们会根据文件名的后缀来判断这个文件是什么类型 当构建响应的时候我们也需要告知浏览器我们返回的是什么类型的资源
所以说我们的第一步操作就是后缀提取
found = http_request.path.rfind("."); if (found == std::string::npos) { http_request.suffix = ".html"; } else { http_request.suffix = http_request.path.substr(found); }
Content-Type在文件后缀和自身之间有一张对照
所以说在我们截取了文件的后缀之后还需要在表中找到对应的内容
我们这里为了方便起见使用静态函数的方式来帮助我们找到后缀对应的内容
同学们也可以尝试使用类来封装
static std::string Suffix2Desc(const std::string& suffix) { static std::unordered_map<std::string , std::string> suffix2desc ={ {".html" , "text/html"}, {".css" , "text/css"}, {".jpg" , "text/html"} }; auto iter = suffix2desc.find(suffix); if (iter != suffix2desc.end()) { return iter->second; } return "text/html"; }