news 2026/10/27 16:41:12

从零手搓 Node.js + Express RESTful API:增删改查与 curl 调试实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手搓 Node.js + Express RESTful API:增删改查与 curl 调试实战

1. 项目缘起与整体设计思路

1.1 为什么选这个题目练手

我一直觉得,学一门后端技术最快的路径不是啃文档,而是从零手搓一个能跑起来的 API 服务。原因很简单:API 服务是几乎所有现代应用的骨架,前端要调它、移动端要调它、第三方系统要对接它,你把它搞明白了,后面学数据库、缓存、鉴权、部署,都是在这个骨架上挂东西。

这个项目我给自己定的目标很明确:不依赖任何脚手架生成器,从空文件夹开始,用 Node.js + Express 手写一个具备增删改查能力的 RESTful API 服务,并且能用 curl 完整验证每一个接口。之所以强调“不依赖脚手架”,是因为express-generator这类工具会一次性塞给你一堆目录和中间件,新手根本分不清哪些是必需的、哪些是模板自带的,出了问题也不知道从哪查。手搓一遍,每一行代码都是你自己写的,心里才有底。

技术选型上,Node.js 我选LTS 版本(当前主流是 20.x 及以上),Express 选 4.x 稳定版。这套组合的好处是生态成熟、资料多、上手快,遇到问题基本都能搜到答案。JavaScript 作为语言,虽然这两年 TypeScript 很火,但对于第一个练手项目,我建议先用纯 JS 把流程跑通,理解清楚请求怎么进来、响应怎么出去,再上类型系统也不迟。

这个项目适合谁?适合已经会一点 JavaScript 基础语法、但没写过后端接口的人;也适合写过后端但一直用框架全家桶、想搞清楚底层到底发生了什么的开发者。整个项目做完,你会对 HTTP 请求的生命周期、路由匹配、中间件执行顺序、JSON 序列化这些概念有非常具体的体感。

1.2 整体架构与目录规划

在动手写代码之前,我习惯先把目录结构想清楚。很多人一上来就npm init然后闷头写,写到后面文件乱成一团,改一个功能要翻五个文件。我的做法是先画结构,再填内容。

这个 API 服务我规划成这样的目录:

my-api/ ├── package.json ├── server.js # 入口文件,负责启动服务 ├── routes/ │ └── users.js # 用户相关的路由 ├── controllers/ │ └── userController.js # 业务逻辑 ├── data/ │ └── users.js # 临时数据存储(先用内存数组) └── middleware/ └── logger.js # 自定义日志中间件

为什么这么分?入口文件只负责“组装”,不负责“干活”。server.js里只做三件事:创建 app、挂载中间件、监听端口。具体的路由逻辑放到routes,业务处理放到controllers,数据先放data。这样分层的好处是,等你以后要把内存数据换成数据库,只需要改data那一层,路由和控制器基本不用动。

这里有个新手常踩的坑:把所有代码都堆在server.js里。刚开始可能就三五个接口,感觉还行,但接口一多,文件几百行,找起来要命。分层不是为了显得专业,是为了你自己以后少受罪。

数据存储这块,第一个项目我强烈建议先用内存数组,不要一上来就接数据库。原因是你还没搞清楚接口逻辑,就去折腾数据库连接、建表、字段类型,问题会混在一起,排查起来非常痛苦。等接口全部跑通、curl 验证无误了,再把数组换成数据库,这时候你就能清晰感受到“数据层”和“逻辑层”的边界在哪里。

2. 环境准备与核心依赖安装

2.1 Node.js 安装与版本选择

环境准备这一步,看着简单,其实是新手翻车最多的地方。我见过太多人卡在node -v报错、npm命令找不到、版本太老装不上依赖这些问题上。

先说版本。Node.js 的版本分两类:LTS(长期支持版)和 Current(最新特性版)。做项目我建议一律用 LTS,因为 LTS 经过充分测试,稳定,而且大部分第三方库都针对 LTS 做过兼容。当前主流 LTS 是 20.x 系列,你装 20 以上的版本基本都没问题。

