☰
caveman编程风格:拒绝过度抽象,回归直白代码
2026/10/29 16:26:07 网站建设 项目流程

说实话,看到"caveman"这个词,我脑子里先蹦出来的是石器时代那个拿着石斧、不讲究包装、全靠力气和直觉解决问题的原始人形象。但放在技术语境里,这个词反倒成了一种很有生命力的编码态度。过去大半年,我在几个内部项目里刻意尝试用这种"原始人"思路来组织和重写代码,踩过不少坑,也确确实实尝到了甜头。这篇文章想把整个思考过程和实操案例拆开来讲:caveman风格究竟在反对什么、适合什么场景、落地时有什么代价,以及怎么判断自己该不该"退化"成原始人。如果你也被动辄几十个类的"架构洁癖"折腾得头疼,想找回对代码的掌控感,这篇值得往下看。

1. 先搞清楚:caveman风格到底在反对什么

1.1 从一次"类爆炸"事故说起

去年年中我接手过一个内部报表系统,代码量不算大,核心逻辑大概三千多行,但被人为拆成了四十多个文件。每个类平均不到一百行,职责划分听起来特别合理:有DateRangeNormalizer、MetricsAggregator、ReportRowBuilder、ColumnWidthCalculator……光看名字你会觉得这项目结构真健康。

但真正去改需求的时候我差点崩溃。一个"把超时阈值从1000ms改成500ms"的需求,牵扯出八个文件要同步修改:配置类要加字段,校验器要处理新值,聚合器要换个判断常量,还要去翻三层继承关系确认子类有没有覆盖父类的默认值。我当时在代码评审记录里写了一句话:"抽象没有降低复杂度,它只是把复杂度藏进了跳转关系里。"

这就是caveman风格想解决的第一个问题:抽象到底是服务于代码的读者,还是服务于写代码的人?当一个类的全部存在意义就是包住两个字段、提供一个getter的时候,它就已经不是工具,而是仪式了。原始人不会搞这些,他会直接打开数据结构,该取什么取什么。

1.2 原始人代码的四个特征

我后来把这类"返祖"代码总结成四个特征,不一定每条都符合,但方向一致:

  • 函数优先,类退居二线。能用普通函数表达的逻辑,就不包成类。只有存在真正的状态聚合、需要把行为和状态绑定在一个生命周期里时,才考虑class或者对象字面量。
  • 数据结构平铺直叙。解析结果就是一个对象,字段直接暴露,不做一堆getter/setter。数据需要转换,就直接写转换函数,而不是塞进构造函数里"顺便初始化"。
  • 控制流肉眼可见。for循环、if判断、switch分支都摊开写,不搞策略模式、责任链模式去"隐藏"分支逻辑。分支多不是罪,强行用模式消掉分支后的间接跳转才是。
  • 依赖显式传递。函数需要什么参数就传什么参数,从入口一路传到底,不搞隐式的全局单例、服务定位器。宁可参数列表长一点,也不要"这个函数内部通过this访问了哪个共享状态"这种让人猜的事情。

这四条翻译成一句话就是:宁可代码看起来笨一点,也不要它"聪明"到让人猜不透。

1.3 这不是反模式,而是成本意识

有人会问:那设计模式、领域驱动设计全是错的吗?当然不是。caveman风格反对的不是模式本身,而是无脑套模式。设计模式解决的是"已知的、确实存在的变化点",如果你根本不知道变化会不会来、什么时候来,只是觉得"大项目都应该分层",那模式就成了负债。

我习惯用一个比喻:抽象是借来的钱,利息是阅读时的跳转成本、调试时的堆栈深度、重构时的联动范围。你可以借钱,但得确认借来的钱能生钱——也就是抽象确实帮你省下了重复劳动,而不是把三行代码搬来搬去组成一套复杂仪式。

2. caveman风格真正擅长的场景与硬边界

2.1 三大典型战场

不是所有项目都适合"返祖",但以下三类场景我实测下来,caveman风格收益大于成本。

第一类是数据处理管道。数据从A变成B、从B变成C,中间每一步都是纯函数就好。输入是日志文件,输出是统计报表;输入是JSON,输出是Excel。这种场景天然适合函数式风格:load、transform、aggregate、write,四个函数串起来,中间用什么class包装反而多余。我写过太多这类脚本,最后的通感就是:管道越直白,调参越轻松。

