目录
要点
-
SaaS API 的广泛使用暴露出了一个问题,那就是处理主要版本的更新和重大变更的方法是不一致的。
-
API 发布者在解决潜在问题时主要关注 API 的向后兼容性。
-
鉴于现代应用程序集成了各种 SaaS API,API 发布者不能只考虑基础 API,还需要考虑包括性能、依赖性、wireformat 兼容性等在内的各种问题。
-
如果不这样做,可能会导致客户不再充分相信版本控制是获取变更信息的可靠工具,迫使 API 发布者支持旧版本来照顾旧版用户,反过来让版本控制的问题更加严重。
-
这里的目标不是在各个方面都实现兼容性,而是识别出来对你所关心的客户群体最重要的那些方面,并清楚地传达相关信息。
引言: 经验丰富的软件工程师非常熟悉软件版本版本控制的概念。版本是 API 演进和变更管理的基石。语义版本控制(SemVer)已成为沟通和管理 API 变更的通用标准。虽然语义版本控制的大部分内容都经受住了时间的考验,但还有一个层面确实存在问题——也就是向后不兼容的重大变更。现在是时候厘清 API 破坏性变更这一概念的细节并建立共识了。
为什么要现在做这件事?
SaaS 平台的广泛流行让现实世界的大多数应用程序都变成了第三方 API 的大杂烩。现代软件构建系统的复杂性、数量飞速增长的库、多语言软件栈和 SaaS 革命加在一起,让软件发行商和使用者都必须对版本控制有一致的理解。
特别是对于 API 重大变更来说,不同人对语义版本控制的解释不一致会导致致命的循环问题,原因有二:
-
由于跨 API 的重大变更建模方式不可预测,因此使用者对于大版本升级也持谨慎态度,即便这些升级是合理有益的也是如此。
-
结果,API 开发人员对是否发起重大升级就会犹豫不决,因为他们会面临版本更新缓慢、维护负担增加和新版本采用缓慢的风险。于是他们有时会将重大变更塞进次要或补丁版本里,结果会进一步损害用户的信任度。
流行 API 的发布者通常会对主要版本提供三到四年的支持,等待更新步伐缓慢的用户跟上节奏。
随着生成式 AI 的 SaaS API 持续快速增长,现在我们恰逢一个很好的时机,来回顾到底重大变更包含哪些内容,以及如何在向后兼容性、可升级性与现代化和可迭代性之间做好权衡。
本文讲的是什么事情,不涉及哪些问题
本文讨论了 SemVer 标准中最具争议和最容易被误解的几个部分,即向后兼容性和重大变更。需要注意的是,下文中提到的这两个概念的 引用来源 的解释是可能被修改的。我们的目标是使用现实世界的实际示例,并尽可能引用开源资料来消除歧义。
-
如果只引入向后兼容的错误修复,则必须增加补丁版本。
-
如果向公共 API 引入新的向后兼容特性,则必须增加次要版本。
-
如果公共 API 引入任何向后不兼容的变更,则必须增加主要版本。
大多数 API 发布者实际上只考虑一类重大变更——对 API 签名(参数,包括其类型、返回类型等)的变更,这会要求开发人员重构这些内容与 API 的集成关系。
本文可作为读者的参考,帮助读者了解应该考虑哪些类型的向后兼容性 API/SDK,以及哪些类型应该被有意忽略。我们将提出一些建议,但我们的目标不是定下规矩,而是提供一份如何使用 SemVer 标准来规划 API 演变路线的指南。我们将演示一个不那么明显的重大变更的示例来帮助大家理解。
由于当今大多数 API 都附带客户端库,因此我们的示例是用 Java 编写的,但它们很容易推广到其他语言和非 SDK 上。我们故意不去深入探讨如何使用特定的设计模式或特定的技巧来在 Gradle 或 Maven 之类的地方解决这些问题。我们的目标是让大家对不同类型的破坏性变更都能有办法应对。
我们先从基本的 API 兼容性开始研究,然后再讨论更细致的向后兼容性概念。
API 兼容性
关于向后兼容性,人们最认可的形式是和 API 中的直接变更有关系的。这些变更可能是 API 签名中的变更,例如对参数、其数据类型或返回类型的修改。此类变更可能需要开发人员修改其现有的 API 集成。
// Old version
public interface BookService {
Book getBookById(int id);
List<Book> searchBooks(String query);
}
// New version
public interface BookService {
// Method was renamed
Book findBookById(int id);
// New non optional limit parameter would result in compile time error
List<Book> searchBooks(String query, int limit);
}
ABI 兼容性
除了 API 兼容性之外,许多编程语言还会考虑 ABI(应用程序二进制接口)兼容性。比如说在 Java 中,即使方法签名发生变更,库也可能会保持 API 兼容性,但 ABI 兼容性可能就没了。
// Old version
public class Example {
public void doSomething(int value) {
// Implementation
}
}
// New version
public class Example {
public void doSomething(Integer value) {
// Implementation
}
}
在这个例子中:API 是兼容的:从这个库的版本 1 切换到版本 2 时,使用 Example 类的 doSomething 方法的程序不需要修改(假设它们传递一个整型值或 int 变量,该变量在版本 2 中自动装箱为 Integer)。
ABI 是不兼容的:如果不重新编译,针对这个库的版本 1 编译的程序将无法继续使用它的版本 2。这是因为方法签名已变更:它现在采用 Integer 对象而不是 int。在 JVM 级别,方法签名包括参数类型,因此 doSomething(int) 和 doSomething(Integer) 在二进制级别被视为不同的方法。
应用程序二进制接口(ABI)兼容性的概念可能看起来有些陌生,或者跟基础 API 没多大关系。但它的用途越来越广泛,特别是当你的实现利用外部函数接口(FFI)与底层原生平台通信时更是如此。FFI 允许用一种编程语言编写的程序调用另一种语言的函数并使用后者编写的服务,而且往往是比较底层的函数和服务。所以一定要确保此类场景中的 ABI 兼容性才能维护软件的完整性和功能,因为它依赖于跨不同编程环境的一致数据结构、函数签名和调用约定。
伙伴 API 兼容性
SaaS 平台越来越普遍地提倡某种“共用效果更佳”的互操作性,强调这些平台的产品结合使用时能够增强功能,并无缝集成。
考虑一个要用到一个支付 API 和一个分析 API 的场景。在这个例子中,这些 API 的客户端库被设计为能够自动实现良好的协同效果。当系统通过支付 API 处理交易时,会自动触发客户端事件的生成:
// Old version
public interface AnalyticsClient {
//Not a public API. For partner use only
public void logPaymentSuccess();
}
public class PaymentsClient {
public void processPayment(AnalyticsClient a) {
a.logPaymentSuccess();
}
}
AnalyticsClient analytics = new AnalyticsClientImpl()
PaymentsClient payments = new PaymentsClient()
payments.processPayment(analytics);
// New version
interface AnalyticsClient {
// Not a public API. For partner use only
// Introduce a backward incompatible change that was informed to the partner
public void logPaymentSuccess(String source, String authToken);
}
AnalyticsClient analytics = new AnalyticsClientImpl()
PaymentsClient payments = new PaymentsClient()
// will not compile unless the payments library is also upgraded.
payments.processPayment(analytics);
就算负责分析工作的合作方提前通知负责支付的一方,告诉他们伙伴 API 已经损坏了,这两个库的最终用户还是需要同时升级分析 API 和支付 API,以防服务出现任何中断。现代构建系统在这方面已经有了很多改进,可以更好地处理依赖关系并建立版本冲突解决策略。然而,我们还是需要仔细分析和理解这些类型的 API 变更及其影响。强制客户同时升级多个依赖项的升级体验可能还是会被视为重大变更。
性能兼容性
公共 API 是传递合约的方式。在现实世界中,API 的使用者对合约的解释各不相同。我们应该设计出鼓励“即发即忘”调用模式的 API(日志记录、计数器等)。在这样的情况下,与实现相关的变更一般不会被视为破坏。但任何明显增加调用延迟的行为都可能导致相当大的行为变化和客户流失的后果。
private static final Logger simpleLogger = new Logger() {
@Override
public void log(String message) {
System.out.println(message); // Just a simple console logger for this example.
}
};
private static final Logger simpleLogger = new Logger() {
@Override
public void log(String message) {
System.out.println(message);
//Expensive IO Operation
writeLogToFile(message);
}
};
Wireformat 兼容性
开发 API 时,为模式演化和数据序列化选择合适的工具也是非常关键的。并非每个 API 都可以灵活地支持进程间通信(IPC)和远程过程调用(RPC)格式,这时它们就只会用 JSON。双向流、繁琐的 API 和处理大型负载等场景需要更针对性的序列化方法。这可能会带来一类难以察觉的破坏性变更。
以一个用于本地日志系统的 SaaS API 为例。升级这个本地 API 可能不会变更其接口。但如果升级改变了数据格式(例如将浮点数表示为字符串),则可能需要同时更新所有客户端应用程序。这样的升级往往很难协调,并且可能演变成重大变更。
Protobuf、Flatbuffers、Avro 和 Parquet 等工具是 API 开发人员的好帮手。它们让我们可以更好地理解模式演变及其与数据传输方法的集成关系。
系统级兼容性
在现代开发工作中,SDK 经常部署在各种且难以预测的环境中,移动操作系统就是一个例子。这种情况通常会导致 API 的实现细节暴露出来,而这些细节原本不应该成为公共接口的一部分。
一个常见的场景是使用 Android SDK,开发人员需要指定一个 minSDKVersion。这代表 SDK 兼容的最低 Android 版本。如果 SDK 更新包含了仅在更高版本的 Android 中可用的新的系统级 API,那么 SDK 清单中的 minSDKVersion 也需要跟上去。虽然这种变更在技术上是必要的,但开发人员并不总是将其视为 API 破坏性变更。
然而,这可能会导致针对旧版本的 API 的使用者遭遇冲突情况,遇到臭名昭著的“minSdkVersion x 不能小于库中声明的版本 x+n”的错误。于是,API 使用者被迫提高他们的 minSDKVersion 版本号,导致他们失去一部分仍在使用旧 Android 版本的用户群。
可降级性
如果升级到较新 API 版本的使用者无法返回到以前的版本,那么这可能就是一个重大变更。例如,如果升级版本重命名了数据库列后旧版本没法理解,那就没办法回滚或降级 API 了。这里有一个与流行的 Android 版 Google Firebase SDK 相关的真实示例。
SDK 无法降级可能意味着整个应用程序都无法回滚,大大增加了开发者引入升级版本时的风险。此类变更可能需要被视为破坏性变更。
// v1
public void init() {
// perform necessary database migrations
}
// v2
// executing this version of init will make it impossible to downgrade to older versions that expect the old column name
public void init() {
// perform necessary database migrations
execute("ALTER TABLE table_name RENAME COLUMN user TO username;");
}
数据收集 / 存储 / 保留兼容性
如果你的新版 API 变更了它们隐式收集、存储或处理的数据,则明智的做法是将其作为重大变更推给用户。无意中收集的数据会对使用者产生现实的法律影响,并且可能会影响软件的分发行为。大多数 SaaS 提供商都应公开告知其数据收集政策是如何间接影响与 App Store、PlayStore 和世界各地的监管机构相关的应用隐私保证的信息。
依赖兼容性
你的 SDK 的依赖项也会引入破坏性变更。除非你“隐藏”依赖项并将它们打包到你的发行版中(但这并不一定是最好的办法,甚至可能无法做到!),否则你的 SDK 依赖项中的符号也是应用程序命名空间的一部分。如果 API 使用了一个库,使用这个 API 的应用也用了这个库,但用的是一个和前者不兼容的版本,这种问题就会变得特别麻烦,带来难以解决的符号冲突。关键在于我们应该只依赖高度稳定的库,因为这种库很少发生重大变化,就算有也是经过深思熟虑的。
一条血泪教训是永远不要让你的公共 API 暴露某个依赖项的 API。这可能是灾难性的,结果你的 API 的发展也必须跟着你的依赖项的节奏来。考虑以下场景:
import org.joda.time.DateTime;
public interface DateProcessorInterface {
void processDate(DateTime jodaDate);
}
每当这里用到的日期库引入重大变更时,对于那些直接依赖这个日期库的客户来说,你的 API 也相当于引入重大变更了。因此,请仔细选择你的依赖项,考虑对它们 shading 或重新命名空间,还要更新到最新版本。
隐式合约兼容性
虽然你的 API 代表一份具体的合约,但客户会从他们的角度解释这份合约。即使打破的是这种隐式合约也会导致不愉快的经历。考虑以下示例:
auth.getSignedInUser(email, password)
.addOnCompleteListener(this, new OnCompleteListener<AuthResult>() {
@Override
public void onComplete(@NonNull Task<AuthResult> task) {
// Callback fires immediately if the session is already in memory
if (task.isSuccessful()) {
} else {
}
}
});
虽然你可能在 API 注释里写的是长时间运行的异步操作,但可能会有客户注意到你的 API 在大多数情况下会立即返回,所以就没在他们的 UI 里设计进度条了。作为 SaaS 提供商,你有责任不引入可能会导致回调不会立即触发的实现变更,以免破坏此类隐式合约。虽然你可能会发现修改这些隐含的协议是合理的举措,但请注意,客户可能会将这些变更视为错误。适当的文档和构建工具(例如 lint 检查)可以帮助我们识别此类隐式合约,并帮助 API 使用者避免痛苦的迁移过程。
总 结
SaaS API 面临的挑战是复杂且不断变化的。本文讨论的重点是我们必须细致地了解版本控制和重大变更的影响。
首先,处理主要版本更新和跨 SaaS API 的重大变更时的任何不一致都可能造成重大错误。API 发布者通常只关注 API 兼容性,而忽视了更广泛的影响。现代应用程序中往往集成了多个 SaaS API,这就需要 API 开发者建立更广阔的视野,涵盖 API 功能和行为的各个方面。
其次,人们越来越认识到重大变更不仅仅涉及 API 签名的变更。它包含一系列因素,从 ABI 和 wireformat 兼容性到系统级变更和隐含的合约预期等等。这些变更如果管理不当,可能会削弱客户对版本控制的信任,认为它不再是了解变更信息的可靠工具,从而迫使发布商支持过时的版本,长期停留在不良的版本控制实践中。
本文给出的示例表明,在各个方面都实现完全向后兼容性并不总是可行或可取的。真正的目标是识别出来对你的客户群最重要的变更并将这些信息清楚地传达给他们。这就要求我们在保持向后兼容性、鼓励可升级性以及拥抱现代化和迭代之间作出精妙的平衡。
本文提供的示例说明了 API 演变的复杂性。这些不仅仅是技术挑战,还涉及对客户需求和期望的深刻理解。在深入了解兼容性和重大变更的各个层面的影响后,API 发布者就可以做出明智的决策,结果不仅可以改进他们的产品,还可以在用户群中培养信任关系和忠诚度。