安装方式上,Windows 和 macOS 用户直接去官网下载安装包,一路下一步就行。Linux 用户(比如 Ubuntu)我推荐用 NodeSource 的源来装,这样版本管理更干净:

# 以 Ubuntu 为例,先更新包索引 sudo apt update # 添加 NodeSource 源(以 20.x 为例) curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - # 安装 sudo apt install -y nodejs

装完之后,一定要验证:

node -v npm -v

两条命令都能输出版本号,才算装好。如果node -v有输出但npm -v报错,多半是环境变量没配好,或者安装过程中断了,重装一遍最省事。

提示:如果你机器上已经装了旧版本 Node,建议先卸载干净再装新版。多个版本混在一起,很容易出现node和npm版本不匹配的诡异问题。

2.2 初始化项目与安装 Express

环境好了,开始建项目。找个你习惯放代码的目录,执行:

mkdir my-api && cd my-api npm init -y

npm init -y会生成一个默认的package.json,省去你一路回车。生成后我建议打开看一眼,把name、description、main这几个字段改成符合你项目的值。main字段指向你的入口文件,我习惯改成server.js。

接下来装 Express:

npm install express

装完你会看到目录下多了node_modules文件夹和package-lock.json。node_modules里是 Express 及其所有依赖,这个文件夹不要手动改,也不要提交到代码仓库。package-lock.json记录了每个依赖的确切版本,这个要提交,它能保证别人拿到你代码后装出来的依赖版本和你完全一致。

这里插一句关于依赖版本的坑。npm install express默认装的是最新版,package.json里会写成"express": "^4.x.x"这种带^的形式。^的意思是“允许装同一大版本下的最新小版本”。大部分时候没问题,但偶尔某个小版本引入 bug,就会导致你本地好好的,别人一装就崩。生产项目里我建议锁定精确版本,把^去掉,写成"express": "4.18.2"这种。

2.3 用 curl 做接口验证的准备

这个项目我全程用 curl 来验证接口,所以提前说清楚 curl 的用法。curl 是命令行里的 HTTP 客户端,几乎所有 Linux 和 macOS 都自带,Windows 10 以后的版本也内置了。

最基础的 GET 请求:

curl http://localhost:3000/users

发 POST 请求带 JSON 数据:

curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"张三","age":28}'

这里-X指定方法,-H加请求头,-d带请求体。Content-Type: application/json这个头非常关键,少了它,Express 的express.json()中间件就解析不出请求体,你会拿到一个空的req.body,然后对着代码怀疑人生。

调试的时候我习惯加-v参数,它会打印完整的请求和响应头,包括状态码、响应头、握手过程。接口返回不对的时候,-v能帮你快速判断是请求发错了还是服务端处理错了。

注意:curl 命令里的 URL 如果带查询参数,记得用引号包起来,比如curl "http://localhost:3000/users?page=1&size=10",否则&会被 shell 解释成后台执行符号,命令行为会完全出乎你的意料。

3. 核心代码实现与逐层拆解

3.1 入口文件 server.js 的组装逻辑

入口文件是整个服务的“总装车间”,我把它写得尽量薄。先看代码:

const express = require('express'); const logger = require('./middleware/logger'); const userRoutes = require('./routes/users'); const app = express(); const PORT = process.env.PORT || 3000; // 挂载中间件 app.use(express.json()); app.use(logger); // 挂载路由 app.use('/users', userRoutes); // 全局错误处理 app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: '服务器内部错误' }); }); app.listen(PORT, () => { console.log(`服务已启动,监听端口 ${PORT}`); });

逐行说。express.json()是内置中间件,作用是把请求体里的 JSON 字符串解析成 JavaScript 对象,挂到req.body上。没有它,你 POST 过来的数据在req.body里就是undefined。这个中间件必须放在路由之前,因为中间件是按注册顺序执行的,顺序错了就解析不到。

app.use(logger)挂的是我自己写的日志中间件,后面细说。app.use('/users', userRoutes)把/users开头的请求全部交给userRoutes处理。这种“前缀 + 路由模块”的写法,是 Express 里组织路由的标准姿势,好处是每个资源一个文件,互不干扰。

