ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js + Express 搭建分层后端接口模块:架构与错误处理实战

Node.js + Express 搭建分层后端接口模块:架构与错误处理实战

前阵子要给一个内部管理工具做一批接口,前后端并行开发,后端得先拿出一套能跑、能测、结构还算清晰的东西给前端对接。项目不复杂,但也不能像教学demo那样把所有代码堆在一个文件里。当时选型没什么犹豫,直接用了Node.js搭配express框架。真正动手之后发现,快速搭一个“能用的”接口模块不难,但要把目录结构、响应格式、错误处理这些细节一次性理顺,还是有几条值得记录的弯路。

这篇文章就从我这个实际项目出发,完整拆解一遍如何用Node.js和express搭建一个后端业务接口模块Demo。核心围绕任务管理接口来演示,覆盖环境初始化、分层目录设计、路由与业务逻辑拆分、统一响应封装、错误处理、参数校验、常见坑点排查等环节。适合两类人阅读:一是前端同学想了解后端接口是怎么组织出来的,二是刚接触Node生态、准备把第一个后端项目搭得稍微正经一点的新手。

1. 项目定位与技术选型:Demo也要按正经项目的架子搭

1.1 这个Demo到底要解决什么问题

很多新手学express,跑通一个hello world之后就觉得“会了”,但真到了需要给前端提供接口的时候,会发现一堆问题:接口地址怎么规划、返回数据格式能不能统一、业务逻辑和路由代码全塞一起后面怎么维护、报错信息怎么让前端看得懂。

这个Demo的核心目标不是实现多么复杂的业务,而是搭建一套可以直接复用的接口模块骨架。也就是说,写完这个项目之后,你要新加一个用户模块、订单模块、通知模块,只需要复制一个模块的目录结构,改改其中的路由和业务实现,就能快速产出新接口。

我把这套骨架拆成五个关注点:

  • 路由层负责接口地址分发,不写业务逻辑
  • 控制器层负责接收请求参数、调用业务方法、返回响应
  • 服务层负责核心业务处理,做到与HTTP细节解耦
  • 统一响应格式,让前端拿到结构一致的JSON
  • 全局错误处理,不把堆栈信息裸奔给调用方

这五个点听起来像是后端架构的常识,但实际很多demo项目都不会一次性做好。

1.2 为什么选Express而不是Koa或Fastify

这里简单聊一下选型。Node生态里现在Web框架不少,常见的有Express、Koa、Fastify、NestJS。我为什么在快速搭建这个场景下选了Express?

第一,Express的生态最成熟。中间件数量、文档丰富度、社区问答沉淀都远高于其他框架。遇到问题搜一下基本都有答案,这对快速交付非常重要。

第二,Express对新手更友好。它的中间件模型是线性的“请求-响应”流转,理解成本低。Koa虽然更现代,但洋葱圈模型和上下文对象需要多花一点时间适应。Fastify性能好、自带schema校验,但插件体系相对小众。NestJS适合大型项目,但引入装饰器、依赖注入等一堆概念,做Demo反而拖慢节奏。

第三,团队协作中Express的兼容性最好。不管同事之前用的是JavaScript还是TypeScript,拿起来都能快速读懂代码。

如果你后面项目的并发量真的到了需要极致优化的时候,再考虑迁到Fastify不迟。对我来说,快速、稳定、大家都会,就是选Express的全部理由。

1.3 目录结构:一上来就把架子搭对

这个Demo的业务场景是任务管理,提供创建任务、查询列表、修改任务、删除任务这几个接口。目录结构参考了常见的分层思路,但不过度设计:

node-express-demo/ ├── package.json ├── server.js ├── app.js └── src/ ├── routes/ │ └── task.routes.js ├── controllers/ │ └── task.controller.js ├── services/ │ └── task.service.js ├── models/ │ └── task.model.js ├── middlewares/ │ └── errorHandler.js └── utils/ └── response.js

入口文件拆成两个:server.js只负责启动HTTP服务,app.js负责创建express实例、注册中间件和路由。这样后面做单元测试时,可以直接对app发起请求而不真正监听端口。

