构建 Web 服务器

Express + TypeScript——路由、中间件、错误处理

🎯 学习目标

学完本章,你将能够:

  • 使用 Express 创建 Web 服务器,实现基本路由和请求处理
  • 使用 Router 模块化组织路由代码
  • 理解中间件流水线机制,能编写自定义中间件
  • 实现全局错误处理和自定义错误类
  • 配置 CORS 跨域访问和静态文件服务

你有没有想过

当你在浏览器输入一个网址按下回车,服务器是怎么"听懂"并回复的?

这个过程的核心就是 Web 服务器。它接收 HTTP 请求,处理后返回响应。

Express 是 Node.js 生态中最成熟的 Web 框架。配合 TypeScript,你可以在写路由时就获得参数类型提示和编译时检查。这一章我们来学习如何用它构建一个完整的 Web 服务器。

请求响应流程

从客户端到服务器再回来的完整路径

图:Web 请求响应完整流程时序

Part 1: Express 集成

从零搭建一个带类型的 Web 服务器

Express 本身是 JavaScript 写的,但配合 @types/express 可以获得完整的 TypeScript 类型支持。这一部分我们会完成基本路由、请求类型定义、跨域配置和静态文件服务——这是任何 Web 服务器的地基。

基本路由

安装 Express 和类型定义后,立刻就能写路由

$ npm install express
$ npm install -D @types/express
import express, { Request, Response, NextFunction } from "express";

const app = express();

// 中间件
app.use(express.json());                           // 解析 JSON body
app.use(express.urlencoded({ extended: true }));   // 解析表单

// GET 路由
app.get("/", (req: Request, res: Response) => {
    res.json({ message: "Hello, TypeScript!" });
});

// 路由参数
app.get("/users/:id", (req: Request<{ id: string }>, res: Response) => {
    const { id } = req.params;
    res.json({ id, name: "张三" });
});

// 查询参数
app.get("/search", (req: Request, res: Response) => {
    const { q, page = "1", limit = "10" } = req.query;
    res.json({ query: q, page: Number(page), limit: Number(limit) });
});

app.listen(3000);

POST 与类型

用泛型参数给 Request 加上 Body 类型

interface CreatePostBody {
    title: string;
    content: string;
    tags?: string[];
}

// 泛型参数顺序:Request<Params, ResBody, ReqBody, Query>
app.post("/posts", (req: Request<{}, {}, CreatePostBody>, res: Response) => {
    const { title, content, tags } = req.body;
    // title 和 content 在这里有完整的类型提示
    res.status(201).json({ id: "1", title, content, tags });
});
泛型参数顺序容易记错:Request<Params, ResBody, ReqBody, Query>。建议只记常用的,复杂的场景用自定义 TypedRequest 封装。

类型扩展

给所有路由的 req 加上自定义字段

// src/types/express.d.ts
declare module "express-serve-static-core" {
    interface Request {
        userId?: string;
        user?: {
            id: string;
            name: string;
            role: "admin" | "user";
        };
    }
}

// 现在所有路由中都可以访问 req.userId 和 req.user
app.get("/me", (req: Request, res: Response) => {
    res.json({ userId: req.userId, user: req.user });
});

这种"声明合并"的技巧在 Express 项目中非常常用。通过在 .d.ts 文件中扩展类型,你可以在整个项目中共享自定义的请求字段。

跨域问题

浏览器为什么阻止你的 API 请求?

当你的前端跑在 localhost:5173,后端跑在 localhost:3000 时,浏览器会拦截请求。这就是 CORS(Cross-Origin Resource Sharing)策略——浏览器只允许同源(相同协议+域名+端口)的请求直接通过。

图:CORS 跨域请求被浏览器拦截的时序
CORS 是浏览器的安全策略,不是服务器的限制。Postman 和 curl 不受影响,因为它们没有"同源策略"的概念。

CORS 配置

用 cors 中间件一行搞定跨域

$ npm install cors
$ npm install -D @types/cors
import cors from "cors";

// 开发环境:允许所有来源
app.use(cors());

