手撸HTTP是理解HTTP的最好方式(4)

本文详细介绍了如何使用C++手动创建一个BufReader类,以从TCP流中解析HTTP请求和响应。内容包括TCP流的特性、BufReader的设计与实现,以及如何利用BufReader解析HTTP请求行、头部和body。最后,通过实现一个简单的HTTP代理服务器来演示BufReader的使用。
摘要由CSDN通过智能技术生成

在之前的介绍课程(三)中我们讲了一些和配置加载与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需要有以下的功能和性质

  1. 它有非常高的性能
  2. 功能简洁丰富
  3. 能够将socket完美封装,对外不展示任何socket的属性。

要做到以上三点,我们分别会通过以下三个方式来实现:

  1. 不允许进行大量的堆分配和释放,一次性实例化在栈空间。
  2. 我们都不是代码大神,本次我将从golang中借鉴(抄袭),来实现简洁的功能
  3. 如果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 的时候,直接 assignstd::string 里然后再把 std::string 返回来。

可是这样做会进行一次拷贝,因此我建议直接把要写入的 std::string 作为引用传入,让内部 bufReader 直接从 socket 直接写到 std::string 内。

我想这应该是一个好办法,处理流程建议为:

  1. 如果 n < 缓存数量,直接assign进去,然后返回。
  2. 如果 n > 缓存数量 < bufSize,先执行 fill 到足够再执行 1
  3. 如果 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{
   }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值