最后那个四参数的错误处理中间件,必须放在所有路由之后。Express 靠参数个数来识别错误处理中间件——四个参数(err, req, res, next)就是错误处理,三个参数就是普通中间件。任何路由里next(err)抛出的错误,都会汇聚到这里统一处理,避免每个接口都写一遍 try-catch。

process.env.PORT || 3000这个写法也值得说一句。它优先读环境变量里的端口,读不到才用 3000。这样部署到云平台时,平台注入的端口能生效,本地开发又不用每次配环境变量。

3.2 自定义日志中间件的实现

日志中间件是我强烈建议每个项目都加的东西,哪怕只是打印一行。它的价值在于:接口出问题时,你能看到请求到底有没有进来、进来的是什么、花了多久。

function logger(req, res, next) { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); }); next(); } module.exports = logger;

这里有个细节:为什么用res.on('finish')而不是直接在next()前打印?因为中间件执行的时候,响应还没发出去,你拿不到最终的状态码。finish事件在响应完全发送后触发,这时候res.statusCode才是准确的。Date.now()记录开始时间,在 finish 里算差值,就得到了请求处理耗时。

req.originalUrl比req.url更可靠,它保留了完整的原始路径和查询参数,不受路由挂载前缀的影响。这个日志格式方法 路径 状态码 耗时是我用了很多年的,简单但信息量足够,扫一眼就知道哪个接口慢、哪个接口在报错。

3.3 路由层与控制器层的职责划分

路由层只做一件事:把 URL 和方法映射到具体的处理函数。看routes/users.js:

const express = require('express'); const router = express.Router(); const userController = require('../controllers/userController'); router.get('/', userController.listUsers); router.get('/:id', userController.getUser); router.post('/', userController.createUser); router.put('/:id', userController.updateUser); router.delete('/:id', userController.deleteUser); module.exports = router;

express.Router()创建的是一个迷你 app,它有自己的中间件和路由系统,但可以挂载到主 app 上。这种设计让路由可以模块化,/users相关的全在这个文件里,/orders相关的放另一个文件,互不干扰。

注意/:id这种写法,冒号开头的是路由参数,Express 会自动把匹配到的值放到req.params.id里。比如请求/users/123,req.params.id就是字符串"123"。这里有个坑:路由参数永远是字符串,哪怕你传的是数字,拿到的也是"123"而不是123。做比较或者运算前记得转换。

控制器层才是真正干活的地方。看controllers/userController.js:

const users = require('../data/users'); exports.listUsers = (req, res) => { res.json({ code: 0, data: users, message: 'success' }); }; exports.getUser = (req, res) => { const id = parseInt(req.params.id, 10); const user = users.find(u => u.id === id); if (!user) { return res.status(404).json({ code: 1, message: '用户不存在' }); } res.json({ code: 0, data: user, message: 'success' }); }; exports.createUser = (req, res) => { const { name, age } = req.body; if (!name || typeof age !== 'number') { return res.status(400).json({ code: 1, message: '参数不合法' }); } const newUser = { id: users.length ? users[users.length - 1].id + 1 : 1, name, age }; users.push(newUser); res.status(201).json({ code: 0, data: newUser, message: 'success' }); }; exports.updateUser = (req, res) => { const id = parseInt(req.params.id, 10); const user = users.find(u => u.id === id); if (!user) { return res.status(404).json({ code: 1, message: '用户不存在' }); } const { name, age } = req.body; if (name !== undefined) user.name = name; if (age !== undefined) user.age = age; res.json({ code: 0, data: user, message: 'success' }); }; exports.deleteUser = (req, res) => { const id = parseInt(req.params.id, 10); const index = users.findIndex(u => u.id === id); if (index === -1) { return res.status(404).json({ code: 1, message: '用户不存在' }); } const [removed] = users.splice(index, 1); res.json({ code: 0, data: removed, message: 'success' }); };

这段代码里有几个我特意处理的点,值得展开说。

