Node.js HTTP 练习答案(四):43~50
43. 原生 HTTP 与 Express 路由
原生版本需要解析 URL,再组合 pathname 和 method 分支;users/:id 还要自己匹配路径段、解码并校验 ID:
if (method === "GET" && pathname === "/users") {}
else if (method === "POST" && pathname === "/users") {}
else {
const match = /^\/users\/([^/]+)$/.exec(pathname);
if (method === "GET" && match) {
const id = decodeURIComponent(match[1]!);
}
}
Express 声明:
router.get("/users", listUsers);
router.post("/users", createUser);
router.get("/users/:id", getUser);
Express 帮助完成路由匹配、参数提取、中间件组合、异步错误传递和 Response 辅助方法,但不会自动完成业务参数验证、权限、事务、超时和正确状态码设计。
44. express.json 背后做什么
它在路由前作为中间件:
- 根据 Content-Type 判断请求是否属于配置的 JSON 类型。
- 消费 IncomingMessage 可读流。
- 解压受支持且允许的压缩请求体。
- 按 limit 限制解压后的 Body 大小。
- 按字符编码解码并 JSON.parse。
- 非法 JSON、错误编码或超限时把错误交给错误处理中间件。
- 成功后把运行时值挂到 req.body。
req.body 仍是不可信输入,TypeScript 类型断言不会做运行时校验,必须继续使用 express-validator、Schema 或类型守卫。
45. res.json 背后做什么
res.json(data) 会按 Express 配置序列化 JSON,设置 application/json Content-Type,处理必要的响应元数据,写 Body 并结束响应。序列化仍可能因 BigInt、循环引用或自定义 toJSON 抛错。调用后当前函数必须 return,不能继续 res.send;headersSent 后也不能再改变状态和 Header。
46. express.static 背后做什么
它把请求路径映射到受控根目录,处理 URL 解码和目录边界,读取文件元数据,设置 Content-Type、Content-Length、Last-Modified、ETag/缓存相关 Header,并以 Stream 返回文件;找不到时通常调用 next,让后续 404 处理。它还处理 HEAD、条件请求和 Range 等大量边界,因此生产代码不应轻率复制一个教学静态服务器替代它或 Nginx。
47. Express 下仍须理解的 HTTP 问题
至少包括:
- 状态码和 HTTP 方法语义。
- Header 与 Body 何时发送、headersSent。
- Content-Type、字符集和 MIME 嗅探。
- Content-Length、chunked 和 Stream。
- 请求体、字段、文件数量和解压后大小限制。
- 客户端中断与任务取消。
- 业务 deadline、socket timeout 和代理 timeout。
- keep-alive、连接池和 Server 超时。
- CORS、Cookie、Authorization 与 CSRF。
- Cache-Control、ETag 和条件请求。
- Nginx 413/502/504 与 Express 错误的区别。
- trust proxy、客户端 IP、HTTPS 终止和 Forwarded Header。
48. 小型原生 HTTP API
import * as http from "node:http";
import * as path from "node:path";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
const port = 8080;
const maximumJsonBytes = 64 * 1024;
const publicRoot = path.resolve("public");
const users: { id: number; name: string }[] = [{ id: 1, name: "Ada" }];
function json(res: http.ServerResponse, status: number, value: unknown): void {
const body = JSON.stringify(value);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
async function body(req: http.IncomingMessage, max: number): Promise<Buffer> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const value = Buffer.from(chunk);
size += value.length;
if (size > max) throw Object.assign(new Error("too large"), { code: "BODY_TOO_LARGE" });
chunks.push(value);
}
return Buffer.concat(chunks, size);
}
function pathInside(root: string, target: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (
relative !== ".."
&& !relative.startsWith(".." + path.sep)
&& !path.isAbsolute(relative)
);
}
async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url ?? "/", "http://localhost");
const method = req.method ?? "GET";
if (url.pathname === "/health") {
if (method !== "GET") { res.setHeader("Allow", "GET"); json(res, 405, { error: "method_not_allowed" }); return; }
json(res, 200, { ok: true }); return;
}
if (url.pathname === "/users") {
if (method === "GET") { json(res, 200, users); return; }
if (method === "POST") {
const raw = await body(req, maximumJsonBytes);
if (raw.length === 0) { json(res, 400, { error: "body_required" }); return; }
let value: unknown;
try { value = JSON.parse(raw.toString("utf8")) as unknown; }
catch { json(res, 400, { error: "invalid_json" }); return; }
if (typeof value !== "object" || value === null || typeof (value as Record<string, unknown>).name !== "string") {
json(res, 422, { error: "invalid_user" }); return;
}
const user = { id: (users.at(-1)?.id ?? 0) + 1, name: (value as { name: string }).name };
users.push(user); json(res, 201, user); return;
}
res.setHeader("Allow", "GET, POST"); json(res, 405, { error: "method_not_allowed" }); return;
}
if (url.pathname === "/echo") {
if (method !== "POST") { res.setHeader("Allow", "POST"); json(res, 405, { error: "method_not_allowed" }); return; }
const raw = await body(req, maximumJsonBytes);
res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": raw.length });
res.end(raw); return;
}
if (url.pathname === "/static/index.html") {
if (method !== "GET") { res.setHeader("Allow", "GET"); json(res, 405, { error: "method_not_allowed" }); return; }
const file = path.resolve(publicRoot, "index.html");
if (!pathInside(publicRoot, file)) { json(res, 403, { error: "forbidden" }); return; }
const information = await stat(file);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": information.size });
await pipeline(createReadStream(file), res); return;
}
json(res, 404, { error: "not_found" });
}
const server = http.createServer((req, res) => {
void handle(req, res).catch((error: unknown) => {
if (!res.headersSent) {
if (error instanceof Error && "code" in error && error.code === "BODY_TOO_LARGE") json(res, 413, { error: "payload_too_large" });
else if (error instanceof Error && "code" in error && error.code === "ENOENT") json(res, 404, { error: "not_found" });
else json(res, 500, { error: "internal_error" });
} else {
res.destroy(error instanceof Error ? error : undefined);
}
});
});
server.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") console.error("端口已经被占用:", port);
else console.error(error);
process.exitCode = 1;
});
server.listen(port, () => console.log("listening", port));
测试:
curl -i http://127.0.0.1:8080/health
curl -i http://127.0.0.1:8080/users
curl -i -X POST http://127.0.0.1:8080/users -H "Content-Type: application/json" -d "{\"name\":\"Bob\"}"
curl -i http://127.0.0.1:8080/static/index.html
curl -i -X POST http://127.0.0.1:8080/echo --data-binary "hello"
内存 users 只用于练习,不支持多进程、持久化和并发数据一致性。
49. HTTP 客户端调试工具
import * as http from "node:http";
import * as https from "node:https";
async function inspectUrl(input: string, timeoutMs = 5000): Promise<void> {
const url = new URL(input);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("只支持 HTTP/HTTPS");
const transport = url.protocol === "https:" ? https : http;
const signal = AbortSignal.timeout(timeoutMs);
await new Promise<void>((resolve, reject) => {
const request = transport.get(url, { signal }, async (response) => {
let total = 0;
const previewChunks: Buffer[] = [];
let previewBytes = 0;
try {
for await (const chunk of response) {
const value = Buffer.from(chunk);
total += value.length;
if (previewBytes < 4096) {
const remaining = 4096 - previewBytes;
const selected = value.subarray(0, remaining);
previewChunks.push(selected);
previewBytes += selected.length;
}
}
const preview = Buffer.concat(previewChunks).toString("utf8").slice(0, 200);
console.log("status:", response.statusCode);
console.log("headers:", response.headers);
console.log("preview:", preview);
console.log("total bytes:", total);
resolve();
} catch (error: unknown) { reject(error); }
});
request.on("error", reject);
});
}
void inspectUrl(process.argv[2] ?? "").catch((error: unknown) => {
if (error instanceof Error && error.name === "AbortError") console.error("请求超时");
else console.error("请求失败", error);
process.exitCode = 1;
});
预览按 UTF-8 解码,二进制或其他字符集只适合展示十六进制/另行处理。生产工具还应限制重定向、最大响应大小和目标地址,防止 SSRF。
50. 接口异常排查清单
连接失败
- DNS 是否解析到正确地址。
- 目标 IP/端口是否监听,防火墙和安全组是否允许。
- curl -v 的错误是 ECONNREFUSED、超时还是 TLS。
请求超时
- 区分连接、Header、socket 空闲、业务总期限和 Nginx upstream 超时。
- 用请求 ID 对齐 Nginx、Express、数据库和下游日志。
- 客户端取消后后台任务是否仍运行。
状态码异常
- 响应由 Nginx、Express、CDN 还是下游产生。
- 404 是代理 location 还是路由;405 是否带 Allow。
- 502 是上游不可用,504 是代理等待超时。
JSON 解析失败
- 检查状态码、Content-Type 和原始 Body;错误页可能是 HTML。
- 检查空 Body、截断、编码和非法 JSON。
Body 过大
- 检查 Nginx client_max_body_size、Express/原生限制、Multer 单文件/总量。
- Content-Length 只用于早拒绝,实际流仍须计数。
响应乱码
- Content-Type 是否带正确 charset。
- 字节实际编码是否与声明一致。
- 是否错误地逐 Chunk 解码多字节字符。
Content-Length 错误
- 使用 Buffer.byteLength,而非字符串 length。
- 压缩、代理改写或 Body 变化后是否重新计算。
- 长度过短会截断,过长可能使客户端继续等待或报协议错误。
客户端提前断开
- 检查 req aborted、res close 和 writableFinished。
- 将取消传递给数据库、fetch、文件 Stream 或子进程。
- 写操作需要事务、幂等和可查询的最终状态。
静态文件 404
- 检查 cwd、绝对根目录、URL 解码、大小写、权限和 Nginx root/alias。
- 检查 SPA fallback 是否错误吞掉资源请求。
代理 502/504
- 先 curl Express 内部端口,再 curl Nginx 公网入口。
- 检查 PM2 状态、监听地址、端口、proxy_pass 和 error log。
- 504 继续检查业务耗时、数据库锁、下游依赖和各层超时顺序。