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 expressnpm 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 服务就是你的起点,把它跑起来,然后大胆地改、大胆地加功能,踩的坑越多,学得越快。