首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

解析HTTP请求报文

上一节中,我们初步设计了一个 HTTP 服务器的框架, 讨论了新建连接、接收消息、关闭连接三个事件的处理方法。在本文中,针对接收消息事件,我们详细介绍一种解析HTTP请求报文的方法。

1. HttpSession的构造过程和状态机

下面的左右两图分别是新建连接和接收消息的事件响应过程。在新建连接回调函数中,我们构建了一个HttpSession对象。这个对象实际上就是一个状态机,接收消息的时候,我们将通过它来解析接收的报文。

(a). 新建连接的响应过程 (b). 接收消息的响应过程

下面的代码是 HttpServer 中用来响应接收消息事件的函数 OnMessage。在其中第4行中,我们通过类型转换拿到了 HttpSession 对象的指针。然后在第7行中, 利用该指针的 HandleMsg 解析消息。

        void HttpServer::OnMessage(ConnectionPtr const & con, SessionPtr const & session)
        {
            std::cout << "接收到了消息" << std::endl;
            HttpSessionPtr ptr = std::static_pointer_cast<HttpSession>(session);
            std::cout << ptr->ToCString() << std::endl;
        
            HttpRequestPtr request = ptr->HandleMsg(con);
        
            if (HttpSession::eResponsing == ptr->mState) {
                HandleRequest(con, request);
                ptr->mState = HttpSession::eExpectRequestLine;
            } else if (HttpSession::eError == ptr->mState) {
                con->SendString("HTTP/1.1 400 Bad Request\r\n\r\n");
                con->Close();
            }
        }

如果 HttpSession 对象成功的解析出一帧请求报文,它就会进入 HttpSession::eResponsing 的状态。 如第10、11行所示,此时我们通过 HttpServer 的成员函数 HandleRequest 来处理请求报文,调用用户注册的回调函数填充响应报文并发送。 最后将 HttpSession 的状态调整为 HttpSession::eExpectRequestLine。 如果解析报文的过程中,出现了任何错误,HttpSession 对象都会进入 HttpSession::eError 的状态。 此时直接发送一个状态码为 400 的响应报文,报告请求报文非法,同时关闭连接。

HttpRequestPtr HttpSession::HandleMsg(ConnectionPtr const & conn) {
    while (!mInBuf->Empty()) {
        if (eExpectRequestLine == mState)
            OnExpectRequestLine(conn);
        if (eReadingHeaders == mState)
            OnReadingHeaders(conn);
        if (eReadingBody == mState)
            OnReadingBody(conn);
        if (eResponsing == mState || eError == mState) {
            HttpRequestPtr request = mRequest;
            mRequest = std::make_shared();
            return request;
        }
    }
    return nullptr;
}

下图和左侧的代码分别是 HttpSession 的状态和 HandleMsg 函数。它一共有5个状态,初始状态就是 eExpectRequestLine。

该函数在一个 while 中消费接收到的消息,直到输入缓存为空。根据当前的状态, 分别调用 OnExpectRequestLine、OnReadingHeaders、OnReadingBody 三个成员函数解析起始行、首部和正文。

如果OnReadingBody成功解析完正文,就会进入 eResponsing 的状态。三个成员函数如果出错了,就会进入 eError 的状态。 在这两中状态下,我们都应当给发送请求的浏览器返回一帧响应报文,并驱使 HttpServer 处理。

2. 解析起始行

下面是函数 OnExpectRequestLine 的代码片段。我们通过 Begin 获取缓存数据的起始地址,PeekFor 则从缓存的起始地址开始查找第一个换行符"\r\n"。 如果没有任何意外的话,指针 begin 和 crlf 之间就是起始行的内容。

        void HttpSession::OnExpectRequestLine(ConnectionPtr const & conn) {
            uint8_t const * begin = mInBuf->Begin();
            uint8_t const * crlf = mInBuf->PeekFor((uint8_t const *)"\r\n", 2);

接下来如下面左侧的代码所示,我们调用成员函数 ParseRequestLine 解析起始行的具体内容。如果成功解析就切换到 eReadingHeaders 的状态否则就报错。最后通过接口 DropFront 把刚刚解析的内容释放掉。

            if (ParseRequestLine(begin, crlf))
                mState = eReadingHeaders;
            else
                mState = eError;
            mInBuf->DropFront(crlf - begin + 2);           
        }

