ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Express框架实战:中间件、路由与错误处理全解析

Express框架实战:中间件、路由与错误处理全解析 我用 Express 写过不少接口服务从几分钟就能跑起来的内部小工具到需要分模块、加权限的生产项目它都是那个“默认不会错”的选择。这个系列前两篇聊了 Node.js 的基础和模块机制这篇就专门拆 Express 框架它到底解决了什么问题、路由和中间件怎么配合、实际开发里有哪些坑是文档里不容易看到的以及面试里反复出现的考点到底在考什么。Express 本身很小核心就是一个路由分发器和中间件机制但它靠着“小核心、可扩展”的设计在 Node.js 生态里站稳了十多年。很多人觉得它“太老”“太简单”其实恰恰是这种简单让它成为理解服务端框架的最佳入口。无论你之后用 NestJS 还是 FastifyExpress 里练熟的那套“请求-中间件-响应”模型都是通用的底子。这一篇不会只贴 Hello World我会带着你从零初始化一个项目手写几个有真实感的接口把中间件顺序、错误捕获、参数校验这些细节讲透最后附上我长期整理的排查笔记和面试速查表。1. 整体设计与选型思路1.1 Express 到底解决的是什么问题Node.js 内置的http模块已经能创建服务器了但直接用你会发现几个痛点。第一路由判断全靠手写if (req.url /xxx)逻辑一多就乱成蜘蛛网。第二请求体解析、Cookie 解析、日志记录、静态文件托管这些通用功能没有统一插槽每次都要自己造轮子。第三对错误处理完全没有约定一个回调里抛异常整个进程可能直接崩掉。Express 在这三件事上给出了标准答案。它提供一套路由 API让你把不同路径和请求方法分门别类它定义了中间件机制让“日志-解析-鉴权-业务-响应”这条流水线可以任意组合它还规定了错误处理中间件的写法把异常收敛到一条链路上。说白了Express 就是把“处理一个 HTTP 请求”这件事拆成了多个可插拔的环节你只需要关心每个环节里自己的那部分业务逻辑。1.2 为什么我仍然建议从 Express 上手你可能听说过 Koa、Fastify甚至 NestJS。我的观点很明确如果目标是理解 Node.js 服务端开发的核心模型Express 是性价比最高的起点。Koa 用async/await重写了中间件模型看起来更现代但它的中间件“洋葱模型”和 Express 的“线性模型”在思维方式上差异很大直接上手 Koa 容易只学到用法搞不清底层请求流程。Fastify 性能强、内置 schema 校验但它对插件的约定和异步启动逻辑对新手有额外负担而且生态里很多老牌中间件还是 Express 风格。NestJS 则完全是另一套体系适合团队协作的大型工程但它的底层默认就是 Express绕不开这一层。当然Express 也有很多被人诟病的地方比如回调风格导致异步代码容易写出面条式嵌套、默认没有参数校验、TS 类型支持全靠补充包。这些确实是问题但不是“不能用的理由”大部分可以通过约定、封装和辅助库解决。下面的选型对比我整理成了表格方便你判断。框架中间件模型参数校验TypeScript性能表现生态成熟度适合场景Express线性回调/同步需额外库社区方案非内置中等偏上极成熟中间件丰富中小型API、快速原型、学习入门Koa洋葱模型async/await需额外库社区方案与Express相近较成熟但部分老中间件不兼容需要更灵活中间件组合的APIFastify插件体系异步内置 JSON Schema内置类型友好高吞吐量优于Express增长快新库多高并发API、微服务NestJS基于Express/FastifyAOP内置 class-validator强内置取决于底层成熟企业级中大型项目、领域驱动设计1.3 明确场景边界Express 适合什么场景不适合什么场景我按经验画一条线。适合的是内部管理系统后端、中小流量 API、BFF后端为前端服务层、快速验证产品原型、物联网设备数据上报服务。不适合的是需要复杂实时长连接且要分布式横向扩容的推送服务、超大型微服务架构里要求吞吐量极限的场景、需要强约束强类型且团队规模很大的长期项目。在适合的范围内Express 的开发效率极高。一个内网报表接口从建目录到上线半小时内完全可能。这也是为什么很多创业公司和运维团队都喜欢拿它写工具类服务。2. 环境准备与最小应用2.1 版本选择与项目初始化先说 Node.js 版本。我建议长期用 LTS 版本比如当前主流的 18.x 或 20.x不要追新。Express 4 和 Express 5 在安装时默认行为不同npm 现在安装express默认装的是 5.x但很多教程和线上老项目还是 4.x。如果你跟着一套 4.x 的教程走装了 5.x 会踩到不少细节差异比如通配路由*的写法、res.status()的使用等。如果你不是要尝鲜建议初始化时显式指定版本。# 初始化项目 mkdir express-demo cd express-demo npm init -y # 安装 Express 4稳定资料最多 npm install express4 # 如果你就是想用最新版 npm install express5初始化完成后package.json里加上type: module可以启用 ESM也可以使用 CommonJS。我个人在小项目里用 CommonJS 更省心因为很多中间件示例代码默认还是require。下面的示例我统一用 CommonJS 写法可读性更好。2.2 最小服务器的三个组成部分一个最基础的 Express 应用本质上就是三句话引入模块、定义路由、监听端口。但理解这三句话背后的分工比背下来更重要。express()生成的是一个应用程序对象它本身就是一组中间件函数的容器。你调用app.get(/)就是在给这个容器追加一个只处理 GET 请求、路径为/的处理函数。app.listen(3000)本质上是内部创建了一个http.Server然后把应用对象作为请求监听器传进去。const express require(express); const app express(); app.get(/, (req, res) { res.send(Hello Express); }); app.listen(3000, () { console.log(server running at http://localhost:3000); });res.send()是 Express 封装好的响应方法它会根据你传入的数据类型自动设置Content-Type。传字符串就是text/html传对象就是application/json。这个细节在刚开始写的时候很省心但到后面要统一返回结构时就要自己写中间件覆盖默认行为。2.3 热更新从 nodemon 到 node --watch开发服务最烦的就是改一行代码要手动重启。以前我们用 nodemon现在 Node.js 原生支持了--watch如果你用的是 Node 18.11 以上版本可以直接用内置能力少装一个全局依赖。# 方式一nodemon nodemon app.js # 方式二Node原生 watchNode 18.11 node --watch app.js个人建议小项目直接用 node 原生的--watch省心。但要注意一点--watch监听的是入口文件及其依赖链如果你的代码里有动态注入的配置目录它不会自动感知。这种情况还是上 nodemon 或者用chokidar自己监听更稳。3. 路由与中间件理解 Express 的骨架3.1 从“路由是路径匹配”到“路由是一组处理流程”很多新手把路由理解成“路径和方法的对应关系”这没错但不够。在 Express 里app.get(/user/:id, handlerA, handlerB)这样的写法其实是在定义一条由多个函数组成的处理链。请求来了先走handlerA再走handlerB只要其中任何一个调用了res.end()或res.send()链路就终止。如果都没有响应最后会走到兜底逻辑。这种设计最大的价值是可以把同一个路由的通用逻辑拆出来。比如根据用户 ID 查询前先做一次参数校验抽一个validateId函数放进去const validateId (req, res, next) { const id Number(req.params.id); if (!Number.isInteger(id) || id 0) { return res.status(400).json({ message: 非法ID }); } req.userId id; next(); }; app.get(/user/:id, validateId, (req, res) { // 这里可以直接使用 req.userId res.json({ id: req.userId }); });next()是那道“放行”指令。不调用next()也不响应请求客户端就会一直等直到超时。这是一个经典坑后面我会专门讲排查。3.2 模块化路由让代码从单文件长成工程当路由少的时候全写在app.js里没问题。一旦超过三十个接口你就需要express.Router()来做模块拆分。它的用法很像一个“迷你 app”可以有自己的中间件和路由定义。// routes/users.js const router express.Router(); router.use((req, res, next) { // 只有访问 /api/users 下的请求才会进入这句日志 console.log(users router, req.method, req.originalUrl); next(); }); router.get(/, (req, res) { res.json({ users: [] }); }); router.get(/:id, (req, res) { res.json({ user: { id: req.params.id } }); }); module.exports router;然后在入口文件里挂载const userRouter require(./routes/users); app.use(/api/users, userRouter);这样/api/users和/api/users/:id都生效了。注意Router 上的路径是“相对于挂载点”的所以在 router 里写/实际对应的是/api/users。这个简写很容易让人迷惑排查路由不匹配时第一个就要检查前缀拼接。3.3 内置中间件与第三方中间件的分工Express 自带了几个关键中间件但它们在不同版本里变化很大这是很多人升级后应用直接挂掉的原因。Express 4.16 之前req.body是默认无法解析的需要单独装body-parser。4.16 之后express.json()和express.urlencoded()被内置进来body-parser就很少需要显式引用了。// 解析 JSON 请求体 app.use(express.json()); // 解析表单请求体extended 允许解析嵌套对象 app.use(express.urlencoded({ extended: true }));这两个中间件必须放在所有依赖req.body的路由之前。如果你看到路由日志里req.body是undefined十有八九就是没加或者顺序不对。还需要一个经常被忽略的内置中间件express.static()。它是用来托管静态资源的图片、CSS、前端构建产物都靠它。它的作用是让指定目录下的文件可以直接通过 URL 访问。// 将 public 目录映射到根路径 app.use(express.static(public));也就是说访问http://localhost:3000/logo.png时会直接返回public/logo.png文件。这个中间件性能不错生产环境前面一般还有 Nginx 顶着但开发环境用它极其方便。第三方的中间件里我几乎每个项目都会用到morgan做访问日志、cors解决跨域、helmet设置安全响应头。它们的配置都比较简单const morgan require(morgan); const cors require(cors); const helmet require(helmet); app.use(morgan(dev)); app.use(cors()); app.use(helmet());这三个中间件就是开发中的“默认三件套”。cors()如果不带参数意味着所有来源都能访问内网工具可以直接用但生产环境的接口必须限制origin这个后面讲常见问题时会展开。3.4 错误处理中间件唯一有四个参数的函数Express 的错误处理中间件和普通中间件的区别从签名上就能看出来它有四个参数第一个是err。它必须放在所有路由之后作为兜底环节。// 普通中间件 app.use((req, res, next) {}); // 错误处理中间件 app.use((err, req, res, next) { console.error(err.stack); res.status(500).json({ message: 服务器内部错误 }); });注意四个参数必须写全。哪怕你不用next也要在参数列表里保留它否则 Express 会把它当成普通中间件处理错误就永远不会进入这个环节。这个细节每年都有新手栽跟头。4. 实操写一组完整的用户接口4.1 接口设计与目录分层为了让你看到真实项目长什么样下面我模拟一个迷你“用户管理”模块。需求很简单提供一个 GET 列表接口、一个 GET 详情接口、一个 POST 创建接口数据先放在内存数组里。但结构要按工程化方式来搭。我推荐的目录组织方式是这样的不一定适用所有项目但对中小型服务足够清晰express-demo/ ├── app.js ├── routes/ │ ├── index.js │ └── users.js ├── controllers/ │ └── users.js ├── middleware/ │ └── errorHandler.js └── data/ └── users.jsroutes里只做路径映射controllers里写业务处理middleware放通用逻辑。这样做的目的不是为了凑文件数而是当路由越来越多时你可以很快定位到某个接口的业务代码在哪里而不是在一个五百行的文件里来回滚动寻找。4.2 模拟数据与控制器实现用内存数组模拟数据是为了让你把注意力放在框架机制上。真实项目里这里是数据库查询但接口层的处理逻辑完全一致。// data/users.js const users [ { id: 1, name: 张三, email: zhangsanexample.com }, { id: 2, name: 李四, email: lisiexample.com } ]; module.exports users;控制器里处理三个场景。列表接口直接返回全部数据详情接口要做 ID 校验和存在性检查创建接口要校验请求体并给新用户分配 ID。// controllers/users.js const users require(../data/users); exports.list (req, res) { res.json({ data: users }); }; exports.detail (req, res) { const id Number(req.params.id); if (!Number.isInteger(id) || id 0) { return res.status(400).json({ message: 非法ID }); } const user users.find((item) item.id id); if (!user) { return res.status(404).json({ message: 用户不存在 }); } res.json({ data: user }); }; exports.create (req, res) { const { name, email } req.body; if (!name || !email) { return res.status(400).json({ message: name 和 email 必填 }); } const newUser { id: users.length 1, name, email }; users.push(newUser); res.status(201).json({ data: newUser }); };这三段代码基本涵盖了一个业务接口最常见的三件事参数校验、业务查询、统一响应。我在响应体里统一包了一层{ data: ... }这是一种约定。真实项目里建议团队统一这种格式前端处理起来会非常舒服。4.3 路由接入与整个应用串起来路由文件绑定控制器// routes/users.js const express require(express); const usersCtrl require(../controllers/users); const router express.Router(); router.get(/, usersCtrl.list); router.get(/:id, usersCtrl.detail); router.post(/, usersCtrl.create); module.exports router;入口文件装配所有中间件和路由// app.js const express require(express); const usersRouter require(./routes/users); const errorHandler require(./middleware/errorHandler); const app express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(/api/users, usersRouter); // 404 兜底 app.use((req, res) { res.status(404).json({ message: 接口不存在 }); }); app.use(errorHandler); app.listen(3000, () { console.log(server running at http://localhost:3000); });到这里一个结构完整的 Express 应用就跑起来了。你可以用 curl 试试curl http://localhost:3000/api/users curl http://localhost:3000/api/users/1 curl -X POST http://localhost:3000/api/users \ -H Content-Type: application/json \ -d {name:王五,email:wangwuexample.com}用 curl 测试时POST 请求如果忘记加Content-Type: application/jsonreq.body会是undefined或空对象这是我自己踩过的坑新手尤其容易犯。你在代码里校验!name时空对象也能通过检查但真实数据永远拿不到排查起来非常困惑。5. 常见问题与排查技巧实录5.1 路由全部 404中间件顺序的魔力在 Express 里执行顺序是严格按照中间件挂载的前后顺序来的。如果你把app.use(/api/users, usersRouter)写在 404 兜底之后那所有请求都会先进入 404 逻辑路由永远不会命中。这类问题排查方式很简单在入口文件加一行放在最前面的日志中间件打印出每个请求的方法和路径然后对比你预期的路由前缀。app.use((req, res, next) { console.log(${req.method} ${req.originalUrl}); next(); });另外还要检查一个地方app.use和app.get/post的区别。app.use匹配的是“路径前缀”只要 URL 以这个字符串开头就会命中。而app.get/post匹配的是“完整路径”。比如app.use(/user, handler)会同时拦截/user和/user/123但app.get(/user, handler)只能命中/user。这个差异看起来简单实际排查时经常是根因。5.2 req.body 是 undefined 或空对象这个问题的原因分三类。第一没有在路由前挂载express.json()或express.urlencoded()。第二请求头的Content-Type不对客户端发的是text/plainJSON 解析中间件不会处理它结果就是空对象。第三请求体格式有问题比如 JSON 字符串写成了单引号。我当时排查过一个诡异现象同一个 POST 接口用 Postman 调用没问题用前端页面调用就拿不到参数。最后定位到是前端代码用了axios默认Content-Type是application/json但请求拦截器里有人全局设置了headers把 Content-Type 覆盖成了application/x-www-form-urlencoded。请求体还是 JSON 字符串但 Express 用表单解析去处理导致结果完全错乱。所以看到req.body不对先抓包看实际的 Content-Type再往后查。5.3 异步错误导致的进程崩溃Express 4 的另一个经典坑是在路由里写async函数如果异步操作抛了异常这个异常不会被 Express 自动捕获。原因很简单Express 4 的路由处理器按回调形式设计异步 Promise 的 rejection 不会主动链到next。如果你的代码是这样的app.get(/user, async (req, res) { const user await getUser(); res.json({ user }); });而getUser()抛错了Express 不会走进错误处理中间件而是会直接变成一个未处理的 Promise rejection在 Node 进程层面可能引发 crash。解决办法是统一包一层const asyncHandler (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; app.get(/user, asyncHandler(async (req, res) { const user await getUser(); res.json({ user }); }));你可能已经注意到Express 5 在这一块做了改进它内部会自动捕获异步错误。如果你用的是 Express 5就不需要这个包装了。这也是我建议新项目在熟练之后考虑升级 5 的原因之一。5.4 跨域问题不是只有 CORS提到跨域大部分人的第一反应是装cors中间件。但这只解决“浏览器拦截响应”的问题。在开发阶段你还会遇到另一个情况预检请求OPTIONS直接返回 404。原因是 Express 默认没有对OPTIONS请求做统一处理如果你没有显式定义app.options(/api/users, ...)预检请求就会落到 404 兜底。使用cors中间件后它会自动处理OPTIONS并返回正确的Access-Control-Allow-Origin和Access-Control-Allow-Methods等响应头所以这个问题通常会被顺带解决。但要注意配置细节。生产环境里如果只写app.use(cors())等于告诉浏览器“任何来源都能访问你的接口”。正确做法是限定 originconst cors require(cors); app.use(cors({ origin: [https://admin.example.com], methods: [GET, POST, PUT, DELETE], allowedHeaders: [Content-Type, Authorization] }));如果前端还带着 Cookie 请求你需要显式加credentials: true并且这时origin不能写成*必须指定具体域名。这一对配置经常有人漏掉后一个导致登录态异常。5.5 面试高频问题速查把热搜词里“node express 面试题”拎出来说。面试官问 Express翻来覆去就那么几个角度我帮你理一下答题要点。第一个问题Express 的中间件机制是什么你至少要答出三点中间件是按顺序执行的函数它可以操作请求和响应对象通过next()决定是否放行到下一个环节。能结合“日志-解析-鉴权-业务-错误处理”这条链路来说基本就算过。第二个问题app.use和app.get有什么区别核心回答是路径匹配规则app.use匹配前缀app.get匹配完全路径。再补一句app.use通常挂全局中间件app.get挂具体路由。第三个问题如何处理异步错误如果面试官问的是 Express 4框架不会自动捕获异步异常需要asyncHandler包装或改用 Express 5。如果问的是 Express 5就直接答 5 解决了这个问题但老代码升级时要注意兼容性。第四个问题Express 的 Router 和整个 app 有什么关系回答方向是Router 相当于一个迷你 app它可以拥有自己的中间件和路由最后通过app.use挂载到某个前缀下。第五个问题动态路由的写法。用req.params.id拿参数注意 5.x 里的*通配符写法变了app.get(*, ...)这种写法在 Express 5 里要改成app.get(/*splat, ...)或者用中间件兜底。面试时如果提到升级经历这个细节是加分项。5.6 Express 4 升级到 Express 5 的兼容清单我最近把一个工具服务从 Express 4 升到了 5除了异步错误自动捕获真的方便还碰到几个破坏性变更列在这里免得你踩同样的坑。app.del()被移除必须用app.delete()这个影响不大。通配路由变了*不再表示路径通配要使用命名通配符/*splat。res.send(status)这种把状态码当第一个参数的旧写法被移除必须写res.status(status).send(...)。req.query变成了 getter不能直接赋值修改。路由响应中的路径参数行为也有调整req.params的解析更加严格。如果你要升级一个老项目我的建议是先在测试环境把路由全部走一遍重点看通配路由、res.send(status)这类调用然后小流量灰度。不要直接全量替换。6. 中间件封装从“能用”到“好用”6.1 封装统一响应格式前面控制器里我写了res.send()和res.json()但真实项目里更好的做法是封装一个统一响应中间件让所有接口返回格式一致。这样前端不管接哪个接口都能按同样的结构去解析数据。一个常规方案是给res挂两个自定义方法。这个方法可行但要注意别跟 Express 内置方法重名。// middleware/response.js module.exports (req, res, next) { res.success (data null, message ok) { res.json({ code: 0, data, message }); }; res.fail (message error, status 400) { res.status(status).json({ code: status, data: null, message }); }; next(); };在入口文件里把它挂到所有路由之前控制器就可以改写成exports.list (req, res) { res.success(users); }; exports.detail (req, res) { // 校验失败时 res.fail(用户不存在, 404); };这样写之后代码里的res.json大量减少接口风格被迫统一。团队协作时这种隐式约束比文档上的“同事自觉”可靠得多。6.2 全局错误处理中间件的正确姿势前面已经写过错误处理中间件的签名这里补充两个实操细节。第一在错误处理中间件里要区分“业务校验失败”和“不可预知异常”。业务校验失败应该返回 4xx并带上明确信息未知异常要返回 5xx日志里记录堆栈但响应体里不要泄漏堆栈细节。// middleware/errorHandler.js module.exports (err, req, res, next) { // 校验类错误假设错误对象上有 status 字段 if (err.status) { return res.status(err.status).json({ message: err.message }); } console.error(未捕获异常:, err); res.status(500).json({ message: 服务器内部错误 }); };第二如果错误处理中间件自己又抛了异常或者你写了next()那就成死循环了。在错误处理中间件里不要调用next()适当时直接返回响应即可。6.3 请求日志 请求ID 耗时统计写多了接口你会发现联调时前端提一句“接口好慢”你根本不知道是慢在哪个节点。最基础的观测手段就是在入口处打请求日志在出口处算耗时。app.use((req, res, next) { const start Date.now(); const { method, originalUrl } req; res.on(finish, () { const cost Date.now() - start; console.log(${method} ${originalUrl} ${res.statusCode} ${cost}ms); }); next(); });如果你用 morgan 的combined格式它自带 HTTP 状态和响应时间但没法直接打印请求 ID。为了串联日志我会在入口用crypto.randomUUID()生成一个请求 ID放到req.requestId里同时在响应头里回传。这样后端日志和前端传回的请求 ID 能对上排查问题省力不少。const { randomUUID } require(crypto); app.use((req, res, next) { req.requestId randomUUID(); res.setHeader(X-Request-Id, req.requestId); next(); });7. 扩展思路Fastify 什么时候更适合你热搜里出现了“express fastify”的对比我在这里多说几句。Fastify 现在越来越流行它的核心卖点包括内置 JSON Schema 校验、性能更优、插件体系更规范、TypeScript 支持更好。我在一个高并发推送转发的场景里用过 Fastify。当时 Express 的吞吐量确实到了瓶颈换到 Fastify 后同样配置下 QPS 提升了 30% 左右。但提升的代价是很多 Express 时代熟悉的中间件不能直接用需要找 Fastify 插件版文档和示例相对少一些。所以我的建议是如果你的项目还处于快速迭代期团队对 Node.js 还在熟悉过程中继续用 Express 完全没问题它的稳定性和生态是最大保障。如果你明确了高吞吐需求或者项目从一开始就要上严格的请求校验体系那可以考虑 Fastify。这里也附一个从 Express 迁移到 Fastify 的粗粒度思路。路由拆分逻辑可以保留express.Router要换成 Fastify 的插件封装req.params、req.query在 Fastify 里语义类似但 query 默认是只读的修改需要额外开启中间件体系从线性变为“封装 前置处理”不太建议硬搬。我并不是劝你换框架而是提供一个判断坐标框架本身不是最重要的重要的是它是否贴合你的团队规模和业务增长节奏。Express 这辈子大概率不会被淘汰但你的项目可能会长大到需要更“重”的框架。8. 我的实际操作体会整套 Express 学下来我最大的感受是它把“繁琐”和“复杂”分开了。繁琐的地方比如请求解析、路由分发它帮你做到了复杂的地方比如业务隔离、错误收敛、工程化拆分它留出了足够的扩展空间但不给你强行规定。我现在写 Express 项目基本固定了一套个人模板入口文件只做仓库装配路由按领域拆到routes目录控制器再单独提一层公共逻辑全部抽到middleware目录。代码写起来可能没有 NestJS 那种“仪式感”但胜在直白任何一个后来人打开目录都能快速找到他要改的地方。最后说一个每个新手都应该养成的习惯不要急着一上来就搭大而全的工程先从一个 20 行的接口开始跑通再加日志加错误处理加参数校验加路由拆分。每一步都清楚它解决什么问题。Express 的魅力恰恰就在这里少即是多全由你掌控。
返回列表