1. 为什么我选 Node.js + Express 来搭这个 API 服务
1.1 从零搭 API 服务,先想清楚三件事
很多人一上来就问“用什么框架”,其实这个问题问早了。搭一个 API 服务,先要回答的是三个更底层的问题:这个服务给谁用、跑在什么环境、后续谁来维护。我这次的小项目是一个内部工具的后端,调用方是几个前端页面和一个小程序,部署在一台 2 核 4G 的云主机上,维护的人只有我自己。这三个前提一确定,技术选型其实就收敛得差不多了。
给谁用决定了接口风格。前端和小程序都习惯吃 JSON,所以 RESTful 风格 + JSON 响应体是最省事的,不需要引入 GraphQL 那套 schema 和 resolver 的复杂度。跑在什么环境决定了运行时,一台普通 Linux 云主机,没有 K8s,没有容器编排,那就需要一个启动快、依赖少、单进程能扛住中小流量的运行时。谁来维护决定了生态,我一个人维护,就得选那种“遇到问题一搜就有答案”的技术栈,而不是那种文档全靠读源码的冷门方案。
把这三条摆出来,Node.js + Express 几乎是自然浮现的答案。Node.js 的事件循环模型天生适合 I/O 密集型的 API 服务,Express 的中间件机制让“鉴权、日志、错误处理”这些横切关注点可以像搭积木一样拼起来。这不是因为它多先进,而是因为它足够成熟、足够简单、足够多的人踩过坑。
1.2 Node.js 和 Express 各自解决了什么问题
先把这两个东西的职责分清楚,不然后面写代码容易混。Node.js 是运行时,它让 JavaScript 能脱离浏览器在服务器上跑,提供文件系统、网络、进程这些能力。Express 是建立在 Node.js 之上的 Web 框架,它封装了http模块,提供了路由、中间件、请求响应对象增强这些便利。
打个比方,Node.js 像是厨房里的灶台和锅,Express 像是已经切好配好的半成品菜。你可以不用 Express,直接用 Node.js 原生的http.createServer写,但那样每个请求都要自己解析 URL、自己判断方法、自己拼响应头,写三个接口就开始重复劳动了。Express 把这些重复的部分抽象掉,让你专注在业务逻辑上。
这里有个新手常犯的误区:以为 Express 很“重”。其实 Express 的核心非常轻,它本身几乎不做任何多余的事,功能全靠中间件叠加。你引入多少中间件,它就有多重。这也是为什么它适合小项目——你可以只装真正需要的那几个包,保持依赖树干净。
1.3 这个项目最终要交付什么
在动手之前,我习惯先把“完成”的定义写下来,避免写着写着跑偏。这个 API 服务最终要满足这些条件:
- 提供至少一组 CRUD 接口,能对一类资源做增删改查
- 有统一的响应格式,成功和失败的结构一致,前端好处理
- 有基础的请求日志,出问题能回溯
- 有输入校验,脏数据进不了业务层
- 有全局错误处理,任何未捕获的异常都返回规范化的错误响应,而不是把堆栈甩给调用方
- 能通过一条命令启动,配置通过环境变量注入
这六条看起来朴素,但每一条背后都对应着一类真实会踩的坑。比如“统一响应格式”这条,如果不提前定好,写到第五个接口时你会发现有的返回{data: ...},有的返回{result: ...},前端得写两套解析逻辑。再比如“全局错误处理”,不提前做的话,某个接口里一个throw就能让整个进程挂掉,这在生产环境是灾难。
提示:小项目最容易犯的错不是技术难,而是没有提前约定规范。花二十分钟把响应格式、目录结构、错误码定下来,后面能省下几个小时的返工。
2. 环境准备与项目骨架搭建
2.1 Node.js 版本选择与安装
Node.js 的版本迭代很快,选版本这件事有讲究。偶数版本是 LTS(长期支持),奇数版本是实验性的 Current。生产项目一律选 LTS,这是铁律。我这次用的是 Node.js 20 的 LTS 版本,它在性能、内置模块、对现代 JavaScript 语法的支持上都比较均衡。
在 Linux 上安装,我不推荐用系统自带的包管理器直接装,因为版本往往偏旧。更稳妥的方式是用 NodeSource 提供的安装脚本,或者用 nvm 这类版本管理工具。用 nvm 的好处是可以在多个项目间切换 Node 版本,互不干扰。
# 用 nvm 安装并切换到 Node.js 20 LTS nvm install 20 nvm use 20 node -v # 应该输出 v20.x.x npm -v如果你不想装 nvm,也可以直接从 Node.js 官网下载对应平台的安装包。Windows 用户下载.msi,macOS 用户下载.pkg,Linux 用户可以用 NodeSource 的脚本。安装完记得验证node -v和npm -v都能正常输出版本号。
这里有个细节值得说:npm是随 Node.js 一起安装的,不需要单独装。但如果你在国内网络环境下觉得npm install慢,可以配置镜像源,这个后面会讲。
2.2 初始化项目与依赖安装
建一个空目录,进去之后执行npm init -y,它会生成一个默认的package.json。这个文件是整个项目的“身份证”,记录了项目名、版本、依赖、脚本命令。我习惯手动改一下name、description和scripts这几项,让它更贴合项目实际。
核心依赖其实只有两个:express负责 Web 框架,dotenv负责读取环境变量。其他的像日志、校验、跨域,我倾向于按需引入,而不是一上来装一大堆。
npm init -y npm install express dotenv npm install --save-dev nodemonnodemon是开发期依赖,它能在你改代码后自动重启服务,省去手动 Ctrl+C 再启动的麻烦。注意它装在devDependencies里,生产环境不需要它。
装完之后,package.json里应该能看到dependencies和devDependencies两个区块。我建议在scripts里加两条命令:
{ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js" } }这样开发时跑npm run dev,部署时跑npm start,职责清晰。
2.3 目录结构怎么划分才不乱
小项目最容易在目录结构上偷懒,所有代码堆在一个index.js里。刚开始确实快,但接口一多,文件就变成几百行的“面条代码”,改一个地方要上下翻半天。我这次用的结构是这样的:
project/ ├── src/ │ ├── server.js # 入口,启动 HTTP 服务 │ ├── app.js # 创建 Express 应用,挂载中间件和路由 │ ├── routes/ # 路由定义 │ │ └── items.js │ ├── controllers/ # 业务逻辑 │ │ └── itemsController.js │ ├── middlewares/ # 自定义中间件 │ │ ├── logger.js │ │ ├── validate.js │ │ └── errorHandler.js │ └── utils/ │ └── response.js # 统一响应封装 ├── .env # 环境变量(不提交到版本库) ├── .env.example # 环境变量模板(提交) ├── .gitignore └── package.json这个结构的关键在于把“入口”和“应用”分开。server.js只负责监听端口,app.js负责组装应用。这样做的好处是测试时可以直接引入app.js而不启动真实端口,后面想加自动化测试会方便很多。
注意:
.env文件一定要写进.gitignore,里面可能有数据库密码、密钥这类敏感信息。同时提供一个.env.example作为模板,让别人知道需要配哪些变量,但里面填的是占位值。
3. 核心代码实现:从入口到接口
3.1 入口文件与应用组装的分离
先看server.js,它要做的事情非常少:加载环境变量、引入应用、监听端口、处理启动异常。
// src/server.js require('dotenv').config(); const app = require('./app'); const PORT = process.env.PORT || 3000; const server = app.listen(PORT, () => { console.log(`API 服务已启动,监听端口 ${PORT}`); }); // 处理进程级别的异常,避免服务静默挂掉 process.on('unhandledRejection', (err) => { console.error('未处理的 Promise 拒绝:', err); server.close(() => process.exit(1)); });这里有个经验点:process.on('unhandledRejection')这个监听一定要加。Node.js 里如果一个 Promise 被拒绝却没有.catch,默认行为在不同版本里不一致,有的只是警告,有的直接退出。加上这个监听,至少能保证异常被记录,并且优雅地关闭服务,而不是留下一个半死不活的进程。
再看app.js,它是应用的“装配车间”:
// src/app.js const express = require('express'); const logger = require('./middlewares/logger'); const errorHandler = require('./middlewares/errorHandler'); const itemsRouter = require('./routes/items'); const app = express(); // 解析 JSON 请求体,限制大小防止超大请求 app.use(express.json({ limit: '1mb' })); // 请求日志 app.use(logger); // 健康检查接口,方便部署时探活 app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: Date.now() }); }); // 业务路由 app.use('/api/items', itemsRouter); // 404 处理,放在所有路由之后 app.use((req, res) => { res.status(404).json({ code: 404, message: '接口不存在', data: null }); }); // 全局错误处理,必须放在最后 app.use(errorHandler); module.exports = app;中间件的顺序在这里至关重要。express.json()必须在路由之前,否则req.body是空的。404 处理必须在所有路由之后,否则它会拦截掉正常请求。错误处理必须放在最后,Express 才会把错误交给它。这个顺序错了,会出现各种“莫名其妙”的问题,比如接口明明写了却返回 404,或者报错了却返回空响应。
3.2 统一响应格式的设计
响应格式这件事,我在项目里定了一个简单但够用的规范:
// src/utils/response.js function success(data = null, message = 'ok') { return { code: 0, message, data }; } function fail(code, message, data = null) { return { code, message, data }; } module.exports = { success, fail };成功时code为 0,失败时code为非 0 的业务错误码。为什么不用 HTTP 状态码直接表达一切?因为 HTTP 状态码只有那么几十个,业务错误却可能有几百种。把“传输层是否成功”和“业务层是否成功”分开,前端处理起来更清晰:先看 HTTP 状态码判断网络是否通,再看code判断业务是否成功。
这里有个细节:data字段永远存在,哪怕值是null。这样前端可以无脑地res.data.data,不用先判断字段是否存在。这种“字段永远存在”的约定,能省掉前端大量的防御性代码。
3.3 请求日志中间件的实现
日志是排查问题的命根子。我不追求花哨的日志系统,但每个请求的方法、路径、状态码、耗时这四项必须有。
// src/middlewares/logger.js module.exports = function logger(req, res, next) { const start = Date.now(); // 响应结束后记录,此时状态码已确定 res.on('finish', () => { const duration = Date.now() - start; console.log( `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ` + `${res.statusCode} ${duration}ms` ); }); next(); };用res.on('finish')而不是在中间件里直接打印,是因为中间件执行时响应还没发出,状态码和耗时都拿不到。finish事件在响应完全发送后触发,这时候的数据才是准确的。
实操心得:日志里一定要带耗时。我遇到过好几次接口“变慢”的问题,靠的就是日志里的
duration字段定位到是某个查询没走索引。没有耗时数据,你只能靠猜。
3.4 输入校验中间件的实现
输入校验我选择手写一个轻量中间件,而不是引入joi或zod这类库。原因很简单:这个项目的校验规则就那么几条,引入一个库反而增加了学习成本和依赖体积。当然,如果校验规则复杂到几十条,那还是用库更划算。
// src/middlewares/validate.js function validateItem(req, res, next) { const { name, price } = req.body; const errors = []; if (!name || typeof name !== 'string' || name.trim().length === 0) { errors.push('name 必须是非空字符串'); } if (name && name.length > 100) { errors.push('name 长度不能超过 100'); } if (price === undefined || typeof price !== 'number' || price < 0) { errors.push('price 必须是非负数字'); } if (errors.length > 0) { return res.status(400).json({ code: 400, message: '参数校验失败', data: { errors } }); } next(); } module.exports = { validateItem };校验的核心原则是“宁可严格,不可宽松”。我见过太多项目因为没校验price是不是数字,结果前端传了个字符串"abc",存进数据库后计算总价时变成NaN,排查半天。校验层多写几行,业务层就能少写一堆防御代码。
4. 路由与业务逻辑的落地
4.1 路由层的职责边界
路由层只做一件事:把 URL 和方法映射到对应的控制器函数。它不应该包含业务逻辑,也不应该直接操作数据。
// src/routes/items.js const express = require('express'); const router = express.Router(); const controller = require('../controllers/itemsController'); const { validateItem } = require('../middlewares/validate'); router.get('/', controller.list); router.get('/:id', controller.getOne); router.post('/', validateItem, controller.create); router.put('/:id', validateItem, controller.update); router.delete('/:id', controller.remove); module.exports = router;注意validateItem只挂在POST和PUT上,因为只有这两个方法需要校验请求体。GET和DELETE不需要。这种“按需挂载”的写法,比在每个控制器里手动校验要干净得多。
4.2 控制器里的业务逻辑
控制器负责处理请求、调用数据层、返回响应。这个项目我用一个内存数组模拟数据存储,重点是把流程跑通,后面换成真实数据库只需要改数据层。
// src/controllers/itemsController.js const { success, fail } = require('../utils/response'); // 模拟数据存储 let items = [ { id: 1, name: '示例商品', price: 99.9, createdAt: Date.now() } ]; let nextId = 2; exports.list = (req, res) => { const { keyword } = req.query; let result = items; if (keyword) { result = items.filter(item => item.name.includes(keyword)); } res.json(success(result)); }; exports.getOne = (req, res) => { const id = Number(req.params.id); const item = items.find(i => i.id === id); if (!item) { return res.status(404).json(fail(40401, '资源不存在')); } res.json(success(item)); }; exports.create = (req, res) => { const { name, price } = req.body; const item = { id: nextId++, name: name.trim(), price, createdAt: Date.now() }; items.push(item); res.status(201).json(success(item, '创建成功')); }; exports.update = (req, res) => { const id = Number(req.params.id); const index = items.findIndex(i => i.id === id); if (index === -1) { return res.status(404).json(fail(40401, '资源不存在')); } const { name, price } = req.body; items[index] = { ...items[index], name: name.trim(), price }; res.json(success(items[index], '更新成功')); }; exports.remove = (req, res) => { const id = Number(req.params.id); const index = items.findIndex(i => i.id === id); if (index === -1) { return res.status(404).json(fail(40401, '资源不存在')); } items.splice(index, 1); res.json(success(null, '删除成功')); };这里有几个值得说的点。第一,req.params.id是字符串,必须用Number()转成数字再比较,否则1 === "1"永远是false,接口会一直返回“资源不存在”。第二,create返回 201 状态码而不是 200,这是 RESTful 的约定,表示“资源已创建”。第三,业务错误码我用了40401这种五位数字,前三位对应 HTTP 状态码,后两位是细分错误,方便定位。
4.3 全局错误处理的兜底
错误处理中间件是最后一道防线。它的签名必须是四个参数(err, req, res, next),Express 靠参数个数来识别它是不是错误处理中间件。
// src/middlewares/errorHandler.js module.exports = function errorHandler(err, req, res, next) { console.error('捕获到错误:', err); // 请求体解析失败(比如 JSON 格式错误) if (err.type === 'entity.parse.failed') { return res.status(400).json({ code: 40001, message: '请求体不是合法的 JSON', data: null }); } // 请求体过大 if (err.type === 'entity.too.large') { return res.status(413).json({ code: 41301, message: '请求体过大', data: null }); } // 其他未预期的错误,不暴露内部细节 res.status(500).json({ code: 50000, message: '服务器内部错误', data: null }); };关键点是最后那个 500 响应,绝对不能把err.message或err.stack返回给调用方。堆栈信息会暴露文件路径、依赖版本这些内部细节,是安全隐患。日志里记录完整信息,响应里只给一个笼统的提示,这是基本的安全意识。
5. 常见问题与排查技巧实录
5.1 启动和依赖相关的坑
问题一:Cannot find module 'express'
这个报错九成是因为没在项目根目录执行npm install,或者node_modules被误删了。解决办法就是回到项目根目录重新装。如果装了还报错,检查一下是不是在错误的目录下执行了node命令,比如在src目录里直接跑node server.js,这时候 Node.js 会从src往上找node_modules,如果结构不对就找不到。
问题二:端口被占用EADDRINUSE
# Linux/macOS 查看占用端口的进程 lsof -i :3000 # 或者 netstat -tlnp | grep 3000找到 PID 后kill掉,或者干脆在.env里换个端口。我习惯在开发时用 3000,测试时用 3001,避免冲突。
问题三:npm install卡住不动
国内网络环境下,默认的 npm 源可能很慢。可以临时切换源:
npm install --registry=https://registry.npmmirror.com或者永久配置:
npm config set registry https://registry.npmmirror.com5.2 请求处理中的典型故障
问题四:req.body是 undefined
最常见的原因是忘了挂express.json()中间件,或者挂在了路由之后。中间件顺序错了,请求体就不会被解析。另一个原因是请求头里没带Content-Type: application/json,express.json()只解析这个类型的请求体。
问题五:接口返回 404,但路由明明写了
先检查路由挂载的前缀对不对。app.use('/api/items', router)意味着路由里定义的/实际访问路径是/api/items。如果你在浏览器里访问/items,那当然是 404。其次检查 404 中间件是不是挂在了路由之前,那样它会拦截所有请求。
问题六:跨域请求被浏览器拦截
如果前端和后端不在同一个域名下,浏览器会拦截跨域请求。开发阶段可以加一个简单的 CORS 中间件:
app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });生产环境不要把*放开,应该指定具体的前端域名。
5.3 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动报模块找不到 | 依赖未安装或目录错误 | 检查node_modules和当前工作目录 |
| 端口占用 | 已有进程监听该端口 | lsof -i :端口找到并结束进程 |
req.body为空 | 中间件缺失或顺序错误 | 确认express.json()在路由之前 |
| 接口 404 | 路由前缀不匹配 | 核对app.use的路径和路由定义 |
| 跨域被拦截 | 缺少 CORS 响应头 | 添加 CORS 中间件或配置代理 |
| 进程莫名退出 | 未捕获的异常 | 加unhandledRejection和uncaughtException监听 |
| 响应慢 | 某个环节阻塞 | 看日志里的耗时字段定位 |
避坑技巧:开发阶段把
nodemon用起来,改完代码自动重启,能省下大量手动操作。但要注意nodemon默认监听所有文件变化,如果日志写到文件里,可能触发无限重启。可以在nodemon.json里配置ignore规则。
6. 部署上线前的最后检查
6.1 环境变量与配置管理
上线前把所有可变配置抽到环境变量里,这是硬性要求。端口、数据库连接、密钥这些都不能硬编码在代码里。.env文件在本地用,服务器上用系统环境变量或者进程管理工具注入。
# .env.example PORT=3000 NODE_ENV=productionNODE_ENV这个变量特别重要,Express 在production模式下会关闭一些调试特性,性能更好,也不会泄露堆栈信息。
6.2 进程守护与日志落盘
直接用node src/server.js启动的服务,一旦进程崩溃就没了。生产环境需要用进程管理工具守护,比如pm2。它能做到崩溃自动重启、日志落盘、开机自启。
npm install -g pm2 pm2 start src/server.js --name my-api pm2 save pm2 startup日志方面,console.log在 pm2 下会被收集到日志文件里,但更好的做法是用专门的日志库写到文件,方便切割和检索。小项目用 pm2 自带的日志功能就够,不用过度设计。
6.3 上线前的自检清单
- 所有接口在本地跑通,包括正常和异常路径
.env已加入.gitignore,仓库里没有敏感信息- 错误响应不包含堆栈和内部路径
- 请求体大小限制已设置,防止超大请求打满内存
- 健康检查接口可用,方便探活
- 日志能正常输出,包含方法、路径、状态码、耗时
- 依赖版本锁定,
package-lock.json已提交
这套流程走下来,一个能用的 API 服务就成型了。它不复杂,但每个环节都有它存在的理由。我个人的体会是,小项目最怕的不是功能少,而是没有规范。规范定得早,后面加功能就是顺水推舟;规范定得晚,每加一个功能都要先还技术债。这个项目后续如果要扩展,最自然的下一步是把内存存储换成真实数据库,再往后是加鉴权和限流。到那时候,现在这套目录结构和中间件机制基本不用动,只需要替换数据层和增加新的中间件就行。