下面是函数 ParseRequestLine 的代码片段。输入的两个参数 begin 和 end 分别指示了起始行的起始地址和换行符所在的地址。 在上一节中我们了解到,起始行是由空格分隔的三个部分构成的。 这里我们通过函数 FindString 查找第一个空格,那么 begin 到 space 之间的字符就是请求方法。我们通过 HttpRequest 的 SetMethod 接口记录下获得的请求方法。 如果没有查找到空格,SetMethod 拒绝记录请求方法,说明这个起始行不合法,应当抛弃。

        bool HttpSession::ParseRequestLine(uint8_t const * begin, uint8_t const * end) {
            uint8_t const * space = FindString(begin, end, (uint8_t const *)" ", 1);
            if (NULL == space)
                return false;
            if (!mRequest->SetMethod(std::string(begin, space)))
                return false;

接着,我们通过 EatByte 吃掉所有的空格,并通过 FindString 找到第二个分隔用的空格。就得到了URL。

            begin = EatByte(space, end, ' ');
            space = FindString(begin, end, (uint8_t const *)" ", 1);
            if (NULL == space)
                return false;
            mRequest->SetURL(std::string(begin, space));

最后,我们通过查找"HTTP/1."的字符串来确认起始行中包含了 HTTP 的版本信息。

            begin = EatByte(space, end, ' ');
            space = FindString(begin, end, (uint8_t const*)"HTTP/1.", 7);
            if (NULL == space)
                return false;
            mRequest->SetVersion(std::string(begin, begin+8));
            return true;
        }

3. 解析头部

HTTP的报文头部是由一行行以':'分隔的键值对构成的,即<key>:<value>\r\n。如下面的代码片段所示,我们仍然通过PeekFor获取一行消息。 如果返回一个空的指针,意味着当前的缓存中还没接收到完整的一帧,我们先返回待下次可读事件触发的时候继续解析。

        void HttpSession::OnReadingHeaders(ConnectionPtr const & conn) {
            uint8_t const * begin = mInBuf->Begin();
            uint8_t const * crlf = mInBuf->PeekFor((uint8_t const *)"\r\n", 2);
            if (NULL == crlf)
                return;

如果发现最后的换行符就在一行的开始位置,意味着我们读到了一个空行。这标志着请求报文的首部结束了。此时,我们我们将切换状态机进入 eReadingBody 的状态。

            if (crlf == begin) {
                mState = eReadingBody;
                mInBuf->DropFront(2);
                return;
            }

接下来,我们这一行中查找第一个字符':',如果无法找到,意味着接收到的报文不合法,我们进入 eError 的状态。

            uint8_t const * colon = FindString(begin, crlf, (uint8_t const *)":", 1);
            if (NULL == colon) {
                mState = eError;
                return;
            }

当我们成功找到分隔符':'之后,可以断言':'之前的部分就是<key>,之后的部分就是<value>。我们通过EatByte和一些while循环来清理<key>和<value>前后的空格, 并计算它们的字符长度。

            uint8_t const * key_begin = EatByte(begin, colon, ' ');
            uint8_t const * key_end = colon;
            while (' ' == key_end[-1]) key_end--;
 
            uint8_t const * val_begin = colon + 1;
            while (*val_begin == ' ') val_begin++;
            uint8_t const * val_end = crlf;
            while (' ' == val_end[-1]) val_end--;

            uint32_t key_len = key_end - key_begin;
            uint32_t val_len = val_end - val_begin; 

最后我们把解析出来的<key>和<value>字符串添加到 HttpRequest 中。

            std::string key((char const *)key_begin, key_len);
            std::string value((char const *)val_begin, val_len);
            mRequest->SetHeader(key, value);
            mInBuf->DropFront(crlf - begin + 2);
        }

4. 解析正文

正文的解析就比较简单了,在介绍HTTP协议的时候,我们了解到 GET、HEAD 请求是没有正文的。 而其他有正文的请求,都会在首部添加一"Content-Length"的字段用来描述正文的长度。在下面代码的第3行中,我们通过 HttpRequest 的 ContentLength 来根据请求方法和首部计算正文长度。

        void HttpSession::OnReadingBody(ConnectionPtr const & conn) {
            size_t n0 = mRequest->mContent.size();
            size_t need = mRequest->ContentLength() - n0;
            if (0 == need) {
                mState = eResponsing;
                return;
            }

            size_t n = (need > mInBuf->Size()) ? mInBuf->Size() : need;
            mRequest->mContent.resize(mRequest->mContent.size() + n);
            mInBuf->PopFront(mRequest->mContent.data() + n0, n);
        }

我们把接收到的数据都写到 HttpRequest 的 mContent 容器中。当容器中保存的字节数与期望的正文长度一致时,意味着正文接收完毕,也意味着接收到了一帧完整的报文。此时进入 eResponsing 的状态, 等待 HttpServer 和用户程序处理请求并生成响应报文回复浏览器。

3. 完

本文中截取的代码只说明了大致的思路,并不能直接应用,有一些细节的问题在这里没有详细阐述,存在很多bug。比如,我们的状态机中,每次都尝试完全消费接收缓存, 示例代码中只有判定 mInBuf 非空这一个结束循环的条件。如果一次通信没有完整的传送报文的话,它很可能陷入死循环的状态。 因为 OnExpectRequestLine、OnReadingHeaders、OnReadingBody这三个成员函数只在成功完成解析或者检测到数据内容非法才会切换状态并消费缓存。 关于这些细节,我们将在后续的重构和优化的过程中考虑。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1