深圳幻海软件技术有限公司 欢迎您!

软件依赖的一知半解

2023-02-28

对系统架构而言,外部系统依赖往往是系统质量属性的最大风险,对软件自身也是如此。软件依赖有着严重的风险,而这些风险常常会被忽视。我们可能尚未理解有效选择和使用依赖关系的最佳实践,甚至没有理解何时选择依赖关系。本文的目的是提高对风险的认识,并尝试更多的解决方案。在软件开发中,依赖项是程序员想要调用的附加

对系统架构而言,外部系统依赖往往是系统质量属性的最大风险,对软件自身也是如此。软件依赖有着严重的风险,而这些风险常常会被忽视。我们可能尚未理解有效选择和使用依赖关系的最佳实践,甚至没有理解何时选择依赖关系。本文的目的是提高对风险的认识,并尝试更多的解决方案。

在软件开发中,依赖项是程序员想要调用的附加代码。添加依赖项可以避免重复工作,例如设计、测试、调试和维护特定的代码单元,这个代码单元被称为包,或者库,或者模块等,本文会混用。采用软件依赖项很常见,咱们都经历过手动安装所需库的步骤,比如 C 的 PCRE 或 zlib; C++的 Boost 或 Qt; 或 Java 的 JUnit等。这些软件库包含了高质量且经过调试的代码,需要大量的专业知识来开发。对于一个需要这些软件包提供的功能的程序来说,手动下载、安装和更新软件包的工作要比从头开始开发这些功能要容易得多。

依赖管理器,也称为包管理器,可以自动下载和安装依赖包。由于依赖管理器使单个软件包更容易下载和安装,成本较低, 使得发布和重用较小的软件包更经济。例如,Node.js 的依赖管理器 NPM 提供了对超过几十万个包的访问。现在基本上每种编程语言都有依赖管理器: Maven (Java)、 Composer(PHP)和pip (Python)等都超过了10万个包。

这种细粒度的、广泛的软件复用的到来是这些年来软件开发中最重要的转变之一。然而,如果我们不更加小心,就会导致严重的问题。

1. 依赖的演变

包或者库都是从 Internet 下载的代码,将一个包作为依赖项添加自己的程序中,该程序暴露依赖项中的所有失败和缺陷,因为它完全依赖于这些下载的代码。这种方式听起来非常不安全。为什么人们这么做?因为它很简单,看起来很有效,是引用内部依赖的自然延续。

过去,大多数开发人员都信任自己所依赖的软件,比如操作系统和编译器。这些软件是从已知的来源购买的,虽然存在着漏洞的可能性,但至少开发者知道他们在和谁打交道,通常有商业或法律资源可用。

在互联网上免费分发的开源软件已经取代了许多早期购买的软件。一些项目建立起了众所周知的声誉,例如早期软件包 libjpeg (1991),HP STL (1994)和 zlib (1995)等,声誉往往会成为了人们决定使用哪些依赖的重要因素,对信任软件来源的商业和法律支持被声誉支持所取代,这可能就是共识的力量。

依赖管理器进一步缩小了开源代码重用模型的规模。现在,开发人员可以在由数十行代码组成的单个函数的粒度上共享代码,这是一项重大的技术成就。无数的软件包是可用的,编写代码可能涉及大量的软件包,但是用于信任代码的商业、法律和声誉支持机制并没有继续下去。开发人员信任更多的代码,而不需要太多的理由。

然而,采用不良依赖的成本可以看作是每个不良结果的成本乘以其发生的可能性之和。使用依赖项的场景决定了坏结果的成本。如果只是个人爱好,其中大多数坏结果的成本几乎为零,因为只是在享受乐趣,风险概率几乎为零。但是,如果是一个维护多年的生产软件,依赖关系中的 bug 的成本可能非常高: 服务器可能宕机,敏感数据可能泄露,客户可能受到伤害,甚至公司可能倒闭。高失败成本使得评估和降低严重风险变得更加重要。