第二类是CLI工具和一次性脚本。它们的生命周期通常只有几周或几个月,需求变动频繁,使用者就是自己和团队里的几个人。这里最忌讳"为了未来扩展"搞抽象——等你真需要扩展时,脚本早就没人维护了。按caveman方式写成一个大main函数加几个辅助函数,改起来反而是最快的。

第三类是教学案例和POC验证。给新人讲逻辑、验证某个方案能不能跑通,重点是把核心思路暴露出来。我见过用策略模式加依赖注入容器写出来的Demo,业务逻辑才二十行,框架代码快两百行,新人看完完全抓不住重点。原始人的做法就很好:直接把逻辑摊在桌面上,看不懂再往下钻。

2.2 哪些项目千万别学原始人

有收益边界就一定有不适合的地方。我的判断标准很简单:当"状态"和"协作"成为主角时,类和组织就是必要的。

典型例子是大型业务系统,比如电商订单、金融账户。订单这个实体有状态流转、有生命周期、有跨领域的校验规则,此时领域模型不是可有可无的装饰,而是把业务规则固化成代码边界的唯一可靠手段。硬要用数组加函数去表示"订单"这种复杂聚合,很快就会被各种case淹没。

长线维护的公共库也一样。你对外发布API,抽象和封装就是你和调用方之间的契约,稳定的接口设计本身就属于库的价值,不能为了直白破坏兼容性。另外,涉及复杂UI状态的场景——比如大型前端应用的全局状态管理——就别返祖了,框架和状态库的抽象正是为了救你于水火之中。

2.3 判断时机的一张问题清单

在陷入"要不要抽象"的纠结时,我会拿出这几条来问自己,比凭感觉靠谱得多:

  • 这段代码给谁读?只有我自己短期用,还是一个团队要长期维护?
  • 改动频率有多高?半年改一次,和每周改三次,策略完全不同。
  • 我能预判变化方向吗?如果完全不知道未来怎么变,先不做抽象,等变了再说。
  • 抽象减少的是真实重复,还是视觉上的重复?如果只是把三行重复包装成一个函数,但那个函数概念上不成立,就别硬造。
  • 把这段逻辑拆成类之后,读代码时需要记住多少个上下文?超过两个,我就重新考虑。

这套清单陪我渡过了大部分"要不要搞个工厂方法"的深夜,效果很好。

3. 一个日志分析脚本的完整重构实录

光说不练是假把式。我用一个真实的内部工具来做案例:解析Nginx访问日志,输出每个URL的平均响应时间、状态码分布,以及TOP10慢请求。原始日志格式简化成这样:

127.0.0.1 - - [10/Oct/2024:13:55:36 +0000] "GET /api/user HTTP/1.1" 200 1024 0.123 127.0.0.1 - - [10/Oct/2024:13:56:02 +0000] "POST /api/order HTTP/1.1" 500 512 2.456

需求也很直接:给一份统计报告,不涉及权限、不涉及实时推送、不用对接任何平台。我故意写了三个版本,依次是朴素版、过度架构版、caveman收敛版,感受一下差异。

3.1 第一版:标准库硬写的朴素版本

第一版是最"原始"的,一个文件,一个main函数,三个辅助函数:

