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

HTTP服务器初步设计和实现

1. 设计HTTP服务器架构

我们先设计一个初步的HTTP服务器,下图中描绘了HttpServer及其相关数据类型的关系。其中HttpServer就是服务器的抽象。 它有一个TcpServer,负责管理各个TCP连接; 它还有一个HttpSession的指针数组,用来管理各个Http会话。成员函数OnNewConnection、OnMessage、OnCloseConnection将被注册称为TcpServer的回调, 分别用来处理新建连接、新消息、关闭连接的事件。当我们成功获得一个请求报文之后,就会通过回调接口mRequestCB通知用户做出响应,并填充参数HttpResponsePtr所指对象。

HttpSession用来管理Http会话,在TcpServer中我们用Connection来管理连接。虽然每个HTTP的报文都一定在一个TCP报文的数据部分中,但是Connection不应该关心具体的报文内容。 所以,这里我们又用HttpSession做了一层抽象,专门拿来解析报文,处理请求的。它有两个私有的成员,mRequest和mResponse分别对应着一次回话过程中请求和响应报文。 这两个成员正是HttpServer的调用接口mRequestCB时具体的传参对象。我们还提供了成员函数HandleMsg用于解析指定连接上接收到的请求报文。

HttpRequest封装了请求报文,它的成员mMethod描述了请求方法,mURL记录了请求的资源路径,mVersion则是请求报文所遵循的协议版本。这三个成员构成了请求报文的起始行, 可以通过接口StartLine把起始行重现出来。mHeaders通过map的形式记录了请求报文首部中的各个键值对,mContent用来记录正文。通过接口ToUint8Vector我们可以把整个报文序列化。 类似的HttpResponse封装了响应报文。我们可以通过接口 ToUint8Vector 完成序列化之后,把 buf 所指对象交给 Connection 发送出去,就完成了一次HTTP会话。

2. HttpServer的构造过程

下面是 HttpServer 的构造函数,有三个传参分别是PollLoop对象、服务器端口号,最大支持的连接数量。整个构造函数没有什么特别之处,都是对它所持有的TcpServer对象进行初始化的工作。

        HttpServer::HttpServer(PollLoopPtr const & loop, int port, int max_conn)
            : mServer(loop, port, max_conn)
        {
            mServer.SetTimeOut(10, 0, 5);
            mServer.SetNewConnCallBk(std::bind(&HttpServer::OnNewConnection, this, _1));
            mServer.SetCloseConnCallBk(std::bind(&HttpServer::OnCloseConnection, this, _1, _2));
            mServer.SetMessageCallBk(std::bind(&HttpServer::OnMessage, this, _1, _2));
        }

在第二行中,我们通过构造列表的方式完成了 mServer 的实例化工作。在第4行中,配置了TcpServer的事件轮盘,每10s轮询一次,轮询5次之后认为超时关闭连接,即一个连接可以有50s的时间没有通信。 第5到7行是在注册回调函数,HttpServer的所有工作流程都是通过这三个回调函数驱动的,下面将分别介绍之。

3. OnNewConnection

右图是调用到 HttpServer 的新建连接回调函数 OnNewConnection 的过程。PollLoop 检测到 POOLIN 事件之后,会经过一系列的回调函数,之后进入 TcpServer 的 OnNewConnection 中。 这一过程可以参见TCP服务器的事件响应过程

在 TcpServer 的 OnNewConnection 中,会先检查一下当前的连接数量,只有在没达到一开始设定的连接数量上限的时候,才会新建一个连接对象 conn,并注册到 PollLoop 循环上。 然后加入 TcpServer 的时间轮盘管理,当长时间没有通信时就会关闭连接。接着 TcpServer 通过回调接口 mNewConnCallBk 调用到了刚刚注册的 HttpServer 的 OnNewConnection 函数。 最后,TcpServer 向新建的连接 conn 注册接收新消息和关闭连接的回调函数,并增加连接计数。

HttpServer 的回调函数 OnNewConnection 只做了一件事情,就是创建一个 HttpSession 对象来管理一次会话。下面是这个函数的代码片段,它以新建的连接对象conn为参数, 先将之设定为非阻塞的通信模式。

SessionPtr HttpServer::OnNewConnection(ConnectionPtr const & conn) {
    conn->GetHandler()->SetNonBlock(true);

接下来通过容器mSessions和mHoles为将要构建的HttpSession对象分配一个索引。

    int idx = 0;
    if (mHoles.empty()) {
        idx = mSessions.size();
        mSessions.push_back(nullptr);                                    
    } else {
        idx = mHoles.back();
        mHoles.pop_back();
        mSessions[idx] = nullptr;
    }

下面是用来管理会话对象的容器 mSessions 和 mHoles 的定义, 与InputBuffer的设计类似, 为了方便管理会话对象,提高内存利用效率,HttpSession 对象中会有一个私有的成员变量记录着其在 mSessions 中的索引, 当对象被释放的时候,我们用容器 mHoles 记录下它的索引。

std::vector<HttpSessionPtr> mSessions;
std::vector<size_t> mHoles;

所以每次在分配新的索引之前,我们都需要检查一下容器 mHoles 是否为空。若非空则说明 mSessions 中有某个或某几个元素被释放了,不用在扩展容器了。 我们只需要从 mHoles 中找到一个可用的索引就好了。否则说明 mSessions 中所有的元素都还存活着,需要扩展容器放置将要创建的对象。

    HttpSessionPtr ptr(new HttpSession(conn));
    ptr->mIdx = idx;
    mSessions[idx] = ptr;                                                
    return ptr;
}