不管预期的成本是多少,都需要一些估计和减少添加软件依赖性风险的方法。可能需要更好的工具来帮助,就像依赖管理器一直关注于降低下载和安装的成本那样。

2. 依赖的检查

在使用代码依赖时,基本的检查可以让我们了解遇到问题的可能性有多大。如果检查中发现了可能出现的小问题,可以采取措施准备或者避免它们。如果检查中发现了大问题,最好不要使用这个包, 也许能够找到一个更合适的,也许需要自己开发一个。开源软件包是由其作者发布的,希望它们会有用,但是较少有可用性或支持的保证。系统挂了,不得不调试这些包,整个项目的质量和性能风险都在我们自己身上。

因此,我们需要在依赖检查时考虑一些因素。

2.1 设计

文件清楚吗?API有清晰的设计吗?如果作者能够在文档中很好地解释依赖包的 API 及其设计,那么他们在源代码中实现正确的可能性就会增加。使用清晰的、设计良好的 API 编写代码也更容易、更快,并且更少出错。作者是否记录了他们对客户端代码的期望,以使升级兼容呢?(例如 C++ 23的兼容性文档。)

2.2 代码质量

代码写得好吗?读一些代码吧。作者看起来是否小心谨慎,始终如一?看到的代码起我们想要调试的代码吗?需要有检查代码质量的系统方法。例如,简单地编译一个启用了重要编译器警告的 c 或 c + + 程序(例如-Wall) ,就可以让开发人员了解在避各种未定义行为方面的严重程度,看看有多少不安全的代码。忽略关于死记硬背的建议,转而关注语义问题。

对不熟悉的开发实践要保持开放的心态。例如,SQLite 库提供了一个单独的200,000行 c 源文件和一个单独的11,000行称为 amalgamation 的头文件。这些文件的大小会引起最初的警觉,但是深入进去会发现实际的开发源代码包含了一个100多个 c 源文件、测试和支持脚本的文件树。事实证明,单文件分发是从原始数据源自动构建的,对于最终用户,尤其是那些没有依赖项管理器的用户来说更加容易。另外,编译后的代码也运行得更快,因为编译器可以看到更多的优化机会。

2.3 测试

代码有测试吗?能运行它们吗?测试确定了代码的基本功能是正确的,并且表明开发人员对于保持代码的正确性是认真的。例如,SQLite 开发树有一个非常全面的测试套件,超过了30,000个单独的测试用例,以及解释测试策略的文档。未来方案的修改可能会引入回归测试,而这些回归测试很容易被发现。

假设测试运行通过,还可以运行时检测(如代码覆盖率分析、竞争检测、内存分配检查和内存泄漏检测)来收集更多信息。

2.4 调试

找到包里的问题列表,里面有开放的 bug 报告吗?使用多久了?是否有许多错误尚未修复?最近有什么错误被修复了吗?如果看到很多关于 bug 的公开问题,而且已经公开了很长一段时间,这不是一个好的迹象。另一方面,如果关闭的问题表明缺陷很少,并且发现是及时修复的,那就太好了。

2.5 维护

查看包的提交历史,代码被积极维护了多长时间?现在还在积极维护吗?积极维护了较长时间的软件包更有可能继续得到维护。有多少人在包上做了提交?许多软件包是业余时间创建和分享的个人项目,还有一些是一群付费开发人员数千小时工作的结果。一般来说,后一种类型的软件包更有可能迅速修复错误,进行稳定的改进,并进行常规维护。

2.6 用法

是否有许多其他软件依赖于此代码库?依赖管理器通常可以提供关于使用情况的统计数据,或者可以使用搜索来评估其他人使用该包的频率。更多的用户至少意味着有很多人能够很好地使用代码,并且能够更快地发现新的 bug。广泛的使用还可以避免持续维护的问题,因为有兴趣的用户可能会做出更多贡献。

