记一次排错经历——npm缓存浅析

缘起

一次在安装项目依赖的时候,终端报了下面这个错,致使依赖安装失败。javascript

经过报错信息能够看出是 @sentry/cli 这个包的缘由,由于项目中并无直接依赖这个包,为了排除包之间的影响,就新建了一个文件夹,单独安装这个包,发现仍是报同样的错。而后就让同事安装下这个包试一下,发现一切正常,并无报错。html

接下来就是一通操做:google搜、github issue搜、换成npm安装、切换npm源、切换node版本、安装别的版本 @sentry/cli 、清除yarn和npm的缓存、重启电脑。。。然而发现并无什么卵用。。。java

看来事情并无那么简单

再回过头来看报错信息,能够发现是在执行 node scripts/install.js 时出现的错误,那就把代码拉下来本地跑一下看看咯。说干就干,把 @sentry/cli clone到本地以后,先安装下依赖,而后执行node scripts/install.js 发现以下报错:node

发现其实是在执行 /Users/sliwey/githome/sentry-cli/sentry-cli --version 命令时发生的错误,根据上面的路径发如今项目根目录下多了一个叫 sentry-cli 的可执行文件。git

因此应该是这个文件有问题,那么这个文件是哪里来的呢,看一下 scripts/install.js 的代码,会发现其实就作了一件事:github

downloadBinary()
      .then(() => checkVersion())
      .then(() => process.exit(0))
      .catch(e => {
        console.error(e.toString());
        process.exit(1);
      });
复制代码

就是下载个可执行的文件,而后检查下版本号。checkVersion先按下不表,不是重点,就只是判断下版本号,来看 downloadBinary (我简化了一下代码,加了点注释,具体代码可查看github.com/getsentry/s…):npm

function downloadBinary() {
      const arch = os.arch();
      const platform = os.platform();
      const outputPath = helper.getPath();
    
      // 根据不一样系统获取对应的下载连接
      const downloadUrl = getDownloadUrl(platform, arch);
    
      // 根据下载连接生成缓存路径
      const cachedPath = getCachedPath(downloadUrl);
    
      // 缓存命中,就把文件复制到当前路径下
      if (fs.existsSync(cachedPath)) {
        copyFileSync(cachedPath, outputPath);
        return Promise.resolve();
      }
    
      // 缓存未命中,就下载,并把文件写入缓存
      return fetch(downloadUrl, { redirect: 'follow', agent }).then(response => {
        const tempPath = getTempFile(cachedPath);
        mkdirp.sync(path.dirname(tempPath));
    
        return new Promise((resolve, reject) => {
          response.body
            .pipe(fs.createWriteStream(tempPath, { mode: '0755' }))
        }).then(() => {
          copyFileSync(tempPath, cachedPath);
          copyFileSync(tempPath, outputPath);
          fs.unlinkSync(tempPath);
        });
      });
    }
复制代码

根据刚才本地的执行状况来看,并无进行下载,可知那个可执行文件是从缓存中拿的,那就打个断点看一下缓存路径:缓存

根据获得的路径,删除对应文件,而后从新安装,everything is ok~bash

下面的才是重点

虽然问题解决了,可是回想了一下以前的一通操做,其中是有作过缓存清除的,包括yarn和npm,当时的作法是经过下面两个命令作的:fetch

yarn cache clean
    npm cache clean --force
复制代码

根据上面获得的缓存路径,能够知道 sentry-cli 缓存在 ~/.npm 文件夹下,因此跟yarn应该不要紧,先排除掉。而后来看npm,发现经过 npm cache clean --force 来清除缓存,并无清掉 ~/.npm 文件夹下的文件,那么这个命令清的是哪里呢?先看下文档怎么说:npm-cache

为了阅读方便,我截了几个图:

乍一看貌似没什么毛病,检查了一下本身的cache配置,也没有发现什么异常:

那么究竟是哪里的问题呢,看来只能看下源码了,目标很直接,找到npm中跟cache相关的代码,而后直接看clean方法的实现(具体代码能够看 lib/cache.js):

function clean (args) {
      if (!args) args = []
      if (args.length) {
        return BB.reject(new Error('npm cache clear does not accept arguments'))
      }
    
      // 重点在这
      // npm.cache就是 ~/.npm
      // 因此cachePath的值应该是 ~/.npm/_cacache
      const cachePath = path.join(npm.cache, '_cacache')
      if (!npm.config.get('force')) {
        return BB.reject(new Error("As of npm@5, the npm cache self-heals from corruption issues and data extracted from the cache is guaranteed to be valid. If you want to make sure everything is consistent, use 'npm cache verify' instead. On the other hand, if you're debugging an issue with the installer, you can use `npm install --cache /tmp/empty-cache` to use a temporary cache instead of nuking the actual one.\n\nIf you're sure you want to delete the entire cache, rerun this command with --force."))
      }
      // TODO - remove specific packages or package versions
      return rm(cachePath)
    }
复制代码

看到这就很明白了, npm cache clean --force 清的是 ~/.npm/_cacache 文件夹中的数据。

转念一想,这一点在文档中不该该不提啊,再回去看一下文档,发现漏看了一块内容。。。

内容以下:

