包装开源项目作为自己的项目,来字节面试,这位同学现场翻车了......

事情背景

最近在 github 上找了一个开源的 C++ 版本的 http server 代码,如果你很好奇,为什么我会看起这个项目来,可以拉到文末。

项目地址:

GitHub - yhirose/cpp-httplib: A C++ header-only HTTP/HTTPS server and client library

这个项目在 github 上看起来挺流行的,有 7.4k 的 star 和 1.6k 的 fork,属于比较受欢迎的项目了。

深入地看了下该项目,有如下优点:

  • 代码整体风格和质量还不错,支持 C++ 11 语法;

  • 代码量不大,如果想在项目中使用,只要包含一个 httplib.h 头文件即可;如果你想做成动态引用库,作者也提供了一个工具,可以把这个项目切成 .h 和 .cpp 两个文件。

  • 支持 Windows 和 Linux 多平台。

  • 支持的 http 功能比较多,像 http multipart-data、http chunk 技术都能支持,同时提供 http client 和 server 端功能,且支持 https 功能。

我猜想,这个项目应该很受学生朋友的喜欢,毕竟十个校招同学有九个项目是一个 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 响应,然后将数据发出去,如果某个路由未设置,则走默认错误处理路由。

项目存在的 bug

整个项目看起来非常的轻量,而且使用起来非常丝滑,从代码质量和风格来看,作者对 Modern C++ 语法写的也比较溜。

但是,整个项目存在两个比较严重的 bug,我们来看挨个看一下。

bug 1

首先是收数据的地方:

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 请求的部分数据,然后再过一段时间再发部分数据,此时,我们需要一个定时器,在客户端连接成功后设置该定时器,如果在规定时间内未收到期望的数据,触发定时器逻辑,断开连接,节约资源。这也是 Nginx 中的做法,甚至在 Nginx 中有新的客户端连接上来时, Nginx 连相关的对象都不创建,一直到该客户端发来第一组数据,这是提高性能的一种策略,为的就是防止那些无效连接(只连接不发数据或者连接了乱发数据的客户端)。

bug 2

我们再来看一下组装好好 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 或者硬伤,在面试的时候要能说的清楚项目的原理和一些细节。

项目经验和基础哪个更重要:

目前人在大厂做 C++ 开发,需要注意的是,即使是 C++ 面试,如果你是应届生,想进大厂,应该优先好好准备算法和数据结构知识以应对面试,这是大型互联网公司面试频率最高的考察范围。项目经验不是必需的,算法和数据结构才是考察的重中之重。

我分享一下我的算法题库 + 整理了一些常见的大厂算法题与面经:

链接: https://pan.baidu.com/s/1S0tvj783vzd1n7C1RVw18A 提取码: 6uv7

通常算法这块的题目并不难,但是一定要在面试前好好准备一下。

原文首发于【CppGuide】公众号,原文链接:

包装开源项目作为自己的项目,来字节面试的同学现场翻车了......

CppGuide 学习资料

小方学习和使用 C/C++ 开发快 13 年了,目前在大厂做架构,面试和指导百人成功找到满意的 C/C++ 岗位。

小方深知新手学习 C/C++ 的重要性和疑难问题,因此特地给 C/C++ 开发的同学精心准备了一份优质学习资料————CppGuide,内容从 C/C++ 语言、网络编程、操作系统原理到完整的项目源码分析,同时这份资料也包括 C/C++ 学习方法、推荐的阅读书籍、简历指导和求职技巧等。

CppGuide 学习资料

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值