目录:

上一篇说了,这并不是一个单纯的 HTTP 服务器,而是需要加入一些便于开发的功能。这一篇主要就讲这一块的设计。

Request 设计

1
2
3
4
5
6
7
8
9
10
11
12
class Request {
HttpVersion httpVersion;
HttpMethod httpMethod;
std::string target;
std::string absPath; // equals to target without query string
std::map<std::string, FormData> queries;
HeaderPtr header;
BufferPtr body;
SessionPtr session;

// 省略成员函数
};

在解析请求的过程中,有专门的 parser 会将 URL 中的 ? 后的请求字符串解析到 queries 里面。对于 Content-Typeapplication/x-www-form-urlencoded 的请求,请求体中的内容也会一并被解析进 queries。

服务器也支持对于 Content-Typemultipart/form-data 的请求的解析,解析的内容也会进入 queries。为了能够查看 multipart/form-data 的数据块的属性,我多加了一层 FormData,作为 queries 的值类型。

但所带来的问题是,对于非 multipart/form-data 的数据,上层在访问的时候,也需要多一次 getData() 操作。不过我暂时不准备解决这个问题。

Header 其实是一个 CaseInsensitiveMap,是基于 std::map 的一个不区分键的大小写的 Map。所有原始请求的 Header 部分都会被放入此中:

1
2
3
4
5
6
7
8
9
10
11
12
13
class HeaderContent {
std::string content;
};

class Header : public CaseInsensitiveMap<HeaderContentPtr> {
};

typedef CaseInsensitiveMap<std::string> FieldParameters;

class HeaderContentWithParameters : public HeaderContent {
std::string mainContent;
FieldParameters parameters;
};

另外一些特化的 Header 也会有单独的类(比如 ContentTypeCookies),它们会继承 HeaderContent,然后同样也会在 Requestheader 中。

Session 则是继承自 std::map<std::string, std::shared_ptr<void>>,保存会话中所需的信息。Session 的具体内容会在下面谈到。

Response 设计

1
2
3
4
5
6
7
class Response {
HttpVersion httpVersion;
StatusCode statusCode;
Header headers;
std::string body;
Cookies cookies;
};

Response 里的 cookies 其实是 Set-Cookie 的列表,服务器在发送响应的时候,会自动将 cookies 里的内容加入到相应头中,不需要手动在 headers 中设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void HttpServer::writeResponse(uv_stream_t *tcp, std::shared_ptr<const Response> resp) {
std::stringstream responseText;

// Append status line
responseText << stringify(resp->httpVersion) << " "
<< (int) resp->statusCode << " " << toReasonPhrase(resp->statusCode) << "\r\n";

// Append headers
for (auto &e : resp->headers.innerMap) {
auto pair = e.second;
responseText << pair.getKey() << ": " << pair.getValue()->getContent() << "\r\n";
}

// Append cookies
for (auto &e : resp->cookies) {
auto cookie = e.second;
responseText << "Set-Cookie: " << cookie.toCookieString() << "\r\n";
}

// Append Content-Length if not chunked
auto contentLengthEntry = resp->headers.get("Content-Length");
if (!resp->isChunked() && !contentLengthEntry.isValid()) {
responseText << "Content-Length: " << resp->body.size() << "\r\n";
}

// Append Body
responseText << "\r\n" << resp->body;

std::string str = responseText.str();

writeData(tcp, str);
}

发送请求这里写得比较暴力,不过问题应该不大。

一切皆为 Middleware

1
2
3
4
5
6
7
8
9
10
class Middleware : public std::enable_shared_from_this<Middleware> {
public:
/**
* If the call function returns nullptr, then the middleware finishes.
* @param req
* @param resp
* @return The middleware that remains to be called
*/
virtual MiddlewarePtr call(RequestPtr req, std::shared_ptr<Response> &resp) = 0;
};

Middleware 的设计很简单,类似 Java Servlet,对于给定的请求进行处理,然后去建构响应。

你可能会对 call 函数的参数 std::shared_ptr<Response> &resp 感到疑惑,或是对 MiddlewarePtr 这个返回值好奇。

我想说,搞出这种奇怪的东西我也很无奈啊?那个 std::shared_ptr<Response> & 是我想处理内部可变性。

这两个设计都是为了解决静态大文件的问题的,后面会谈到。

HttpServer 会持有一个主 Middleware,解析完的请求就会被交给这个 Middleware

Middleware 处理完的请求就会被发送给客户端。

为方便开发,框架自带了几个常用的 Middleware

RouteMiddleware

RouteMiddleware 是为了解决路由问题的。路由规则由正则表达式和 HTTP 谓词共同决定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef std::pair<std::regex, std::shared_ptr<Middleware>> Rule;
class RouteMiddleware : public Middleware {
std::map<HttpMethod, std::vector<Rule>> rules;
public:
void addRule(std::regex, HttpMethod, std::shared_ptr<Middleware>);
MiddlewarePtr call(RequestPtr req, std::shared_ptr<Response> &resp) override;
};