src目录按职责分成了routes、controllers、services、models、middlewares、utils六个子目录。很多Demo项目的问题是路由里直接写业务逻辑,controller里又处理数据存取,导致接口一多就纠缠不清。这里明确分层,每一层只做一件事情。

2. 环境准备与项目初始化:先把启动流程理顺

2.1 Node版本与包管理工具的选择

在动手写代码之前,先确认本机环境。这个项目需要Node.js环境,建议版本在14以上。我本机用的是18版本,支持较新的ES语法和稳定的fetch API。检查版本方式:

node -v npm -v

包管理工具方面,npm是Node自带的,不需要额外安装,用起来最省事。如果你本地装了pnpm或者yarn,当然也可以,但注意团队协同时统一即可,避免出现lockfile混乱。我这次直接用npm。

2.2 初始化项目并安装依赖

创建一个空目录,进入后执行初始化命令:

mkdir node-express-demo cd node-express-demo npm init -y

npm init -y会生成一个默认的package.json,后面按需修改。然后安装express本体和开发依赖nodemon:

npm install express npm install -D nodemon

express是运行时依赖,nodemon是开发辅助工具,用于监听文件变化自动重启服务。实际部署时不需要nodemon,所以装在devDependencies里。

2.3 入口文件的写法与启动脚本

打开package.json,scripts部分改成这样:

"scripts": { "start": "node server.js", "dev": "nodemon server.js" }

然后创建server.js:

const app = require('./app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server is running at http://localhost:${PORT}`); });

用环境变量控制端口是常见做法,方便部署时由平台指定端口号。本地开发默认3000,如果被占用也能快速用环境变量换端口。

此时简单创建一个app.js验证环境通不通:

const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('Hello Express'); }); module.exports = app;

执行npm run dev,浏览器访问http://localhost:3000,看到“Hello Express”说明基础环境没问题。这一步虽然简单,却值得认真做一遍,把启动流程跑通了,后面所有模块的调试都建立在这个基础之上。

3. 核心接口模块的实现:路由、控制器、服务层各司其职

3.1 路由层:接口地址的统一入口

路由层把不同URL分发给对应的控制器。新建src/routes/task.routes.js:

const express = require('express'); const taskController = require('../controllers/task.controller'); const router = express.Router(); router.get('/', taskController.list); router.get('/:id', taskController.detail); router.post('/', taskController.create); router.put('/:id', taskController.update); router.delete('/:id', taskController.remove); module.exports = router;

这里只做了URL到控制器的映射,没有写任何业务代码。有一个细节值得注意:路由参数:id放在具体路径后面,路由匹配时Express会自动解析到req.params.id。

在app.js里挂载路由时统一加上/api/tasks前缀:

const taskRoutes = require('./src/routes/task.routes'); app.use(express.json()); app.use('/api/tasks', taskRoutes);

这样控制器里就不用关心前缀路径,只处理/、/:id这类相对路径即可。后面如果新增版本号,比如/api/v2/tasks,只需要改挂载位置。

3.2 控制器层:只做参数接收和响应返回

控制器负责从请求中取数据、调用服务层、把结果通过统一响应格式返回。这个Demo中task.controller.js如下:

const taskService = require('../services/task.service'); const { success, fail } = require('../utils/response'); exports.list = (req, res, next) => { try { const data = taskService.getTaskList(req.query); res.json(success(data)); } catch (error) { next(error); } }; exports.detail = (req, res, next) => { try { const data = taskService.getTaskById(req.params.id); res.json(success(data)); } catch (error) { next(error); } }; exports.create = (req, res, next) => { try { const data = taskService.createTask(req.body); res.json(success(data, '创建成功')); } catch (error) { next(error); } }; exports.update = (req, res, next) => { try { const data = taskService.updateTask(req.params.id, req.body); res.json(success(data, '更新成功')); } catch (error) { next(error); } }; exports.remove = (req, res, next) => { try { taskService.deleteTask(req.params.id); res.json(success(null, '删除成功')); } catch (error) { next(error); } };

可以看到每个函数都包了try/catch,catch里调用next(error)把错误抛给全局错误处理中间件,而不是自己处理。这个约定非常关键,能保证所有异常都有唯一的出口。

3.3 服务层:真正的业务逻辑所在

