简介:这份资源是一个基于C++实现的CGI库源码包,面向对CGI机制感兴趣的C++开发者,尤其适合需要处理Web表单、动态页面和后台数据库交互的场景。库将CGI基础操作封装为可复用类,并集成字符串解析、MySQL访问和Socket通信模块,可帮助开发者快速构建C++ CGI程序。压缩包共49个文件,以17个cpp源文件与17个h头文件为主体,配有多份Makefile构建脚本和少量辅助文件,整体大小仅43KB,结构紧凑清晰。已有323人浏览学习。从源码构成看,资源涵盖CGI请求与响应封装、MySQL连接与查询、字符串工具、TCP/Socket封装等模块,代码量不大但覆盖面广,适合作为学习CGI工作原理和C++网络编程的参考案例。对希望了解早期动态网站技术或复用经典C++代码的开发者,有不错的参考价值。 先别急着把 CGI 这个词扔进故纸堆。这年头做 Web 后端的,不是 Node 就是 Go,再不然 Python 一把梭,C++ 写 CGI 听着确实像考古。但我在实际折腾过几个内部工具之后,反倒觉得这老古董在某些场景下比谁都香——没有运行时依赖、内存可控、启动即用,处理起高吞吐的小请求那叫一个干净利落。这篇文章就把我手头攒下来的一个 C++ CGI 库拆开讲讲,从协议原理到代码实现到线上踩坑,一次性说清楚。
1. 为什么还在用 C++ 写 CGI
1.1 不是所有服务都该上框架
很多朋友一听到“写个 Web 服务”,第一反应就是上 Spring Boot、上 Gin、上 FastAPI。但如果你只是要给内网运维平台写个状态查询接口,或者给嵌入式设备做个远程参数配置页面,这些重框架反而成了累赘:动不动几百 MB 的运行时、几十秒的冷启动、依赖传递能把人绕晕。
CGI 的思路很简单粗暴——Web 服务器接收请求,把请求信息通过环境变量和标准输入传给外部程序,程序把响应写到标准输出完事。进程生命周期就是一次请求的完整生命周期,没有常驻内存的服务进程,自然也就没有内存泄漏积累、没有连接池管理、没有并发踩踏问题。每个请求都是独立进程,崩了也就崩那一次请求,主服务纹丝不动。
我选择 C++ 而不是 Python 或 Shell 来完成这类 CGI 程序,核心考量有三个:一是启动速度,C++ 编译出来的二进制在毫秒级完成初始化,Python 光解释器启动都要几十毫秒;二是资源占用,静态编译完的二进制也就几 MB,扔到任何 Linux 机器上直接跑,连解释器都不用装;三是逻辑复杂度,当请求处理涉及到二进制协议解析、加密运算、大数组计算时,C++ 的类型系统和内存控制优势就体现出来了。
1.2 这个库到底解决什么问题
裸写 CGI 程序最痛苦的地方在于,所有脏活累活都得自己干:解析环境变量、读取标准输入、处理 GET 和 POST 的编码格式、拼装响应头、处理 Content-Type、URL 解码、Cookie 解析……这些代码每写一个新程序就得重新来一遍,繁琐而且容易出错。
我这个人比较懒,于是把这些公共逻辑抽出来做成了一个小库,提供一套干净的接口,让业务代码只关注自己的逻辑,不用碰协议细节。这个库不是一个完整的 Web 框架,它没有路由、没有模板引擎、没有 ORM,它就是一个处理 CGI 协议层的工具包——你只需要编译时候链接上它,然后写你的main()函数就行。
2. CGI 协议里的那些门道
2.1 环境变量才是请求的入口
CGI 程序启动后,Web 服务器(Apache、Nginx 配合 spawn-fcgi、或者轻量级的 lighttpd)会把请求的关键信息塞进环境变量。这里面最常用的有这些:
| 变量名 | 含义 | 典型示例 |
|---|---|---|
| REQUEST_METHOD | HTTP 请求方法 | GET / POST |
| QUERY_STRING | URL 问号后面的参数 | id=1024&page=2 |
| CONTENT_LENGTH | 请求体的字节长度 | 37 |
| CONTENT_TYPE | 请求体的媒体类型 | application/x-www-form-urlencoded |
| HTTP_COOKIE | 浏览器的 Cookie 头 | session=abc123 |
| HTTP_USER_AGENT | 浏览器标识 | Mozilla/5.0... |
| REMOTE_ADDR | 客户端 IP | 192.168.1.10 |
| PATH_INFO | URL 中脚本名之后的路径 | /user/detail |
getenv()函数可以直接拿到这些值,但它返回的是char*,如果变量不存在会返回nullptr,不处理就解引用直接崩溃。所以库里面我统一做了封装,全部转成std::string,不存在的变量返回空串,调用方不用再判空。
2.2 数据是怎么从服务器流进程序里的
POST 请求的数据不走环境变量,而是通过标准输入(stdin)传给 CGI 程序。需要注意的重点来了:读取 POST 数据必须严格按照 CONTENT_LENGTH 来读,不能读到 EOF 就停。因为 Web 服务器和 CGI 程序之间的管道是复用的,如果直接循环读标准输入,有可能会把下一次请求的数据(在 keep-alive 场景下)或者多余的管道数据一起读进来,造成数据污染。
正确做法是先std::getenv("CONTENT_LENGTH"),用std::atoi转成整数,然后按照这个精确的字节数去读。我这个库的读取逻辑长这样:
std::string Cgi::readBody() { const char* lenStr = getenv("CONTENT_LENGTH"); if (!lenStr || strlen(lenStr) == 0) { return ""; // GET 请求没有 body } int contentLength = std::atoi(lenStr); if (contentLength <= 0) { return ""; } // 做个明显大于实际值的上限保护,防止恶意超大请求 if (contentLength > MAX_BODY_SIZE) { return ""; // 拒绝超大请求体 } std::string body; body.resize(contentLength); size_t readTotal = 0; while (readTotal < size_t(contentLength)) { size_t chunk = fread(&body[readTotal], 1, size_t(contentLength) - readTotal, stdin); if (chunk == 0) { break; // 异常情况:数据不够 } readTotal += chunk; } body.resize(readTotal); return body; }这里有个细节值得新手注意:body.resize(contentLength)是为了提前分配好缓冲区,避免反复push_back导致多次内存拷贝。小请求体无所谓,但几 MB 的请求体如果一点一点 append,性能会肉眼可见地卡。
2.3 响应的格式讲究顺序
CGI 程序的响应结构很简单:先输出响应头,空一行,然后输出正文内容。这个"先头后体"的顺序是硬性要求,不能乱。
// 正确的响应格式 std::cout << "Content-Type: text/html; charset=utf-8\r\n"; std::cout << "Cache-Control: no-store\r\n"; std::cout << "\r\n"; // 必须空一行,分隔响应头和正文 std::cout << "<html><body>Hello, CGI!</body></html>";很多第一次写 CGI 的朋友会栽在这样的坑里:忘记输出\r\n\r\n这个分隔空行。少了它,服务器会把整个输出当成响应头来解析,然后报出莫名其妙的 500 错误。还有的人过早输出调试信息(比如std::cout << "debug"),结果把洋葱头串进了头信息里,同样会破坏协议格式。
3. 手写一个趁手的 C++ CGI 库
3.1 库的整体结构与类设计
我的库只放两个文件:cgi.h和cgi.cpp,不带任何第三方依赖,编译命令一行搞定。这种轻量级的结构本身就是一种优势——你可以直接把这两个文件扔进任何 C++ 项目里,不用cmake也不用vcpkg。
核心类叫CgiRequest,职责包括:
- 获取请求方法(GET / POST / PUT / DELETE 等)
- 获取查询参数(解析 QUERY_STRING)
- 获取 POST 表单数据(解析请求体)
- 获取请求头(Cookie、User-Agent 等)
- 便捷的 URL 解码功能
配套一个CgiResponse类,职责包括:
- 设置响应状态码(200、302、404 等)
- 设置响应头字段
- 输出重定向
- 输出 HTML、纯文本或 JSON
两个类加起来不到 500 行代码,但日常用到的功能都齐了。下面我逐个拆解关键实现。
3.2 查询参数解析与 URL 解码
查询参数是 URL 里?后面那串key=value&key2=value2格式的数据。解析逻辑不复杂,就是个字符串切割,但有两个容易出错的地方:
- 加号
+在 URL 编码里代表空格,解码时候要转成空格 - 百分号
%XX是十六进制转义,需要转换成对应 ASCII 字符
std::string urlDecode(const std::string& input) { std::string result; result.reserve(input.size()); for (size_t i = 0; i < input.size(); ++i) { if (input[i] == '+') { result += ' '; } else if (input[i] == '%' && i + 2 < input.size()) { int hexVal = std::stoi(input.substr(i + 1, 2), nullptr, 16); result += static_cast<char>(hexVal); i += 2; } else { result += input[i]; } } return result; }友情提示:如果客户端传的是中文内容,%后面跟的可能是多字节 UTF-8 的多个转义序列。这个解码函数不会破坏它们,因为每个字节独立解码,组合起来仍然是合法的 UTF-8 字符串。前提是你别在中间强制转成 Latin-1 或者 GBK,否则中文会乱码。我在CgiRequest里统一保持 UTF-8 编码,业务程序自己决定是否转换编码。
解析查询参数的核心代码:
std::map<std::string, std::string> CgiRequest::parseQueryString() { std::map<std::string, std::string> params; const char* qs = getenv("QUERY_STRING"); if (!qs || strlen(qs) == 0) return params; std::string query(qs); size_t start = 0; while (start <= query.size()) { size_t ampPos = query.find('&', start); std::string pair = query.substr( start, ampPos == std::string::npos ? std::string::npos : ampPos - start ); size_t eqPos = pair.find('='); if (eqPos != std::string::npos) { std::string key = urlDecode(pair.substr(0, eqPos)); std::string value = urlDecode(pair.substr(eqPos + 1)); params[key] = value; } if (ampPos == std::string::npos) break; start = ampPos + 1; } return params; }这里选的容器是std::map而不是std::unordered_map。虽然 unordered_map 的查找是 O(1),但在参数数量很少(一般不超过 10 个)的场景下,map 的红黑树 O(log n) 查找和它差不多快,而且 map 按键排序输出这个特性在调试时特别舒服。对 CGI 这种短生命周期进程来说,这点性能差距根本构不成问题。
3.3 POST 表单解析与文件上传的取舍
POST 的application/x-www-form-urlencoded格式和查询字符串基本一样,直接套用上面的解析函数即可。但还有一个更复杂的multipart/form-data格式——这是浏览器上传文件时用的格式。Content-Type 头会带一个boundary字段,用来分隔每个表单字段。
void CgiRequest::parseMultipartFormData(const std::string& body, const std::string& boundary) { std::string delimiter = "--" + boundary; size_t pos = 0; while (true) { size_t partStart = body.find(delimiter, pos); if (partStart == std::string::npos) break; partStart += delimiter.size(); // 跳过 \r\n if (partStart + 2 <= body.size()) partStart += 2; size_t partEnd = body.find(delimiter, partStart); if (partEnd == std::string::npos) break; std::string part = body.substr(partStart, partEnd - partStart - 2); // 去掉末尾\r\n // 拆分头信息和内容 size_t headerEnd = part.find("\r\n\r\n"); if (headerEnd != std::string::npos) { std::string headers = part.substr(0, headerEnd); std::string content = part.substr(headerEnd + 4); // 从 headers 里解析 name="..." 和 filename="..." // 从 content 里得到字段值或文件内容 } pos = partEnd; } }文件上传场景下,缓冲区大小就要特别注意了。如果你在 Web 服务器那边把client_max_body_size设成 200M,而 CGI 程序持有的内存只有几个 G,那几 M 的文件拷贝虽然没问题,但几十 M 的重复拷贝就会让内存吃紧。一个务实的策略:除非业务确实需要文件上传,否则就检查 CONTENT_TYPE 不是表单格式就直接拒绝。我在库里默认不解析 multipart,而是留给业务方自己调用一个独立的函数去解析,按需取用,避免所有请求都承担这份开销。
3.4 响应封装与状态码处理
CgiResponse实现起来简单,但有几个细节值得沉淀。首先状态码和响应头的关系要处理好:输出Location头时,状态码应该是 302 或者 301,不能是 200。有些新手会输出Location: /index.html同时状态码保持 200,浏览器虽然也能跳转,但语义不对,搜索引擎和调试都会受到困扰。
void CgiResponse::sendRedirect(const std::string& location) { setStatus(302); setHeader("Location", location); sendHeaders(); // 重定向响应不需要 body } void CgiResponse::sendJson(const std::string& jsonStr) { setHeader("Content-Type", "application/json; charset=utf-8"); setHeader("Cache-Control", "no-store"); sendHeaders(); sendBody(jsonStr); }另一个容易忽略的点是 JSON 接口场景下的 Content-Type。有些人偷懒统一输出text/html,前端用fetch拿回来后还要自己解析文本。规范输出application/json; charset=utf-8,前端可以直接response.json(),省一层转换。这些细节看似不起眼,但用起来会很顺手。
4. 代码串起来:一个完整的多参数 GET 接口
光说理论容易飘,直接上完整代码。下面这个例子实现了一个带两个参数的求和接口:/sum.cgi?a=10&b=32,返回 JSON 格式的结果。同时支持 POST 同名表单提交。
// sum.cpp #include "cgi.h" #include <iostream> #include <json/json.h> // 这里用了 jsoncpp,也可以自己拼接 JSON int main() { CgiRequest request; CgiResponse response; // 获取请求参数,GET 和 POST 统一入口 std::string aStr = request.Param("a"); std::string bStr = request.Param("b"); if (aStr.empty() || bStr.empty()) { response.setStatus(400); response.sendJson("{\"error\":\"missing param a or b\"}"); return 0; } try { int a = std::stoi(aStr); int b = std::stoi(bStr); int sum = a + b; response.sendJson("{\"a\":" + std::to_string(a) + ",\"b\":" + std::to_string(b) + ",\"sum\":" + std::to_string(sum) + "}"); } catch (const std::exception& e) { response.setStatus(400); response.sendJson("{\"error\":\"invalid number\"}"); } return 0; }Param函数是库里的便利函数:如果有 POST body 就优先从 body 里取,否则从 QUERY_STRING 里取。它内部做了一件事——把 GET 和 POST 的取值逻辑统一到同一个接口下,前端不用关心数据用什么方法传的,后端代码也不用写两套取值逻辑。
写到这里有个小心得想分享:参数校验绝对不能省。std::stoi对非法输入会抛std::invalid_argument异常,不捕获就进程崩溃。而 CGI 程序崩溃了,Web 服务器只会给浏览器返回 500,日志里还看不见有用信息。所以但凡是用户可控的输入,必须包一层 try-catch。
编译命令:
g++ -O2 -std=c++11 -o sum.cgi sum.cpp cgi.cpp -ljsoncpp把编译出来的sum.cgi放到 Web 服务器的 cgi-bin 目录,访问http://your-server/cgi-bin/sum.cgi?a=10&b=32,就能看到响应。
5. 环境配置与部署时容易踩的坑
5.1 本地调试用 Python 起一个 HTTP 服务器
没有现成的 Nginx 环境怎么调试?方案其实很简单——Python 自带的http.server就支持 CGI:
mkdir cgi-bin cp sum.cgi cgi-bin/ python3 -m http.server 8080 --cgi浏览器访问http://localhost:8080/cgi-bin/sum.cgi?a=10&b=32即可。注意cgi-bin目录名不能改,Python 的 CGI 处理器默认只认这个目录。如果改成scripts之类的名字,访问会直接 404。这个小坑在官方文档里写得不明显,我当初折腾了十分钟才发现。
调试模式加参数:
python3 -m http.server 8080 --cgi --bind 0.0.0.0这样局域网内的其他机器也能访问到调试服务,方便手机端测试。
5.2 Nginx 部署:fastcgi 协议还是纯 CGI
很多人一提到 Nginx 和 CGI,就自然会想到spawn-fcgi和 FastCGI。但需要说明的是:Nginx 原生并不支持直接的 CGI 协议,它只支持 FastCGI。想让 Nginx 跑 CGI 程序,有两条路:
- 用
fcgiwrap这个工具,它充当适配器,把 FastCGI 请求转成传统 CGI 请求,调用你的外部程序 - 直接改用支持 CGI 的 Web 服务器,比如 Apache 的
mod_cgi模块,或者 lighttpd
我自己测试一般直接上 Apache,一条命令就装好:
apt install apache2 a2enmod cgi systemctl restart apache2然后把编译好的sum.cgi复制到/usr/lib/cgi-bin/,访问http://your-server/cgi-bin/sum.cgi?a=10&b=32。Apache 默认对 cgi-bin 目录有执行权限,基本零配置就能跑起来。
商用环境我最常用的还是Nginx + spawn-fcgi + fcgiwrap组合。注意这里的配置有几个关键点需要强调。首先spawn-fcgi是管理 FastCGI 外部进程的工具,它负责把外部程序常驻在一个 socket 上,避免每个请求都有重新 fork 的开销。然后fcgiwrap再把 FastCGI 协议翻译成标准 CGI 协议,最终调用你的 C++ 程序。
spawn-fcgi的基础用法:
spawn-fcgi -a 127.0.0.1 -p 9000 -u www-data -g www-data -f /usr/lib/cgi-bin/sum.cgiNginx 配置:
location ~ \.cgi$ { root /var/www/html; include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin$fastcgi_script_name; }这种模式下 C++ 程序以常驻进程方式运行,不再是一个请求一个进程了。此时要格外小心:C++ 程序里的静态变量和全局缓存此时是跨请求共享的,必须加锁或者不用全局状态。我最早踩过这个坑,程序里放了个全局计数器,独立进程模式下每个请求都从 0 开始,一切正常;切到常驻模式后,计数器一直在涨,直接破防。
5.3 权限与目录的三大纪律
CGI 程序部署权限的重要性排第一。如果 Web 服务器以www-data用户运行,那么 CGI 程序的执行和读取权限必须让www-data有权限。实际操作中我踩过的坑是:编译出来的二进制权限是755 root root,这没问题;但如果你的输入依赖一个配置文件,而配置文件权限是600 root root,那么 CGI 程序以 www-data 身份运行时就根本没权限读这个文件,浏览器看到的就是 500 错误。
用ls -l检查一下你的cgi-bin目录内容:
ls -l /usr/lib/cgi-bin/ # -rwxr-xr-x 1 root root 21944 sum.cgi最好统一确保文件属于www-data:www-data或者至少其他用户有r和x权限。
还有输出缓冲。std::cout在 C++ 里是带缓冲的,但 CGI 程序不能在退出前故意冲刷缓冲区——因为优先级是反向的:如果你在代码里调用了std::endl,它会冲刷缓冲区,把输出立刻发送到服务器;而如果不刷新,内容会在main返回时统一 flush。两种方式都能工作。但如果你在输出响应头之后还要继续输出正文,切忌在头尾之间过早 flush,这可能导致服务器认为头结束了而把后面的正文当成下一个响应解析。
6. 常见问题排查实录
6.1 浏览器显示"500 Internal Server Error"一片空白
CGI 的 500 错误是最常见的,也是信息最少的。这个错误表示 CGI 程序根本没有完成一次合法的响应,或者直接崩溃了。排查顺序:
- 第一步,直接去命令行跑一下这个
.cgi程序,手动设置环境变量,看能不能正常输出。很多人跑./sum.cgi发现没有输出就懵了,实际上 CGI 程序需要REQUEST_METHOD等环境变量支持,否则会走异常分支崩溃 - 第二步,查看 Web 服务器错误日志。Apache 的日志在
/var/log/apache2/error.log,Nginx + fcgiwrap 的日志在/var/log/nginx/error.log。如果你是 Ubuntu 或 Debian,把日志文件尾部拉出来一看便知
tail -n 20 /var/log/apache2/error.log大部分 500 错误都是三类原因:可执行文件权限不对、动态链接器找不到.so库、代码运行到一半抛异常没捕获。前两类看日志直接定位,第三类就需要在关键调用点加try-catch兜底了。
6.2 请求参数是中文/特殊字符,拿到手全乱了
这个问题我帮好几个同事排查过。根源几乎都是 URL 编码的多次解码。浏览器端对中文做了encodeURIComponent,生成%E4%B8%AD%E6%96%87这样的转义;如果你在库里解码了一遍变成 UTF-8 字符串,然后业务代码又调用了一次urlDecode,那么百分号已经被替换成字符了,第二次解码会把原有的%状态搞乱,中文就变成乱码。
排查思路很简单:在拿到参数后先用日志打印十六进制字节值,确认是 UTF-8 正常编码还是被二次转义了。如果%E4%B8%AD%E6%96%87在你的日志里显示为%25E4%25B8%25AD,那就是多解码了一次。
6.3 本地 curl 测试成功,浏览器访问就失败
这个情况我遇到过两次。一次是响应头没写Content-Type,curl 不在乎,但浏览器对没有 Content-Type 的响应会猜测类型,经常猜错,导致页面展示乱码或下载文件。另一次是输出的内容里有未转义的 HTML 特殊字符(比如<和>),浏览器当成标签解析,页面结构就坏了。
不管写什么响应,把 Content-Type 写对永远是最基础的要求。渲染 HTML 就写text/html; charset=utf-8,返回数据就写application/json; charset=utf-8。这个头不仅是给浏览器看的,也是给调试工具看的,能省下一堆猜测的时间。
6.4 运行时崩溃"C++ exception",日志里看不到堆栈
CGI 程序的崩溃和常驻服务不同:没有守护进程帮你记录堆栈,标准错误默认丢到服务器日志里,但堆栈信息往往拿不到多少。我在自己的库里统一加了个模式——程序入口最外层包一个全局 try-catch:捕获到未处理异常时,往标准错误输出异常信息,然后返回异常退出码。这样日志里至少能看到异常类型和what()描述:
int main() { try { return realMain(); } catch (const std::exception& e) { std::cerr << "Unhandled exception: " << e.what() << std::endl; return 1; } }注意这里我们没有向 std::cout 输出任何内容。因为异常发生时,可能响应头已经输出一半了,再输出正文只会让整个响应更乱。让进程异常退出,Web 服务器给浏览器一个干净的 500,再从日志里查异常信息,这个策略最稳。
7. 从通用到趁手:我的几个自定义增强
基础库稳定之后,我又往里加了一些让自己用起来更舒服的功能,这里挑三个最有价值的分享。
第一个是统一的 JSON 输出格式。内部接口加了请求 ID 和时间戳,出问题时前端报一个 request_id 过来,直接在日志里 grep 就能定位到那次请求的完整处理链路,排查效率提升显著。
第二个是慢日志功能。在realMain入口记录一个开始时间,响应输出完成后计算耗时,超过 500ms 就写慢日志。CGI 程序进程模型本身性能好是优势,但如果真出现慢查询,必须有工具逮住它,不能靠运气排查。
第三个是防御性大小写处理。getenv返回的CONTENT_TYPE可能是Application/X-WWW-Form-UrlEncoded,大小写五花八门。我的库在判断时做了一次统一转小写,再比较前缀,避免因为大小写问题导致 POST 解析失败。
8. 和现代 C++ Web 框架的边界在哪里
写到最后想聊点实在的。C++ CGI 的适用场景非常具体:对性能有明确要求、请求处理逻辑不复杂、生命周期短、环境受限。如果你要在 C++ 里实现完整的 RESTful API、需要 WebSocket 支持、要做鉴权中间件,老实说换个思路用Drogon或cpp-httplib这类现代库会舒服得多。但如果是嵌入式设备、内网运维工具、教学演示、或者需要对现有 C++ 算法做 Web 封装,用我这种小库写 CGI 反而更清爽。
这个库的整体设计哲学就是四个字:够用就好。不追求功能大而全,只求拿起来顺手、丢了不心疼。代码量不大,有问题分分钟改完重新编译。相比那些动辄几十万行、升级一次要重新学一套写法的框架,这种小工具反而陪伴我最久。目前我已经把库从最初的一百来行扩到几百行,每加一次功能都会顺手把单元测试补上,日常用起来基本上没有再在下层协议上出过问题。
本文还有配套的精品资源,点击获取