2.7 安全性

依赖包能够处理不可信的输入吗?如果是,它是否对恶意输入具有强大的抵抗力?它是否有列出安全问题的历史?例如, 流行的 PCRE 正则表达式库有诸如缓冲区溢出等问题的历史,特别是在其解析器中。这一发现并没有立即导致放弃 PCRE,但它确实使我们更仔细地考虑测试和隔离。

2.8 许可证

代码是否得到了正确的许可?它到底有没有许可证?公司是否接受这样的许可证?很多 GitHub 上的项目都没有明确的许可证。公司可能会对依赖项的许可证施加进一步的限制。例如,不允许使用类似 agpl 许可证授权的代码,它可能过于繁琐,也不允许使用类似 wtpl 的许可证,它可能过于模糊。

2.9 依赖的依赖

代码库是否有自己的依赖项?间接依赖关系中的缺陷与直接依赖关系中的缺陷一样对程序不利。依赖管理器可以列出给定包的所有依赖项,理想情况下应该按照这里描述的方式检查每个依赖项。具有许多依赖项的包会带来额外的检查工作,因为这些相同的依赖项会带来需要进行评估的额外风险。

许多开发人员可能从来没有看过依赖关系的完整列表,也不知道它们依赖什么。例如,包括 Babel、 Ember 和 Reactall 在内的许多流行项目间接依赖于一个名为 left-pad 的微型库,该包由一个单独的八行函数组成。在2016年3月,作者从 NPM 中删除了这个包,无意中破坏了大多数 Node.js 用户的构建。当时的轰动至今记忆犹新。

3. 依赖的测试

检查过程应该包括运行库自己的测试。如果库通过了检查,并且决定依赖于它,那么下一步应该是编写新的测试,重点是我们应用程序所需的功能。这些测试通常以简短的独立程序开始,编写这些程序是为了确保我们能够理解库的 API,并确保它完成预期的任务。值得付出额外的努力,将这些程序转换为可以针对包的较新版本运行的自动化测试。如果发现了一个 bug 并且有了一个潜在的修复,那么希望能够轻松地重新运行这些特定于项目的测试,以确保修复没有破坏其他任何东西,值得对基本检查所确定的可能存在问题的领域进行研究。

4. 依赖的抽象

根据库的不同,也许更新会把软件包带向一个新的方向,也许会发现严重的安全问题,也许会有更好的选择。出于所有这些原因,将项目轻松迁移到新的依赖项是值得的。

如果库将在项目源代码的许多地方使用,那么迁移到新的依赖项将需要对所有这些不同的源位置进行更改。更糟糕的是,如果库在自己项目的 API 中公开,那么迁移到新的依赖项将需要对调用API 的所有代码进行更改,而我们可能无法控制这些更改。为了避免这些代价,有必要定义一个自己的接口,并使用依赖项实现该接口的封装。封装应该只包含项目从依赖库中需要的内容,而不是依赖库提供的所有内容。理想情况下,这允许仅更改封装接口来替换不同但同样适合的依赖关系。每个项目的迁移到新接口时,将测试封装接口的实现。

这种间接性使测试备用库变得容易,并且它防止了在源代码树的其余部分中意外地引入依赖库的内部方法。反过来,这又确保了在需要时可以轻松地切换到不同的依赖项。

5. 依赖的隔离

在运行时隔离依赖项也可能是适当的,以便限制错误可能造成的损害。例如,Google Chrome 允许用户在浏览器中添加依赖文件/扩展代码。因此,在一个糟糕的扩展中,一个可利用的 bug 不能自动访问浏览器本身的整个内存,并且可以被阻止进行不适当的系统调用。如今,隔离依赖关系可以降低运行该代码的相关风险。

