普通视图

发现新文章,点击刷新页面。
昨天以前Claude's Blog

从 is-promise 事件我们可以学到什么

作者 Claude Ray
2020年5月16日 19:35

前言

4 月 25 日,NPM 社区又一次因更新事故引燃技术圈的讨论,导火索便来自名为 is-promise 的包。

网上盛传一个单行代码的包影响到了谷歌、FaceBook、亚马逊等众多大咖的知名项目,也有人扬言它使几乎整个 JavaScript 生态陷入了混乱。

不过“雪崩”之时,我和身边人都没有体会到震感,不禁疑惑,平时很少有场景需要判断某个值是否为 Promise,如此名声不显、功能又不重要的 NPM 包,真的有这么大的影响和破坏力吗?

既是好奇心的驱使,也是不认同部分夸张的言辞,我决定向前一探究竟。

is-promise 简介

先解读一下事故发生之前,is-promise 2.1.0 版本的完整代码。

1
2
3
4
5
module.exports = isPromise;

function isPromise(obj) {
return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function';
}

这是一个比较宽松的 Promise Like 检查函数,虽然包名叫 is-promise,其实更像 is-thenable。别看只有一行的逻辑,需要不浅的功力才能准确写出。

例如,前置的 typeof 能有效过滤 String.prototype.then = function () {} 这样不合规范的 thenable 字符串。

我们可以不使用,但不该贬低这个包的价值。Promise/A+ 是一个自由的规范,而非语言特性,长久以来有着众多版本实现,采取这种具有包容性的判断方式是合情合理的。

类似的 NPM 包还有 Sindre Sorhus 的 p-is-promise,它增加了 catch 方法的检查。

回顾

让我们一起回到那个周末,重新审视整个事件的始末。

时间线

is-promise 作者 Forbes Lindesay 回顾了当时的主要历程:

2020–04–25T15:03:25Z — 发布存在问题的 2.2.0
2020–04–25T17:16:00Z — Ryan Zimmerman 提交了修复 PR
2020–04–25T17:48:00Z — 在社交软件上收到告警
2020–04–25T17:54:00Z — 合并 Ryan 的 PR,发布 2.2.1
2020–04–25T17:57:00Z — 阅读并关闭 BUG 相关的 issues,重新开了一帖以便集中沟通
2020–04–25T18:06:00Z — Jordan Harband 提到 “exports” 字段仍然存在问题
2020–04–25T18:08:08Z — 从 package.json 中移除 “exports” 字段,发布 2.2.2
2020–04–25T19:20:00Z — 撤销 2.2.0 和 2.2.1

可见,作者收到告警信息后的反应是非常迅速的,但撤销操作滞后的问题仍需要指责。

接下来,我们逐个分析 2.2.x 版本的更迭。

2.2.0

  • 添加 Typescript 声明文件
  • 支持 ES Module 风格的 import

站在上帝视角,我们明确知道问题出在这里,作者在 package.json 中新增了两个字段

1
2
3
4
5
6
7
{
"type": "module",
"exports": {
"import": "index.mjs",
"require": "index.js"
}
}

很快,就有人反馈 BUG,一共有两类报错

错误一:exports 的文件路径遗漏了 ‘./‘,在 Node.js 中

1
Error [ERR_INVALID_PACKAGE_TARGET]: Invalid "exports" main target "index.js" defined in the package config /xxx/node_modules/is-promise/package.json; targets must start with "./"

错误二:添加了 type: module,导致 require 被禁用,必须使用 import 才能引入。

1
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /xxx/node_modules/is-promise/index.js

以及被隐藏的错误三:没有更新 package.json 中的 files 字段,导致 index.mjs、index.d.ts 没有一起打包发布。

2.2.1

  • 修复错误的 ESM 用法

改动后的 package.json 包含如下

1
2
3
4
5
6
{
"exports": {
"import": "./index.mjs",
"require": "./index.js"
}
}

然而,如果使用 require(‘is-promise/package.json’) 引入模块下其他文件,则会抛出

1
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package.json' is not defined by "exports" in /Users/claude/Workspace/test/is-p/node_modules/is-promise/package.json

甚至不允许引用 ‘is-promise/index’ 和 ‘is-promise/index.js’。

2.2.2

  • 从 package.json 删除 exports 字段

为了彻底解决 2.2.0 带来的 Breaking Change,终于在 2.2.2 删掉了 exports 字段。

问题字段解析

本次事故源于两个少见的 package.json 字段,我们已经见识到了其副作用,但还没搞明白为什么会被作者引入,不妨进一步明确它们的概念。

官网文档在 12.x 及以上版本都包含这些字段的描述,但是并不代表 12.x 用户一定享受到了这个特性。

type

它决定当前 package.json 层级目录内文件遵循哪种规范,包函两种值,默认为 commonjs。

  • commonjs: js 和 cjs 文件遵循 CommonJS 规范,mjs 文件遵循 ESM 规范
  • module: js 和 mjs 文件遵循 ESM 规范,cjs 文件遵循 CommonJS 规范

要正常使用这个特性,在 Node.js v12.x 的早期版本,必须主动开启 –experimental-modules。但是从 v12.16.0 以后就有些混乱,不开启选项的情况下错误使用该字段会立即抛出异常。直到了 v13.2.0 正式引入,取消了实验特性的标识,才算恢复正常。

is-promise 将 type 显式指定为 module,显然会影响到特定版本的 CommonJS 用户。

exports

