在之前的介绍课程(三)中我们讲了一些和配置加载与log程序的工作。
今天的任务是 “[难]定义一个bufReader类,并且使用该bufReader从TCP流中解析HTTP请求和返回体”,这是唯一一个被我标识为难的东西,其实也不难,只是相对繁琐。
所有的代码都在 https://github.com/dashjay/http_demo/tree/master/4-bufreader 中
本节课的代码,全部在上一节课的基础上
Let’s do it
0x1 TCP基于流传输
TCP传输从不以包为单位,也就是说,一个 GET 请求或一个 POST 请求并不是一个包。也不是你所想象的(我们之前描述的样子),我们想象总是很美好,以为它是这个样子的,读起来很轻松。
GET / HTTP/1.1
Key: Value
body
把他画成上面这样只是为了方便理解,其实他是这样的:GET / HTTP/1.1\r\nKey: Value\r\n\r\nbody
。而且,请求和请求之间没有什么界限。因此他们会是这样的:GET / HTTP/1.1\r\nKey: Value\r\n\r\nbodyGET /...
,你在读取的过程中可能会遇得到很多奇怪的事情:
- 请求长度比你想象的长一点
- 请求长度比你想象的短一点
- …
在读取过程中总会遇到各种不同的问题,读起来会很痛苦,如果写一个reader帮助我们实现一些类似 readline()
或 read_n()
的函数,这样我们起码能在读取的过程中,节省一些精力。
我们倒过来思考吧,如果我现在有了一行数据,我应该怎么提取出数据呢?
我们一起看一下这段代码,我们尝试简单的从一行 char 字符串中提取出请求的 method。
const char *line = "GET / HTTP/1.1\r\n"
auto idx{
line};
while(*idx!=' ' || *idx != '\n' || *idx != '\r'){
idx++;
}
std::string method;
if(idx == ' '){
method.assign(line, idx);
}else{
// 头部parse失败
}
大概的方式就是在字符串中寻找空格,然后再分割字符串并且赋值到每个请求单元中。
写这类代码需要会使用C-Style字符串,指针操作等,并且常用以下方法:
- std::find(…)
- std::string.assign(…)
0x2 BufReader
buf -> |G|E|T| |/| |H|T|T|P|/|1|.|1|\r|\n|.....
|↑|
我们大概会这样做这件事,我们会创建一个固定长度的buf,然后通过这个buf实现一些类似于readline,或者read_n这样的操作,buf需要有以下的功能和性质
- 它有非常高的性能
- 功能简洁丰富
- 能够将socket完美封装,对外不展示任何socket的属性。
要做到以上三点,我们分别会通过以下三个方式来实现:
- 不允许进行大量的堆分配和释放,一次性实例化在栈空间。
- 我们都不是代码大神,本次我将从golang中借鉴(抄袭),来实现简洁的功能
- 如果buf尺寸不够用了,会提供其他方法,并且直接赋值到目标中,减少拷贝次数。
纯栈区操作
可以认为 char *key = new char[n];
这样分配的内存在堆上,操作慢,并且需要手动释放,唯一的优点就是空间大。
为了速度我们必须要在栈区分配空间,我们有两个方式,第一是通过模板(cpp的模板又可以开讲一节课了……我不立flag了)来创建,另一个是直接写死在类中,两种情况下,都必须是常量,用起来都差不多,我选择模板创建。
下面就是我认为一个 BufReader 应该有的成员。
template<size_t siz>
class BufReader {
private:
// use template for allocate the m_buf in stack for speed
sockpp::tcp_socket *m_sock;
char *m_r;
char *m_w;
// 有时候遇到错误不会立刻返回,而是把错误储存起来,在必要时候检测是否有错误。
// 因为不管有没有错误,buffer里面已经有的东西是存在的
int error_num;
char m_buf[siz];
}
写成这样我需要做一些解释:本来也可以用 std::array<char, siz>
来取代 char m_buf[siz]
,但是在这个类中我们完全使用栈空间,要求极速,我相信纯 char 数组应该是要快一些(在我们安全的使用下),另外 std::array 的其他功能我也用不到呀,只会是累赘。
两个指针 m_r 和 m_w 分别对应着 *读指针 和 写指针。按照原理来说 m_buf[siz] 其实也是一个指针。那么一共是三个 char 指针,他们一开始是重合的:m_r = m_w = m_buf;
。
有新的数据写入的时候,会从 m_w 的位置开始写,并且写多少 m_w 就往后移动多少。
你没猜错,读也是一样的,从 m_r 开始读,读多少 m_r 就往后移动多少。当要读的数量 n > (m_w - m_r) 时,就需要进行填充 fill()
了。
让我们先来尝试使用一个填充函数吧。
1. fill()
该函数的作用是在在尝试 read,没数据或者有数据但是数据不够的时候调用。(下面代码包含注释写清楚了为什么这样写)
#define MaxConsecutiveEmptyReads 100
template<size_t siz>
void BufReader<siz>::fill() {
// 读取过的数据,m_r 前有一段空的位置
// 如果 m_r 不在 buf 的头部 ...
if (m_r > m_buf) {
// ... 计算拥有多少数据
int dist{
static_cast<int>(m_w - m_r)};
// 如果有数据...
if (dist > 0) {
// ... 将他们在 buf 中对齐数组buf存放
std::memcpy(m_buf, m_r, dist);
m_w -= dist;
m_r = m_buf;
} else {
// ... 直接归位三个指针
m_r = m_w = m_buf;
}
}
errors::Error err("BufReader", "fill", ErrFillFullBuffer);
// 如果 buf 已经满了,还在读就会返回(这里未来应该抛出异常)
if (static_cast<size_t>(m_w - m_buf) >= siz) {
throw std::move(err);
}
// 尝试 MaxConsecutiveEmptyReads 次读取
for (auto i{
MaxConsecutiveEmptyReads}; i > 0; i--) {
// 读取到 m_w 位置上,直接写入最大的能读取的数值
// 因为我们是 bufReader,因此不怕多读,反正迟早要读的。
auto n = m_sock->read(m_w, siz - (m_w - m_buf));
// n 小于 0 已经是 socket报错了
// 未来为抛出异常,在示例代码中也是
// 现在为了不影响大家学习,暂时用return代替
if (n < 0) {
err.detail = ErrNegativeCount;
throw std::move(err);
}
// m_w 指针向后偏移
m_w += n;
// 出错了应该返回
if (m_sock->last_error() != 0) {
error_num = m_sock->last_error();
err.detail = m_sock->last_error_str();
throw std::move(err);
}
// 读到东西就返回
if (n > 0) {
return;
}
}
// 100次 没读到东西也要返回了
err.detail = ErrNoProgress;
throw std::move(err);
}
首先说明一下,如果你不了解异常,不用担心,这个概念很简单,你可以暂停阅读并且阅读一些资料。(再次立flag,等文章全部结束了,我全文搜索flag来一个一个补全)。
抛出异常是程序,自己发现运行有错误并且无法继续的时候,抛出一个对象(一般派生自 std::exception
)。可以通过 catch 语句来捕捉,就像 python 的 except 语句那样。
一个fill函数就是这样的,每当我们需要读取数据的时候,发现数据不够我们就先调用一次 fill()
,然后再尝试,直到读取到为止。
size_t read(void *buf, size_t n) 这个在sockpp中定义的函数,明明已经传入了要读的尺寸,为什么还要返回一个读到的数据长度呢?因为它这个函数的源码中描述了,它并不是尽力读取,而是只尝试读取,我们可以理解为,读了多少算多少,下面是 sockpp 源码中的函数说明:
/**
* Reads from the port
* @param buf Buffer to get the incoming data.
* @param n The number of bytes to try to read.
* @return The number of bytes read on success, or @em -1 on error.
*/
virtual ssize_t read(void *buf, size_t n);
read_until(char delim)
我们在HTTP的操作中比较常见的是希望能读到 \r\n
或 \n
为结束。因此希望能定义一个叫做 read_until 函数,能够在某个字符出现之前,一直读取。
这个函数你可以先尝试自己写,我会为你提供头部定义,返回前别忘了,移动你的 m_r 和 m_w 指针
template<size_t siz>
std::pair<char *, char *> BufReader<siz>::read_until(char delim);
// 使用的时候
res = reader.read_until('\n')
// res.first 和 res.second 就是上方 line 的开头和尾巴
// 如果你不了解pair,也没关系,我们把它当做返回两个值的小伎俩
// res.first 代表开始读的地方
// res.second 代表第一个碰到的 '\n' 的指针
readline
这个函数依赖于上方的函数,实现起来也很简单,所以我把实现直接写出来了
template<size_t siz>
std::pair<char *, char *> BufReader<siz>::readline(){
return this->read_until('\n');
}
read_n(size_t n)
尝试读取指定长度的内容,我计划为这个函数提供两套实现(重载)。
template<size_t siz>
std::pair<char *, char *> BufReader<siz>::read_n(size_t n);
template<size_t siz>
bool BufReader<siz>::read_n(std::string &buf, size_t n);
当希望读取指定长度 n 时,我们不太确定是否能在bufReader上做这个操作,因为我们不知道现在缓存里有多少内容了,也不知道 n 是否会 大于 buf总数。
我们要尽力 read
,因此我们这里希望能提供两个函数的实现,一个是常规读取,读取n个字符长度。
另一个的实现方案是定义一个 std::string
,当 n > bufsize
的时候,直接 assign
到 std::string
里然后再把 std::string 返回来。
可是这样做会进行一次拷贝,因此我建议直接把要写入的 std::string
作为引用传入,让内部 bufReader
直接从 socket 直接写到 std::string
内。
我想这应该是一个好办法,处理流程建议为:
- 如果 n < 缓存数量,直接assign进去,然后返回。
- 如果 n > 缓存数量 < bufSize,先执行 fill 到足够再执行 1
- 如果 n > bufSize,先把缓存部分直接assign进去,剩下的部分直接从socket读取,高效。
暂停 + 思考
写到这里我已经不知道教程怎么写了,因为也许你没有完成这几个函数,想完成了再继续。
目前按照模块化编程的道理,我们应该写一系列的 test,并且对 bufReader类进行大量自动化测试,没问题了再继续,但是我不决定这样做,我们的最主要的目的是为了理解 HTTP 和 手动尝试一次 HTTP 编程,如果你现在没有完成 bufReader 类,你不需要太担心。
这个 bufReader 我是参考的 golang 的 bufio,它的源代码在这里 https://github.com/golang/go/blob/master/src/bufio/bufio.go, 自动化测试流程也在文件夹内:https://github.com/golang/go/tree/master/src/bufio,我准备先屏蔽这小部分内容,对下面的内容进行仔细讲解。
在底部我们有整个bufReader实现的源代码,github仓库中也有我们课程配套的代码。
0x3 使用 bufReader 解析 HTTP 请求
有时候要有勇气,大胆往前走,如果你因为什么东西卡主了,多半是肺热,吃点葵花牌小儿……
开始吧,不皮了 😉
解析请求行
记得我们刚才readline返回的是一个 pair 么?这个pair有头有尾,我们的解析请求行的函数就这么写吧。
namespace parser{
bool parse_request_line(const char *beg,
const char *end, Request &req){
// 申请一个 char 指针标记 end 的位置
auto p_end{
end};
// 尝试寻找第一个 ' '(space)
auto space1{
std::find(beg, end, ' ')};
// 如果没找到...
if (*space1 != ' ') {
return false;
}
// method 就成功获取了
req.method.assign(beg, space1);
// 第二个空格的寻找
auto space2{
std::find(space1 + 1, end, ' ')};
if (*space2 != ' ') {
return false;
}
req.path.assign(space1 + 1, space2);
// 尾部 CRLF 的去除
while (*p_end == '\r' || *p_end == '\n') {
p_end--;
}
req.proto.assign(space2 + 1, p_end + 1);
return true;
}
}
上节课有小伙伴问,命名空间什么的自己从来没有尝试使用过,这次我们可以试试,把整个解析函数们都定义在 parser 命名空间中,如上方所定义的那样。
拓展: 这里使用字符串指针操作并搜索第一个空格的方法,也许没有使用 std::cmatch
方法的快,你可以了解并且尝试。
在上面的函数的帮助下,下方的test可以正常运行
#include <iostream>
#include "cxxhttp.h"
int main() {
Request req{
}