ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

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

1.1 为什么选这个题目练手

我一直觉得,学一门后端技术最快的路径不是啃文档,而是从零手搓一个能跑起来的 API 服务。原因很简单:API 服务是几乎所有现代应用的骨架,你手机上的外卖 App、公司内部的报表系统、甚至家里那台智能音箱,背后都是一堆 HTTP 接口在互相喊话。把这块搞明白了,后面学数据库、缓存、消息队列、容器化,都是在这个骨架上挂肉。

这次的小项目目标很明确:用 Node.js + Express 搭一个提供 JSON 数据的 RESTful API,包含增删改查四个基本动作,最后用 curl 验证。选 Node.js 而不是 Java、Go,是因为它对新手最友好——不用编译,改完代码刷新就能看到效果,而且 JavaScript 语法对前端同学来说是零门槛。Express 则是 Node 生态里最成熟的 Web 框架,社区资料多,遇到问题一搜一大把。

这个项目适合谁?如果你刚学完 JavaScript 基础语法,知道什么是函数、对象、数组,但还没碰过后端;或者你是前端开发者,想搞清楚自己每天调用的接口是怎么写出来的;再或者你是个运维/测试,想自己搭个 Mock 服务方便联调——那这篇内容就是给你准备的。整个过程不需要你有服务器,本地电脑就能跑通。

1.2 技术选型的几个关键考量

在动手之前,我先把几个关键选择说清楚,这样你后面看代码时不会一头雾水。

为什么用 Express 而不是原生 http 模块?Node.js 自带的http模块确实能起服务,但你要自己处理路由匹配、请求体解析、错误响应格式,写起来又臭又长。Express 把这些脏活累活封装好了,一个app.get('/users', handler)就能定义一个接口,中间件机制也让日志、鉴权、跨域这些横切关注点变得清爽。对于小项目来说,Express 的"够用且不啰嗦"是最合适的。

为什么数据先存在内存里?很多教程一上来就让你装 MySQL、配连接池,结果新手卡在环境配置上三天,代码一行没写。我的建议是:先用一个数组模拟数据库,把 API 的逻辑跑通,理解请求-响应的完整链路。等你对路由、状态码、JSON 序列化这些概念有感觉了,再把存储层换成真正的数据库,那时候你才知道为什么要用 ORM、为什么要做连接池。

为什么用 curl 做验证?浏览器只能方便地发 GET 请求,要测 POST、PUT、DELETE 就得装 Postman 之类的工具。curl 是命令行工具,几乎每台机器都有,而且它把 HTTP 请求的原始形态暴露得很清楚——你能看到请求头、请求体、响应码、响应头,这对理解 HTTP 协议帮助极大。热词里那一堆curl -v、curl: (56)、curl: (35)的报错,其实都是大家在用 curl 调试时踩的坑,后面我会专门讲怎么排查。

1.3 整体架构长什么样

整个服务的结构非常简单,就三层:

  • 入口层:app.js负责创建 Express 应用、挂载中间件、启动监听端口。
  • 路由层:定义 URL 路径和 HTTP 方法的映射关系,比如GET /api/users对应获取用户列表。
  • 数据层:一个内存数组充当"数据库",提供增删改查的原子操作。

请求进来后的流程是:先经过全局中间件(日志、JSON 解析),然后匹配到对应路由,路由处理函数操作数据层,最后把结果以 JSON 格式返回。这个流程和大型项目的本质是一样的,只是大型项目会把每一层拆成独立的文件甚至独立的服务。

提示:不要小看这个"玩具"结构。我见过太多人一上来就搞微服务、搞分层架构,结果连一个接口的完整生命周期都说不清楚。先把单体跑通,再谈拆分。

2. 环境准备与核心细节解析

2.1 Node.js 安装:版本选择与常见坑

第一步是装 Node.js。这里有个关键点:一定要装 LTS 版本。LTS 是长期支持版,稳定、bug 少、社区兼容性好。热词里频繁出现的node.js lts下载、node.js 20+、ubuntu安装node.js 20+说明大家都在纠结版本问题。我的建议是直接用 20.x 的 LTS,它在性能和 API 稳定性之间平衡得最好。

安装方式分两种。Windows 和 macOS 用户直接去官网下载安装包,一路下一步就行。Linux 用户(比如 Ubuntu)我推荐用 NodeSource 的源来装,比系统自带的版本新:

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs

装完后验证一下:

node -v npm -v

如果node -v输出v20.x.x,说明装好了。这里插一句,热词里有个permission denied while trying to connect to the docker api,虽然和 Node 没直接关系,但道理相通——权限问题十有八九是没用对用户身份。在 Linux 上装全局包时如果报权限错误,不要无脑sudo,正确做法是配置 npm 的全局目录到用户目录下,避免污染系统环境。

