引言

一个链上 SDK 的好坏,很少取决于它提供了多少 API。
真正决定体验的,是用户在使用过程中:

  • 是否需要反复理解底层协议细节
  • 是否经常因为默认行为不明确而踩坑
  • 是否在升级时对行为变化毫无心理预期

在实际工程中,许多 SDK 的问题并不来自链本身,而是来自 SDK 只是对底层接口的简单封装,却没有承担应有的“复杂度吸收”责任。

本文不是一篇教你“如何调用合约”的教程,而是写给 设计和维护链上 SDK 的工程师 的工程实践总结。

我们关注的问题包括:

  • 哪些复杂度应该被 SDK 吸收,哪些应该暴露给用户?
  • 如何设计 API,让“正确用法”成为默认路径?
  • 如何在协议与业务不断演进的情况下,控制破坏性改动的成本?
  • 命名、模块、默认值、性能、测试、文档,如何作为一个整体协同工作?

全文将刻意从工程视角出发,而非语法或风格偏好。
每一条原则,都对应一个现实问题:误用风险、维护成本或长期稳定性。

如果你正在构建一个会被他人长期依赖的链上 SDK,那么抽象质量,而不是功能数量,才是决定成败的关键。

1. 先从“懂业务”开始:业务理解决定 SDK 的上限

写 SDK 的目标从来不是“把底层接口包一层”,而是将某个领域(domain)的复杂性,以尽可能低的认知成本交付给使用者。

更工程化地说:SDK 的核心价值在于重新分配复杂度——
将原本分散在每个调用方工程中的复杂度,集中到 SDK 内部,并将其转化为:

  • 稳定的抽象
  • 可演进的 API
  • 可诊断的错误
  • 可被类型系统与执行路径约束的正确用法

如果缺乏业务理解,SDK 往往只能退化为薄封装(thin wrapper):
接口数量不少,但调用方式脆弱、错误难以定位、升级频繁 breaking,长期维护成本随使用规模线性甚至指数级上升。

下面从工程结果出发,说明为什么“懂业务”直接决定 SDK 是否优秀。

1.1 业务理解的本质:决定复杂度应该落在哪里

SDK 面对的是开发者。开发者的痛点通常不是“没有能力调用接口”,而是:

  • 不知道应该调用哪些接口才能完成业务动作;
  • 不清楚调用顺序与依赖关系(workflow);
  • 不理解参数的业务语义与约束(单位、范围、前置条件、默认值);
  • 不知道失败的原因与修复方式;
  • 被迫为性能付出成本:过多 RPC、过多签名、过多交互。

因此,SDK 工程师面对的并不是“怎么暴露接口”,而是如何拆解和安放复杂度:

  1. 领域复杂度(Domain complexity)
    业务概念、状态机、约束关系(例如合约状态、权限、资金流、边界条件)。
  2. 流程复杂度(Workflow complexity)
    完成一个业务动作所需的步骤组合与顺序(例如多合约交互、读写混合、需要签名与等待确认)。
  3. 故障复杂度(Failure modes)
    失败类型、可恢复性、可诊断性(例如业务状态不满足、参数越界、链上 revert等)。

业务理解的意义在于:你只有理解上述复杂度,才能判断哪些应该暴露给用户,哪些应该在 SDK 中默认、封装或自动推导。

1.2 业务理解如何影响 API 设计质量(从“接口”到“业务动作”)

优秀 SDK 的 API 不是“底层接口的镜像”,而是对业务动作施加约束的执行入口。
API 设计的目标并不是“覆盖所有可能的调用方式”,而是通过 API 的形状本身,限制用户只能以业务上正确、可预期的方式完成目标。

这也是薄封装与领域抽象的根本分界线:

  • 薄封装:
    暴露大量低层方法,SDK 只负责转发调用,流程与正确性完全由用户工程承担。
  • 领域抽象:
    对外提供少量稳定的“动作级 API”,由 SDK 内部负责编排流程、校验前置条件,并在失败时给出业务语义明确的反馈。

是否具备业务理解,直接决定你能否完成下面几项关键设计决策。

业务理解决定你能否完成以下关键设计:

1.2.1 决定“必须暴露”与“应该默认”的边界

从 SDK 设计角度看,“必须暴露”与“应该默认”的划分,本质上是责任归属的划分:

  • 哪些风险必须由调用方显式承担;

  • 哪些风险应由 SDK 吸收并通过默认值、推导或校验来规避。
    你需要区分:

  • 用户必须决策的信息(必须暴露,否则会误用)
    例如风险参数、不可逆选择、影响资金安全的配置。

  • SDK 可以推导/默认的信息(应该隐藏或给合理默认)
    例如某些 max/min、timeout、可通过链上查询得到的对象信息。

缺乏业务理解时,SDK 往往会退化为“参数堆叠”:
为了避免判断责任边界,选择把所有字段都暴露出来,把复杂度原样转嫁给用户工程。

1.2.2 从“接口集合”到“业务动作”的 API 形状设计

当用户想完成某个目标时,更希望调用的是:

  • registerUser(...)createPosition(...)executeStrategy(...)

而不是一串低层步骤的手动拼装:

  • callContractA(...)callContractB(...)encode(...)sign(...)send(...)parseEvent(...)

是否是“动作级 API”,并不取决于它内部调用了多少接口,而取决于三个工程标准:

  • 是否对 workflow 的完整性负责,SDK 是否保证步骤顺序正确、前置条件满足。
  • 是否在失败时返回与业务状态相关的错误而不是仅仅转发底层异常。
  • 是否保证调用完成后,系统处于一个业务上可理解的状态,而不是“部分成功但状态不明”。

