这篇文章是回答一位小方说服务器开发知识星球的球友的问题,他的问题是:
秋招找C++后台开发该准备什么样的项目比较好呢?
目前有两个想法:
1. 做一个小型的web服务器;2. 做一个简易版的数据库。
请问各位大佬有没有推荐的项目啊
以下是小方的回答:
目前人在大厂做 C++ 架构,面试的应届生不下于百人,而面试时以一个 web 服务器作为项目经历的学生挺常见的。不是说 web 服务器作为项目不行,但是有一些注意事项。我给你讲两个同学以 web 服务器作为项目的故事,希望对你有帮助。
一、A 同学的 Web 服务器项目

A 同学的项目就是一个 Web 服务器,看简历上的描述挺高端的。
作为面试官,我在实际面试中问了该同学以下问题:
1. 介绍一下整个服务的程序结构。该同学的描述是主线程开启监听 socket 之后,进入无限循环调用 accept 处理客户端连接,accept 返回新的客户端 socket 后封装成任务交给线程池处理,线程池的线程共用一个队列,当有任务产生时,从任务队列中取出任务执行。我首先询问了一下,主线程如何通知工作线程有任务,该同学说使用条件变量,并且每次只唤醒一个工作线程,此时我扩展了一下问题,假设我某次投递了 N 个任务,我想同时唤醒 N 个线程(N 小于工作线程数目),这样要如何设计?
2. 接着我对他关于程序结构的描述提出了质疑,如果 accept 之后就将客户端 socket 封装成任务交给线程池处理,此时严格来说是没有任务需要执行的,因为客户端 socket 上不一定有数据需要收发,如果有数据需要收发,任务线程如何处理?如果在工作线程中将客户端 socket 挂载到某 IO 复用函数上去,那么为了保证效率,这些任务就常驻线程池了,这样几个连接之后,线程池的所有线程都被占用了,无法继续处理其他任务了。在我提出这些质疑后,这位同学给不出合理的解释。
3. 既然是 Web 服务,那么解析 HTTP 数据包是一个核心功能,我询问了该同学 HTTP 协议的格式,该同学清楚 HTTP 包头和包体如何分界,当时当我问到,HTTP 是基于 TCP 协议的,TCP 是流式协议,包头可以通过 \r\n\r\n 确定边界,包体如何确定边界呢?该同学说不清楚。我接着又问,既然是 HTTP 协议,那么肯定可以处理 GET 和 POST 请求,那么 GET 请求和 POST 请求有什么区别,你在处理的时候,如何区分的,分别又是如何解包的,该同学只能说出 GET 请求的参数放在 URL 后面,但是说不清楚 POST 请求的数据放在哪里,如何确定数据长度。这里实际是对 HTTP 协议格式的考察。
4. 项目中说这个服务是“高性能的”,可以支持到上万 QPS,我接着问,你是如何做压测的,压测的服务所在的配置如何,最后该同学告诉我,该项目其实只是单纯的支持上万连接,而不是上万 QPS,谈到连接,我让该同学介绍下 Socket 编程服务端和客户端的基本流程,该同学答出来了,接着我问了一个细节问题,服务端需要调用 bind 一个端口号,如果不 bind 会怎样?客户端通常不需要 bind 一个端口号,但是如果调用 connect 函数前,我们调用 bind 函数绑定一个端口号,会怎样?该同学答不上来。
5. 项目中提到 Proactor 模式,我询问他这是一种什么样的模式。然后,我说据我所知,一般 Reactor 模式用得更多,那你知道 Proactor 和 Reactor 模式有什么区别吗?该同学答不上来。在解释 Proactor 模式的时候,该同学提到了 IO 复用函数,我询问他知道哪些常用的 IO 复用函数,该同学介绍了 select、poll、epoll,我接着询问这三个 IO 复用函数的使用场景和优缺点,该同学答对后,我问了下 epoll 模型的水平和边缘触发模式的区别。接着,我给出一个具体场景,假设我某个客户端 socket 绑定到 epollfd 上后使用边缘触发模式,现在该客户端发来了 100 个字节,是否会触发读事件;服务端收了 50 个字节,读事件会在下一轮中继续触发吗?假设接着客户端又发了 10 字节,此时服务端会触发读事件吗?
6. 我接着询问了该同学使用何种 IDE 开发的该项目,于是问了一些该 IDE 的调试命令,该同学不熟悉。
7. 接着我问该同学是否熟悉一些网络调试命令,如如何查看一个服务监听端口已经开启、当前连接信息,是否会使用 tcpdump 等抓包工具等等。
不知道,这些问题你是否能答上来。
根据该同学的面试表现,我怀疑该同学是否有深度参与过这个项目,可能只是把别人的东西拿来借用一下。另外,实际考察中发现该同学不熟悉 HTTP 协议,网络编程的基础勉强及格,实践(调试)经验不足。所以,这个项目写在简历中起到了相反的作用,面试不通过。
二、B 同学的 Web 服务器项目
原来的项目地址:
https://github.com/yhirose/cpp-httplib
深入地看了下该项目,有如下优点:
代码整体风格和质量还不错,支持 C++ 11 语法;
代码量不大,如果想在项目中使用,只要包含一个 httplib.h 头文件即可;如果你想做成动态引用库,作者也提供了一个工具,可以把这个项目切成 .h 和 .cpp 两个文件。
支持 Windows 和 Linux 多平台。
支持的 http 功能比较多,像 http multipart-data、http chunk 技术都能支持,同时提供 http client 和 server 端功能,且支持 https 功能。
这个项目应该很受学生朋友的喜欢,例如 B 同学就使用了这个项目进行改造写进自己的简历中。
项目使用
该项目的 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,且面试时不能解释清楚为什么这么做,所以最终也没能通过面试。这位同学把这个库包装成了自己的项目,然后在面试中暴露出自己网络编程知识的短板......
三、在哪里可以系统地学习到上述知识?
看了上述内容后,有同学问,那上面的知识,可以在哪里系统地学习到呢?
这些知识一方面是经验的积累,另外一方面可以通过阅读相关书籍获得,我给你推荐两本书:
尹圣雨《TCP/IP 网络编程》
如果你从来未接触过网络编程,或者想找一本网络编程入门书籍,那么我建议你选择尹圣雨的《TCP/IP 网络编程》。这本书的特点是:
针对零基础读者,讲解了什么是网络编程(Socket 编程);
详细地介绍 Socket 编程中常用的各种 API 函数的用法和注意事项;
详细地介绍了常用的网络模型(select、poll、epoll 等);
书中的代码都比较短小和具有典型性,适合初学者一边阅读,一边自己上机操作;
包括 Windows 和 Linux 两个平台的常用 socket 函数。
游双《Linux 高性能服务器编程》
这本书不是完全讲网络编程的,但是整个书的是以网络框架设计串起来的,在这本书中你将学到如何利用基础的 socket 函数和网络模型开发性能高的服务程序框架,尤其是多线程模式下,我最早就是从这本书中学到 Reactor 和 Proactor 模式的。
另外,这本书的第一篇有四章内容,讲解了 TCP/IP 协议栈的内容,但是与一般的计算机理论书籍不同的是,这四章是利用 nc、tcpdump、iptables 等网络工具对协议栈的数据包进行抓取和分析。如果你在计算机网络理论方面存在如下问题,那么建议好好跟着前四章实践一遍,之后,你会觉得这些知识都通透了:
如对 TCP 三次握手四次挥手一直处于理论状态,记不住三次握手和四次挥手具体过程,总是记不住 SYN、ACK 等数据包顺序,总是记不住 CLOSE_WAIT 、TIME_WAIT 等状态;
总是对 TCP 滑动窗口、流量控制、TCP 重传有个模糊的概念,详细的又讲不清楚;
connect 函数动作发生在三次握手之前还是之后,accept 函数动作发生在三次握手之前还是之后?
总是对 TCP、IP 、ARP 等协议格式不清楚;
不知道如何利用网络命令去调试网络问题,不知道如何抓包。
这是一本从事 Linux C/C++ 开发必读之书。
我刚工作那会儿,做股票行情服务器的底层服务开发,需要熟悉网络编程,那会儿天天下班抱着这本书看,建议小白把书中的网络通信代码都自己敲一遍。
我们面试一些同学时,发现很多同学写的网络通信程序在本机测试没问题,一拿到局域网或者测试环境就不能正常工作,这本书也会告诉你答案。
关于面试中常见的网络编程题和考察知识点,我专门总结过一篇文章《网络通信题目集锦》。我在知乎也专门开设过一个 Live 讲解如何回答这些题目,有兴趣的同学可以点这里:
轻松搞定技术面试中常见的网络通信问题(https://www.zhihu.com/lives/922110858308485120)
相关阅读:
-
如何设计断线自动重连机制 -
心跳包机制设计详解 -
Linux 网络故障排查的瑞士军刀 -
Linux tcpdump 使用介绍 -
服务器开发通信协议如何设计 -
【腾讯后台开发】实习生技能要求 -
从零实现一个 http 服务器 -
服务器开发中网络数据分析与故障排查经验漫谈
四、给同学们选择面试项目的一点建议
所以我的建议是,对于应届生,无论是引用开源项目还是自己的项目,一定要吃透项目,尤其是项目中不能有明显的 bug 或者硬伤,在面试的时候要能解释得清楚项目的原理和一些设计细节。
五、项目经验和基础哪个更重要:
目前人在大厂做 C++ 开发,需要内推可以点这里:
需要注意的是,即使是 C++ 面试,如果你是应届生,想进大厂,应该优先好好准备算法和数据结构知识以应对面试,这是大型互联网公司面试频率最高的考察范围。项目经验不是必需的,算法和数据结构才是考察的重中之重。
我分享一下我的算法题库 + 整理了一些常见的大厂算法题与面经:
链接: https://pan.baidu.com/s/1Igq2ZG06cFE0BRMxM_T6Sg 提取码: tjok
通常算法这块的题目并不难,但是一定要在面试前好好准备一下。
如果你也想得到专门的简历指导或者内推或者模拟面试的机会,可以加入小方的星球。
有读者好奇:小方说服务器开发知识星球简历review、模拟面试和职业指导到底是什么样子的?
通俗地说,小方会根据你要面试的岗位安排至少一次的模拟面试,然后给你一些针对性的改进和学习建议,并对你的简历和面试过程进行指导,直到你拿到 offer。
以下是部分截图:







图中两位同学,小方全程协助最终拿到offer。
总体来说,小方的星球提供五大服务:
优问优答
不定期的技术直播和录像
优质源码分享和指导
大厂内推、职业解惑、模拟面试和简历review
十一大技术专栏
其中 职业解惑、模拟面试和简历review 在星球有效期内是无次数限制的。
如果你想加入星球,原价 325/年,扫描下面的二维码可以立即优惠 100 元(当然,如果你是老读者,想得到更低的价格可以加小方微信 easy_coder 咨询,非诚勿扰):

如果你已经是球友,并且想提前续费,可以私聊小方(easy_coder)领取半价续费券。
觉得有用就点个【在看】 