// 生产环境:精确配置
const corsOptions: cors.CorsOptions = {
    origin: ["https://myapp.com", "https://admin.myapp.com"],
    methods: ["GET", "POST", "PUT", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: true,           // 允许携带 Cookie
    maxAge: 86400,               // preflight 缓存 24 小时
};

app.use(cors(corsOptions));

credentials: true 允许前端携带 Cookie 和 Authorization 头。但注意,开启 credentials 时 origin 不能是 *,必须写明具体域名。浏览器对"预检请求"(OPTIONS)会先发一次探测,cors 中间件会自动处理。

静态文件

Express 也能直接服务 HTML、CSS、JS 和图片

import express from "express";

// 基本用法:把 public 目录映射为静态资源
app.use(express.static("public", {
    maxAge: "1d",             // 浏览端缓存 1 天
    etag: true,               // 启用 ETag
    dotfiles: "deny",         // 禁止访问 .env 等隐藏文件
}));

// 访问 public/logo.png → http://localhost:3000/logo.png
// SPA 部署:React/Vue 打包后,所有路径都回退到 index.html
import path from "path";

app.use(express.static(path.join(__dirname, "../client/dist")));

// 必须放在所有 API 路由之后!
app.get("*", (_req, res) => {
    res.sendFile(path.join(__dirname, "../client/dist/index.html"));
});
安全提醒:默认启用目录列表时可能暴露文件结构。生产环境建议设置 dotfiles: "deny",并且不要把服务器源码目录作为静态目录。

Part 2: 路由模块化

把路由拆成独立模块,项目才能长得住

把所有路由塞在一个文件里,很快就会变成"面条代码"。Express Router 让你按功能拆分路由——用户路由、文章路由、认证路由各一个文件,最后在入口统一挂载。这也是中大型项目的基本组织方式。

Express Router

每个模块一个路由文件,最后挂载到 app 上

// src/routes/users.ts
import { Router } from "express";

const router = Router();

router.get("/", async (req, res, next) => {
    try {
        const users = await userService.findAll();
        res.json(users);
    } catch (err) {
        next(err); // 交给错误处理中间件
    }
});

router.post("/", async (req, res, next) => {
    try {
        const user = await userService.create(req.body);
        res.status(201).json(user);
    } catch (err) {
        next(err);
    }
});

export default router;
// src/index.ts —— 挂载路由
import userRoutes from "./routes/users";
import postRoutes from "./routes/posts";

app.use("/api/users", userRoutes);
app.use("/api/posts", postRoutes);

Part 3: 中间件

请求处理流水线上每一个工位

中间件是 Express 的灵魂。你可以把它理解为"请求处理流水线上的一个工位"——每个工位可以检查、修改请求,或直接截断响应。这一部分我们会学习中间件的执行顺序、next() 的三种用法、日志记录、Cookie 解析、认证以及限流。

中间件流水线

请求穿过一个又一个中间件,直到有人响应

图:Express 中间件流水线处理流程

每个中间件有三个选择:调用 next() 放行、发送响应截断、抛出错误。

next() 三种用法

放行、跳过、报错——理解中间件链的关键

// ① next() —— 交给下一个中间件
app.use((req, res, next) => {
    console.log("第一步");
    next();  // 继续往下走
});

// ② next(err) —— 跳过所有普通中间件,直接进入错误处理器
app.use((req, res, next) => {
    const err = new Error("出问题了");
    next(err);  // 跳到 4 参数的 errorHandler
});

// ③ next('route') —— 跳过当前路由剩下的中间件(只在 app.METHOD 中有效)
app.get("/admin",
    (req, res, next) => {
        if (!req.user?.isAdmin) return next("route"); // 跳到下一个 /admin 路由
        next();
    },
    (req, res) => {
        res.json({ msg: "欢迎管理员" });
    }
);
app.get("/admin", (req, res) => {
    res.json({ msg: "你不是管理员" });  // next('route') 跳到这里
});

next() 放行到下一个中间件;next(err) 把错误一路传到四参数错误处理器;next('route') 跳过当前路由组中剩余的处理函数,进入下一个匹配的路由。理解这三种调用方式,你就掌握了 Express 中间件的执行模型。

日志中间件

记录每个请求的方法、路径、耗时

import { Request, Response, NextFunction } from "express";

function logger(req: Request, res: Response, next: NextFunction): void {
    const start = Date.now();
    res.on("finish", () => {
        const duration = Date.now() - start;
        console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`);
    });
    next();
}

app.use(logger);

结构化日志

生产环境用 pino 替代 console.log

$ npm install pino pino-http
import pino from "pino";
import pinoHttp from "pino-http";

const logger = pino({
    level: process.env.LOG_LEVEL || "info",  // debug / info / warn / error
    transport: process.env.NODE_ENV !== "production"
        ? { target: "pino-pretty" }   // 开发环境彩色输出
        : undefined,                   // 生产环境输出 JSON
});

// 请求日志中间件——自动记录 method、url、status、耗时
app.use(pinoHttp({ logger }));

// 业务日志
logger.info({ userId: "123" }, "用户登录");
logger.warn({ memory: "90%" }, "内存使用率过高");
logger.error({ err }, "数据库连接失败");

日志级别从低到高:trace < debug < info < warn < error < fatal。开发用 debug,上线用 info,只把 error 和 warn 写入告警系统。结构化日志(JSON 格式)方便 ELK 等日志平台检索和分析。

认证中间件

检查请求头中的 Token,未认证则直接拒绝

function authMiddleware(req: Request, res: Response, next: NextFunction): void {
    const token = req.headers.authorization?.replace("Bearer ", "");
    if (!token) {
        res.status(401).json({ error: "未提供认证令牌" });
        return;
    }
    try {
        const payload = verifyToken(token);
        req.userId = payload.userId;
        req.user = payload.user;
        next();
    } catch {
        res.status(401).json({ error: "令牌无效或已过期" });
    }
}

// 受保护的路由
app.use("/api/posts", authMiddleware, postRoutes);

Cookie 与解析

请求体和 Cookie 的解析中间件

$ npm install cookie-parser
$ npm install -D @types/cookie-parser
import cookieParser from "cookie-parser";

// 注册中间件(放在路由之前)
app.use(express.json());             // 解析 JSON body → req.body
app.use(express.urlencoded({ extended: true }));  // 解析表单 → req.body
app.use(cookieParser("my-secret"));  // 解析 Cookie → req.cookies

// 读取 Cookie
app.get("/api/profile", (req, res) => {
    const theme = req.cookies.theme;           // 普通 Cookie
    const token = req.signedCookies.token;     // 签名 Cookie(防篡改)
    res.json({ theme, token });
});

// 设置签名 Cookie
app.post("/api/theme", (req, res) => {
    res.cookie("token", "abc123", {
        signed: true,       // 用密钥签名,客户端无法伪造
        httpOnly: true,     // JS 无法读取(防 XSS)
        maxAge: 86400000,   // 1 天过期
    });
    res.json({ ok: true });
});
签名 vs 普通Cookie:签名 Cookie 用服务端密钥生成校验值,客户端篡改后服务端能检测到。适合存储 session ID、token 等敏感数据。

异步中间件

Express 不自动捕获 async 错误,需要包装一层

// 异步中间件包装器
function asyncHandler(
    fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
    return (req: Request, res: Response, next: NextFunction) => {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}

// 使用——无需 try/catch,错误自动传给 errorHandler
app.get("/api/users", asyncHandler(async (req, res) => {
    const users = await userService.findAll();
    res.json(users);
}));

这是 Express 项目中的经典模式。每个 async 路由都套一层 asyncHandler,就不用在每个路由里手动 try/catch 了。

请求限流

防止恶意用户把你的服务器打爆

$ npm install express-rate-limit
import rateLimit from "express-rate-limit";

// 全局限流:所有接口每 15 分钟最多 100 次请求
const globalLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 100,
    message: { error: "请求太频繁,请稍后再试" },
    standardHeaders: true,   // 返回 RateLimit-* 头
    legacyHeaders: false,
});

app.use(globalLimiter);

// 单路由限流:登录接口每分钟最多 5 次
const loginLimiter = rateLimit({
    windowMs: 60 * 1000,
    max: 5,
    message: { error: "登录尝试过多,请1分钟后再试" },
});

app.post("/api/login", loginLimiter, asyncHandler(async (req, res) => {
    // 登录逻辑...
}));

限流是生产环境的基本防护。全局限流防止整体过载,单路由限流保护敏感操作(登录、注册、发送验证码)。standardHeaders: true 让前端通过响应头知道剩余配额,配合前端做友好提示。

Part 4: 错误处理

统一兜底,让错误不再是噩梦

程序总会出错。与其让每个路由自己处理错误,不如统一在一个地方兜底。这一部分我们用自定义错误类区分"预期的业务错误"和"未知的系统 bug",再通过全局错误处理器统一返回格式化的响应。

自定义错误类

用 AppError 区分"预期的错误"和"未知的 bug"

class AppError extends Error {
    constructor(
        public statusCode: number,
        public message: string,
        public isOperational = true,
    ) {
        super(message);
    }
}

// 在路由中使用
app.get("/api/users/:id", asyncHandler(async (req, res) => {
    const user = await userService.findById(req.params.id);
    if (!user) {
        throw new AppError(404, "用户不存在");
    }
    res.json(user);
}));

全局错误处理

四个参数的中间件就是错误处理器,必须注册在所有路由之后

// 全局错误处理中间件(4个参数)
function errorHandler(
    err: Error, req: Request, res: Response, next: NextFunction
): void {
    if (err instanceof AppError) {
        res.status(err.statusCode).json({
            status: "error",
            message: err.message,
        });
        return;
    }

    // 未知错误——不要暴露内部细节
    console.error("未处理的错误:", err);
    res.status(500).json({
        status: "error",
        message: "服务器内部错误",
    });
}

// 注册(必须在所有路由之后!)
app.use(errorHandler);

// 404 处理
app.use((_req, res) => {
    res.status(404).json({ error: "接口不存在" });
});

常见问题

中间件的注册顺序很重要! 日志要放在最前面,错误处理要放在最后面。

问题一:中间件不生效

原因:app.use(middleware) 写在了路由定义之后。

修复:中间件必须在路由之前注册。Express 按注册顺序执行。

问题二:async 路由报错但服务器没有响应

原因:Express 4.x 不会自动捕获 async 函数抛出的错误。

修复:使用 asyncHandler 包装,或在每个路由内手动 try/catch 并调用 next(err)。

问题三:404 处理器拦截了所有请求

原因:app.use((_req, res) => ...) 注册在了业务路由之前。

修复:404 处理器必须放在所有路由和错误处理器的最后。

试一试

练习一

任务:创建一个 GET /api/time 接口,返回当前时间和请求的 URL。给它加上日志中间件。

// 提示:在 res.on("finish", ...) 中记录日志
// 你的代码:

参考答案

app.use(logger); // 日志中间件
app.get("/api/time", (req, res) => {
    res.json({ time: new Date().toISOString(), url: req.url });
});

练习二

任务:写一个 POST /api/users 路由,要求 body 中必须有 name 和 email。如果缺少,用 AppError 返回 400。

// 提示:检查 req.body.name 和 req.body.email
// 你的代码:

参考答案

app.post("/api/users", asyncHandler(async (req, res) => {
    const { name, email } = req.body;
    if (!name || !email) {
        throw new AppError(400, "name 和 email 必填");
    }
    res.status(201).json({ name, email });
}));

📝 要点总结

本章核心知识点

Express 路由

app.get/post/put/delete 定义端点,Router 模块化让路由清晰可维护

中间件

中间件是 Express 的核心——日志、认证、限流都靠它,next() 控制流程

错误处理

自定义错误类 + 全局错误处理中间件 = 统一的错误响应格式

CORS

跨域是前后端分离的必经之路,cors 中间件一行搞定

下一章

Web 服务器的基础你已经掌握了。下一章看看现代后端框架——Express 之外的更多选择。

1 / X