没人买账的技术:一个关于 API 设计的反思
过去半年里做了两个基础工具的实验:一个规则引擎(GuluExpression),一个轻量级流程引擎。复盘的时候发现一个问题——GuluExpression 在技术细节上有不少值得肯定的地方,但 API 设计对用户体验极不友好,理解成本非常高,这个缺陷过于严重,以至于掩盖了所有技术上的优点,导致没人买账。
流程引擎的设计因此做了一个转变:先设计 API,再考虑实现。经历多次迭代之后,形成了一个判断——将理解成本作为 API 设计的第一优先级,可以作为后续 API 设计的标杆参考。
这篇文章是对这个判断的完整论证,用两个项目的正反例作为上下文。
API 是价值传递的通道,通道堵了价值就出不来
先说 GuluExpression 的核心技术价值是什么。
它解决的是一个真实的工程困境:在风控、CRM、自动化营销这类业务里,同一套规则需要同时用于检索(转成 ES Query)、判定(Java 实时执行)、看板(转成 HiveSQL)。传统方案是一种规则多处实现,维护成本极高。GuluExpression 的思路是:定义一套统一的 DSL,AST 结构公开透明,通过访问者模式让同一份规则自动适配到不同的执行平台。
这个设计思路是对的。EsQueryTransformerVisitor 实现 GuluNodeVisitor<QueryBuilder>,每个 AST 节点类型对应一个 visit 方法,调用方想新增一种转换目标只需要新建一个 Visitor,不需要动任何已有代码。扩展点的粒度设计得恰好,这是真正的技术优点。
但调用方永远感知不到这个优点,因为他在到达"体会到优点"这一步之前就放弃了。
原因在 GuluContext 接口上:
public interface GuluContext {
Object getIdentifier(String path);
Object getEnvVar(String path);
GuluAstNode getReferAstNode(String path); // 给引擎内部用
Boolean getReferExpressionResult(String path); // 给引擎内部用
}
前两个方法是给调用方实现的,后两个是引擎执行时内部调用的。但它们挤在同一个接口里,调用方看到四个方法,不知道后两个是什么意思,不知道自己要不要实现它们,不知道实现错了会发生什么。
这是一个接口职责没有分离的问题。引擎内部的机制泄漏到了 API 边界外侧,调用方被迫承担了本不属于他的认知负担。
BaseGuluContext 的存在部分解决了这个问题——继承它只需要实现 getValueByPath 就够了。但"你应该继承 BaseGuluContext 而不是直接实现 GuluContext"这个意图,在接口层面完全看不出来。调用方需要先踩坑,或者先读文档,才能知道正确的用法。
还有一个细节:GuluExpressions.parserAndRegister() 的第三个参数类型是 BaseGuluContext 而不是 GuluContext,因为只有抽象类上才有 registerReferExpression 方法。这把一个实现细节泄漏到了工厂方法的签名里,调用方看到这个签名会有一个疑问:为什么这里不能传接口?他需要去翻源码才能理解原因。
技术上的优点是真实的,但 API 是传递技术价值的通道。通道堵了,价值就传不出去。
做得好的地方,都在做同一件事
对比来看,GuluExpression 里做得好的地方,恰好都是在"减少调用方需要建立的心智模型"。
入口设计是一个例子。调用方永远只需要知道 GuluExpressions.parser(),不需要知道 GuluCommonExpression 的存在。接口和实现之间的隔离做得很彻底,GuluCommonExpression 是公开的还是包级私有的都无所谓,因为调用方根本不会去碰它。
NOT NOT 的消除处理是另一个例子,藏在 Parser 里:
private GuluEvalBoolNode parserNotExpression() {
if (check(NOT, EXCLAMATION)) {
advance();
GuluEvalBoolNode notExpression = parserNotExpression();
if (notExpression instanceof GuluBoolNotNode) {
return ((GuluBoolNotNode) notExpression).getWrappedNode(); // 双重否定消除
} else {
return new GuluBoolNotNode(notExpression);
}
}
return parserBaseBoolExpression();
}
NOT NOT x 在解析阶段直接被消除成 x,不会在 AST 里留下两层 GuluBoolNotNode。对调用方来说,这意味着他实现 Visitor 的时候不需要考虑"如果遇到嵌套的 NOT 怎么办"——引擎已经保证了 AST 里不会出现这种情况。复杂性被消化在内部,没有流向外侧。
流程引擎里也有类似的例子。Handler 接口的两个工厂方法:
static <I> Handler<I, Void> voidHandler(Consumer<I> consumer)
static Handler<Void, Void> justRun(Runnable runnable)
如果没有它们,写一个没有返回值的节点要这样写:
node(input -> { doSomething(input); return null; }, ...)
return null 是纯粹的语言噪音,和业务意图无关,但你不得不写。voidHandler 和 justRun 把这个噪音吸收掉了,调用方直接传 Consumer 或 Runnable,代码读起来就是它字面上的意思。
还有 when().caseWhen().defaultThen() 的链式终结设计。defaultThen() 是必须调用的终结方法,不调用就无法完成 when 的配置。调用方不需要记住"我还有什么没做"——忘了写兜底分支,这件事在编写阶段就会暴露,不会等到运行时。
这些做得好的地方,有一个共同的模式:都是在替调用方做决策——把"你可能会忘的事"变成强制约束,把"你不需要知道的事"藏进内部,把"语言层面的噪音"消化在边界里。
理解成本是一种隐性税,而且会自我繁殖
为什么理解成本需要是第一优先级,而不只是"需要关注的因素之一"?
因为它和其他设计目标之间有一个不对称性:其他目标的欠债可以后补,理解成本的欠债会自我繁殖。
性能不够,可以后来优化。功能不完整,可以后来补充。但理解成本高的 API 一旦被广泛使用,它就开始繁殖——调用方在它之上构建新的抽象,新的抽象继承了它的理解成本,还可能叠加新的理解成本。等你意识到问题想要重构,迁移成本已经高到无法承受。
每一个泄漏出来的内部概念,每一个需要调用方记住的约定,每一个"你应该继承这个抽象类而不是直接实现接口"的隐性规则——这些都是税。调用方每次使用你的 API,都要在脑子里维护一张"我需要记住的事情"的清单。这张清单的成本不是一次性的,它在每次阅读代码时都要付,在每次新人接手时都要付,在每次出了 bug 排查时都要付。
从更宏观的视角看,软件系统天然趋向于复杂——需求在增加,代码在增长,概念在累积。理解成本是这种熵增的一部分。控制它的手段是:在每一个 API 边界上,主动把复杂性消化在内侧,而不是让它流向外侧。不是消灭复杂性——复杂性不会消失——而是把它封装在它应该在的地方,让它不再扩散。
这就是为什么理解成本需要是第一公民:不是因为它最重要,而是因为它是其他所有设计目标能够持续成立的前提。一个系统如果没有人能理解,它的性能、它的功能、它的可靠性,对维护者来说都是无效的。
为什么理解成本会在不知不觉中变高
知道了理解成本的危害,还需要回答另一个问题:为什么它会在不知不觉中变高?不是因为设计者不在乎,而是有一个更根本的原因。
设计者对自己领域的熟悉程度,会让他天然低估调用方的理解成本。
GuluContext 里那四个方法,对设计者来说是自然的——他知道哪两个是给调用方用的,哪两个是引擎内部用的,因为他写了整个引擎。但调用方没有这个背景,他看到四个方法,不知道该实现哪个,不知道后两个是什么意思。设计者的"显而易见",是建立在调用方没有的知识上的。
这有一个专门的名字,叫知识诅咒——一旦你知道了某件事,你就很难想象不知道它是什么感觉。
知识诅咒解释了为什么"根据现实情况的调用方来设计"这个原则,在实践中经常失效。你有预设的使用人群,但你对调用方的理解,和调用方实际的认知背景之间,存在偏差。这个偏差不是因为你没有认真思考,而是因为你的认知起点和调用方的认知起点之间有一道鸿沟,而你已经忘记了鸿沟的存在。
这也是"先设计 API,再考虑实现"这个实践有价值的原因之一。在你写实现之前设计 API,你对内部机制的了解还不深,更容易站在调用方的视角上。等实现写完了再回头设计 API,知识诅咒已经很深了,很难再还原那个"第一次看到这个接口"的感受。
这个原则的边界在哪里
"将理解成本作为第一优先级"不是一个普适原则,它有明确的适用边界:只在 API 边界上成立。
API 边界是"从哪里开始,调用方可以不关心你内部的逻辑"的那条线。边界内侧是实现,边界外侧是调用方的世界。这条边界不是物理上的文件或模块边界,而是你对外做出承诺的那条线。
只要存在这条边界,理解成本就应该是设计它时的第一个问题。边界内侧的代码——私有方法、内部类、实现细节——不在这个原则的管辖范围内,那里的设计目标是另一套。
这个边界的定义还有一个推论:理解成本不能被度量,但可以被校准。校准的方式不是设计指标,而是让真实的调用方用你的 API,观察他在哪里卡住,听他说哪里看不懂。GuluExpression 的问题就是这样被发现的——不是算出来理解成本高,而是"没人买账"这个真实反馈告诉你的。只是这个反馈来得有点晚,代价有点大。
总结
一句话总结这半年的经验:
将理解成本作为 API 设计时的第一优先级,用真实调用方的反馈而不是设计者自己的判断来校准它,因为知识诅咒会让设计者天然低估调用方的困难。
这不只是一个设计原则,它还是一个对抗知识诅咒的主动机制。你必须显式地把它放在第一位,才能抵消掉那个天然的认知偏差。不显式要求,它就会自然滑落。