ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

不依赖框架,用Node.js标准库手写极简API服务

2026/10/7 11:21:53 拓冰建站 浏览量
不依赖框架,用Node.js标准库手写极简API服务 最近整理个人项目时我看着package.json里一长串依赖列表发了一会儿呆。一个内部工具的API服务总共六个接口装了一整套Web框架全家桶依赖树拉出来两百多个包每次升级都像拆弹。于是我决定走一次极端把请求处理逻辑全部推倒只留一个我命名为caveman的最小骨架。不用框架、不用ORM、不用第三方路由就靠Node.js标准库的http模块硬生生把一套增删改查接口跑起来。今天的博文就把这个改造过程完整复盘一下包括我为什么坚持砍掉中间件、怎么做路由匹配和请求体解析以及实测中踩过的那些坑。1. 整体设计思路与核心取舍1.1 项目缘起为什么做一个“原始人”版本先说项目背景。这是一个给团队内部用的数据管理小工具每天请求量很低核心就是几个资源的增删改查。原来用的是主流Web框架功能确实齐全但大部分能力我们根本用不上。更关键的隐患是一旦依赖树里有某个传递依赖被打上高危漏洞标就得被迫跟着升级每次升级都可能引入行为变化最后陷入“改一个参数测一整个晚上”的循环。圈子里早就有一种说法叫“caveman approach”翻译成大白话就是“原始人方案”能用标准库解决的绝不引第三方包代码能直写就不抽象调用关系能一眼看懂就不让框架帮你做“魔法”。我决定用这一思路重写整个API层给自己定了一条硬性目标任何人打开代码五分钟内能看懂全部请求入口十分钟内能改出一个新接口。最后的结果是依赖只剩一个服务本身不到两百行打包后体积从几十MB缩到不到五十KB。1.2 三个关键取舍1.2.1 路由不自动扫描显式注册框架通常自带路由自动注册扫描目录、加载控制器、绑定依赖注入。这套机制在项目膨胀时确实省事但小项目里反而成了黑盒。caveman的做法是手工调用get、post、put、delete方法把路径和处理函数显式挂上去。少写几个装饰器换来的是调用栈完全透明。1.2.2 中间件不用洋葱模型用函数包裹Express风格的洋葱模型中间件功能强大但调试时要记住next()调用的顺序、响应是否已被写入、错误有没有被吞掉。caveman只保留最简单的函数组合读取请求体、鉴权、业务处理一层层用普通函数包起来。调用顺序写在代码里不是写在框架约定里。1.2.3 参数校验不引第三方库参数校验库很多写法也很优雅但为了几个字段的校验引入一个几千行代码的依赖成本太高。caveman用原生JavaScript手动写守卫函数虽然啰嗦一点但边界条件完全可控出问题时直接看那几行if语句就能定位。1.3 什么时候别学caveman我在这里先把边界说清楚避免有人看了文章热血沸腾拿这套方案去做大项目。caveman适合这几类场景内部管理工具、个人项目、原型验证、接口数量少于二十个且业务逻辑不复杂的服务。反过来如果团队有十几个人同时开发、接口几十上百个、需要统一鉴权埋点限流或者业务处在高速迭代期那还是老老实实用成熟框架别用原始人方案硬扛复杂度。极简不是目的降低总维护成本才是。判断维度标准框架方案Caveman极简方案接口规模几十到上百个个位数到十几个团队规模多人协作需要约束一人或两人维护依赖风险依赖树庞大升级成本高依赖极少升级几乎无感调试体验中间件和容器注入带来黑盒调用链平铺直接可读学习门槛需要理解框架约定只需要HTTP基础2. 核心细节解析与实操要点2.1 路由匹配的两种模式我把路由设计成精确匹配和参数匹配两种模式。精确匹配最简单路径等于/health这种写死的字符串直接比较即可。参数匹配则针对/books/:id场景需要把:id这种占位符转换成正则捕获组。路径转换逻辑其实很直接。把/books/:id替换成/books/([^/])然后正则匹配请求路径。这里有个容易踩的细节捕获组解构时要按占位符出现的顺序映射多个参数时顺序不能乱。我见过有人写代码时用了对象解构结果参数顺序一变就取到错误值。另外路径结尾的斜杠很有迷惑性。/books/和/books在语义上应该等价所以正则末尾加一个可选斜杠/?$。在HTTP世界很多客户端会自动加斜杠不做这个处理就会遇到“明明注册了路由却总是404”的诡异问题。下面是我的核心匹配代码// src/router.js const { URL } require(url); class Caveman { constructor() { this.routes []; } add(method, path, handler) { const paramNames []; const regexStr path .replace(/\/{2,}/g, /) .replace(/:([A-Za-z0-9_])/g, (_, name) { paramNames.push(name); return ([^/]); }) .replace(/\//g, \\/); const regex new RegExp(^${regexStr}/?$); this.routes.push({ method: method.toUpperCase(), path, regex, paramNames, handler, }); return this; } get(path, handler) { return this.add(GET, path, handler); } post(path, handler) { return this.add(POST, path, handler); } put(path, handler) { return this.add(PUT, path, handler); } delete(path, handler) { return this.add(DELETE, path, handler); } handle(req, res) { const url new URL(req.url, http://${req.headers.host || localhost}); const pathname url.pathname; const matched this.routes.find((route) { if (route.method ! req.method.toUpperCase()) return false; const m pathname.match(route.regex); if (!m) return false; req.params {}; route.paramNames.forEach((name, index) { req.params[name] decodeURIComponent(m[index 1]); }); return true; }); if (!matched) { res.writeHead(404, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ ok: false, message: Not Found })); return; } matched.handler(req, res); } listen(...args) { const http require(http); const server http.createServer((req, res) this.handle(req, res)); return server.listen(...args); } } module.exports { Caveman };2.2 请求体读取与JSON解析读取POST请求体是另一个坑。Node.js的http模块不会直接给你一个body对象它给你一个流你必须自己监听data事件把缓冲片段收齐再在end事件里合并。很多人第一次写的时候会漏掉对大请求体的保护客户端传一个1GB的数据服务端直接内存爆炸。我给readBody加了一个limit参数默认限制在1MB超限就立刻销毁连接并返回413状态码。对一个小工具服务来说1MB完全够用同时有效防止意外耗光内存。// src/body.js const MAX_BODY_SIZE 1 * 1024 * 1024; // 1MB function readBody(req, limit MAX_BODY_SIZE) { return new Promise((resolve, reject) { let size 0; const chunks []; req.on(data, (chunk) { size chunk.length; if (size limit) { const err new Error(Payload Too Large); err.statusCode 413; req.destroy(); reject(err); return; } chunks.push(chunk); }); req.on(end, () { const raw Buffer.concat(chunks).toString(utf8); resolve(raw); }); req.on(error, reject); }); } module.exports { readBody };body解析完成后再决定要不要做JSON转换。我一般不在读取阶段直接JSON.parse因为body合法性和业务字段合法性是两个不同层面的问题。读取阶段只保证“拿到了完整字符串”解析阶段再决定“这是不是合格的JSON”这样错误定位更清晰。2.3 统一响应与错误约定接口风格统一排查问题时能省一半时间。我给所有响应定了一个固定结构{ ok: true, data: ... }表示成功{ ok: false, message: 错误描述 }表示失败。这个结构很简单但好处是前端可以用统一逻辑处理返回值不需要每个接口各搞一套。状态码的使用也有讲究。路由找不到返回404请求体超限返回413校验失败返回400操作过程中抛异常返回500。如果实现了鉴权未登录统一返回401而不是403登录了但权限不足才是403。这些细节看着鸡毛蒜皮但前后端联调时少扯很多皮。2.4 用函数组合代替中间件caveman没有真正的中间件机制但我不希望每个业务处理函数里重复写鉴权、日志、错误处理代码。我的做法是写几个通用的包裹函数function withLog(handler) { return async (req, res) { console.log(${req.method} ${req.url} - ${new Date().toISOString()}); return handler(req, res); }; } function withAuth(handler) { return async (req, res) { const token req.headers[x-token]; if (token ! expected-token) { res.writeHead(401, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ ok: false, message: Unauthorized })); return; } return handler(req, res); }; }使用时的可读性极好app.get(/health, withLog((req, res) { res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ ok: true, data: { status: up } })); }));这种组合方式没有洋葱模型的异步时序问题每个包裹函数负责一件事返回前你的代码已经执行完出错也只会影响当前这层。对小项目来说这比引入完整的中间件体系要可靠得多。3. 实操过程与核心环节实现3.1 初始化项目与目录结构我把项目目录设计成最小可用形态caveman/ ├── src/ │ ├── router.js # 路由核心 │ ├── body.js # 请求体读取 │ └── response.js # 统一响应封装 ├── index.js # 业务入口 └── package.jsonpackage.json里除了项目名和启动脚本没有任何第三依赖。启动命令简单粗暴{ name: caveman, version: 1.0.0, main: index.js, scripts: { start: node index.js } }3.2 编写统一响应封装我先把response.js写好后续所有接口都调用它。封装的两个函数很简单一个成功响应一个失败响应。这是最基础但又特别容易整齐划一的地方。// src/response.js function sendJSON(res, statusCode, payload) { const body JSON.stringify(payload); res.writeHead(statusCode, { Content-Type: application/json; charsetutf-8, Content-Length: Buffer.byteLength(body), }); res.end(body); } function ok(res, data) { sendJSON(res, 200, { ok: true, data }); } function fail(res, statusCode, message) { sendJSON(res, statusCode, { ok: false, message }); } module.exports { ok, fail };注意Content-Length必须用Buffer.byteLength计算不能用body.length直接算因为中文在UTF-8编码下会占三个字节body.length拿到的是字符数而不是字节数。不写Content-Length也行但写上了客户端解析更快HTTP行为也更规范。3.3 注册业务接口假设这个工具管理的是图书数据我需要/books这个集合资源和/books/:id这个单资源接口。业务流程不复杂但足以展示caveman的使用方法。// index.js const { Caveman } require(./src/router); const { readBody } require(./src/body); const { ok, fail } require(./src/response); const { withLog } require(./src/middleware); const app new Caveman(); // 模拟内存数据 const books [ { id: 1, title: Caveman Style, author: Anonymous }, ]; app.get(/health, withLog(async (req, res) { ok(res, { status: up, time: Date.now() }); })); app.get(/books, withLog(async (req, res) { ok(res, books); })); app.get(/books/:id, withLog(async (req, res) { const book books.find((item) item.id req.params.id); if (!book) { fail(res, 404, Book not found); return; } ok(res, book); })); app.post(/books, withLog(async (req, res) { const rawBody await readBody(req); let data; try { data JSON.parse(rawBody); } catch (e) { fail(res, 400, Invalid JSON body); return; } if (!data.title || typeof data.title ! string) { fail(res, 400, Field title is required and must be a string); return; } const newBook { id: String(Date.now()), title: data.title, author: data.author || unknown, }; books.push(newBook); ok(res, newBook); })); app.put(/books/:id, withLog(async (req, res) { const rawBody await readBody(req); let data; try { data JSON.parse(rawBody); } catch (e) { fail(res, 400, Invalid JSON body); return; } const book books.find((item) item.id req.params.id); if (!book) { fail(res, 404, Book not found); return; } if (data.title ! undefined) book.title data.title; if (data.author ! undefined) book.author data.author; ok(res, book); })); app.delete(/books/:id, withLog(async (req, res) { const index books.findIndex((item) item.id req.params.id); if (index -1) { fail(res, 404, Book not found); return; } const [removed] books.splice(index, 1); ok(res, removed); })); app.listen(3000, () { console.log(caveman server running at http://localhost:3000); });这套注册方式没有任何隐藏依赖接口方法就是数组里的一条记录匹配过程就是正则的一次遍历。新增一个接口只需要两步写一个处理函数再调用app.get或者app.post注册一下。没有控制器扫描没有依赖注入容器没有一大堆xml或者yaml配置。3.4 本地压测与效果对比写完之后我做了一次简单压测用系统自带的ab工具并发50请求1000次目标是最小化接口/health。最终结果QPS稳定在每秒一万以上内存占用始终低于30MB。和之前用完整框架搭建的同一接口相比吞吐量提升不算夸张但内存占用确实降了一个量级。指标框架方案Caveman方案QPShealth接口约9000约12000常驻内存约90MB约28MB启动时间约600ms约120ms数字不是重点重点是这个项目在真实请求场景下的表现完全够用。压测数据也告诉自己极简不意味着性能一定差省掉大量框架内部逻辑之后有时反而更轻快。4. 常见问题与排查技巧实录整个改造过程不是一帆风顺的。我把实际遇到的几个典型问题整理出来这些问题在标准框架里可能被处理掉了但自己写底层时就全暴露了。4.1 路径总是匹配不到接口全部404我第一次写完路由匹配所有接口都返回404。排查后发现问题出在正则对斜杠的处理上。我在把路径转换成正则时忘了把路径里的斜杠转义成\\/。此时正则里的斜杠被当成普通字符处理本来应该匹配/books/1结果只能匹配到奇怪的字符串自然全部失败。这里记住一个关键点当你用字符串构造正则时所有正则元字符都必须手工转义。斜杠不是元字符但在这个场景里想匹配URL里的字面斜杠转义一下会更稳妥。调试时可以在Node里把生成的正则console.log出来你会非常直观地看到问题。4.2 JSON解析崩溃的连锁反应POST接口一直报500原因是JSON.parse直接抛异常又没有被捕获。这个问题根源很明确但暴露了一个设计漏洞请求体读取是流式操作错误可能在end事件前抛出来也可能在end事件之后抛出来。我后来把JSON.parse单独放进try/catch并把它归为业务层校验错误返回400而不是500。这个区分很重要客户端传了畸形JSON是客户端的错服务端处理逻辑出bug才是服务端的错状态码不能含糊。4.3 大请求体导致内存飙升加readBody限制之前我拿一个循环请求脚本做了压力测试POST一条几MB的数据服务端内存立刻往上跳。虽然这个小工具正常场景不会有人传大文件但万一有异常客户端或恶意访问没做限制就是灾难。加上limit参数后超过1MB直接销毁连接既保护了内存又给了客户端明确提示。4.4 并发请求下响应串场有一次日志打印正常但前端收到的数据偶尔是别的用户请求的内容。排查半天发现问题出在共享变量上。某个中间件函数把请求的userId挂到了模块级变量上并发请求一多前一个请求还没来得及响应后一个就把变量覆盖了。这个问题的教训是所有请求级别的数据都必须挂在req对象上或者作为参数显式传递绝不能用模块级变量存请求上下文。框架帮你解决的很多问题只是因为它把请求作用域隔离做好了自己写极简骨架的时候这个责任就落在你身上。4.5 避坑速查表常见问题具体表现解决思路路径404所有或部分接口匹配失败检查正则生成输出正则到控制台对比JSON解析500畸形请求体导致服务端崩溃用try/catch包裹返回400内存飙升POST大请求体打爆内存请求体读取时加字节数上限响应串场并发时客户端收到他人数据请求上下文放入req禁止模块级变量中文响应长度错误响应体被截断或解析慢用Buffer.byteLength计算Content-Length路径尾部斜杠带斜杠的请求匹配不上正则末尾加/?$匹配可选斜杠5. 我实际用下来的几条体会caveman这套极简骨架在我手里跑了一个多月了几个内部接口稳定运行再没出现升级依赖带来的风险。我个人最明显的感受是写代码时的安全感变强了。以前是建立在框架版本和中间件行为之上现在是我自己手写的每个函数行为之上。这种不透明的调用链对业务多变的项目来说是负担但对小工具和个人项目来说反而是最踏实的背书。最后再分享一个小技巧如果你也想做类似的极简改造建议不要一次性重写全部接口而是先挑一个最简单的健康检查接口迁移过来跑通再迁移第二个。极简并不等于粗糙你只是把复杂度从“框架内部的约定”转移到了“自己代码的可读性”上。这个平衡点找对了维护成本能大幅下降找错了你会发现自己只是把框架干过的活在更低层级重新干了一遍。我的建议是从一个小项目开始拿一个接口做实验亲身体会一下这中间的取舍再判断这套原始人方案适不适合你。