服务层不感知HTTP,只接收普通数据和返回普通对象。这样的好处是业务方法可以被多个控制器复用,也可以直接在测试脚本里调用。task.service.js:

const taskModel = require('../models/task.model'); const { BizError } = require('../utils/response'); exports.getTaskList = (query) => { const { keyword, status } = query; return taskModel.filterByCondition({ keyword, status }); }; exports.getTaskById = (id) => { const task = taskModel.findById(id); if (!task) { throw new BizError('TASK_NOT_FOUND', '任务不存在'); } return task; }; exports.createTask = (payload) => { const { title, description, status } = payload; if (!title || !title.trim()) { throw new BizError('PARAM_ERROR', '标题不能为空'); } return taskModel.create({ title, description, status }); }; exports.updateTask = (id, payload) => { const updated = taskModel.update(id, payload); if (!updated) { throw new BizError('TASK_NOT_FOUND', '任务不存在'); } return updated; }; exports.deleteTask = (id) => { const removed = taskModel.delete(id); if (!removed) { throw new BizError('TASK_NOT_FOUND', '任务不存在'); } return true; };

这里抛出的是自定义的BizError,带有业务错误码和提示信息。区分错误类型很重要:参数错误、资源不存在这类错误属于“可预见的业务错误”,不应该被当作系统异常处理,而是要让前端拿到明确的提示。

3.4 数据层:先用内存存储把流程跑通

为了聚焦接口模块的组织方式,这个Demo先用内存数组存储任务数据。task.model.js:

