Monite 的 API 版本管理

139 阅读10分钟

我们都喜欢拥有闪亮的新工具,但却厌烦不断更新它们的麻烦。这适用于所有东西:操作系统、应用程序、API、Linux 包。代码因为更新而停止工作是痛苦的,尤其是当更新并不是我们主动发起时,更是双倍的痛苦。

在 Web API 开发中,每次更新都可能破坏用户的代码。如果你的产品是 API,那么这些更新每次都可能令人恐惧。Monite 的主要产品是我们的 API 和白标 SDK。作为一家以 API 为核心的公司,我们非常注重保持 API 的稳定性和易用性。因此,避免 破坏性更改 是我们优先考虑的问题之一。

一个常见的解决方案是向客户发布弃用警告,并且尽量少发布破坏性更改。突然之间,你的发布可能需要几个月时间,有些功能必须隐藏或甚至在下一个版本发布之前不能合并。这会减缓开发进度,并迫使用户每隔几个月就更新他们的集成。

如果你加快发布频率,用户将不得不频繁更新他们的集成。如果你延长发布间隔,你的公司将发展较慢。你为用户制造的麻烦越多,对你来说就会越方便,反之亦然。这显然不是最优的方案。我们希望以自己的节奏前进,而不破坏现有客户的系统,这在常规的弃用方法下是不可能实现的。这就是为什么我们选择了另一种解决方案:API 版本管理

这是一个相当简单的想法:在任何时间发布破坏性更改,但将它们隐藏在一个新的 API 版本下。这样你可以享受到两全其美的好处:用户的集成不会被经常破坏,而你可以以任何你喜欢的速度前进。用户可以在他们愿意的时候迁移——没有任何压力。

考虑到这个想法的简单性,它似乎适合任何公司。这是你在典型的工程博客中会期待阅读的内容。遗憾的是,这并不简单。

警惕成本

API 版本管理是困难的,非常困难。其虚假的简单性在你开始实施时很快就会消失。遗憾的是,互联网从未真正警告你,因为关于这个话题的资源惊人地少。绝大多数资源讨论了将 API 版本放在哪里,但只有少数文章尝试回答:“我们如何实施它?”常见的方法包括:

  • 将不同版本的相同 Web 应用程序放入单独的部署中
  • 复制在版本间发生更改的单个路由
  • 为每个版本复制整个版本化应用程序

单独的部署可能非常昂贵且难以维护,复制单个路由在大规模更改时扩展性不好,而复制整个应用程序会产生大量额外代码,几乎会在几个版本后淹没你。

即使你尝试选择最便宜的方法,版本管理的负担也会很快显现。起初,它会显得简单:在这里添加另一个模式,在业务逻辑中添加另一个分支,最后复制几个路由。但随着版本的增加,你的业务逻辑将迅速变得无法管理,你的开发人员可能会混淆应用程序版本和 API 版本,甚至开始对数据库中的数据进行版本管理,从而使你的应用程序变得难以维护。

你可能希望同时不会有超过两个或三个 API 版本;你可以每隔几个月删除旧版本。如果你只支持少量的内部消费者,这是真的。但来自组织外部的客户不会喜欢被迫每隔几个月就升级一次的体验。

API 版本管理很快可能成为你基础设施中最昂贵的部分,因此在此之前进行细致的研究是至关重要的。如果你只支持内部消费者,那么使用 GraphQL 这样的工具可能会简单一些,但它也可能迅速变得和版本管理一样昂贵。

如果你是一家初创公司,建议将 API 版本管理推迟到开发的后期阶段,当你有资源来做好它时。在此之前,弃用和 增量更改策略 可能足够了。你的 API 可能不会总是表现出色,但至少你可以通过避免明确的版本管理来节省大量资金。

我们如何实施 API 版本管理?

经过几次试验和许多错误后,我们处于十字路口:我们之前提到的版本管理方法维护成本太高。由于我们的挣扎,我制定了以下要求,作为一个完美版本管理框架所需的条件:

  1. "维护大量版本很容易" 以确保版本管理不会减缓我们的功能开发
  2. "删除旧版本很容易" 以确保我们可以轻松清理代码库
  3. "创建新版本不太容易" 以确保我们的开发人员仍然受到激励尝试在没有版本的情况下解决问题
  4. "在版本间维护变更日志很容易" 以确保我们和客户始终可以了解版本之间的真实差异

遗憾的是,几乎没有现有方法的替代方案。这时,一个疯狂的想法浮现在我的脑海里:如果我们尝试构建一些复杂的东西,完美适合这个工作的东西——像 Stripe 的 API 版本管理

经过无数次实验,我们现在有了 Cadwyn:一个开源的 API 版本管理框架,不仅实现了 Stripe 的方法,还在其基础上做了重要改进。我们将讨论其在 Fastapi 和 Pydantic 中的实现,但核心原理与语言和框架无关。

Cadwyn 如何工作

版本更改

