简介:一份基于 C++ 实现的 CGI 库源码包,面向希望理解早期 Web 动态交互机制、练习 C++ 网络编程的开发者。它把 CGI 请求识别、查询字符串解析、HTML 响应构造等基础操作封装起来,并集成字符串处理、MySQL 数据库访问与 Socket 通信模块,可用于搭建小型动态页面、表单处理或后台管理原型,也适合作为阅读经典 Web 技术路径的示例工程。压缩包共 49 个文件、约 43KB,以 17 个 cpp 源文件和 17 个 h 头文件为主体,另含 4 个 makefile 及少量备份与构建辅助文件,源码体量轻,便于按模块通读、编译和改造。已有 325 人学习,读者可从中梳理 CGI 程序的初始化、请求处理、数据库交互、响应发送流程,并观察核心类与工具类的职责划分,获得可复用的网络编程与源码分析思路。
1. 从一台测试机上的老程序说起
接手一个老项目的二次开发时,我在测试机上看到一个三兆出头的 C++ CGI 程序,被丢在 Apache 的 cgi-bin 目录里。访问一下,它吐出一段 JSON,进程随即退出,没有守护进程,没有监听端口,也没有依赖管理。那一刻我才重新意识到:如果手里已经有一坨 C++ 代码——图像处理、解压、加解密、算法引擎——想给它挂一个 HTTP 入口,C++ CGI 依然是成本最低的那条路。这份资源就是那条路上的一层薄封装,把 CGI 规范里每次都要手写一遍的环境变量解析、stdin 读取、头部拼装、URL 编解码、multipart 切片,收进一个只吃标准库和 POSIX 的小库里。它适合手里已经有 C++ 代码、需要快速对外暴露一个 HTTP 入口的人,不适合拿它去搭高并发业务后台。
2. 项目定位与技术选型:这层薄封装到底解决了什么
2.1 CGI 协议的本质:一次请求启动一个进程
先把运行时模型摆清楚。这个库不是 Web 服务器,它不监听端口,也没有 accept 循环。编译出来的可执行文件平时躺在 cgi-bin 目录里,Web 服务器匹配到路径,fork 一个子进程 exec 它,子进程从启动到退出就是一个完整的请求生命周期,短则几毫秒,长则几秒,退出后一切状态清空。
请求信息分两条通道进来。请求行和请求头被服务器翻译成一组环境变量:REQUEST_METHOD说明是 GET 还是 POST,QUERY_STRING装着问号后面那串,CONTENT_TYPE和CONTENT_LENGTH描述请求体,SCRIPT_NAME和PATH_INFO拼出访问路径,HTTP_开头的是原始请求头,REMOTE_ADDR是客户端地址。请求体走第二条通道,从标准输入进来,长度由CONTENT_LENGTH精确指定,不能靠读到 EOF 来判断——这是新手第一个大坑,后面单独讲。
响应只有一条出口:标准输出。程序往 stdout 写「头部 + 一个空行 + 正文」,服务器把这段内容当 HTTP 响应原样转发给客户端。头部里必须有Content-Type,否则浏览器拿到的是未定义类型;要改状态码就写一行Status: 404 Not Found;要跳转就写Location并配 302。注意这套约定里没有「HTTP/1.1 200 OK」这一行——状态行由 Web 服务器自己拼,CGI 程序只负责吐出头部字段,这也是很多人第一次写完发现浏览器报 500 的原因。
隔离性是这个模型最大的好处:每个请求一个进程,崩了不牵连别人,也没法靠共享内存搞出莫名其妙的状态污染,调试时你甚至可以直接在 shell 里把程序跑一遍。代价同样明显:fork 和进程启动的开销、没有连接复用、跨请求状态只能落盘或者签名后塞回 cookie。所以这类 C++ CGI 库的甜点区是低 QPS、工具性质、计算密集型的接口——上传一张图做处理、调一次算法引擎、跑一次批量导出,而不是做秒杀。
2.2 技术栈盘点:C++17 加 POSIX,外部依赖为零
这个库的依赖表短到几乎没有。语言标准定在 C++17,用到了std::string_view、结构化绑定和std::optional;平台侧只依赖 POSIX 那几样东西——<unistd.h>的read、<sys/stat.h>的文件元信息、<cstdlib>的environ。构建用 CMake,测试用自带的一套极简断言宏,不引 gtest 也不引 Catch2,理由很实际:这类库经常被塞进一个构建环境受限的离线机器里,多一个第三方依赖就多一次「装不上」的风险。
| 能力 | 依赖来源 |
|---|---|
| 请求解析(环境变量、stdin) | std::map、<unistd.h> |
| 响应拼装与输出 | std::ostream/write |
| URL 编解码 | 纯手写查表 |
| multipart 表单切分 | 纯手写字符串扫描 |
| 会话签名 | 可选,用 HMAC 时自行接 OpenSSL |
| 构建 | CMake ≥ 3.16 |
| 测试 | 仓库自带的CHECK宏 |
我自己在项目里用它的方式是把它编成静态库,业务代码只包含头文件、链接一个.a,然后 CMake 里加一句target_link_libraries。整个过程不需要改动服务器的任何配置,也不需要 root 权限去装东西,这点在客户现场特别省事。
2.3 目录结构:include 里的头文件就是 API 契约
源码树的组织方式很直白,include和src一一对应,读代码的时候基本不用跳来跳去。
mini-cgi/ ├── CMakeLists.txt ├── include/cgi/ │ ├── request.hpp # 请求对象:方法、路径、查询参数、表单、头 │ ├── response.hpp # 响应对象:状态码、头、正文、重定向 │ ├── url.hpp # URL 编解码与 key=value 解析 │ ├── multipart.hpp # multipart/form-data 边界切分 │ ├── session.hpp # 基于签名 cookie 的轻量会话 │ └── app.hpp # 极简路由表 ├── src/ │ ├── request.cpp │ ├── response.cpp │ ├── url.cpp │ ├── multipart.cpp │ ├── session.cpp │ └── app.cpp ├── examples/ │ ├── hello.cpp # 最小示例:返回一段 HTML │ ├── upload.cpp # 文件上传示例 │ └── session_demo.cpp # 登录态示例 └── tests/ ├── test_url.cpp └── test_request.cppinclude/cgi下每个头文件对应一个能力域,Request和Response是核心的两个类,其他都是围绕它们的工具。App这层是我在项目里加得最多的一块——原始版本只有Request/Response,业务代码里全是一长串if (path == "/a") ... else if (path == "/b"),一旦接口超过五个就完全没法维护。加一张map<string, map<string, Handler>>之后,路由声明集中在main里,业务函数各写各的,可读性提升非常明显。
2.4 设计取舍:为什么不做成 HTTP 服务框架
拿到一个 C++ CGI 库,第一反应往往是「能不能用它写整个后端」。技术上当然可以,但从工程角度看并不划算,这也是这个库刻意保持「薄」的原因。
| 方案 | 适用场景 | 代价 |
|---|---|---|
| C++ CGI 库(本资源) | 已有 C++ 核心代码,需要一个 HTTP 外壳 | 每请求一进程,QPS 上限低 |
| 独立 HTTP 服务(如 C++ 的轻量 HTTP 库) | 需要长连接、WebSocket、高并发 | 要自己管端口、进程、启停 |
| 反向代理 + RPC | 多语言混合、服务治理 | 引入 RPC 框架和 IDL |
这个库选的是第一条路:把自己做成别人 Web 服务器的一个插件,而不是自己当服务器。它不做连接池、不做 TLS、不做静态文件托管,这些全部交给 Apache 或 Nginx。这样换来的是部署极其简单——把编译产物拷进 cgi-bin,改一下权限就完事,不需要动防火墙,也不需要给业务申请新端口。
3. 核心模块拆解:一次请求从环境变量走到响应体
3.1 Request:环境变量映射和 body 读取的边界
Request类负责把两路输入收拢成一个对象。它的构造函数接收char** envp,先把所有KEY=VALUE拆进一张std::map,再按 CGI 规范取字段。
// include/cgi/request.hpp #pragma once #include <map> #include <string> #include <vector> namespace cgi { class Request { public: explicit Request(char** envp); const std::string& method() const { return method_; } const std::string& path() const { return path_; } std::string query(const std::string& key) const; // QUERY_STRING 里的参数 std::string form(const std::string& key) const; // 表单字段 std::string header(const std::string& key) const; // 原始 HTTP 头 const std::string& body() const { return body_; } private: void parseEnv(char** envp); void readBody(); std::string method_, path_, queryString_, body_; std::map<std::string, std::string> env_; // 全部环境变量 std::map<std::string, std::string> query_; // 解码后的查询参数 std::map<std::string, std::string> form_; // 解码后的表单字段 }; }实现里有两个地方值得停下来看:环境变量到请求字段的映射,以及 body 的读取循环。
// src/request.cpp #include "cgi/request.hpp" #include "cgi/url.hpp" #include <cstring> #include <unistd.h> namespace cgi { static std::string pick(const std::map<std::string,std::string>& env, const std::string& key) { auto it = env.find(key); return it == env.end() ? std::string() : it->second; } void Request::parseEnv(char** envp) { for (char** p = envp; p && *p; ++p) { const char* eq = std::strchr(*p, '='); if (!eq) continue; env_.emplace(std::string(*p, eq), std::string(eq + 1)); } method_ = pick(env_, "REQUEST_METHOD"); queryString_ = pick(env_, "QUERY_STRING"); // PATH_INFO 优先级高于 SCRIPT_NAME:前者是路由段,后者是脚本路径 path_ = pick(env_, "PATH_INFO"); if (path_.empty()) path_ = pick(env_, "SCRIPT_NAME"); } void Request::readBody() { // CONTENT_LENGTH 是 CGI 规范里唯一可靠的长度来源 std::string lenStr = pick(env_, "CONTENT_LENGTH"); if (lenStr.empty()) return; std::size_t len = 0; try { len = static_cast<std::size_t>(std::stoul(lenStr)); } catch (...) { return; // 长度非法,按无 body 处理 } body_.resize(len); std::size_t got = 0; while (got < len) { ssize_t n = ::read(STDIN_FILENO, &body_[got], len - got); if (n <= 0) break; // 客户端提前断开,保留已读内容 got += static_cast<std::size_t>(n); } body_.resize(got); // 按实际读到的字节数收缩 }pick是查表小工具,找不到就返回空串而不是抛异常,因为 CGI 里很多变量本来就是可选的。path_的取值顺序有讲究:PATH_INFO是 URL 里脚本名之后的额外路径,做路由时更准确;SCRIPT_NAME是脚本本身的路径,在没有 PATH_INFO 的配置里兜底。
readBody是这一段的核心。CONTENT_LENGTH是唯一可信的长度来源,不能靠循环读到 EOF——标准输入在 CGI 里不是普通文件,读到 EOF 可能意味着挂起等待。循环用read而不是std::cin,是因为read的返回值能区分「读到了」和「客户端断开」,而且可以一次读满指定长度,少踩缓冲区截断的坑。循环结束后用resize(got)收缩,保证 body 长度和实际收到的字节数一致,后面解析表单时就不会读到越界的垃圾数据。
query和form两个 getter 内部其实都调用了同一个parseKeyValue,区别只是数据源:前者解queryString_,后者解body_。把编解码抽到url.cpp里之后,两个 getter 各剩三行。
3.2 Response:状态行、头部,以及那个决定生死的空行
Response是一个流式拼装器,业务侧调用status()、header()、write()往里塞东西,最后send()一次性刷到 stdout。
// include/cgi/response.hpp #pragma once #include <map> #include <string> namespace cgi { class Response { public: Response& status(int code); Response& header(const std::string& key, const std::string& value); Response& write(const std::string& body); Response& json(const std::string& body); Response& redirect(const std::string& url, int code = 302); void send(); private: int status_ = 200; std::map<std::string, std::string> headers_; std::string body_; }; }实现里最容易被忽略的是状态码和空行这两件事。
// src/response.cpp #include "cgi/response.hpp" #include <iostream> namespace cgi { static const char* reason(int code) { switch (code) { case 200: return "OK"; case 204: return "No Content"; case 301: return "Moved Permanently"; case 302: return "Found"; case 400: return "Bad Request"; case 403: return "Forbidden"; case 404: return "Not Found"; case 500: return "Internal Server Error"; default: return "Unknown"; } } void Response::send() { if (!headers_.count("Content-Type")) headers_["Content-Type"] = "text/html; charset=utf-8"; // CGI 里没有状态行,状态码通过 Status 头告诉服务器 if (status_ != 200) headers_["Status"] = std::to_string(status_) + " " + reason(status_); headers_["Content-Length"] = std::to_string(body_.size()); std::string out; out.reserve(body_.size() + 256); for (const auto& kv : headers_) { out += kv.first; out += ": "; out += kv.second; out += "\r\n"; } out += "\r\n"; // 这一行空行是头部和正文的分界,缺了必翻车 out += body_; std::cout.write(out.data(), static_cast<std::streamsize>(out.size())); std::cout.flush(); } }三个关键点。第一,CGI 程序不能自己输出HTTP/1.1 200 OK这一行,状态码要通过Status:头传过去,Apache 和 Nginx 会读这个头并替换掉默认的 200。第二,Content-Length必须和正文实际字节数一致,否则浏览器会一直转圈等剩下的数据,或者直接把多出来的部分丢掉。第三,头尾之间那个\r\n\r\n是硬性分隔符,少了它服务器会把正文当成头部的一部分,日志里冒出malformed header from script这种让人一脸懵的错误。json()和redirect()都是对header()的糖,前者补Content-Type: application/json,后者补Location和状态码,代码不超过五行。
3.3 URL 编解码和表单解析:别在百分号上栽跟头
application/x-www-form-urlencoded的格式是k1=v1&k2=v2,其中空格被编码成+,其他特殊字符编码成%XX。解码时两个坑最常见:一是+要还原成空格而不是保留,二是%后面凑不齐两位十六进制时不能吞掉后续字符。
// src/url.cpp #include "cgi/url.hpp" namespace cgi { static int hexVal(char h) { if (h >= '0' && h <= '9') return h - '0'; if (h >= 'a' && h <= 'f') return h - 'a' + 10; if (h >= 'A' && h <= 'F') return h - 'A' + 10; return -1; } std::string urlDecode(const std::string& in) { std::string out; out.reserve(in.size()); for (std::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 << 4) | lo)); i += 2; continue; } } out.push_back(c); // 非法编码原样保留,不吞字符 } return out; } void parseKeyValue(const std::string& s, std::map<std::string,std::string>& out) { std::size_t pos = 0; while (pos < s.size()) { std::size_t amp = s.find('&', pos); if (amp == std::string::npos) amp = s.size(); std::string pair = s.substr(pos, amp - pos); std::size_t eq = pair.find('='); if (eq != std::string::npos) { out[urlDecode(pair.substr(0, eq))] = urlDecode(pair.substr(eq + 1)); } else if (!pair.empty()) { out[urlDecode(pair)] = ""; // 允许 key-only 参数 } pos = amp + 1; } } }urlDecode的边界判断i + 2 < in.size()保证不会读到字符串末尾之外,非法百分号编码直接原样输出,不会让后续字符被误吞。parseKeyValue把「带等号」和「只有 key」两种情况分开处理,因为前端有些复选框提交时确实会发只有 key 的字段。解码发生在取值时而不是解析时,好处是同一份map可以同时服务于原始值和已解码值两种需求,如果哪天需要调试,只要把urlDecode换成恒等函数,就能看到服务器原样传来的内容。
Request::query()和Request::form()内部各缓存了一份map,首次调用时解析,之后直接查表,避免同一字段被解多次。这个细节在字段十几个的表单上不明显,但当请求体到几兆时差别就出来了。
3.4 multipart/form-data:文件上传的切片逻辑
文件上传走的是multipart/form-data,它和 urlencoded 的差别在于:body 被一个从CONTENT_TYPE里读出来的 boundary 切成一堆分段,每个分段有自己的头部和内容。这个库的做法是先切段、再逐个解析,把职责拆开。
// src/multipart.cpp #include "cgi/multipart.hpp" namespace cgi { // 从 CONTENT_TYPE 里抠出 boundary=xxx,支持带引号的形式 std::string extractBoundary(const std::string& contentType) { const std::string key = "boundary="; auto pos = contentType.find(key); if (pos == std::string::npos) return {}; pos += key.size(); if (pos < contentType.size() && contentType[pos] == '"') { auto end = contentType.find('"', pos + 1); if (end == std::string::npos) return {}; return contentType.substr(pos + 1, end - pos - 1); } auto end = contentType.find(';', pos); return contentType.substr(pos, end == std::string::npos ? std::string::npos : end - pos); } // 按 --boundary 切分,返回每个分段的原始内容(含段头) std::vector<std::string> splitParts(const std::string& body, const std::string& boundary) { std::vector<std::string> parts; if (boundary.empty()) return parts; const std::string delim = "--" + boundary; std::size_t pos = body.find(delim); while (pos != std::string::npos) { std::size_t start = body.find("\r\n", pos); if (start == std::string::npos) break; start += 2; std::size_t next = body.find(delim, start); if (next == std::string::npos) break; std::size_t end = next; if (end >= 2 && body.compare(end - 2, 2, "\r\n") == 0) end -= 2; parts.push_back(body.substr(start, end - start)); pos = next; } return parts; } }extractBoundary处理了两种格式:boundary=----abc和boundary="----abc",后者在浏览器里更常见。splitParts的核心是三次find——找到分界符、跳过紧跟的\r\n、找下一个分界符;尾部多出来的\r\n需要减掉,否则文件内容末尾会平白无故多两个字节,下载下来校验值对不上。
切出来的分段里,段头部分是Content-Disposition: form-data; name="file"; filename="a.png"加一个空行,之后才是真正的文件字节。解析这一段需要再拆一次\r\n\r\n,前面是元信息,后面是内容。我在实际项目里给Part结构加了name、filename、contentType和data四个字段,业务侧通过multipart::findPart(parts, "file")拿到文件内容,直接写盘或者交给后面的处理函数。
需要提醒的是,这个版本的切分是线性扫描,对大文件意味着整块 body 要在内存里过一遍。如果上传动辄几百兆,建议改成边读边切,或者把readBody换成流式回调,否则内存峰值会很难看。
3.5 App 路由:把 if-else 收进一张表
App是业务代码唯一直接接触的门面。它维护一张方法 -> 路径 -> 处理器的两级表,run()负责请求进来后查表、调用、发响应、返回退出码。
// include/cgi/app.hpp #pragma once #include <functional> #include <map> #include <string> #include "cgi/request.hpp" #include "cgi/response.hpp" namespace cgi { using Handler = std::function<void(Request&, Response&)>; class App { public: void get(const std::string& path, Handler h) { routes_["GET"][path] = std::move(h); } void post(const std::string& path, Handler h) { routes_["POST"][path] = std::move(h); } int run(char** envp); private: std::map<std::string, std::map<std::string, Handler>> routes_; }; }// src/app.cpp #include "cgi/app.hpp" namespace cgi { int App::run(char** envp) { Request req(envp); Response res; auto byMethod = routes_.find(req.method()); if (byMethod != routes_.end()) { auto hit = byMethod->second.find(req.path()); if (hit != byMethod->second.end()) { hit->second(req, res); res.send(); return 0; } } // 未命中时给一个最简 404,避免服务器记录空响应 res.status(404).write("not found"); res.send(); return 0; } }run()里的两个查找都是std::map的对数级,路由数量在几十个量级完全无感。真正需要改的是错误处理——处理器里如果抛异常,进程会直接 abort,服务器日志里看到的是一行Premature end of script headers,完全定位不到问题。我的做法是在hit->second外面套一层try/catch,把异常转成 500 并把异常信息写进返回体(生产环境改成写日志),这样至少能从浏览器里看到出错的原因。
main函数只剩两行有效逻辑,所有路径注册集中在一块,新增接口时不用动run本身:
// examples/hello.cpp #include "cgi/app.hpp" int main(int, char** argv, char** envp) { cgi::App app; app.get("/hello", [](cgi::Request&, cgi::Response& res) { res.write("<h1>hello, cgi</h1>"); }); app.get("/time", [](cgi::Request&, cgi::Response& res) { res.json(R"({"ok":true})"); }); return app.run(envp); }argv在 CGI 里通常用不上,因为所有信息都在环境变量和 stdin 里;envp必须显式传给run,这也是为什么main的签名要写全三个参数。return app.run(envp)的返回值决定了进程退出码,正常情况返回 0,服务器看到 0 才认为脚本执行成功并去解析输出。
4. 编译与部署:把 cgi-bin 目录真正跑起来
4.1 CMake 构建:一个静态库加三个示例
构建这块没什么玄学,一个add_library加几个add_executable就够了。
cmake_minimum_required(VERSION 3.16) project(mini_cgi LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_BUILD_TYPE Release) add_library(cgi STATIC src/request.cpp src/response.cpp src/url.cpp src/multipart.cpp src/session.cpp src/app.cpp ) target_include_directories(cgi PUBLIC include) target_compile_options(cgi PRIVATE -Wall -Wextra -Wpedantic) add_executable(hello examples/hello.cpp) add_executable(upload examples/upload.cpp) add_executable(session_demo examples/session_demo.cpp) target_link_libraries(hello PRIVATE cgi) target_link_libraries(upload PRIVATE cgi) target_link_libraries(session_demo PRIVATE cgi)target_include_directories里的PUBLIC是关键,它让链接cgi的可执行文件自动获得头文件搜索路径,业务侧不用再重复写一遍include_directories。-Wall -Wextra -Wpedantic只是编译期告警,不影响产物,但这类库最怕的就是隐式类型转换,开着没坏处。构建命令走标准流程:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j"$(nproc)" ls -lh build/hello build/uploadnproc取核数并发编译,产物在build/下,hello通常几十 KB,静态链接进 libstdc++ 也就几百 KB,拷到任何一台同架构的机器上都能跑。
4.2 两种部署方式:Apache 的 ScriptAlias 和 Nginx 的 fcgiwrap
Apache 是 CGI 的老本行,配起来最短。把可执行文件拷到/var/www/cgi-bin,加个ScriptAlias就行:
ScriptAlias /cgi-bin/ "/var/www/cgi-bin/" <Directory "/var/www/cgi-bin"> AllowOverride None Options +ExecCGI Require all granted AddHandler cgi-script .cgi </Directory>ScriptAlias把 URL 前缀映射到磁盘目录,同时也隐式打开了「这个目录下的文件可执行」的语义。Options +ExecCGI必须显式给,很多发行版默认关掉。文件权限要设成755,属主要和 Apache 的运行用户能对上,否则日志里就是清一色的Permission denied。
Nginx 自己不支持 CGI,得挂fcgiwrap把 CGI 协议转成 FastCGI:
location /cgi-bin/ { fastcgi_pass unix:/var/run/fcgiwrap.socket; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /var/www/cgi-bin$fastcgi_script_name; }SCRIPT_FILENAME这一行是拼出来的,$fastcgi_script_name是 URL 里的脚本路径,前面必须补上磁盘根目录,否则fcgiwrap找不到文件。include fastcgi_params负责把REQUEST_METHOD、QUERY_STRING这些变量透传成 CGI 环境变量,漏了它,你的程序拿到的方法永远是空字符串。
4.3 命令行模拟请求:不打开浏览器也能把逻辑调通
CGI 有个隐藏福利:它本质上就是一个读环境变量、读 stdin、写 stdout 的普通程序,所以完全可以在 shell 里手动喂参数,省掉「改一行、传一次、刷一次浏览器、翻一次日志」的循环。
# 模拟一次 GET /hello?name=alice REQUEST_METHOD=GET \ QUERY_STRING='name=alice&age=20' \ SCRIPT_NAME=/hello \ PATH_INFO=/hello \ ./build/hello# 模拟一次 POST 表单提交,CONTENT_LENGTH 必须手动对上 printf 'name=alice&age=20' | \ REQUEST_METHOD=POST \ CONTENT_LENGTH=17 \ CONTENT_TYPE='application/x-www-form-urlencoded' \ SCRIPT_NAME=/hello \ ./build/hello两条命令里都有几个必填变量:REQUEST_METHOD决定路由走哪张表,CONTENT_LENGTH必须等于 printf 输出字节数(这里是 17,别数错,name=alice&age=20正好 17 个字符),CONTENT_TYPE决定表单走 urlencoded 还是 multipart 分支。输出直接打在终端上,头部和正文一起显示,一眼就能看出有没有少空行、长度对不对。我调试上传时通常先用这条命令跑一遍小文件,确认切分逻辑没问题,再去浏览器里测。
注意:手动模拟时如果没有
Content-Type,程序按 HTML 处理,浏览器打开就是一堆未定义类型;排查返回格式问题时先看这一项。
5. 避坑排查:五个把 CGI 程序卡死的现场
5.1 现象一:页面 500,日志只说 malformed header
现象很典型:浏览器显示 500,Apache 的 error log 里只有一行malformed header from script 'hello': Bad header=...,程序本身的 stdout 看起来没毛病。
原因是头部和正文之间少了一个空行,或者头部某一行用了\n而不是\r\n。CGI 规范要求头部字段之间用 CRLF 分隔,并且头部结束后必须再跟一个 CRLF 表示空行。用std::cout << "Content-Type: text/html\n\n"这种写法在部分服务器上能过,在网络中间件、代理、负载均衡那边就可能被拒绝。
解决方式是在Response::send()里统一拼装,别在业务代码里手写头部字符串。所有头部字段走同一个map,最后一次性输出,分隔
本文还有配套的精品资源,点击获取