caveman编程风格:零依赖搭建轻量级Web服务的实践指南

发布时间:2026/10/7 10:58:27
caveman编程风格:零依赖搭建轻量级Web服务的实践指南 caveman这个词这几年在开发者圈子里其实挺有意思。有人拿它调侃自己还在用上古工具链也有人把它当成一种极简主义的代号——“能用最原始的手段解决问题就不要引入重型武器”。我最近接手的一个内部小项目就用了caveman作为代号。说白了这是一个完全不依赖Spring Boot、不依赖Express甚至不依赖任何第三方框架的轻量级Web服务只用JDK自带的HttpServer和Node.js原生http模块就搭起了一个能跑、能看、能上内网给团队用的工具型站点。这篇文章把这套“穴居人式”的搭建思路完整拆开从选型逻辑、核心代码实现到部署脚本、常见问题排查全程记录真实踩坑经验。如果你也面临“临时工具不值得上框架”、或者“服务器资源吃紧但还得跑一个Web页面”的尴尬这篇文章正好对得上你的需求。1. 内容整体设计与思路拆解1.1 为什么用caveman思维做Web服务先说清楚项目背景。团队内部需要一个展示每日构建状态的小页面要求很简单能显示最近几次构建的时间、状态、产物链接外加一个简易的文件上传入口。这种工具型页面放在大型Web框架里当然是杀鸡用牛刀——Spring Boot启动就要占几百MB内存Express虽然轻但要为了一个页面拉一整套node_modules依赖链回来也属实有点浪费。当时的硬性限制是部署目标是内网一台只有512MB内存的老机器上面已经跑着Jenkins和Nexus剩下的资源只够跑一个“小东西”。在这种场景下我直接把caveman理念搬了出来不引框架、不搞工程化、不用构建步骤就用编程语言自带的HTTP能力手写一个极简的Web服务。所谓caveman风格本质上是三层原则最小依赖可执行文件跑起来之前不引入任何第三方库系统里有什么就用什么。最小抽象不搞MVC、不搞依赖注入、不搞AOP一切以“请求进来、处理完、响应出去”为最核心的循环。最小部署没有打包环节不用配置容器运行一条命令就是一个进程这个进程就是Web服务本身。有人可能会问这算不算自虐我的回答是在“工具型应用”这个特定边界内caveman方案反而性价比最高。它不需要框架的生态红利因为功能本身就那么几个它也不需要框架的约定大于配置因为每个请求的映射都是自己控制的出问题可以直接顺着代码往下查不存在“为什么这个bean没注入成功”这种玄学问题。1.2 选型对比原生HTTP能力 vs 主流框架做技术选型时我把三套方案放在一张表里做过对比。框架当然有它的价值但在“一个页面、两个接口、内网使用”这个前提下原生方案的优势很明显。维度caveman原生方案Spring BootExpress/Flask启动内存30-80MB300MB以上80-150MB含运行时第三方依赖零大量中等部署复杂度拷文件跑命令打包配置容器安装依赖配置启动器请求路由可读性手写判断逻辑透明注解/配置需理解框架行为中间件机制需理解框架行为适用场景内网小工具、快速原型、资源受限环境业务复杂、团队协作、长期维护中等复杂度Web服务这不是说Spring Boot不好。事实上如果这个项目的功能膨胀到需要用户体系、需要数据库ORM、需要审计日志、需要多环境配置我会毫不犹豫选择Spring Boot。但在caveman这个项目里需求边界清清楚楚——静态页面加几个JSON接口手写HTTP处理器的代码量并不会比写框架里的Controller多多少。选型背后的关键逻辑是先评估需求的复杂度上界再决定引入的复杂度下界。需求复杂度的天花板就那么高你引入的框架复杂度如果远超它那就是在给自己挖坑。caveman方案给你的是一个比较低的起点而不是一个无限扩展的天花板——认清楚这一点你才知道它适合什么场景。1.3 这套方案解决的核心痛点市面上主流的Web开发教程几乎都在教你如何快速上手一个框架。但当你真的需要“快速交付一个内网工具页面”时框架的启动成本和学习成本反而成了负担。caveman方案解决的核心痛点主要有四个资源受限老机器跑不动大框架但Java或Node的运行时几乎是标配用它们自带的HTTP能力就能搞定。交付速度没有初始化工程、没有装依赖、没有配置文件真正从零到能访问页面压缩到10分钟以内。排查成本请求处理逻辑全在自己的代码里没有中间件黑盒浏览器请求一到断点打下去直接看到全过程。长期稳定依赖越少被供应链问题波及的概率越低。一个不需要npm install、不需要Maven下载依赖的项目在几年后重新拉起来跑照样能启动。2. 核心细节解析与实操要点2.1 用JDK原生HttpServer搭建最小服务我最初用Java实现因为那台内网机器上已经装好了JDK8不用额外安装任何东西。JDK从6开始就内置了com.sun.net.httpserver.HttpServer这是一个基于HttpServer类的轻量级HTTP服务器实现支持HTTP/1.1足以应对几十个并发以内的内部工具场景。核心代码非常直观在main方法里创建服务器、绑定端口、注册一个根路径处理器然后启动import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; public class CavemanServer { public static void main(String[] args) throws IOException { int port 8080; HttpServer server HttpServer.create(new InetSocketAddress(port), 0); server.createContext(/, new RootHandler()); server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(4)); server.start(); System.out.println(Caveman server running at http://0.0.0.0: port); } } class RootHandler implements HttpHandler { Override public void handle(HttpExchange exchange) throws IOException { String response htmlbodyh1Caveman is live/h1/body/html; exchange.getResponseHeaders().set(Content-Type, text/html; charsetutf-8); exchange.sendResponseHeaders(200, response.getBytes(utf-8).length); OutputStream os exchange.getResponseBody(); os.write(response.getBytes(utf-8)); os.close(); } }这段代码有四个细节值得注意。第一HttpServer.create的第二个参数传0表示使用系统默认的backlog长度对于内网小工具来说完全够用。第二setExecutor很关键如果不设置HttpServer会用内部默认的线程池这个线程池的配置策略可能不适合你实际的并发模型显式指定一个newFixedThreadPool(4)至少能让你知道并发上限是4个线程。第三sendResponseHeaders的第二个参数需要传响应体字节长度如果你传-1则代表使用chunked传输编码对于小响应体而言直接传字节长度更清晰。第四一定要setContent-Type浏览器对没有内容类型的响应容易按纯文本解析中文必乱码。2.2 用Node.js原生http模块做对照实现后来团队里有个同事希望在另一个环境上复用同样的逻辑但那只机器上没有JDKNode倒是装好了。我把同样的小服务用Node原生http模块写了一遍逻辑完全一致只是语法不同。const http require(http); const server http.createServer((req, res) { if (req.url /) { res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(htmlbodyh1Caveman is live/h1/body/html); return; } res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(Not Found); }); server.listen(8080, 0.0.0.0, () { console.log(Caveman server running at http://0.0.0.0:8080); });Node这版更简洁因为没有Java那套显式的getResponseHeaders和sendResponseHeaders的分步操作writeHead一步到位。但简洁也意味着更容易漏东西——比如Content-Type没设置浏览器会直接把返回内容按application/octet-stream下载而不是渲染再比如中文字符串长度在计算响应体大小时容易算错好在Node的res.end()不要求你手动传长度框架内部会自动处理。2.3 新增路由和JSON接口的完整实现光有个欢迎页不算一个完整的工具服务还得加上业务接口。以构建状态展示页为例它需要提供一个/api/builds接口返回最近几次构建记录同时要支持一个/report页面用来手动查看历史报告。在Java版本里路由分发我用最原始的方式解析exchange.getRequestURI().getPath()然后按前缀分支。class RootHandler implements HttpHandler { Override public void handle(HttpExchange exchange) throws IOException { String path exchange.getRequestURI().getPath(); String method exchange.getRequestMethod(); if (/.equals(path) GET.equals(method)) { // 返回HTML首页 sendHtml(exchange, 200, buildIndexHtml()); } else if (/api/builds.equals(path) GET.equals(method)) { // 返回JSON数据 sendJson(exchange, 200, buildBuildsJson()); } else if (/report.equals(path) GET.equals(method)) { // 返回报告页 sendHtml(exchange, 200, buildReportHtml()); } else { sendText(exchange, 404, Not Found); } } }分段方法sendJson和sendHtml做了公共逻辑抽取核心是设置Content-Type和CORS头内网工具偶尔会被别的页面iframe嵌进去顺手加个Access-Control-Allow-Origin: *能少很多麻烦。Node版本的实现也完全不用框架只需多解析一下req.urlconst server http.createServer((req, res) { const path req.url.split(?)[0]; if (path / req.method GET) { sendHtml(res, buildIndexHtml()); } else if (path /api/builds req.method GET) { sendJson(res, buildBuildsJson()); } else if (path /report req.method GET) { sendHtml(res, buildReportHtml()); } else { sendText(res, 404, Not Found); } });这里面split(?)[0]是个小经验因为请求URL可能带query参数比如/api/builds?page1如果你不做这步剥离路由匹配会全部失效。Java的getPath()天然不含query部分Node需要自己处理。2.4 静态资源文件服务的实现思路工具页面一般还要配点css和js如果每个静态文件都写一个路由分支那就真的变caveman到愚蠢了。这里用了一个很朴素的方案把静态文件放在public目录下读取磁盘文件然后写回响应。private static void serveStaticFile(HttpExchange exchange, String path) throws IOException { String filePath public path; File file new File(filePath); if (!file.exists() || file.isDirectory()) { sendText(exchange, 404, File Not Found); return; } String contentType guessContentType(file.getName()); exchange.getResponseHeaders().set(Content-Type, contentType); exchange.sendResponseHeaders(200, file.length()); try (OutputStream os exchange.getResponseBody(); FileInputStream fis new FileInputStream(file)) { byte[] buffer new byte[4096]; int len; while ((len fis.read(buffer)) ! -1) { os.write(buffer, 0, len); } } }关键点有两个。第一是guessContentType你可以用JDK自带的Files.probeContentType也可以自己维护一个小映射表.html、.css、.js、.png、.svg、.ico自主可控。第二是流处理读取文件时使用缓冲区循环写避免一次性把整个文件读进内存——虽然内网工具页面文件都不大但养成好习惯能让你未来直接套用到更大的文件上。Node版本的实现思路一致用fs.createReadStream管道到res代码反而更短。const fs require(fs); const path require(path); function serveStaticFile(res, urlPath) { const filePath path.join(__dirname, public, urlPath); if (!filePath.startsWith(path.join(__dirname, public))) { res.writeHead(403); res.end(Forbidden); return; } fs.createReadStream(filePath) .on(error, () { res.writeHead(404); res.end(Not Found); }) .pipe(res); }这段Node代码里的路径判断值得多说一句path.join之后再确认拼接后的路径仍然在public目录内这是防目录穿越的基础手法。网上那些用path.join(__dirname, public, req.url)直接拼接然后交给createReadStream的写法遇到../路径时存在读取任意文件的风险内网工具也不能埋这个雷。3. 实操过程与核心环节实现3.1 项目目录结构与初始化整个caveman项目的目录结构被压缩到极致caveman/ ├── public/ │ ├── index.html │ ├── style.css │ └── app.js ├── src/ │ └── com/example/caveman/ │ └── CavemanServer.java ├── data/ │ └── builds.json └── start.shpublic存放前端静态资源data存放模拟构建记录的JSON文件start.sh是启动脚本。没有pom.xml没有package.jsonNode版本除外没有node_modules没有target目录整个项目体积换算成磁盘占用不到200KB。初始化时唯一要做的事就是确保目标机器上有对应运行时。Java版本要求JDK8及以上因为旧的JDK6/7也内置HttpServer但API略有差异Node版本要求Node.js 10以上更高的版本对http模块行为做了不少兼容性改进用新不用旧。3.2 启动脚本与进程守护写法写start.sh的时候要考虑到服务器重启的场景。内网机器偶尔会因为断电或者维护重启如果服务不能自动拉起来工具就形同虚设。所以启动脚本我套了一层“启动前检查”的逻辑#!/bin/bash APP_HOME$(cd $(dirname $0) pwd) PORT8080 # 检查端口是否被占用 if lsof -i :$PORT /dev/null 21; then echo Port $PORT already in use, skip startup exit 1 fi cd $APP_HOME nohup java -Xms32m -Xmx128m -cp src com.example.caveman.CavemanServer logs/app.log 21 echo $! logs/app.pid echo Caveman server started at http://0.0.0.0:$PORT几个细节值得展开。-Xms32m -Xmx128m是特意为512MB老机器定制的内存上限实际上服务跑起来稳定占用也就40MB上下。nohup配合让进程脱离终端会话保证SSH断开后服务不退出。日志重定向到logs/app.log排查问题时可以直接看这个文件。如果机器上有systemd考虑到开机自启的需求还可以写一个service单元[Unit] DescriptionCaveman Tool Service Afternetwork.target [Service] WorkingDirectory/opt/caveman ExecStart/usr/bin/java -Xms32m -Xmx128m -cp src com.example.caveman.CavemanServer Restartalways RestartSec5 [Install] WantedBymulti-user.targetRestartalways可比cron轮询健壮多了进程被误杀后5秒内自动拉起来完全不占用额外精力。3.3 进程内存与并发参数的选定依据caveman方案的一个核心优势就是低资源占用但参数不能随便拍脑袋定得有依据。这就要回到线程池大小的选择上。对于Java版本HttpServer.setExecutor(Executors.newFixedThreadPool(4))这里的4是怎么来的呢真实场景是内网同时访问这个工具页面的用户数不超过5个每个人在页面上触发的并发请求数一般不超过2个比如一次页面加载加一次JSON刷新峰值并发估算约10个请求。线程池设为固定4意味着同一时间最多4个请求被并行处理其余请求会排队等待。对于页面体量只有几十KB的响应处理一个请求的时间在毫秒级4个线程完全够用。给一个直观的内存计算过程一个线程默认分配1MB线程栈-Xss1m是默认值4个线程占用4MB栈空间加上HttpServer内部的一些缓冲区和堆内存整体定格在40MB出头。用-Xmx128m做上限已经是留出了3倍余量。Node版本的内存管理是另一套模型你不需要手动分配线程池底层用的是libuv的线程池配合事件循环默认线程池大小是4。如果涉及文件系统操作较多可以用UV_THREADPOOL_SIZE环境变量调整但这个项目里静态文件服务压力不大保持默认即可。参数选择的核心逻辑永远是先分析请求模型再设定资源参数最后通过压测验证。内网小工具没有高并发压力参数自然不需要追求大而猛够用、稳定、不浪费就是好配置。3.4 前端页面与后端接口联调的实测记录前端的首页做得尽量简洁用原生的fetch拉取/api/builds渲染构建状态列表。这里有一个联调时才能发现的小坑如果使用Java版本HttpServer默认只对/路径注册了处理器/api/builds这些路径是在同一个handler里通过getPath()分发的没问题但如果你把静态文件服务也挂在/下会造成路由冲突——/既是页面入口又是静态文件兜底必须先判断路径。实测中的请求流程是这样的浏览器访问http://内网IP:8080/服务器返回index.html。index.html里的script加载/app.js触发静态文件服务逻辑返回JS文件。app.js执行fetch(/api/builds)服务器返回JSON格式的构建记录数组。前端把JSON渲染成表格页面上显示最近5次构建的时间、结果和产物链接。我在联调时专门抓了一次请求日志大概是这样的GET / - 200 text/html GET /style.css - 200 text/css GET /app.js - 200 application/javascript GET /api/builds - 200 application/json四个请求全部命中预期路由响应时间从毫秒级到几十毫秒不等对于内网工具来说体感是“秒开”。为了模拟上线后的真实压力我顺手用ab -n 1000 -c 10跑了一遍压测如果机器上没有ab可以用wrk或者JMeter。Java版本的测试结果是1000个请求、10个并发平均响应时间大约15ms错误率0%。Node版本的测试结果是平均响应时间大约20ms错误率也是0%。两者在这个场景下都足够稳。3.5 文件上传接口的完整实现工具页面除了展示构建状态还需要一个简易的文件上传功能。实现思路是前端用FormData上传文件后端读取请求体写入指定目录。Java原生实现里HttpExchange的getRequestBody()可以直接拿到请求体输入流} else if (/upload.equals(path) POST.equals(method)) { String saveDir data/uploads/; File dir new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } String fileName URLDecoder.decode( exchange.getRequestURI().getQuery() ! null ? exchange.getRequestURI().getQuery().replace(filename, ) : upload_ System.currentTimeMillis(), UTF-8 ); File targetFile new File(saveDir, fileName); // 防护确保目标文件仍然在saveDir下 if (!targetFile.getCanonicalPath().startsWith(new File(saveDir).getCanonicalPath())) { sendText(exchange, 403, Forbidden); return; } try (InputStream is exchange.getRequestBody(); OutputStream os new FileOutputStream(targetFile)) { byte[] buffer new byte[4096]; int len; while ((len is.read(buffer)) ! -1) { os.write(buffer, 0, len); } } sendJson(exchange, 200, {\status\:\uploaded\}); }上传接口有一个很容易被忽略的安全点getCanonicalPath()的比对。如果不做这个检查有人上传一个名为../../etc/cron.d/evil的文件就可能覆盖系统敏感文件。这是caveman级别的手写实现必须自己防守的地方用框架时框架可能已经帮你做了但用原生方案时责任全在自己身上。Node版本的上传实现同样手写const fs require(fs); } else if (path /upload req.method POST) { const saveDir path.join(__dirname, data, uploads); const filename decodeURIComponent(req.headers[x-filename] || (upload_ Date.now())); const targetFile path.join(saveDir, path.basename(filename)); if (!targetFile.startsWith(saveDir)) { res.writeHead(403); res.end(Forbidden); return; } const writeStream fs.createWriteStream(targetFile); req.pipe(writeStream); req.on(end, () { res.writeHead(200); res.end(JSON.stringify({ status: uploaded })); }); }这里用path.basename直接剥离掉所有目录部分天然规避了路径穿越比手工比对规范字符串更省心。4. 常见问题与排查技巧实录4.1 端口被占用导致启动失败我在一次重启服务时遇到java.net.BindException: Address already in use当时第一反应是“上一个进程没杀干净”。排查过程分三步用lsof -i :8080查看端口占用情况确认PID。用ps -ef | grep [PID]确认进程身份看是不是原来那个Java进程。如果确认是旧进程kill -9杀掉再启动。后来为了避免这种手动操作的麻烦我在start.sh里加了端口占用检查一旦发现端口被占就退出并给出提示而不是让Java抛一个让人困惑的堆栈。另一个小技巧是检查日志文件logs/app.logJava进程如果启动时端口被占堆栈前几行会直接说明问题比你在网上搜报错要快得多。4.2 中文乱码问题这个坑几乎必踩。Java版本的HttpServer默认的字符编码不是UTF-8如果你在响应里直接写中文浏览器端十有八九显示成乱码。解决办法就是三个地方都要显式指定UTF-8exchange.getResponseHeaders().set(Content-Type, text/html; charsetutf-8)response.getBytes(utf-8)前端页面meta charsetUTF-8Node版本会好一些因为默认就是UTF-8但响应头里的Content-Type还是要写全否则部分浏览器对没有charset的响应会猜编码猜错就乱码。还有一个容易忽略的细节query参数里的中文。比如/api/builds?keyword编译成功Java里直接用getRequestURI().getQuery()拿到的字符串是URL编码状态必须URLDecoder.decode(..., UTF-8)后才可读。Node里则是decodeURIComponent。如果不做这步你拿到的就是%E7%BC%96%E8%AF%91%E6%88%90%E5%8A%9F这类编码串。4.3 静态文件刷新不生效内网工具改完CSS或JS后浏览器经常还显示旧版本这是因为浏览器缓存了静态资源。排查过程就两步在浏览器开发者工具的Network面板看请求的响应状态如果显示304 Not Modified说明资源没有更新。解决方式一在静态文件链接后加版本号比如link relstylesheet hrefstyle.css?v20250321每次改动版本号。解决方式二在响应头里加Cache-Control: no-store彻底禁止缓存适合内网这种不考虑性能极致优化的场景。我推荐方式二因为内网工具对缓存带来的性能收益不敏感反而一致性问题更让人头疼。加上这个响应头后每次改动前端文件刷新页面就能看到最新效果。4.4 大文件上传超时或断流上传功能上线后有同事尝试传一个200MB的构建日志压缩包结果传了一半就断了。排查后发现是HttpServer默认的acceptCount和读写缓冲区配置不足以支撑大体积请求体传入。解决思路有三个在HttpServer.create时调大backlog参数从0改成128或256让连接队列容量更大。读取请求体时使用更大的缓冲区从4096提到8192减少读写循环次数。最重要的别让大文件走HTTP同步上传。内网场景下最佳实践是把上传接口作为接收端前端分片上传或者直接用scp类工具传送大文件HTTP只负责展示和管理入口。这个问题的本质是HTTP协议本身没有对上传大小做限制限制来自Web服务器的配置以及你对内存和磁盘的预期。caveman方案本身就不是为大文件传输设计的遇到这种需求应该重新评估工具边界。4.5 线程池满导致页面卡住有段时间页面响应特别慢查看日志发现大量请求在排队。原因是某个请求处理逻辑里做了一个耗时的磁盘扫描操作阻塞了一个线程而固定4线程的池子很快被占满其余请求只能等。解决的思路是耗时操作异步化。对于构建状态这种数据可以在内存里维护一份缓存后台线程每隔30秒重新读取一次数据文件并更新缓存请求进来时直接返回缓存内容毫秒级响应。这也算caveman方案的一种“主动优化”——在不引入缓存框架Redis的前提下自己用ConcurrentHashMap加一个定时刷新机制就能解决问题。经验总结手写Web服务时一定要在脑子里给每个请求的处理时间画一个预期。如果处理时间超过100ms就要考虑是不是应该异步化或者缓存化。框架不会替你解决这个问题反而可能因为层层封装让你忽略了耗时点。5. 方案边界与扩展建议5.1 什么时候不该用caveman方案每套方案都有它的边界。和团队分享这套经验时我特别强调了几种“别用caveman”的情况一是业务复杂度在可以预见的未来会大幅膨胀。比如你预判这个工具要接入用户认证、角色权限、工作流审批那从一开始就不该用原生手写直接用成熟框架更稳妥因为你省下来的初期开发时间会在后期加倍还回去。二是需要和其他系统做深度集成。比如要对接公司统一登录、要接入消息队列、要调用多个微服务这时候框架里的现成组件和社区方案会大大降低集成成本。三是对性能有极致要求。原生HttpServer和Node原生模块的性能虽然不差但相比Netty、Nginx这类专门优化的服务器还是有不小差距。高并发场景下做性能调优的空间也更小。caveman方案的定位应该是快速验证想法、内部工具落地、资源受限环境运行、或作为学习HTTP协议底层的教学项目。在这些边界内它是极致高效的选择出了边界它就变成了给自己找麻烦。5.2 从caveman到半自动化的平滑演进项目跑了一个月后我做了第一次演进在原有手写逻辑上增加了一个配置读取能力把端口、上传目录、静态资源根目录都抽到一个config.properties文件里。这样做不是因为原方案有问题而是团队要在一台新机器上部署不想改代码重新编译。演进的核心原则是改动只针对痛点不追新潮。每次演进都要有一个实际驱动的理由——要么部署不方便要么排障麻烦要么功能不满足。没有实际驱动的“重构”和“升级”是在消耗caveman方案原本的价格优势。如果后续真的要接入数据库我不会引入完整的ORM框架而会用JDBC自带的能力加上一个轻量的连接管理。如果真的要加用户登录我可能会用HTTP Basic认证而不是引入Spring Security。演进是一步步来的保持每一步都有“当前屁股坐在哪里”的清醒认知。5.3 用caveman思想反哺框架学习最后想分享一个比较私人的体会。做过caveman项目之后回头再理解Spring Boot和Express这些框架感觉完全不一样了。以前用Spring BootRequestMapping注解一加请求就映射到方法上了但你其实不知道框架底层做了什么。手写一遍之后你知道了HTTP请求要从接收、解析、路由查找、参数绑定走完一遍你才真正理解那些注解只是替你完成了这些步骤。同理用Node原生模块写完路由分发你再回头看Express的app.get(/xxx)和中间件机制一眼就能知道它在哪一层做了什么。这种“先会走路再学跑步”的路径对新人特别友好。网上很多教程一上来就教Spring Boot学完之后面对一个HTTP请求从进入到响应脑子还是一片空白。如果你把caveman项目先手写一遍再回去看框架代码那种“原来如此”的感觉比看十遍架构图都管用。从我几次实际部署和长期维护的经验来看caveman这个代号起得精准。旧时代的穴居人用最少的工具活了下来这套方案也用最少的依赖撑起了一个工具型服务的生命周期。资源受限的内网环境里它不显眼但它从不出故障。团队里有人问我为什么不用个“正规”点的框架我的回答一直是工具要适配场景不是场景去适配工具。