最后,我们构建了 HttpSession 对象,记录了分配的索引,保存到了容器 mSessions 中,并将这个对象的指针返回了。

这里有必要啰嗦两句。我们在输入缓存与解码分发一文中对输入缓存做了一些优化, 但是后来我在写这个Http服务器的时候,感觉这个优化并不怎么优,尤其是通过 InBufObserver 的回调来处理消息这一点。 所以在源码tag:v0.0.7b之后,我们就把这个回调改回来了,仍然由 TcpServer 来分发。 但是为了建立起 Connection 和 HttpSession 之间的联系,我们在 ConnectionNode 中增加了一个字段 session 来记录它。所以这里的回调函数会有返回值, 把新建的 HttpSession 对象返回了,交给 TcpServer 把它记录到相应的 ConnectionNode 中。我知道这样的实现很丑,暂时先这样,完成了初版的HttpServer之后,我们还会回来重构它的。

4. OnNewMessage

右图是调用到 HttpServer 处理新消息的回调函数 OnMessage 的过程。TcpServer 的逻辑十分简单,只是处理了回调函数之后,更新了一下时间轮盘而已。所有的消息处理工作都是由 HttpServer 来完成的。

看起来 HttpServer 处理消息的工作流程很复杂,实际上只做了三件事情。首先,解析报文尽可能的消费接收到的数据,这一工作将交给 HttpSession 对象来完成。

然后根据 HttpSession 对象的状态,如果成功接收到一帧完整的报文,就调用回调接口 mRequestCB 让用户来根据请求报文填充响应报文。如果解析出错了就直接生成一个400状态码的报文, 告诉浏览器请求不合法。

相关的代码片段,这里就不放了,读者可以参见源码v0.0.7b。 下面按照这个逻辑,我们把上一节中编写的例程做一些修改,让它看起来像个服务器。 在这个例程中,我们构建了一个 HttpServer 的对象,并注册了回调函数来处理请求报文。这个例程只要接收到一个完整的请求报文,就会返回一个 "Hello" 给浏览器, 仍然没有关心请求报文的具体内容。相比于上一节的say_hello_to_browser而言,这个例程更可靠一点,因为它能保证对一个完整的请求报文做出响应。

        /******************************************************************************
         * 
         * 非阻塞HttpServer - http服务器例程
         * 
         * 单线程
         * 
         *****************************************************************************/
        #include <XiaoTuNetBox/HttpServer.h>
        #include <XiaoTuNetBox/Utils.h>
               
        using namespace std::placeholders;
        using namespace xiaotu::net;
        
        void OnOkHttpRequest(HttpRequestPtr const & req,
                             HttpResponsePtr const & res)
        {
            res->SetStatusCode(HttpResponse::e200_OK);
            res->AppendContent("<h1>Hello</h1>");
        }
        
        int main() {
            PollLoopPtr loop = CreatePollLoop();
            HttpServer http(loop, 65530, 3);
        
            http.SetRequestCallBk(std::bind(&OnOkHttpRequest, _1, _2));
        
            loop->Loop(10);
        
            return 0;
        }

5. OnCloseConnection

右图是调用到 HttpServer 处理关闭连接的回调函数 OnCloseConnection 的过程。逻辑非常简单。在 TcpServer 中通过回调接口 mCloseConnCallBk 调用该回调函数。 然后将响应的连接从PollLoop循环和时间轮盘中移除。在 HttpServer 的 OnCloseConnection 函数中只是释放了与被关闭连接对应的 HttpSession 对象。

这里把 HttpSession 跟 Connection 耦合在了一起,这不是一个好的设计。 因为浏览器从服务器那边请求数据的时候,连接通道并不是一直保持的,有时为了加速还会同时建立多个连接通道同时请求数据。所以一个 Http 会话不应该与连接有关系, 应当根据请求报文的头部来确定会话更为合适。

6. 完

本文中,我们初步设计了一个HttpServer,并讨论了它的构造过程,以及关于新建连接、新消息、关闭连接三个事件的回调函数流程。目前的设计和实现中,把 Http 会话和 Tcp 连接两个概念耦合在一起了, 我们将在后续的重构过程中逐渐完善它们的抽象意义,将之解耦。




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