仓库源文站点原文


title: Verdaccio 性能优化:代理分流 date: 2019-11-30 19:08:03 tags: [Node.js,Verdaccio,private npm registry,npm]

categories: Node.js

前言

这里的 Verdaccio 是指用于搭建轻量级 npm 私有仓库的开源解决方案,以下简称 npm 私服。

前段时间写了一点分流相关的优化思路,但那是以节省资源开销为主、不冲破原有结构的微调,从结果上看,甚至不是合格的优化。

随着用户(请求)数量的上升,服务响应速度和效率其实才是最要紧的问题,节省资源终究不能改善这一点。因此我决定实施上次浮现在脑中的想法,将内外网的 npm 包流量彻底分流。

<!--more-->

关于 Cluster 模式的说明

再次解释,Verdaccio 官方文档明确表示不能支持(PM2)Cluster 模式。另外,其云存储方案是可以支持多进程多节点部署的,但只提供了 google cloud、aws s3 storage 的插件。

不过在此基础上,只要拥有自己的云存储服务,就能使用或设计一套新的存储插件,进而支持多进程架构。此方案一定可行,只是相比本篇的做法,需要的成本更高一些。

俗话说得好,没有一个中间层解决不了的问题,而在 Verdaccio 的场景下,这种做法又是相当地迅速和高效。

原理

npm 安装机制

如果不了解 npm 官方客户端的安装机制,稍后可以阅读阮一峰的博客[[http://www.ruanyifeng.com/blog/2016/01/npm-install.html][《npm 模块安装机制简介》]],少部分知识已经不适用于当前版本了,不过最重要的是能理解 npm 下载流程。

其中我们需要知道,npm 包下载前,客户端会向上游服务器查询包信息,以及获取压缩包的下载地址 url,并将此 url 存放在 package-lock.json 文件中。以后每次执行下载,都会优先使用 package-lock.json 中的地址。

npm 下载最长请求路径

为了方便理解 Verdaccio 所处的位置,我来绘制一下 npm 包下载时从客户端到 Verdaccio 再到上游的最长请求路径简图,并忽略中间的安全验证环节,如下所示。

npm 请求路径

接口转发

有了代理层,就可以忽略 Verdaccio 内部的各种逻辑,不受技术栈的约束,编写少量的代码,便能完成主要接口的分流。

首要的接口是 /:package/:version? ,释放私服最大的查询压力,原因可以看这里的解释

次要的接口是 /:package/-/:filename ,也就是实际的下载接口。并且其中还涉及另一个极为有利的优化。

尽管 Verdaccio 是转发上游的资源,它也会将下载 url 变更为自己的服务域名。因此不论依赖是否私有,记录到 package-lock.json 中的地址都是 Verdaccio 的地址。

但经过代理层的分流,此后经过更新的 package-lock.json 将保留原汁原味的下载地址,此后下载压缩包的请求再也不会发到私服。

综上所述,我们可以将私服超过 99.99% 的流量转移到代理或上游服务。

条件

接下来,我们来确定分流口径,自然是判断一个 package 是否是私服私有,因此需要 Verdaccio 提供接口,获取私有包的列表。

Verdaccio 有一个 /-/verdaccio/packages 接口用来获取所有私有包的信息,但这个包主要用于 Web 页面,包含大量我们不需要的信息,甚至简单一点,只要提供私有 npm 包的包名就能满足筛选条件。

因此,可以改良 /-/verdaccio/packages,例如新增一个专门获取包名列表的接口,并增加内存缓存。

Verdaccio 版本不同时,做法也有很大差异,相信这里的处理不是问题,只要认真阅读上述接口就能获取思路了。

PS:还是补充一点代码吧,早期版本 Verdaccio 只需要这样改:

/**
 * Get name list of all visible package
 * @route /-/verdaccio/names
 */
route.get('/names', async function(req, res, next) {
  // 此处 cache 作为缓存,在有新的私有 npm 包发布时刷新即可
  let names = cache.get('packageNames');
  if (!names) {
    try {
      names = await storage.localStorage.localList.get();
    } catch(err) {
      return next(err);
    }
    cache.set('packageNames', names);
  }
  next(names);
})

最新的 names 要使用回调的方式取值,伪代码:

const names = await new Promise((resolve, reject) =>
  storage.localStorage.storagePlugin.get((err, list) =>
    err ? reject(err): resolve(list)))

实现方式

客户端

客户端也能承担分流的任务,即像 cnpm 一样包装一层自己的 npm cli 工具,但分流的逻辑要简单许多,只需检查要安装的包是否属于私有,然后分为两批安装。

缺陷是推行难度和速度都不理想,于是这里只是顺便提一下。

服务端

到这一步,技术选型已经无所谓了,自然可以 nginx + lua,简单一点就继续使用 Node.js 实现。

由于其他原因,我用 express 做了实现,贴一点转发逻辑,大家就自由发挥吧。

const request = require('request');
const rp = require('request-promise-native');

const publicRegistry = 'http://registry.npm.taobao.org';
const privateRegistry = 'http://npm.private.com';

const sec = 1000;
const min = 60 * sec;

const privateListCache = [];

/**
 * 检查并更新私服包名列表的缓存
 * 缓存可以基于 redis 或内存,注意控制好更新节奏
 */
async function checkPrivateCache() {}

/**
 * npm package 请求分流
 * @route /:packages/:version?   版本检查
 * @route /:packages/-/:filename 下载
 */
async function packages(req, res, next) {
  console.log(req.url)
  await checkPrivateCache();
  // 请求默认转发至 taobao
  let baseUrl = publicRegistry;
  if (privateListCache.length && privateListCache.includes(req.params.package)) {
    // 转发私服的请求
    baseUrl = privateRegistry;
  }

  const options = {
    uri: baseUrl + req.url,
    timeout: 2 * min
  };
  try {
    request(options).on('error', next).pipe(res)
  } catch(err) {
    next(err);
  }
}

/**
 * 其他请求原样转发私服
 * @route /*
 */
function all(req, res, next) {
  // 清除 headers 的 host
  const headers = Object.assign({}, req.headers, { host: undefined })
  const options = {
    uri: privateRegistry + req.url,
    method: req.method,
    timeout: 2 * min,
    headers
  }
  try {
    req.pipe(request(options).on('error', next)).pipe(res);
  } catch (err) {
    next(err)
  }
}

结果

在同样的测试条件下,私服的 /:package/:version? 接口平均响应耗时从 4s 降至 400 ms,可以明显感觉到速度的提升,并且可以通过不断扩展代理层优化处理效率。作为轻量级的私服解决方案,已经可以续命很久了。

后续

这个系列就此结束了吗?当然没有,cluster 的坑还没填呢!也确实可能会鸽掉…

因为支持 cluster 需要较深入的二次开发,也有新的中间件引入,相比目前的成本要高出不少。并且 Verdaccio 新旧版本的逻辑存在一定差异,我在老版本中已经解决了此问题,但新版可能又要另一套实现。

所以,等我读完 Verdaccio 最新的代码再说吧~