简单来讲在 npm@5 以后,npm把缓存数据放在配置文件中 cache 字段配置的路径下面的 _cacache 文件夹中。结合上面两段文档的内容,可得出:

  • 配置文件中的 cache 字段配置的是根目录
  • 缓存数据放在根目录中的 _cacache 文件夹中
  • clean 命令清除的是 _cacache 文件夹

npm缓存到底存了什么

打开 _cacache 文件夹,发现里面并非像 node_modules 里面同样一个个的包,而是这样的:

打开能够发现 content-v2 里面基本都是一些二进制文件,把二进制文件的扩展名改成 .tgz 再解压以后,会发现就是在咱们熟知的npm包。 index-v5 里面是一些描述性的文件,也是 content-v2 里文件的索引,仔细看会发现有点像HTTP的响应头,并且还有缓存相关的值:

那么这些文件是怎么生成的呢?从上面的文档中,能够得知,npm 主要是用 pacote 来安装包的,咱们来看一下 npm 在代码中是怎么使用pacote的吧。npm主要有如下三个地方会用到 pacote:

对比上述三个 pacote 的方法能够发现,其主要依赖的方法是 lib/withTarballStream.js,代码比较多,简化一下,主要看中文注释就好:

function withTarballStream (spec, opts, streamHandler) {
      opts = optCheck(opts)
      spec = npa(spec, opts.where)
    
      // 读本地文件
      const tryFile = (
        !opts.preferOnline &&
        opts.integrity &&
        opts.resolved &&
        opts.resolved.startsWith('file:')
      )
        ? BB.try(() => {
          const file = path.resolve(opts.where || '.', opts.resolved.substr(5))
          return statAsync(file)
            .then(() => {
              const verifier = ssri.integrityStream({ integrity: opts.integrity })
              const stream = fs.createReadStream(file)
                .on('error', err => verifier.emit('error', err))
                .pipe(verifier)
              return streamHandler(stream)
        })
        : BB.reject(Object.assign(new Error('no file!'), { code: 'ENOENT' }))
    
      // 上一步reject以后,从缓存中读
      const tryDigest = tryFile
        .catch(err => {
          if (
            opts.preferOnline ||
          !opts.cache ||
          !opts.integrity ||
          !RETRIABLE_ERRORS.has(err.code)
          ) {
            throw err
          } else {
    	    // 经过cacache来读缓存中的数据
            const stream = cacache.get.stream.byDigest(
              opts.cache, opts.integrity, opts
            )
            stream.once('error', err => stream.on('newListener', (ev, l) => {
              if (ev === 'error') { l(err) }
            }))
            return streamHandler(stream)
              .catch(err => {
                if (err.code === 'EINTEGRITY' || err.code === 'Z_DATA_ERROR') {
                  opts.log.warn('tarball', `cached data for ${spec} (${opts.integrity}) seems to be corrupted. Refreshing cache.`)
                  // 当错误码为EINTEGRITY或Z_DATA_ERROR时,清除缓存
                  return cleanUpCached(opts.cache, opts.integrity, opts)
                    .then(() => { throw err })
                } else {
                  throw err
                }
              })
          }
        })
    
      // 上一步reject以后,再下载
      const trySpec = tryDigest
        .catch(err => {
          if (!RETRIABLE_ERRORS.has(err.code)) {
          // If it's not one of our retriable errors, bail out and give up.
            throw err
          } else {
            return BB.resolve(retry((tryAgain, attemptNum) => {
    
    	      // 下载包,这边实际上是经过npm-registry-fetch来下载的
              const tardata = fetch.tarball(spec, opts)
              if (!opts.resolved) {
                tardata.on('manifest', m => {
                  opts = opts.concat({ resolved: m._resolved })
                })
                tardata.on('integrity', i => {
                  opts = opts.concat({ integrity: i })
                })
              }
              return BB.try(() => streamHandler(tardata))
            }, { retries: 1 }))
          }
        })
    
      return trySpec
        .catch(err => {
          if (err.code === 'EINTEGRITY') {
            err.message = `Verification failed while extracting ${spec}:\n${err.message}`
          }
          throw err
        })
    }
复制代码

从上述代码中,能够知道 pacote 是依赖 npm-registry-fetch 来下载包的。查看 npm-registry-fetch 的文档发现,在请求时有个 cache 属性能够设置:npm-registry-fetch#opts.cache

可知,若是设置了 cache 的值(npm中是 ~/.npm/_cacache),便会在给定的路径下建立根据IETF RFC 7234生成的缓存数据。打开那个rfc的地址,发现就是描述 HTTP 缓存的文档,因此本段开头说的 index-v5 下面的文件也就好理解了。

简单总结一下:

  • ~/.npm/_cacache 中存的是一些二进制文件,以及对应的索引。
  • npm install 时,有缓存的话,会经过 pacote 把对应的二进制文件解压到相应的 node_modules 下面。
  • npm自己只提供清除缓存和验证缓存完整性的方法,不提供直接操做缓存的方法,能够经过 cacache 来操做这些缓存数据。

写在最后

回顾了一下整件事情,发现文档看仔细是多么重要!谨记!谨记!可是也把平时不怎么关注的点梳理了一遍,也算是有所收获,以文字的形式记录下来,便于回顾。

原文连接: github.com/sliwey/blog…

相关文章
相关标签/搜索