第一,统一响应格式。所有接口都返回{ code, data, message }这个结构。code为 0 表示成功,非 0 表示业务错误。为什么不用 HTTP 状态码区分一切?因为 HTTP 状态码表达的是“传输层”的结果,而业务错误(比如“用户不存在”)用状态码表达会很别扭。统一格式让前端处理起来简单:先看 HTTP 状态码判断请求是否成功,再看code判断业务是否成功。

第二,参数校验。createUser里检查了name存在且age是数字。这里用typeof age !== 'number'而不是!age,是因为age为 0 时!age是 true,会误判。数字 0 是合法值,但 falsy,这是 JavaScript 里非常经典的坑。同理,字符串空串也是 falsy,判断字符串是否存在要用typeof x === 'string' && x.length > 0或者直接x === undefined。

第三,id 生成策略。我用users[users.length - 1].id + 1来生成新 id。这个策略在内存数组里够用,但并发场景下会出问题——两个请求同时进来,可能拿到同一个 id。真实项目里 id 应该由数据库自增或者用 UUID 生成。这里先用简单方案,等接数据库时再换。

第四,parseInt(req.params.id, 10)的第二个参数。这个10是进制,必须写。不写的话,parseInt("08")在某些老引擎里会被当成八进制解析,得到 0。虽然现代引擎默认按十进制,但显式写上更稳妥,也是团队协作时的好习惯。

3.4 数据层与内存存储的取舍

data/users.js就是一个数组:

module.exports = [ { id: 1, name: '张三', age: 28 }, { id: 2, name: '李四', age: 32 } ];

用module.exports导出一个数组,Node.js 的模块缓存机制保证所有 require 这个文件的地方拿到的是同一个数组引用。所以控制器里 push、splice 的修改,其他模块也能看到。这是内存存储能工作的关键。

但这里有个必须知道的限制:服务一重启,数据就没了。因为数组在内存里,进程结束就释放了。所以这个方案只适合练手和演示,真实项目必须接数据库。我建议你在跑通所有接口后,专门花时间把这一层换成 SQLite 或者 MongoDB,感受一下“只改数据层”的爽快。

4. 完整实操验证与 curl 调试实录

4.1 启动服务与首次验证

代码写完,启动服务:

node server.js

看到服务已启动,监听端口 3000就说明起来了。这时候别急着写复杂请求,先用最简单的 GET 验证服务活着:

curl http://localhost:3000/users

正常的话会返回:

{"code":0,"data":[{"id":1,"name":"张三","age":28},{"id":2,"name":"李四","age":32}],"message":"success"}

如果这一步就报错,先看服务端控制台有没有日志输出。没有日志说明请求根本没到服务,检查端口对不对、服务是不是真的启动了。有日志但返回不对,那就是代码逻辑问题,对着日志里的状态码排查。

4.2 增删改查全流程验证

创建用户:

curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"王五","age":25}'

预期返回 201 状态码和新创建的用户对象,id 应该是 3。这里如果返回 400,说明参数校验没过,检查name和age的类型。如果返回的req.body是空对象,八成是漏了Content-Type头。

查询单个用户:

curl http://localhost:3000/users/1

返回 id 为 1 的用户。试试查一个不存在的:

curl http://localhost:3000/users/999

应该返回 404 和用户不存在。这个“查不存在”的用例一定要测,很多新手只测正常路径,上线后一遇到异常输入就崩。

更新用户:

curl -X PUT http://localhost:3000/users/1 \ -H "Content-Type: application/json" \ -d '{"age":30}'

只传age,name应该保持不变。这验证了更新逻辑里if (name !== undefined)的判断是对的——没传的字段不动。

删除用户:

curl -X DELETE http://localhost:3000/users/2

返回被删除的用户。再查一次列表,确认 id 为 2 的没了。

4.3 用 -v 参数定位疑难问题

接口行为不符合预期时,-v是我的第一选择。比如你发现 POST 请求返回 400,但参数明明是对的,加-v看看:

curl -v -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"测试","age":20}'

输出里会看到> Content-Type: application/json这一行,确认请求头发出去了。再看< HTTP/1.1 400 Bad Request,确认服务端返回的状态码。如果请求头没问题、状态码是 400,那就是服务端校验逻辑的问题,回去看代码。

