上游优先(Upstream First) 是开源协作中的重要理念,今天就结合实例来聊一聊为什么要采用上游优先以及上游优先该怎么做。

上游优先

软件项目不可避免地会依赖其他项目并且被其他项目所依赖,本项目的依赖叫做上游(Upstream),依赖本项目的其他项目则叫做下游(Downstream)。而在维护项目的过程中,出于各种原因,会需要对上游做出变更,常见的情况包括:

  • 上游做出了破坏性变更
  • 上游出现了一个尚未修复的 BUG
  • 上游缺乏本项目需要的功能特性

这些变更优先反馈给上游的决策取向则叫做上游优先。

理论上开源协作理所当然地需要将变更反馈给社区(有些甚至是协议强制要求的),但是在实践当中,出于维护自身短期利益,不熟悉开源协作流程,短期目标驱动的 KPI 压力等原因,很多时候变更并没有反馈给上游,从而出现了这样的反模式(来自 @tison's twitter):

采用上游优先则能够帮助减少我们内部魔改后无法跟进社区更新带来的维护负担和负资产。

我认为上游优先主要有以下工作:

  • 更新
  • 沟通
  • 贡献

更新

保持更新是上游优先的第一步,一般选择上游发布的最新稳定版本或者长期维护版本。保持更新本质上是保持对上游维护者的信任:我相信上游能够交付高质量的软件,本次更新会带来新的功能改进和 BUG 修复并且遵守他的稳定性承诺。

tison 推荐的经典著作《合作的进化》中有精彩的论述:作者以组织的两轮“重复囚徒困境”竞赛为研究对象,结果发现在两轮竞赛中胜出的都是最简单的策略“一报还一报”,也就是总是以合作开局,在第一步合作,但从此以后就采取“以其人之道,还治其人之身”的策略,采用对方上一步的选择。

保持更新意味着我们的项目总是会选择以合作开局,如果维护者满足了我们的期待,那后续仍保持更新;如果维护者没有回应我们的期待,取而代之的是不断地意外 break 和低级 BUG,我们会选择对抗,不再依赖或者切换到其他上游。

OpenDALsize 的故事就是一个绝妙的例子。OpenDAL 的性能测试中依赖 size 来进行关于 size 大小的转换,但是在最近的更新中,size 连续做了多次破坏性变更:

保持更新一方面保证我们的项目能够持续在稳定的基础上构建,另一方面也能持续地向社区带来反馈,帮助社区更好地构建项目。Github 近年来已经成为 Git 的重要贡献者,他们持续保持 Git 版本与最新稳定版的同步,将自己测试/生产环境中遇到的问题反馈给社区,一方面维护了自己的线上稳定,另一方面也帮助 Git 社区复现和验证了大量 BUG。无独有偶,JetBrains 出于开发 PhpStorm 的考虑,不断跟进最新的 PHP 版本,成为了 PHP 社区重要的开发力量,甚至领导组建了 The PHP Foundation。从这一角度反过来思考 PhpStorm 商业上的成本也就不会觉得奇怪了(63% 的 PHP 开发者使用 PhpStorm 进行开发)。

伴随着业界对开源软件供应链问题的持续关注,保持更新在变得越来越容易。Github DependabotRenovate Bot 都能实现按照指定要求保持依赖处于最新状态。cargo-update 等命令行应用可以实现依赖的手动更新,维护者可以根据自己项目的实际情况指定一个版本更新策略。

沟通

保持沟通是上游优先的重要一环:没有沟通就没有所谓的开源。开源项目相对于闭源项目最大的不同就是沟通渠道是公开的:我们可以向上游直接反馈自己遇到的 BUG,需要的功能特性等等。

沟通渠道的公开并不意味着我们就能够滥用:我们需要按照社区的要求填写 BUG 反馈,尽自己所能的提供公开信息,提供可复现的步骤。antfu 的新博文 请提供最小重现 对这一问题有精彩的论述。我还观察到另一种负面案例是在遇到 BUG 之后不主动向上游反馈,而是在心里把上游骂一顿之后便自己采用 workaround,实际上上游由于场景的匮乏,完全不知道这个 BUG 的存在进而无法给予修复。

Databendpprof-rs 的故事就很好地说明了这一点。prof-rs 是由 TiKV 社区孵化的 Rust CPU Profiler 库,在 Rust 社区内使用广泛,Databend 也不例外。但是在最近的一次升级中,pprof-rs 破坏了 Databend 在 x86_64-unknown-linux-musl target 下的构建:pprof-rs 的维护者 @YangKeao 认为最新版本的 musl 中已经包括了 pthread_getname_np 而 libc 也提供了相应的支持,于是把之前在 musl target 下使用 fallback 的机制变更撤回了。然而 Databend 还没有升级构建构建环境中的 musl 至最新版本,所以构建因为找不到符号失败了。

起初 Databend 社区并不知道 pprof-rs 做出这样变更的原因,为了避免再次出现类似的 break,还在考虑是不是需要 Fork 出来自己独立维护。出于上游优先的考虑,@PsiACEpprof-rs 项目开了一个新 issue 汇报了本次构建遇到的问题: pprof 0.9.1: Databend cannot be built on musl targets,然后再自行 workaround 保证 Databend 构建不受影响并在 patch 的地方醒目地给出了该 Issue 的链接,并且要求在该问题修复后将上游切换回去。

在 Track 这个 issue 的过程中,我们发现导致本次构建失败的根本原因是我们的 musl 版本没有升级到最新版本。于是 @everpcpc 提交了 PR chore: upgrade musl in build tool image to 1.2.3,将 Databend 的构建镜像中的 musl 依赖升级至最新版本。在后续的进一步沟通中,我们发现了更多相关的问题。

倘若 Databend 社区没有保持跟上游 pprof-rs 的沟通,可能就错过了彻底定位该问题的机会,埋下了更大的隐患。

贡献

参与开源项目的贡献是维护自己利益的最佳方式。开源项目不同于公司内部项目,其决策受到开源共同体的整体利益驱动。不参与到开源贡献中就缺乏对项目发展方向的影响力,进而导致自己已有投资的全盘落空。

贡献在这里的含义是全方面的:下游项目能够帮助上游项目定位/调试问题,可以直接提供 BUG 的修复,甚至可以在 CI 中增加对应的测试用例保证不被意外破坏。

为了避免 pprof-rs 再次意外引入导致 Databend break 的变更,我提交了 PR feat: Fix and cover tests for target x86_64-unknown-linux-musl,修复并启用了 target x86_64-unknown-linux-musl,也就是 Databed 用例下的集成构建与测试。这样,未来 pprof-rs 的任何一个 PR 都将先在 Databend 的使用场景下进行构建和测试,然后才能通过。

async-compression 项目中,我向社区提交了一个 proposal: Export codec and Encode/Decode trait,描述了自己的使用场景,提出了自己的需求。通过这种方式, OpenDAL 可以加入到 async-compression 的长期演化中,从而避免依赖一个停止维护的临时分支。这样的需求反馈也推动作者思考自己项目的使用场景,以及如何更好的支持这样的 use-case。目前 async-compression 的作者提交了一个新的 PR Remove support for tokio 0.2 and 0.3 来移除过时的特性以支持未来更好的扩展。

总结

最后,上游优先并不意味着对 Fork 的全盘否定。开源共同体的分裂与合并反映了不同利益趋向的生产力大小对比,如果自己确实发现上游存在着无法解决的问题,并且认为自己可以做的更好,那完全可以取而代之成为新的上游。

这些案例都在说明只有能够供给充足生产力的社区创造的 Fork 能够成为新的上游,被众多项目信任;生产力供应不足的社区创造的 Fork 则会在短暂的喧闹后自然地被遗忘,其收益远远不如采用 Upstream First 战略。

本文结合实例讨论了为什么要采用上游优先以及上游优先该怎么做的话题,希望更多项目和贡献者能够加入到上游优先的行列中来,一起交付高质量的软件!