16 抽象与分层:分层架构、接口、泄漏的抽象
开篇提问
先问你一个关于”抽象”的根本问题:
计算机科学里有句名言:“计算机科学中的任何问题,都可以通过增加一个间接层来解决。“(David Wheeler) 但这句话还有后半句,更少被人引用:”……除了间接层太多的问题。”
请认真想一下这后半句:为什么”加一层”能解决问题,但”层太多”本身又会成为问题?加层到底带来了什么,又偷走了什么?
想清楚这个,你就理解了软件工程里最核心的一组张力:抽象是我们对抗复杂度的唯一武器,但它同时也是复杂度的来源之一。
子主题一:抽象为什么有效——信息隐藏
抽象的本质是什么?一句话:
抽象 = 保留”必要的接口”,隐藏”不必要的细节”。
它的威力来自一个认知事实:人脑同时能处理的信息量极其有限(著名的”7±2”法则,现代研究认为更少)。所以任何复杂系统,都必须被”分块”才能被人理解和维护。
抽象就是”分块的边界”。一个好的抽象,让你在使用它时只需要知道接口,不需要知道实现——这样你的大脑就不用同时装载两层的细节。
经典例子:
- 文件系统:你用
read()/write(),不需要知道数据在磁盘的哪个扇区、怎么寻道、怎么管理空闲块 - TCP 协议:你读写 socket,不需要知道重传、拥塞控制、滑动窗口
- 函数:你调用
sort(arr),不需要知道它内部是快排还是归并
这些抽象的价值是巨大的:它让”造文件系统的人”和”用文件系统的人”可以分开思考。
这个原则有个正式的名字——信息隐藏(Information Hiding),David Parnas 在 1972 年提出。它是模块化设计的理论基石。
子主题二:分层——把抽象叠起来
当抽象被”叠”起来,就是分层(Layering)。
最经典的分层是网络协议栈(OSI 七层 / TCP/IP 四层):
- 应用层(HTTP)不知道数据是怎么传的
- 传输层(TCP)不知道传的是什么内容
- 网络层(IP)不知道包有没有到达
- 链路层不知道网络的拓扑
每一层只需要对上一层提供接口、对下一层提出要求,其他一概不管。
分层的巨大价值:
- 可替换性:换掉某一层的实现,上层不用改(以太网换成 WiFi,HTTP 不用改)
- 独立演化:TCP 可以演进(拥塞控制算法迭代),不影响 HTTP
- 认知分块:你可以只理解其中一层,不需要理解全部
分层的原则是”严格单向依赖”:上层依赖下层,下层不知道上层。 一旦破坏这个原则(下层依赖上层、或者跨层依赖),分层的好处就迅速消失,系统变成一团乱麻。
子主题三:抽象泄漏定律——抽象的必然失败
现在讲那个”但是”。前面第 4 章提过抽象泄漏定律(Joel Spolsky 提出):
所有非平凡的抽象,在某种程度上都会泄漏。
再深入一层:为什么抽象必然泄漏?
原因一:抽象只能隐藏”常见情况”,隐藏不了”异常情况”。 文件系统抽象了”读写文件”,但磁盘满了、权限不足、文件被占用、IO 错误——这些异常情况依然要暴露给你,你必须处理。
原因二:抽象隐藏了性能特征,而性能是可见的。 ORM 让你”不用写 SQL”,但查询慢的时候,你必须知道它生成了什么 SQL、有没有走索引、有没有 N+1 问题。性能是藏不住的。
原因三:抽象隐藏了资源消耗,而资源是有限的。 线程池抽象了”线程管理”,但队列积压、线程耗尽的时候,你依然要面对这些”被隐藏”的细节。
原因四(最根本):抽象是在”某种假设”之上建立的,而假设迟早会不成立。 TCP 假设”网络最终会通”,但网络会分区;文件系统假设”磁盘不会丢数据”,但磁盘会坏。当假设破灭,抽象就泄漏了。
抽象泄漏的工程含义,极其重要:
你使用任何抽象,都必须具备”打穿它”的能力。 也就是说,当抽象泄漏时,你得有能力、有知识去理解底层发生了什么。
这就是为什么”只会用框架”是危险的:你用的抽象迟早会泄漏,而你没有能力打穿它,就只能束手无策。这也是为什么”基础知识”(操作系统、网络、编译原理)永远不会过时——它们是你的”打穿抽象”的能力储备。
子主题四:抽象的两大代价
抽象泄漏之外,抽象还有两个直接的代价:
代价一:性能损失。(第 4 章讲过) 通用抽象必然带来运行时决策、边缘情况处理、泛化数据结构——这些都要付性能税。
代价二:认知负担的转移。 抽象降低了”使用”的认知负担,但增加了”理解全貌”的认知负担。
这是个反直觉的点:当系统有很多层抽象时,每一层单独看都很简单,但”理解整个系统”变得更难了——因为你要理解各层之间怎么交互、某个行为到底是哪一层造成的。
一个经典症状:“这个 bug 到底出在哪一层?”
- 一个 HTTP 请求超时:是应用层逻辑?是连接池?是 TCP?是 DNS?是负载均衡?
- 每一层都”看起来没问题”,但组合起来就出问题。这就是分层的隐性代价:调试路径变长了。
所以抽象不是”越多越好”。 每一层抽象都应该是”净收益为正”的——它省下的复杂度,要大于它引入的复杂度。
判断标准:
- 这个抽象被用了几次?(用一次就别抽象了——“三振出局”原则:第三次重复时才抽象)
- 它隐藏的复杂度,是否真的不需要被知道?
- 它泄漏的时候,代价有多大?
子主题五:好的抽象长什么样
讲了这么多问题,那好的抽象是什么样?几个可操作的标准:
标准一:窄接口(Narrow Interface)。 接口越小越好。一个只暴露 3 个方法的模块,比暴露 30 个方法的好理解得多。接口越窄,你越容易”假装”它不是复杂的。
标准二:深模块(Deep Module)。——这是 John Ousterhout(《A Philosophy of Software Design》作者)的核心观点。
好的模块是”深”的:接口小(窄),但实现的功能多(深)。 坏的模块是”浅”的:接口很大,但功能很少(比如那种只有一行代码的 setter/getter 类)。
深模块的价值公式:模块提供的价值 / 接口带来的认知成本。 分子越大、分母越小,模块越好。
标准三:抽象要匹配”使用者的心智模型”。 文件系统抽象成”文件 + 目录”,是因为它匹配人脑对”文件柜”的直觉。如果一个抽象需要你先理解一堆新概念才能用,它就失败了。
标准四:不泄漏的抽象最好,但做不到时要”泄漏得可预测”。 既然泄漏不可避免,那就让泄漏发生在可预期的地方、以可预期的方式。比如:
- 明确文档化”这个抽象不保证什么”
- 提供 escape hatch(逃生舱口)——允许你在必要时绕过抽象直接操作底层
- 错误信息要能穿透抽象(不要吞掉底层错误再抛一个没用的新错误)
标准五:抽象层之间要有”可观测的边界”。 每一层都要能记录”我做了什么、花了多久、成功没有”。没有可观测性的抽象层,是调试的黑洞。(呼应第 14 章)
子主题六:分层架构的经典形态与它的三种病
先问个扎心的问题:你入职的每一个后端项目,是不是都是 controller / service / dao? 而且不管这项目是做电商、做 IoT、还是做内部审批流,目录长得一模一样?
这说明什么?说明三层架构已经变成了一种肌肉记忆,而不是一个经过思考的决策。
三层架构到底在分什么
表现层(Presentation)/ 业务层(Business)/ 数据层(Data)——这个划分最早不是某篇论文定的,而是 1990 年代企业应用(Java EE / EJB、微软 DNA)实践沉淀出来的;Martin Fowler 在《Patterns of Enterprise Application Architecture》(2002)里把它系统化;Eric Evans 在《领域驱动设计》(2003)里又切成了四层:Interface / Application / Domain / Infrastructure。
但关键不是”分几层”,而是按什么维度分。
答案很直白:按”变化速率”分。
- UI 变得最快(今天 Vue,明天 React,后天小程序、App、开放 API)
- 业务规则次之(促销规则一个月改三版)
- 存储最慢(MySQL 换 Postgres 这种事,几年一次,甚至从不发生)
这跟 Parnas 1972 年说的完全是一回事:模块应该围绕”可能变化的设计决策”来切分,把每个决策藏在一个模块里。 分层只是这个原则在”变化速率”这条轴上的具体应用。
生活化类比:搬家装箱。你会按箱子装满来分吗?不会。你按”我什么时候会用到它”分——当季衣服一箱(天天开),书一箱(偶尔开),过冬被褥一箱(一年开一次)。分层的依据从来不是体积,而是访问频率。
病一:转发层(Pass-through Layer)
看这段代码,熟悉吗?
// Controller
public OrderDTO getOrder(Long id) { return orderService.getOrder(id); }
// Service
public OrderDTO getOrder(Long id) { return orderManager.getOrder(id); }
// Manager
public OrderDTO getOrder(Long id) { return orderDao.getOrder(id); }
// Dao
public OrderDO getOrder(Long id) { return sqlSession.selectOne("getOrder", id); }四个方法,四个文件,零行业务逻辑。请问 Service 隐藏了什么?Manager 又隐藏了什么?
答案是:它们什么也没隐藏,只隐藏了”无聊”。
这就是过度分层最典型的形态,转发方法(pass-through method)。它的净收益是负的:多三次跳转、多三个文件、多三处需要同步修改签名的地方,换来的是……规范感。
有个可以量化的判据:数一数每一层里,方法体只有一句 return xxx(...) 的比例。超过 30%,这一层就该被质疑了。
病二:改名手术(Shotgun Surgery)
第二个症状更疼。产品说:“订单上加一个’预计送达时间’字段。”
然后你改了:DO(数据库实体)、DTO(传输对象)、VO(视图对象)、BO(业务对象)、PO(持久化对象)、Mapper XML、Service 里的转换方法、Controller 的组装、前端的 TypeScript 类型……
9 个文件,0 行业务逻辑。
这就是”改一个小需求要动五层”的经典场景。为什么会这样?因为每一层都在做对象转换,而转换代码是纯粹的结构复制。层数越多,转换链条越长,复制次数越多。
问题的根子在哪?在于这些层之间没有”语义差异”,只有”格式差异”。 DO 和 DTO 长得一模一样,只是放到不同的包里,扮演不同的社会角色。这种层是”按格式分层”,不是”按职责分层”——它不隐藏任何决策。
(顺带说一句:这不代表 DO/DTO 分离本身是错的。边界上的转换是有价值的——它防止数据库 schema 直接泄漏成 API 契约。错的是中间那一大串长得一模一样的对象。两端需要边界,中间不需要收费站。)
病三:架构宇航员(Architecture Astronaut)
“架构宇航员”这个词是 Joel Spolsky 造的(对,就是提抽象泄漏定律那位),指的是那些脱离地面、热衷于在抽象层面飞来飞去、但从不落地解决问题的人。
具体表现:
- 目录里有
Manager、Handler、Helper、Util、Processor、Executor六个包,问它们区别是什么,没人说得清 - 命名全部是技术词汇(“XX 处理器""XX 管理器”),没有一个是业务词汇(“订单""库存""定价”)
- 讨论”我们这层该叫什么""要不要再抽一个 Strategy”的时间,比讨论”这个需求到底怎么回事”的时间还长
- 新人入职第一周的任务是理解这套分层,第二周还在理解
为什么会走到这一步?因为加层是”看起来在工作”的最低成本方式。加一层抽象,代码行数涨了,架构图好看了,PPT 有东西讲了,但业务价值是零。写一个真正解决业务问题的函数很难,加一个 IOrderServiceFactory 很容易。
一句话自检:这一层如果删掉,世界会变糟吗?如果答案是”只是少了一次跳转”,那就删掉。
子主题七:跨层依赖——为什么”抄近路”能毁掉整个分层
再说分层那个铁律:上层依赖下层,下层不知道上层。 但现实里,这条铁律被破坏的方式有两种,都得警惕。
越界一:反向依赖(下层调用上层)。 比如 UserRepository 里直接调用 UserController.refreshCache(),或者数据层抛出一个 HTTP 语义的 NotFoundException(404)。
越界二:跨层调用(跳层)。 比如表现层直接写 SQL,或者 Controller 直接操作 Redis 的 key。
两种越界的结果是一样的:层与层之间的内部实现被焊死了。
具体例子一:UI 层直接写 SQL
上古时代的 JSP 里嵌 SQL,你以为已经绝迹了?并没有,它只是换了马甲:
- 报表工具里写”业务规则 SQL”:一张大宽表,几十行
CASE WHEN,里面塞满了”VIP 用户折扣""黑名单过滤""试用期不算”这些活生生的业务规则 - 低代码平台上,业务人员在画布里配 SQL
- BI 看板里,分析师写了一段 SQL,从此这个口径就成了”公司标准”,而它背后的代码谁也不知道在哪
危害是什么?
第一,业务规则散落 N 处,无法收敛。 同一条”VIP 判定规则”出现在 3 段 SQL、2 处 Java 代码、1 个前端判断里。改一处漏两处,数据开始打架,然后没人再相信任何报表。
第二,不可测试。 那段 SQL 在报表工具的文本框里,没有版本管理,没有单元测试,没有 code review。它像野草一样活着。
第三,schema 变更变成考古。 改一个字段名,你得 grep 整个代码库,还要记得去三个 BI 系统里翻。而 SQL 是字符串,grep 还可能漏(拼接出来的表名、动态列名)。
第四,SQL 注入。 UI 层拼 SQL 通常意味着拼字符串。
第五,也是最狠的:数据库变成了事实上的全局共享状态。 任何人、任何系统、任何脚本都能直接读写表。你的领域模型(“订单必须经过支付才能发货”)在数据库层面根本不存在——因为谁都能 UPDATE orders SET status='已发货'。
具体例子二:业务逻辑渗透进 SQL 和存储过程
反过来也一样致命。把定价规则、状态机、风控规则写进存储过程:
CREATE PROCEDURE calc_price(...)
BEGIN
IF @user_level = 'VIP' AND @amount > 1000 THEN
SET @discount = 0.8;
ELSEIF EXISTS (SELECT 1 FROM blacklist WHERE uid = @uid) THEN
SET @discount = 1.0;
ELSEIF ... -- 后面还有 200 行
END IF;
END这 200 行是最核心的业务资产,但它被埋在了一个最糟糕的地方:
- 无法单元测试:你得先起一个真实的数据库,灌数据,调过程,再查结果。跑一次测试 3 秒起步,于是没人写测试。
- 版本管理在数据库里:Git 里看不到它。改了线上环境的存储过程,代码库毫不知情。
- 和数据库厂商绑死:Oracle 的 PL/SQL 和 MySQL 的过程语法不兼容。迁移?重写。
- 无法水平扩展:应用服务器可以加机器,数据库最难加。把计算压到数据库,等于把负载压到了系统里最不能 scale 的那一个节点上。
- 调试只能靠打日志(甚至没有日志)。
- 没人敢改 → 变成化石代码 → 新人接手要花两周读懂 → 于是更没人敢改。
这是一个恶性循环,终点是”这套系统只有老王能维护,而老王明年退休”。
为什么跨层依赖这么致命?回到第一性原理
分层带来的三个收益——可替换性、独立演化、认知分块——全部建立在”层间接口稳定”这一个前提上。
跨层依赖本质上是在说:“我依赖的不是你的接口,是你的内部实现细节。“一旦如此,你改内部实现就会打断别人,于是内部实现也不敢改了,独立演化能力归零。
生活化类比:分层就像一栋楼的楼板。三楼要用电,从二楼的线槽里走,这没问题(相邻依赖)。但如果三楼的人嫌麻烦,直接打一根柱子撑到一楼地基上——那以后一楼想重新装修,发现三楼的柱子杵在这儿,动不了了。
怎么防?靠自觉是没用的
架构约定写在 Wiki 里,deadline 一来就是废纸。唯一有效的办法是把它变成 CI 里的一条红线:
- Java:ArchUnit,写测试断言”domain 包下的类不允许 import infra 包”
- Python:import-linter,在配置里声明层级契约
- Go:
go list -deps配合go-enum-style 自定义检查,或用golangci-lint的depguard/importas规则禁止某些 import - .NET:NDepend 的依赖矩阵
- 通用办法:在 CI 里跑一个 20 行的脚本,扫描”controller 目录下出现
SELECT字符串”这种规则
关键不在于工具,在于失败要发生在 CI 里,而不是发生在 review 时的口头提醒里。
最后补一条容易搞混的经验法则:
“向下看”是允许的,“向上调用”是禁止的。
意思是:领域层可以知道”持久化”这个概念(定义一个 OrderRepository 接口),这没问题;但领域层绝对不能调用 MySQLOrderRepository 这个实现。前者是依赖抽象,后者是依赖细节。这条界线,正好把我们引向子主题十一的依赖倒置。
反问一句:你的项目里,改一次数据库字段需要动几个文件?答案是”1 个”还是”9 个”?这个数字,就是你架构健康度的体检报告。
子主题八:逃生舱口——好抽象必须承认自己会漏
前面说”抽象必然泄漏”,那设计者该怎么办?装作没看见吗?
先看一个生活场景:你为什么敢坐飞机? 不是因为飞机不会出事,而是因为——它有紧急出口。一个没有窗户、没有门、完全封闭的舱体,你是不敢进去的。
抽象的道理一模一样:好抽象不是”保证不泄漏”的抽象,而是”泄漏时给你留了门”的抽象。
什么是逃生舱口
逃生舱口(Escape Hatch)= 抽象层主动留出的、官方支持的、通往下一层的通道。
它和”绕过抽象”的区别在于:它是设计者主动开的洞,而不是使用者被迫砸的墙。
看几个各个层级的真实例子:
语言层:
- C 的
asm内联汇编;Rust 的unsafe+asm!;Go 的unsafe包 - Java 的
sun.misc.Unsafe(后来被VarHandle和 Foreign Function & Memory API 取代) - C# 的
unsafe/Marshal
数据访问层:
- GORM 的
Raw()/Exec();Hibernate 的createNativeQuery();EF Core 的FromSqlRaw() - 为什么必须有?因为 CTE、窗口函数、
FORCE INDEX提示、Oracle 的 optimizer hint、复杂的报表聚合——ORM 的查询构造器要么表达不出来,要么表达出来比原生 SQL 还难懂。
前端:
- React 的
ref(直接操作 DOM)、dangerouslySetInnerHTML - 名字里直接写着”危险”,这就是好设计
平台层:
- Kubernetes 的
annotations、CRD、CNI/CSI/CRI 插件接口 - Terraform 的
local-exec/null_resource(在声明式抽象里挖一个命令式的洞)
编译器:
__builtin_*intrinsics、#[repr(C)]、SIMD intrinsics
你会发现一个规律:所有活下来的抽象,都有逃生舱口;所有声称”你永远不需要碰底层”的抽象,最后都被人用最脏的方式打穿了(反射、改源码、fork 一份、或者干脆重写)。
逃生舱口的四条设计准则(重点)
准则一:显式(Visible)——名字要丑、要长、要刺眼。
Rust 的 unsafe 关键字、React 的 dangerouslySetInnerHTML、Go 的 unsafe 包、Hibernate 的 createNativeQuery(“Native”两个字就在提醒你:你已经离开 ORM 的保护区了)。
用起来肉疼,人才会慎用。 反面教材是什么?一个看起来平平无奇的配置项 enableRawMode: true,或者一个普通方法名叫 query() 却能执行任意 SQL。危险的事必须长得危险。
准则二:可审计(Auditable)——能 grep 出来,能统计,能卡 CI。
unsafe、Raw(、asm、dangerouslySetInnerHTML 都是可 grep 的字符串。于是你可以:
- CI 里统计
unsafe块的数量,超过阈值就告警 - 所有包含原生 SQL 的 PR 强制资深工程师 review
- 出安全事故时,10 分钟列出全部风险点
如果逃生舱口散落成”配置项”或者”约定俗成的用法”,你就审计不了了。
准则三:局部(Local)——洞口要小,位置要集中。
- Rust 的
unsafe块要尽可能短,把不安全的操作压缩在几行里,外层用安全 API 包住 - 原生 SQL 应该只出现在
Repository的某一个方法里,而不是散落在业务代码里
逃生舱口不是”绕过去”,是”在受控的位置开一个洞”。 洞开在承重墙上和开在隔墙上,是两回事。
准则四:有文档,说清放弃了什么。
写清楚三件事:为什么这里必须绕过抽象?绕过后我失去了抽象层的哪些保证(比如”这段 SQL 用了 MySQL 语法,切换数据库时会失效”)?出问题怎么排查?
一个反直觉的判据:逃生舱口使用率是抽象质量的体温计
如果你的 ORM 项目里,30% 的查询都用 Raw() 写原生 SQL——那不是使用者的错,是抽象错了。它隐藏的东西(SQL),恰恰是你最需要的东西。
逃生舱口的健康使用率,经验值在 5%~15%:
- 接近 0%:可能是好事(抽象很贴合),也可能是抽象太封闭(人们宁可复制代码也不敢用),要看有没有”用反射硬搞”的痕迹
- 10% 左右:健康,说明抽象覆盖了主流场景,边缘场景有出口
- 超过 30%:抽象选错了,该重新划分边界了
最后一句硬话:逃生舱口不是设计的失败,而是设计的诚实。 承认自己会漏,比假装完美,要可靠一万倍。
子主题九:错误穿透——别吞掉我的错误
来看一个三层架构里每天都在发生的事故。
DAO 层执行 SQL,数据库返回一个死锁错误。然后:
try {
return orderDao.update(order);
} catch (Exception e) {
log.error("更新失败", e);
return null; // ← 事故现场
}Controller 拿到 null,判断”订单不存在”,给用户返回”查无此单”。用户重试了三次,全部失败,但系统看起来一切正常——没有告警,没有错误率上升。
直到三天后对账,发现少了 200 万。
三类错误处理反模式
反模式一:吞掉(Swallowing Errors)。
空 catch {}、只 log 不抛、return null / return false 代替错误。
危害是什么?错误没有消失,它只是从”显式的失败”变成了”隐式的错误状态”。 一个本该立刻爆炸的问题,被推迟到 200 行之外、另一个模块里、以 NullPointerException 或者”数据对不上”的形式爆炸。
这是所有反模式里最恶劣的一种,因为它同时破坏了三样东西:正确性、可观测性、可调试性。
反模式二:翻译时丢信息(Lossy Translation)。
catch (SQLException e) {
throw new ServiceException("操作失败"); // 原始信息呢?
}上层现在只知道”操作失败了”。但它需要知道的是:这是死锁(可以重试)?唯一键冲突(业务问题,别重试)?连接超时(基础设施挂了)?还是权限不足(配置错了)?
这四种情况的处理方式完全不同。丢掉原始错误,等于剥夺了上层做正确决策的所有依据。
反模式三:语义污染(跨层的语义泄漏)。
DAO 层抛 HttpException(404),或者领域层的错误里带着 HTTP 状态码。
想一想这有多荒谬:数据访问层为什么要知道 HTTP 的存在? 如果明天这套逻辑要被一个 gRPC 接口、一个定时任务、一个消息消费者复用,这个 404 是什么意思?
下层的错误不应该携带上层的传输语义。 这是跨层依赖在错误维度上的表现。
正确做法:包装(Wrap),而不是替换(Replace)
一句话原则:保留原始错误,加上下文,向上抛。
各语言的标准做法:
Go(1.13 之后):
rows, err := db.QueryContext(ctx, sql, userID)
if err != nil {
return nil, fmt.Errorf("查询用户 %d 的订单: %w", userID, err)
}%w 把原始错误包进错误链,上层用 errors.Is(err, sql.ErrNoRows) 或 errors.As(err, &pqErr) 判定类型——即使被包了五层,判定依然有效。
Java: 异常链,new ServiceException("...", cause),Throwable 自带 getCause()。
Rust: thiserror 的 #[from] + source(),或 anyhow 的 .context("...")。
三条实操规则
规则一:要么处理,要么包装后上抛,绝不吞掉。
如果确实需要忽略一个错误(比如关闭资源、清理缓存),必须写注释说明为什么合法。空 catch 不写注释,等同于埋雷。
规则二:每一层只加”这一层知道的那点上下文”,错误的最终翻译只发生一次——在系统边界。
- DAO 层加:
"查询订单表 orders,SQL: ..." - Service 层加:
"取消订单,orderID=123,userID=456" - HTTP Handler 层(边界):决定返回 500 还是 400、记什么日志、是否上报监控、是否重试
为什么翻译只在边界做一次? 因为下层不知道上层用什么协议、什么用户语言、什么重试策略。让每层都翻译一遍,结果是错误被翻译了五次,信息每翻译一次丢一层。
规则三:错误要携带”可判定”的信息,而不是一段字符串。
调用方需要能问出这些问题:这是超时吗?是可重试的吗?是唯一键冲突吗?是权限问题吗?
用类型(Go 的自定义 error 类型、Java 的异常类)、哨兵值(ErrNotFound、ErrConflict)或者错误码来承载,而不是让上层去匹配错误文案。
为什么? 因为文案是会变的。今天你 strings.Contains(err.Error(), "timeout"),明天有人把文案改成”请求超时”,你的重试逻辑静默失效。
附带一个日志纪律
底层不记日志,中间层只加上下文,边界层记一次。
否则一个错误在 5 层里被 log 了 5 次:日志量 ×5,告警 ×5,而你在 Kibana 里翻了 5 条看起来一样的日志,还是不知道源头是哪个。(这呼应第 14 章讲的可观测性:日志的价值在于可关联,不在于数量。)
生活化类比:错误是个快递包裹。
- 吞掉 = 包裹丢了,不通知任何人,三个月后客户投诉才被发现
- 翻译丢失 = 把包裹里的东西倒出来换个新箱子,发票和保修卡全丢了
- 正确 = 原包裹原样往下传,外面套一层袋子,袋子上写”这是订单 #123 的那批货,收件人是张三”
反问:现在就去你的代码库搜一下 catch (Exception e) 后面跟着 return null 的地方,看看有几处。每一处都是一颗定时炸弹,而且引信长度随机。
子主题十:抽象与性能的冲突专题
前面第 4 章和子主题四都提过”抽象有性能代价”,但只说了”有”。这一节把它掰开:代价具体来自哪四个机制?有没有可能做到零代价?Go 又为什么不做?
机制一:不透明 → 优化机会消失
编译器的优化(内联、常量传播、循环展开、向量化)前提是它能看见实现。抽象层一挡,它什么都看不见。
最经典的对照是 C 的 qsort 和 C++ 的 std::sort:
// C: 比较函数通过函数指针传入
qsort(arr, n, sizeof(int), compare); // compare 无法内联,n 次间接调用// C++: 比较器是模板参数,类型在编译期已知
std::sort(arr.begin(), arr.end(), Compare{}); // Compare::operator() 可内联同一个算法、同一份数据,差别在哪?std::sort 里每一次比较都可能是零开销(直接展开成几条指令),qsort 里每一次比较都是一次真实的间接调用(压栈、跳转、无法预测分支)。实际测量下来,差距经常在 2~10 倍。
而且更狠的是:一旦比较操作被内联进排序循环,编译器才有可能对整个循环做进一步优化(比如把比较中的公共子表达式提到循环外)。不透明不只是”多一次跳转”,是整条优化链被断开。
机制二:间接与动态分发
虚函数调用、接口方法调用、函数指针——本质都是”运行时才知道跳到哪里”。
以 Go 为例:接口方法调用走的是 itable 查找,是动态分发,无法内联(Go 编译器在中端有一些 devirtualization 优化,但只在能静态推导出具体类型时才生效,绝大多数场景下不生效)。
这就意味着:一个被抽象层包起来的小函数,哪怕函数体只有一行,你也要为它付一次完整的调用开销。而这一行如果被内联,成本是零。
机制三:泛化数据结构的内存代价(这一条最容易被低估)
装箱(boxing):Java 的 Integer vs int,Go 的 interface{} 存值,C# 的 object。值类型一旦被装进通用容器,就要在堆上分配一个对象,外加一次指针跳转。
指针追逐(pointer chasing):List<Integer> 的真实结构是”一个数组,每个元素是一个指针,指向堆上散落各处的 Integer 对象”。而 int[] 是一段连续内存。
差距有多大?遍历一千万个元素:
int[]:连续内存,CPU 预取全部命中,缓存行利用率 100%,还能被向量化List<Integer>:一千万次随机跳转,cache miss 率极高,无法向量化
这不是 2 倍的差距,是 10 倍起步的差距,因为内存墙(memory wall)比 CPU 慢两个数量级。
记住一句话:抽象的性能代价,大部分不在 CPU 指令上,在缓存上。
机制四(最隐蔽):抽象改变了算法选择的空间
你用 ORM 的 .filter() / .map(),那请问:这个过滤是下推成 SQL 在数据库里做,还是把全表拉到内存里过滤?
抽象把这个关键决策藏起来了。你看到的只是一行优雅的链式调用,看不到背后是一次全表扫描还是一次带索引的查询。
N+1 问题就是这么来的——抽象让你看不见”循环体里每次迭代都发了一条 SQL”。如果代码里明晃晃写着 for (id : ids) { query(id); },你一眼就看出问题;但写成 ORM 的关联加载,你只能等监控告警。
更深一层:最贵的代价是”看不见”
抽象的性能代价 = 直接运行时开销 + 认知开销。
认知开销是什么意思?优化的前提是测量,测量的前提是你能看懂每一层在干什么。抽象层叠起来之后,火焰图上 80% 的采样落在你从没读过名字的函数里:(*SomeProxy).Invoke、reflect.Value.Call、sync.(*Pool).Get……
你不是慢在抽象上,你是慢在”不知道该优化哪一层”上。
零成本抽象:C++ 和 Rust 的追求
Bjarne Stroustrup 的定义(这是 C++ 的设计基石之一):
What you don’t use, you don’t pay for. And what you do use, you couldn’t hand code any better. 你不用它,就不付代价;你用了它,手写也写不出更好的代码。
怎么做到的?核心手段是把决策从运行时挪到编译期:
- 模板 + 单态化(monomorphization):
vector<int>和vector<string>是两份各自独立生成的代码,类型已知,全部可内联,没有装箱 - 内联:消除调用开销,并打开后续优化的空间
- constexpr / 编译期计算:计算在编译时完成,运行时是零
- RAII:资源管理(锁、文件句柄)的开销是零——它只是编译期插入的析构调用,和手写
unlock()生成的代码一模一样
Rust 把这套理念继承并发扬:迭代器链 iter().map(f).filter(g).sum() 编译出来的汇编,和手写的 for 循环几乎逐条一致。闭包没有堆分配,async/await 被编译成状态机而不是每次分配一个任务。
但”零成本”不是真的零——它是”运行时零成本,编译期收费”。 收费形式是:编译变慢、二进制变大(单态化的代码膨胀,一个泛型函数被实例化 50 次就是 50 份代码)、错误信息变复杂。
这是一笔交易:用编译时间换运行时间。
Go 的取舍:一个完全不同的答案
Go 的设计目标第一条不是”跑得最快”,而是简单、编译快、读得快。于是它在很多地方主动选择了”性能换简单”:
- 接口是动态分发的,不做单态化。这是明确的选择:不做单态化,编译就快,二进制就小,语义就简单
- 没有内联控制:Go 没有
inline关键字(只有反过来的//go:noinline),内联由编译器自己判断 - 逃逸分析决定栈/堆分配,但开发者只能间接影响(改代码结构),不能直接指定
- 泛型的折中(Go 1.18):这是最典型的例子。Go 泛型用的是 GC shape stenciling + 字典的混合方案——同一”内存形状”的类型共用一份生成代码(避免 Rust 式的代码膨胀),类型特定的操作通过运行时字典查找(避免 Java 类型擦除后的全装箱)。结果是:既不像 Rust 那样全单态化(编译慢、体积大),也不像 Java 那样全擦除(装箱开销)。三个目标——运行时性能、编译速度、二进制体积——只能选两个,Go 选了后两个。
Go 的整体答案是:让抽象的成本”可预测且足够小”,然后用并发模型(goroutine)和 GC 调优去补,而不是把复杂度堆给程序员。
对于一个 90% 的时间花在数据库查询、RPC 调用、磁盘 IO 上的后端服务,这个取舍是对的——** microseconds 级的抽象开销,在 milliseconds 级的 IO 面前不值一提。**
但如果你在写一个 parser、一个序列化库、一个高频交易撮合引擎,那结论完全相反。
三条实操规则
规则一:热路径上,抽象层数要少。
不是”不要抽象”,是”把抽象的边界放在循环外面”。for { repo.FindByID(id) } 改成 repo.FindByIDs(ids) 一次批量——批量 API 是抽象层最重要的性能设计,它就是抽象层给自己开的性能逃生舱口。
规则二:先测量,再拆抽象。
pprof / perf / 火焰图会告诉你钱到底花在哪。不要凭直觉”这个接口调用肯定慢”就去内联——90% 的情况下,钱花在 IO 和内存分配上,而不是虚函数调用上。
规则三:抽象层要暴露批量接口和流式接口。
FindByID / FindByIDs / Stream 三件套。只有单个操作的抽象,会把调用方逼进 N+1 的死角,而调用方唯一的出路就是绕过你。
反问:你有没有为了”优雅”,把一个 10 行的循环换成 3 个 interface + 2 个工厂方法,然后发现 P99 涨了 30%、新人要读 7 个文件才能理解业务逻辑?那个 10 行的循环,可能才是正确答案。
子主题十一:依赖倒置在架构层——箭头全部指向中心
先问一个问题:为什么很多号称”三层架构”的项目,领域层的单元测试依然要起一个真实的 MySQL?
答案通常很朴素:领域层的代码里 import 了数据库驱动。而一旦 import 了,你就再也分不开了。
这就是依赖倒置原则(DIP)要解决的问题。
先复习 DIP 本身
DIP 是 SOLID 里的 D,Robert C. Martin 在 1996 年提出,原文两条:
- 高层模块不应该依赖低层模块,二者都应该依赖抽象。
- 抽象不应该依赖细节,细节应该依赖抽象。
在类和接口的层面,这就是”面向接口编程”。但在架构层面,它的含义要狠得多:
依赖的方向要反转,而且抽象的所有权必须归使用方(高层)。
第二句才是精髓。接口定义在哪一层,比接口本身重要得多。
传统三层 vs 反转后的三层
传统(依赖向下):
Controller → Service → Dao → MySQL驱动
领域逻辑 ──────────┘ 焊死在 MySQL 上
领域层知道 MySQL 的存在。于是:无法脱离数据库测试(要起 Docker、灌 fixture、测试跑 30 秒)、无法替换存储、schema 变更直接冲击领域逻辑。
反转后(依赖向内):
┌─────────────────────────────┐
│ Domain(领域层) │
│ OrderService │
│ interface OrderRepo ←┐ │ ← 接口定义在"我"(使用方)这里
│ interface PayGateway │ │
└────────────────────────┼────┘
│ 实现
┌────────────────────────┴────┐
│ Infrastructure(基础设施) │
│ MySQLOrderRepo implements │
│ StripePayGateway implements │
└─────────────────────────────┘
箭头全部指向中心。 领域层定义”我需要什么”(端口 Port),基础设施层提供”我怎么做”(适配器 Adapter)。领域层完全不知道 MySQL、Kafka、Redis、Stripe 的存在。
四种叫法,其实是同一个东西
这几个名字你一定听过,但它们的关系未必理清过:
| 名称 | 提出者 | 时间 |
|---|---|---|
| 六边形架构(Ports and Adapters) | Alistair Cockburn | 2005 |
| 洋葱架构(Onion Architecture) | Jeffrey Palermo | 2008 |
| 整洁架构(Clean Architecture) | Robert C. Martin | 2012 博客 / 2017 同名书 |
| DDD 分层(Interface/Application/Domain/Infrastructure) | Eric Evans | 2003 |
它们是同一个想法的不同画法:业务规则在中心,技术细节在外圈,依赖只准向内。 差别主要在术语(Port/Adapter vs Use Case/Gateway)和图形(六边形 vs 同心圆 vs 四层)上。选一个顺手的用就行,别把时间花在争论画法上。
一个关键的认知升级
“层”不是一个文件夹,是一个依赖方向。
你可以把所有代码整整齐齐放在 controller/、service/、dao/ 三个目录里,然后在 service 里 import 数据库驱动、在 service 里直接调第三方 HTTP API、在 service 里读环境变量——目录结构完美,依赖方向一塌糊涂。
目录结构不会救你,依赖方向才会。
Go 的天然契合(这一节的重点)
现在说为什么 Go 特别适合玩这套。
第一,隐式接口让”消费者定义接口”零成本。
对比一下。Java 里你要写 class MySQLOrderRepo implements OrderRepo——实现方必须 import OrderRepo。所以如果 OrderRepo 定义在领域包,实现包就依赖领域包(方向正确,OK);但如果你想让领域层使用这个接口,接口又必须能被领域层看到——于是很多团队的做法是把接口放到一个单独的 api 包或者干脆放到实现包里,然后领域层 import 实现包。依赖方向立刻就反了。
Go 里没有 implements 关键字。MySQLOrderRepo 只需要有那几个方法,就自动满足 OrderRepo,不需要 import 任何东西。于是:
domain/ ← 定义 OrderRepo 接口(消费者在这里)
infra/mysql/ ← 实现,import domain(单向,正确)
包依赖图上,箭头干净地指向中心。这不是靠纪律维持的,是语言机制自带的。
第二,“accept interfaces, return structs”(接受接口,返回结构体)。
这是 Go 社群的共识,含义是:
- 函数参数尽量用接口类型(调用方要求尽量少)
- 函数返回值用具体类型(不要剥夺使用者的信息)
配合消费者定义接口,就产生了一个奇妙的效果:每个调用方只声明自己真正需要的那一两个方法。
最经典的就是 io.Reader——一个方法:
type Reader interface {
Read(p []byte) (n int, err error)
}一个方法,全生态通用:文件、网络连接、内存 buffer、压缩流、加密流、strings.Reader,全都满足它。接口越小,实现它的东西越多;接口在被使用的地方定义,它就不会为了”复用”而膨胀。
第三,Go 里不该出现”一一对应的接口 + 实现”。
Java 项目里常见的 UserService + UserServiceImpl 这对双胞胎,在 Go 里是明显的反模式。为什么?
为一个实现定义一个接口,等于为一本书配了一个书签,然后宣称你在藏书。
这个接口没带来任何可替换性(只有一个实现,且永远不会有第二个),只带来了:一次间接跳转、一个需要维护的文件、新人要找实现得靠 IDE 跳转。
判据很简单:这个接口有几个实现?几个调用方?如果都是 1,那它大概率不该存在。
第四,实践建议:
- 接口定义在使用它的包里(通常是
domain或application) - 实现放在
infra/下的子包,构造函数返回具体类型(return &MySQLOrderRepo{}) - 业务函数参数用接口(
func CancelOrder(repo OrderRepo, id int) error) - 测试时在
_test.go里手写一个内存版 fake(type memOrderRepo struct{...}),比 mock 框架更简单、更稳
DIP 也有代价,别到处用
每加一个接口,就多一层间接:新人要找实现得靠 IDE,读代码时的跳转变多,调用链变长。
所以 DIP 只该用在”真正的边界”上:
- 外部系统:数据库、消息队列、第三方 API、文件系统、时钟(是的,把
time.Now()包成接口,测试才能控制时间) - 易变的技术选型:明确的、已经发生过或即将发生的替换
- 必须被测试隔离的地方
不要在稳定的内部代码之间到处插接口。 一个 UserValidator 接口只有一个实现、十年不会换,你为它付出的间接层,换来了什么?
反问:打开你的项目,数一数有多少接口只有一个实现、且从没被 mock 过?那些就是可以删掉的抽象税。
子主题十二:业界对照——TCP/IP 为什么赢了,OSI 为什么输了
这一节我们跳出来,看一个规模更大、时间跨度更长的真实实验。
一个反常的事实:OSI 七层模型几乎每个程序员都会背,但今天整个互联网跑的是 TCP/IP。
一个更”完备”、更”严谨”、由国际标准化组织制定的模型,输给了一个从工程实践里长出来的东西。为什么?
先摆清事实
- OSI(Open Systems Interconnection):由 ISO 制定,1984 年成为国际标准(ISO 7498,对应的 ITU-T 版本叫 X.200)。它是先有模型,再有协议——自上而下设计。
- TCP/IP:来自 ARPANET 的实践。Vint Cerf 和 Bob Kahn 1974 年发表《A Protocol for Packet Network Intercommunication》;1983 年 1 月 1 日 ARPANET 全网切换到 TCP/IP(历史上称为 flag day);此后互联网起飞。它是先有能跑的代码,再总结成模型——自下而上。
OSI 那套协议(X.25、CLNP、TP4、X.400 邮件、X.500 目录服务)实现复杂、性能差、昂贵,最终基本被淘汰,只留下一些碎片(ASN.1 编码、X.509 证书——这两个今天还在用)。
TCP/IP 赢在哪:不是”层数少”
很多人以为答案是”OSI 七层太多,TCP/IP 四层更少,所以更简单”。这只说对了十分之一。真正的原因有五条。
原因一:一个是”从跑起来的代码里总结出来的”,一个是”在会议室里设计出来的”。
IETF 的文化被 David Clark 在 1992 年一句话说透了:
We reject kings, presidents and voting. We believe in rough consensus and running code. 我们拒绝国王、总统和投票。我们相信粗略共识和能跑的代码。
TCP/IP 的每一层,都是为了解决一个当时真实存在的问题而加的:IP 解决异构网络互联,TCP 解决可靠传输,HTTP 解决超文本分发。而 OSI 的会话层和表示层,是为了让模型对称好看而加的。
抽象应该从真实使用中长出来,而不是从想象中被发明。 这就是第 4 章说的”三振出局”——第三次重复时才抽象,因为前两次重复证明了这个抽象有真实需求。OSI 在”第零次重复”时就抽象了七层。
原因二:TCP/IP 有一个”沙漏腰”(Narrow Waist / Hourglass)。
这是最关键的一条,也是分层架构能带来”独立演化”的全部秘密。
HTTP SMTP SSH DNS BitTorrent QUIC ... ← 上面什么都能跑
╲ │ ╱
┌─────────────┐
│ IP │ ← 细细的腰,极窄,极稳定
└─────────────┘
╱ │ ╲
以太网 WiFi PPP 光纤 卫星 4G/5G ... ← 下面什么都能接
IP 层就是那个腰:只做一件事——尽力而为地把包从 A 送到 B,不保证可靠、不保证顺序、不保证不重复。它窄到几乎不可能再简化,也因此稳定到几十年没变过。
正因为腰窄且稳定,上下两层才能自由组合、独立爆炸式演化。 三十年间,下面的链路层换了好几代(以太网→WiFi→4G→光纤到户),上面的应用层换了无数个(Web→P2P→流媒体→移动 App),IP 一动不动,两边谁也不用通知谁。
OSI 呢?它有七层,但每一层里都有多个互相竞争的协议族和大量可选功能,腰不唯一,于是层与层之间根本没法自由组合。
原因三:层数不是越多越好——会话层和表示层就是”转发层”。
OSI 的会话层(Session)和表示层(Presentation)在实际中几乎没有独立存在的价值:
- 会话管理?要么归入传输层(TCP 的连接本身就是会话),要么归入应用层(TLS session、HTTP Cookie)
- 表示(编码、压缩、加密)?全在应用层(TLS、gzip、protobuf、JSON)
在几乎所有实现里,这两层什么都不做,只是把数据原样传下去。
认出来了吗?这就是子主题六里讲的”转发层”症状,只不过它发生在网络协议栈里。 一个在架构图上不能少、在代码里什么也没干的层——如果你的系统里有这样的层,你就重演了 OSI 的失败。
原因四:实现成本和性能。
OSI 协议栈的实现巨大、慢、贵,需要专门的设备和高价license。而 TCP/IP 有一个简洁、可移植、直接可用的参考实现——随 BSD UNIX 一起几乎免费地扩散到全世界的大学和企业。
分层的价值必须能被”实现成本”兑现。 一个理论完美但没人实现得起的分层,价值为零。
原因五:时机和网络效应。
TCP/IP 绑定在 UNIX 上,随工作站和校园网普及,先形成了生态和用户基数。OSI 被多国政府以采购标准(如美国的 GOSIP)强制推行过——但没有代码、没有用户在手,行政命令推不动生态。
一个重要的反转:OSI 模型并没有完全失败
必须公允地说:OSI 作为描述和教学工具极其成功。 今天所有人说”三层交换机""七层负载均衡""L4 vs L7”,用的都是 OSI 的词汇。它的协议输了,它的模型赢了。
但这恰恰揭示了一个深刻的区分:
“一个模型作为描述工具有用”和”一个模型作为建造蓝图有用”,是两件完全不同的事。
你画的那些系统架构图,很多时候是事后用来解释系统的描述工具。如果你把它当成事前用来建系统的蓝图,照着框框往里塞代码——你就走上了 OSI 的路。
对照回软件架构:你的”腰”在哪?
把 TCP/IP 的教训翻译成软件架构的语言:
第一问:你的系统里,那个”窄腰”是什么?
- 是领域模型?是消息格式(Protobuf schema)?是 API 契约(OpenAPI)?是事件总线上的事件定义?
- 它足够窄吗?它稳定吗?如果它每半年变一次,那你上面和下面都不可能独立演化——你就没有腰,你只有一堆互相焊接的层。
第二问:你有几个”OSI 会话层”? 写架构图时不能少,实际代码里只是转发。现在就去数一数。
第三问:你的分层是从真实痛点里长出来的,还是从架构图里画出来的? 每一层,能不能说出一个”如果没有这一层,就会真实发生的具体问题”?说不出来,它就是表示层。
子主题十三:实操决策框架——到底该不该加这层抽象
讲了这么多,最后要给一个能拿回公司用的东西。
先建立一个正确的心态
抽象的成本是”现在付,永远付”;抽象的收益是”未来收,可能不收”。
所以默认答案应该是:先不加。
这不是保守,这是对成本结构的清醒认识。Sandi Metz 说过一句被广泛引用的话:
Duplication is far cheaper than the wrong abstraction. 重复远比错误的抽象便宜。
为什么?因为重复是可见的、局部的、容易删的——你一眼能看到三处相似代码,删掉它、改掉它,都是几分钟的事。而错误的抽象是隐形的、全局的、极难移除的——它被十几个模块依赖,改它的接口要动半个代码库,而且它的”错”往往要半年后才暴露。
抽象是可以在事后加的(第三次重复时),但错误的抽象几乎不可能在事后被安全移除。
三个决策问题
当你手痒想加一层抽象时,依次问自己这三个问题。三个都是肯定,才加。
问题一:这个抽象要对抗的是哪一种”变化”?你能叫出它的名字吗?
这不是修辞,这是 Parnas 信息隐藏原则的直接应用:模块应该围绕”可能变化的设计决策”来划分。 说不出那个决策,这就不是一个抽象,只是一层包装。
好的答案(具体、可证伪):
- “客户要求支持 MySQL 和 PostgreSQL 两种部署,合同里写了。”
- “支付渠道已经换过两次了(支付宝→微信→银联),明年还要接 Stripe。”
- “这块逻辑我要在三个不同的入口复用,而且它们的入参格式不同。”
坏的答案(模糊、不可证伪):
- “以后可能会扩展。“(YAGNI:你不会需要它)
- “这样更灵活。“(灵活是还没付账时的幻觉——每一次灵活都对应着一处没被简化的复杂度)
- “大家都这么写。“(同侪压力不是技术理由)
- “三层架构就该有 service 层。“(教条不是理由)
追问一句:如果我说”半年后这个变化不会发生”,你还加这层吗? 如果答案是”那就不加了”,说明你心里清楚这是投机,不是需求。
问题二:不加这层,最坏会发生什么?那个代价我扛得住吗?
把代价量化,不要停留在”会很麻烦”这种模糊判断。
问自己四个小问题:
- 重复了几处? 3 处还是 30 处?
- 每处多少行? 5 行还是 200 行?
- 如果未来这些重复要分道扬镳(各自演化成不同实现),代价是什么? 是改两次吗?还是灾难?
- 这个变化已经发生过一次了吗?
关键不等式:重复的成本是线性的,抽象的成本是”常数 + 永久的认知税”。
3 处 5 行的重复 = 15 行冗余,改的时候多改两处,成本极低。 一层错误的抽象 = 一个永久存在的文件、一次永久的间接跳转、永久的”这层是干嘛的”疑问、以及一个永久的出错点。
15 行重复,完胜一层错误抽象。
还有一个必须问的反向问题:
如果这个抽象错了,删掉它要动多少文件?
如果答案是”整个代码库”——那你不是在加抽象,你是在下注,而且是全仓下注。
反问:你有没有在一个抽象上锁死了整个项目,然后用接下来两年为这个决定还债?
问题三:当它泄漏或者出错时,我能不能打穿它?
这是抽象泄漏定律的直接推论。三个检查项,任何一项是”否”,这层抽象就是赌博:
检查项 A:可观测吗? 这层有没有日志、指标、追踪?它能不能说出”我做了什么、花了多久、成功没有”?出问题的时候,你能不能在不看源码的情况下知道是它的问题? (没有可观测性的抽象层,是调试的黑洞——呼应子主题五的标准五。)
检查项 B:可绕过吗?
有没有逃生舱口?绕过它的成本是什么——是一次函数调用(db.Raw(...)),还是一次 fork 源码?
没有出口的抽象,是把你的命运交给了抽象的作者。
检查项 C:可理解吗? 打穿它需要多少背景知识?团队里有几个人具备?如果只有一个——
你引入的不是抽象,是人质。
这一条在引入第三方框架时尤其重要。引入一个团队里只有一个人真正理解的 ORM、一个只有一个人能调参的消息队列、一个只有一个人读得懂源码的 DSL——你以为你引入的是能力,实际上你引入的是单点故障,而且是一个会休年假、会跳槽、会忘记的单点故障。
三个辅助判据(加分项,不是必须)
- 它是深模块吗? 接口宽度 ≤ 你能一口气说清,隐藏的复杂度 ≥ 一堆。价值公式:模块提供的价值 / 接口带来的认知成本。
- 它有 ≥2 个真实的实现或调用方吗? 只有一个实现 + 一个调用方的接口,通常是不必要的。
- 加完之后,依赖图还是单向无环的吗? 画一下 import 图,如果有环,这层抽象不但没解决问题,还制造了新问题。
反向清单:什么时候该删抽象
不要只想着加,也要定期清理。这四条中任意两条成立,就该考虑拆掉它:
- 转发方法占比 > 30%:一层里三分之一的方法只是
return next.xxx() - 逃生舱口使用率 > 20%:人们都在绕过它,说明它挡错了地方
- 实现一个新功能要同时修改 ≥4 层:改名手术已经成型
- 新人问”这层是干嘛的”,答案是”转发给下一层”
最后一个类比
加抽象像买保险。
买的时候你付的是确定的保费(复杂度、认知负担、性能、多一个文件)。赔的时候你拿到的是不确定的赔付(未来那个变化真的发生了,你省下了一大笔)。
那该买哪种保险?只买”赔不起”的那种:变化是真实的(已经发生过)、代价是巨大的、而且你扛不住的。
你不会给一支笔买保险(成本 > 收益),也不会因为”房子可能会丢”就买十份保险(保费叠加,赔付不变)。
抽象只买在”破产风险”上。
演进脉络
-
1960s-70s:结构化编程、模块化思想诞生;Parnas 提出信息隐藏。
-
1970s-80s:分层架构确立(OSI 模型);抽象数据类型和面向对象兴起,封装/继承/多态成为抽象的三大工具。
-
1990s-2000s:企业级分层架构(表现层/业务层/数据层);但过度分层导致”架构宇航员”问题——层层包装却没做实事。
-
2000s 至今:对”过度抽象”的反思—— microservices 强调”小而自治”;“YAGNI”(你不会需要它)和”KISS”原则被强调;同时 DSL 和领域建模让抽象更贴近问题域。
-
2000s-2010s:架构层反思依赖方向——六边形架构(Cockburn, 2005)→ 洋葱架构(Palermo, 2008)→ 整洁架构(Martin, 2012/2017),共同主张”依赖向内、领域在中心”;微服务把”层”进一步变成”服务边界”。
-
2010s-2020s:抽象与性能的再平衡——Rust 把”零成本抽象”做成主流卖点;Go 1.18 泛型用”GC shape + 字典”混合方案折中编译速度、体积与运行时;eBPF 让内核这个最厚的抽象层可以被安全地”开洞”;WebAssembly 正在成为云原生的新”窄腰”(一次编译,到处运行)。
-
当下:抽象化的钟摆仍在中点两侧来回——一端是 Serverless / 低代码 / PaaS 把抽象推到极致(你连进程都看不到了),另一端是”去抽象化”的反弹(eunomia、裸金属、手写 SIMD、以及”我们就用 PostgreSQL”这种主动减层的实践)。
主线:抽象的历史,是一部”不断加层”和”不断反思加层”交替的历史。每一次新抽象解决了一批问题,随后人们发现抽象本身的代价,于是开始新一轮的简化。这个循环不会停止,因为复杂度本身在增长——我们只是在不断重新划定”哪里该抽象、哪里该保持具体”的边界。
本章思考题
【思考题】
-
抽象的本质是什么?为什么说它是”对抗复杂度的唯一武器”,同时也是”复杂度的来源之一”?
-
抽象泄漏定律说”抽象必然泄漏”。请给出抽象泄漏的四个根本原因。
-
为什么说”使用抽象必须同时具备打穿抽象的能力”?这和”基础知识不过时”有什么关系?
-
什么是”深模块”?为什么”接口小、功能多”是好抽象的标准?
-
三层架构(表现层/业务层/数据层)到底按什么维度划分?“过度分层”有哪些典型症状?请给出至少三个可观测的判据。
-
分层架构的两类越界(反向依赖、跨层调用)分别是什么?请各举一个具体例子说明它会造成什么后果,并说明为什么”靠 Wiki 上的架构约定”拦不住它。
-
既然抽象必然泄漏,好抽象应该主动提供什么机制?请说明”逃生舱口”的四条设计准则,并解释为什么”逃生舱口的使用率”可以作为抽象质量的体温计。
-
错误处理里有三类跨层反模式,请分别说明其危害。正确的跨层错误传播方式是什么?为什么”每一层都翻译一次错误”是错的?
-
抽象拖慢程序的具体机制有哪些?(至少说出四个)“零成本抽象”的含义是什么?它是如何实现”零成本”的,真正的代价转移到了哪里?Go 在这一点上做了什么不同的取舍?
-
依赖倒置原则(DIP)在架构层面的核心主张是什么?六边形架构、洋葱架构、整洁架构是什么关系?为什么 Go 的隐式接口与”消费者定义接口”天然契合这套思想?
-
业界对照题:为什么更”完备”的 OSI 七层模型在协议层面失败了,而 TCP/IP 成功了?请说出至少三条原因,并指出其中哪一条最关键。
-
给出一个”该不该增加一层抽象”的可操作决策框架。请说出那三个必须回答的具体问题,并解释为什么默认的答案应该是”先不加”。
-
综合应用题:你在 review 一个 PR,同事为了”方便以后替换数据库”,给项目加了
IOrderRepository接口 +MySQLOrderRepository实现 +RepositoryFactory工厂 + 一层OrderManager转发当前四个抽象层,并且 catch 掉所有 DAO 异常后返回空列表。请用本章的知识逐条指出问题,并给出具体的修改建议。
【参考答案】
-
抽象的本质是”保留必要接口、隐藏不必要细节”,威力来自人脑信息处理能力有限(7±2),必须分块才能理解复杂系统。它是唯一武器,因为信息隐藏让”造的人”和”用的人”可以分开思考。但它也是复杂度来源:多一层抽象就多一层间接、多一层认知负担、多一处可能出问题的地方;且层多了之后”理解全貌”反而更难(调试路径变长,“这个 bug 在哪一层”难以定位)。所以抽象必须净收益为正。
-
四个原因:一、抽象只能隐藏常见情况,隐藏不了异常情况(磁盘满、权限不足、网络错误必须暴露);二、抽象隐藏不了性能特征(查询慢时必须知道生成了什么 SQL、走没走索引);三、抽象隐藏不了资源消耗(线程池队列积压、连接耗尽依然要面对);四、最根本——抽象建立在某种假设之上(网络会通、磁盘不丢数据),假设迟早破灭,抽象就泄漏。
-
因为抽象必然泄漏,泄漏时你必须能下钻到底层才能定位问题;若只会用框架而缺乏底层知识,泄漏发生时便束手无策。这正是”基础知识(操作系统、网络、编译原理)永不过时”的原因——它们是”打穿抽象”的能力储备,决定了你能否在抽象失效时继续排查,而不是停在表面无能为力。
-
深模块是 John Ousterhout 提出的标准:好的模块接口窄(暴露的方法少)但实现的功能深(做的事多),即”小接口、大功能”。价值公式 = 模块提供的价值 / 接口带来的认知成本,分子越大分母越小越好。反之”浅模块”(接口很大但功能很少,如一堆 setter/getter 类)引入了认知负担却没隐藏多少复杂度,是坏抽象。
-
三层架构不是按代码量或层数划分,而是按变化速率划分(UI 变化最快、业务次之、存储最慢),本质是 Parnas 信息隐藏原则的应用:模块应围绕”可能变化的设计决策”切分。过度分层的三个症状与判据:(1) 转发层——某层方法体中只有一句
return next.xxx()的比例超过 30%;(2) 改名手术——加一个字段要改 DO/DTO/VO/BO/PO + 三层转换 + Mapper + 前端,文件数远大于逻辑量;(3) 架构宇航员——目录里 Manager/Handler/Helper/Util 泛滥但说不清职责差异,包里全是技术词汇没有业务词汇,讨论分层的成本超过讨论业务。自检法:这一层删掉,如果世界只是”少了一次跳转”,就该删。 -
两类越界:反向依赖(下层调用上层,如 Repository 调用 Controller、DAO 抛 HTTP 404 语义);跨层调用(跳层,如 UI 层直接写 SQL、Controller 直接操作 Redis key)。后果举例:业务逻辑写进存储过程或报表 SQL 后——无法单元测试(要起真实 DB)、版本管理在数据库里、与数据库厂商绑死、计算压到最难水平扩展的节点、无人敢改,最终变成化石代码;UI 层写 SQL 则让业务规则散落 N 处、不可测试、schema 变更要考古、且数据库沦为全局共享状态(领域约束形同虚设)。根因:分层收益全部建立在”层间接口稳定”上,跨层依赖等于依赖了内部实现,独立演化能力归零(类比:三楼直接打柱子撑到一楼地基)。靠 Wiki 拦不住,因为架构约定在 deadline 面前一文不值;必须把规则变成 CI 红线(ArchUnit、import-linter、depguard、自定义扫描脚本),让失败发生在 CI 里而不是口头提醒里。区分要点:“向下看”(依赖抽象)允许,“向上调用”(依赖实现)禁止。
-
好抽象应主动提供逃生舱口——设计者主动留出的、通往下一层的官方通道(区别于使用者被迫砸墙)。四条准则:(1) 显式——名字要丑、要长、要刺眼(Rust
unsafe、ReactdangerouslySetInnerHTML、HibernatecreateNativeQuery),用起来肉疼人才慎用,绝不能用看起来正常的 API 做危险的事;(2) 可审计——必须是可 grep 的字符串,能在 CI 里统计数量、强制 review,出事故时 10 分钟列出全部风险点;(3) 局部——洞口要小、位置要集中(unsafe 块尽量短,原生 SQL 只出现在 Repository 的某个方法里),是”在受控位置开洞”而不是”绕过去”;(4) 有文档——说清为什么必须绕过、放弃了抽象层的哪些保证、出问题怎么排查。使用率作为体温计:10% 左右健康(主流场景被覆盖、边缘场景有出口);接近 0% 要警惕存在”用反射硬搞”的痕迹;超过 30% 说明抽象选错了——它隐藏的(如 SQL)恰恰是你最需要的。核心观点:逃生舱口不是设计失败,而是设计诚实。 -
三类反模式:(1) 吞掉(swallowing)——空 catch / 只 log 不抛 / 返回 null,错误从”显式失败”变成”隐式错误状态”,推迟到 200 行外以 NPE 或数据不一致的形式爆炸,同时破坏正确性、可观测性、可调试性,是最恶劣的一种;(2) 翻译丢信息——
throw new ServiceException("操作失败"),上层无法区分死锁(可重试)、唯一键冲突(业务问题)、连接超时(基础设施故障)、权限不足(配置错误),被剥夺了做正确决策的依据;(3) 语义污染——DAO 抛 HTTP 404,下层携带了上层的传输语义,被 gRPC/定时任务/消息消费者复用时语义失效。正确做法:包装(wrap)而非替换(replace)——保留原始错误 + 加上下文。Go 用fmt.Errorf("...: %w", err)配errors.Is/As(包五层仍可判定),Java 用异常链 cause,Rust 用thiserror的#[from]/source()或anyhow的.context()。三条规则:要么处理要么包装上抛绝不吞(确需忽略必须写注释);每层只加”这一层知道的上下文”,最终翻译只在系统边界发生一次(下层不知道上层的协议、语言和重试策略,翻译五次就丢五次信息);错误要携带可判定信息(类型/哨兵值/错误码)而不是匹配文案(文案一改,重试逻辑静默失效)。附带的日志纪律:底层不记、中间层只加上下文、边界层记一次,否则日志量 ×5 却仍找不到源头。 -
四个机制:(1) 不透明→优化机会消失,编译器看不见实现就无法内联/常量传播/向量化(C
qsort传函数指针 vs C++std::sort模板,差距常达 2~10 倍,且断开了整条优化链);(2) 间接与动态分发(Go 接口方法走 itable 查找,无法内联);(3) 泛化结构的内存代价——装箱 + 指针追逐(List<Integer>vsint[],差 10 倍起步,因为内存墙比 CPU 慢两个数量级,抽象的性能代价主要在缓存上);(4) 最隐蔽——抽象改变了算法选择空间,把”能否下推”藏起来,N+1 由此产生。更深一层:最贵的是认知开销,“你不是慢在抽象上,而是慢在不知道该优化哪一层”。零成本抽象(Stroustrup 定义):“不用就不付代价,用了手写也写不出更好的代码”,靠模板+单态化+内联+constexpr+RAII 把决策从运行时挪到编译期;Rust 的迭代器链编译后与手写循环汇编几乎一致。它不是真零,而是”运行时零成本,编译期收费”——编译变慢、二进制膨胀。Go 的取舍:目标首位是简单/编译快/读得快而非最快——接口动态分发不做单态化、无 inline 关键字(只有//go:noinline)、逃逸分析只能间接影响、Go 1.18 泛型采用 GC shape stenciling + 字典的混合方案(在运行时性能、编译速度、二进制体积三者中取后两者),主张”抽象成本可预测且足够小,用并发和 GC 调优去补”。三条实操规则:热路径上抽象层数要少且把抽象边界放在循环外;先测量(pprof/火焰图)再拆抽象,90% 的钱花在 IO 和内存分配上;抽象层必须暴露批量与流式接口(FindByID/FindByIDs/Stream),否则会把调用方逼进 N+1 死角——批量 API 是抽象层给自己开的性能逃生舱口。 -
DIP(Robert C. Martin, 1996)两条:高层不依赖低层,二者都依赖抽象;抽象不依赖细节,细节依赖抽象。架构层面的精髓是第二层含义:依赖方向反转,且抽象的所有权归使用方(高层)——接口定义在哪一层,比接口本身更重要。反转后:领域层定义端口(
OrderRepo、PayGateway、EventPublisher),基础设施层提供适配器(MySQLOrderRepo、StripePayGateway),箭头全部指向中心。四种叫法是同一想法的不同画法(六边形/Cockburn 2005、洋葱/Palermo 2008、整洁/Martin 2012 博客与 2017 书、DDD 四层/Eric Evans 2003),共同主张”业务规则在中心,技术细节在外圈,依赖只准向内”。关键认知:“层”不是文件夹而是依赖方向,目录结构不会救你,依赖方向才会。Go 的契合:(1) 隐式接口——无implements,实现方不必 import 接口所在包,包依赖图上箭头天然指向领域层,这是语言机制自带的而非靠纪律维持;(2) “accept interfaces, return structs” + 消费者定义接口,每个调用方只声明自己需要的一两个方法(io.Reader只有一个方法却全生态通用),接口越小实现越多,在被使用的地方定义就不会为复用而膨胀;(3) 因此 Go 里不该出现UserService/UserServiceImpl这种一一对应配对——只有一个实现的接口等于”为一本书配个书签然后宣称在藏书”;(4) 实践:接口定义在使用方包里,实现放 infra,构造函数返回具体类型,测试时手写内存 fake。代价提醒:DIP 只用于真正的边界(外部系统、易变选型、需测试隔离处,如把time.Now()包成接口),不要在稳定内部代码间到处插接口。 -
至少三条:其一,TCP/IP 是”从跑起来的代码里总结出来的”,OSI 是”在会议室里设计出来的”——IETF 的文化是 David Clark 1992 年那句”我们拒绝国王、总统和投票;我们相信粗略共识和能跑的代码”;OSI 的会话层与表示层是为了模型对称好看而加,不是为解决真实问题而加(抽象应从真实使用中长出,OSI 在”第零次重复”时就抽象了七层)。其二(最关键),TCP/IP 有沙漏腰(narrow waist):IP 只做”尽力而为送达”,窄到无法再简化,因此几十年稳定,上下两层得以自由组合、独立爆炸式演化;OSI 每层都有多个互相竞争的协议族,腰不唯一,层间无法自由组合。其三,会话层与表示层在实际中几乎不做任何事(会话归入 TCP 连接或 TLS/Cookie,表示归入 TLS/gzip/protobuf),是典型的”转发层”。其四,实现成本与性能:OSI 栈巨大、慢、贵,TCP/IP 有简洁可移植的 BSD 参考实现并随 UNIX 几乎免费扩散——分层的价值必须能被实现成本兑现。其五,时机与网络效应:TCP/IP 随 UNIX 和工作站先形成生态,OSI 虽有政府采购标准(GOSIP)强推,但没有代码和用户就推不动生态。重要反转:OSI 作为描述与教学工具极其成功(今天说”L4/L7""七层负载均衡”都在用它的词汇),但”作为描述工具有用”和”作为建造蓝图有用”是两回事——照着架构图往框里塞代码,就是重演 OSI 的失败。
-
心态前提:抽象成本”现在付、永远付”,收益”未来收、可能不收”,所以默认”先不加”。Sandi Metz:“重复远比错误的抽象便宜”——重复可见、局部、易删;错误抽象隐形、全局、极难移除(被十几个模块依赖,其错误半年后才暴露)。三个问题必须全”是”:(1) 这个抽象要对抗哪一种变化?能叫出名字吗? 应用 Parnas 原则,说不出”可能变化的设计决策”就只是包装;好答案具体可证伪(合同要求支持两种 DB、支付渠道已换过两次),坏答案模糊(“以后可能扩展""更灵活""大家都这么写""三层架构就该有 service 层”)。(2) 不加这层最坏会怎样?代价扛得住吗? 量化:重复几处、每处几行、分道扬镳的代价、这个变化是否已经发生过一次。关键不等式:重复成本是线性的,抽象成本是”常数 + 永久认知税”,3 处 5 行重复完胜一层错误抽象;反问”如果抽象错了,删掉要动多少文件”,若答案是整个代码库,那是全仓下注而非加抽象。(3) 它泄漏或出错时,我能不能打穿它? 三项检查:可观测(有日志/指标/追踪,不看源码也能判断是不是它的问题)、可绕过(有逃生舱口,绕过成本是一次函数调用还是一次 fork)、可理解(打穿需要多少背景知识,团队里有几人会;只有一人 = 你引入的不是抽象,是人质)。辅助判据:是否深模块、是否有 ≥2 个真实实现或调用方、加完依赖图是否仍单向无环。反向清单(中两条即考虑拆):转发方法 > 30%、逃生舱口使用率 > 20%、新功能要动 ≥4 层、新人问”这层干嘛的”答案是”转发给下一层”。类比:加抽象像买保险,付确定保费换不确定赔付,只买在”破产风险”上——不会给一支笔买保险,也不因”房子可能丢”就买十份。
-
逐条问题与建议:(1) 不必要的抽象层——
IOrderRepository只有一个实现,且”以后替换数据库”属于未发生的变化,违反问题一(叫不出已发生的变化)和问题二(重复成本线性、抽象成本永久);辅助判据也是否定的(实现数 = 1)。建议:删掉接口和RepositoryFactory,直接返回具体类型;真需要替换时(第三次重复/真实需求出现时)再抽接口,成本几乎为零。(2)OrderManager是转发层——它只是把调用转给 Repository,没有隐藏任何设计决策。建议:合并进 Service,或者直接删掉这一层(自检:“删掉它世界只是少一次跳转”)。(3) 工厂模式在这里是过度设计——一个实现 + 一个工厂 = 两层间接换零收益。(4) 最严重:吞掉异常返回空列表——这是错误处理第一类反模式,把”数据库死锁/连接失败”伪装成”订单为空”,错误变成隐式状态,对账时必然出事且没有任何告警。建议:用fmt.Errorf("查询用户 %d 的订单: %w", userID, err)包装后上抛(Go),在边界层决定返回 500 还是降级、记一次日志并上报监控。(5) 可观测性缺失——这几层都没有说明”我做了什么、花了多久、成功没有”,出问题无法定位是哪一层。建议:至少在 Repository 层加慢查询日志与错误计数。(6) 没有逃生舱口与批量接口——只有单条查询会把调用方逼向 N+1。建议:补FindByIDs批量方法,并保留执行原生 SQL 的显式出口(如Raw),而不是让人用反射去硬搞。总结一句话:这个 PR 加了四层抽象税,换来的灵活性从未兑现,却引入了一个会静默造成资金差错的严重缺陷。