还有一种常见情况:请求发出去了,但一直卡着不返回。这通常是服务端某个地方死循环了,或者中间件里忘了调next()。Express 的中间件如果不调next()也不发响应,请求就会一直挂着,直到超时。排查方法是在中间件里加日志,看执行到哪一步停了。

5. 常见问题排查与避坑经验

5.1 端口占用与启动失败

Error: listen EADDRINUSE: address already in use :::3000这个报错,意思是 3000 端口被别的程序占了。可能是你上次启动的服务没关干净,也可能是别的软件在用这个端口。

排查方法:

# Linux/macOS lsof -i :3000 # Windows netstat -ano | findstr :3000

找到占用进程的 PID,杀掉它,或者换个端口启动:

PORT=3001 node server.js

我个人的习惯是开发时固定用一个不常用的端口,比如 3000 被占的概率其实挺高的,换成 4000 或 8080 会省心一些。

5.2 req.body 为空的几种原因

这是新手遇到频率最高的问题,没有之一。req.body是undefined或者空对象,原因通常有这几个:

现象原因解决
req.body 为 undefined没挂 express.json()在路由前 app.use(express.json())
req.body 为空对象请求没带 Content-Typecurl 加 -H "Content-Type: application/json"
req.body 为空对象JSON 格式错误用工具校验 JSON 合法性
req.body 解析报错请求体太大配置 express.json({ limit: '1mb' })

排查顺序:先确认中间件挂了没,再确认请求头带了没,最后确认 JSON 格式对不对。这三步走完,99% 的情况都能解决。

5.3 路由匹配顺序引发的诡异 bug

Express 的路由是按注册顺序匹配的,匹配到第一个就停。这意味着如果你把router.get('/:id', ...)写在router.get('/list', ...)前面,请求/users/list会被/:id捕获,req.params.id变成字符串"list",然后你的查询逻辑就会去找 id 为 "list" 的用户,当然找不到。

规则是:具体路径写在参数路径前面。静态路由优先,动态路由靠后。这个坑我在真实项目里踩过,当时排查了半天,最后发现是路由顺序问题。

5.4 跨域问题的临时处理

前端调这个 API 时,浏览器会报 CORS 错误。开发阶段最简单的处理是装cors中间件:

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

app.use(cors())会允许所有来源访问。但这只适合开发环境,生产环境必须配置具体的允许来源,否则等于把接口完全敞开。我见过有人把cors()直接带到生产,结果接口被随便调用,这个教训要记住。

5.5 常见 curl 报错速查

报错信息含义排查方向
curl: (7) Failed to connect连不上服务服务没启动或端口不对
curl: (52) Empty reply服务端没返回内容服务端崩溃或没发响应
curl: (56) Recv failure连接被重置服务端处理中异常退出
curl: (35) SSL connect errorSSL 握手失败协议或证书问题
400 Bad Request请求格式错误检查 JSON 和请求头

这些报错里,(7)和(52)最常见。(7)基本就是服务没起来,(52)通常是服务端代码抛异常了但没被捕获,进程直接挂了。遇到(52),去看服务端控制台的报错堆栈,那里有真相。

6. 项目扩展方向与个人实践体会

6.1 从内存到数据库的平滑迁移

接口全部跑通后,我建议做的第一件事就是把内存数组换成真正的数据库。推荐从 SQLite 开始,因为它不需要单独装服务,一个文件就是一个库,特别适合练手。

迁移的核心思路是:只改 data 层,控制器和路由不动。把data/users.js改成一个封装了数据库操作的模块,对外暴露findAll、findById、create、update、remove这几个方法。控制器里原来直接操作数组的地方,改成调用这些方法。因为方法签名和原来数组操作的行为一致,控制器几乎不用改。

这个过程能让你深刻体会到分层设计的价值——如果一开始代码全堆在一起,这次迁移就得大改;分层之后,改动被限制在一个文件里。

6.2 加上参数校验和错误码体系

现在项目里的参数校验是手写的 if 判断,接口一多就会重复。可以引入joi或者zod这类校验库,把校验规则声明式地写出来。同时把错误码整理成常量,比如1001表示参数错误、1002表示资源不存在,前端拿到错误码就能做对应处理,不用去解析错误文案。