当 SDK 把这些步骤全部暴露给用户时,实际上是在要求每一个调用方都重新实现一次业务流程控制与错误处理。
在工程实践中,这几乎必然导致行为不一致、隐性 bug 以及难以维护的用户代码。当然,动作级 API 并非在所有场景下都是最优解。
对需要极端灵活性或研究性使用的场景,SDK 可以通过 advanced / raw 命名空间暴露低层接口,但它们应被明确标记为“逃生通道”,而非默认路径。

1.3 业务理解如何减少 breaking change(抽象在稳定边界上)

SDK 的长期维护中,breaking change 是成本最大的事件之一。要减少 breaking,关键在于把 public API 建立在稳定边界上。

  • 稳定边界:业务实体与业务动作(domain object + domain action)
    例如用户、订单、仓位、注册、兑换、提现。
  • 不稳定边界:实现细节
    合约版本变化、字段变化、RPC provider 行为差异、临时 workaround 等。

业务理解强的人更容易识别哪些变化是“实现细节”,从而将变化隔离在 SDK 内部(internal adapter layer)。业务理解弱时,常见问题是把不稳定细节直接暴露为 public API,导致每次底层变化都引发用户侧升级成本。

1.4 业务理解如何显著降低使用成本(helpers / 默认值 / ID-based API)

SDK 的目标不是“可调用”,而是“好用”。“好用”的核心指标之一是:用户需要填写的信息是否尽可能少且不容易出错。

业务理解直接驱动三类典型能力:

1.4.1 Helper / Builder:减少用户填写字段

当某个对象参数字段很多时,如果你理解哪些字段是“业务必需”,就能提供:

  • createXxxParams({ requiredFields }) 自动补全默认值与推导字段

并在 SDK 内部复用,降低重复逻辑与维护成本。

1.4.2 ID-based API:让用户传 “id” 而不是完整对象

如果对象可由链上或后端查询获得,且用户通常只持有 id,就应支持:

  • doSomethingById(id)

由 SDK 负责 resolve/lookup,再执行后续流程。这类设计往往能显著降低用户工程复杂度,也减少错误输入的概率。

1.4.3 合理默认值:把“懂业务的人才知道”的配置沉淀为默认

例如 maxXX/minXXtimeoutslippage 等参数,若有行业/业务上合理的默认,应当提供默认并允许覆盖,而不是强迫用户每次都填。

1.5 业务理解如何提升错误可诊断性(actionable errors)

不懂业务时,错误处理容易停留在:

  • revert
  • unknown error
  • invalid params

但对 SDK 用户而言,真正有价值的是“可行动的错误”(actionable):

  • 为什么失败(业务原因 / 前置条件不满足 / 状态不允许)
  • 如何修复(提供具体建议或下一步)
  • 当前处于何种业务状态(例如已注册/未注册、余额不足、权限不足等)

要做到这一点,你必须理解业务状态机与约束关系,否则只能转发底层错误,无法形成对用户友好的诊断信息。

1.6 业务理解如何指导性能优化(懒加载 / multicall / 少签名)

在合约类 SDK 中,性能往往等价于:

  • 更少的 RPC 调用(稳定性与速度)
  • 更少的签名次数(交互成本)

业务理解决定你能否识别:

  • 哪些数据在同一 workflow 中必然会被读取(适合 multicall 聚合读取)
  • 哪些步骤可以合并、哪些必须拆分(决定签名次数与交易数量)
  • 哪些是用户的主路径(把优化投入在最高频路径上)

没有业务理解的性能优化容易沦为“技术上正确但不解决核心痛点”。

1.7 结论:业务理解 = SDK 抽象能力的来源

可以用一个工程化的结论来收束本节:

  • 不懂业务:SDK 更像“接口转发器”,把复杂度交还给用户。
  • 懂业务:SDK 才能形成稳定抽象,提供默认与辅助能力,减少 breaking changes,并把错误与性能优化做得对用户真正有意义。

换句话说:业务理解不是前置知识,而是 SDK 抽象、稳定性与体验的直接输入。

为了把这种“抽象能力”落到可执行的工程实践,下面两章会从两个维度展开:

  • 命名:让 API 在用户的 IDE 里“自解释”,降低误用与沟通成本。
  • 模块边界:让变化被锁在内部,减少 breaking change 的传播范围。

2. 好的命名:方法名、参数名要表意,不要抽象到“看不懂”

命名不是“代码风格问题”,而是 SDK 设计问题。因为对 SDK 用户而言,命名就是 API 的主要交互界面:用户阅读到的不是你的实现,而是方法名、参数名、类型名、返回值字段名,以及它们在IDE里的提示信息。

一个命名体系是否优秀,直接决定:

  • 用户能否在不看源码/不翻文档的情况下正确使用;
  • 用户是否会误用(misuse)或用错(bug)。

这章的核心观点是:SDK 的命名应该承载业务语义与约束,并通过一致的规则,把“正确用法”变成最自然的用法。

2.1 命名的目标:让“误用变难,正确变简单”

SDK 的命名应当达到三个工程化目标:

  1. 可发现(Discoverable):用户通过自动补全能找到正确的入口。
  2. 可推断(Inferable):用户看到签名就能推断参数含义与调用结果。
  3. 可演进(Evolvable):后续扩展不会迫使你推翻旧 API 或引入大量 breaking change。

如果命名不承载语义,就会把成本转移到用户侧:

  • 用户需要记忆/查文档才能使用;
  • 用户需要不断试错;
  • 出错时也难定位(因为参数没有意义、错误信息也不清晰)。