可疑代码的运行时隔离是困难的,而且很少完成。真正的隔离需要一种完全内存安全的语言,没有非类型化的代码。这不仅在 C和 C++ 语言中具有挑战性,而且在提供受限制不安全操作的语言中也很具有挑战性,例如 Java 在包含 JNI的时候,或者 Go和 Swift 在包含它们的“不安全”特性时。即使是在 JavaScript 这样的内存安全语言中,代码通常也可以访问超出其需要的内容。针对这类问题的众多可能防御措施之一,是更好地限制依赖。

6. 依赖的避免

如果一个依赖项看起来太危险,无法找到一种方法来隔离它,最好的答案可能是完全避免它,或者至少避免那些我们认为最有问题的部分。

如果只需要依赖库的一小部分,最简单的解决方案可能是复制所需的内容,当然,保留适当的版权和其他法律声明。我们正在承担修复错误、维护等责任,但也完全与更大的风险隔离开来。一点点复制总比一点点依赖要好。

7. 依赖的升级

升级带来了引入新 bug 的机会,如果没有相应的回报,为什么要冒这个风险呢?这种分析忽略了两个成本。首先是最终升级的成本。在软件方面,代码更改的难度不是线性的,做10个小更改比做一个等价的大更改更简单,也更容易做对。第二个问题是发现已修复bug 的代价。特别是在安全场景中,已知的错误可能会被利用,可能是攻击者的闯入。

及时升级是很重要的,但这意味着向项目中添加新的代码,这意味着要更新新版本依赖库的风险评估。至少,需要浏览从当前版本到升级版本的变更差异,或者至少阅读发布文档,以确定升级代码中可能需要关注的领域。如果许多代码正在更改,以致难以消化,那么可以将这种情况纳入风险评估。

重新运行依赖库自己的测试也是有意义的。如果它具有自己的依赖项,那么项目的配置完全有可能使用与库作者使用的不同版本依赖项。运行库自己的测试可以快速识别特定于配置的问题。同样,升级不应该是完全自动的。在部署升级版本之前,必须验证它们是否适合自己的环境。

在大多数情况下,延迟升级比快速升级的风险更大。

8. 依赖的关注

重要的是要持续关注,甚至可能重新评估使用它们的决定。

首先,确保使用我们所认为的特定库版本。现在,大多数依赖管理器可以轻松记录给定库版本预期源码的加密哈希值,然后在另一台计算机或测试环境中重新下载这个库时检查这个哈希。这可以确保使用与我们检查测试时相同的依赖源码。

同样重要的是,要注意新的间接依赖关系是否会爬进来。升级可以很容易地引入新的包,而我们的项目现在依赖于这些包。它们也是值得关注的,恶意代码可能被隐藏在一个不同的包中。依赖关系还会影响项目的大小。

升级是重新考虑使用依赖项的自然时机,定期重新审视依赖关系也很重要。这个项目被放弃了吗?也许是时候开始计划取代这种依赖性了。

9. 依赖,该说不该说的

软件复用好处不应被低估,依赖关系比以往任何时候都多,它给软件开发人员带来了积极的转变。即便如此,我们却没有完全考虑到潜在的后果。

  • 关于软件依赖有三个主要的建议:
  • 认识到问题,我们需要集中精力来解决这个问题。
  • 为今天建立最佳实践,需要最佳实践来使用依赖关系的管理。这意味着制定从决策到评估,以及减少和跟踪风险的过程。事实上,正如工程师专注于测试一样,有些人可能需要专注于管理依赖关系。
  • 为明天开发更好的依赖技术。依赖管理器基本上消除了下载和安装的成本。未来的开发工作应该侧重于降低使用依赖项所必需的评估和维护成本。构建工具至少应该使运行依赖库自己的测试变得容易,还应该提供简单的方法来隔离可疑的依赖库。

对特定依赖关系的严格检查需要大量工作,并且仍然有例外出现。对于每一个可能的新依赖,不太可能有开发人员真正付出这样的努力,尽管文中给出的可能只是一个子集。