2.2 初始化项目与依赖安装

找个空文件夹,执行:

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

npm init -y会生成一个默认的package.json,里面记录了项目名、版本、依赖等信息。npm install express会把 Express 装到node_modules目录,并在package.json的dependencies里加上一条记录。

这里有个新手常问的问题:node_modules要不要提交到 Git?绝对不要。这个目录动辄几百兆,而且里面的内容完全可以通过package.json和package-lock.json还原。正确的做法是在.gitignore里加上node_modules/。

另外,我习惯在package.json的scripts里加一条启动命令:

"scripts": { "start": "node app.js", "dev": "node --watch app.js" }

node --watch是 Node 20 内置的热重载功能,改完代码自动重启,省得你每次手动 Ctrl+C 再重跑。以前大家用 nodemon,现在原生支持了,少装一个依赖。

2.3 理解 HTTP 方法与状态码

在写代码前,必须把 RESTful 的核心约定搞清楚,否则你写出来的接口别人没法用。

HTTP 方法语义对应操作幂等性
GET获取资源查询是
POST创建资源新增否
PUT全量更新资源替换是
PATCH部分更新资源修改否
DELETE删除资源删除是

幂等性这个词听起来唬人,其实很简单:同一个请求发一次和发十次,对服务器状态的影响是一样的。GET 查十次数据不会变,DELETE 删十次结果都是"已删除",所以它们幂等;POST 发十次会创建十条记录,所以不幂等。理解这个,你设计接口时就不会乱用方法了。

状态码方面,记住几个常用的就够了:

  • 200 OK:请求成功,正常返回数据。
  • 201 Created:资源创建成功,通常用于 POST。
  • 400 Bad Request:客户端传的参数有问题。
  • 404 Not Found:请求的资源不存在。
  • 500 Internal Server Error:服务器自己出错了。

热词里那个api error: 400 this model's maximum context length is 1048576 tokens就是典型的 400 错误——客户端传的内容超限了,服务端明确告诉你哪里不对。好的 API 就应该这样,错误信息要具体,别只返回一个"出错了"。

3. 从零实现完整 API 服务

3.1 搭建最小可运行服务

先写一个最简单的版本,确认环境没问题:

const express = require('express'); const app = express(); const PORT = 3000; app.get('/', (req, res) => { res.json({ message: 'API 服务已启动' }); }); app.listen(PORT, () => { console.log(`服务运行在 http://localhost:${PORT}`); });

保存为app.js,运行npm run dev,然后在另一个终端执行:

curl http://localhost:3000/

你应该看到{"message":"API 服务已启动"}。这一步看着简单,但它验证了整条链路:Node 能跑、Express 能加载、端口能监听、curl 能通。任何复杂项目都应该从这样一个最小闭环开始,别一上来就写几百行,出了错你都不知道从哪查。

3.2 设计数据模型与内存存储

我们用"用户管理"作为业务场景。先定义一个内存数组:

let users = [ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' } ]; let nextId = 3;

nextId用来生成新用户的 ID。为什么不直接用数组长度加一?因为删除操作会让数组长度变化,导致 ID 重复。用一个独立的计数器是更稳妥的做法。

注意:内存存储的数据在服务重启后会全部丢失,这是它的固有特性。开发阶段无所谓,但如果你要演示给别人看,记得每次重启后重新造数据,或者干脆写个初始化脚本。

3.3 实现查询接口(GET)

获取用户列表:

app.get('/api/users', (req, res) => { res.json({ code: 0, data: users }); });

获取单个用户:

app.get('/api/users/:id', (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 }); });

这里有几个细节值得说。第一,req.params.id拿到的是字符串,必须用parseInt转成数字再比较,否则'1' === 1永远是 false。热词里javascript判断数据类型是高频搜索,说明很多人在这上面栽过跟头。第二,我用了{ code, data, message }这种统一响应格式,code: 0表示成功,非零表示失败。这是国内很多团队的约定俗成,好处是前端处理逻辑统一,不用去猜每个接口的返回结构。

3.4 实现创建接口(POST)

POST 请求的数据在请求体里,Express 默认不解析请求体,需要挂一个中间件:

app.use(express.json());

这行代码必须放在所有路由之前。然后写创建逻辑:

app.post('/api/users', (req, res) => { const { name, email } = req.body; if (!name || !email) { return res.status(400).json({ code: 1, message: 'name 和 email 不能为空' }); } const newUser = { id: nextId++, name, email }; users.push(newUser); res.status(201).json({ code: 0, data: newUser }); });

