深入理解NPM依赖模型

npm是目前前端开发领域最主流的包管理器。虽然有其他选择,但npm的江湖地位短期内已经无法撼动。即便是强大如Bower这样可以管理前端所有文件的工具也被推到了一边,对我来说最有趣的是npm相对新颖的依赖管理方法。不幸的是,根据我的经验,它实际上并没有那么好理解,所以我尝试在这里澄清它是如何工作的,以及它如何影响用户或包开发者的。

基础回顾

站在较高的层次上来说,npm与其他编程语言的包管理器并没有太大不同:包依赖于其他包,它们用版本范围表示这些依赖关系。npm碰巧使用semver版本控制方案来表达这些范围,但它执行版本解析的方式大多不重要; 重要的是包可以依赖于范围而不是特定版本的包。

这在任何生态系统中都非常重要,因为将库锁定到一组特定的依赖项可能会导致严重的问题,但与其他类似的软件包系统相比,它在npm的情况下实际上要少得多。实际上,对于库作者来说,将依赖关系固定到特定版本而不影响依赖包或应用程序通常是安全的。棘手的一点是确定何时这是安全的,什么时候不安全,这就是我经常发现人们出错的原因。

重复依赖和依赖树

npm的大多数用户(或者至少是大多数软件包作者)最终都知道,与其他软件包管理器不同,npm安装了一个依赖树。也就是说,每个安装的软件包都有自己的一组依赖项,而不是强制每个软件包共享相同的规范软件包。显然,现在几乎每个包管理器都必须在某个时刻对依赖树进行建模,因为这就是程序员表达依赖关系的方式。

例如,考虑两个包,foobar。 它们中的每一个都有自己的依赖项集,可以表示为树:

foo
├── hello ^0.1.2
└── world ^1.0.7

bar
├── hello ^0.2.8
└── goodbye ^3.4.0

想象一下依赖于foo和bar的应用程序。显然,worldgoodbye 的依赖关系完全不相关,所以npm如何处理它们相对无趣。 但是,请考虑hello的情况:两个包都需要冲突的版本。

大多数软件包管理器(包括RubyGems/Bundler,pip和Cabal)都会在这里进行barf,报告版本冲突。这是因为,在大多数软件包管理模型中,一次只能安装任何特定软件包的一个版本。 从这个意义上说,软件包管理器的主要职责之一是找出一组将同时满足每个版本约束的软件包版本。

相比之下,npm有一个更容易的工作:它完全可以安装同一个包的不同版本,因为每个包都有自己的依赖集。在前面提到的示例中,生成的目录结构如下所示:

node_modules/
├── foo/
│   └── node_modules/
│       ├── hello/
│       └── world/
└── bar/
    └── node_modules/
        ├── hello/
        └── goodbye/

值得注意的是,目录结构非常接近实际的依赖树。上面的图是一个简化:在实践中,每个传递依赖都有自己的node_modules目录,依此类推,但目录结构很快就会变得非常混乱。(此外,npm 3执行一些优化以尝试在可能的情况下共享依赖关系,但最终不需要实际理解模型)

当然,这个模型非常简单。显而易见的效果是每个软件包都有自己的小沙箱,对于像ramda,lodash或underscore这样的实用程序库来说,它非常出色。如果foo取决于ramda@^0.19.0但是bar取决于ramda@^0.22.0,它们可以完全和平共存而没有任何问题。

乍一看,只要底层运行时支持所需的模块加载方案,该系统显然优于替代的平面模型。然而,它并非没有缺点。

最明显的缺点是代码大小显着增加,因为同一个软件包的许多副本可能具有不同的版本。代码大小的增加通常意味着不仅仅是一个更大的程序 - 它可以对性能产生重大影响。较大的程序不能轻易地适应CPU缓存,只需要一些缓存页的写入和读取就可以显著降低速度。然而,这主要只是一种权衡,因为你牺牲了性能,而不是程序的正确性。

更加隐蔽的问题(以及我在npm生态系统中看到的那个没有太多考虑的问题)是依赖隔离如何影响跨包通信。

依赖性隔离和传递包边界的值

