Node.js HTTP 练习答案(二):19~33

导航:题目 · 1~18 · 34~42 · 43~50

19. 简单路由表

import * as http from "node:http";
type Handler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>;
const routes = new Map<string, Handler>([
  ["GET /users", (_req, res) => sendJson(res, [])],
  ["POST /users", (_req, res) => { res.statusCode = 201; sendJson(res, { created: true }); }],
]);

async function dispatch(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
  const handler = routes.get((req.method ?? "GET") + " " + pathname);
  if (handler) { await handler(req, res); return; }
  const methods = [...routes.keys()].filter((key) => key.endsWith(" " + pathname)).map((key) => key.split(" ", 1)[0]!);
  if (methods.length) { res.setHeader("Allow", methods.join(", ")); res.writeHead(405).end("Method Not Allowed"); return; }
  res.writeHead(404).end("Not Found");
}

20. 统一错误响应

const server = http.createServer((req, res) => {
  void dispatch(req, res).catch((error: unknown) => {
    console.error(error);
    if (!res.headersSent) {
      res.statusCode = 500; sendJson(res, { error: "internal_error" });
    } else {
      res.destroy(error instanceof Error ? error : undefined);
    }
  });
});

Header 已发送后无法可靠改成 500 JSON,只能停止继续写并销毁不完整响应。

21. 简化中间件

type Middleware = (req: http.IncomingMessage, res: http.ServerResponse, next: () => void) => void;
const middleware: Middleware[] = [
  (req, _res, next) => { console.log(req.method, req.url); next(); },
  (req, res, next) => {
    if (req.headers["x-api-key"] !== "demo") { res.writeHead(401).end("Unauthorized"); return; }
    next();
  },
  (_req, res) => res.end("ok"),
];
function run(index: number, req: http.IncomingMessage, res: http.ServerResponse): void {
  const current = middleware[index];
  if (current) current(req, res, () => run(index + 1, req, res));
}

next 把控制权交给后续层;真实 Express 还处理错误中间件、异步边界和路由匹配。

22. 请求日志

const startedAt = process.hrtime.bigint();
res.once("finish", () => {
  const ms = Number(process.hrtime.bigint() - startedAt) / 1e6;
  console.log(new Date().toISOString(), req.method, req.url, res.statusCode, ms.toFixed(1) + "ms");
});

finish 表示响应已交给底层,不保证客户端完整收到。还应监听 close 记录中断,并避免两个事件重复结算。

23. 客户端中断

if (req.url === "/slow") {
  let disconnected = false;
  req.once("aborted", () => { disconnected = true; });
  res.once("close", () => { if (!res.writableFinished) disconnected = true; });
  const timer = setTimeout(() => { if (!disconnected) res.end("done"); }, 5000);
  res.once("close", () => clearTimeout(timer));
}

真实耗时任务还要把 AbortSignal 传给数据库、fetch 或子进程,仅不写响应不能停止后台工作。

24. 返回 HTML 文件

import { readFile } from "node:fs/promises";
try {
  const html = await readFile("public/index.html");
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": html.length });
  res.end(html);
} catch (error: unknown) {
  if (isErrno(error) && error.code === "ENOENT") res.writeHead(404).end("Not Found");
  else throw error;
}

25. 流式下载

import { createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";
res.setHeader("Content-Type", "application/octet-stream");
try { await pipeline(createReadStream("large.zip"), res); }
catch (error: unknown) {
  if (!res.headersSent) {
    if (isErrno(error) && error.code === "ENOENT") res.writeHead(404).end("Not Found");
    else res.writeHead(500).end("Internal Server Error");
  }
  else res.destroy(error instanceof Error ? error : undefined);
}

pipeline 比单独 pipe 更适合等待完成和集中传播错误。

26. Content-Type 映射

const mime: Record<string, string> = {
  ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8",
  ".png": "image/png", ".jpg": "image/jpeg", ".txt": "text/plain; charset=utf-8",
};
const type = mime[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
res.setHeader("Content-Type", type);

27. 防止路径穿越

import * as path from "node:path";
const root = path.resolve("public");
function resolveStatic(input: string): string {
  const target = path.resolve(root, input.replaceAll("\\", "/"));
  const relative = path.relative(root, target);
  if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) throw new Error("Forbidden");
  return target;
}

还要先安全 decode URL,并考虑符号链接/TOCTOU;高风险文件服务不要只靠字符串检查。

28. 缓存头

res.setHeader("Cache-Control", "public, max-age=60");

HTML 通常短缓存或 no-cache 以便更新入口;内容 Hash 的 JS/CSS 可使用 public, max-age=31536000, immutable。缓存策略必须与发布文件命名匹配。

29. http.get

http.get("http://127.0.0.1:8080/", (response) => {
  console.log(response.statusCode, response.headers);
  const chunks: Buffer[] = [];
  response.on("data", (chunk: Buffer) => chunks.push(chunk));
  response.on("end", () => console.log(Buffer.concat(chunks).toString("utf8")));
}).on("error", console.error);

30. http.request 发送 JSON

const body = JSON.stringify({ name: "Bob" });
const request = http.request({
  hostname: "127.0.0.1", port: 8080, path: "/json", method: "POST",
  headers: { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body) },
}, (response) => response.pipe(process.stdout));
request.on("error", console.error);
request.write(body);
request.end();

31. 客户端网络错误

const request = http.get("http://127.0.0.1:9999/", (response) => response.resume());
request.on("error", (error: NodeJS.ErrnoException) => {
  if (error.code === "ECONNREFUSED") console.error("目标端口拒绝连接");
  else console.error(error);
});

response.resume 消费不需要的响应体,便于连接正确收尾。

32. 客户端超时

const request = http.get("http://127.0.0.1:8080/slow", (response) => response.resume());
request.setTimeout(2000, () => request.destroy(new Error("REQUEST_TIMEOUT")));
request.on("error", (error) => {
  if (error.message === "REQUEST_TIMEOUT") console.error("请求超时");
  else console.error("连接或传输失败", error);
});

setTimeout 只通知 socket 一段时间无活动,不会自动销毁请求,也不等于严格的总耗时 deadline。总期限更适合 AbortSignal.timeout 加明确取消。

33. 读取客户端响应流

const request = http.request("http://127.0.0.1:8080/download", async (response) => {
  let totalBytes = 0;
  try {
    for await (const chunk of response) totalBytes += Buffer.from(chunk).length;
    console.log({ status: response.statusCode, totalBytes });
  } catch (error: unknown) { console.error("响应流失败", error); }
});
request.on("error", console.error);
request.end();

客户端 response 也是 IncomingMessage/Readable;Chunk 不是完整消息边界。

function isErrno(error: unknown): error is NodeJS.ErrnoException {
  return error instanceof Error && "code" in error;
}