你的界面有两个通道
摘要
本文介绍了一个界面设计框架,其中每个界面都有两个通道(带内和带外)用于传达关注点。文章认为,好的设计会迫使用户面对重要的关注点,而不是让他们忽略这些关注点。
<p><a href="https://lobste.rs/s/khavt4/your_interface_has_two_channels">评论</a></p>
查看缓存全文
缓存时间: 2026/06/11 15:34
您正在使用接口进行分析和处理。所有接口都有两个通道。原文链接:https://tomeraberba.ch/your-interface-has-two-channels
发布日期:2026年6月11日 · 11分钟阅读
编辑本文:https://github.com/TomerAberbach/website/edit/main/private/posts/your-interface-has-two-channels.md
以下代码可以轻易通过粗略的代码审查:
`` const response = await fetch('https://example.com/flags.json') const flags = await response.json() startServer(flags) ``
某天端点返回了500状态码,`flags` 变成了 `{ error: 'Internal Server Error' }`,没有任何键匹配真实的选项,服务器悄无声息地使用所有默认值启动。
`fetch`
(https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch)
不会因HTTP错误而拒绝(reject)。它无论如何都会成功解析,并且接口没有任何提示告诉你应该检查 `response.ok`
(https://developer.mozilla.org/en-US/docs/Web/API/Response/ok)。
这个bug并非你*决定*跳过错误处理,而是你根本不知道需要做这个决定。
每个人都曾使用过像这样把你推入绝望之坑
(https://blog.codinghorror.com/falling-into-the-pit-of-success/)
的接口。我已经跌入谷底足够多次,以至于能看清这个模式。对于接口暴露的每个关注点(concern),它要么强迫你面对它,要么允许你在不经意间忽略它。忽略一个已被你面对的关注意味着一个有意的决定,而忽略一个未知的关注点则让你在不自知的情况下接受了某些假设。正是这种信号传递方式决定了接口的失败模式:是由于决策失误,还是由于意外疏忽。
一旦你用这种方式看待接口,许多熟悉的设计问题就变成了同一个问题:是抛出异常还是返回错误值?是必需参数还是提供默认值?是对象类型还是联合类型(union type)?每一个问的都是:接口应该以多高的音量来信号化一个关注点。很快你就会总结出回答这些问题的原则。
## 关注点信号化 permalink (https://tomeraberba.ch/your-interface-has-two-channels#concern-signaling)
关注点信号化
我借鉴了电信领域
(https://en.wikipedia.org/wiki/Signaling_(telecommunications)#In-band_and_out-of-band_signaling)
的信号化术语。带内信令(In-band signaling)意味着控制信息与数据在同一通道中传输。带外信令(Out-of-band signaling)使用独立的通道传输控制信息。这种区分可以清晰地映射到接口的关注点上。
每个接口都有同样的两个通道,它的每个关注点都落在其中一个通道上:要么是用户为了使用接口而必须面对的通道,要么是可以被用户忽略的、位于一旁的通道。
## 错误处理 permalink (https://tomeraberba.ch/your-interface-has-two-channels#error-handling)
错误处理
考虑一个返回成功或失败的联合类型
(https://en.wikipedia.org/wiki/Tagged_union)
的函数。调用者无法在使用该函数时不意识到错误的存在。例如,返回 Rust 的 `Result`
(https://doc.rust-lang.org/std/result/)
类型会强制调用者显式地处理错误:
`` fn parse_config(raw: &str) -> Result { ... } // 尝试不进行unwrap就直接使用结果会触发类型错误。 // 如果调用者决定忽略错误,那这就是一个有意的决定。 let result = parse_config(raw); match result { Ok(config) => start_server(config), Err(e) => eprintln!("{e}"), } ``
在这种情况下,错误是*带内的*。面对它是使用该接口本身不可分割的一部分。1 (https://tomeraberba.ch/your-interface-has-two-channels#fn-1)
现在考虑一个返回 `Config` 并在失败时*抛出异常*的函数。调用者可以直接使用 `Config`,因为异常不需要任何确认。例如,抛出 JavaScript 的 `Error`
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#throwing_a_generic_error)
允许调用者在没有面对错误的情况下继续:
`` /** @throws Error for invalid configs. */ function parseConfig(raw: string): Config { // ... } // 如果调用者没有阅读函数文档,不知道它可能会抛出异常, // 就可能在无意中忽略错误。 const config = parseConfig(raw) startServer(config) ``
在这种情况下,错误是*带外的*。面对它需要纪律性,调用者可以在不知道其存在的情况下轻易绕过。
抛出*受检异常*
(https://en.wikipedia.org/wiki/Exception_handling_(programming)#Checked_exceptions)
的函数将错误移回带内。调用者被迫显式地捕获或传播错误。例如,Java 的 `throws`
(https://docs.oracle.com/javase/tutorial/essential/exceptions/declaring.html)
关键字使错误处理成为带内的:
`` Config parseConfig(String raw) throws ParseException { // ... } // 调用者被迫处理受检异常。如果不catch或未声明 `throws ParseException`, // 这段代码无法编译。 void start(String raw) throws ParseException { Config config = parseConfig(raw); startServer(config); } ``
然而,当一个关注点被过于严厉地置于带内时,结果会适得其反,因为承认这件事变成了一种条件反射。Java 程序员
人尽皆知地
(https://www.artima.com/articles/the-trouble-with-checked-exceptions)
通过空的 `catch` 块、非受检的 rethrows 或 `throws Exception` 子句来静默受检异常。这比带外还要糟糕。代码只是*看起来*面对了关注点。Rust 的成功表明,Java 的失败在于人体工程学(ergonomics),而不是面对本身。`Result` 强制了同样的承认,但它处理或传播起来
很愉快
(https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html)。
受检异常还揭示了一点:承载*数据*的通道并不等同于承载*关注点*的通道。受检异常穿梭于返回值之外,但关注点却是带内的。反向的错配也存在:带内的数据并不意味着带内的关注点。例如,C 风格的 -1 哨兵值
(https://en.wikipedia.org/wiki/Sentinel_value)
从数据角度看是带内的,但从关注意义上看却是带外的,因为用户可能会忽略检查这个哨兵值:
`` // `open` 通过返回 -1 来信号化失败。 // 如果调用者不知道 -1 是可能的返回值,他们可能不会检查。 int fd = open("config.json", O_RDONLY); // 如果 `fd == -1`,这是未定义行为。 read(fd, buf, sizeof(buf)); ``
## 命名 permalink (https://tomeraberba.ch/your-interface-has-two-channels#naming)
命名
名称可以将关注点移入带内或带外。例如,Java 的 `HashSet`
(https://docs.oracle.com/javase/8/docs/api/java/util/HashSet.html)
没有保证的迭代顺序,但名称只描述了实现,没有提及排序属性。对于小集合,迭代顺序可能巧合地与插入顺序一致,因此用户可能在不知不觉中依赖它:
`` // 对于小集合,可能按插入顺序打印,引诱用户依赖一个并未保证的顺序。 HashSet set = new HashSet<>(List.of(3, 1, 4, 1, 5)); for (int value : set) { System.out.println(value); } ``
Java 的 `TreeSet`
(https://docs.oracle.com/javase/8/docs/api/java/util/TreeSet.html)
将排序关注点移入带内。名称暗示了树结构,这强烈意味著有排序的迭代,因此用户可以推断排序是约定的一部分:
`` // 名称暗示排序顺序。用户更有可能认识到排序是一个有意的特性。 TreeSet set = new TreeSet<>(List.of(3, 1, 4, 1, 5)); for (int value : set) { System.out.println(value); } ``
## 联合类型 permalink (https://tomeraberba.ch/your-interface-has-two-channels#union-types)
联合类型
联合类型
(https://en.wikipedia.org/wiki/Tagged_union)
是使非法状态不可表示
(https://vimeo.com/14313378)
的常用工具,这作为副产品将关注点移入带内,因为每个变体都是用户必须考虑的情况。但合法性和信号化是独立的。即使每个状态都已经是合法的,联合类型也能将一个关注点移入带内。例如,JavaScript 的 `KeyboardEvent`
(https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent)
类型上的每个属性值组合都是有效的,但它却隐藏了一个关注点:
`` interface KeyboardEvent extends UIEvent { // 哪个主键被按下。 key: string // 次要修饰键标志。所有值组合都是有效的。 altKey: boolean ctrlKey: boolean metaKey: boolean shiftKey: boolean // 其他无关属性... } ``
用户主要查找和访问的属性是 `key`,因为这是一个*键*盘事件。他们很容易忘记检查修饰键,导致逻辑无意中过于宽泛。修饰键关注点是带外的。这些标志不是事件的主要数据,因此它们不能可靠地吸引用户的注意力。
修饰键关注点更带内的表现形式可能如下所示:
`` type KeyboardEvent = | { kind: 'single-key' key: string } | { kind: 'modified-key' primaryKey: string altKey: boolean ctrlKey: boolean metaKey: boolean shiftKey: boolean } ``
在 TypeScript 中,用户无法访问此类型上除了 `kind` 之外的任何数据。他们将被迫逐个考虑单键和修饰键的情况。这里并没有需要消除的非法状态,但切换到联合类型仍然影响了信号化。2 (https://tomeraberba.ch/your-interface-has-two-channels#fn-2)
## 必需参数 permalink (https://tomeraberba.ch/your-interface-has-two-channels#required-parameters)
必需参数
必需参数可以通过移除假设来将关注点移入带内。例如,Java 的
[`String(byte[], Charset)` 构造器](https://docs.oracle.com/javase/8/docs/api/java/lang/String.html#String-byte:A-java.nio.charset.Charset-)
有一个可选的 `Charset`
(https://docs.oracle.com/javase/8/docs/api/java/lang/String.html#String-byte:A-)
参数。当省略时,将使用平台的默认字符集,通常是
UTF-8
(https://en.wikipedia.org/wiki/UTF-8)。如果用户忘记指定字符集,那么默认字符集可能会在解码过程中损坏数据。字符集关注点是带外的:
`` // 调用者可能没有意识到正在使用平台的默认字符集,这可能在解码时损坏数据。 String text = new String(bytes); ``
另一方面,Guava
(https://github.com/google/guava)
是一个流行的 Java 库,它使得在未指定 `Charset` 的情况下无法从 `ByteSource` 生成 `CharSource`
(https://guava.dev/releases/19.0/api/docs/com/google/common/io/ByteSource.html#asCharSource(java.nio.charset.Charset)):
`` String text = ByteSource.wrap(bytes) // `byte[]` -> `ByteSource` .asCharSource(charset) // 此处需要 `Charset` .read(); // -> `String` ``
`asCharSource(Charset)` 中的必需参数将字符集关注点移入了带内。
## 随机化 permalink (https://tomeraberba.ch/your-interface-has-two-channels#randomization)
随机化
随机化可以通过防止用户隐式依赖确定的可观测行为来将关注点移入带内。例如,与 `HashSet` 类似,Java 的 `HashMap`
(https://docs.oracle.com/javase/8/docs/api/java/util/HashMap.html)
具有未指定的迭代顺序,用户可能意外地依赖它。谷歌的工程师通过
修改他们的 JDK 以随机化哈希迭代顺序
(https://eaftan.github.io/hash-ordering/)
将这一关注点移入了带内。用户会观察到顺序在代码运行之间变化,从而无法在不知情的情况下依赖它。Go 语言对其映射也采取了同样的措施,从
Go 1 开始随机化迭代顺序
(https://go.dev/doc/go1#iteration)。3 (https://tomeraberba.ch/your-interface-has-two-channels#fn-3)
## UI permalink (https://tomeraberba.ch/your-interface-has-two-channels#ui)
UI
关注点信号化也适用于 UI。Slack 的线程功能是带外的。当频道中有一条新的顶级消息到达时,UI 不会强迫你在发送另一条顶级消息和在线程中回复之间做出决定。阻力最小的路径是始终可用的顶级文本输入框。结果是,用户一直在顶级意外地回复。
Google Chat 的线程功能*曾经*是带内的,直到谷歌“升级”
(https://workspaceupdates.googleblog.com/2023/05/google-chat-upgrading-conversations-grouped-by-topic-to-inline-threading.html)
为 Slack 的方式。原来的 UI 强迫你决定是点击按钮开始一个新线程,还是在现有线程的文本输入框中回复。没有始终可用的顶级文本输入框。在原来的设计中,我从未见过用户意外地开始一个新线程。
Google Chat 的原始设计,按对话主题分组:Google Chat 空间按对话主题分组
以及它与 Slack 内联线程匹配的新设计:Google Chat 内联线程空间
## 信号化原则 permalink (https://tomeraberba.ch/your-interface-has-two-channels#signaling-principles)
信号化原则
到了这里你可能认为每个关注点都应该是带内的,但这是不可行的。一个接口有多个关注点,它们并非同等相关或同等重要。在带内和带外信号化之间选择与其说是科学,不如说是艺术4 (https://tomeraberba.ch/your-interface-has-two-channels#fn-4),但有几个原则可以提供帮助:
- **合理的默认值**: 如果一个默认值适用于绝大多数情况,那么该关注点应该被设为带外。示例:
- JavaScript 的 `Array.prototype.indexOf(searchElement, fromIndex)`
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf)
函数在 `fromIndex` 未指定时默认从第一个索引开始搜索。这几乎总是用户想要的。
- 一个自动分页处理分页端点的函数可以默认选择一个合理的页面大小。这可能不是所有用例中最具性能的选择,但结果总是正确的。
- **安全的默认值**: 涉及数据完整性、隐私或其他安全问题的关注点必须为带内,除非默认值是无破坏性、保护隐私且安全的。示例:Python 的 `open`
(https://docs.python.org/3/library/functions.html#open)
函数默认为读取模式,这是安全的,因为它不能破坏数据。
- **可操作性**: 只有当用户通常能在面对点上做出有意义的响应时,一个关注点才应是带内的。强行施加关注点只会产生猜测或条件反射。示例:
- 数据库查询超时最好在观察生产行为后进行调优。强迫程序员预先猜测只会增加噪音,而非安全性。
- Android 原本要求用户在安装时批准应用的整个权限列表,那时用户没有评估它的上下文,因此他们只是条件反射地点击通过。
Android 6.0 将提示移到了运行时
(https://developer.android.com/training/permissions/requesting),
在应用需要每个权限的时刻面对用户,此时他们才能做出有意义的响应。
- **自我揭示性**: 如果一个关注点只在用户自然会发现的场景中才有意义,那么它应该被设为带外。示例:
- HTTP 客户端默认跟随重定向。如果重定向是一个问题,程序员会注意到并禁用它。当关注点变得相关时,它会自我显现。
- PostgreSQL 删除时的默认外键动作为 `NO ACTION`,导致删除被引用的行失败。如果这种行为后来被发现是错误的,程序员会注意到并指定正确的动作。
- **受众期望**: 接口使用者自行选择进入的严谨程度可以判断是将关注点设为带内还是带外。示例:
- 如果你正在使用一个互斥锁库,说明你已经选择了关心正确性边缘情况。Rust 从 `mutex.lock()`
(https://doc.rust-lang.org/std/sync/struct.Mutex.html#method.lock)
返回 `Result` 以带内方式暴露毒化问题,对于这个受众来说是合适的。
- 像 Python 这样的高级脚本语言默认使用带缓冲 I/O 而不要求用户指定缓冲区大小,对于这个受众来说是合适的。大多数 Python 用户不关心 I/O 性能
相似文章
设计工程杂志
这是 Interfaces 的推广页面,Interfaces 是一本订阅制的设计工程杂志,提供交互式演示、源代码、精选资源以及私有社区,强调在人工智能时代工艺与品质的重要性。
别再试图用工程方法逃避倾听用户
一篇论述软件工程师和产品设计师常通过过度设计框架与系统来逃避真正倾听用户的文章,同时列出七种妨碍有效倾听用户与利益相关者的常见陷阱。
对于同时公开 MCP 和 CLI 的情况,这两种工具/命令是否应该暴露完全相同的功能?
作者讨论了同时设计 MCP 和 CLI 接口时的架构挑战,权衡了功能镜像化与利用各自独特优势(CLI 的可组合性,MCP 的安全性和可审计性)之间的利弊。
倾听的幻象
本文分析了“倾听的幻象”这一心理现象:用户因语言线索而感知到AI具有共情能力,尽管AI并不具备真正的理解力。文章提出了设计指南,以确保透明度并防止用户将人际连接的需求外包给自动化系统。
交互式评估需要设计科学
本立场论文认为,交互式AI评估应被视为一种设计科学范式,提出了用于通过轨迹评估动态系统行为的双轴分类法和报告标准。