使用ramda的早期示例是npm的默认依赖关系管理方案真正可圈可点的地方,因为Ramda只提供了一堆简单的函数。通过这些是完全无害的。事实上,两个不同版本的Ramda混合功能将是完全可以的!不幸的是,并非所有情况都如此简单。

考虑一下React,React组件通常不是普通的旧数据;它们是复杂的值,可以通过各种方式进行扩展,实例化和呈现。React使用内部私有格式表示组件结构和状态,使用精心安排的键和值的混合以及JavaScript的对象系统的一些更强大的功能。这个内部结构可能会在React版本之间发生很大的变化,因此使用[email protected]定义的React组件可能无法正常使用[email protected]

考虑到这一点,请考虑两个包定义自己的React组件并将其导出供消费者使用。查看它们的依赖树,我们可能会看到如下内容:

awesome-button
└── react ^0.3.0

amazing-modal
└── react ^15.3.1

鉴于这两个软件包使用了非常不同的React版本,npm将根据请求为每个软件包提供自己的React副本,并且可以愉快地安装软件包。但是,如果您尝试将这些组件一起使用,它们根本不起作用!较新版本的React根本无法理解旧版本的组件,因此您可能会遇到运行时错误,这些错误信息往往莫名其妙。

什么地方出了错?好吧,当包的依赖性纯粹是实现细节时,依赖性隔离很有效,这些细节从外部是感知不到的。但是,只要包的依赖性作为其接口的一部分公开,依赖性隔离不仅会出现细微错误,而且会在运行时导致完全失败。在传统的依赖关系管理下,这种情况要好得多,他们会在你尝试安装两个他们不能一起工作的软件包时告诉你,而不是等着你自己解决这个问题。

这可能听起来不是太糟糕 - 毕竟,JavaScript是一种非常动态的语言,所以静态的形式保证可用性几乎都很少,而且如果出现这些问题,你的测试应该能够捕获这些问题 - 但是当两个软件包在理论上可以一起工作很好时,它会导致不必要的问题,但是因为npm为每个人分配了一个特定包的副本(也就是说,它不够聪明,弄清楚它可以给他们两个相同的副本),所以事情就会崩溃。

特别是在npm之外看,并且在应用于其他语言时考虑这个模型,这一点越来越明显,这是行不通的。这篇博客文章的灵感来自一个讨论应用于Haskell的npm模型的Reddit线程,这个漏洞被吹捧为它无法用于这种静态语言的原因。

由于JavaScript生态系统的发展方式,大多数人都可以较为轻松地摆脱这种不正确行为所带来的潜在问题,而不引入任何新的问题。具体来说,JavaScript倾向于依赖duck类型而不是像instanceof这样的更严格的检查,因此满足相同协议的对象仍然是兼容的,即使它们的实现不完全相同。 但是,npm实际上为这个问题提供了一个强大的解决方案,允许包作者明确表达这些“跨接口”依赖关系。

对等依赖

通常,npm包依赖项列在包的package.json文件中的“dependencies”键下。但是,有一个名为“peerDependencies”的另一个较少使用的密钥,其格式与普通依赖项列表相同。区别在于npm如何执行依赖项解析:包不是获取自己的对等依赖项副本,而是期望依赖项由其依赖项提供。

这实际上意味着使用像Bundler和Cabal这样的工具使用的“传统”依赖解析机制有效地解决了对等依赖:必须有一个满足每个人约束的规范版本。从npm 3开始,事情就不那么简单了(具体来说,除非依赖包明确地依赖于对等包本身,否则不会自动安装对等依赖关系),但基本思想是相同的。这意味着包作者必须为他们安装的每个依赖项做出选择:它应该是普通依赖还是对等依赖?

这是我认为人们往往会有点疑惑的地方,即使是那些熟悉对等依赖机制的人。幸运的是,答案相对简单:在程序包可见的任何位置都可以看到有问题的依赖项?

这有时很难在JavaScript中看到,因为“类型”是不可见的; 也就是说,它们是动态的,很少明确地写出来。但是,仅仅因为类型是动态的并不意味着它们在运行时(并且在各种程序员的头脑中)不存在,所以规则仍然成立:如果包的公共接口中的函数类型以某种方式依赖于依赖性,它应该是对等依赖。