2.2 方法命名:以“业务动作”命名,而不是“实现方式”命名

SDK 的 public 方法名应当优先表达 what(做什么)和 which(作用对象),而非 how(怎么实现)。

2.2.1 推荐结构:动词 + 业务对象 + 必要限定词

  • createOrder / cancelOrder / getOrder
  • registerUser / getUser
  • estimateGas / simulateTransaction / sendTransaction

限定词用于把差异讲清楚,避免“一个词装下所有语义”:

  • getX(单个) vs listX(集合)
  • previewX/estimateX(不会产生链上状态变更) vs executeX/submitX(会产生变更)
  • resolveX(把 id/alias 映射成完整对象) vs fetchX(远程获取)

2.2.2 避免“技术味”动词作为 public API

以下词通常语义不足,适合 internal,不适合 public:

  • handle / process / execute / do / manage / perform

问题不在于这些词“不能用”,而在于它们通常缺少业务约束信息:用户无法从名字推断到底做了哪些事、有没有副作用、会不会签名、会不会发交易。

2.3 参数命名:让参数名携带“业务语义 + 约束信息”

参数命名的底线是“表意”,但 SDK 需要更进一步:让参数名显式表达约束,从而减少误用。

2.3.1 用“领域词汇”替代泛化名词

避免:

  • dataobjparamspayload(除非确实是通用载荷)
  • options(当它包含关键业务决策项时)

倾向于:

  • userIdorderIdpositionId
  • recipientAddress
  • contractConfigfeeConfig
  • registrationParams(比 params 更好)

一个可操作的规则:

如果一个参数名离开上下文就看不懂,那它不适合作为 public API。

2.3.2 参数名必须表达单位、范围或语义维度