type 是相对较老的特性,exports 则是鲜有人知。

功能来自 proposal-pkg-exports 提案,以实验特性 –experimental-exports 加入 v12.7.0,于 v12.16.0 正式引入。具体时间线可以通过这个 PR 追溯。

下面看它的具体作用。

通常,我们用 main 字段指定包的入口文件,但也仅限于指定唯一的入口文件。

exports 字段是 main 的补充,支持定制不同运行环境、不同引入方式下的入口文件,也支持导出其他文件,看下面的例子便知。

1
2
3
4
5
6
7
8
9
10
{
"main": "./main.js",
"exports": {
".": "./main.js",
"./feature": {
"browser": "./feature-browser.js",
"default": "./feature.js"
}
}
}

但值得注意的是,在支持 exports 的 Node.js 版本中,exports 会覆盖 main.js。

exports 一旦被指定,只能引用 exports 中显示导出的文件。

用下面这种特殊写法,才能允许项目内所有文件被导出(未经过充分测试)。
但缺点是无法使用 import isPromise from 'is-promise/index’,而必须带上文件后缀 import isPromise from 'is-promise/index.mjs'

1
2
3
4
5
6
{
"exports": {
".": ".",
"./": "./",
}
}

此外,作者想当然以为 exports 和 main 字段一样,支持省略 “./“,这在文档中并没有交代。

作者复盘

事后,作者发布了一篇 《is-promise post mortem》,他公开说明了上述的一部分错误,还总结了致使犯错的几个因素

  • 习惯于本地发布,不经过 CI 验证
  • 使用新特性,CI 却没有添加支持新特性的 Node 版本
  • 只验证了代码,没有验证实际发布到 NPM 的包
  • 本人不在,其他维护者没有途径发布修复补丁

总结下来就两点,测试不充分,流程不规范。

再谈影响

我翻找了相关 ISSUES,发现 create-react-app@angular/clifirebase-tools 等项目的确受到影响,具体表现则为安装、构建失败。

再回看 NPM 生态,is-promise 周下载量在千万级,存在直接引用关系的就有 766 个包(现只剩 561,受事故影响,许多包取消了引用),GitHub 显示依赖它的项目更是有 3.5m 之众。

从问题版本 2.2.0 发布,到 2.2.2 修复,历时约 3 个小时,考虑到 NPM 的缓存机制,实际影响时间会被拉长。

因此,它的影响范围的确很广,但实际没有那么夸张。

一方面,Node.js 12.16.0 以前的 LST 和更早版本才是主流,这些运行时可被认定为安全。

另一方面,遭到辐射的项目(大多为 CLI 工具)并不具备整个生态的代表性,也不会危及生产环境。

旁观者的思考

看过了问题,也借此反思一下如何避免悲剧发生在自己身上吧。

锁定版本

加锁可以 100% 避免本次意外,尤其面向应用开发者,这是一直在呼吁的工作,却很少真正落地。

不要吐槽 package-lock.json 会自己变,因为只有一个 lock 文件是不成气候的,如果 package.json 没有锁定版本,NPM 才会使用浮动的版本覆盖 package-lock.json。

但对于 NPM 包的开发者,除非是对稳定性有所要求的工具链、产品,还是不建议滥用版本锁定。如果所有的 NPM 包都这么做,一定会加大 node_modules 的混乱程度,也不利于及时享受到相关依赖的修复补丁,反而提高了维护难度。

单元测试

测试的重要性无须多言。

is-promise 的新增更改根本没有得到测试覆盖,甚至连 require 引入都会报错。除了开发者要完善 CI,NPM 是否也有提供内置检测服务的义务呢?

该不该使用小型代码库

小型库背后是众多开源人士的努力贡献,优质的文档、测试用例远超代码的原始价值。

is-promise 的问题不在于它有几行代码,并且代码逻辑没有变更。

个人认为,NPM 包开发者有必要减少依赖数量,应用开发者则可以自由决定。引用也好,套用也罢,但至少请给这些代码的作者和协议应有的尊重。

文档不济

2.2.0 这个版本号的使用是否得当,如果只从功能上看,它是向下兼容 2.1.0 的一次更新吗?

看过上面 exports 字段的介绍可以得知,它当然属于 Breaking Change,但 Node.js 文档的描写是模糊的,让 is-promise 的作者认为 exports 是无害的。

官网通篇没有一个警告字样,如果没有这次事故后才提交的 PR,恐怕会有更多的人掉入坑中。

Yarn or NPM

曾经有不少人倾向于 Yarn 的机制,时至今日,Yarn 和 NPM 的差距已经大大收缩,两者都是不错的选择,我唯一建议是不要混合使用。

publish

Yarn 的速度已经没有特别大的优势

还有像 PNPM 这类致力于改进 NPM 生态的努力,值得我们持续关注。

总结

当前仍在批判 NPM 生态的人群,大部分不会参与 JS 社区的建设,愿改善现状而贡献的更是凤毛麟角。

各位 NPM 用户无须危言耸听,人有失手,马有失蹄,只要规范流程,能够有效降低负面影响。

逆耳未必是忠言,希望更多有价值的声音能被发出。

Verdaccio 性能优化:代理分流

作者 Claude Ray
2019年11月30日 19:08

前言

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

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

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

关于 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 只需要这样改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 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 要使用回调的方式取值,伪代码:

1
2
3
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 做了实现,贴一点转发逻辑,大家就自由发挥吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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 最新的代码再说吧~

❌
❌