MiddlewarePtr RouteMiddleware::call(RequestPtr req, std::shared_ptr<Response> &resp) {
for (auto rule:rules[req->getHttpMethod()]) {
auto regex = rule.first;
if (std::regex_match(req->getAbsPath(), regex)) {
return rule.second->call(req, resp);
}
}
throw HttpError(StatusCode::HTTP_NOT_FOUND, "No rule found");
}

开发者可以将路由规则添加到 RouteMiddleware 中,RouteMiddleware 会将请求派发到指定的下一层 Middleware 处。

RouteMiddlewareHttpServer 的主 Middleware 的一个好选择。

StaticMiddleware

StaticMiddleware 是用来响应静态文件的请求的。作为 HTTP 服务器,伺服静态文件是很基础的要求。但对于当前的框架设计,这并不是一件容易的事情。

因为 Response 需要构造完毕才能够发出,所以如果要发送一个 1 GiB 的文件,就需要先将这 1 GiB 的内容读入内存,这显然是不可接受的。这时,分块传输成为了唯一选择。

但对于异步服务器,简单地读入文件,然后将分块产生起来放入 I/O 队列肯定不行,网络传输比硬盘读写慢多了,最后还是将文件全部读入进了内存,没有起到分块应该带来的结果。

我给每个 Client 一个等待队列的长度上限,设为 8,也就是说,一个 Client 未传输的分块数最多为 8。分块请求统一由 processChunks 函数来处理,已否要继续获取分块都是由这个函数来调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct AsyncChunkedResponseHandler {
RequestPtr req;
std::shared_ptr<ChunkedResponse> resp;
};

void HttpServer::processChunks(AsyncChunkedResponseHandler handler, uv_stream_t *tcp) {
auto client = static_cast<Client *>(tcp->data);
if (handler.resp->finished) {
// Process the next request
client->processRequest();
return;
}

while (!handler.resp->finished && client->queued < 8) {
auto polyResp = std::dynamic_pointer_cast<Response>(handler.resp);
client->currMiddleware = client->currMiddleware->call(handler.req, polyResp);
writeChunks(handler, tcp);
}
}

void HttpServer::writeChunkCallback(uv_write_t *req, int status) {
/*
* Not so important code here
*/

handler->client->queued--;

/*
* Not so important code here
*/

server->processChunks(AsyncChunkedResponseHandler(chunkedReq, resp),
reinterpret_cast<uv_stream_t *>(tcp));
}

分块也是一个一个让 libuv 去发送,当有分块发送完毕后,writeChunkCallback 函数会继续调用回 processChunks 函数。如果所有的分块都已经生成完毕,Responsefinished 属性会变为 true,对应的 Middleware 也将不再调用。

这里就体现出为什么 Middlewarecall 函数要返回一个 MiddlewarePtr 了。因为生成一个 chunk 后,processChunks 函数需要反复调用那个还未处理完毕的 Middleware。但显然调用 HttpServer 的主 Middleware 是不对的,我们需要那个未处理完毕的 Middleware将自己的指针返回回来,这样才能在之后调用到它。

然后就让我们看一看 StaticMiddleware 长什么样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class StaticMiddleware : public Middleware {
std::string path;
std::fstream file;
};

MiddlewarePtr StaticMiddleware::call(RequestPtr req, std::shared_ptr<Response> &resp) {
if (!file.is_open()) {
resp->setStatusCode(StatusCode::HTTP_NOT_FOUND);
resp->body = "404 Not Found";
return nullptr;
}

if (!resp->isChunked()) {
resp.reset(new ChunkedResponse(*resp));
return shared_from_this();
}

auto chunkedResp = std::dynamic_pointer_cast<ChunkedResponse>(resp);
if (file.eof()) {
chunkedResp->finished = true;
} else {
const size_t BUF_SIZE = 65536;

auto chunk = std::make_shared<Chunk>(BUF_SIZE);
file.read(chunk->buf, chunk->len);
chunk->len = static_cast<size_t>(file.gcount());
chunkedResp->pushChunk(chunk);
}

return shared_from_this();
}

StaticMiddleware 首先会检测当前的 Response 是不是分块传输的,如果不是就将其转为分块传输的。然后通过 std::shared_ptr 的内部可变性,直接把原来的 Response 替换掉。于是就可以在调用方不知情的情况下,将原来的普通 Response 转换成 ChunkedResponseResponse 的子类)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* This function will call the middleware.
* After calling the middleware, if the response has "Transfer-Encoding: chunked",
* the response should be able to and will be cast to a ChunkedResponse.
* Then the middleware returned back will be repeatedly called until the response is finished.
* @param req
* @param tcp
*/
void HttpServer::process(RequestPtr req, uv_tcp_t *tcp) {
auto resp = std::make_shared<Response>(req->getHttpVersion());
auto client = static_cast<Client *>(tcp->data);
client->currMiddleware = middleware->call(req, resp);
writeResponse(reinterpret_cast<uv_stream_t *>(tcp), resp);

if (resp->isChunked()) {
auto chunkedResp = std::dynamic_pointer_cast<ChunkedResponse>(resp);
processChunks(
AsyncChunkedResponseHandler(req, chunkedResp),
reinterpret_cast<uv_stream_t *>(tcp));
}
}