在金融/区块链场景尤其关键。推荐把单位写进名字,而不是靠注释补救:

  • timeoutMs(不是 timeout
  • amountWei / amountLamports / amountAtoms
  • gasLimitgasPriceGwei
  • slippageBps(bps 比例单位明确)

这类命名的价值在于:它把“隐性约束”变成“显性接口”,能在 code review 和 IDE 里被即时发现。

2.4 类型/返回值命名:把“使用者关心的结果”命名出来

SDK 的返回值往往比入参更影响体验。常见反模式是返回一个“无结构的大对象”,用户只能靠猜。

2.4.1 返回值结构化 + 命名对齐用户心智

例如把结果拆为:

  • transactionHashreceiptevents
  • requestresult
  • valuemetadata

并避免让用户反复写 result[0] 之类的“仪式性代码”。如果确实存在单个/多个的差异,建议拆分 API(如单参/多参两个函数)。

2.5 一致性:命名体系必须“可预测”,否则越写越乱

SDK 是长期演进的公共接口。命名不一致会产生“二次学习成本”,比任何单点命名错误都更致命。

建议在团队内明确一个命名规则集(可以写成短文档),例如:

  • getX:读取单个(不产生副作用)
  • listX:读取集合(支持分页/过滤)
  • createX:创建资源(可能产生链上状态变更)
  • buildX:纯构造(不 IO)
  • resolveX:把 id/alias 转为实体
  • estimateX/previewX:纯预测(不交易)
  • submitX/sendX:发起交易(需要签名)
  • waitForX:等待确认/状态推进

只要规则稳定,用户就能通过名字推断行为,这会显著降低支持成本与误用率。

2.6 可演进性:命名要为未来留空间(尤其避免“过度承诺”)

SDK 的命名有一个容易忽略的点:名字本身是契约。命名过于具体,未来很难扩展而不 breaking。

2.6.1 过度具体的风险

例如把实现策略写进名字:

  • fetchFromRpcXreadFromContractX

未来如果引入缓存/多数据源,名字就不再准确。

更推荐表达语义而不是来源:

  • fetchX(强调 IO)
  • getX(强调读取)
  • resolveX(强调映射/补全)

2.6.2 Options 的命名策略

options 很常见,但要分两种:

  • 行为开关(可选、非关键):可以放在 options
  • 业务决策(关键、影响安全或结果):应该提升为显式参数或拆成不同方法

否则用户会在 options 里错过关键配置,导致误用。

3. 模块化与隔离:控制变化传播半径,避免 SDK 结构性退化

模块化并不是“把文件拆小”,而是一种变化管理手段。
在 SDK 这种长期演进的软件中,模块设计直接决定了三项关键工程指标:

  • 修改半径:新增功能或修复缺陷时,需要触碰多少已有代码;
  • 重构成本:实现调整或性能优化时,是否能在不影响 public API 的情况下完成;
  • 破坏概率:一次变化引入 breaking change 的可能性有多大。

当模块边界设计失当时,SDK 会出现一种典型退化路径:
public API 不断膨胀、内部依赖交错、组合逻辑散落各处,最终任何一次修改都会牵一发而动全身。

这一章关注的不是“抽象是否正确”,而是:
当变化不可避免地发生时,你是否有结构性的手段把它们锁在内部。

3.1 模块化的核心目标:限制变化的传播路径

SDK 的变化来源非常明确:

  • 合约升级、协议调整
  • 新网络或新环境支持
  • 性能优化(multicall、缓存、并发策略)
  • 依赖库或基础设施的升级

模块化的核心目标不是让结构“更优雅”,而是回答一个工程问题:

当上述任意一种变化发生时,它最多能影响到哪些模块?
一个健康的模块结构,应该具备以下性质:

  • 外层模块对变化不敏感
  • 内层模块允许频繁替换
  • 变化沿着单向、可预期的路径传播,而不是在系统中横向扩散

如果一次合约字段调整,需要同时修改业务 API、workflow 逻辑和测试用例,说明变化已经穿透了模块边界。

3.2 Single Responsibility 在 SDK 中的真实含义:对“变化类型”负责

在 SDK 语境下,Single Responsibility 不应被理解为“一个类只做一件事”,
而应被理解为:

一个模块只对一种“变化类型”负责。
这是判断模块边界是否合理的更实用标准

一种常见且有效的分层方式,是按“变化敏感度”而非功能拆分(由外到内):

  1. Domain Layer(领域层)
    面向业务语义与动作,对底层实现变化最不敏感。
    这一层应尽可能稳定,是 public API 的主要承载者。

  2. Workflow / Orchestration Layer(编排层)
    对组合策略变化敏感,但不直接暴露底层细节。
    允许重构流程,而不影响领域层 API 语义。

  3. Adapter Layer(适配层)
    对外部依赖变化最敏感,例如合约版本、RPC 行为、编码方式、multicall 实现。
    这一层的职责是吸收不稳定性。

  4. Pure Utilities(纯工具层)
    对变化不敏感,通常是纯函数或类型工具。
    高复用、易测试、可独立演进。

当变化发生时,你应该能明确回答:
它属于哪一类变化?因此只需要修改哪一层?

3.3 组合能力的结构性风险:当 workflow 开始侵蚀核心模块

许多 SDK 在早期按业务拆模块是正确的,但随着功能增长,容易出现一种结构性退化:

为了“方便使用”,不断把组合方法塞进核心模块。

3.3.1 典型失控形态:组合方法爆炸

例如同一模块内出现:

  • a
  • aAndB
  • aAndBAndC

典型表现包括:

  • 同一模块中同时存在原子方法与多层组合方法
  • API 数量随组合复杂度线性甚至指数级增长
  • workflow 逻辑与领域语义混杂在一起

这类结构性问题的风险并不在于“代码多”,而在于:

  • 修改半径扩大:重构一个流程,需要同步调整多个 public API
  • 职责模糊:无法判断某段逻辑属于业务语义还是组合策略
  • 演进受阻:任何流程调整都可能引发 breaking change

3.3.2 更稳健的做法:让组合层成为“变化缓冲区”

更可持续的结构是:

  • 核心模块只保留稳定、原子、可组合的能力
  • workflow / orchestration 逻辑集中在独立层
  • public API 只暴露少量高价值组合入口
  • 其余组合保持 internal,可随实现自由调整

这种结构的工程收益是明确的:

  • 对用户:有清晰、有限的入口,不需要理解所有组合可能
  • 对维护者:流程重构主要发生在组合层,核心模块保持稳定
  • 对演进:新策略、新优化可以通过替换组合层实现,而不必破坏既有 API

从变化管理的角度看,组合层的角色不是“多做事”,而是吸收变化。

3.4 为什么模块化能减少 breaking change:把变化锁在内部

第 1 章解释了为什么 breaking change 的成本高,这里给出对应的结构性做法:通过边界设计把不稳定细节封装在内部。

breaking change 的根源往往是暴露了太多不稳定细节:

  • 暴露底层对象结构(字段变化就 break)
  • 暴露底层调用顺序(workflow 变了就 break)
  • 暴露中间状态与临时策略(状态机/策略调整就 break)

要减少 breaking,本质是:

  1. 对外只暴露稳定抽象
    业务动作、业务对象标识(id)、少量必要配置。
  2. 把不稳定细节内聚
    合约地址/ABI、encode/decode、multicall、缓存、fallback 等放在 internal adapter。
  3. 提供可演进路径
    新能力优先“新增 API”,而不是修改旧 API 行为;必须修改时提供 deprecate 周期与迁移指南。

3.5 推荐的演进策略:先原子,后统一(unify),并预留隔离层

对于复杂 workflow,早期很难一次性抽出“完美抽象”。更稳妥的节奏是:

  • 早期:先把原子能力做对(正确性 + 可测试 + 类型清晰)
  • 中期:当 workflow 全貌清晰后,再抽象 unified/组合能力
  • 全程:组合能力从一开始就放在独立文件/层,避免核心模块膨胀

命名与模块边界解决的是“结构正确”。但 SDK 的成功往往取决于另一件事:你是否持续识别并消除用户在集成过程中的摩擦点。

4. SDK 的价值在于帮用户省事 —— 一种可工程化的觉知

“帮用户省事”并不是一句愿景口号,而是一套可以被持续执行、被复盘、被演进的方法。

从 SDK 设计者的视角看,真正的价值不在于暴露了多少底层能力,而在于:
SDK 是否主动承担了那些本不该由用户反复处理的复杂度。
这里的“觉知(awareness)”指的是一种工程判断标准:

将用户在集成过程中的摩擦视为产品缺陷,并在 API 设计、默认值、错误模型、文档与发布节奏中持续修正这些缺陷。

本章不再重复“理解业务的重要性”。假设你已经完成了正确的业务抽象,本章关注的是:
如何在此基础上,让SDK随着使用不断变得更省事。

4.1 把“用户省事”量化:三类摩擦点

在 SDK 场景中,用户的痛点通常不是“功能缺失”,而是摩擦过高。
这些摩擦可以被稳定地归类为三种:

  1. 认知摩擦(Cognitive Friction)

用户需要理解过多概念、隐含规则或前置条件,例如:

  • 参数之间存在隐式约束,但未显式表达
  • 单位、范围、状态依赖只存在于文档或示例中
  • 正确用法依赖对底层系统的理解

认知摩擦的本质是:SDK 要求用户补齐设计者本应提供的上下文。

  1. 编码摩擦(Implementation Friction)

用户为了完成一个目标,需要写大量与“意图无关”的代码,例如:

  • 拼装字段众多的对象
  • 手动补齐可推导参数
  • 重复的校验、解析、解构逻辑

当用户写的大量代码只是为了“满足 API 形态”,而非表达业务意图时,编码摩擦已经过高。

  1. 交互摩擦(Interaction Friction)

常见于网络或链上场景:

  • 多次 RPC / HTTP 调用
  • 多次签名或确认
  • 可合并却被拆散的流程

在 SDK 视角下,额外的交互轮次本身就是设计成本,而不是中性的实现细节。

做“觉知”不是凭感觉优化,而是每次迭代都能回答:

  • 我消除了哪一类摩擦?
  • 消除的方式是什么?
  • 是否引入了新的摩擦?

4.2 以用户旅程(user journey)驱动 API 形态,而不是以底层能力驱动

一个常见误区是:
直接将底层能力一一映射为 SDK API。

建议用“用户旅程”来审视 SDK:

  • 用户的目标是什么?(完成某个业务动作)
  • 用户手上有什么信息?(往往是 id / address / 少量输入,而不是完整对象)
  • 用户最怕什么?(填错、签太多次、失败无法恢复)

据此设计两条并行路径(覆盖不同成熟度的用户):

*** 最短路径(Default Workflow)***

  • 输入最少
  • 默认值合理
  • 错误清晰、可恢复
  • 性能与交互次数已被优化

这是为多数用户的常见路径而设计的。

*** 可控路径(Advanced Control)***

  • 允许显式传入完整对象或策略
  • 覆盖非标准或高阶使用场景
  • 不强行简化,但保持一致性

关键在于:
最短路径不是“隐藏能力”,而是将最常见的成功路径固化为最低摩擦的 API。

4.3 参数与对象的“省事设计”:让用户表达意图,而不是填实现细节

当你发现用户需要构造一个字段很多的对象时,通常意味着 SDK 把内部复杂度暴露给了用户。改进时可优先使用三种模式:

  1. Helper/Builder(默认补齐)
    • 提供 createXxxParams(input):只要求用户提供表达意图的最小字段,其余字段由 SDK 默认/推导。
  2. ID-based API(用标识替代实体)
    • 提供 doSomethingById(id):SDK 内部 resolve → 校验 → 执行。
  3. 默认值策略(把经验沉淀为默认)
    • min/max/timeout/slippage 等参数提供安全默认,并允许覆盖;默认值要可解释(文档 + 注释 + @default)。

这些模式的评价标准很直接:用户是否能用更少输入完成同样的目标,且错误率更低。

4.4 让错误“可行动”:把失败变成下一步

在 SDK 中,错误信息本身就是 API 的一部分。一个可行动(actionable)的错误至少要包含:

  • 发生了什么(what)
  • 为什么(why:业务约束/状态不满足/输入不合法)
  • 怎么修(how:建议用户做什么,或给出下一步 API)

你可以把“怎么修”标准化为:

  • 建议传哪个参数/改哪个值
  • 建议先调用哪个 get/resolve/validate
  • 提示当前状态与允许的状态迁移

当错误从“记录信息”变成“引导动作”,支持成本会显著下降。

4.5 省事也包含性能(概览)

性能在 SDK 里通常等价于“更少的调用、更少的签名、更少的等待”。这部分细节较多,本文单独在第 5 章展开。

4.6 觉知的闭环:从用户反馈到 API 演进

“觉知”最终要落到闭环能力:

  • 在 issue/支持反馈中识别高频摩擦点(尤其是反复被问到的参数、反复出错的步骤)。
  • 把摩擦点转化为具体改动:新增 helper、提供 id 路径、补默认值、改错误信息、补示例。
  • 演进时优先保持兼容:新增API优于改旧行为;必须改时明确deprecate与迁移路径(配合 changelog)。

5. 性能与交互体验:少 call、少签名、少等待

在链上 SDK 中,“性能”并不等价于计算速度,而是用户完成一次业务动作所付出的真实成本。

对用户来说,慢通常意味着三件事:

  • 需要发起 过多 RPC 调用
  • 需要多次签名 / 多笔交易
  • 在失败或不确定状态中长时间等待且不可恢复

因此,链上 SDK 的性能优化,本质是对交互路径的重构,而不是对单次调用的微调。

5.1 先定义“快”的含义:用户感知指标优先

在链上语境中,“快”应优先用以下指标衡量:

  • 完成一次业务动作需要多少次 RPC read
  • 需要用户签名多少次
  • 最短路径需要发送多少笔交易
  • 在最坏情况下需要等待多久
  • 失败是否可预判、可避免、可恢复

如果 SDK 作者无法明确这些指标,所谓“性能优化”往往只是在局部做无关紧要的改动。

5.2 读优化:聚合读取(multicall / batch)优先

链上SDK的read性能,核心问题不是单次调用慢,而是调用次数过多。
核心原则:以 workflow 为单位组织read,而不是以合约方法为单位
常见反模式是:

  • 每个 API 各自发起 read
  • 用户在一个业务动作中隐式触发多次 RPC
  • SDK内部缺乏对“必读状态集合”的整体视图

更合理的策略是:

  • 明确一个workflow真正需要的最小状态集合
  • 使用multicall将这些读取合并
  • 将聚合逻辑放在adapter层,而非domainAPI

设计要点

  • multicall是默认路径,而不是可选优化
  • 对 multicall失败提供fallback(单次读取)以保证可用性
  • domain层只感知“状态已准备好”,不关心读取方式

multicall 的价值不在于“更快”,而在于让性能可预测。

5.3 写优化:减少签名次数与交易数量

在链上SDK中,用户对“慢”的直觉感受,往往来自签名与确认次数,而非RPC延迟。

常见高成本来源:

  • 为可预校验的问题仍然发送交易(失败后revert)
  • 将一个业务意图拆成多笔必须连续执行的交易
  • 把签名分散在多个API中,导致用户频繁确认

SDK 侧的设计责任

  • 能read校验的,绝不 write,比如allowance、状态前置条件、余额等应在本地或 RPC 侧校验
  • 合并语义一致的操作,如果多笔交易表达的是同一业务意图,应优先合并
  • 集中签名入口,默认 workflow 中,签名步骤应是明确、可预期的“一个时刻”

重要前提:

减少签名不应牺牲安全边界。高风险操作必须保持显式,而不是“自动化”。

5.4 交易等待与状态不确定性:不要把复杂度留给用户

链上交互的一个核心难点是:交易提交后并不等于完成。SDK 如果直接把 txHash 丢给用户,本质上是在转嫁复杂度。
更合理的抽象方向:
SDK 内部处理:

  • 交易发送
  • confirmation 策略
  • 常见失败模式(revert / dropped / timeout)

对外提供:

  • 明确的成功/失败语义
  • 可配置的等待策略(timeout, confirmation,retry等等)

目标不是“隐藏链上不确定性”,而是把不确定性收敛成可理解的状态机

5.5 懒加载(Lazy Initialization):避免为未使用路径付费

链上 SDK 常见的重成本包括:

  • provider / client 初始化
  • ABI / metadata 加载
  • network / chain 信息解析

SDK 的一个现实使用场景是:
被引入,但只使用其中一小部分功能。

懒加载的设计原则

  • 仅在真正进入某条业务路径时才初始化相关依赖
  • 不改变 public API 的同步/异步语义
  • 将初始化逻辑集中在 adapter 或 factory 中,保持可测试性

懒加载的目标不是“极致性能”,而是避免无效工作成为用户的默认成本。

6. Clean Code:让 SDK 的调用路径更清晰、更少误用

“代码整洁”在 SDK 语境下的评价标准和应用代码不同:

  • 应用代码更关注业务迭代速度;
  • SDK 更关注 可读性、可组合性、可误用性(misuse resistance),以及长期演进时的改动风险。

换句话说:Clean Code 不是为了“优雅”,而是为了让 SDK 的调用路径更像一条清晰、可预测的流水线,减少用户与维护者的误解。

6.1 SDK 的 clean code 目标:降低误用概率(misuse resistance)

SDK 的 bug 很多不是实现错误,而是“API 被用错”。因此 clean code 的第一目标是:

  • 让错误用法更难写出来
  • 让正确用法更自然
  • 让异常路径更容易定位

这要求你在代码层面持续做两件事:收敛入口(少而清晰)与 显式语义(少隐含规则)。

6.2 单参 vs 多参:不要让用户到处写 result[0]

如果一个能力既支持单个输入也支持多个输入,并且返回数组,调用方往往会出现大量“取第一个”的逻辑。这会带来两类问题:

  • 读代码的人会疑惑:为什么总取第一个?这是约定还是偶然?
  • 业务语义被隐藏在调用点,导致重复与误用。

更推荐的做法是提供两个清晰的入口:

  • foo(one) → 返回单个结果
  • fooMany(many[]) → 返回多个结果

当确实需要用 TypeScript 泛型表达“单/多”统一签名时,也应尽量让“多数用法”保持最简单(例如通过默认泛型参数),避免让大多数用户承担类型复杂度。

6.3 异步代码:避免无意义的 return await

当一个函数只是把 Promise 结果向上传递时,return await 往往是冗余的。保留 return await 的典型理由只有少数几类:

  • 你要在当前层做 try/catch 并转换错误
  • 你要在当前层做 finally 清理
  • 你明确需要改变异常栈的呈现方式

否则直接 return promise 通常更清晰。

6.4 不要覆盖(override)传入参数:用不可变思维降低副作用

SDK 中副作用的代价更高:一旦用户复用同一个对象作为参数,SDK 在内部修改它,会产生难排查的行为差异。

建议:

  • 不修改输入对象(尤其是 public API 的参数)
  • 需要变换时创建新对象并返回/向下传递

这会显著提升可推理性,也更利于测试与复用。

6.5 扁平化控制流:prefer early return,减少嵌套

深层嵌套会让调用路径不清晰,尤其在 SDK 里(充满参数校验、状态判断、兼容逻辑)。推荐模式:

  • 先做 guard clauses(不满足条件直接 return/throw)
  • 主流程保持顺序叙事

这能让读者更快建立“这条调用链到底做了什么”的心智模型。

6.6 文件与模块尺寸:文件过大时优先拆分

当一个文件同时包含:

  • 对外 API
  • workflow 编排
  • adapter 细节
  • 大量工具函数

它往往会变成“改哪里都会影响别处”的高风险文件。

建议按职责拆分:

  • public API:尽量薄(编排/调用)
  • workflow/orchestration:组合逻辑
  • adapter:底层差异与 IO
  • utils:纯函数

拆分的直接收益是:测试更容易写、重构更敢做、review 更容易。

6.7 让约束显式化:用类型与命名替代隐含约定

SDK 里最容易产生误用的“隐含约定”包括:单位、范围、默认值含义、前置条件。clean code 的做法是把这些约束前移:

  • 用命名表达单位:timeoutMsslippageBps
  • 用类型收敛输入:避免 any/object
  • 用 helper 把默认值与推导集中在一个地方,而不是散落在各调用点

这不仅是“代码更干净”,也是减少支持成本的最有效手段之一。

7 测试(Test):用最复杂路径验证 SDK 的正确性与稳定性

在 SDK 中,测试的目标不是“覆盖率好看”,而是回答一个更现实的问题:

当用户走到最复杂、最容易出错的路径时,SDK 是否仍然给出正确、可预测的行为?

尤其在链上 SDK 中,一次未覆盖的边界条件,往往意味着真实资金风险。

7.1 测试优先覆盖“最复杂路径”,而不是“最常见路径”

单元测试可以覆盖基础逻辑,但 SDK 的高风险往往集中在:

  • 多状态依赖的 workflow
  • 参数组合复杂、约束多的 API
  • 依赖链上状态 / 外部返回值的路径

因此,在写 集成测试(integration test) 时,应刻意选择:

  • 状态最多的路径
  • 分支最多的情况
  • 最接近真实使用但最不“顺”的流程

原则是:
如果最复杂的情况是正确的,简单情况通常不会错;反之不成立。

7.2 测试边界条件:围绕“基准值”构造 max / min 变化

SDK 中大量逻辑依赖最大值 / 最小值 / 阈值(例如额度、时间、滑点、限制条件)。测试这些逻辑时,一个常见反模式是:

  • 每个 case 写一套完全不同的 mock
  • 数值来源分散,阅读成本高
  • 很难看出“变化点”在哪里

更推荐的方式是:

  • 定义一个业务合理的基准值(baseline)

  • 所有测试都围绕该基准值做偏移:

    • 小于最小值
    • 等于边界值
    • 大于最大值

    例如:

    1
    2
    3
    4
    5
    6
    7
    baseAmount

    baseAmount - 1

    baseAmount

    baseAmount + 1

    这种方式的优势在于:

    • 边界意图清晰
    • 测试之间具有可比性
    • 后续业务规则变化时,只需调整基准值

7.3 测试描述的原则:用结构和 BDD 思路表达语义

在 SDK 测试中,describe/it 层级已经提供了语义框架,不必在每个测试用例里重复上下文。更推荐结合 Given / When / Then 模式,让测试既清晰又简洁:

  • Given:测试的前置状态或条件(通常放在 describe 或 beforeEach)
  • When:触发的行为(通常是测试主体操作)
  • Then:期望结果(放在 it 中)
    例如:
1
2
3
4
5
6
7
8
9
10
11
describe("given allowance is insufficient", () => {
beforeEach(async () => {
await sdk.setAllowance(0)
})

describe("when user tries to transfer tokens", () => {
it("throws an error") // Then
it("auto approves if auto-approve is enabled") // Then
})
})

说明:

  • describe 描述 前置条件 / 场景(Given)
  • 内层 describe 描述 行为 / 操作(When)
  • it 只描述 期望结果(Then)

这种结构有几个好处:

  • 减少重复描述:每个 it 不必重复前置条件或行为,只关注期望结果
  • 增强可读性:从 describe 层级就能理解测试流程
  • 适合复杂 workflow:对于集成测试、多步骤操作,BDD 风格能自然串起整个链路

8. 文档(Doc):把“隐含假设”显性化

SDK 文档的价值不在“教用户怎么调 API”,而在于把只有 SDK 作者知道的隐含假设提前讲清楚:默认会做什么、哪些约束不能违反、改动会带来什么风险与成本。这样既降低使用摩擦,也让后来维护的人不必靠猜。
本章分两部分:

  • 面向用户的文档:帮助使用者理解 API 的动作、默认行为、典型使用方式,以及容易误解的地方
  • 面向开发者的注释实践:帮助 SDK 团队理解复杂逻辑的约束、边界与实现意图

8.1 面向用户的文档

推荐follow JSDoc document来写注释。

8.1.1 把参数从方法里拿走:让 SDK 看起来不像一张配置表

很多SDK在“第一眼”就已经输了:你点进方法是想确认能不能完成某个动作,却被一长串参数迫使提前理解所有选项。

1
2
3
4
5
6
7
8
mint(
collectionId: string,
owner: PublicKey,
maxFee?: number,
confirmLevel?: "confirmed" | "finalized",
skipPreflight?: boolean,
timeout?: number,
)

你本来只想知道“现在能不能 mint 一个 NFT?”,结果被迫立刻思考:哪些是必填,哪些是高级配置,现在要不要关心。阅读体验在起跑线就输了。
更好的第一眼应该只表达“我能帮你做什么”:

1
async mint(params: MintParams): Promise<MintResult>

信息密度被刻意压低:

  • mint:业务动作
  • MintParams:细节在别处
  • Promise:有链上副作用
    用户可以先决定“要不要用它”,而不是被参数细节拦在门口

8.1.2 参数该待的地方:一个可以慢慢读的 Type

当参数被移进 type,你就有空间解释“为什么是这样,而不是那样”:

1
2
3
4
5
6
7
8
9
10
export type MintParams = {
/**
* Maximum acceptable transaction fee (in SOL).
*
* @default 0.005
* @remarks
* 取保守值,常规用户不必改;只有在拥堵导致频繁掉单时再调高。
*/
maxFee?: number;
};

这些“为什么”的说明如果放在@param里,很难维护,导致代码太长;放在 type 上,它天然就是给“想弄懂的人”看的。

不是所有方法都要抽 type。你在也会遇到一类“干净”的工具函数:

1
export function validateFee(fee: number, max: number): boolean
  • 参数直白
  • 无策略与默认值语义
  • 上下文就在方法本身
    再抽一个 type 反而让人烦。这不是统一规范,而是判断“阅读成本是否值得”。

8.1.3 默认值必须解释业务含义

默认值是易用性的一部分,但也是误解高发区。尤其当默认值满足任一条件时,文档必须把含义讲清楚:

  • 是魔法值(数字/枚举/开关一眼看不出意义)
  • 来自经验或安全边界(偏保守/偏激进)
  • 改动会影响风险或成本(费用、授权、成功率、性能、可追溯性)

推荐写法模板(用一句话也行):
默认是什么 → 为什么这样默认 → 什么时候你应该改 → 改了会带来什么代价

1
2
3
4
5
6
7
8
9
type ActionParams = {
/*
* Automatically enable X feature when balance is not enough
* Set this to `false` to disable this behavior
*
* @default: true
*/
enableXOption?: boolean;
}

8.1.4 行为依赖“隐含前提”时,要先说“会发生什么”,再说“你如何配置它”

链上 SDK 最容易踩坑的一类误解是:

“我以为我只做了一个动作,结果 SDK 背后自动做了额外事情。”
比如:自动选择批量策略、自动处理费用代付/包装、自动补齐授权、自动拆分成多笔交易等。这些不一定要展开实现细节,但必须把用户可感知的副作用写出来:

  • 会发生什么(自动做了哪些事)
  • 触发条件(在什么情况下会自动发生)
  • 如何关闭/如何配置(对应哪个 options)
  • 关闭后的后果(你需要自己承担什么)
1
2
3
4
5
/*
* The method takes the multicall strategy and automatically approve if the allowance is insufficient.
*/
public async doBatchAction(params) -> Promise<result>

当 API 行为依赖协议设计、链上规范或业务文档/RFC 时,文档里不应重复解释整套背景,而应:

  • 写结论与影响(用户做决定需要的信息)
  • 给出链接(高级用户自行深入;外部规则变化时更易维护)
1
2
You can access {@link https://rfc.example.org/rfc-1234 | RFC-1234: Why this behavior exists}.

8.2 面向 SDK 开发者的文档:防止错误重构(Developer-facing)

这一类文档的唯一目标是:

防止合理的代码修改,造成不合理的行为变化。

8.2.1 所有“非直觉”的实现,都必须被显性解释

当一个实现无法仅通过代码本身被正确理解时,就必须配套文档说明。
所谓“非直觉”,通常包括但不限于:

  • 多分支逻辑,且执行顺序不可随意调整
  • 看起来可以抽象、合并、泛化,却被刻意拆开或写死
  • 为特定边界条件、链上行为或历史兼容性而存在的防御逻辑
  • 与常规最佳实践不一致,但是刻意为之的实现

这些实现如果缺乏解释,几乎一定会在未来被:

  • 合并分支
  • 删除“多余判断”
  • 抽象成“更优雅”的通用逻辑
    而结果通常是 silent bug。

8.2.2 文档必须解释“为什么不能改”,而不仅是“它在做什么”

开发者文档的重点,不在于描述逻辑流程,而在于约束未来的修改行为。

一个合格的开发者注释,至少应回答:

  • 这个实现解决了什么非显然的问题
  • 依赖了哪些外部约束(协议、链上行为、历史 bug)
  • 哪些改动看似安全,但实际上会破坏正确性
  • 如果未来一定要改,风险边界在哪里
    从这个角度看,开发者文档更像是设计决策记录(Design Rationale),而不是代码说明书。
1
2
3
4
5
// edge case: when inputs contain mixed modes, must split into two groups
// reason: mixed grouping breaks invariants of permission + batching
group_requests_by_mode()
process_group_A()
process_group_B()

结语:写好 SDK,本质是在管理复杂度

一个高质量的链上 SDK,从来不是某一个“聪明设计”的产物,
而是大量一致、克制、可复用的工程决策 叠加的结果。

回顾全文,可以发现一个反复出现的核心原则:
SDK 的职责,是吸收复杂度,而不是转移复杂度。
在工程实践中,这条原则可以被具体化为一些可反复使用的判断标准:

  • 如果用户必须理解内部 workflow 才能正确使用 API,说明抽象已经泄漏
  • 如果 breaking change 频繁出现,说明 public API 贴在了不稳定边界上
  • 如果文档只能解释“发生了什么”,却解释不了“为什么必须这样做”,未来一定会被错误重构
  • 如果性能优化需要用户显式开启,绝大多数用户都会在默认路径上付出不必要的成本

一个真正成熟的 SDK,往往在多个维度上同时成立:

  • 领域理解 决定哪些能力值得被抽象
  • 命名与类型 决定用户如何理解约束与边界
  • 模块边界 决定哪些地方可以安全演进
  • 默认值与辅助方法 决定用户的主路径体验
  • 测试 决定哪些复杂场景永远不会 silently break
  • 文档 决定哪些隐含假设不会被误解或忽略

这些维度彼此无法替代,也无法靠单点优化弥补。

写好一个链上 SDK,说到底不是炫技,而是一种工程纪律:

  • 在设计 API 之前,先理解业务与协议
  • 在暴露能力之前,先思考误用方式
  • 在追求性能之前,先优化默认路径
  • 在代码稳定之前,不急于扩展 public surface

当你能长期坚持这些选择时,你的 SDK 才会真正成为一个可以被信任、被依赖、被演进的工程资产。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

蜀ICP备2025133850号