1. 为什么Node+Express这个组合依然最适合业务接口Demo
1.1 不是因为它最潮,而是因为它最省事
后端框架的选择这两年确实五花八门,NestJS有完整的依赖注入体系,Fastify宣称性能更高,但这几年我做一个业务接口Demo,第一选择仍然没有变过:Node.js搭配express。原因不复杂,大部分时候我们要的不是架构表演,而是把一个业务场景快速转成可联调的接口,让前端同事能先跑起来,让产品能先看到效果,让后端后续接手时能看得懂。
Express的优点恰恰藏在"老"这个字里。它核心思想极简单:一个请求进来,经过中间件处理、路由匹配、返回响应,仅此而已。没有控制器装饰器,没有自动生成的Swagger,没有复杂的模块组织。你写的每一行代码都是直接的JavaScript,出了问题打开文件就能定位,不用在一堆抽象层里翻来翻去。对大团队那是缺点,对小团队和Demo场景反而是福利。
再一个原因就是生态与经验积累。Express的中间件生态经过多年沉淀,cors、helmet、morgan、multer这些常用能力都有现成方案,遇到问题百度或社区里基本都有人遇到过。资料多到有点冗余,但真的能救命。用NestJS写一个接口,你需要理解装饰器、模块、提供者、注入器这一套概念;用Express写同一个接口,只需要一个回调函数。后者学习成本低到几乎可以忽略。
1.2 Demo的边界:先想清楚哪些不该做
做Demo最忌讳一上来就过度设计。我有段时间就犯过这种毛病,两个接口的Demo硬是引入了ORM、分层架构、统一响应对象、代码生成器,最后光搭建骨架就花了两天,业务代码一行还没写。这种投入产出比在Demo阶段是灾难级的。
一个合格的业务接口模块Demo,应该只包含这几样东西:能启动的HTTP服务、清晰的路由拆分、必要的参数校验、统一的错误返回、日志输出,以及一份能跑通的数据来源。什么消息队列、缓存、微服务治理、鉴权体系,统统后置。比如鉴权,Demo阶段用最简token校验就够了;数据库,先用内存Mock数据;完全不需要在建Demo的阶段去规划连接池、读写分离。
这里我给一个建议:把Demo当成"可运行的接口契约",而不是"可以上线的系统"。它的核心价值是让业务逻辑可见、可测、可讨论。等业务方向确认了,再往工程化方向演进化,效率反而更高。这篇内容后面所有的设计和步骤,都基于这个原则展开。
2. 搭建前的工程规划:目录、依赖与环境变量一次理清
2.1 目录结构这样拆,后面不会乱
很多人写Express项目喜欢把路由全堆在入口文件里,app.js几百行,路由回调和业务逻辑混在一起。两三个接口没事,超过十个接口基本就是灾难。Demo虽然小,但目录规划从第一天就顺手做好,后面扩展的成本低很多。
我用一个比较通用且不复杂的目录结构,大家可以参考:
demo-server/ ├── src/ │ ├── app.js # 应用入口,负责中间件与路由挂载 │ ├── server.js # 启动文件,读取端口并listen │ ├── routes/ │ │ ├── index.js # 路由汇总模块 │ │ ├── user.js # 用户业务接口 │ │ └── product.js # 商品业务接口 │ ├── middleware/ │ │ ├── auth.js # 简易token校验 │ │ ├── errorHandler.js # 统一错误处理 │ │ └── logger.js # 请求日志 │ ├── services/ │ │ └── mockData.js # 模拟数据或后续的数据访问层 │ └── utils/ │ └── response.js # 统一响应格式 ├── .env # 环境变量 └── package.json注意这里把routes、middleware、services分开了。routes只写接口路径和参数提取,middleware负责横切逻辑,services负责数据获取。这个分层适合绝大多数中小型业务接口模块,既不会像目录爆炸那样混乱,也不会让一个小Demo承载太多抽象概念。
2.2 package.json、依赖安装与基础启动脚本
创建工程我习惯手动来,不依赖脚手架。脚手架虽然快,但会塞进来很多用不到的内容,Express官方生成器的模板也比较老了。手动创建反而对每个依赖心里有数:
mkdir demo-server cd demo-server npm init -y npm install express dotenv cors npm install --save-dev nodemon几个依赖各司其职:express是核心;cors解决跨域;dotenv让环境变量不从代码里写死;nodemon做开发时自动重启。总共就这四个,不多装,这是Demo的基本原则。
装完把package.json的启动脚本改一下:
{ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js" } }这里我想多说一句自定义脚本的意义。start用于部署或正式环境,dev用于本地开发。养成用不同脚本区分环境的习惯,后面无论接测试环境还是上生产,都不用临时改命令。很多人直接用node server.js跑,等需要热更新时又去装一堆工具,绕了一圈。
2.3 用.env锁住端口与密钥,别写死在代码里
Demo阶段的端口、密钥、接口地址这些配置,我见过太多人直接写在代码里。当时觉得没什么,反正就几个接口。但一旦前端要联调、后面要接数据库,配置散落在各处就很痛苦。建议从最开始就用.env统一管理:
PORT=3000 JWT_SECRET=demo_secret_change_me DB_HOST=localhost DB_PORT=5432 DB_NAME=demo DB_USER=demo DB_PASSWORD=demo然后在server.js里读取:
const express = require('express'); const cors = require('cors'); const dotenv = require('dotenv'); dotenv.config(); const app = require('./app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`[server] running at http://localhost:${PORT}`); });这里有个细节:dotenv需要在引入app之前执行,因为app内部可能也需要读取环境变量。我习惯在server.js这个启动文件顶部就完成配置加载,而不是在app.js里做。顺序这件事在Node里从来不小事,你永远不知道哪个模块会在init阶段就读取process.env。
3. 用Router把业务接口模块从入口文件里拆出来
3.1 入口文件只做挂载,业务全部下沉到routes
接下来是Express的核心机制之一:Router。很多初学者不理解为什么不用app.get直接写接口。当接口数量少的时候确实差别不大,但接口模块一多,全堆在入口文件里就会变成一团毛线。Router可以让你在独立文件中定义一组接口,最后作为一个中间件挂载到特定前缀下。
我在app.js里只做挂载这件事:
const express = require('express'); const cors = require('cors'); const routes = require('./routes'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); const app = express(); // 全局中间件 app.use(cors()); app.use(express.json()); // 路由统一挂载到 /api 前缀下 app.use('/api', routes); // 由统一错误处理接管后续 app.use(notFoundHandler); app.use(errorHandler); module.exports = app;看到没有,入口文件只处理三件事:全局中间件、路由挂载、错误兜底。哪个接口怎么实现、参数怎么校验,它一概不管。这样的入口文件任何人打开,三分钟之内就能知道整个服务有哪些能力。
3.2 写一个带前缀的业务模块:用户接口实战
路由汇总文件routes/index.js负责把各个业务模块拼起来:
const express = require('express'); const userRouter = require('./user'); const productRouter = require('./product'); const router = express.Router(); // 不同业务模块挂在不同前缀 router.use('/user', userRouter); router.use('/product', productRouter); module.exports = router;这样最终访问路径是/api/user/xxx和/api/product/xxx。前端拿到的是带具体业务语义的URL,后端维护时只用打开对应的routes文件。
再往下一层,user.js里写真正的接口逻辑:
const express = require('express'); const { getUsers, getUserById, createUser } = require('../services/userService'); const { authMiddleware } = require('../middleware/auth'); const { validateCreateUser } = require('../middleware/validate'); const router = express.Router(); // 用户列表 router.get('/', async (req, res, next) => { try { const users = await getUsers(); res.json({ code: 0, data: users, message: 'ok' }); } catch (err) { next(err); } }); // 用户详情,动态参数放在路径里 router.get('/:id', async (req, res, next) => { try { const user = await getUserById(req.params.id); res.json({ code: 0, data: user, message: 'ok' }); } catch (err) { next(err); } }); // 需要登录态才能创建用户 router.post('/', authMiddleware, validateCreateUser, async (req, res, next) => { try { const user = await createUser(req.body); res.status(201).json({ code: 0, data: user, message: 'created' }); } catch (err) { next(err); } }); module.exports = router;这里有一个核心经验:每个异步路由都要用try/catch包起来,并且错误要传给next。在Express 4里,如果你不这样做,异步方法抛出的异常不会被错误中间件捕获,请求会卡住,进程甚至可能因未捕获异常崩溃。这个坑我在第5部分还会细讲,但请大家写第一行接口时就形成这个肌肉记忆。
3.3 业务模块里常见的分层尝试
有些同学会问,routes/user.js里已经把参数校验、鉴权、业务逻辑都混在一个文件了,这样不是没分层吗?对于Demo来说,我认为这是可接受的。真正需要进一步拆分时,通常发生在两个情况:
第一种,同一个数据源的接口数量超过10个。此时可以把services再细分成service和dao两层,service处理业务判断,dao只做数据读写。第二种,多个接口复用同一段复杂业务逻辑。这时候抽出一个公共service函数就很自然。
Demo阶段强行套这三层,反而会让代码量增加一倍。我建议遵循一个原则:按"文件不超过150行、职责不超过两个"来拆,不做预拆分。等你意识到某个文件开始臃肿,那才是动手重构的最佳时机,而不是开工第一天。
不过有一个小经验值得现在就做:把统一返回格式抽成一个函数。上面的代码中res.json每次都写{ code: 0, data, message }。抽成utils/response.js会很清爽:
const success = (res, data, message = 'ok') => { res.json({ code: 0, data, message }); }; const fail = (res, status, message, detail = null) => { res.status(status).json({ code: status, message, detail }); }; module.exports = { success, fail };路由里就变成success(res, users),少写重复代码,也保证所有接口的返回结构一致。这个细节前端联调时非常受益,他们只需要解析统一的字段结构。
4. 中间件体系:日志、鉴权、参数校验怎么按顺序串
4.1 中间件执行顺序直接决定接口行为
在Express里,中间件的执行顺序就是代码书写的顺序。这句话看起来简单,但很多人栽在它上面。比如express.json()这个内置中间件,如果被放在路由之后,那所有接口的req.body都会是undefined。再比如鉴权中间件,如果你把它挂在了某个路由之后,那它等于失效——请求先到路由已经返回了,轮不到鉴权执行。
所以在app.js里,全局中间件的顺序我通常固定为:cors、json、日志、路由前缀挂载。这四条的顺序背后都有原因:
- cors必须最先,因为浏览器跨域预检OPTIONS请求要最先被处理;
- json在路由之前,保证后续所有路由都能拿到req.body;
- 日志在路由之前,才能记录所有请求的进入;
- 路由挂载放在最后,前面所有中间件完成预处理后再进入具体业务。
app.use(cors()); app.use(express.json()); app.use(async (req, res, next) => { const start = Date.now(); res.on('finish', () => { const cost = Date.now() - start; console.log(`[req] ${req.method} ${req.originalUrl} ${res.statusCode} ${cost}ms`); }); next(); }); app.use('/api', routes);4.2 自定义日志与耗时统计
上面的代码里我加了一个自定义日志中间件,没有用morgan这类库。原因是做Demo时,一个console.log就能满足95%的排错需求,不值得为一个日志功能引入依赖。这个中间件最实用的一点是统计耗时,联调时前端说"接口很慢",你先看日志里每个接口的cost,就能定位是后端处理耗时长还是网络传输耗时长。
日志中间件还有一个好处:它能完整记录请求的到达顺序。联调时一旦出现"前端发了请求但后端没反应",第一件事就是看日志里有没有这条请求的method和URL。如果完全没有,说明请求根本没到后端,问题在网络层或域名解析;如果到了但没返回,说明后端内部卡住了。这种定位思路能省下大把排查时间。
4.3 一个够用的token鉴权中间件
Demo阶段的鉴权,我见过有人直接接了OAuth全套,也有人完全不鉴权裸奔。大多数业务接口模块的Demo,直接用一个简单的Bearer Token即可。中间件写在middleware/auth.js:
const dotenv = require('dotenv'); dotenv.config(); const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization || ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; // Demo场景下,token与约定的密钥一致即放行 if (token && token === process.env.JWT_SECRET) { next(); } else { res.status(401).json({ code: 401, message: 'unauthorized', detail: '缺少有效的访问凭证' }); } }; module.exports = { authMiddleware };注意我没有引入jsonwebtoken这个库。因为JWT库通常包含签发和验签两条链路,而Demo通常只需要验签或者干脆用约定值校验。等真正要接登录服务时再替换,中间件只暴露一个next()接口,替换成本非常低。这又是一个"先跑通再替换"的例子。
对这个中间件,我说一个体验落差:一旦你开始用鉴权中间件,就要考虑哪些接口应该公开、哪些应该保护。我的习惯是:在router内部按路由粒度挂载,而不是整体挂。用户列表和详情可以先公开用于联调,创建、修改、删除走鉴权。这样前端早期联调不用纠结token,后续逐步收紧。如果一开始全加了鉴权,前端第一次跑通就会多一道障碍。
4.4 参数校验的统一写法
参数校验也是容易写脏的部分。有人直接用if/else在路由里手动判断十几个字段,有人直接装Joi或者express-validator这种校验库。Demo阶段,我倾向于写一个简短的校验中间件,能覆盖最常用的必填和格式检查即可。
const validateCreateUser = (req, res, next) => { const { name, email } = req.body || {}; const errors = []; if (!name || typeof name !== 'string') { errors.push('name 字段必填且必须为字符串'); } if (!email || !/^\S+@\S+\.\S+$/.test(email)) { errors.push('email 字段格式不正确'); } if (errors.length > 0) { return res.status(400).json({ code: 400, message: 'invalid params', detail: errors }); } next(); };这个中间件的好处是任何路由都可以复用,参数不对时返回清晰的错误信息,前端一看就知道缺了什么。等接口复杂了,比如需要嵌套对象、联合类型、自定义错误码,再去接Zod或Joi不迟。但一开始就接完整校验框架,会有很多配置代码占用你注意力,让Demo的核心业务被淹没。
5. 错误处理与异步防线:接口最怕静默失败
5.1 统一错误结构,让联调的人一眼看懂
很多接口挂掉后,前端收到的响应是HTML格式的错误页面,或者直接连接中断,这给联调带来极大的麻烦。好的错误处理要统一输出JSON结构,并且把错误码、错误信息、错误细节分开。
我建议的错误结构是:
{ "code": 400, "message": "invalid params", "detail": ["email 字段格式不正确"] }code和HTTP状态码对应,方便上层统一拦截;message是面向人的简短描述;detail放具体的错误原因,甚至可以是数组。这样前端面对任何异常,都能用同一套逻辑解析。
在middleware/errorHandler.js里,我实现了一个错误处理中间件:
const notFoundHandler = (req, res, next) => { res.status(404).json({ code: 404, message: 'not found', detail: `接口 ${req.method} ${req.originalUrl} 不存在` }); }; // 注意:四个参数是Express识别错误处理中间件的标志 const errorHandler = (err, req, res, next) => { const status = err.status || 500; const message = err.message || 'internal server error'; console.error('[error]', req.method, req.originalUrl, status, message, err.stack); res.status(status).json({ code: status, message, detail: err.detail || null }); }; module.exports = { notFoundHandler, errorHandler };这里一个关键点:错误处理中间件必须要有四个参数(err, req, res, next)。只写三个会被Express当成普通中间件处理,完全不会生效。这个坑很多人排查半天才发现。
5.2 Express 4与Express 5在异步错误上的差别
在Express 4中,异步函数里throw的异常不会被Express捕获,因为async函数返回的是一个Promise,而Express 4的中间件机制是同步模型。如果你在async路由里直接写:
router.get('/bad', async (req, res) => { throw new Error('boom'); });这个错误不会进入errorHandler,请求会卡住,直到超时,甚至抛出的Error会成为unhandledRejection, 导致进程内存异常。这就是"静默失败"的典型场景。
解决办法有几种,最简单的就是我前面说的:每个路由用try/catch包住,然后调用next(err)。类似:
try { const users = await getUsers(); success(res, users); } catch (err) { next(err); }Express 5在这方面做了改进,它能够自动捕获异步中间件里抛出的异常。但问题是你现在npm install express默认装的是4.x还是5.x取决于标签。如果用的是Express 5,可以少写很多try/catch,但为了代码兼容性和稳定性,我建议无论如何把错误转发写清楚。因为你不能假设未来的维护者知道你依赖的是Express 5的自动化行为。
5.3 兜底404与错误日志落盘
除了统一错误处理,还要处理"路由不存在"的情况。很多Demo不写这个,导致前端访问一个拼错的URL时,得到的是Express默认的HTML 404页面,格式混乱不说,还暴露了后端技术栈信息。用前面写的notFoundHandler兜底,所有未匹配的请求都会返回统一的JSON格式,这才像个有专业度的接口服务。
错误日志落盘这个事,Demo阶段至少要做到console.error打印堆栈。有些同学不想屏幕上刷太多日志,就直接把错误吞掉,这是最危险的行为。接口报错连日志都没有,出了问题只能盲猜。如果你愿意多写几行,可以考虑用fs.appendFile把错误写入文件:
const fs = require('fs'); const path = require('path'); const logError = (data) => { const line = `${new Date().toISOString()} ${data}\n`; fs.appendFile(path.join(__dirname, '../logs/error.log'), line, () => {}); };在errorHandler里调用即可。有了错误日志文件,联调阶段甚至可以远程让测试同学复现问题,然后把日志文件发过来,效率高很多。
6. 数据层先Mock再接库:接口协议不要被数据库绑架
6.1 用内存Mock快速跑通业务逻辑
写后端业务接口Demo,最常遇到的一个问题是:数据库还没准备好,表结构还没设计完,前端急着要接口。这时候如果坚持要先建库、建表、连DB,整个进度会被无限拖慢。我的做法是把services层做成可替换的,先用内存Mock数据把接口形态定下来。
先建一个services/userService.js,逻辑先干层:
const mockUsers = [ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' }, ]; const getUsers = async () => { // 未来这里会改成从数据库查询 return mockUsers; }; const getUserById = async (id) => { const user = mockUsers.find((u) => u.id === Number(id)); if (!user) { const err = new Error('用户不存在'); err.status = 404; throw err; } return user; }; const createUser = async (data) => { const newUser = { id: mockUsers.length + 1, ...data }; mockUsers.push(newUser); return newUser; }; module.exports = { getUsers, getUserById, createUser };注意createUser直接把数据push到内存数组里,服务重启数据就清空了。这正是Mock应有的行为。前端的请求流程能完整走通,后端的接口语义也能验证,这两点才是Demo阶段要解决的核心问题。
6.2 后续接MySQL/PostgreSQL时的选型参考
等Mock阶段验证了接口协议,再决定接哪种数据库。接数据库时,我用过的方案有两条路线:用原生查询,或者上一个ORM/查询构造器。
如果后续表结构比较固定、查询不复杂,直接使用mysql2/pg这个驱动写SQL即可,学习成本低,性能可控。代码类似:
const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, }); const getUsers = async () => { const [rows] = await pool.query('SELECT id, name, email FROM users'); return rows; };如果业务表多、关联复杂,我更推荐上一个适配业务特点的ORM工具,这类工具能帮你维护表模型、关联关系、自动生成迁移脚本。但我不建议在Demo阶段一开始就上ORM,因为ORM的建模和迁移配置很花时间,且会让"接口长什么样"这个问题被数据库建模分走注意力。
我的顺序建议是:先Mock定义接口契约,再绘制表结构,再替换service实现,最后补全事务和边界逻辑。这个顺序保证了前端、联调、测试不会被数据库准备时间阻塞。
7. 联调排错:这几个坑我都替你踩过了
7.1 跨域403与预检请求
前端在浏览器里访问你的接口,如果域名或端口和页面不一致,就会遇到跨域问题。最常见的是报CORS错误或OPTIONS请求返回403。在Express里解决方式很简单,就是使用cors中间件:
const cors = require('cors'); app.use(cors());但这里有个细节:默认cors()允许所有来源,且会处理预检请求OPTIONS。如果你的Demo上线了,需要限制来源,可以配置:
app.use(cors({ origin: ['http://localhost:5173', 'https://your-site.example.com'], methods: ['GET', 'POST', 'PUT', 'DELETE'], }));注意一个坑:用axios等库发起带自定义Header的请求时,浏览器会先发OPTIONS预检。如果你的跨域配置没有正确响应OPTIONS,或者路由层面拦截了OPTIONS,就会出现"接口在Postman里能通、在浏览器里不通"的诡异现象。用cors中间件可以避免绝大部分这种问题。
7.2 req.body总是undefined的真相
这是我被问最多的问题。新手写:
app.get('/', (req, res) => { console.log(req.body); });然后发现req.body永远是undefined。原因在于express.json()没有生效或没有在路由之前注册。Express在默认情况下不会解析请求体,你必须显式使用内置中间件。同时还要确认你发出的Content-Type是不是application/json,如果前端用a标签或直接fetch时没有设置Content-Type,即使后端配置了json解析,body也可能拿不到。
调试技巧:在日志中间件里输出req.body,结合前端network面板看请求头,能快速定位是解析失败还是前端没设置Header。
7.3 端口占用与热更新失效
端口占用这个问题小程序里也常见:node服务没退干净,再次运行时报EADDRINUSE错误。解决办法很简单,找到占用进程杀掉:
lsof -i :3000 kill -9 <pid>Windows下用netstat -ano | findstr :3000配合taskkill。更规范的做法是在package.json里加一个prestart脚本,启动前自动检查端口,或者在进程管理层面统一管理。但Demo阶段手工处理就够了。
热更新失效的问题则更隐蔽。npm run dev跑的是nodemon,它默认监听当前目录下的文件变化。如果你改了.env文件,nodemon默认不监听dotenv文件,需要配置--ext参数。另外如果你在引入的包里面改代码,nodemon不会重启,这也是正常的。排查思路是:改了文件,nodemon有没有打印restarting信息,没有就是没监听到。
7.4 异步异常导致进程崩溃的排查
这个坑非常隐蔽。场景是这样的:接口代码里用的数据库连接池过期,异步任务抛出一个未被捕获的异常,进程直接退出。前端看到的就是接口长时间无响应,然后服务器挂了。
排查时需要开启Node的unhandledRejection监听:
process.on('unhandledRejection', (reason) => { console.error('[fatal] unhandledRejection:', reason); // 记录后,可选择手动退出或继续运行 });加上这个之后,错误一发生就能在日志里看到。不过这只是兜底,真正的解决方案还是前面第5部分强调的:每个异步路由都用try/catch + next(err)把异常交给Express错误处理机制。这不仅是规范问题,更是进程稳定性问题。我见过太多线上服务因为一个异步未捕获异常直接挂掉,重启后依旧复现,最终就是这个根因。
排错时还有一个经验:当服务频繁无响应时,先看进程还在不在,再看CPU和内存占用,最后看错误日志。顺序不能反,否则你会从一个假的现象出发,绕很多弯路。
跑完这个Demo,我留了几个长期有效的习惯
这个Demo跑通之后,我整理了几个每次写Express都会遵守的习惯:入口文件永远保持薄,业务下沉到routes;routes里的异步函数全部包try/catch;错误处理中间件永远放在路由之后、响应周期末端;环境变量不进代码;数据层从Mock起步。这几个习惯一点都不复杂,但每一条都是靠踩坑换来的。
最后再分享一个小技巧:Demo上线联调前,用Postman或Apifox把接口清单整理好,包括每个接口的入参、出参、错误码。这步看着不起眼,但它能把"后端接口写完了"和"前端能联调了"之间的差距直接抹平。接口文档不一定要用高大上的Swagger,一张整理清晰的接口表格,往往比工具生成的文档更实用。等你的Demo被验证可行,再决定要不要上自动化文档体系也不迟。