let tasks = []; let nextId = 1; function filterByCondition({ keyword, status }) { let result = tasks; if (status) { result = result.filter((item) => item.status === status); } if (keyword) { result = result.filter((item) => item.title.includes(keyword)); } return result; } function findById(id) { const numericId = Number(id); return tasks.find((item) => item.id === numericId) || null; } function create(payload) { const task = { id: nextId++, title: payload.title, description: payload.description || '', status: payload.status || 'todo', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; tasks.push(task); return task; } function update(id, payload) { const task = findById(id); if (!task) return null; if (payload.title !== undefined) task.title = payload.title; if (payload.description !== undefined) task.description = payload.description; if (payload.status !== undefined) task.status = payload.status; task.updatedAt = new Date().toISOString(); return task; } function delete(id) { const index = tasks.findIndex((item) => item.id === Number(id)); if (index === -1) return false; tasks.splice(index, 1); return true; } module.exports = { filterByCondition, findById, create, update, delete };

内存数组的方案在服务重启后数据会丢失,但这不影响演示接口模块的组织方式。等需要持久化时,把model层里的数组替换成数据库查询就好,上层的服务和控制器代码不需要改动。这是分层设计带来的最直接收益。

4. 工程化细节:统一响应、错误处理、参数校验和调试辅助

4.1 统一响应格式的设计思路

前后端联调时最容易出现的矛盾就是响应格式不一致——有人返回{ data: [] },有人返回{ list: [] },前端对接每个接口都要单独适配。这个问题最好在第一个接口出来的时候就从源头解决。

这个Demo中统一响应格式为:

{ "code": 0, "msg": "ok", "data": {} }

其中code为0代表成功,非0代表失败;msg是给前端的提示文案;data是业务数据。在src/utils/response.js里封装两个方法:

function success(data = null, msg = 'ok') { return { code: 0, msg, data }; } function fail(code, msg) { return { code, msg, data: null }; } class BizError extends Error { constructor(code, msg) { super(msg); this.code = code; this.msg = msg; } } module.exports = { success, fail, BizError };

使用BizError时,错误码可以用业务枚举,比如TASK_NOT_FOUND、PARAM_ERROR,前端可以根据错误码做不同分支处理,也可以只展示msg给用户。

4.2 全局错误处理中间件

Express应用里所有路由的异常最终都应该落在同一个错误处理中间件中。新建src/middlewares/errorHandler.js:

const { fail } = require('../utils/response'); // eslint-disable-next-line no-unused-vars function errorHandler(err, req, res, next) { console.error(err); if (err instanceof Error && err.code && err.msg) { return res.status(200).json(fail(err.code, err.msg)); } return res.status(500).json(fail('INTERNAL_ERROR', '服务器内部错误')); } module.exports = errorHandler;

这里有个决策:业务错误(BizError)用HTTP 200返回,因为请求本身是被成功处理的,只是业务结果为失败。系统异常则用HTTP 500,因为确实是服务出问题了。这个区分能帮助前端从HTTP状态码快速判断是“请求没到后端”还是“后端逻辑拒绝了”,再从code定位具体业务原因。

在app.js中注册错误中间件时位置有讲究,必须在所有路由之后注册:

const errorHandler = require('./src/middlewares/errorHandler'); // 路由挂载之后 app.use(errorHandler);

错误中间件不放在路由前面,否则它捕获不到下层的异常。

4.3 参数校验:手写轻量校验还是引入第三方库

参数校验是接口开发中容易被忽略的环节。数据进到服务层时要是脏数据,后面所有逻辑都可能被带偏。这个Demo里没有引入如Joi、express-validator等重量级校验库,而是用手写校验函数,理由很简单:接口少时引入一套校验体系反而增加学习成本和维护负担。

手写校验适合简单场景,比如检测必填字段和字段类型:

function validateCreateTask(payload) { if (!payload || typeof payload.title !== 'string' || payload.title.trim() === '') { throw new BizError('PARAM_ERROR', 'title为必填字符串'); } if (payload.status && !['todo', 'doing', 'done'].includes(payload.status)) { throw new BizError('PARAM_ERROR', 'status不合法'); } }

把这个校验函数在控制器里调用一下,数据就会在入口处被拦截。

当项目复杂度上升时再考虑引入校验库也不迟。我的判断标准是:如果参数超过5个字段、有多种类型嵌套结构,就值得引入Joi或zod;如果就是三四个简单字段,手写更直观。

注意:校验一定放在业务逻辑之前。很多问题排查成本高的原因就是因为脏数据已经深入到计算链路里了,错误信息模糊不清。

4.4 请求日志与跨域处理

开发调试过程中,看一眼终端就知道哪个接口被调用、耗时多久、状态码多少,非常节省时间。最轻量的办法是引入morgan:

npm install morgan

然后在app.js中:

const morgan = require('morgan'); app.use(morgan('dev'));

跨域问题在前后端分离开发时几乎必遇。前端跑在http://localhost:5173,后端在http://localhost:3000,直接请求会触发浏览器的同源策略。引入cors中间件即可解决:

npm install cors
const cors = require('cors'); app.use(cors());

Demo阶段用默认配置放开所有跨域请求没太大问题。生产环境必须限制来源域名,写清楚origin白名单。

5. 常见问题与排查技巧实录

5.1 启动时报错 EADDRINUSE:端口被占用

这个报错几乎每个用Node开发的人都遇到过。启动时提示:

Error: listen EADDRINUSE: address already in use :::3000

说明3000端口被别的进程占用了。排查方式:

lsof -i :3000 kill -9 <PID>

也可以用环境变量指定另一个端口启动:

PORT=3001 npm run dev

这里想提醒一个容易忽略的点:很多时候端口被占用是因为上一个node进程没有正常退出,尤其是用了nodemon之后偶尔会出现旧进程还在的情况。遇到这类问题先看进程列表再动手kill,不要盲目乱杀。

5.2 接口返回404,路由就是匹配不上

新手经常遇到的情况是请求/api/tasks返回404,但明明路由已经注册了。排查顺序:

  • 确认app.js里挂载路径和请求路径是否一致,注意/api/tasks和/tasks是两个不同路径
  • 确认请求方法是否匹配,路由注册了router.get('/:id'),但前端发的是POST请求
  • 确认路由挂载顺序,如果前面有一个app.use('/api', ...)把请求拦截走了,后面的路由不会生效

还有一个隐蔽原因:控制器里没有正常返回响应。比如函数执行完没有调用res.json,请求就会一直挂着,超时后前端看到的可能是404或网络错误。

5.3 req.body 是 undefined 或空对象

这个问题高发在刚接触express的新手身上。Express默认不解析请求体,必须显式注册express.json()中间件。如果忘记加,POST接口拿到的req.body就是 undefined。

在app.js中:

app.use(express.json());

注意位置要在路由挂载之前。另外,如果前端提交的是application/x-www-form-urlencoded格式,需要额外支持时可以用express.urlencoded({ extended: true })。

排查这类问题可以先在控制器里打印一下req.body,确认中间件有没有生效。

5.4 异步回调中的异常吞掉排除了很久

这是我在写Node接口时印象最深的一类问题。Express 4中,异步回调里抛出的异常不会被路由的try/catch捕获。比如:

router.get('/data', async (req, res) => { const data = await someAsyncFunction(); // 这里抛错 res.json(data); });

如果someAsyncFunction抛错,这个错误会变成一个未处理的Promise rejection,请求会一直挂住,前端等不到响应。排查起来非常隐蔽,因为后端打印不出来明确报错。

经验做法是给异步控制器套一层包装函数:

function asyncHandler(fn) { return function (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; }

然后在路由中:

router.get('/data', asyncHandler(async (req, res) => { const data = await someAsyncFunction(); res.json(data); }));

这样异步异常就能进到全局错误处理中间件。Express 5已经把Promise rejection纳入处理流程,但用的是Express 4的项目依然要保留这个习惯。

5.5 返回中文乱码

express返回JSON时默认使用UTF-8,一般情况下不会乱码。如果出现乱码,检查两处:

  • 响应头Content-Type是否包含charset=utf-8
  • 服务端源文件本身是不是UTF-8编码,Windows上保存为GBK的话输出就会乱

设置响应头:

res.setHeader('Content-Type', 'application/json; charset=utf-8');

更省心的方案是在app.js里统一注册:

app.use((req, res, next) => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); next(); });

5.6 修改代码后不生效,还是旧逻辑在跑

用node server.js启动时,修改代码不会自动生效,必须重启进程。开发环境用nodemon能解决这个问题。如果用了nodemon还是不生效,大概率是nodemon监听的目录不对。

默认nodemon会监听当前目录下的文件变化,但node_modules里文件变动频繁,会出现不必要的重启。建议在package.json中加一个nodemon配置:

"nodemonConfig": { "watch": ["app.js", "server.js", "src"], "ext": "js,json" }

这样只监听src目录和两个入口文件,编辑其他文件不会误触发重启。实测开发体验提升非常明显。

6. 从Demo到真实项目:后续扩展的几个方向

如果照着上面的流程走一遍,你应该已经有一套能跑的后端接口模块了。接下来往真实项目演进时,有几个方向值得继续做。

第一个是持久化存储。内存数组在服务重启后一切归零,替换方案从轻到重可以是:写入JSON文件、使用SQLite、接入MySQL或MongoDB。只要数据层的接口方法签名不变,上层代码不需要改动。

第二个是引入环境变量管理。现在端口只是从process.env.PORT取了默认值,数据库连接串、JWT密钥、第三方API地址这些都应该放进环境变量。用dotenv加载.env文件,同时在.env.example里写明字段说明,方便同事复制配置。

第三个是接口文档输出。接入swagger-ui-express,配合JSDoc注释生成在线接口文档,前端就不用反复问你“这个字段是什么意思”了。这个投入在接口超过10个之后非常划算。

第四个是鉴权逻辑。接口模块如果要接入登录体系,加一层JWT鉴权中间件即可,在路由层面给需要保护的接口挂载:

app.use('/api/tasks', authenticate, taskRoutes);

这样做的好处是鉴权逻辑和业务逻辑完全隔离。

如果项目继续膨胀,模块越来越多,还可以考虑用路由模块自动加载的方式,避免每加一个模块都手动改一次app.js。加载方式可以用fs遍历routes目录,或者引入routing-controllers这类装饰器框架。这个看项目复杂度,不强求。

最后聊一下实际体会。我开发这个Demo时最大的感受是,后端接口模块的核心不在某个语法或某个库,而在“边界划分”。路由只管分发、控制器只管接收和响应、服务层管业务、模型层管数据,这个边界一旦清晰,后续排查问题和新增功能都会轻松很多。下一批节点在踩坑中获得的经验,就是所有代码都要经过统一的响应和错误出口,不要每个接口各自为政。按这个思路,哪怕以后模块增长到一百个,骨架依然是稳的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进