用 curl 测试:

curl -X POST http://localhost:3000/api/users \ -H "Content-Type: application/json" \ -d '{"name":"王五","email":"wangwu@example.com"}'

-H指定请求头,-d指定请求体。Content-Type: application/json这个头千万不能漏,否则express.json()中间件不会解析请求体,req.body会是空对象。我见过太多人卡在这里,反复检查代码逻辑,结果问题出在请求头。

3.5 实现更新与删除接口

更新用 PUT:

app.put('/api/users/:id', (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 { name, email } = req.body; users[index] = { id, name: name || users[index].name, email: email || users[index].email }; res.json({ code: 0, data: users[index] }); });

删除用 DELETE:

app.delete('/api/users/:id', (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 }); });

findIndex返回匹配元素的下标,找不到返回 -1,这个判断模式在数组操作里非常常用。splice(index, 1)删除一个元素并返回被删除的元素数组,用解构取出第一个就是被删的对象。

3.6 加上日志中间件

为了调试方便,加一个简单的请求日志:

app.use((req, res, next) => { console.log(`${new Date().toISOString()} ${req.method} ${req.url}`); next(); });

next()是 Express 中间件的核心,调用它才会把控制权交给下一个中间件或路由。忘记调用 next() 是新手最常见的 bug,表现就是请求发出去后一直挂起,浏览器转圈圈,最后超时。热词里curl: (28) timeout这类超时错误,排查时就要先看看是不是某个中间件卡住了。

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

4.1 curl 报错速查表

调试 API 时 curl 的报错信息五花八门,我整理了一份速查表:

报错信息含义排查方向
curl: (7) Failed to connect连不上服务服务没启动,或端口写错
curl: (28) timeout连接超时中间件卡住,或网络不通
curl: (35) recv failure: connection reset连接被重置服务端崩溃,或协议不匹配
curl: (56) recv failure接收数据失败服务端提前关闭连接
curl: (23) failure writing output写输出失败磁盘满,或管道被关闭
curl: (52) empty reply空响应服务端没返回任何内容就断了

热词里curl 56 recv failure: 连接超时和curl: (35) recv failure: connection reset by peer出现频率很高。这两个错误的共同点是服务端在响应完成前就断开了连接。常见原因有三个:一是代码里有未捕获的异常导致进程崩溃;二是响应体太大,客户端等不及断了;三是中间件里调用了res.end()之后又试图写数据。

排查这类问题的杀手锏是curl -v,-v会把请求和响应的完整过程打印出来,包括 TCP 连接、TLS 握手、请求头、响应头。你能清楚看到是在哪一步断的。如果连 TCP 都没建立起来,那就是网络或端口问题;如果请求发出去了但没响应,那就是服务端逻辑问题。

4.2 请求体解析失败的三种情况

req.body是undefined或空对象,是 POST/PUT 接口最常见的故障。我总结了三种情况:

情况一:没挂express.json()中间件。这是最基础的,检查app.use(express.json())是否在所有路由之前。

情况二:请求头没带Content-Type: application/json。Express 是根据这个头来决定用哪个解析器的,头不对,body 就是空的。

情况三:JSON 格式本身有语法错误。比如用了单引号、多了个逗号、中文没转义。express.json()解析失败会抛异常,如果你没做错误处理,客户端会收到一个 400 但信息不明确。可以加一个错误处理中间件:

app.use((err, req, res, next) => { if (err instanceof SyntaxError && err.status === 400) { return res.status(400).json({ code: 1, message: 'JSON 格式错误' }); } next(err); });

注意错误处理中间件有四个参数,缺一不可,Express 靠参数个数来识别它。

4.3 端口占用与进程管理

Error: listen EADDRINUSE: address already in use :::3000这个错误几乎每个人都遇到过。意思是 3000 端口被别的进程占了。解决办法有两个:换端口,或者杀掉占用进程。

Linux/macOS 下查占用:

lsof -i :3000 kill -9 <PID>

Windows 下:

netstat -ano | findstr :3000 taskkill /PID <PID> /F

我个人的习惯是开发时用 3000,但准备一个环境变量来覆盖:

const PORT = process.env.PORT || 3000;

这样部署到云平台时,平台注入的 PORT 环境变量会自动生效,不用改代码。

4.4 几个我踩过的坑

坑一:路由顺序影响匹配结果。app.get('/api/users/:id')如果写在app.get('/api/users/active')前面,那么访问/api/users/active会被:id路由捕获,id变成字符串'active'。具体路径要写在参数路径前面,这是 Express 路由匹配的规则。