const fs = require('fs'); function parseLine(line) { const parts = line.split(' '); return { url: parts[6], status: Number(parts[8]), time: Number(parts[10]), }; } function main() { const lines = fs.readFileSync('access.log', 'utf8').split('\n'); const stats = new Map(); // url -> { count, total, statusMap } const slow = []; for (const line of lines) { if (!line.trim()) continue; const { url, status, time } = parseLine(line); if (!stats.has(url)) stats.set(url, { count: 0, total: 0, statusMap: new Map() }); const entry = stats.get(url); entry.count += 1; entry.total += time; entry.statusMap.set(status, (entry.statusMap.get(status) || 0) + 1); if (time > 1.0) slow.push({ url, time }); } console.log('URL\t平均耗时(ms)\t状态码分布'); for (const [url, entry] of stats) { const avg = (entry.total / entry.count).toFixed(3); const statusText = JSON.stringify([...entry.statusMap]); console.log(`${url}\t${avg}\t${statusText}`); } console.log('\nTOP10慢请求:'); slow.sort((a, b) => b.time - a.time).slice(0, 10) .forEach(item => console.log(`${item.time.toFixed(3)}ms\t${item.url}`)); } main();

这个版本大约七十行,没有任何抽象,把数据塞进Map里,字段直接取用。第一次跑就通了,从读到结果不到十分钟。它的可读性完全靠变量命名和结构天然的直线逻辑撑起来:读文件、解析、聚合、输出,一目了然。

但肉眼也看得出它的缺点:如果以后要支持按时间段过滤、要分组统计不同状态码的耗时、要输出JSON而不是命令行文本,主函数马上会膨胀。这就是朴素版和收敛版的区别所在——朴素不是终点,收敛才是。

3.2 第二版:加入"分层架构"之后发生了什么

为了对比,我模仿之前那套"架构洁癖"的思路,把同一个需求拆成第二版。我建了LogEntry类、LogParser类、UrlStatAggregator类、SlowRequestFilter类、ReportFormatter类,然后再来一个NginxReportService去编排它们。每个类职责清晰、命名优雅、接口规范,看起来就像一份教科书作业。

核心代码长这样:

class UrlStatAggregator { constructor() { this.stats = new Map(); } add(entry) { /* 聚合逻辑 */ } getStats() { return this.stats; } } class NginxReportService { constructor(parser, aggregator, filter, formatter) { this.parser = parser; this.aggregator = aggregator; this.filter = filter; this.formatter = formatter; } generateReport(filePath) { const lines = readLines(filePath); for (const line of lines) { const entry = this.parser.parse(line); this.aggregator.add(entry); this.filter.collect(entry); } return this.formatter.format(this.aggregator.getStats(), this.filter.top(10)); } }

你发现没有,第二版的业务逻辑和第一版是完全一样的,唯一不同的是穿了一层又一层外套:NginxReportService要管四个依赖的组装,四个类各自维护内部状态,调用关系从"函数调用"变成了"对象协作"。改一次需求时,你要先判断改动落在Parser、Aggregator、Filter还是Formatter里,然后跑一遍关联测试。

这不是说第二版写得烂,它确实是很多团队的标准姿势。但对于一个日志统计脚本,这就像去超市买一瓶酱油,非要开一辆带导航和定速巡航的SUV——能到,但油钱和停车成本全花在路上了。

3.3 第三版:caveman式的收敛

第三版是我最终保留的版本。它保留了第一版的直白数据流,又吸收了第二版的名字组织思路,不搞类,但把函数拆得足够清晰:

const fs = require('fs'); function loadLines(filePath) { return fs.readFileSync(filePath, 'utf8').split('\n'); } function parseLine(line) { const parts = line.split(' '); return { url: parts[6], status: Number(parts[8]), time: Number(parts[10]) }; } function groupByUrl(entries) { const stats = new Map(); for (const { url, status, time } of entries) { if (!stats.has(url)) stats.set(url, { count: 0, total: 0, statusMap: new Map() }); const s = stats.get(url); s.count += 1; s.total += time; s.statusMap.set(status, (s.statusMap.get(status) || 0) + 1); } return stats; } function filterSlowEntries(entries, thresholdMs) { return entries .filter(e => e.time > thresholdMs) .sort((a, b) => b.time - a.time) .slice(0, 10); } function formatReport(stats, slowList) { const lines = ['URL\t平均耗时(ms)\t状态码分布']; for (const [url, s] of stats) { const avg = (s.total / s.count).toFixed(3); lines.push(`${url}\t${avg}\t${JSON.stringify([...s.statusMap])}`); } lines.push('\nTOP10慢请求:'); for (const item of slowList) lines.push(`${item.time.toFixed(3)}ms\t${item.url}`); return lines.join('\n'); } function main(filePath) { const entries = loadLines(filePath).filter(l => l.trim()).map(parseLine); const report = formatReport( groupByUrl(entries), filterSlowEntries(entries, 1000) ); console.log(report); } main('access.log');

和第一版相比,第三版把"聚合""过滤""格式化"各自提成了顶级函数,数据流变成了清晰的一条链:loadLines -> parseLine -> groupByUrl/filterSlowEntries -> formatReport。和第二版相比,它没有隐藏任何中间状态,没有需要来回跳转的对象协作关系,整段代码在一个屏幕内能全部读完。

3.4 三个版本放在一起看,差距在哪

我把三个版本拿给组里三个同事做了一次小实验:只看代码,不跑程序,回答一个问题——"如果把超时阈值从1000ms改成500ms,需要动哪些地方?"

  • 第一版:两处,一处是if (time > 1.0)的常量,一处是提到函数顶部的阈值参数。
  • 第二版:他们平均找了两到三分钟,需要在filter和service之间来回确认,有个人还担心SlowRequestFilter里是否缓存了旧阈值。
  • 第三版:一眼就能定位到filterSlowEntries(entries, 1000)这个调用点,改一个参数即可。

这就是我常说的结论:直白代码不是"不需要思考",而是让思考花在业务本身,而不是花在还原代码的组织结构上。第三版的每一行都在回答"现在在做什么数据转换",而第二版有一半的类是在回答"谁负责调用谁"。

4. 原始风格真正容易踩碎的暗坑

4.1 团队协作中的可读性错觉

caveman风格有一个非常隐秘的坑:它对你来说是直白的,对别人未必。

因为你全程参与了数据流的构造,脑子里已经有一张清晰的图。你写groupByUrl(entries)时,你清楚entries是解析后的日志对象数组。但一个从没接触过这段代码的新人,看到这个函数名可能还要猜:entries是原始行字符串还是结构化对象?formatReport第一行就用了Tab拼接,输出格式是不是约定俗成的?

我在一个项目里就吃过亏。我用caveman风格写了一套埋点清洗逻辑,函数拆得干干净净,自认为可读性一流。结果两周后组里新人接手,问我"这个数据在transformEvent之后还是事件对象吗?怎么看它有没有留字段?"我才意识到,函数式简洁对写作者是一种幸福,对读者却可能少了一根"类型骨架"的拐杖。

所以后来我补了两个习惯:一是给关键函数写一两行注释说明输入输出结构和约定;二是在数据流交汇处用debug打印中间态,保证新手能顺着日志理解。原汁原味的士气不能丢,但拐杖得给读者备好。

4.2 测试、类型和调试工具的适配问题

第二个坑是工具链的适配。现代的IDE和类型系统往往更偏爱类和成熟框架,因为它们的结构更可预期。caveman风格的代码有几个实际摩擦点:

  • 跳转定义变弱。函数依赖显式传参,参数对象是普通的Map或字面量对象,IDE不容易精确推断字段类型。如果你用JavaScript,这一步尤其明显——VSCode能给你类型提示,但如果数据流经过了map、filter,推断经常会丢失。
  • 重构工具帮助有限。"重命名变量"在一个高度函数式、参数来回传的环境里,很容易漏改。反而在封装良好的类里,IDE很多时候能帮你安全地重构字段和方法。
  • 测试mock更直观但更繁琐。显式传参让mock依赖变得很简单——直接把依赖当参数传进来就行,不需要引入依赖注入框架。但如果某个函数的参数七八个,测试构造数据的成本也不低。典型的取舍:类型安全换来了参数控制的自由,参数的自由又带来了冗长。

我的建议是:如果项目类型系统支持(比如TypeScript或Rust),尽量给中间数据结构定义类型别名,哪怕只是type LogEntry = { url: string; status: number; time: number }。这一个动作能把函数式代码从"裸奔"变成"带眼镜跑",收益巨大。

4.3 性能陷阱:直白不等于高效

还有一个反直觉的坑:caveman代码更直白,但它不一定更高效,甚至更容易写出性能问题。

举个例子,我在做日志分析时第一版直接在逐行循环里查数据库里的URL白名单,每行一次SQL。日志文件一大了就爆炸,十几万行跑了好几分钟。后来改成把白名单先做成一个Set整体加载到内存,再进循环判断,速度瞬间降到两秒以内。

这跟caveman有什么关系?关系在于:直白代码把循环和调用摊开写在明面上,性能瓶颈反而一眼就能看到——当你看到for循环体内还有一个需要IO的函数调用,立刻就能闻到N+1的味道。在过度抽象的系统里,这个循环往往被藏到第三层方法调用之下,反而不容易暴露。所以caveman不会自动让你写出高效的代码,它只是让低效代码更显眼,剩下的还是得靠自己的工程意识。

4.4 什么时候必须往回走

最后说说边界。我在实际项目里不会无脑全部caveman化,某些情况下会主动退回到更结构化的风格:

  • 团队内部有明确的代码规范或框架约定(比如Spring全家桶、React的状态容器),这时跟着规范走比坚持个人风格更重要。
  • 需要对外提供稳定接口的模块,即使里面是caveman,边界上也得做好封装。
  • 项目里已经存在大量面向对象代码,新代码突然全部函数式,会造成风格割裂,审阅成本反而更高。

我现在的策略是:基础设施类、业务状态复杂类用结构化方式,工具逻辑、数据处理链路用caveman方式,中间用清晰的函数边界衔接。既保住团队协作的底线,又保住写代码的爽感。

5. 落地caveman风格的三条铁律和一份复核清单

5.1 铁律一:复杂度应该长在数据流上,而不是类继承上

写任何一段逻辑前,先把这条数据流的"起点"和"终点"画出来。起点是输入格式,终点是输出格式。中间每一步都是输入数据 -> 处理 -> 输出数据的衔接。如果发现需要维护一堆类之间的关系才能走通这条流,大概率是方向错了。

我有个习惯:写代码前先在注释里把数据流写出来,比如:

// access.log中的每行 -> 结构化LogEntry -> 按url分组聚合 -> 输出统计表格 // -> 筛选耗时>1s的请求 -> 排序取TOP10

这段注释就是函数拆分的蓝图。后面的代码再复杂,也是在执行这段注释。这也是为什么caveman代码能在多年后还能被人快速捡起来改——数据流的骨架一直就在那里。

5.2 铁律二:先写最笨的版本,再决定要不要抽象

这是我跟所有新人强调的一条:不要在第一遍写的时候就搞抽象,先让功能跑起来。很多"优雅"的架构,其实是在需求还不清楚时,为了追求完美而提前造的。一旦功能能跑,你对真实痛点就有感受了,这时候再看是哪个地方真的重复、真需要抽象,再动手也不迟。

我管它叫"三遍法则":第一遍,怎么简单怎么来;第二遍,在真实使用中发现重复和痛点;第三遍,针对痛点击中要害地抽象。第一遍就抽象,八成是在猜;第三遍还不抽象,那就是惰性。

5.3 铁律三:用命名承担解释责任

caveman代码没有类型封装来承载语义,命名就是唯一的地图。宁可名字长一点,也要把意图说清楚。我见过太多parseData、processItem、doStuff式的缺陷命名,这种代码即使再扁平,读者也得靠猜。好的caveman命名是这样的:loadAccessLogLines、parseHttpStatusCode、groupEntriesByUrl、formatSlowRequestReport。把动词和宾语都摆出来,读代码的人几乎不需要回头想。

对于局部变量也一样,time不如responseTimeMs,lines在彼此不耦合时可以直接叫rawLogLines。这是一种用文字换翻页推理成本的做法,特别值得。

5.4 代码评审时我用的一份自查清单

我给自己和同事做评审时,会把下面这些问题过一遍,当成caveman风格的最后一道质检:

检查项问题
数据流可见只看主流程代码,能不能说出数据从哪来、到哪去?
依赖显式每个函数用到的外部依赖是不是都从参数传入?
抽象必要性每个类/函数/对象,去掉它能少多少阅读成本?还是反而多出跳转?
命名清晰度不读函数体,只看函数名能不能猜到它返回什么?
中间态可观测程序崩溃或跑飞时,能不能在关键节点打印中间值定位?
修改外扩半径改一个需求,动的函数是否尽量少?

这六条差不多就是我的返祖护身符。满足多数,代码基本不会走样;如果都被否,那可能真的需要一个更传统的设计来撑住复杂度。

5.5 我现在的折中策略

最后总结一下我在业务代码里的实际用法。拿手头的运营数据项目举例,最外层是几个Service类,对外提供方法;这些方法内部全部是caveman式写的纯函数组合,数据流清晰,字段直接。领域对象该有的校验和状态流转,保留在模型层;但数据转换、统计计算、格式组装这些"数据搬运"的活,全面caveman化。

这是一种两头兼顾的策略:该结构化的时候别害羞,该原始的时候别端着。风格之争永远服务于一个终极目标——降低读代码的脑力成本,不管是给自己读还是给团队读。

写在最后的一点体会

跟caveman风格相处大半年,我最大的体会是:它不是一个非黑即白的技术选型,更像一把"反向的尺子"。当你犹豫要不要再包一层、再建一个类的时候,用它量一下:"这一层让读代码的人多看了几个文件?让修需求的人多改了哪些地方?让未来的我还能不能一眼看穿?"答案如果都是否,那就别客气,保持原始,保持直白。

最后分享一个小技巧,特别适合还犹豫要不要"返祖"的人:每次合入代码前,把窗口宽度调到100字符以内,用一个只读过注释、没看过实现的同事视角读一遍你写的函数链,把任何"需要停下来想一会儿"的地方直接标红——然后用参数、注释或者拆函数的方式把它消灭掉。这个习惯坚持两个月,你对复杂度的嗅觉会明显变灵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询