最近在 github 上找了一个开源的 C++ 版本的 http server 代码,如果你很好奇,为什么我会看起这个项目来,可以拉到文末。
项目地址:
https://github.com/yhirose/cpp-httplib
这个项目在 github 上看起来挺流行的,有 7.4k 的 star 和 1.6k 的 fork,属于比较受欢迎的项目了。
深入地看了下该项目,有如下优点:
我猜想,这个项目应该很受学生朋友的喜欢,毕竟十个校招同学有九个项目是一个 XX 版高性能 WebServer。
该项目的 README.md 中给了很多的例子,使用这个项目也很简单,我们以这个项目的自带例子为例:
#include <chrono>
#include <cstdio>
#include <httplib.h>
using namespace httplib;
int main(void) {
Server svr;
svr.Get("/", [=](const Request & /*req*/, Response &res) {
res.set_redirect("/hi");
});
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!\n", "text/plain");
});
// 设置错误处理路由(如404页面)
svr.set_error_handler([](const Request & /*req*/, Response &res) {
const char *fmt = "<p>Error Status: <span style='color:red;'>%d</span></p>";
char buf[BUFSIZ];
snprintf(buf, sizeof(buf), fmt, res.status);
res.set_content(buf, "text/html");
});
svr.listen("0.0.0.0", 8080);
return 0;
}
以上数行代码就建好了一个 http server,我们启动后,用浏览器发一个 http 请求看下效果:
如果我们访问一个不存在的路径,则会显示一个 404 页面:
这个项目整个结构很精炼,我来介绍下。
主线程的逻辑(从 main 函数开始):
工作线程是一个循环,其流程如下:
for (;;) {
std::function<void()> fn;
{
std::unique_lock<std::mutex> lock(pool_.mutex_);
// 1. 等待条件变量被唤醒
pool_.cond_.wait(
lock, [&] { return !pool_.jobs_.empty() || pool_.shutdown_; });
// 2. 队列不为空时,条件变量被唤醒,从队列中取出任务
fn = pool_.jobs_.front();
pool_.jobs_.pop_front();
}
// 3. 执行任务
fn();
}
fn()
是被放入队列中的任务,实际指向 process_and_close_socket(sock)
函数,由于连接已经建立,所以在这个函数中读取数据,然后解析 http 请求报文,然后根据设置的 http 路由进行处理,在路由处理函数中组装 http 响应,然后将数据发出去,如果某个路由未设置,则走默认错误处理路由。
整个项目看起来非常的轻量,而且使用起来非常丝滑,从代码质量和风格来看,作者对 Modern C++ 语法写的也比较溜。
但是,整个项目存在两个比较严重的 bug,我们来看挨个看一下。
首先是收数据的地方:
bool Server::process_request(Stream &strm, bool close_connection,
bool &connection_closed,
const std::function<void(Request &)> &setup_request) {
// 1. 分配一块内存缓冲区
std::array<char, 2048> buf{};
detail::stream_line_reader line_reader(strm, buf.data(), buf.size());
// 2. 利用buf缓冲区从socket中收取数据
if (!line_reader.getline()) { return false; }
// 无关代码省略...
}
其中 line_reader.getline()
函数的实现如下:
bool stream_line_reader::getline() {
glowable_buffer_.clear();
for (size_t i = 0;; i++) {
char byte;
// 在这里调用 socket recv函数收取数据,
// 注意这里的sock是非阻塞socket
auto n = strm_.read(&byte, 1);
if (n < 0) {
return false;
} else if (n == 0) {
if (i == 0) {
return false;
} else {
break;
}
}
append(byte);
if (byte == '\n') { break; }
}
return true;
}
不知道读者是否看出上述代码的 bug ?
作者的本意是,由于 socket 是非阻塞的,所以在一个死循环(注意上述代码中 for
循环没有退出条件)中收取数据,一直收到 \n
结束(http 的头每一行都以 \r\n
结束),所以收到一个 \n
就可以认为收到了一行,这也是函数 getline
的含义。
但是这个存在一个问题,这样在一个循环里面收取数据,如果收不到 \n
或者过了很久才收到 \n
,那么这个任务就不会结束,一直在占据着某个工作线程,这样如果当这样的请求数等于工作线程数时,线程池就被占满了,再也无法处理新的 http 请求了。这种场景很容易模拟,只要 http 客户端建立连接后,先发了 http 请求头的几个字符,然后 sleep 几秒再接着发,多几个这样的客户端,这个 http server 就卡住了。
那么正确的做法应该怎么做呢?我们应该要处理以下情形:
\r\n
),我们需要给当前已经接收到的数据设置一个上限,超过该上限时还没收到特定的分隔符,认为请求非法,断开连接;我们再来看一下组装好好 http 响应然后发送的逻辑:
bool Server::write_response_core(Stream &strm, bool close_connection,
const Request &req, Response &res,
bool need_apply_ranges) {
// 发送http头
if (!detail::write_headers(bstrm, res.headers)) { return false; }
// 发送http body
auto &data = bstrm.get_buffer();
detail::write_data(strm, data.data(), data.size());
}
我们来看 detail::write_data
的实现:
bool write_data(Stream &strm, const char *d, size_t l) {
size_t offset = 0;
while (offset < l) {
//strm.write中调用socket send函数
auto length = strm.write(d + offset, l - offset);
if (length < 0) {
return false;
}
offset += static_cast<size_t>(length);
}
return true;
}
这里存在的问题是,在网络编程中,当我们有数据需要发送时可以直接发送,但是如果数据因为对端 TCP 窗口太小发不出去时,我们应该将数据缓存起来,并注册监听 socket 可写事件,在下一次可写事件触发时,我们接着发数据,一直到数据发完为止,这个库中缺少这样的逻辑,所以程序是不健壮的。
网络编程中,如何收取和发送数据正确的姿势,可以参考我之前写的这篇文章《网络通信中收发数据的正确姿势》。
因此,这个项目如果用在商业项目或者面试中,一定要记得把 bug 修改掉。
最后,也向库的原作者表示感谢,代码写得不错,如果优化一下就更好了。
有读者很好奇,为啥我会突然分析起这个 http 库?因为某位同学最近来我们公司面试,而且还把这个库包装成了自己的项目,然后在我的质疑两连问中暴露出网络编程知识的短板......
虽然该同学当场翻车了,但是请不要气馁,江湖路远,补缺补差有机会再战。
另外,提醒下广大需要面试的读者朋友:引用开源项目须谨慎,尤其是面试和作为商业使用,一定要认真阅读,看看是否有严重 bug 或者硬伤。
关注我,更多有趣实用的编程知识~
下篇预告
《如何调试MySQL Server 8.0 代码》。
推荐阅读
如果想加入 高质量 C++ 技术交流群 进行交流,可以先加我微信 easy_coder,备注"加微信群",我拉你入群。
原创不易,点个赞呗