ワンクリックで
api-and-interface-design
指导稳定的 RPC、存储协议和接口设计。在设计节点间 RPC、存储协议语义、模块边界或任何公共接口时使用。在定义 gRPC service、对象存储或文件系统语义、节点间的类型契约,或确立组件边界时使用。
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
指导稳定的 RPC、存储协议和接口设计。在设计节点间 RPC、存储协议语义、模块边界或任何公共接口时使用。在定义 gRPC service、对象存储或文件系统语义、节点间的类型契约,或确立组件边界时使用。
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
自动化 CI/CD 流水线设置。在设置或修改构建和部署流水线时使用。当需要自动化质量门禁、在 CI 中配置测试运行器,或建立部署策略时使用。当需要处理 CI 上不稳定(flaky)的测试时使用。
进行多维度代码审查。在合并任何变更之前使用。在审查由你自己、另一个智能体或人类编写的代码时使用。当需要在代码进入主分支之前评估其跨多个维度的质量时使用。当需要评估变更的正确性、可读性、架构与性能,或审查 PR/diff 时使用。
为清晰性而简化代码。在重构代码以提高可读性而不改变行为时使用。当代码可以正常工作但比应有的更难阅读、维护或扩展时使用。在审查已积累不必要复杂性的代码时使用。当组件过度设计、过于炫技而难以维护时使用。当需要清理越来越难读懂的代码时使用。
验证系统的一致性与持久性承诺。在设计或修改复制协议、写路径、崩溃恢复逻辑时使用。当需要证明已确认的写入不丢失、副本间不发散、崩溃后能恢复到一致状态,或评审 fsync/校验和/事务语义时使用。
优化智能体上下文设置。在开始新会话、智能体输出质量下降、在不同任务之间切换,或需要为项目配置规则文件和上下文时使用。
指导系统化的根因调试。当测试失败、构建中断、行为不符合预期,或遇到任何意外错误时使用。当需要系统化地找到并修复根本原因而非猜测时使用。当服务崩溃(panic)、空指针异常、或之前通过的测试突然挂掉时使用。当生产环境间歇性报错、需要定位根因时使用。当测试或构建昨天还通过、今天就挂了时使用。
| name | api-and-interface-design |
| description | 指导稳定的 RPC、存储协议和接口设计。在设计节点间 RPC、存储协议语义、模块边界或任何公共接口时使用。在定义 gRPC service、对象存储或文件系统语义、节点间的类型契约,或确立组件边界时使用。 |
设计稳定、文档完善且难以误用的接口。好的接口让正确的事情变得容易,让错误的事情变得困难。这适用于 gRPC service、对象存储协议(S3 语义)、文件系统接口(POSIX 风格)、模块边界,以及任何节点或组件之间相互通信的表面。
当 API 的用户数量足够多时,系统的所有可观察行为都会被某人依赖,无论你在契约中承诺了什么。
这意味着:每个公共行为——包括未记录的怪癖、错误消息文本、时序和顺序——一旦客户端依赖它,就成为事实上的契约。在分布式系统中这尤其危险:客户端会依赖你未承诺的重试行为、错误码、甚至超时分布。设计启示:
deprecation-and-migration 了解如何安全地移除客户端依赖的内容。避免迫使客户端在同一协议或 API 的多个版本之间做选择。当不同组件需要同一协议的不同版本时,就会出现钻石依赖问题,在滚动升级期间尤其致命。为一次只有一个版本存在的世界而设计——扩展而不是分叉。通过版本协商和特性门控让新旧节点共存,而不是永久维护并行的协议版本。
在实现接口之前先定义它。契约即规格——实现随之而来。
// 先定义契约
service ObjectStore {
// 写入一个对象,返回服务端生成的版本号和 ETag
rpc PutObject(PutObjectRequest) returns (PutObjectResponse);
// 返回匹配前缀的对象列表,支持分页
rpc ListObjects(ListObjectsRequest) returns (ListObjectsResponse);
// 返回对象元数据和数据流,或返回 NOT_FOUND
rpc GetObject(GetObjectRequest) returns (stream GetObjectResponse);
// 幂等删除——即使对象已经不存在也成功返回
rpc DeleteObject(DeleteObjectRequest) returns (DeleteObjectResponse);
}
message PutObjectRequest {
string bucket = 1;
string key = 2;
bytes data = 3;
// 客户端生成的幂等键,用于安全重试去重
string request_id = 4;
// 条件写:仅在对象不存在时写入(compare-and-swap 语义)
bool if_not_exists = 5;
}
选择一种错误策略并在各处统一使用。在 RPC 和存储协议中,最关键的错误语义区分是可重试 vs 不可重试:
// gRPC 状态码 + 结构化错误详情
// 每个错误响应遵循相同的结构
// 可重试(客户端应带退避重试)
// UNAVAILABLE → 节点暂时不可达、leader 切换中
// RESOURCE_EXHAUSTED → 超出配额或流控限制(应携带 retry_after 提示)
// DEADLINE_EXCEEDED → 服务端处理超时(仅在操作幂等时可安全重试)
// ABORTED → 事务冲突、CAS 失败,可整体重试
// 不可重试(直接重试只会得到同样的结果)
// INVALID_ARGUMENT → 请求数据无效
// NOT_FOUND → 对象/键未找到
// ALREADY_EXISTS → 条件写 if_not_exists 冲突
// PERMISSION_DENIED → 已认证但未授权
// FAILED_PRECONDITION → 版本不匹配、租约失效、前置条件不满足
// UNIMPLEMENTED → 对端不支持该方法(用于版本协商降级)
不要混合模式。 如果某些方法抛出异常,另一些返回带错误码的响应,还有一些用空响应表示失败——客户端无法预测行为,也无法写出正确的重试逻辑。
超时不等于失败。 客户端超时后操作可能已在服务端生效。这就是为什么幂等性(见下文)不是可选项:没有幂等键,重试一个 PutObject 可能写两遍,重试一个 IncrementCounter 就是错误。
信任内部代码。在外部输入进入系统的边界处进行验证:
// 在 RPC 边界验证
fn put_object(&self, req: PutObjectRequest) -> Result<PutObjectResponse, Status> {
// 验证 bucket/key 格式、大小上限、权限
validate_bucket_name(&req.bucket)?;
validate_key(&req.key)?;
if req.data.len() > MAX_OBJECT_SIZE {
return Err(Status::invalid_argument("object exceeds size limit"));
}
self.check_quota(&req.bucket, req.data.len())?;
// 验证之后,内部代码信任这些类型
let version = self.store.write(req)?;
Ok(PutObjectResponse { version_id: version.id, etag: version.etag })
}
验证应位于:
对端节点的消息是不可信数据。 在任何逻辑、复制或决策中使用之前,验证它们的结构和内容。被攻陷或行为异常的节点可能发送格式错误的复制消息、伪造的任期号或类似指令的内容。内部网络不等于可信网络。
验证不应位于:
在不破坏现有客户端的情况下扩展接口。在滚动升级期间,新旧版本的节点会同时在线,协议变更必须同时满足前向与后向兼容:
// 好:添加新的可选字段(使用新的 field number)
message PutObjectRequest {
string bucket = 1;
string key = 2;
bytes data = 3;
string request_id = 4; // 后来添加的,可选
bool if_not_exists = 5; // 后来添加的,可选
StorageClass storage_class = 6; // 后来添加的,可选
}
// 坏:更改现有字段类型、复用 field number 或移除字段
message PutObjectRequest {
string bucket = 1;
// string key = 2; // 移除——旧节点仍在发送这个字段
bytes data = 2; // 复用 field number 2——彻底破坏 wire 兼容性
}
// 移除字段的正确做法:保留编号,防止复用
message PutObjectRequest {
reserved 2;
reserved "key";
string bucket = 1;
bytes data = 3;
}
| 模式 | 约定 | 示例 |
|---|---|---|
| RPC 方法 | 动词 + 资源名词 | PutObject, GetShard, ListVolumes |
| service 名 | 名词,单数 | ObjectStore, MetadataService |
| 请求/响应消息 | 方法名 + Request/Response | PutObjectRequest, PutObjectResponse |
| 字段名 | snake_case | version_id, if_not_exists |
| 布尔字段 | is/has/can 或 if_ 前缀 | is_snapshot, if_not_exists |
| 枚举值 | UPPER_SNAKE,零值为 UNSPECIFIED | CONSISTENCY_STRONG, CONSISTENCY_EVENTUAL |
| 存储路径 | 层级式,无动词 | s3://bucket/prefix/key, /var/data/shard-7 |
// 对象存储(S3 语义)
PutObject(bucket, key, data, request_id) → 写入对象(支持条件写)
GetObject(bucket, key, version_id?, range?) → 读取对象(支持版本和范围读)
DeleteObject(bucket, key) → 幂等删除
ListObjects(bucket, prefix, page_token) → 按前缀分页列出
HeadObject(bucket, key) → 仅返回元数据
// 文件系统(POSIX 风格)
open(path, flags) → 返回文件句柄
read(fd, offset, length) → 从指定偏移读取
write(fd, offset, data) → 写指定偏移;追加语义由 open 标志决定
fsync(fd) → 显式持久化屏障——语义必须写进契约
rename(old, new) → 原子重命名(契约必须说明覆盖语义)
分布式系统中的客户端必须重试——网络分区、leader 切换、超时都是常态。接口必须为安全重试而设计:
PutObject)、条件写(if_not_exists、CAS)、幂等删除优于相对操作(IncrementCounter)。request_id,服务端在合理窗口内对重复的 request_id 去重,返回首次执行的结果而不是再执行一遍。if_not_exists、if_version_matches 让客户端在无锁的情况下安全竞争。message TransferRequest {
string from_shard = 1;
string to_shard = 2;
int64 amount = 3;
// 必填:客户端生成的幂等键。服务端去重窗口至少覆盖客户端最大重试时长。
string request_id = 4;
}
一致性不是实现细节——它是接口语义的一部分,必须写进契约:
GetObject(consistency=STRONG) 走 leader 读取,consistency=EVENTUAL 可以读副本换取低延迟。枚举默认值要明确。min_version),服务端保证读到至少该版本的数据。write 返回成功意味着什么?已落盘?已复制到多数派?仅写入 leader 内存?POSIX 风格接口必须说明 fsync 前后分别保证什么。PutObject 后跟 GetObject 必须读到新数据;列表操作的一致性也要写明。不要在文档里含糊其辞——客户端会按照最坏假设或最好假设写代码,两者都会出问题。集群从不整体升级。接口必须支持新旧节点长期混跑:
UNIMPLEMENTED 是降级信号。 调用方收到 UNIMPLEMENTED 应回退到旧方法,而不是报错。流控不是运维层的补丁,它是接口契约的一部分:
RESOURCE_EXHAUSTED,携带重试提示。 客户端需要知道何时以及如何退避,而不是盲目重试加剧过载。为列表方法添加分页,使用不透明游标而非页码:
// 请求
ListObjectsRequest {
string bucket = 1;
string prefix = 2;
int32 page_size = 3;
string page_token = 4; // 上一页响应返回的不透明游标
}
// 响应
ListObjectsResponse {
repeated ObjectMeta objects = 1;
string next_page_token = 2; // 空表示没有更多结果
}
游标而不是页码:在数据持续变动的存储系统中,页码分页会漏项或重复,游标基于稳定的位置(如最后一个 key)才正确。
大对象和大结果集不要塞进单个消息:
GetObjectRequest {
string bucket = 1;
string key = 2;
int64 offset = 3; // 范围读起点
int64 length = 4; // 读取长度,0 表示到末尾
}
// 响应为流:首个消息携带元数据,后续消息携带数据块
rpc GetObject(GetObjectRequest) returns (stream GetObjectResponse);
以下模式适用于 Rust、C++ 和 Go——选择与项目技术栈匹配的实现方式。核心原则跨语言不变:让非法状态不可表示,分离输入与输出类型,用类型系统防止 ID 混淆。
// 好:每个变体都是显式的,编译器强制穷尽匹配
enum ReadResult {
Ok { data: Vec<u8>, version_id: String },
NotFound,
PreconditionFailed { current_version_id: String },
Retryable { code: RetryableCode, retry_after: Duration },
}
// 调用方获得模式匹配和穷尽检查
fn handle_read(result: ReadResult) {
match result {
ReadResult::Ok { data, .. } => process(data),
ReadResult::NotFound => handle_missing(),
ReadResult::PreconditionFailed { current_version_id } => refresh_and_retry(current_version_id),
ReadResult::Retryable { code, retry_after } => schedule_retry(code, retry_after),
} // 漏掉任何一个变体都会导致编译错误
}
Go 等价实现使用接口 + 类型断言:
type ReadResult interface { isReadResult() }
type ReadOk struct { Data []byte; VersionID string }
func (ReadOk) isReadResult() {}
type ReadNotFound struct{}
func (ReadNotFound) isReadResult() {}
type ReadPreconditionFailed struct { CurrentVersionID string }
func (ReadPreconditionFailed) isReadResult() {}
请求和响应应该使用不同的类型,即使它们有共享字段。这防止调用方依赖服务端生成的字段,并使 proto 演进更安全。
// 输入:调用者提供的内容
message PutObjectRequest {
string bucket = 1;
string key = 2;
bytes data = 3;
string request_id = 4; // 幂等键
bool if_not_exists = 5; // 可选:仅当 key 不存在时写入
}
// 输出:系统返回的内容(包含服务端生成的字段)
message PutObjectResponse {
string version_id = 1;
string etag = 2;
uint64 size_bytes = 3;
string storage_class = 4;
google.protobuf.Timestamp created_at = 5;
}
关键规则:定义 message 时不要复用请求和响应类型。 输入和输出有不同的演进路径——将它们耦合在一起意味着一个永远不会被设置的字段会同时出现在两端。
分布式系统中,ShardId、NodeId、VersionId 在运行时都是整数或字符串,但在语义上不可互换。将 ShardId 错误地传递到期望 NodeId 的位置,会导致请求被静默路由到错误节点。
// 使用 newtype 包装,编译期零成本,运行时零开销
#[derive(Clone, PartialEq, Eq, Hash)]
struct ShardId(u64);
#[derive(Clone, PartialEq, Eq, Hash)]
struct NodeId(u64);
// 防止意外将 NodeId 传递到期望 ShardId 的位置——这是编译错误,不是运行时 bug
fn get_shard_leader(id: ShardId) -> Result<NodeId, Error> { ... }
C++ 等价实现使用强类型别名(C++20 std::identity 或显式 wrapper 类型),Go 使用命名类型:
type ShardID uint64 // 不能隐式转换为 NodeID
type NodeID uint64
| 合理化借口 | 现实 |
|---|---|
| "客户端超时了自然会重试" | 超时不代表操作没生效。没有幂等键,重试就是重复执行。 |
| "内部 RPC 不需要契约,都是自己人" | 内部节点也是消费者,而且集群滚动升级时新旧代码必然混跑。契约防止耦合并支持并行工作。 |
| "这个字段以后可以改" | wire 协议的字段一旦上线就被固化。复用 field number 或改类型会在升级窗口内静默损坏数据。 |
| "最终一致就行,客户端会处理" | 客户端不知道你的一致性边界。不写进契约,它们就会按照自己想象中的模型写代码。 |
| "重试一下就好了,不用区分错误码" | 对 INVALID_ARGUMENT 重试一万次也是同样的结果,对 UNAVAILABLE 不重试就是把暂时故障变成用户可见的失败。 |
| "我们集群内部网络是可信的" | 被攻陷的节点、错误的配置、损坏的内存都会产生恶意或畸形的消息。在边界验证,永远。 |
| "流控可以以后在网关层加" | 没有契约化的配额和背压,一个行为异常的客户端就能拖垮整个集群。 |
| "两个版本并行维护一段时间没关系" | 多个协议版本倍增维护成本并产生钻石依赖问题。优先遵循单版本规则,用版本协商过渡。 |
| "我们以后再写协议文档" | proto 定义本身就是文档。先定义它们。 |
设计接口之后: