普通视图

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

浅谈 npm 依赖治理

作者 Claude Ray
2022年6月28日 12:16

想想项目创建之后,多久没给 npm 依赖升级了?

如何得知当前项目 npm 依赖的“健康度”?

给老项目升级 npm 依赖,有哪些注意事项?

核心诉求

  • 提高可维护性。不容易和后引入的依赖产生冲突。引入新特性,功能表现和文档描述接近,后续开发也能得心应手。
  • 提高可移植性。方便老项目向高版本 npm 或 pnpm 迁移。
  • 提高可靠性。只要依赖还在稳定迭代,升级必定能引入一系列 bugfix(却也可能引入新 bug)。
  • 提高安全性。官方社区会及时通告 npm 依赖的安全漏洞,将版本保持在安全范围,能排除许多隐患。

流程方法

  • 使用专业的评估工具。手动升级 @latest 等于把依赖当成黑盒来操作。
  • 按优先级处理。集中精力升级核心依赖,以及含有安全隐患的库,否则时间投入很容易超出预期。
  • 阅读 changelog,评估升级影响。
  • 回归测试,十分重要。

除了回归测试以外,主导治理的人不仅要熟悉项目内容,也要对计划升级的 npm 包有充分了解。如果没有合适的人选,建议继续在代码堆里坚持一会儿,毕竟升级有风险,后果得自负。

检索工具

以下内容以 npm 为例,pnpm 和 yarn 有可替代的命令。

过时依赖 npm outdated

npm outdated 命令会从 npm 源检查已安装的软件包是否已过时。

随便拿几个包举例:

1
2
3
4
5
6
7
8
9
10
11
Package                             Current   Wanted        Latest  Location
axios 0.18.1 0.18.1 0.27.2 project-dir
log4js 2.11.0 2.11.0 6.5.2 project-dir
lru-cache 4.1.5 4.1.5 7.10.2 project-dir
socket.io 2.4.1 2.5.0 4.5.1 project-dir
vue 2.6.14 2.6.14 3.2.37 project-dir
vue-lazyload 1.3.3 1.3.4 3.0.0-rc.2 project-dir
vue-loader 14.2.4 14.2.4 17.0.0 project-dir
vue-router 3.5.3 3.5.4 4.0.16 project-dir
vuex 3.6.2 3.6.2 4.0.2 project-dir
webpack 3.12.0 3.12.0 5.73.0 project-dir

默认只会检查项目 package.json 中直接引用的依赖, --all 选项可以用来匹配全部的依赖。但没有必要,真要彻底升级,更推荐尝试重建 lock 文件。

对于 outdated 的包,使用 npm update 或其他包管理工具对应的 update 命令即可安装 SemVer 标准执行升级。如果想跨越 Major 版本,则需要手动指定升级版本。

风险依赖 npm audit

npm audit 命令同样是向 npm 源发起请求,它将 package-lock.json 作为参数,返回存在已知漏洞的依赖列表。 换句话说,audit 不需要安装 node_modules 就可以执行,其结果完全取决于当前的 package-lock.json。

返回节选如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Run  npm install swiper@8.2.5  to resolve 1 vulnerability
SEMVER WARNING: Recommended action is a potentially breaking change
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Critical │ Prototype Pollution in swiper │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package │ swiper │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ swiper │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path │ swiper │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info │ https://github.com/advisories/GHSA-p3hc-fv2j-rp68 │
└───────────────┴──────────────────────────────────────────────────────────────┘
found 125 vulnerabilities (8 low, 66 moderate, 41 high, 10 critical) in 2502 scanned packages
run `npm audit fix` to fix 15 of them.
96 vulnerabilities require semver-major dependency updates.
14 vulnerabilities require manual review. See the full report for details.

如果你发现执行结果为 404,说明当前源不支持 audit 接口,可更换到支持 audit 的官方源重新执行。

1
2
3
4
npm http fetch POST 404 https://registry.npmmirror.com/-/npm/v1/security/audits 306ms
npm ERR! code ENOAUDIT
npm ERR! audit Your configured registry (https://registry.npmmirror.com/) does not support audit requests.
npm ERR! audit The server said: <h1>404 Not Found</h1>

结果中虽然提到了 npm audit fix 命令,却不总是可靠的,它能修复的依赖有限,远不如通过升级 root 依赖修复间接依赖带来的数量明显。

隐式依赖 npx depcheck

npm cli 工具 depcheck 能辅助我们找到项目中 Unused dependencies(无用依赖)和 Phantom dependencies(幻影依赖),分别表示写入 package.json 但没被项目使用、被项目引用了但没有写入 package.json。

depcheck 更像是一个缩小排查范围的过滤器,不能轻信其打印结果。例如,depcheck 默认无法识别特殊挂载的 plugin。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Unused dependencies
* clipboard
* cross-env
* firebase
* proxy
* route-cache
* socket.io
Unused devDependencies
* add-asset-html-webpack-plugin
* commitizen
* eslint
* husky
* jasmine
* rimraf
* stylelint
Missing dependencies
* node-notifier: ./build/utils.js

要删除一个无用依赖,必须熟悉该 npm 包的使用性质,再结合 grep 工具反复确认。

僵尸依赖 npm install

最后,还要提防一种 Zombie dependencies(僵尸依赖)。不同于前面介绍的隐式依赖,它的危害很大。

首先它切实被项目使用,但已经被维护者 deprecated 或 archieved。意味着版本不再更新,包名不会出现在 outdated 列表;很可能没人报告漏洞,也不会出现在 audit 列表。但潜在的 bug 无人修复,它将一直躲藏在项目里,伺机而动。

笔者没发现合适的工具去寻找僵尸依赖,只好多留意 npm install 的 deprecated 日志。

治理建议

如何阅读 CHANGELOG

changelog 一般位于代码仓库的 CHANGELOG.mdHistory.md,随意一些的也可能放在在 Github 的 releases 页,正式一些的会放在官方网站的 Migrations 类目。

如果发现一个 npm 包没有 changelog,或 changelog 写得太差,建议换成其他更靠谱的替代品 ,就只能靠阅读 commits 了。

关键词(欢迎补充):

  • BREAKING CHANGE
  • !
  • Node.js

开发者普遍会用上面的方式标注不兼容的变更。

lock 文件版本管理

该建议是对商业软件的研发流程而言。活跃的开源场景并不需要 lock 文件,为了开发者迭代和测试的过程能趁早发现兼容性问题。

package-lock.json 的设计文稿就直言推荐把 lock 文件加入代码仓库:

  • 保证团队成员和 CI 能使用完全相同的依赖关系
  • 作为 node_modules 的轻量化备份
  • 让依赖树的变化更具可见性
  • 加速安装过程

但是,npm 依赖管理的策略因团队和项目而异,是否提交 lock 文件到 git 仓库可以按需取舍,版本管理的形式还有很多。

例如研发流程完善,每次发布的 lock 文件都会留在制品库或镜像中,能够随时被还原。可如果缺少相关举措,就要想办法将生产环境的 lock 文件备份,为问题复现、故障恢复提供依据。

更新 hoisting

常年累月的更新之下,许多 package-lock.json 的外层依赖的版本会落后于子节点,因为目前 npm 为了保持最小更新幅度,不会对 lock 树做旋转和变形。即使更新的项目的直接依赖到 latest,它的间接依赖可能还是旧的,以致现存的依赖提升结果和默认 hoisting 算法的偏差越来越大。

一些老项目脱离 package-lock.json 文件之后,甚至无法正常安装构建。此时依赖已经处于非常不健康的状态,开发者需要担心新引入的依赖是否会破坏平衡,无法迁移 npm 包管理工具,也不能升级 Node.js 版本。不过亡羊补牢并不复杂,总好过修复一个没有 package-lock.json 的项目。

想生成一份可靠的 package-lock.json,最简单的办法就是除旧迎新:

1
2
rm -rf package-lock.json node_modules
npm i

更好的办法是换到不使用 hoisting 的依赖管理工具。

情况讲清楚了,什么时候重建可以看自身需求。但是,将 lock 文件加入 .gitignore 的同学就要注意了,如果别人出现了你本地无法复现的问题,记得先删掉 package-lock.json。

整理 dependencies 和 devDependencies

package.json 中 dependenciesdevDependencies 的区别就不必介绍了,但大家在项目中是否会做严格区分呢?

一来 devDependencies 是为 npm 包优化依赖关系设计的,作为应用的项目通常不会打包发布到 npm 上;二来不作区分也没有直接带来不良后果。因此经常会有小伙伴将开发环境依赖的工具直接安装到 dependencies 中。

不过,即使对项目而言,devDependencies 也有积极意义:

  • 能从语义上划分依赖的用途
  • 使用 npm install --production 可以忽略 devDependencies,提高安装效率,显著减少 node_modules 的体积

第二点还需要做个补充说明,由于静态项目的构建环境往往需要安装大部分 devDependencies 中的依赖,一般只有放在服务端运行的 Node.js 项目才需要考虑这么做。但随着 TypeScript 的普及或是 SSR 的引入,这些服务端项目在运行前也需要执行构建。那还有什么用?别忘了,还有一个 npm prune --production 能用作后置的项目体积优化。

当然了,语义划分带来帮助也足够大了,例如根据依赖关系来优化 npm 治理的优先级和策略。

顺便再提一句,dependencies 和 devDependencies 不是用来区分重要程度,请不要把运行可有可无的依赖放在 devDependencies,应该放在 optionalDependencies 中。

结语

以上介绍的经验多为概述,主要结合 npm 依赖管理工具的特点,没能介绍 yarn 和 pnpm 等工具独有的 API 和问题,如果读者想了解更多内容,请查阅相关文档。此外,同时使用多种依赖管理工具的项目颇为复杂,比较少见,本文未作分析,也不建议读者朋友们尝试。而在软件工程领域,依赖治理还有很多要点需要我们去进一步实践,不过内容更侧重于 refactor。

回到标语所提项目依赖的“健康度”,实为笔者胡诌,用来形容依赖关系的混乱程度。不做这些依赖治理,也没有太大关系,因为软件的生命周期往往坚持不到依赖关系崩坏的那天。但混乱的依赖管理,却能轻易促成代码的腐化。

记录 Got(Node.js) 代理 HTTP 请求的坑

作者 Claude Ray
2021年1月27日 19:05

在过去,request 模块几乎是 Node.js 端的不二选择,可惜已被放弃维护。如今流行的模块虽然变多,但不意味着它们足够成熟,我还是倾向于专注 Node.js 端的那几个。

需求越简单,选择越不重要。不过相较而言,Got 的接口设计看起来更友好,并且它是做到支持 Connection Timeout 和 Read Timeout 的少数。

https://github.com/sindresorhus/got/#comparison 的 Advanced timeouts

实际用下来,还是遇到了坑,顺便扒了一眼 Got 的代码。

native http?

先解释一下为什么不用原生 http 模块吧。

毕竟在 Node.js 提供代理服务是非常容易的事儿,普遍的优化无非是:

  • 使用 http.Agent。用它来支持 keepAlive,关闭 Nagle 算法等等。
  • 使用 stream.pipe()。相对于“接收-等待-发送”的模式,一方面主要是节省内存,另一方面可以减少等待,提高传输效率(但也不是绝对的)。

不用安装第三方依赖,基于原生 http 写上十来行代码即可完工,所以我自己很喜欢直接用 http 做一些工作。

但如果代理需求变得复杂,使用现成的轮子才能利于队友们(也包括自己)维护。

decompress

Got 默认对响应执行 decompress,对于代理而言毫无意义,需要关掉。

1
2
3
4
5
6
7
async _onResponseBase(response: IncomingMessageWithTimings): Promise<void> {
const {options} = this;

if (options.decompress) {
response = decompressResponse(response);
}
}

这在 decompress 的文档上有说明,但是一点儿都不醒目。

If this is disabled, a compressed response is returned as a Buffer. This may be useful if you want to handle decompression yourself or stream the raw compressed data.

最坑的是,我是因为遇到了 stream 的 bug (#issues/1279) 才注意到这个选项。在开启 decompress 的情况下,使响应值的 content-length 是错误的,该 bug 会导致返回的结果不完整。

accept-encoding

关闭 decompress 之后,响应开始变慢到肉眼可见得慢。

原因如下:

1
2
3
4
5
6
7
8
async _makeRequest(): Promise<void> {
const {options} = this;
const {headers} = options;

if (options.decompress && is.undefined(headers['accept-encoding'])) {
headers['accept-encoding'] = supportsBrotli ? 'gzip, deflate, br' : 'gzip, deflate';
}
}

accept-encoding 只在 decompress 为 true 的时候设置,否则无法享用 gzip 等压缩带来的优化。

solution

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
const got = require('got');
const HttpAgent = require('agentkeepalive');
const HttpsAgent = HttpAgent.HttpsAgent;

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

const supportsBrotli = process.versions.brotli;

const request = got.extend({
agent: {
http: new HttpAgent(),
https: new HttpsAgent(),
},
// content-length is not set correctly when streaming with decompress
// #issues/1279
decompress: false,
// accept-encoding won't be set without decompress
headers: {
'accept-encoding': supportsBrotli ? 'gzip, deflate, br' : 'gzip, deflate',
},
timeout: {
connect: 3 * sec,
socket: 2 * min,
},
});

这大概率不是最终版,立一个 flag,如果再遇到问题,就自己从头写一个专用于代理场景的 http 库。

如何管理桌面窗口

作者 Claude Ray
2020年12月23日 20:49

想向同事分享窗口切换的一点心得,不小心写成一篇没什么营养的方法论。虽然主要还是讲窗口切换。

publish

图片未加速

什么是窗口管理

如果你不知道什么是窗口管理,在开启话题之前,不妨先来确认一下窗口管理器和桌面环境的概念。

首先说桌面环境,翻译自 Desktop Environment,它负责为用户提供完整的操作界面,而不仅仅是狭义的“桌面部件”,还包括图标、窗口、工具栏、壁纸等等。

再说窗口管理器,Window Manager,它是上述桌面环境的一部分,关乎图形化应用的窗口的基本操作,主要为如何进行排列和切换。窗口管理器是桌面环境的一部分,甚至可以完全独立于桌面环境,只运行窗口管理器,从而节省硬件资源。它包含以下类型:

  • Float 悬浮:不同窗口可以相互重叠,就像桌子上随意摆放的白纸一样(这里借用了 Archlinux Wiki 的比喻)。正是 MacOS 和 Windows 提供的模式。
  • Tiling 平铺:窗口不能重叠,而是像瓦片一样挨个摆放。
  • Dynamic 动态:兼顾上述两种模式,可以动态切换窗口放置方式。

不同类型的窗口管理器提供了不同的窗口摆放方式,还提供了各自的窗口切换逻辑,其中“平铺”更倾向于使用键盘操作,如何选择,主要看个人口味。

虽然着重介绍了窗口管理器,但它不是今天的主角,我们应该跳出所有的运行环境,去发现真正的“窗口管理器”其实是使用者自己。

排列方式

窗口排列是一个答案无足轻重选择题,需要结合显示器的使用习惯作答。如果仅从思路上讲,相比手动排列,自动排列无疑是更好的选择,此时平铺类窗口管理器的优势就体现出来了。

然而,Linux 可以非常轻松地调换窗口管理器,在 MacOS 下可供的选择就不多了。yabai 要求关闭 SIP,提高了安全风险,Amethyst 功能较弱,好在轻量可控。如果放弃一点点平铺的功能性,可以选择 moom 这类辅助布局软件。考虑到本文不是工具推荐,也就不再介绍更多。

对使用小屏幕和习惯全屏的用户而言,绝大多数的使用场景是全屏,则没必要安装辅助工具。

切换方式

操作背景

按以下特征对号入座,目的是想让大家思考不同使用习惯之间的异同点。现在你的窗口管理习惯,是否适用于其他的用户呢?

窗口模式:

  • 全屏化
  • 窗口化
  • 最小化(隐藏)

桌面分布:

  • 单显示器-单桌面:将所有开启的窗口放在同一个桌面下,不采用任何虚拟桌面。
  • 单显示器-多桌面:(按照习惯)将不同的软件放在不同的虚拟桌面下。
  • 多显示器-单桌面:和多桌面类似,但不采用虚拟桌面,每台显示器就是一个桌面。
  • 多显示器-多桌面:各台显示器放置了不同的虚拟桌面,互相隔离。

操作习惯

  1. 鼠标/触摸板
  2. 全局快捷键(系统默认)
    • MacOS 可以使用 command+tab 和 command+` 切换,Linux、Windows 有 alt+tab
    • MacOS 可以使用 control+↑ 和手势操作,Linux、Windows 有 win、win+tab
  3. 启动器:例如 MacOS 的 Spotlight、Alfred,Windows 的 Everything,Linux 的 rofi、dmenu 等等
  4. 全局快捷键(自定义)

它们的区别:

  1. 寻找(思考) -> 移动(思考+操作) -> 确认(操作)
  2. 寻找(操作+思考) -> 确认(操作)
  3. 寻找(思考+肌肉记忆) -> 确认(操作)
  4. 确认(思考+肌肉记忆)

肌肉记忆≈闭眼操作

虽然存在很大的误差,但不难发现,桌面越复杂,操作复杂度的差距就越明显。

如何自定义快捷键

两个代表性工具,MacOS Hammerspoon,Linux wmctrl。同事 MacOS 开发较多,因此以 Hammerspoon 为例。

1
2
3
4
5
6
7
8
9
10
11
local hyper = {"cmd", "shift"}

-- 示例:打开或切换到浏览器
hs.hotkey.bind(hyper, "C", function()
hs.application.launchOrFocus("/Applications/Google Chrome.app")
end)

-- 示例:打开或切换到终端
hs.hotkey.bind(hyper, "Return", function()
hs.application.launchOrFocus("/Applications/Alacritty.app")
end)

假如一个应用开启了多个窗口,也可以通过窗口标题、序号进行精准切换。

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
--- 根据标题切换应用窗口
-- @param appTitle 系统 menu bar 左上角的标题
-- @param appName 安装目录的名称或绝对路径
-- @param winTitle 模糊匹配项目名,注意 .()[]+- 等字符需要转义
function launchOrFocusWindow(appTitle, appName, winTitle)
return function()
local app = hs.application(appTitle)
if app == nil then
hs.application.open(appName)
else
local windows = app:allWindows()
for _, win in pairs(windows) do
local found = string.match(win:title(), winTitle)
if found ~= nil then
win:focus()
return
end
end
app:activate()
end
end
end

-- 示例:VSCode 多开窗口的切换,给名为 "my-project" 的项目定制快捷键
hs.hotkey.bind(hyper, "1", launchOrFocusWindow("Code", "Visual Studio Code", "my%-project"))

launchOrFocusWindow 参数有些奇葩,因为 hs.application.get 和 hs.application.open 分别需要 title、path,互不兼容(可能是 bug)。
不过 get 和 open 还同时支持 bundleID,我认为名称对普通用户更友好,但如果你知道怎么获取 bundleID,自然可以用它来统一此处的入参。

利用丰富的 API,你还可以设计更多复杂的功能。

如何设置更多快捷键

全局快捷键极易引起冲突,譬如某狗输入法(别用)。为了避免这种烦恼,我们可以在 Hammerspoon 设置组合键。

1
local hyper = {"cmd", "alt", "ctrl"}

可惜,并不是所有人的手都能成长为“八爪鱼”,腱鞘炎了解一下?我们尽可能把多个按键合并,同时注意减少小拇指的使用。

以 MacOS 为例,使用 Karabiner-Elements,将大拇指附近不需要的按键设置为 hyper,配置示例如下

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
{
"title": "Change option key",
"rules": [
{
"description": "Change right_option to left_option + left_control + left_command if pressed with other keys, to escape if pressed alone.",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "right_option",
"modifiers": {
"optional": [
"any"
]
}
},
"to": [
{
"key_code": "left_option",
"modifiers": [
"left_control",
"left_command"
]
}
],
"to_if_alone": [
{
"key_code": "escape"
}
]
}
]
}
]
}

按住右 option,等于同时按住了 option+control+command,还可以随手实现轻按一下等于 ESC 的效果。

别忘了,组合键可不止这三个,还可以再从键盘上选几个键,设为 option+control+command+shift 等等,从此再也不用担心自定义的键位不够用了。

结语

排列窗口的方式很大程度取决于个人口味,自由度也非常高。窗口切换的操作具备更强的逻辑性,需要付出一定的成本。两者都可以提高工作效益,值得思考改进。但也必须承认,改进 Workflow 的边际效应明显,希望读完这篇文章的你,宁可什么都不做,也不要反复抉择。

最后,分享一下我目前的 MBP 使用习惯吧:极端的全屏使用者,彻底禁用 Dock,隐藏 Menu Bar,将通知和时间放在了 Touch Bar,每天享受沉浸式的屏幕体验。

SSR 页面 CDN 缓存实践

作者 Claude Ray
2020年7月3日 19:38

SSR 是一项资源密集型任务,要抵抗更大流量、提供更快的服务,缓存是其中的必修课。

而 CDN 缓存——作为静态资源的首要支撑,适合武装到 SSR 页面吗?

开始之前

大家对 CDN 应该已经耳熟能详,如果不甚了解也没关系,我们先通过一系列问答带诸位走近这个话题。

为什么接入 CDN?

抽象一个简单的请求链路,方便理解 CDN 的定位。

接入前:

cdn1

用户 -> Nginx -> App Server

接入后:

cdn2

用户 -> CDN -> Nginx -> App Server

看似增加了一层传输成本,其实不然。

CDN 利用自身广大的服务器资源,能动态优化访问路由、就近提供访问节点,以更低延迟、更高带宽从源站获取数据,优化了网络层面的用户体验。

为什么开启 CDN 缓存?

开启前:浏览器 -> CDN -> Nginx -> App Server1 -> App Server2 -> …

开启后:浏览器 <-> CDN

CDN 能够缓存用户请求到的资源,并且可以包含 HTTP 响应头。在下一次任意用户请求同样的资源时,用缓存的资源直接响应用户,节省了本该由源站处理的所有后续步骤。

更直观的表达,就是截短了请求链路。

cdn3

如何开启 CDN 缓存?

在不考虑自研 CDN 的情况下,开启 CDN 缓存的步骤非常简单:

  1. 域名接入 CDN 服务,同时针对路径启用缓存
  2. 在源站设置 Cache-Control 响应头,为了更灵活地控制缓存规则,但并不是必须

哪些服务可以开启 CDN 缓存?

大部分网站都适合接入 CDN,但 SSR 页面只有满足一定条件才可以开启 CDN 缓存

  • 无用户状态
  • 对时效性要求不高,至少能接受分钟级的延迟

怎样判断是否命中缓存?

不同 CDN 平台检测的方法略有不同,本质上都是判断响应头的标识字段。以腾讯 CDN 为例,响应头 X-Cache-Lookup 分别表示

  • Hit From MemCache: 命中 CDN 节点的内存
  • Hit From Disktank: 命中 CDN 节点的磁盘
  • Hit From Upstream: 未命中缓存,回源
cdn-headers

如果该字段不存在,说明该页面没有配置 CDN,或未开启缓存。

CDN 缓存优化

用来衡量缓存效果的重要指标是缓存命中率,在正式设置 CDN 缓存之前,我们再来了解几个提高缓存命中率的要点。这些要点也适合作为评估系统是否应该接入 CDN 缓存的标准。

延长缓存时间

提高 Cache-Control 的时间是最有效的措施,缓存持续时间越久,缓存失效的机会越少。

即使页面访问量不大的时候也能显著提高缓存命中率。

需要注意,Cache-Control 只能告知 CDN 该缓存的时间上限,并不影响它被 CDN 提早淘汰。流量过低的资源,很快会被清理掉,CDN 用逐级沉淀的缓存机制保护自己的资源不被浪费。

忽略 URL 参数

用户访问的完整 URL 可能包含了各种参数,CDN 默认会把它们当作不同的资源,每个资源又是独立的缓存。

而有些参数是明显不合预期的,例如,页面链接在微信等渠道分享后,末尾被挂上各种渠道自身设置的统计参数。平均到单个资源的访问量就会大大降低,进而降低了缓存效果。

CDN 支持后台开启 过滤参数 选项,来忽略 URL ? 后面的参数。 此时同一个 URL 一律当作同一个资源文件。

在腾讯 CDN 中,忽略参数的功能无法针对某个 URL,仅支持整个域名生效,这让过滤参数成为了极具风险的操作。除非域名缓存专用,否则不建议开启这个选项,即便同域名内所有已接入 CDN 缓存的资源都不依赖 URL 参数,也不能保证将来不会因此踩坑。

主动缓存

化被动为主动,才有可能实现 100% 的缓存命中率。

常用的主动缓存是资源预热,更适合 URL 路径明确的静态文件,动态路由无法交给 CDN 智能预热,除非依次推送具体的地址。

代码演进

谈过 CDN 缓存优化的几个要点,便可得知 CDN 后台的配置是需要谨慎对待的。我在实际操作中,也经过了几个阶段的调整,可毕竟具体配置方式取决于 CDN 服务商,因此本文不再深入讨论。

现在,我们要把目光转到代码层的演进了。

一、掌控缓存

代码配置有一个前提,即 CDN 后台需要开启读取源站 Cache-Control 的支持。

而后,只要简单地添加响应头,就能从运维手中接管设置 CDN 缓存规则的主动权。

以 Node.js Koa 中间件为例,全局的初始化版本如下

1
2
3
app.use((ctx, next) => {
ctx.set('Cache-Control', `max-age=300`)
})

当然,上述代码的疏漏是非常多的。在 SSR 应用中,不太需要缓存所有的页面,这就要补充路径的判断条件。

二、控制路径

虽然 CDN 后台也可以配置路径,但配置方式乃至路径数量都有局限性,不如代码形式灵活。

假如我们只需要缓存 /foo 页面,就加入 if 判断

1
2
3
4
5
app.use((ctx, next) => {
if (ctx.path === '/foo') {
ctx.set('Cache-Control', `max-age=300`)
}
})

这就陷入了第一个陷阱,一定要注意路由对 path 的处理。一般地,’/foo’ 和 ‘/foo/‘ 是两个独立的 path。可能因为 ctx.path === ‘/foo’ 而漏掉了请求 path 为 /foo/ 的处理。

三、补充路径

伪代码如下

1
2
3
4
5
app.use((ctx, next) => {
if ([ '/foo', '/foo/' ].includes(ctx.path)) {
ctx.set('Cache-Control', `max-age=300`)
}
})

此外,CDN 后台的配置也需要规避这个问题。在腾讯 CDN 中,目录和文件适用于不同的页面路径。

四、忽略降级页面

在服务端渲染失败时,为了提高容错,我们会返回降级之后的页面,转为客户端渲染。如果因为偶然的网络波动,导致 CDN 缓存了降级页面,将在一段时间内持续影响用户体验。

所以我们又引入了 ctx._degrade 自定义变量,标识页面是否触发了降级

1
2
3
4
5
6
7
8
9
10
11
12
app.use(async (ctx, next) => {
if ([ '/foo', '/foo/' ].includes(ctx.path)) {
ctx.set('Cache-Control', `max-age=300`)
}

await next()

// 页面降级时,取消缓存
if (ctx._degrade) {
ctx.set('Cache-Control', 'no-cache')
}
})

没错,这并不是最后一个陷阱。

五、Cookie 和状态治理

上面已经提到了 CDN 可以选择性地缓存 HTTP 响应头,可是此选项是对整个域名生效,又普遍需要开启。

新的问题正是来自一个不希望被缓存的响应头。

应用 Cookie 的设置依赖于响应头 Set-Cookie 字段,Set-Cookie 的缓存直接会导致所有用户的 Cookie 被刷新为同一个。

有多个解决方案,一是该页面不要设置任何 Cookie,二是代理层过滤掉 Set-Cookie 字段。可惜腾讯 CDN 目前还不支持对响应头的过滤,这步容错必须自己操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.use(async (ctx, next) => {
const enableCache = [ '/foo', '/foo/' ].includes(ctx.path)

if (enableCache) {
ctx.set('Cache-Control', `max-age=300`)
}

await next()

// 页面降级时,取消缓存
if (ctx._degrade) {
ctx.set('Cache-Control', 'no-cache')
}
// 缓存页面不设 Set-Cookie
else if (enableCache) {
ctx.res.removeHeader('Set-Cookie')
}
})

上面增加的代码旨在页面响应前移除 Set-Cookie,但是中间件的加载顺序是难以控制的。特别是一些(中间件)插件,会隐式地创建 Cookie,这让 Cookie 的清理工作异常麻烦。如果后续维护人员不知情,很可能将 Set-Cookie 重新加入到响应头中。所以,这种擦屁股的工作,尽量在代理层处理,而不是放在代码逻辑中。

除了 Cookie,还可能面临其他状态信息管理问题。比如在 Vuex 的 renderState 中存放请求用户的登录状态,此时 HTML 页面嵌入了用户信息,如果被 CDN 缓存,在客户端将发生和未清除 Set-Cookie 相似的问题。类似的例子还有很多,它们的解决思路非常相像,接入 CDN 缓存前务必对状态信息做好全面的排查。

六、定制缓存路径

现在功能总算趋于正常,然而缓存规则复杂多变,如果想设置更多页面,还要单独定制缓存时间呢?这段代码仍需要不断地变动。

例如,我们只想缓存 /foo/:id,而不缓存 /foo/foo、/foo/bar 等路径。

注意 CDN 后台可能只支持配置一个 /foo/ 开头的缓存路径,这就要求我们需要将 ctx.set(‘Cache-Control’, ‘no-cache’) 做为默认处理,加在中间件的第一行。

又比如,我们想缓存 /foo 页面 5 分钟,/bar 页面 1 天,又需要引入一个时间配置表。

这个中间件和相应的配置就会变得越来越难以维护。

因此,我们换一种思路,缓存规则不再交给中间件,而是转到 Vue SSR 的 entry-server,通过 metadata 可以做到页面级别的配置。由于 SSR 方案的差异性,不再赘述具体实现。

七、缓存失效

缓存失效是个中性词,如何处理 CDN 缓存失效,此中利弊不得不慎重权衡。

一方面,它会间歇增加服务压力,在 Serverless 应用中还会提高计算成本。而另一方面,许多场景我们不得不主动触发它,才能真正更新资源。

CDN 缓存的黑暗面无法让人忽视。对用户而言,缓存是透明的,对产品、技术却很可能成为阻碍。

如果处理不当,它将影响新功能能否及时发布、阻断后置所有服务的埋点、提高风险感知的成本,以及无法保障一致性,增加了线上问题的排查难度。

因此,十分有必要设立一个负责缓存刷新、预热的触发式服务,用以改进开发人员的体验。可是 CDN 缓存可控性很低,刷新也不能做到全然实时生效。

处于频繁变化的页面,最好考虑进入稳定期再开启 CDN 缓存。即使是稳定的、大流量的页面,也还需要考虑 CDN 缓存穿透的防范措施。

一旦 CDN 缓存在 SSR 架构中得到重用,就要做好长期调整决策的准备。

总结

CDN 缓存是一把利刃,在大流量的场景下,可以替源站拦截几乎所有的请求,能提供极强伸缩性的负载。

那么 SSR 应用适合接入 CDN 缓存吗?再一次细数上面提到的诸多问题…

  • 路径控制
  • 页面降级
  • 状态治理
  • 缓存失效

答案得你自己说了算。

实际上,极少数 SSR 页面场景才需要 CDN 缓存,如门户首页。

流量不高、路径分散的一般业务,只需要使用动态的 CDN 加速和静态文件缓存,就能基本满足 CDN 代理层的优化需要。

分享一个 npm dist-tag 的冷知识

作者 Claude Ray
2020年4月29日 21:13

dist-tag 是广为 npm 包开发者所熟知的属性,如果不是今天碰到一个有趣的问题,我根本没想过拉它出来玩。

为了照顾不曾了解 dist-tag 的用户,我先用一句话介绍 —— dist-tag 是 npm 版本号的命名空间,而 latest 则是默认的命名空间。想必大家不会陌生 npm install <name>@latest 这样的用法吧。

更重要的是,除非 package.json 中有所指定,所有安装默认在 latest 空间下匹配版本号。而处在 latest 空间时,也不会去名为 beta 的 dist-tag 下查找版本号。

那么问题来了!设想一个 npm 包首次发布就使用 beta 作为 dist-tag,它可以被 npm install <name> 安装吗?

答案是肯定的。

我还发布了一个空白的 npm beta 包作为验证。

publish

install

明明 beta 和 latest 属于不同的命名空间,为啥这里用 latest 就把 beta 装了?

原因很简单,npm 服务端在初始化一个包时,不论发布者使用了什么 dist-tag,都会同时把它添加到 latest 上。这的确是个不成文的 feature,甚至 Verdaccio 等私服方案也按此逻辑来实现了。

为了不污染 npm 环境(或承接相应的骂名),上面测试发布的包已经被笔者下架了 :P

Verdaccio 性能优化:单机 Cluster

作者 Claude Ray
2019年12月31日 19:46

本篇将讨论如何解决 Verdaccio 官方本地存储方案不支持 Cluster 的问题。

前言

标题为什么叫单机 Cluster 呢?

因为多机 Cluster 已经无法使用默认的本地存储,必须配合一套新的存储方案,而官方只提供了 AWS 和 Google Cloud 的支持。这在国内已经是一道门槛,因此大概率是要用上其他云存储服务的,这意味着必须做一个 Verdaccio 插件实现必备的 add、search、remove、get 功能。

糟糕的是,倘若自己的云存储不支持查询功能,还得基于数据库再造一套轮子,甚至再加一套解决读写冲突的轮子。

一句话来说,Verdaccio 是轻量级好手,不适合也不必要承载太重的装备。重度使用的场景下,与其从头定制的存储体系,不如直接上 cnpm、Nexus 等体积更大、相对成熟的系统。

话说回来,作为尝试,我还是基于 Redis 实现了它的单机 Cluster。虽然修改的 Verdaccio 版本较旧,但其新版 V4 的架构并没有太大变化,思路还是一致的。

思路

Verdaccio 默认无法使用 PM2 Cluster 启动,有两大阻碍。

其一,缓存同步。它使用进程级别的内存缓存,没有实现进程间通讯,多进程之间缓存信息不能同步。

其二,写锁。本地存储将内容持久化到本机磁盘,只有进程级别的“锁”,多进程容易出现写文件冲突。

这两个问题处理起来其实非常简单,特别是引入 Redis 之后。

针对第一点,内存缓存可以迁移到 Redis,但是其中有大体积的 JSON 信息,不适合存在 Redis,可以用 Redis 做消息中心,管理各进程的缓存状态。

针对第二点,私服本身属于简单的业务场景,Redis 锁完全可以胜任。

实现

本应该是 Show Code 环节,可念在笔者改的版本不存在普适性,索性改成修改要点的简单罗列吧。

  • 重写 local storage,本地存储依赖一个叫 .sinopia-db.json.verdaccio-db.json 的文件,其中保存所有私服的包。这个文件的内容适合使用 Redis 的 set 结构进行替换。

  • 查找并替换所有 fs.writeFile,加锁处理。在锁的实现上,新手需要多看官方文档,大部分博客的实现都是错误的,比如忽略了解锁步骤的原子化操作。

  • 向上回溯修改的链路。

额外的补充

想来这可能是专题的最后一期,于是把不太相关的几个小问题也堆到下面吧。

只关心 Cluster 改造的看官可跳过此节,直接看末尾总结。

异步风格

由于手上的 Verdaccio 版本较老,整体还是 callback 风格,让改造多了一点工作量。我使用的 Redis 客户端为 ioredis,注意把涉及到的调用链路都改造为 async/await。

发布订阅

另一个坑点是我拿到的 Redis 其实是 Codis 集群,这套方案的一个缺点是无法使用 Redis 弱弱的发布订阅功能,也就不能直接拿来订阅更新内存缓存的消息。只好另辟蹊径,将 Redis 作为“缓存中心”,进程取缓存前先查询标志位,如果标志位存在,代表内存缓存需要更新。以进程号等信息做 key 前缀表示区分。

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
const os = require('os');

class CacheCenter {
constructor(prefixKey = 'updated') {
this.data = new Map();
// 利用 redis 缓存标志位,为空时表示缓存需要更新
this.prefix = prefixKey;
// 用 pm2 进程号区分缓存状态
this.ip = getIPAddress();
this.id = `${this.ip}:${process.env.NODE_APP_INSTANCE || 0}`;
}

async get(key) {
const isCached = this.data.has(key);
if (isCached) {
const isCacheLatest = await redis.hget(this._key(key), this.id);
if (isCacheLatest) {
return this.data.get(key);
}
}
return undefined;
}

async set(key, value) {
this.data.set(key, value);
await redis.hset(this._key(key), this.id, Date.now());
redis.expire(this._key(key), 7 * 24 * 60 * 60);
}

async del(key) {
redis.del(this._key(key));
}

has(key) {
return this.data.has(key);
}

_key(key) {
return `${this.prefix}:${key}`;
}
}

function getIPAddress() {
const interfaces = os.networkInterfaces();
for (const iface of Object.values(interfaces)) {
for (const alias of iface) {
if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
return alias.address;
}
}
}
return '127.0.0.1';
}

module.exports = new CacheCenter();

页面搜索优化

顺便一提,Verdaccio web 页面的 /search 接口性能极差,实现也存在诸多问题。此处值得加一层内存缓存,等到新包发布时刷新。

早期 Verdaccio 不支持使用 name 搜索名为 @scope/name 的包,可增加一条 name 专用的索引字段促成改进。根源是依赖的 lunr 引擎版本过低(0.7.0),但最新 lunr 的表现依然不太理想。

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
class Search {
/**
* Constructor.
*/
constructor() {
this.index = lunr(function() {
this.field('name', {boost: 10});
this.field('unscoped', {boost: 8});
this.field('description', {boost: 4});
this.field('author', {boost: 6});
this.field('readme');
});
}

/**
* Add a new element to index
* @param {*} pkg the package
*/
add(pkg) {
this.index.add({
id: pkg.name,
name: pkg.name,
unscoped: getUnscopedName(pkg.name),
description: pkg.description,
author: pkg._npmUser ? pkg._npmUser.name : '???',
});
}
// ...
}

/**
* 截取包名中不带 scope 的部分
* 参照命名规范 @scope/name,直接截取/后的字符串
*/
function getUnscopedName(name) {
return name.split('/')[1];
}

总结

为 Verdaccio 开启 Cluster 能力并不是一个轻松的做法,但经过这个系列解读,却可以轻松地作出选择。

如果只是想一定程度上提高处理高并发的性能,可以采取上一篇代理分流的做法,代理可以帮你分担 99% 以上的压力。

如果想进一步提升性能,实现应用的平滑重启,本文单机 Cluster 并配合 pm2 reload 的做法不妨一试。

而一但想开启多节点集群的能力,几乎超出了轻量级私服的理念,试着迁移到 cnpm、Nexus 吧。

让 npm install 不使用缓存的方法

作者 Claude Ray
2019年12月6日 12:04

Why

npm 的安装出错是屡见不鲜,往往是因为安装的环境不够 “clean”。

通常情况下,只要删除项目目录的 node_modules 和 package-lock.json,重新执行 install 就能解决。

偶尔也会出现上述操作解决不了的问题,譬如 npm 的缓存文件异常,就需要在安装前执行 npm cache clean --force 清空缓存目录。

但 npm cache clean 也存在两个未处理的缺陷,使它既不完全可靠又具备风险。

  1. 部分依赖会和 npm 共用缓存目录(终端下通过 npm config get cache 命令查看,默认 ~/.npm),用来存放自己的临时文件。

    而 npm@5 之后,cache clean 只会清除该缓存目录下的 _cacahce 子目录,而忽视不在该子目录的缓存。

    例如 @sentry/cli 将缓存放在了和 _cacache 同级的 sentry-cli 目录,clean cache 不会清除此处缓存。

    此例有网友专门记录了排错经过

  2. 突然执行 cache clean,将导致正在使用 npm install 的项目丢失部分依赖。

    如果有多个项目在同一环境执行 npm install,此问题的影响会进一步扩大,npm 将抛出各种文件操作错误。

鉴于缓存出错是极小概率事件,若能使用温和的安装方式避开缓存文件,无疑是更好的选择。

可是,npm install 利用缓存的行为是默认且强制的,目前官方还没有提供形如 –no-cache 的选项来做一次忽略缓存的干净安装。

npm-cache 机制详见官网文档

How

尽管 npm cli 还没支持,但这个需求我们自己实现起来却十分简单。

既然 cache 目录是通过 npm config get cache 获取的,也就支持相应的 set 方式。为每个待安装项目重新配置 cache 目录,等于变相地清除了 npm 之前所有的缓存。

当然,直接 npm config set cache 会让 npm 全局生效,为了单独设置缓存目录,在项目内添加 .npmrc 文件,并加入

1
cache=.npm

可观察到缓存路径的变更生效

1
2
3
4
5
6
7
$ npm config get cache
/Users/claude/.npm

$ cd ~/node-project && echo cache=.npm >> .npmrc

$ npm config get cache
/Users/claude/node-project/.npm

再安装就会重新下载依赖啦,还起到了环境隔离的作用。

Verdaccio 性能优化:上游路径转发

作者 Claude Ray
2019年10月22日 19:32

背景

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

近期观察发现,有些项目依赖了名为 npm 的 npm 包,每次项目部署时都会向私服 /npm 发起请求记录,并在监控曲线上呈明显的高耗时,这引起了我们的关注。

有些项目依赖了 npm 自身的包,每次项目部署时都会产生对私服 /npm 路由的请求记录,并在监控曲线上呈明显的高耗时,这引起了我们的关注。

原因

Verdaccio 对公共(外网)npm 包的中转存在不小的性能损耗。

其中一个问题,通过私服下载未经缓存的公共 npm 包,Verdaccio 都要等上游镜像的响应完整结束之后,才开始响应私服用户的请求。这导致 Verdaccio 的整体速度比直接用上游慢了一截。

至于会慢多少呢,要提到另一个 npm 机制:一个依赖 package 下载之前,要先到镜像地址的 =/:package/:version?= 接口获取完整的包信息,之后才会下载所需的版本。而一个模块历史发布过的版本越多,信息量越大。尤其是 npm 自身这个包,访问一下 http://registry.npmjs.org/npm 便知。

Verdaccio 慢就慢在获取包信息这一步,它必须等待上游接口响应完成,才能做相关 JSON 解析和逻辑处理。因此不仅仅是慢的问题了,还有内存和 CPU 的大量消耗。

然而这一步对于 Verdaccio 又很重要,因为它的对于此接口的缓存策略基于文件,只有拿到完整的 JSON 返回值才能将其记录到文件中。只是默认仅 2 分钟的缓存时间,让这一步操作的性价比打了折扣。

思路

从上面看,私服接口性能优化空间还很大,哪怕只是将几个体积较大的“罪魁祸首” npm 包单独优化,也能缓解私服的压力。

首先想到的是让 Verdaccio 不必等待上游全部返回就开始响应私服用户。其次是现有的缓存机制对部分低频率高开销的 package 请求形同虚设,小机器又经不起缓存扩充的资源消耗,网络带宽倒是相对不缺,降低计算成本、纯网络代理转发是一个可行的方向。

Verdaccio 会对下载的 npm 包信息做解析和记录,但其实我们并不关心那些只属于上游的包,只希望它能承担好转发工作,甚至所有公共依赖都不经过私服处理。

退一步讲,就是要弱化在私服中对这些公共依赖的处理,减少解析过程 —— 用 stream 或 buffer 完成请求转发。

实现

遗憾的是 Verdaccio 自身的接口难以复用,只好直接在其基础上增加路由(中间件)。简单粗暴,对项目的熵值影响不大。

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
const _ = require('lodash');
const createError = require('http-errors');
const request = require('request');
const URL = require('url');

const Middleware = require('../../web/middleware');
const Utils = require('../../../lib/utils');

module.exports = function(route, auth, storage, config) {
const can = Middleware.allow(auth);

// 优化特定依赖的获取,以 `npm` 举例
route.get('/npm', (req, res) => {
// 拼接镜像地址
const upLinkUrl = _.get(config, 'uplinks.npmjs.url', 'https://registry.npm.taobao.org');
const packageUrl = URL.resolve(upLinkUrl, req.originalUrl);

// 利用 Verdaccio 定义的 res.report_error 来采集错误
const npmRes = request(packageUrl)
.on('error', res.report_error);

// 直接将上游结果转发,快速响应请求
req.pipe(npmRes).pipe(res);
});

route.get('/:package/:version?', can('access'), function(req, res, next) {
// ...
});
// ...
};

上面是 stream 方式的修改,也可以把路由改写为中间件。stream 转发减轻了服务的内存压力(节省上百 MB 的临时缓冲),并减少这部分接口 50% 以上的 TTFB 响应时间,不过总体响应时间却因为 stream 有所延长。

降低机器负载的目标达成了,但压力测试证明这会大大拖慢进程的处理效率,在并发较低的情况下才能采用。

作为尝试,目前这个 patch 只用在了特定依赖。Verdaccio 可优化的方向很多,单进程可提升空间有限的情况,该把重心放在横向扩展上了。

待续

转发所有上游 npm 包的念想还未落地,虽然做起来应该很简单,但需要继续摸索 Verdaccio 结构,才好给出更合适的修改方案。

现在能给出的最简单做法就是适当调高 Verdaccio 默认 2 分钟的缓存 TTL。提升最大的做法是扩展 Verdaccio 尚未支持的 Cluster 架构……

1
2
3
4
5
request({ url: packageUrl, encoding: null }, (error, resp, body) => {
if (error) return res.report_error(error);
res.set('Content-Type', resp.headers['content-type']);
return res.send(body);
})

再者,结合自身情况,可以尝试更多玩法。如果系统内存富足,把 stream 稍微改一改,变为回调形式。缺点和 Verdaccio 一样的是必须等 resp 完整返回,但 encoding: null 确保响应结果为 buffer,能省略 JSON 解析,优点是可以基于 buffer 做 LRU Cache。

CSAPP DataLab 题解

作者 Claude Ray
2019年10月2日 23:19

DataLab

近来开始读 CS:APP3e 第二章,但干看书做课后题太乏味,于是提前把 DataLab 拉出来练练。不一定是优解,趁热记录一下思路吧。

如果读者是那种还没做完 lab 就想借鉴答案的,还请收手,坚持独立完成吧,正如课程作者所说,Don't cheat, even the act of searching is checting.

bitXor

1
2
3
4
5
6
7
8
9
10
/* 
* bitXor - x^y using only ~ and &
* Example: bitXor(4, 5) = 1
* Legal ops: ~ &
* Max ops: 14
* Rating: 1
*/
int bitXor(int x, int y) {
return ~(~(x & ~y) & ~(~x & y));
}

简单的公式可以写作 (x & y) | (~x & y) ,但题目要求只能用 ~ & 两种操作,换句话就是考察用 ~ & 来实现 | 操作,和逻辑与或非类似。

tmin

1
2
3
4
5
6
7
8
9
/* 
* tmin - return minimum two's complement integer
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 4
* Rating: 1
*/
int tmin(void) {
return 1 << 31;
}

这个题目就是计算出 0x80000000 ,基本的移位操作即可,不用复杂化。

isTmax

1
2
3
4
5
6
7
8
9
10
/*
* isTmax - returns 1 if x is the maximum, two's complement number,
* and 0 otherwise
* Legal ops: ! ~ & ^ | +
* Max ops: 10
* Rating: 1
*/
int isTmax(int x) {
return !(~(1 << 31) ^ x);
}

上面已经知道怎么获取 TMIN,TMAX 可以用 ~TMIN 表示,因此主要考察两个数是否相等 —— ^

错误更正

感谢 @nerrons 兄指正

前面的解法忽略了操作符的限制,是不合题意的。故更换思路:由于 TMAX + 1 可得到 TMIN,若 x 为 TMAX,则 x + 1 + x 结果为 0。

但直接这样写无法通过检测程序,是因为 0xffffffff 同样满足 x + 1 + x 为 0 的特性,需要将该情况排除。

1
2
3
int isTmax(int x) {
return !(~((x + 1) + x) | !(x + 1));
}

allOddBits

1
2
3
4
5
6
7
8
9
10
11
12
/* 
* allOddBits - return 1 if all odd-numbered bits in word set to 1
* where bits are numbered from 0 (least significant) to 31 (most significant)
* Examples allOddBits(0xFFFFFFFD) = 0, allOddBits(0xAAAAAAAA) = 1
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 12
* Rating: 2
*/
int allOddBits(int x) {
int odd = (0xAA << 24) + (0xAA << 16) + (0xAA << 8) + 0xAA;
return !((x & odd) ^ odd);
}

先构造 0xAAAAAAAA,利用 & 操作将所有奇数位提出来,再和已构造的数判等。

negate

1
2
3
4
5
6
7
8
9
10
/* 
* negate - return -x
* Example: negate(1) = -1.
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 5
* Rating: 2
*/
int negate(int x) {
return ~x + 1;
}

二进制基础扎实的话,可以秒出结果。

isAsciiDigit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 
* isAsciiDigit - return 1 if 0x30 <= x <= 0x39 (ASCII codes for characters '0' to '9')
* Example: isAsciiDigit(0x35) = 1.
* isAsciiDigit(0x3a) = 0.
* isAsciiDigit(0x05) = 0.
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 15
* Rating: 3
*/
int isAsciiDigit(int x) {
/* (x - 0x30 >= 0) && (0x39 - x) >=0 */
int TMIN = 1 << 31;
return !((x + ~0x30 + 1) & TMIN) & !((0x39 + ~x + 1) & TMIN);
}

主要思路可以用逻辑运算表示,(x - 0x30 >= 0) && (0x39 - x) >=0,这里新概念是如何判断数值是否小于 0。

conditional

1
2
3
4
5
6
7
8
9
10
11
12
/* 
* conditional - same as x ? y : z
* Example: conditional(2,4,5) = 4
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 16
* Rating: 3
*/
int conditional(int x, int y, int z) {
int f = ~(!x) + 1;
int of = ~f;
return ((f ^ y) & of) | ((of ^ z) & f);
}

这里我用 ~(!x) + 1 构造了 x 的类布尔表示,如果 x 为真,表达式结果为 0,反之表达式结果为 ~0。

isLessOrEqual

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 
* isLessOrEqual - if x <= y then return 1, else return 0
* Example: isLessOrEqual(4,5) = 1.
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 24
* Rating: 3
*/
int isLessOrEqual(int x, int y) {
/* (y >=0 && x <0) || ((x * y >= 0) && (y + (-x) >= 0)) */
int signX = (x >> 31) & 1;
int signY = (y >> 31) & 1;
int signXSubY = ((y + ~x + 1) >> 31) & 1;
return (signX & ~signY) | (!(signX ^ signY) & !signXSubY);
}

核心是判断 y + (-x) >= 0。一开始我做题时被 0x80000000 边界条件烦到了,所以将其考虑进了判断条件。

具体做法是判断 Y 等于 TMIN 时返回 0,X 等于 TMIN 时返回 1。此外也考虑了若 x 为负 y 为 正返回 1,x 为正 y 为负返回 0。

这样想得太复杂了,使用的操作有点多,而题目对 ops 限制是 24,担心过不了 dlc 的语法检查。 所以又花更多时间想出更简单的方法。用逻辑操作可以写作 (y >=0 && x <0) || ((x * y >= 0) && (y + (-x) >= 0))。不过我后来在 linux 上运行了一下第一种方法,dlc 并没有报错。

logicalNeg

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 
* logicalNeg - implement the ! operator, using all of
* the legal operators except !
* Examples: logicalNeg(3) = 0, logicalNeg(0) = 1
* Legal ops: ~ & ^ | + << >>
* Max ops: 12
* Rating: 4
*/
int logicalNeg(int x) {
int sign = (x >> 31) & 1;
int TMAX = ~(1 << 31);
return (sign ^ 1) & ((((x + TMAX) >> 31) & 1) ^ 1);
}

x 小于 0 时结果为 1,否则检查 x + TMAX 是否进位为负数。

howManyBits

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
/* howManyBits - return the minimum number of bits required to represent x in
* two's complement
* Examples: howManyBits(12) = 5
* howManyBits(298) = 10
* howManyBits(-5) = 4
* howManyBits(0) = 1
* howManyBits(-1) = 1
* howManyBits(0x80000000) = 32
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 90
* Rating: 4
*/
int howManyBits(int x) {
int sign = (x >> 31) & 1;
int f = ~(!sign) + 1;
int of = ~f;
/*
* NOTing x to remove the effect of the sign bit.
* x = x < 0 ? ~x : x
*/
x = ((f ^ ~x) & of) | ((of ^ x) & f);
/*
* We need to get the index of the highest bit 1.
* Easy to find that if it's even-numbered, `n` will lose the length of 1.
* But the odd-numvered won't.
* So let's left shift 1 (for the first 1) to fix this.
*/
x |= (x << 1);
int n = 0;
// Get index with bisection.
n += (!!(x & (~0 << (n + 16)))) << 4;
n += (!!(x & (~0 << (n + 8)))) << 3;
n += (!!(x & (~0 << (n + 4)))) << 2;
n += (!!(x & (~0 << (n + 2)))) << 1;
n += !!(x & (~0 << (n + 1)));
// Add one more for the sign bit.
return n + 1;
}

这里我利用了之前 conditional 的做法,讲 x 为负的情况排除掉,统一处理正整数。统计位数可以采取二分法查找最高位的 1,但做了几轮测试就会发现二分法存在漏位的问题。

不过这只在偶数位发生,奇数位不受影响。因此为了排除这个影响,我暴力地用 x |= (x << 1) 的办法让最高位的 1 左移 1 位。

floatScale2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 
* floatScale2 - Return bit-level equivalent of expression 2*f for
* floating point argument f.
* Both the argument and result are passed as unsigned int's, but
* they are to be interpreted as the bit-level representation of
* single-precision floating point values.
* When argument is NaN, return argument
* Legal ops: Any integer/unsigned operations incl. ||, &&. also if, while
* Max ops: 30
* Rating: 4
*/
unsigned floatScale2(unsigned uf) {
int exp = (uf >> 23) & 0xFF;
// Special
if (exp == 0xFF)
return uf;
// Denormalized
if (exp == 0)
return ((uf & 0x007fffff) << 1) | (uf & (1 << 31));
// Normalized
return uf + (1 << 23);
}

只需要简单地取出指数部分,甚至不需要拆解,排除 INF、NaN、非规格化的情况之后,剩下规格化的处理是指数部分的位进一。

floatFloat2Int

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
/* 
* floatFloat2Int - Return bit-level equivalent of expression (int) f
* for floating point argument f.
* Argument is passed as unsigned int, but
* it is to be interpreted as the bit-level representation of a
* single-precision floating point value.
* Anything out of range (including NaN and infinity) should return
* 0x80000000u.
* Legal ops: Any integer/unsigned operations incl. ||, &&. also if, while
* Max ops: 30
* Rating: 4
*/
int floatFloat2Int(unsigned uf) {
int TMIN = 1 << 31;
int exp = ((uf >> 23) & 0xFF) - 127;
// Out of range
if (exp > 31)
return TMIN;
if (exp < 0)
return 0;
int frac = (uf & 0x007fffff) | 0x00800000;
// Left shift or right shift
int f = (exp > 23) ? (frac << (exp - 23)) : (frac >> (23 - exp));
// Sign
return (uf & TMIN) ? -f : f;
}

首先拆分单精度浮点数的指数和基数,指数部分减去 127 偏移量,用来排除临界条件。大于 31 时,超过 32 位 Two’s Complement 的最大范围,小于 0 则忽略不计,根据题意分别返回 0x80000000 和 0。

之后根据指数部分是否大于 23 来判断小数点位置。如果大于,说明小数部分全部在小数点左边,需要左移;如果小于则需要右移。最后补上符号位。

floatPower2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 
* floatPower2 - Return bit-level equivalent of the expression 2.0^x
* (2.0 raised to the power x) for any 32-bit integer x.
*
* The unsigned value that is returned should have the identical bit
* representation as the single-precision floating-point number 2.0^x.
* If the result is too small to be represented as a denorm, return
* 0. If too large, return +INF.
*
* Legal ops: Any integer/unsigned operations incl. ||, &&. Also if, while
* Max ops: 30
* Rating: 4
*/
unsigned floatPower2(int x) {
int exp = x + 127;
// 0
if (exp <= 0)
return 0;
// INF
if (exp >= 0xFF)
return 0x7f800000;
return exp << 23;
}

加 127 得到指数阶码,超过表示范围则返回 0 和 INF。由于小数点后面都是 0,只需左移指数部分。

小结

现在 Mac 已无法运行 32 位的代码检查工具 dlc,不过可以先跑逻辑测试,等写完再放到 Linux 机跑一遍 dlc 测试。

原以为这点知识在学校掌握得还可以,随书习题和前几道 lab 也的确简单,实际做到后面有许多卡壳的点,浮点数的概念都模糊了,真是一边翻书一边做,快两天才完成。书本的这章我还是甭跳了,继续刷去吧。

不靠谱的 Egg.js 框架开发指南

作者 Claude Ray
2019年9月17日 19:44

这是一篇面向 Egg.js 上层框架开发者的科普文。

Egg 官网基本做到了呈现所有“必知必会”的内容,再写一份 Egg 使用教程已经毫无必要,不如聊聊 Egg 上层框架开发过程中可能有用的技巧。

概览

文档

深入浅出的官网和专栏分享

核心

阅读源码的必经之路

  • egg-core
  • egg-cluster

命令行工具

  • egg-scripts: 用于生产环境的部署工具
  • egg-bin: 开发环境的 debug、test、coverage
  • ets: egg-ts-helper,用于辅助 egg ts 项目生成 .d.ts 声明文件,为 egg 的 ts 开发提供友好的智能提示,已经被 egg-bin 内部集成
  • egg-init: egg 的脚手架初始化工具,框架开发者总是需要搭建自己的脚手架,因此这个可以仅作了解,我们并不会使用。自 npm@6 以后,增加了 npm-init 的新特性
    • npm init foo -> npx create-foo
    • npm init @usr/foo -> npx @usr/create-foo
    • npm init @usr -> npx @usr/create

测试工具

  • egg-mock: 提供了完整的 mock 代码,测试 API 来自 supertest

进阶

进阶 Egg 的步骤包括但不限于通读官网文档,至少要熟悉下面两个话题才能算了解了 Egg。

深入

接下来是几个或多或少官网没有讲到的话题。

平滑重启

Egg 的多进程模型决定了 PM2 这样的进程管理工具对它意义不大。可惜的是没有了 PM2,我们也失去了 pm2 reload 这样轻量的平滑重启方案,鉴于 Egg 应用不短的启动时长,必须在流量进入 Node.js 之前加以控制。

对有强力运维的团队来讲,server 的启动时间不是问题,问题是还有不少 Node.js 项目只有一层代理甚至是裸运行的,又不想给运维加钱。对此最基本的建议是前置 nginx ,在配置多个节点的 upstream 之后,默认的选服策略就带上了容错机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
upstream backend {
server backend1.example.com weight=5 max_fails=3 fail_timeout=60s;
server backend2.example.com:8080 weight=2;

server backup1.example.com:8080 backup;
server backup2.example.com:8080 backup;
}

server {
location / {
proxy_pass http://backend;
}
}

简单来说,fail_timeout 默认 (10s) 就可以提供一个 “server backend 被 nginx 判定不可用之后,10s 之内不会有新的请求发送到该地址” 的缓冲期。

参考 nginx 的配置说明,酌情调整 max_failsfail_timeout 等参数,为服务提供一个基本但可靠的稳定保障吧。

路由

egg-router vs koa-router

egg-router 的逻辑基于 koa-router,早期直接引用 koa-router,在其基础上封装了 Egg.js 应用的路由注册,以及其他小特性。 后来 egg-router 从 egg-core 中剥离,并更改维护方式为 fork(koa-router 的维护度太低了),但没有做 breaking changes。两者的主要差别如下,稍后会做详细介绍:

  • RESTful
  • 默认大小写敏感

RESTful

koa-router 提供了比较基础的 RESTful API 支持,.get|put|post|patch|delete|del

Egg 实现了一套应用较广的约定,以便在 Egg 应用中快速编写 RESTful CRUD。

app.resources('routerName', 'pathMatch', controller)

MethodPathRoute NameController.Action
GET/postspostsapp.controllers.posts.index
GET/posts/newnew_postapp.controllers.posts.new
GET/posts/:idpostapp.controllers.posts.show
GET/posts/:id/editedit_postapp.controllers.posts.edit
POST/postspostsapp.controllers.posts.create
PUT/posts/:idpostapp.controllers.posts.update
DELETE/posts/:idpostapp.controllers.posts.destroy

举例如下,根据以上映射关系,在 app/controller/post.js 中选择性地实现相应方法即可。

1
app.resources('/posts', 'posts')

route name 是 koa-router 就定义了的可选参数,如果指定了 route name,当路由匹配成功时,会将此 name 赋值给 ctx._matchedRouteName

sensitive

Egg 在创建 router 的时候传递了 sensitive=true 的选型,在 koa-router 中开启了大小写敏感。
sensitive=true

Radix Tree

Radix Tree 是一种基于前缀的查找算法,Golang 的 echo、gin 等 web 框架的路由匹配都使用了该算法。

而 egg-router(koa-router) 以及 express router 均采用传统的正则匹配,具体做法是用 path-to-regexp 将路由转化为正则表达式,路由寻址就是遍历查找符合当前路径的路由的过程。

对比基于两种算法的路由查找效率,Radix Tree 更占优势,并且 url 越长,路由数量越多,性能差距越大。

以下是 10000 个路由情况下主流路由中间件的性能比拼,数据截选自 koa-rapid-router

静态路由

ArchitectureLatencyReq/SecBytes/Sec
koa + koa-router245.07 ms394.2556 kB
fastify1.96 ms493247 MB

动态路由

ArchitectureLatencyReq/SecBytes/Sec
koa + koa-router220.29 ms441.7562.7 kB
fastify1.9 ms50988.657.24 MB

那为什么不全面使用 Radix Tree 呢?其实只有少数涉及大量路由和性能的场景,如 npm registery。

如果项目真的有如此性能需要,恐怕你不得不考虑用该算法编写的路由中间件来取代默认的 egg-router 了。

引入 Elastic APM

如何支持 egg 框架

需求:elastic-apm hook 必须在 Egg 和 egg-router 被 require 前完成加载。

之前有一篇更详细的文章《elastic-apm-node 扩展篇 —— Egg》,适用于 Egg 应用层的 APM 接入。而在框架层则简单许多,可以直接在框架入口文件做此处理,应用开发者无须再关心这个包的处理细节。

ts 项目启动卡住

由于 egg-bin 内置的 ets (egg-ts-helper) 会用子进程同步地预加载一部分 ts 代码用作检查,apm 会被顺势加载,如果配置的环境变量或 serverUrl 字段有误,导致访问无法连通的 apm-server,最终会让该子进程挂起,ets 无法正常退出。

ets 只在 egg-bin start/dev/debug 启动 ts 项目时生效,不会影响线上经过编译的 js egg-script start 启动。

针对上述情况,增加了默认不在 ets 编译过程启动的处理,特征是存在 ETS_REGISTER_PID 环境变量。因此实际上运行调试和测试时都不会开启 apm。

同时单独运行 ets 时没有上述变量,因此将 NODE_ENV 为 undefined 的环境也排除。

1
2
3
4
5
const enableAPM = process.env.APM_ENABLE || (!process.env.ETS_REGISTER_PID && process.env.NODE_ENV);
if (enableAPM) {
const isDev = process.env.APM_DEV === 'true' || process.env.NODE_ENV !== 'production';
apm.start({ isDev });
}

框架仓库管理

在 npm 官方提供 momorepo 的正式支持之前,我们可以使用 Lerna 作为统一的框架、插件管理工具。

对于我们日常需要的 npm 管理操作,Lerna 并没有引入太多额外的使用成本,并且可以通过 npm 指令一一封装。

使用方式其实非常灵活,按团队的习惯来就好。如果之前没有使用过,可以参考 midway/scripts 下的 Lerna 脚本,并且可以在 CI 构建过程中执行版本更迭和发布。

获取实时 ctx

框架开发时遇到了一个少见情况,需要通过 Egg 的 app 对象获取当前上下文的 ctx 对象,用于在特别插件的中间件函数中定位 Egg 的上下文,以实现插件日志挂载到 ctx 对象。

其实这是一个没什么用的需求 :)

听起来比较绕,举个例子,在 egg 中使用 dubbo2.js —— 引入的方式参考 dubbo2.js 和 egg 的集成指引文档,并在其中使用中间件扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// {plugin_root} ./app.js
module.exports = app => {
const dubbo = Dubbo.from({....});
app.beforeStart(async () => {
dubbo.use(async (ctx, next) => {
const startTime = Date.now();
await next();
const endTime = Date.now();
console.log('costtime: %d', endTime - startTime);
});
await dubbo.ready();
console.log('dubbo was ready...');
})
}

上述的 ctx 并不属于 egg 创建的 ctx,两者之间相互隔离。唯一能让两者产生联系的,就是使用闭包中的 app。

于是有了 egg-current-ctx 这个模块,借助 app.currentCtx 方法,可以将两种 ctx 联系起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = app => {
const dubbo = Dubbo.from({....});
app.beforeStart(async () => {
dubbo.use(async (ctx, next) => {
const startTime = Date.now();
const eggCtx = app.currentCtx;
// 对 eggCtx 处理
console.log('', eggCtx.query);
await next();
const endTime = Date.now();
console.log('costtime: %d', endTime - startTime);
});
await dubbo.ready();
console.log('dubbo was ready...');
})
}

如果想把 dubbo2.js 中 ctx 的属性挂载到 egg 的 ctx 上,这个没什么卵用的插件就能散发一点温度。

感兴趣的可以看 egg-current-ctx 的代码实现,基于 async_hooks。

发布加速

Egg + ts 应用具备 150M 起步的 node_modules,再加上网络原因(和小水管 npm 私服),安装、拷贝速度十分感人。

如何提速?

这里旨在提供解决思路,一定有更好的方案,欢迎交流指正

  1. node_modules 不再每次都安装,打包平台和线上环境缓存第一次安装的依赖。(参考 travis-ci)

  2. 针对前一点的改进,node_modules 安装在代码目录上层,发布平台只拷贝代码,版本号式迭代。

    可是目录层级的处理在 Egg 项目上略显吃力,需要一套完整的项目和测试用例协助试错。因为 egg-utils 等工具类的底层代码将 node_modules 目录层级写得太死了。

    举个例子,egg-utils/lib/framework.js 66L ,导致无法查找上层 node_modules 里的 egg 依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function assertAndReturn(frameworkName, moduleDir) {
    const moduleDirs = new Set([
    moduleDir,
    // find framework from process.cwd, especially for test,
    // the application is in test/fixtures/app,
    // and framework is install in ${cwd}/node_modules
    path.join(process.cwd(), 'node_modules'),
    // prevent from mocking process.cwd
    path.join(initCwd, 'node_modules'),
    ]);
    for (const moduleDir of moduleDirs) {
    const frameworkPath = path.join(moduleDir, frameworkName);
    if (fs.existsSync(frameworkPath)) return frameworkPath;
    }
    throw new Error(`${frameworkName} is not found in ${Array.from(moduleDirs)}`);
    }
  3. npm 私服优化。修改上游镜像是一方面,自建的服务如果无法支持多节点多进程,也很容易成为安装依赖的性能瓶颈。假如使用 verdaccio 的本地存储模式,将很难得到官方 cluster 方案支持,除非你购买了 google cloud 或 aws s3。

Reference

Nginx SWRR 算法解读

作者 Claude Ray
2019年8月10日 13:13

Smooth Weighted Round-Robin (SWRR) 是 nginx 默认的加权负载均衡算法,它的重要特点是平滑,避免低权重的节点长时间处于空闲状态,因此被称为平滑加权轮询。

该算法来自 nginx 的一次 commit:Upstream: smooth weighted round-robin balancing

在阅读之前,你应该已经了解过 nginx 的几种负载均衡算法,并阅读了 SWRR 的实现。

介绍此算法的文章有很多,但用数学角度给出证明过程的较少,尽管并不复杂。这里把自己的思路分享一下,为了便于理解,只考虑算法核心的 current_weight,忽略受异常波动影响的 effective_weight。

更新说明

在写下博客之前,我还没有翻到其他靠谱的证明过程,就草草记录了自己粗鄙的思路。可发布文章一年之后,再来回顾的我不禁汗颜,为了照顾读者(更未来的自己),参考 nginx平滑的基于权重轮询算法分析 重新梳理了文章脉络。

以至于现在的内容更像是他人博客的学习笔记,和初版大不相同。这让患有原创洁癖的我深感羞愧,之后的自己务必用更数学的风格去做解析。

算法描述

由于所有节点都有原始权重和当前权重,为了方便区分,我们称当前权重为“状态”。

节点的初始状态均为 0,每开始一轮新的选择,先为各个节点加上其原始权重大小的值,然后选出权重最大的节点,将其值减去所有节点的权重和,最后,该节点作为命中节点返回。

接下来是官方的示例,对于权重占比 { 5, 1, 1 } 的 A, B, C 三个节点,每轮节点的选择和状态的变换如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| Round |  A |  B |  C | Selected Node |
|-------|----|----|----|---------------|
| | 0 | 0 | 0 | |
|-------|----|----|----|---------------|
| 1 | 5 | 1 | 1 | A |
| | -2 | 1 | 1 | |
|-------|----|----|----|---------------|
| 2 | 3 | 2 | 2 | A |
| | -4 | 2 | 2 | |
|-------|----|----|----|---------------|
| 3 | 1 | 3 | 3 | B |
| | 1 | -4 | 3 | |
|-------|----|----|----|---------------|
| 4 | 6 | -3 | 4 | A |
| | -1 | -3 | 4 | |
|-------|----|----|----|---------------|
| 5 | 4 | -2 | 5 | C |
| | 4 | -2 | -2 | |
|-------|----|----|----|---------------|
| 6 | 9 | -1 | -1 | A |
| | 2 | -1 | -1 | |
|-------|----|----|----|---------------|
| 7 | 7 | 0 | 0 | A |
| | 0 | 0 | 0 | |

证明思路

假设有三个服务器节点 A B C,它们的权重分别为 a、b、c 并保持不变,W 表示所有服务器节点权重的总和,即 W = a + b + c。

根据 SWRR 算法,每台服务器的初始权重均为 0。

ABC
000

也可以用等式表达当前的权重(状态)之和

1
Sum = 0 + 0 + 0 = 0

每次开始选择,各节点的状态会增加对应权重的大小。从中选择 CW 最大的节点,并将其值减去 W。

首先,所有节点加权,不妨设 A 为权重最大的节点,经过第一轮变换之后

ABC
a - Wbc

此时,节点的状态和仍然为 0

1
2
3
Sum = (a - W) + b + c
= (a - a - b - c) + b + c
= 0

综上,每一轮选择都是将总资源根据权重分配给各自节,再由权重最大的节点一次性消耗掉。依此类推,无论第几次选择,他们的和恒等于零。


假设 A 已经被选择了 a 轮 (a < W),即将开始第 n 轮选择(a < n < W),那么 A 节点的状态为

1
n * a - a * W = a * (n - W) < 0

由于状态总和恒为 0,而 A 节点状态小于 0 的时候一定不会被选中,因此 A 最多只能被选择 a 轮。同理,其他每个节点也最多只能被选择等同于节点权重的次数。


最后证明算法的平滑性,即 A 节点不会连续被选择 a 次。

不妨设 A 节点已经被连续选择了 a - 1 次,那么当前 A 节点的状态为

1
(a - 1) * a - (a - 1) * W = (a - 1) * (a - W) < 0

同上一条证明,由于状态总和恒为 0,而 A 节点状态小于 0 的时候一定不会被选中,因此 A 最多只能被连续选中 a - 1 轮。即每个节点也不会被连续选择,平滑性得证。

记一次 Node.js 进程挂起的 BUG 追踪

作者 Claude Ray
2019年8月8日 21:31

前言

先把干货放前面,辅助排查的 npm 模块有:wtfnode,why-is-node-running,active-handles 等,使用方法差不多如下,可以查看各自的文档。

1
2
3
4
5
6
const wtf = require('wtfnode');

// your codes

// track down
wtf.dump();

也可以更深入地排查,因为上述模块的核心都是 Node.js 文档未标注的两个接口:

1
2
process._getActiveHandles();
process._getActiveRequests();

好了,本篇到这里结束了,剩下的内容,劝你还是跳过吧:流水帐警告⚠️️ 翻车警告⚠️️

问题经过

背景是为 ts + egg 项目引入 apm 探针,由于 apm 必须在“启动文件”的第一行完成加载,即整个 egg 的生命周期开始之前,因此需要使用独立的脚本或指令进行启动。具体如何处理可以参考这篇博客

问题就出在脱离了 egg 的声明周期,得额外管理不同运行环境下 apm-server(APM 数据采集服务器)的地址配置。可能第一时间就能想到 Node.js 的环境变量,思路没错,毕竟程序和 egg-bin 绑定,NODE_ENV 环境变量的命名符合规范,主要为 development、test、production。特定环境读取特定的即可。

但调试过程中,写错了 apm-server 路径,遇到了进程启动过程中卡住的现象。解决起来很容易,但好奇是什么原因造成的,因为正常引入 elastic-apm-node 并填写一个错误的 url 并不妨碍主流程的运行。

战前准备

省略翻代码的过程,简单的结论就是:进程卡死的问题由 egg-bin 内置的 ets(egg-ts-helper)指令诱发,其使用 child_process.execSync 方法启动子进程来预加载一部分代码用作检查,而子进程卡住不退出,才导致父进程无法继续向下执行。

ets 执行 execSync 的位置:https://github.com/whxaxes/egg-ts-helper/blob/master/src/utils.ts#L107 其中 cmd 的内容是执行 ./scripts/eggInfo 文件。eggInfo 指令了 egg 的 loader 来获取插件信息,因此 apm 作为生命周期之前的模块被顺便加载了。

经过检查和断点调试,已经找出进程无法正常退出的根源在 elasitc-apm-http-client 模块和 apm-server 的通讯之间。并且如果把 apm-server 的目标地址改成本机未使用的端口,如 http://localhost:8201,进程可迅速地正常退出。但如果填写一个错误地或不存在的地址,例如 http://10.10.10.10:8200 ,以致访问超时,进程就会挂起。

开始狩猎

我们已经缩小了问题重现的范围,就可以仔细阅读代码了。

首先看向 http client 创建的步骤,唯一值得注意的点是一个轮询操作。apm 创建时默认开启了 elastic-apm-http-client 的 centralConfig 选项,此功能是允许在 Kibana 看板上直接修改 apm agent 的配置而无须重启 Node.js 进程,实现原理便是轮询 apm-server 以查询最新的配置信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Client.prototype._pollConfig = function () {
// ...
const req = this._transport.get(opts, res => {
// ...
})

req.on('error', err => {
this._scheduleNextConfigPoll()
this.emit('request-error', err)
})
}

Client.prototype._scheduleNextConfigPoll = function (seconds) {
if (this._configTimer !== null) return

seconds = seconds || 300

this._configTimer = setTimeout(() => {
this._configTimer = null
this._pollConfig()
}, seconds * 1000)

this._configTimer.unref()
}

虽然轮询可怕,但上面已经为 Timeout 调用了 unref 方法。正常来说,只要没有其他 event loop 在运行,Timer.unref() 能够让 Node.js 进程在 Timeout 回调调用前退出,可以防止程序空转。

因此问题不在这段代码,为了验证推断,关闭此选项之后,果然依旧不能正常退出。

既然进程还在运转,就一定有其他的 event loop。在 elastic-apm-http-client 中继续寻找到了另一个 Timeout,该函数每次触发数据上报时都会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Client.prototype._maybeCork = function () {
if (!this._writableState.corked && this._conf.bufferWindowTime !== -1) {
this.cork()
if (this._corkTimer && this._corkTimer.refresh) {
// the refresh function was added in Node 10.2.0
this._corkTimer.refresh()
} else {
this._corkTimer = setTimeout(() => {
this.uncork()
}, this._conf.bufferWindowTime)
}
} else if (this._writableState.length >= this._conf.bufferWindowSize) {
this._maybeUncork()
}
}

Timer.refresh() 可以重置已执行的定时任务,这里很有可能是真正的问题点。

暂停代码阅读,先盲目猜测一波:在第一次请求超时之前,进程一定不会退出,但超时之后,将在 30 秒后才会重新发起新的请求,进程退出的机会就在这 30 秒。而 _maybeCork 这里虽然每次数据上报都会触发不经过 unref 处理的 setTimeout,奈何我查了 bufferWindowTime 默认才 20 毫秒。所以问题出在这里的可能性又很渺茫了,试下把这段注释掉,果然……
但凭着对 elastic-apm-node 项目的熟悉,性能指标的上报间隔恰好也是 30 秒,这里一定有个定时任务的,但之前直接在项目中搜索关键字未找到,就忽略了。

根据相关时间字段,又检索到了定时任务的创建地点 —— measured-reporting 模块,然而这里也做了 unref 处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
_createIntervalCallback(intervalInSeconds) {
this._log.debug(`_createIntervalCallback() called with intervalInSeconds: ${intervalInSeconds}`);

const timer = setInterval(() => {
this._reportMetricsWithInterval(intervalInSeconds);
}, intervalInSeconds * 1000);

if (this._unrefTimers) {
timer.unref();
}

this._intervals.push(timer);
}

狩猎失败/超时

在 node_modules 中全局搜索了 setTimeout 以及 setInterval,排除了所有可疑迹象,剩下的连接就难排查了,迫于“生产力”,问题暂时追踪到这里。。。

亡羊补牢

思考了对应的解决方案:

  1. 将 ets 改成异步执行,但可能失去了前置检查的意义
  2. 分析并重写 elastic-apm-node 的连接机制
  3. 在 ets 执行时不启动 apm
  4. 做好连通性检测,确保 apm 的配置可靠再 apm.start(),但网络请求是异步的,会让 apm 之后整个项目的模块加载都在异步回调中处理

1、2 的成本太高,而 4 没有做过可靠的测试,不保证不会对模块加载和优化产生副作用,所以最可行的方案是 3。

找准方向就开搞,通过 process.env 入手,关注几个比较有用的环境变量:

  1. process.env.NODE_ENV
  2. process.env.ETS_REGISTER_PID,此变量存在时,证明有 ets 参与,不启动。但特别地,单独运行 ets 时没有此变量,也没有 NODE_ENV,应对方法是将 NODE_ENV 为 undefined 的环境也排除。
  3. process.env.npm_lifecycle_event,是 npm 添加的当前执行的 npm script 名称标识。可以考虑为 start,restart 时才启动。

补充方案:
通过 process.env 向 apm 传递自定义参数,便于控制配置项。

最后 apm 启动头部长这个样子

1
2
3
4
5
6
7
// 自定义 APM_ENABLE 作为开关条件
const enableAPM = process.env.APM_ENABLE || (process.env.NODE_ENV && !process.env.ETS_REGISTER_PID);
if (enableAPM) {
// 除了 NODE_ENV,也可以使用自定义的 ENV
const options = getOptionsByENV(process.env.NODE_ENV)
apm.start(options);
}

总结

虽然没结论,但进程挂起的根本原因是没错的。找 BUG 最耗时的是方向歪了,试过用干净的 Node.js 代码模拟,没能复现问题。而 wtfnode 和其他跟踪模块,因为 callsite 覆盖问题,也没能提供清晰的调用栈,或许应该考虑从修复 callsite 这个方向入手?

To be continue?

但是意义不大了,从这堆充满 hack 的代码中并没有学到特别有价值的东西,而且时间成本太高。倒是警醒自己在 Timer 和 socket 的底层使用上,务必留意 unref 的处理。还是把时间留给更重要的事吧,衰!

❌
❌