6.3 我踩过的几个真实坑

第一个坑是忘记处理异步错误。Express 4.x 不会自动捕获 async 函数里抛出的异常,如果你的控制器是async的,里面await报错,请求会一直挂着不返回。解决办法是用express-async-errors这个库,或者自己包一层 try-catch。这个坑很隐蔽,因为同步代码报错 Express 能捕获,异步的就不行。

第二个坑是响应发了两次。比如你在控制器里先res.json()发了响应,后面又调了一次res.status().json(),Express 会报Cannot set headers after they are sent。排查方法是检查所有分支,确保每个分支只发一次响应,而且发完就return。

第三个坑是JSON 里的中文乱码。这个通常不是 Express 的问题,而是 curl 或者终端编码的问题。res.json()默认会设置Content-Type: application/json; charset=utf-8,中文是正常的。如果显示乱码,检查你的终端编码设置。

6.4 后续可以继续做的练习

这个项目跑通之后,可以沿着几个方向继续深入:加一个简单的 Token 鉴权,理解请求怎么带身份信息;加请求频率限制,防止接口被刷;加单元测试,用 Jest 或 Mocha 给每个接口写测试用例;最后把它部署到云服务器上,用 Nginx 做反向代理,体验完整的上线流程。

我个人在实际操作中的体会是,第一个 API 项目不要追求功能多,要追求每一层都亲手写过、每一个报错都亲手排查过。你在这个小项目里踩的坑,在大项目里一个都不会少,只是规模不同而已。把基础打扎实,后面学什么框架都是水到渠成的事。

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

Python第二次作业全攻略:从核心语法、函数到调试实战

如果你正在跟“Python第二次作业”较劲&#xff0c;那我要先恭喜你&#xff1a;你已经跨过了从“照着教程抄代码”到“自己动手拼逻辑”的那道坎。第一次作业大家都差不多&#xff0c;无非是打印个Hello World、算个两数之和、写个简单的判断&#xff1b;第二次作业就开始见真章…

作者头像 李华
网站建设 2026/10/20 20:17:35

计算机网络入门:从分层模型到抓包排障的一条主线

计算机网络这个词说出来轻飘飘&#xff0c;但真正想把它弄明白的人&#xff0c;十个里有八个会在三周内放弃。原因不是它难&#xff0c;而是资料铺天盖地——从OSI七层到TCP三次握手&#xff0c;从子网掩码到路由协议&#xff0c;每个知识点单拎出来都有人讲&#xff0c;合在一…

作者头像 李华
网站建设 2026/10/12 20:34:26

系统架构师软考区块链考点:共识机制与联盟链架构案例解析

1. 为什么系统架构师考试绕不开区块链“软考-系统架构师-区块链”这组关键词放在一起&#xff0c;在高级资格备考圈里越来越常见。很多同学是先拿下了软件设计师或网络工程师的中级证书&#xff0c;再来挑战系统架构师&#xff0c;结果第一感受就是&#xff1a;考试风格变了。中…

作者头像 李华
网站建设 2026/10/27 23:49:40

Python爬虫实战:多线程自动下载高清壁纸脚本全解析

很多人刚接触Python时&#xff0c;第一个想做的“有点用”的小工具&#xff0c;往往不是爬网页数据&#xff0c;而是“帮我自动下载壁纸”。这个需求听起来简单&#xff0c;实际上它把Python爬虫的核心环节几乎都串了一遍&#xff1a;网络请求、数据解析、文件保存、异常处理、…

作者头像 李华
网站建设 2026/10/14 1:51:20

多AI产品割裂怎么办?套件化Agent底座五大层设计与迁移实践

最近接手了一个让我挺头疼的项目&#xff1a;公司里陆陆续续上了五个 AI 产品&#xff0c;有聊天助手、文档协作、数据分析、流程自动化、知识库问答&#xff0c;都是一线团队各自选型后跑起来的。表面上看各司其职&#xff0c;实际上问题很多。每个产品都有独立的登录入口、独…

作者头像 李华