最近整理个人项目时,我看着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; charset=utf-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; charset=utf-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; charset=utf-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; charset=utf-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方案 |
|---|---|---|
| QPS(health接口) | 约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这套极简骨架在我手里跑了一个多月了,几个内部接口稳定运行,再没出现升级依赖带来的风险。我个人最明显的感受是:写代码时的安全感变强了。以前是建立在框架版本和中间件行为之上,现在是我自己手写的每个函数行为之上。这种不透明的调用链,对业务多变的项目来说是负担,但对小工具和个人项目来说,反而是最踏实的背书。
最后再分享一个小技巧:如果你也想做类似的极简改造,建议不要一次性重写全部接口,而是先挑一个最简单的健康检查接口迁移过来,跑通再迁移第二个。极简并不等于粗糙,你只是把复杂度从“框架内部的约定”转移到了“自己代码的可读性”上。这个平衡点找对了,维护成本能大幅下降;找错了,你会发现自己只是把框架干过的活在更低层级重新干了一遍。我的建议是,从一个小项目开始,拿一个接口做实验,亲身体会一下这中间的取舍,再判断这套原始人方案适不适合你。