为了使这更具体一点,让我们看几个例子。 首先,让我们看看一些简单的案例,从ramda的一些用法开始:

import { merge, add } from 'ramda'

export const withDefaultConfig = (config) =>
  merge({ path: '.' }, config)

export const add5 = add(5)

这里的第一个例子非常明显:在withDefaultConfig中,merge纯粹用作实现细节,因此它是安全的,并且它不是模块接口的一部分。 在add5中,这个例子有点棘手:add(5)的结果是由Ramda创建的部分应用函数,所以从技术上讲,Ramda创建的值是该模块接口的一部分。但是,add5与外界的合约只是它是一个JavaScript函数,它的参数增加了五个,并且它不依赖于任何特定于Ramda的功能,所以ramda可以安全地成为非同类依赖。

现在让我们看一下使用jpeg图像库的另一个例子:

import { Jpeg } from 'jpeg'

export const createSquareBuffer = (size, cb) =>
  createSquareJpeg(size).encode(cb)

export const createSquareJpeg = (size) =>
  new Jpeg(Buffer.alloc(size * size, 0), size, size)

在这种情况下,createSquareBuffer函数使用普通的Node.js Buffer对象调用回调,因此jpeg库是一个实现细节。如果这是该模块公开的唯一函数,则jpeg可以安全地成为非对等依赖项。但是,createSquareJpeg函数违反了该规则:它返回一个Jpeg对象,该对象是一个不透明的值,其结构由jpeg库专门定义。 因此,具有上述模块的包必须将jpeg列为对等依赖项。

这种限制也是相反的。例如,请考虑以下模块:

import { writeFile } from 'fs'

export const writeJpeg = (filename, jpeg, cb) =>
  jpeg.encode((image) => fs.writeFile(filename, image, cb))

上面的模块甚至没有导入jpeg包,但它隐含地依赖于Jpeg接口的encode方法。因此,尽管在代码中的任何地方都没有明确地使用它,但是包含上述模块的包应该包括jpeg作为对等依赖。

关键是要仔细考虑您的模块与其依赖的交互细节。如果这些交互以任何方式涉及其他包,则它们应该是对等依赖关系。如果他们不这样做,他们应该是普通的依赖。

将npm模型应用于其他语言

包管理的npm模型比其他语言更复杂,但它提供了一个真正的优势:实现细节保留为实现细节。在其他系统中,当你个人知道包管理器报告的版本冲突不是真正的问题,但是因为包系统必须选择一个规范版本时,很有可能发现自己处于“依赖地狱”中,没有办法在不调整依赖项中的代码的情况下取得进展。这非常令人沮丧。

这种依赖性隔离并不是现有的最先进的包管理形式 - 实际上远非如此 - 但它肯定比其他大多数主流系统更强大。当然,大多数其他语言只是通过更改包管理器就无法采用npm模型:拥有全局包命名空间可以防止在运行时级别安装同一包的多个版本。npm能够做它的功能是因为Node本身支持它。

也就是说,对等和非对等依赖之间的二分法有点令人困惑,特别是对于不是包作者的人。弄清楚哪个包需要进入哪个组并不总是明显或微不足道的。幸运的是,其他语言也许可以提供帮助。

返回Haskell,其强大的静态类型系统可能会完全自动地检测到这种区别,而当暴露的接口中使用的包未被列为对等依赖时,Cabal实际上可能会报告错误(就像它当前阻止导入的方式一样)传递依赖而不明确依赖它。这将允许帮助程序功能包继续保持实现细节,同时仍保持强大的接口安全性。这可能需要做很多工作才能正确管理类型类实例的全局特性,这可能会比天真的方法更加复杂 - 但它会增加一层目前尚不存在的灵活性。

从JavaScript的角度来看,npm已经证明它可以成为一个有能力的包管理器,尽管不断增长的,不断变化的JS生态系统给它带来了巨大的负担。作为一个软件包作者自己,我会恳请其他用户仔细考虑对等依赖关系功能,并努力使用它来编码他们的接口契约 - 这是npm模型中一个常被误解的宝石,我希望这篇博文有助于解决这个问题。

原文链接: Understanding the npm dependency model

你可能感兴趣的:(DevOps)