ARTICLE DETAIL

资讯详情

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

Node.js + Express 实战:从零搭建可交付的 API 服务

Node.js + Express 实战:从零搭建可交付的 API 服务 1. 为什么我选 Node.js Express 来搭这个 API 服务1.1 从能跑起来到能扛住用的选型逻辑很多人第一次搭 API 服务脑子里第一反应是我要不要上 Spring Boot要不要搞 Go是不是得配个 Nginx 才显得专业。我一开始也这么想结果折腾了两天环境接口一个没写。后来我换了个思路先让服务跑起来再谈优化。这个思路直接决定了我的技术选型——Node.js 加 Express。为什么是这两个先说 Node.js。它的核心优势是事件驱动、非阻塞 I/O翻译成人话就是当你的接口需要等数据库返回、等文件读取、等第三方接口响应的时候Node.js 不会傻等而是去处理下一个请求。对于 API 服务这种请求进来、查数据、返回结果的典型场景这个特性非常对路。而且 JavaScript 一门语言从前写到后不用在 Python、Java、JS 之间来回切换心智对个人小项目来说认知负担小很多。再说 Express。它是 Node.js 生态里最老牌、最轻量的 Web 框架之一。轻量意味着什么意味着你不需要理解一大堆约定和注解几行代码就能定义一个路由。我实测下来一个能返回 JSON 的接口从零到跑通不超过 10 行代码。对于小项目实战这个定位Express 的性价比是最高的。这里我要强调一个选型原则小项目的技术选型优先看上手成本和调试成本而不是理论性能上限。你一个人写的小服务QPS 撑死几百Node.js 完全够用。等你真的到了需要换 Go 或 Rust 的量级那说明项目已经成功了到时候再重构也不迟。1.2 环境准备里最容易被忽略的三个细节环境准备这一步看起来简单实际上坑最多。我踩过的坑主要集中在三个地方。第一个是 Node.js 版本。网上教程有的让你装最新版有的让你装 LTS。我的建议是生产环境一律用 LTS 版本。LTS 是长期支持版稳定、bug 少、社区资料全。你可以在终端里敲node -v看当前版本如果显示的是奇数版本号比如 21、23那通常是当前版适合尝鲜不适合干活偶数版本号比如 20、22才是 LTS。装的时候直接去官网下 LTS 的安装包一路下一步就行安装器会自动帮你配好环境变量。第二个是 npm 的镜像源。默认源在国外装依赖的时候经常卡住或者超时。我一般会换成国内镜像命令是npm config set registry https://registry.npmmirror.com。换完之后npm install的速度会有肉眼可见的提升。这个操作不影响任何功能纯粹是省时间。第三个是项目目录的初始化。很多人直接在一个乱七八糟的文件夹里npm init结果后面文件越堆越多自己都找不到入口文件。我的习惯是先建一个干净的空目录进去之后再初始化。命令是mkdir my-api cd my-api npm init -y。那个-y是跳过一堆交互式提问直接生成默认的package.json省事。提示package.json里的main字段决定了入口文件默认是index.js。如果你后面把入口改成了app.js记得同步改这个字段否则启动的时候会报找不到模块。1.3 依赖安装只装必要的别贪多初始化完项目接下来装依赖。这一步我的原则是只装当前阶段真正用得到的。很多人一上来就npm install一大堆什么数据库驱动、日志库、验证库全装上结果一半没用上还拖慢了安装速度。这个阶段我实际装的只有两个express核心框架必装。nodemon开发时的热重载工具改完代码自动重启服务省得你每次手动 CtrlC 再重跑。安装命令是npm install express和npm install -D nodemon。注意那个-D它表示装成开发依赖意思是这个包只在开发阶段用上线的时候不需要。这样区分的好处是将来部署到服务器时可以用npm install --production只装生产依赖体积更小、启动更快。装完之后在package.json的scripts里加一行dev: nodemon index.js。以后启动服务直接敲npm run dev改代码自动生效。这个小配置能帮你省下大量重复操作的时间属于装一次爽一路的投入。2. 从一行代码到第一个能返回 JSON 的接口2.1 最小可运行服务的骨架长什么样环境齐了现在写代码。我先给你一个最小可运行的服务骨架你把它复制到index.js里敲npm run dev就能看到一个服务跑起来了。const express require(express); const app express(); const PORT 3000; app.get(/, (req, res) { res.json({ message: 服务已启动, timestamp: Date.now() }); }); app.listen(PORT, () { console.log(服务运行在 http://localhost:${PORT}); });就这么几行。我拆开讲一下每一行的作用因为理解骨架比抄代码重要。第一行require(express)是把 Express 模块引进来。第二行express()创建了一个应用实例你可以把它理解成一个服务容器后面所有的路由、中间件都挂在这个实例上。PORT是端口号3000 是开发时最常用的因为不容易和其他系统服务冲突。app.get(/, ...)定义了一个路由当有人用 GET 方法访问根路径/时执行后面的回调函数。回调函数有两个参数req是请求对象包含客户端传来的所有信息res是响应对象你用它来给客户端回话。这里我用res.json()返回了一个 JSON 对象而不是res.send()返回纯文本——因为 API 服务的标准输出格式就是 JSON。最后app.listen()让服务真正开始监听端口。跑起来之后你在浏览器里访问http://localhost:3000就能看到那段 JSON。2.2 路由设计为什么 RESTful 风格值得坚持服务能跑了接下来是设计接口。这里我要聊一个很多人忽略但很重要的点路由命名规范。新手最容易犯的错是把路由写成动词比如/getUser、/deleteUserById、/createOrder。这种写法能跑但不够专业而且随着接口变多会越来越乱。正确的做法是遵循RESTful 风格用名词表示资源用HTTP 方法表示动作。举个例子假设你要做一个用户管理接口操作不推荐写法RESTful 写法HTTP 方法获取用户列表GET /getUsers/usersGET获取单个用户GET /getUser?id1/users/1GET创建用户POST /createUser/usersPOST更新用户POST /updateUser/users/1PUT删除用户POST /deleteUser/users/1DELETE看出来了吗URL 里只有名词动作交给 HTTP 方法。这样做的好处是接口语义清晰看 URL 就知道操作的是什么资源而且能充分利用 HTTP 协议本身的语义比如 GET 请求可以被缓存、PUT 和 DELETE 是幂等的。Express 里实现这些路由非常直接app.get(/users, (req, res) { /* 返回列表 */ }); app.get(/users/:id, (req, res) { /* 返回单个 */ }); app.post(/users, (req, res) { /* 创建 */ }); app.put(/users/:id, (req, res) { /* 更新 */ }); app.delete(/users/:id, (req, res) { /* 删除 */ });那个:id是路径参数访问/users/123的时候req.params.id就是123。这个机制让你不用为每个用户写一个路由一个模板搞定所有。2.3 处理请求体body-parser 这件事现在怎么做上面讲的是 GET 请求参数在 URL 里。但 POST 和 PUT 请求数据通常在请求体里比如前端提交一个表单或者一段 JSON。这时候就涉及到一个经典问题怎么把请求体解析出来。早期 Express 需要单独装body-parser这个中间件。但从 Express 4.16 开始内置了express.json()和express.urlencoded()不用再额外装包了。我一般这样配置app.use(express.json()); app.use(express.urlencoded({ extended: true }));第一行负责解析 JSON 格式的请求体第二行负责解析表单格式。app.use()表示这是全局中间件所有请求都会先经过它。配置好之后你在 POST 路由里就能直接用req.body拿到数据了app.post(/users, (req, res) { const { name, email } req.body; res.status(201).json({ id: Date.now(), name, email }); });注意那个res.status(201)201 是 HTTP 状态码表示资源创建成功。状态码用对了接口才算专业。常见的还有 200成功、400客户端参数错误、404资源不存在、500服务器内部错误。这些不是摆设前端会根据状态码决定怎么处理响应。注意express.json()默认只解析Content-Type: application/json的请求。如果前端发的是别的类型req.body会是空对象。这个坑我踩过排查了半天才发现是请求头没设对。3. 让接口真正可用错误处理、参数校验与日志3.1 统一错误处理别让服务一崩就全崩服务能返回数据了但这只是能用离可用还差得远。第一个要解决的问题是错误处理。新手写的接口一旦出错比如访问了不存在的资源、传了非法参数要么直接返回一堆堆栈信息要么整个服务崩掉。这两种都很糟糕。正确的做法是统一捕获错误返回结构化的错误信息。Express 的错误处理有个特殊机制一个接收四个参数的中间件就是错误处理中间件。四个参数分别是err、req、res、next。它必须放在所有路由的最后面app.use((err, req, res, next) { console.error(err.stack); res.status(err.status || 500).json({ error: { message: err.message || 服务器内部错误, code: err.code || INTERNAL_ERROR } }); });这样任何路由里抛出的错误都会被这个中间件接住返回统一的 JSON 格式。前端拿到之后可以根据code字段做针对性处理而不是去解析一段乱七八糟的 HTML。配合这个机制我在路由里会主动抛错而不是手动res.status(404).json(...)app.get(/users/:id, (req, res, next) { const user findUser(req.params.id); if (!user) { const err new Error(用户不存在); err.status 404; err.code USER_NOT_FOUND; return next(err); } res.json(user); });next(err)会把错误传递给错误处理中间件。这种写法的好处是业务逻辑和错误响应解耦路由里只管发现问题怎么响应交给统一的地方处理。3.2 参数校验在入口处把脏数据挡掉第二个要解决的问题是参数校验。API 服务最怕的就是脏数据——前端传了个空字符串、传了个超长文本、传了个非法格式的邮箱你的服务照单全收最后数据库里全是垃圾。我的原则是在入口处校验不合格的直接拒绝。校验逻辑可以手写也可以用现成的库。手写的话一个简单的校验函数长这样function validateUser(body) { const errors []; if (!body.name || typeof body.name ! string) { errors.push(name 必须是非空字符串); } if (!body.email || !/^[^][^]\.[^]$/.test(body.email)) { errors.push(email 格式不正确); } return errors; }然后在路由里调用app.post(/users, (req, res, next) { const errors validateUser(req.body); if (errors.length 0) { const err new Error(参数校验失败); err.status 400; err.code VALIDATION_ERROR; err.details errors; return next(err); } // 校验通过继续处理 });这里有个经验校验失败时把所有的错误一次性返回而不是遇到第一个就返回。这样前端可以一次性把所有问题都展示给用户体验更好。我见过很多接口是改一个错、报一个错用户来回提交好几次非常烦。3.3 请求日志出问题时你能查到什么第三个要解决的问题是日志。服务上线之后你不可能盯着控制台看。出了问题你得能回溯哪个接口被调用了、传了什么参数、返回了什么、耗时多久。最简单的做法是写一个日志中间件app.use((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(); });这段代码会在每个请求结束时打印一行日志格式是方法 路径 状态码 耗时。别小看这一行排查问题的时候它能帮你快速定位是哪个接口慢、哪个接口报错、报错的时候返回了什么状态码。如果项目再大一点我会换成morgan或pino这类专业日志库支持日志分级、输出到文件、格式化等。但小项目阶段上面这段手写代码完全够用而且没有额外依赖理解成本低。提示日志里不要打印敏感信息比如密码、完整的身份证号、支付信息。这些一旦写进日志文件就是安全隐患。我一般会在打印前做一层脱敏处理。4. 实测中踩过的坑与排查思路4.1 端口被占用EADDRINUSE 的完整排查链路服务跑不起来最常见的原因就是端口被占用报错信息是EADDRINUSE: address already in use :::3000。我第一次遇到的时候一脸懵后来总结出一套排查流程。第一步确认是不是真的被占用。在终端里敲lsof -i :3000Mac/Linux或者netstat -ano | findstr :3000Windows看看哪个进程占着这个端口。第二步决定是杀掉还是换端口。如果占用的是你之前没关干净的服务直接杀掉kill -9 PID。如果占用的是系统服务或者别的项目那就换个端口比如改成 3001。第三步从根源上避免。我现在习惯把端口号写成环境变量const PORT process.env.PORT || 3000;。这样启动的时候可以PORT3001 npm run dev临时换端口不用改代码。部署到云平台的时候平台通常也会通过环境变量注入端口这个写法能直接兼容。这个坑的本质是开发时反复启动服务很容易忘记关掉上一个。用nodemon能缓解一部分因为它会接管进程管理但偶尔也会抽风。养成改完代码看一眼终端的习惯能省很多事。4.2 跨域问题CORS 报错为什么总是让人抓狂前端调接口的时候如果前端和后端不在同一个域名或端口下浏览器会拦截请求控制台报一堆 CORS 相关的错误。这是浏览器的安全机制不是你的代码写错了。解决方法是在服务端设置响应头明确告诉浏览器我允许跨域。最省事的做法是装cors中间件const cors require(cors); app.use(cors());这一行代码会给所有响应加上Access-Control-Allow-Origin: *允许任何来源访问。开发阶段这样用没问题但上线前一定要收紧改成只允许你自己的前端域名app.use(cors({ origin: https://your-frontend.com, methods: [GET, POST, PUT, DELETE], credentials: true }));这里有个容易踩的坑如果你用了credentials: true允许携带 Cookie那origin就不能是*必须是具体的域名。浏览器不允许允许任何来源和携带凭证同时存在。这个限制我当初排查了很久因为报错信息很隐晦。4.3 异步错误没被捕获为什么我的服务会突然挂掉Express 有个历史遗留问题它不会自动捕获异步函数里抛出的错误。什么意思呢看这段代码app.get(/data, async (req, res) { const result await someAsyncOperation(); // 如果这里抛错 res.json(result); });如果someAsyncOperation抛了错这个错误不会被错误处理中间件接住而是变成一个未处理的 Promise 拒绝在 Node.js 里可能导致进程直接退出。服务就这么莫名其妙挂了。解决办法有两个。一是手动 try-catchapp.get(/data, async (req, res, next) { try { const result await someAsyncOperation(); res.json(result); } catch (err) { next(err); } });二是写一个包装函数把异步路由包起来自动捕获错误const asyncHandler (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; app.get(/data, asyncHandler(async (req, res) { const result await someAsyncOperation(); res.json(result); }));我推荐第二种因为它不用在每个路由里重复写 try-catch代码更干净。这个坑非常隐蔽因为开发时可能一直不触发上线后遇到某个边界情况才爆发排查起来很痛苦。4.4 请求体过大被拒绝413 错误的处理还有一个坑是请求体过大。Express 的express.json()默认限制请求体大小为 100KB。如果你要上传图片的 base64、或者提交一个很大的 JSON就会报 413 错误。解决办法是调大限制app.use(express.json({ limit: 10mb }));但这里要提醒一句限制调大是有代价的大请求体会占用更多内存也更容易被恶意利用。所以我的建议是按实际需求设置不要无脑设成Infinity。如果确实要传大文件应该走专门的文件上传接口用multipart/form-data而不是塞进 JSON 里。5. 从能跑到能交付部署前的最后几件事5.1 环境变量管理别把密钥写进代码项目开发完了准备部署。第一件事是把配置和代码分离。什么叫配置端口号、数据库连接串、第三方接口的密钥这些都是配置。它们不应该硬编码在代码里因为一旦代码提交到仓库密钥就泄露了。标准做法是用环境变量。Node.js 里通过process.env.XXX读取。开发时可以用dotenv这个库把变量写在.env文件里PORT3000 DB_URLmongodb://localhost:27017/mydb API_KEYyour-secret-key然后在代码最开头加一行require(dotenv).config()之后process.env.PORT就能读到值了。关键点.env文件必须加到.gitignore里绝对不能提交到仓库。我见过太多因为把密钥提交上去、被人扫到、然后产生高额账单的案例。同时我会在项目里放一个.env.example文件列出所有需要的变量名但不填真实值方便别人知道要配哪些。5.2 优雅关闭让服务体面地退出服务部署之后总会遇到需要重启的时候——更新代码、调整配置、服务器维护。如果直接杀掉进程正在处理的请求会被中断用户看到的是报错。优雅关闭就是解决这个问题的收到关闭信号后先停止接收新请求等正在处理的请求完成再退出。实现起来不复杂const server app.listen(PORT, () { console.log(服务运行在 ${PORT}); }); process.on(SIGTERM, () { console.log(收到关闭信号开始优雅关闭); server.close(() { console.log(所有连接已关闭进程退出); process.exit(0); }); });SIGTERM是系统发送的请关闭信号。收到之后server.close()会停止接受新连接等现有连接处理完再执行回调退出。这个机制在容器化部署比如 Docker、K8s里尤其重要因为平台会定期发送信号来滚动更新。5.3 健康检查接口让运维知道你还活着最后一个建议是加一个健康检查接口。这个接口不干别的就是返回一个我还活着的信号app.get(/health, (req, res) { res.json({ status: ok, uptime: process.uptime() }); });看起来很简单但作用很大。部署平台、负载均衡器、监控系统都会定期访问这个接口判断你的服务是否正常。如果连续几次访问失败平台会自动重启服务或者把流量切走。process.uptime()返回服务已经运行的秒数能帮你判断服务是不是刚重启过。我在实际项目里还会在这个接口里加上数据库连接状态、依赖服务的可用性检查让它成为一个真正的健康指标而不只是进程还在。不过小项目阶段返回个ok就够了别过度设计。这套流程走下来一个能跑、能用、能交付的 API 服务就成型了。我自己的体会是小项目最大的敌人不是技术难度而是想太多。先把最小可运行版本跑起来再一步步加错误处理、加校验、加日志每一步都有明确的理由而不是一开始就追求完美架构。等你把这套流程走顺了再回头看那些复杂的框架和设计模式会发现它们解决的问题你都已经亲手遇到过一遍了理解起来会快很多。
返回列表