坑二:忘记处理异步错误。如果路由处理函数是 async 的,里面抛出的异常 Express 默认捕获不到,会导致进程崩溃。Node 20 之后可以用process.on('uncaughtException')兜底,但更好的做法是用 try-catch 包住,或者用express-async-errors这个库。

坑三:JSON 里的中文显示成乱码。这通常是终端编码问题,不是服务的问题。res.json()会自动设置Content-Type: application/json; charset=utf-8,数据本身是对的。用curl ... | python -m json.tool格式化一下就能正常显示。

坑四:ID 类型不一致。从 URL 拿到的 id 是字符串,从请求体拿到的可能是数字,比较时一定要统一类型。我习惯在入口处就parseInt,后面全程用数字。

5. 接口测试与验证实操

5.1 完整的 curl 测试脚本

把下面这段存成test.sh,一键跑完所有接口的验证:

#!/bin/bash BASE="http://localhost:3000/api/users" echo "=== 1. 获取用户列表 ===" curl -s $BASE | python -m json.tool echo "=== 2. 创建用户 ===" curl -s -X POST $BASE \ -H "Content-Type: application/json" \ -d '{"name":"赵六","email":"zhaoliu@example.com"}' | python -m json.tool echo "=== 3. 获取单个用户 ===" curl -s $BASE/1 | python -m json.tool echo "=== 4. 更新用户 ===" curl -s -X PUT $BASE/1 \ -H "Content-Type: application/json" \ -d '{"name":"张三丰"}' | python -m json.tool echo "=== 5. 删除用户 ===" curl -s -X DELETE $BASE/2 | python -m json.tool echo "=== 6. 验证删除结果 ===" curl -s $BASE | python -m json.tool

-s参数让 curl 静默执行,不显示进度条,输出更干净。管道接python -m json.tool是为了格式化 JSON,让结果可读。这个脚本跑一遍,你就能确认所有接口是否正常工作。

5.2 用 curl -v 深入观察 HTTP 交互

想真正理解 HTTP,一定要用curl -v看一次完整的交互:

curl -v -X POST http://localhost:3000/api/users \ -H "Content-Type: application/json" \ -d '{"name":"测试","email":"test@example.com"}'

输出里以>开头的是请求内容,<开头的是响应内容。你会看到请求行、请求头、请求体,以及响应的状态行、响应头、响应体。这是理解 HTTP 协议最直观的方式,比看任何文档都管用。特别注意响应头里的Content-Type、Content-Length、Date,以及状态码。

5.3 接口设计的几个经验之谈

第一,URL 用名词复数,不要用动词。GET /api/users而不是GET /api/getUsers。HTTP 方法本身已经表达了动作,URL 只负责定位资源。

第二,版本号放在 URL 里。/api/v1/users比/api/users更好,因为将来接口不兼容升级时,老客户端还能继续用 v1。

第三,分页参数用 query string。GET /api/users?page=1&size=20,不要塞到路径里。分页、排序、过滤这些都属于查询条件,放 query 里语义最清晰。

第四,错误响应要包含足够信息。至少要有错误码、错误描述,有条件的话加上出错的字段名。热词里api error: 400 the parameter messages.content.type specified in the request就是个好例子,它明确告诉你是哪个参数的类型不对。

6. 后续扩展方向

这个项目虽然小,但扩展空间很大。等你把基础版跑通后,可以按这个顺序逐步加功能:

第一步,接入真实数据库。把内存数组换成 SQLite(零配置,适合练手),再换成 MySQL 或 PostgreSQL。你会接触到连接池、事务、SQL 注入防护这些概念。

第二步,加参数校验。手写if (!name)太原始,用zod或joi这类库做 schema 校验,代码更简洁,错误信息更规范。

第三步,加鉴权。用 JWT 做登录态,给接口加上Authorization头校验。这是从"玩具"走向"能用"的关键一步。

第四步,写自动化测试。用jest+supertest给每个接口写测试用例,改代码时跑一遍就知道有没有破坏原有功能。

第五步,容器化部署。写个 Dockerfile,把服务打包成镜像。这时候你会遇到热词里提到的permission denied while trying to connect to the docker api这类问题,但那是另一个故事了。

我个人在实际操作中的体会是:小项目最大的价值不是功能本身,而是让你把一条完整链路走通。从写代码、启动服务、发请求、看响应、排查错误,这一圈走下来,你对后端的理解会比看十篇文章都扎实。很多人学不会后端,不是因为知识点太难,而是因为一直在看别人写好的代码,从没自己从零搭过一个能跑的东西。这个 API 服务就是你的起点,把它跑起来,然后大胆地改、大胆地加功能,踩的坑越多,学得越快。

RELATED READING

延伸阅读

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