调用方在第一次调用完 Middleware 后,会先直接将 Response 的头部发送出去。然后发现这是一个 ChunkedResponse,之后的任务就交给 processChunks 函数(上面已经分析过了)了。

SessionMiddleware

对于网站开发来说,Session 是很重要的功能,所以框架也准备内置 Session 的支持。

1
2
3
4
5
6
7
8
class SessionBase {
std::mt19937 gen;

std::mutex sessionMutex;
std::time_t expires;
std::shared_ptr<std::unordered_map<std::string, SessionPtr>> oldGen;
std::shared_ptr<std::unordered_map<std::string, SessionPtr>> newGen;
}

SessionBase 是保存所有 Session 的一个简易数据库,其中有 oldGennewGen 两个散列表。新加入过的 Session 会进入 newGen 中,当当前时间超过 expires 时,原来的 oldGen 会被抛弃,原来的 newGen 会变成 oldGennewGen 则初始化为一个空的散列表,新的 expires 也会随之更新。

当根据 Session ID 去查找对应的 Session 时,会先在 newGen 中查找。如果没有找到,则进入 oldGen 查找,如果找到,则将其再移入 newGen

现在的超时时间设定为 2 个小时,也就是说,一个 Session 如果没有被查找更新过,它会有 2~4 个小时不定的寿命。

感觉起来好像怪怪的,但也没有什么实际的影响,初步就这样了。

libuv 是多线程的,对于不同请求的处理可能同时进行。所以所有对于 SessionBase 的操作都需要加锁。

然后看 SessionMiddleware 就很简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void SessionMiddleware::process(RequestPtr req, std::shared_ptr<Response> &resp) {
static const std::string SESSION_COOKIE_NAME = "HANE_SESSIONID";

auto &sessionBase = SessionBase::getInstance();

auto cookies = req->getCookies();
auto sessionId = cookies->find(SESSION_COOKIE_NAME);

SessionPtr session;

if (sessionId == cookies->end()) {
session = sessionBase.newSession();
resp->cookies.putCookie({SESSION_COOKIE_NAME, session->getId()});
} else {
session = sessionBase.getSession(sessionId->second.value);
if (!session) {
session = sessionBase.newSession();
resp->cookies.putCookie({SESSION_COOKIE_NAME, session->getId()});
}
}

req->session = session;
}

如果根据 Cookies 中的 HANE_SESSIONID 找到了对应的 Session,则将其加入到 Request 之中。这时,SessionBase 中对应的 Session 如果在 oldGen,会被更新入 newGen

如果没有找到对应的 Session,则会新建一个 Session,然后把对应的 Session ID 加到响应的 cookie 列表中。

对于需要使用 Session 的网站,SessionMiddleware 可以作为 HttpServer 的主 Middleware

模板系统

虽然现在前后端分离很火,但服务器渲染总是不可淘汰的。

但 C++ 作为一门静态语言,做模版引擎总是比较难受的。既然 Middleware 部分基本跟 Java Servlet 一样的话,模板部分我也选择类似 JSP 将模板文件直接编译成 Servlet 的源代码的做法。

不过我最终的操作还是有一点不一样,由模板编译成的并不是直接的 Servlet,而是一个 Template 类的实现。

1
2
3
4
class Template {
protected:
virtual std::string render() = 0;
};

Template 类只有一个 render 函数,实际的模板都会去继承 Template 类,然后加上自己需要的属性。

比如一个注册用户的模板就会有这样一个类定义:

1
2
3
4
5
6
7
8
class UsersNewHtml : Template {
std::vector<std::string> errorMessages;

public:
UsersNewHtml() = default;
UsersNewHtml(const std::vector<std::string> &errorMessages) : errorMessages(errorMessages) {}
std::string render() override;
};

然后在模板文件中,就可以嵌入 C++ 代码了。

1
2
3
4
5
6
7
<% for (auto &message : errorMessages) { %>
<div class="alert alert-danger" role="alert">
<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
<span class="sr-only">Error:</span>
<%= message %>
</div>
<% } %>

光这样还不能用,需要有一个编译器把这段模板代码编译成 C++ 代码,作为实现。

这个编译器做的事情很简单,将 <% ... %> 的代码直接添加入代码,<%= ... %> 的代码则认为是表达式,直接加入到渲染的字符串中。

根据上面那一段模板代码会生成类似下面的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::string UsersNewHtml::render() {
std::string result;
for (auto &message : errorMessages) {
result += R"7elP4k3G(
<div class="alert alert-danger" role="alert">
<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
<span class="sr-only">Error:</span>
)7elP4k3G";
result += fmt::format("{}", message);
result += R"8KEg6Pl5(
</div>
)8KEg6Pl5";
}
return result;
}

将生成的 C++ 代码加入到编译列表中,在外面调用 UsersNewHtmlrender 就可以渲染出模板内容了。