简介:这份C++ CGI库源码包面向希望了解早期Web开发机制、练习C++网络编程与数据库访问的开发者。它把CGI请求处理、字符串操作、MySQL连接和Socket通信封装为可复用模块,涵盖请求方法识别、查询字符串与表单数据解析、HTML响应拼装、SQL访问及套接字收发等基础环节,可用于学习动态网页生成与服务器交互流程。压缩包共49个文件、约43KB,以17个cpp源文件和17个h头文件为主体,另含4个Makefile构建脚本及少量备份文件,模块划分覆盖CGI核心、HTML模板、TCP套接字、日志与MD5等工具。已有325人浏览学习。读者可观察CGI程序如何接收请求、连接数据库并组织响应,理解C++在网络服务端的工程组织方式,也能借鉴字符串工具、异常处理和构建脚本的写法,适合作为Web开发历史与C++网络编程的参考案例。
1. 别急着写路由:一个 C++ CGI 库真正替你挡掉的是什么
第一次用 C++ CGI 接表单的人,多半会卡在同一个画面:本地跑得好好的程序,扔进 cgi-bin 就返回 502,日志里只有一行 “malformed header”。问题不在业务代码,而在你手写了协议。一个 C++ CGI 库的价值,是把「读环境变量、按 CONTENT_LENGTH 读 stdin、拼头部、写 stdout」这套重复且极易写错的胶水封进 Request / Response 两个对象里,让你只关心「哪个路径、返回什么」。它适合三类人:手上已有大量 C++ 算法代码想直接暴露成 HTTP 接口的;要给内网工具加一个轻量管理页面的;以及被脚本语言运行时体积困扰、想用单个二进制交付的。它不适合做高并发 API 网关——读完第 6 章的进程开销数据,你自己就能判断边界在哪。
2. CGI 进程模型拆开看:从环境变量到 stdout 的完整链路
2.1 一次请求在 CGI 里走的七步
CGI 不是库,是约定。宿主 HTTP 服务器收到请求后,如果 URL 命中 cgi-bin 之类的映射规则,就 fork 一个子进程、exec 你的可执行文件,并把请求信息塞进环境变量,把请求体塞进子进程的 stdin;你的程序把响应头和响应体写到 stdout,进程退出,服务器把这段输出补上状态行转发给客户端。
这条链路上有七个环节,每一环都有坑:
- 服务器设置
REQUEST_METHOD、QUERY_STRING、CONTENT_TYPE、CONTENT_LENGTH、SCRIPT_NAME、SERVER_PROTOCOL、REMOTE_ADDR等变量; - 你的进程启动,此时 stdin 是一个管道,里面是请求体(GET 时为空);
- 库读取环境变量构造 Request;
- 库按
CONTENT_LENGTH指定的字节数从 stdin 读满请求体——注意是「读满指定字节」,不是「读到 EOF」; - 路由分发到你的处理函数;
- 处理函数往 Response 里写头字段和正文;
- 库把
Content-Type: ...\r\n\r\n加正文一次性写到 stdout,return 0。
理解第 4 步和第 7 步,能解决后面 80% 的疑难问题。stdin 不读到 EOF 是因为服务器可能复用管道或迟迟不关写端,读到 EOF 会直接挂住;stdout 必须是「头部 + 空行 + 正文」的裸格式,不能带 HTTP 状态行,因为状态行由服务器生成。
2.2 最小可跑:二十行接进工程
以这类库常见的三层结构(Request / Response / App)为例,不同实现类名会有差异,对照头文件改一下名字即可。
// main.cpp —— 一个入口,注册路由后交给库接管 #include "cgi/app.h" #include "cgi/response.h" #include <string> int main() { cgi::App app; // 注册一个 GET 端点;处理函数签名固定为 (const Request&, Response&) app.route("GET", "/hello", [](const cgi::Request& req, cgi::Response& res) { // query() 第二个参数是默认值:参数缺失时不会抛异常,也不会返回空指针 std::string name = req.query("name", "world"); res.set_header("Content-Type", "text/plain; charset=utf-8"); res.body("hello, " + name + "\n"); }); return app.run(); // 内部:解析环境变量 -> 分发 -> 拼头部 -> 写 stdout }编译成独立可执行文件,不需要链接任何 Web 框架:
# 头文件方式引入(若该库为 header-only,去掉 -L 和 -l 两段即可) $CXX -std=c++17 -O2 -Wall -Wextra \ -I./include -L./build \ -o hello.cgi main.cpp -lcgic # CGI 程序必须可执行,否则服务器 exec 失败,前端只会看到 500 chmod 755 hello.cgi逻辑说明:app.route的第一个参数是方法名,第二个是SCRIPT_NAME或PATH_INFO匹配的路径,具体用哪个要看库的实现——多数库先比SCRIPT_NAME,再把PATH_INFO交给路由做子路径匹配,这一点在后面讲 RESTful 风格路径时会再遇到。res.body()是追加写入,同一个处理函数里调用多次会拼接,不要误以为是覆盖。参数方面,-O2不是为了性能,而是减小二进制体积;-Wall -Wextra建议保留,这套代码里最容易出现的警告是「有返回值未处理」,往往对应真的漏了错误分支。
2.3 环境变量到请求对象的映射表
库把环境变量包装了一层,但底层语义没变。排查问题时对照这张表,比翻头文件快:
| CGI 环境变量 | 库里的取法(典型) | 常见误用 |
|---|---|---|
REQUEST_METHOD | req.method() | 大小写敏感,别和"get"比较 |
QUERY_STRING | req.query("k") | 原始串未解码,需要req.raw_query() |
CONTENT_TYPE | req.content_type() | 带; charset=和; boundary=,必须切分 |
CONTENT_LENGTH | req.content_length() | 无请求体时为空串,转数字要判空 |
SCRIPT_NAME | req.script_name() | 是 URL 里的程序路径,不等于文件系统路径 |
PATH_INFO | req.path_info() | 可能为空,路由要能吃空串 |
REMOTE_ADDR | req.remote_addr() | 前面有反向代理时是代理地址 |
HTTP_COOKIE | req.cookie("sid") | 多个 Cookie 以;分隔,值里可能有= |
提示:
CONTENT_LENGTH在 GET 请求里通常不存在,std::getenv返回空指针。直接std::stoul(nullptr)是未定义行为,必须先判空再转。
2.4 不用起服务器也能调:本地造环境跑一遍
调试 CGI 最省时间的做法是不装任何 Web 服务器,自己在 shell 里把环境变量摆出来,直接跑可执行文件。这是我每次改完解析逻辑必走的一步,比反复重启服务器快一个数量级。
# GET:查询串要写成百分号编码后的样子,模拟浏览器发出来的原始形态 export REQUEST_METHOD=GET export SCRIPT_NAME=/hello export QUERY_STRING='name=%E5%B0%8F%E7%8E%8B' export SERVER_PROTOCOL=HTTP/1.1 ./hello.cgi正确输出应该是「头部 + 空行 + 正文」三段,形如Content-Type: text/plain; charset=utf-8空一行再是hello, 小王。如果你看到的是完整 HTML 页面或一堆乱码,说明 stdout 被污染了,去看第 4 章。
POST 要额外配对CONTENT_LENGTH,而且长度必须按字节算:
BODY='user=abc&age=30' export REQUEST_METHOD=POST export CONTENT_TYPE='application/x-www-form-urlencoded' # 用 wc -c 取字节数;${#BODY} 在部分 locale 下数的是字符数 export CONTENT_LENGTH=$(printf '%s' "$BODY" | wc -c) printf '%s' "$BODY" | ./hello.cgi这里有个血泪经验:printf '%s' "$BODY"必须加引号并显式给格式串。写成echo $BODY会多一个换行符,长度对不上,程序读满 N 字节后把多出来的\n留在管道里,下一次读就错位了——在常驻模式下这类错位会串到别的请求上,非常难查。
3. 请求解析的三块硬骨头:查询串、表单体与 multipart
3.1 查询串:先切分、后解码,顺序反了就出乱码
QUERY_STRING是a=1&b=2形式,但它没有转义机制保护&和=——这两个字符在值里一定已经被百分号编码过。所以解析顺序必须是「先按&切、再按第一个=切、最后统一解码」。反过来先解码,值里原本编码的%26会变成真正的&,一刀切下去字段就散了。
#include <string> #include <vector> #include <utility> static int hexval(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; // 非法十六进制位 } std::string url_decode(const std::string& in) { std::string out; out.reserve(in.size()); for (size_t i = 0; i < in.size(); ++i) { char c = in[i]; if (c == '+') { out.push_back(' '); continue; } // 表单语义:+ 代表空格 if (c == '%' && i + 2 < in.size()) { int hi = hexval(in[i + 1]), lo = hexval(in[i + 2]); if (hi >= 0 && lo >= 0) { // 只解合法的两位转义 out.push_back(static_cast<char>(hi * 16 + lo)); i += 2; continue; } } out.push_back(c); // 非法转义原样保留,宁可露出问题也不要丢数据 } return out; } void split_query(const std::string& qs, std::vector<std::pair<std::string, std::string>>& out) { size_t i = 0; while (i <= qs.size()) { size_t amp = qs.find('&', i); if (amp == std::string::npos) amp = qs.size(); std::string kv = qs.substr(i, amp - i); if (!kv.empty()) { size_t eq = kv.find('='); // 只切第一个等号 std::string k = (eq == std::string::npos) ? kv : kv.substr(0, eq); std::string v = (eq == std::string::npos) ? "" : kv.substr(eq + 1); out.emplace_back(url_decode(k), url_decode(v)); // 解码放在最后 } i = amp + 1; } }逻辑说明:hexval返回 -1 表示不是十六进制位,这时不消费、不跳过,把%原样写出去,避免%zz这种脏数据导致少字节。split_query用i <= qs.size()而不是<,是为了让循环体处理最后一个&之后的尾段;虽然多跑一次空串,但被kv.empty()挡住了,代价可以接受。参数上,这个函数把结果追加到调用方传入的 vector,好处是能自然保留重复键——?tag=a&tag=b在多数业务里是数组语义,用map会悄悄丢一个。取单个值时用first或last需要你在库里明确约定,别让上层猜。
3.2 表单体:按 CONTENT_LENGTH 读,别用流到 EOF
application/x-www-form-urlencoded的请求体和查询串格式完全一样,可以直接复用split_query。真正的坑在读取这一步。
#include <istream> #include <string> #include <cstdlib> std::string read_body(std::istream& in, const char* content_length, size_t max_bytes) { if (content_length == nullptr) return {}; // GET / DELETE 常见 char* end = nullptr; unsigned long long n = std::strtoull(content_length, &end, 10); if (end == content_length || n == 0) return {}; // 空串或非数字 if (n > max_bytes) return {}; // 超过上限直接拒,别硬读 std::string body; body.resize(static_cast<size_t>(n)); in.read(&body[0], static_cast<std::streamsize>(n)); body.resize(static_cast<size_t>(in.gcount())); // 短读按实际字节截断 return body; }逻辑说明:三个校验缺一不可。判空指针防的是 GET;end == content_length防的是"abc"这种非数字;上限校验防的是有人发一个CONTENT_LENGTH: 99999999999让你一次resize把内存吃光,这是最廉价的 DoS。resize后立刻用gcount()回填长度,是因为客户端可能中途断开,read只读到一部分就置 failbit,此时按原始 N 截断会把一段未初始化内存当成请求体交给业务代码,后果比报错严重得多。
参数怎么定:max_bytes建议按业务分层,纯表单 64KB 到 256KB 足够,带文件上传的走 multipart 单独设上限(见 3.4)。不要用一个全局常量糊住所有端点,上传端点和登录端点用同一个限额,要么登录被限制,要么内存被放大。
3.3 multipart:边界、CRLF 与文件落盘
multipart/form-data是三者里唯一需要自己写状态机的。CONTENT_TYPE里带着boundary=----WebKitFormBoundaryXXXX,正文用这个边界分隔每一段,每段内部再是「头部 + 空行 + 数据」。
#include <string> #include <vector> struct Part { std::string name; // 表单字段名 std::string filename; // 有值说明是文件,空则是普通字段 std::string content_type; std::string data; }; std::vector<Part> parse_multipart(const std::string& body, const std::string& boundary) { std::vector<Part> parts; const std::string delim = "--" + boundary; size_t pos = body.find(delim); if (pos == std::string::npos) return parts; while (true) { pos += delim.size(); if (body.compare(pos, 2, "--") == 0) break; // 结束标记 --boundary-- if (body.compare(pos, 2, "\r\n") == 0) pos += 2; // 跳过段间换行 size_t head_end = body.find("\r\n\r\n", pos); // 头部与数据的分界 if (head_end == std::string::npos) break; std::string headers = body.substr(pos, head_end - pos); size_t next = body.find("\r\n" + delim, head_end + 4); if (next == std::string::npos) break; std::string data = body.substr(head_end + 4, next - (head_end + 4)); Part p; p.data = std::move(data); if (size_t n = headers.find("name=\""); n != std::string::npos) p.name = headers.substr(n + 6, headers.find('"', n + 6) - (n + 6)); if (size_t f = headers.find("filename=\""); f != std::string::npos) p.filename = headers.substr(f + 10, headers.find('"', f + 10) - (f + 10)); if (size_t c = headers.find("Content-Type: "); c != std::string::npos) p.content_type = headers.substr(c + 14, headers.find("\r\n", c) - (c + 14)); parts.push_back(std::move(p)); pos = next + 2; // 回到下一个 delim 起点 } return parts; }逻辑说明:循环顶部先pos += delim.size()跨过分隔符本身,再判断是不是--结尾;pos = next + 2从\r\n--boundary里跳过 CRLF 回到--boundary起点,这样下一轮顶部那次加法正好对齐。文件数据里如果恰好出现\r\n--boundary这段字节,会被误判为结束——这是 multipart 协议本身的局限,标准做法是让边界串足够随机,库在生成时保证这一点。
落盘时不要用客户端传来的filename拼路径。它在正文里出现的顺序、编码的字节序列完全由客户端控制。库里一般提供save_as(part, dir)之类的接口,底层做法是「在目标目录下用随机名创建文件 → 写入 → 把原始文件名另存为元数据」。参数上,filename只用于展示和下载时设置Content-Disposition,绝不能参与路径拼接。
3.4 上传相关参数怎么定
这套参数我在几个内网工具上反复调过,可以直接当起点:
| 参数 | 建议值 | 依据 |
|---|---|---|
| 单请求体上限 | 8MB(纯表单 256KB) | 超过后进程常驻内存翻倍,收益不抵风险 |
| 文件落盘阈值 | 1MB | 小于此值留在内存,避免小文件把磁盘 inode 打满 |
| 读取超时 | 5s~10s | 内网够用,公网慢速上传要单独放宽 |
| 单段文件名长度 | ≤ 255 字节 | 多数文件系统的单段上限 |
| 临时目录 | 独立分区 | 写满时不影响程序自身日志 |
注意:如果库是「一次把整个请求体读进
std::string」的实现,上面第一行的上限就是硬约束。想支持大文件必须确认它有没有流式接口,没有的话只能在服务器层限制请求体大小,让超限请求根本进不到你的进程。
4. 响应侧最容易翻车的地方:头部、状态码与输出缓冲
4.1 首行不是 HTTP/1.1 200 OK
这是最高频的错误,没有之一。写了十年 HTTP 的人第一次写 CGI 也会顺手写上状态行。
// 错误写法:把完整 HTTP 响应写到 stdout,服务器会认为头部格式非法 // std::cout << "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n"; // 正确写法:只输出头字段,空行之后是正文,状态行由服务器生成 std::cout << "Content-Type: text/plain; charset=utf-8\r\n"; std::cout << "Cache-Control: no-store\r\n"; std::cout << "\r\n"; std::cout << "ok\n";逻辑说明:CGI 的输出格式是「若干Name: value\r\n」+「\r\n」+「正文」。服务器解析到空行后开始转发正文。行尾必须是 CRLF 而不是单个 LF,有些服务器宽容,有些直接判 502,写死成\r\n最省事。头部字段名大小写不敏感,但约定俗成都用首字母大写形式。
char*字面量里混进\n是第二个隐蔽错法:"...utf-8\n"少了\r,看上去本地输出正常,一到服务器就报头部非法。用std::cout时建议所有头部一次性拼好再输出,减少部分写入的机会。
4.2 状态码、重定向与 Cookie 的三种写法
CGI 没有状态行可写,非 200 的响应靠约定的Status:头传递:
// 404:库里一般封装成 set_status,底层就是输出 Status 头 res.set_status(404); // -> "Status: 404 Not Found\r\n" // 302 重定向:Location 必须存在,且正文可以为空 res.set_status(302); res.set_header("Location", "/login?from=%2Fadmin"); // 有些服务器只认 Location 就自动切 302,但显式给 Status 更稳 // Cookie:多个值用同名字段重复输出,不要自己用逗号拼 res.set_header("Set-Cookie", "sid=abc123; Path=/; HttpOnly; SameSite=Lax"); res.set_header("Set-Cookie", "theme=dark; Path=/; Max-Age=86400");逻辑说明:Status的值是「三位数字 + 空格 + 原因短语」,原因短语可以省略但建议带上,某些客户端解析器依赖它。Set-Cookie的坑在于它是一个「多值头」——HTTP 规定同名头可以重复出现,但Set-Cookie的值内部又用;分隔属性,所以绝对不能把两个 Cookie 用逗号或分号拼成一个头,否则客户端只会认第一个。多数库的set_header是追加而不是覆盖,如果你的库是覆盖语义,需要找它对应的add_header接口。
HttpOnly和SameSite建议默认带上。内网工具也一样,管理页面的会话 Cookie 一旦被 XSS 读走,等于把后台交出去。
4.3 输出缓冲:什么时候必须自己算 Content-Length
不写Content-Length时,服务器有两种处理方式:对 HTTP/1.1 客户端改用 chunked 编码,或者先把整个输出缓冲起来自己算长度。前者对客户端友好,后者会让你的响应在服务器内存里多待一会儿。
需要自己算长度的场景只有三种:要显示下载进度条、要输出超大文件、或者客户端是不支持 chunked 的老设备。前两种场景意味着你要走流式输出:
// 流式输出大响应:明确给长度,边生成边写,写完立刻 flush res.set_header("Content-Type", "application/octet-stream"); res.set_header("Content-Length", std::to_string(file_size)); res.flush_headers(); // 先落地头部,客户端可以开始接收 char buf[64 * 1024]; size_t n; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { res.write(buf, n); // 追加到输出缓冲 res.flush(); // 每块都推出去,避免内存堆积 }逻辑说明:flush_headers和flush是两个不同动作,前者在第一次真正写出时发生且只发生一次,后者可以重复调用。如果库没有这两个接口,说明它是「全缓冲」实现,整份响应在内存里拼完再输出,那就老老实实设上限,别指望流式。参数上,64KB 是管道缓冲区常见的整数倍,能减少系统调用次数;再大也不会显著变快,因为瓶颈在写端。
反过来,如果你不设Content-Length又手动flush,就变成既没长度也没法 chunked 的尴尬状态,服务器可能缓冲全部再发,也可能出错,别这么干。
4.4 stdout 只走响应,日志全部走 stderr
CGI 进程的 stdout 是「协议通道」,任何计划外的输出都会污染头部。最常见的三种污染源:std::cout调试语句忘了删、某个第三方库往 stdout 打印版本信息、printf和std::cout混用导致缓冲区乱序。
#include <cstdio> #include <iostream> // 日志一律走 stderr,服务器会把它收进错误日志 void log_line(const char* level, const std::string& msg) { std::fprintf(stderr, "[%s] %s\n", level, msg.c_str()); std::fflush(stderr); // stderr 通常无缓冲,显式 flush 更保险 } // 如果必须用 iostream,进来第一件事是把它和 C 的缓冲区解绑 int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr); log_line("INFO", "cgi start"); // ... }逻辑说明:sync_with_stdio(false)让 C++ 流不再和 C 的FILE*共享缓冲区,速度更快,代价是两者输出顺序不再保证——所以要么全用cout,要么全用printf,别混。日志走 stderr 的好处是它不受 CGI 协议约束,服务器会把它和自身错误日志合并,出问题时在一处就能看到前后文,不用去翻两个文件。
注意:stderr 写入过多也会阻塞。如果单次请求日志量可能超过管道缓冲区(通常 64KB),考虑改成写文件而不是 stderr,否则进程会卡在写日志上,表现为「请求超时但 CPU 空闲」。
5. 避坑与排查:五条把服务打挂的血泪记录
5.1 页面一直转圈,最后 504,日志里什么都没有
现象:提交表单后浏览器转圈十几秒,服务器返回 504,你自己的日志一个字都没打出来。
原因:处理函数里读请求体读到了 EOF,或者根本没读。前者发生在你自己写解析、用while (in.good())循环读管道的时候——服务器不关写端,good()一直是真,循环永远不结束。后者是POST请求体留在管道里没消费,进程退出时某些服务器认为「响应不完整」,把连接挂在那里等。
解决:请求体只按CONTENT_LENGTH读固定字节数,读完就停。写成in.read(buf, n)一次读完,不要用while循环拼。如果确实需要分块读,循环条件必须是「已读字节数 < N」,而不是流状态。修完后顺手在main开头打一行 stderr 日志,确认进程真的起来了。
5.2 中文全变问号,或者返回的内容被截断
现象:浏览器显示ä½ å¥½或一串????,接口返回的 JSON 少半截。
原因:三个独立问题撞在一起。环境变量和请求体里的中文是百分号编码的 UTF-8 字节序列,你没解码就往外拼;Content-Type里没写charset=utf-8,浏览器按本地编码猜;长度用了字符数而不是字节数,中文被按字节截断。
解决:解码放在切分之后(见 3.1);所有文本响应显式写charset=utf-8;长度一律用std::string::size(),它返回的就是字节数。排查时用xxd看实际输出的字节序列,%E4%BD%A0解出来应该是E4 BD A0三个字节,对得上就说明解码没问题,问题在传输层。
5.3 小文件正常,超过几兆就变成空文件
现象:上传 100KB 的图成功,上传 5MB 的视频得到一个 0 字节文件,程序没有任何报错。
原因:服务器层有请求体上限,超限的请求被截断或直接拒绝,但你的程序按CONTENT_LENGTH去读时只读到一部分,gcount()小于预期,你resize回填后得到一个短 body,parse_multipart找不到结束边界,返回空数组——中间没有任何异常,因为每一步都「合法」。
解决:读完立刻校验body.size() == n,不等就返回 413 并记录实际字节数。同时在服务器配置里把上限调到和业务匹配的值,两个数字要对齐,否则一边放宽一边收紧,排查时会被误导。上传大文件前先确认库有没有流式解析接口,没有就别硬撑。
5.4 偶发 500,重放就正常
现象:一天出现几次 500,同样的请求重发就成功,日志干净。
原因:处理函数里抛了异常,App::run()没有兜底,异常穿过main导致进程以非零码退出。服务器看到子进程异常终止且没有输出完整头部,只能返回 500。因为没输出任何内容,你的日志也没来得及 flush。
解决:在分发那一层加try/catch(...),捕获后输出一个合法的 500 响应(带Status: 500头和简单正文),并把what()写 stderr。别指望catch(std::exception&)能覆盖全部,std::string的substr越界抛的是std::out_of_range(能被捕获),但有些库会抛非标准异常。同时把「进程启动时打一行日志」变成惯例,这样能区分「没起来」和「起来后崩了」。
5.5 压测一上就大量 CLOSE_WAIT,进程数暴涨
现象:几十个并发就出现大量僵死进程,系统负载飙升,请求大面积超时。
原因:一请求一进程的模型下,每次请求都 fork + exec,进程创建本身有成本,而如果父进程没有正确回收子进程(waitpid),会留下僵尸。这不是你的库的问题,是 CGI 模型的天花板,但表现得很像「库有 bug」。
解决:先量出单次调用的成本(第 6 章有脚本),确认是模型问题还是自己代码慢。并发真的上来了,只有两条路:把业务改成常驻进程(FastCGI 模式),或者把重活挪到独立服务里,CGI 只做转发。别在这个模型上做连接池、线程池之类的优化,收益和复杂度不成比例。
6. 常驻化与压测:把 CGI 的进程开销算清楚再决定
6.1 先把 fork+exec 的成本量出来
在讨论「要不要上常驻」之前,得先有一个自己环境下的数字。不需要装压测工具,用一段 Python 直接 fork/exec 那个可执行文件,模拟服务器的调用方式,测出来的就是 CGI 模型的下限。
import os, subprocess, statistics, time # 完全模拟服务器设置的环境,别漏 GATEWAY_INTERFACE ENV = { "REQUEST_METHOD": "GET", "QUERY_STRING": "name=test", "SCRIPT_NAME": "/hello", "SERVER_PROTOCOL": "HTTP/1.1", "GATEWAY_INTERFACE": "CGI/1.1", } def once(): t0 = time.perf_counter() p = subprocess.run(["./hello.cgi"], env={**os.environ, **ENV}, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5) dt = (time.perf_counter() - t0) * 1000 assert p.returncode == 0, p.stderr.decode("utf-8", "replace")[:200] assert p.stdout.startswith(b"Content-Type"), "头部缺失,stdout 被污染" return dt samples = sorted(once() for _ in range(200)) print(f"p50={statistics.median(samples):.2f}ms " f"p95={samples[int(len(samples) * 0.95)]:.2f}ms " f"max={samples[-1]:.2f}ms")逻辑说明:once()里两个断言就是前面几章所有坑的自动检查——非零退出码说明异常没兜住,stdout 不以Content-Type开头说明头部格式错了或被日志污染。取 200 个样本去掉预热影响,看 p95 而不是平均值,因为偶发的慢请求才是真正影响体验的。参数上timeout=5是必须的,没有它,一个卡在 READ 上的进程会让脚本永远挂着。
在一台普通开发机上,这个数字通常在 2 到 8 毫秒之间,其中绝大部分是 fork + exec + 动态链接的开销,你的业务代码往往只占零点几毫秒。也就是说,如果你的接口需要 200 QPS,CGI 模型光进程创建就吃满了,这时候讨论代码优化毫无意义。
6.2 常驻化改造:从 CGI 到 FastCGI 的三处改动
常驻化的标准路径是 FastCGI:进程启动后不退出,与服务器保持一条连接,循环接收请求帧。库里如果带了这个模式,改造只涉及三处,业务代码几乎不动。
第一处是请求来源。环境变量变成了帧里的 name-value 对,键名基本沿用 CGI 那套(REQUEST_METHOD、QUERY_STRING、CONTENT_LENGTH都在),库内部把它们塞进同一个 Request 对象,所以req.query()这类调用不用改。差别在REMOTE_ADDR可能变成REMOTE_ADDR加HTTP_X_FORWARDED_FOR组合,取真实 IP 的逻辑要跟着调整。
第二处是输出通道。不再是 stdout,而是FCGI_STDOUT帧,响应结束时必须发FCGI_END_REQUEST告知服务器这一轮完了。多数库的Response析构或run()内部会处理,但如果你手写了输出路径,漏发这个帧的表现是「请求一直挂着,服务器等不到结束标记」。
第三处是状态清理。一请求一进程时,全局变量天然干净;常驻之后,上一次请求写在static里的东西会带到下一次。最典型的是自己缓存的路由表、连接池、错误状态标志。改造时要做的第一件事是把所有static和全局变量过一遍,能挪进请求上下文的全挪进去。
# 常驻模式启动后,用同一段解析逻辑连续发三个请求,验证状态没串 export CGI_MODE=fastcgi export FCGI_LISTEN=127.0.0.1:9001 ./hello.cgi & # 用不同的 name 连续请求,三次返回必须互不影响6.3 上线前的验证清单
改造完别急着压测,先把下面这张表逐条走一遍。我踩过的坑里,有一半能在这张表上提前拦住:
| 检查项 | 通过标准 | 不通过的典型表现 |
|---|---|---|
| 头部格式 | 首行是Content-Type:或Status: | 502、malformed header |
| 空请求体 | GET 不读 stdin,直接返回 | 请求挂起直到超时 |
| 超大请求体 | 超过上限返回 413 且有日志 | 内存暴涨、OOM |
| 中文响应 | 字节序列为合法 UTF-8 | 乱码、JSON 解析失败 |
| 异常路径 | 抛异常仍返回合法 500 响应 | 连接被重置、日志空白 |
| 重复键 | ?t=a&t=b两个值都在 | 只剩一个 |
| 并发 50 | 无僵尸进程、无 CLOSE_WAIT 堆积 | 进程数持续上涨 |
| 常驻模式 | 连续 100 次请求结果一致 | 第 N 次开始返回上次的数据 |
最后一行是常驻化最容易被忽略的验证点。做法很简单:让处理函数把一个自增计数器写进响应体,发 100 次请求,如果看到计数器一直增长说明状态确实串了——注意,有时候串状态是「预期行为」(比如统计总请求数),关键是你要知道哪些该串、哪些不该。我一般会把这个自增测试保留在仓库里当回归用例,每次改动解析层都跑一遍。
还有个更隐蔽的验证点:请求中途断开。用脚本发一个 POST,声明CONTENT_LENGTH: 1000,实际只写 300 字节就关连接。合格的表现是进程在读超时后干净退出(或返回 400),不合格的表现是永远阻塞在那次read上,把连接池里的名额占死。这个场景平时遇不到,一旦碰上就是凌晨三点的故障。
从那以后我每次接一个新的 C++ CGI 库,第一件事都不是写业务路由,而是先用 shell 手工造环境跑通 GET 和 POST 两条路径,把头部字节用xxd看一眼,再跑一遍并发脚本拿到基线数字。这三步加起来不到二十分钟,能省掉后面几天的排查。希望帮到你。
本文还有配套的精品资源,点击获取