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

14,671 阅读7分钟

缘起

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

通过报错信息可以看出是 @sentry/cli 这个包的原因,因为项目中并没有直接依赖这个包,为了排除包之间的影响,就新建了一个文件夹,单独安装这个包,发现还是报一样的错。然后就让同事安装下这个包试一下,发现一切正常,并没有报错。

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

看来事情并没有那么简单

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

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

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

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

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

    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~

下面的才是重点

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

    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…