ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

从零手搓 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 -ynpm init -y会生成一个默认的package.json省去你一路回车。生成后我建议打开看一眼把name、description、main这几个字段改成符合你项目的值。main字段指向你的入口文件我习惯改成server.js。接下来装 Expressnpm 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?page1size10否则会被 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.jsconst 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/123req.params.id就是字符串123。这里有个坑路由参数永远是字符串哪怕你传的是数字拿到的也是123而不是123。做比较或者运算前记得转换。控制器层才是真正干活的地方。看controllers/userController.jsconst 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}只传agename应该保持不变。这验证了更新逻辑里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杀掉它或者换个端口启动PORT3001 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/jsonreq.body 为空对象JSON 格式错误用工具校验 JSON 合法性req.body 解析报错请求体太大配置 express.json({ limit: 1mb })排查顺序先确认中间件挂了没再确认请求头带了没最后确认 JSON 格式对不对。这三步走完99% 的情况都能解决。5.3 路由匹配顺序引发的诡异 bugExpress 的路由是按注册顺序匹配的匹配到第一个就停。这意味着如果你把router.get(/:id, ...)写在router.get(/list, ...)前面请求/users/list会被/:id捕获req.params.id变成字符串list然后你的查询逻辑就会去找 id 为 list 的用户当然找不到。规则是具体路径写在参数路径前面。静态路由优先动态路由靠后。这个坑我在真实项目里踩过当时排查了半天最后发现是路由顺序问题。5.4 跨域问题的临时处理前端调这个 API 时浏览器会报 CORS 错误。开发阶段最简单的处理是装cors中间件npm install corsconst 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; charsetutf-8中文是正常的。如果显示乱码检查你的终端编码设置。6.4 后续可以继续做的练习这个项目跑通之后可以沿着几个方向继续深入加一个简单的 Token 鉴权理解请求怎么带身份信息加请求频率限制防止接口被刷加单元测试用 Jest 或 Mocha 给每个接口写测试用例最后把它部署到云服务器上用 Nginx 做反向代理体验完整的上线流程。我个人在实际操作中的体会是第一个 API 项目不要追求功能多要追求每一层都亲手写过、每一个报错都亲手排查过。你在这个小项目里踩的坑在大项目里一个都不会少只是规模不同而已。把基础打扎实后面学什么框架都是水到渠成的事。
返回列表