Node.js HTTP 练习答案(四):43~50

导航:题目 · 1~18 · 19~33 · 34~42

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 背后做什么

它在路由前作为中间件:

  1. 根据 Content-Type 判断请求是否属于配置的 JSON 类型。
  2. 消费 IncomingMessage 可读流。
  3. 解压受支持且允许的压缩请求体。
  4. 按 limit 限制解压后的 Body 大小。
  5. 按字符编码解码并 JSON.parse。
  6. 非法 JSON、错误编码或超限时把错误交给错误处理中间件。
  7. 成功后把运行时值挂到 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 问题

至少包括:

  1. 状态码和 HTTP 方法语义。
  2. Header 与 Body 何时发送、headersSent。
  3. Content-Type、字符集和 MIME 嗅探。
  4. Content-Length、chunked 和 Stream。
  5. 请求体、字段、文件数量和解压后大小限制。
  6. 客户端中断与任务取消。
  7. 业务 deadline、socket timeout 和代理 timeout。
  8. keep-alive、连接池和 Server 超时。
  9. CORS、Cookie、Authorization 与 CSRF。
  10. Cache-Control、ETag 和条件请求。
  11. Nginx 413/502/504 与 Express 错误的区别。
  12. 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. 接口异常排查清单

连接失败

请求超时

状态码异常

JSON 解析失败

Body 过大

响应乱码

Content-Length 错误

客户端提前断开

静态文件 404

代理 502/504