所有其他版本管理方法的问题在于我们复制了太多。为什么我们要复制整个路由、控制器,甚至整个应用程序,而只是合同的一个小部分被破坏了呢?

使用 Cadwyn,当 API 维护者需要创建新版本时,他们将破坏性更改应用于最新的模式、模型和业务逻辑。然后,他们创建一个版本更改——一个封装新版本和之前版本之间所有差异的类。

例如,假设之前我们的客户可以用一个地址创建用户,但现在我们希望允许他们指定多个地址而不是一个。版本更改将如下所示:

class ChangeUserAddressToAList(VersionChange):
    description = (
        "将 `User.address` 重命名为 `User.addresses` 并将其类型更改为字符串数组"
    )
    instructions_to_migrate_to_previous_version = (
        schema(User).field("addresses").didnt_exist,
        schema(User).field("address").existed_as(type=str),
    )

    @convert_request_to_next_version_for(UserCreateRequest)
    def change_address_to_multiple_items(request):
        request.body["addresses"] = [request.body.pop("address")]

    @convert_response_to_previous_version_for(UserResource)
    def change_addresses_to_single_item(response):
        response.body["address"] = response.body.pop("addresses")[0]

instructions_to_migrate_to_previous_version 被 Cadwyn 用来生成旧版本 API 的模式代码,而两个转换函数则是我们能够维护任意数量版本的窍门。流程如下:

  1. Cadwyn 使用 change_address_to_multiple_items 转换器将所有来自旧 API 版本的用户请求转换为最新 API 版本,并将它们传递给我们的业务逻辑
  2. 业务逻辑、API 响应和数据库模型始终根据最新 API 版本进行调整(当然,它们必须仍然支持旧功能,即使这些功能在新版本中被删除)
  3. 在业务逻辑生成响应后,Cadwyn 使用 change_addresses_to_single_item 转换器将其转换为客户端请求当前使用的旧 API 版本

在 API 维护者创建了版本更改后,他们需要将其添加到我们的 VersionBundle 中,以告诉 Cadwyn 这个 VersionChange 将包含在某个版本中:

VersionBundle(
    Version(
        date(2023, 4, 27),
        ChangeUserAddressToAList
    ),
    Version(
        date(2023, 4, 12),
        CollapseUserAvatarInfoIntoAnID,
        MakeUserSurnameRequired,
    ),
    Version(date(2023, 3, 15)),
)

就是这样:我们添加了一个破坏性更改,但我们的业务逻辑只处理一个版本——最新的版本。即使我们添加数十个 API 版本,我们的业务逻辑也将免于版本管理逻辑、常量重命名、条件语句和数据转换器的困扰。

版本链

版本更改依赖于 API 的公共接口,我们几乎不会在现有 API 版本中添加破坏性更改。这意味着一旦发布了版本,它将不会被破坏。

因为版本更改描述了版本中的破坏性更改,而旧版本中没有破坏性更改,我们可以确保版本更改是完全不可变的——它们永远没有理由更改。不可变的实体比作为业务逻辑的一部分更容易维护,因为业务逻辑是不断演变的。版本更改也会一个接一个地应用——形成一个版本之间的转换链,可以将任何请求迁移到任何更新版本,并将任何响应迁移到任何旧版本。

Cadwyn 中请求和响应的流动示意图

副作用

API 合同比仅仅是模式和字段要复杂得多。它们包含所有端点、状态码、错误、错误消息,甚至业务逻辑行为。Cadwyn 使用我们上面描述的相同 DSL 来处理端点和状态码,但错误和业务逻辑行为是另一回事:它们无法使用 DSL 描述,需要嵌入到业务逻辑中。

这使得这种版本更改的维护成本比其他所有更高,因为它们影响业务逻辑。我们将这种属性称为“副作用”,并尽一切可能避免它们,因为它们带来了维护负担。所有想要修改业务逻辑的版本更改都需要标记为具有副作用。这将作为识别“危险”版本更改的一种方式:

class RequireCompanyAttachedForPayment(VersionChangeWithSideEffects):
    description = (
        "用户现在必须在其账户中拥有 company_id "
        "才能进行新的支付"
    )

它还允许 API 维护者检查客户端请求是否使用了包含此副作用的 API 版本:

if RequireCompanyToBeAttachedForPayment.is_applied:
    validate_company_id_is_attached(user)

没有银弹

Cadwyn 有很多好处:它大大减轻了我们开发人员的负担,并且可以集成到我们的基础设施中,以自动生成变更日志并改善 API 文档。

然而,版本管理的负担仍然存在,即使是复杂的框架也不是万能的解决方案。我们尽力在绝对必要时才使用 API 版本管理。我们还通过设立专门的“API 委员会”来确保我们的 API 初次实现时就正确。所有重要的 API 更改都会在这里由我们最优秀的开发人员、测试人员和技术写作人员审查,然后才开始实施。

特别感谢 Brandur Leach 对他在 Stripe 撰写的 API 版本管理文章的贡献,以及在我实现 Cadwyn 时给予的帮助:没有他的帮助,这一切都无法实现。