news 2026/10/27 22:51:47

Node.js + Express 从零搭建 RESTful API 服务实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Node.js + Express 从零搭建 RESTful API 服务实战指南

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 nodemon

nodemon是开发期依赖,它能在你改代码后自动重启服务,省去手动 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.com

5.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=production

NODE_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 服务就成型了。它不复杂,但每个环节都有它存在的理由。我个人的体会是,小项目最怕的不是功能少,而是没有规范。规范定得早,后面加功能就是顺水推舟;规范定得晚,每加一个功能都要先还技术债。这个项目后续如果要扩展,最自然的下一步是把内存存储换成真实数据库,再往后是加鉴权和限流。到那时候,现在这套目录结构和中间件机制基本不用动,只需要替换数据层和增加新的中间件就行。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 22:48:26

函数式响应式编程(FRP)实战:从核心思路到落地避坑指南

函数式响应式编程&#xff08;FRP&#xff09;这几年被提得越来越多&#xff0c;但真正在项目里落地过的人都知道&#xff0c;这东西概念听着优雅&#xff0c;实际用起来坑不少。我第一次接触 FRP 是在一个实时数据看板的项目里&#xff0c;当时界面上的图表要跟着后端推送的数…

作者头像 李华
网站建设 2026/10/27 22:46:44

双指针技巧在数组元素移除中的应用

1. 移除元素问题解析这道题目要求我们原地移除数组中所有等于给定值的元素&#xff0c;并返回新数组的长度。题目看似简单&#xff0c;但考察了数组操作的基本功和双指针技巧的灵活运用。1.1 题目核心要求给定一个数组nums和一个值val&#xff0c;我们需要&#xff1a;移除所有…

作者头像 李华
网站建设 2026/10/27 22:46:08

C++工程化冒泡与选择排序:泛型迭代器实现与性能优化

1. 这不是教科书里的“冒泡”和“选择”&#xff0c;而是我带三届新人时反复重写的两段核心代码你点开这个标题&#xff0c;大概率正被算法题卡在LeetCode第215题的边缘&#xff0c;或者刚在某公司笔试里看到“手写冒泡排序并优化”&#xff0c;手心冒汗却只记得“相邻比较、大…

作者头像 李华
网站建设 2026/10/27 22:45:34

Ubuntu下OpenCV源码编译全指南:CUDA+SIFT+Python绑定实战

1. 项目概述&#xff1a;为什么Ubuntu下配OpenCV不是“装个包”就完事&#xff1f; OpenCV基础&#xff08;二&#xff09;——这个标题里藏着一个被新手严重低估的现实&#xff1a;在Ubuntu系统下完成一套真正可用、可调试、可扩展的OpenCV开发环境&#xff0c;远不止执行一条…

作者头像 李华
网站建设 2026/10/27 22:45:28

Git Rebase实战:重写提交历史的完整指南

1. 为什么我们需要重写提交历史&#xff1f;作为一个每天与Git打交道的开发者&#xff0c;我经常遇到这样的场景&#xff1a;在本地开发分支上提交了十几个小改动&#xff0c;准备合并到主分支时&#xff0c;发现提交记录杂乱无章&#xff0c;包含大量"临时保存"、&q…

作者头像 李华
网站建设 2026/10/27 22:45:24

Loop Engineering:AI时代可验证、可回滚的工程闭环方法论

1. 这不是又一个AI编程工具教程——Loop Engineering到底在解决什么真问题&#xff1f;“Loop Engineering”这个词最近在开发者圈子里高频出现&#xff0c;但很多人点开搜索结果后反而更迷糊&#xff1a;它既不像Cursor那样有安装包、登录页和明确的界面&#xff0c;也不像Cop…

作者头像 李华