浅谈 npm 依赖治理

想想项目创建之后,多久没给 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.md 或 History.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 中 dependencies 和 devDependencies 的区别就不必介绍了,但大家在项目中是否会做严格区分呢? 一来 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。 回到标语所提项目依赖的“健康度”,实为笔者胡诌,用来形容依赖关系的混乱程度。不做这些依赖治理,也没有太大关系,因为软件的生命周期往往坚持不到依赖关系崩坏的那天。但混乱的依赖管理,却能轻易促成代码的腐化。

2022/6/28
articleCard.readMore

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

在过去,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 库。

2021/1/27
articleCard.readMore

如何管理桌面窗口

想向同事分享窗口切换的一点心得,不小心写成一篇没什么营养的方法论。虽然主要还是讲窗口切换。 图片未加速 什么是窗口管理 如果你不知道什么是窗口管理,在开启话题之前,不妨先来确认一下窗口管理器和桌面环境的概念。 首先说桌面环境,翻译自 Desktop Environment,它负责为用户提供完整的操作界面,而不仅仅是狭义的“桌面部件”,还包括图标、窗口、工具栏、壁纸等等。 再说窗口管理器,Window Manager,它是上述桌面环境的一部分,关乎图形化应用的窗口的基本操作,主要为如何进行排列和切换。窗口管理器是桌面环境的一部分,甚至可以完全独立于桌面环境,只运行窗口管理器,从而节省硬件资源。它包含以下类型: Float 悬浮:不同窗口可以相互重叠,就像桌子上随意摆放的白纸一样(这里借用了 Archlinux Wiki 的比喻)。正是 MacOS 和 Windows 提供的模式。 Tiling 平铺:窗口不能重叠,而是像瓦片一样挨个摆放。 Dynamic 动态:兼顾上述两种模式,可以动态切换窗口放置方式。 不同类型的窗口管理器提供了不同的窗口摆放方式,还提供了各自的窗口切换逻辑,其中“平铺”更倾向于使用键盘操作,如何选择,主要看个人口味。 虽然着重介绍了窗口管理器,但它不是今天的主角,我们应该跳出所有的运行环境,去发现真正的“窗口管理器”其实是使用者自己。 排列方式 窗口排列是一个答案无足轻重选择题,需要结合显示器的使用习惯作答。如果仅从思路上讲,相比手动排列,自动排列无疑是更好的选择,此时平铺类窗口管理器的优势就体现出来了。 然而,Linux 可以非常轻松地调换窗口管理器,在 MacOS 下可供的选择就不多了。yabai 要求关闭 SIP,提高了安全风险,Amethyst 功能较弱,好在轻量可控。如果放弃一点点平铺的功能性,可以选择 moom 这类辅助布局软件。考虑到本文不是工具推荐,也就不再介绍更多。 对使用小屏幕和习惯全屏的用户而言,绝大多数的使用场景是全屏,则没必要安装辅助工具。 切换方式 操作背景 按以下特征对号入座,目的是想让大家思考不同使用习惯之间的异同点。现在你的窗口管理习惯,是否适用于其他的用户呢? 窗口模式: 全屏化 窗口化 最小化(隐藏) 桌面分布: 单显示器-单桌面:将所有开启的窗口放在同一个桌面下,不采用任何虚拟桌面。 单显示器-多桌面:(按照习惯)将不同的软件放在不同的虚拟桌面下。 多显示器-单桌面:和多桌面类似,但不采用虚拟桌面,每台显示器就是一个桌面。 多显示器-多桌面:各台显示器放置了不同的虚拟桌面,互相隔离。 操作习惯 鼠标/触摸板 全局快捷键(系统默认) MacOS 可以使用 command+tab 和 command+` 切换,Linux、Windows 有 alt+tab MacOS 可以使用 control+↑ 和手势操作,Linux、Windows 有 win、win+tab 启动器:例如 MacOS 的 Spotlight、Alfred,Windows 的 Everything,Linux 的 rofi、dmenu 等等 全局快捷键(自定义) 它们的区别: 寻找(思考) -> 移动(思考+操作) -> 确认(操作) 寻找(操作+思考) -> 确认(操作) 寻找(思考+肌肉记忆) -> 确认(操作) 确认(思考+肌肉记忆) 肌肉记忆≈闭眼操作 虽然存在很大的误差,但不难发现,桌面越复杂,操作复杂度的差距就越明显。 如何自定义快捷键 两个代表性工具,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,每天享受沉浸式的屏幕体验。

2020/12/23
articleCard.readMore

SSR 页面 CDN 缓存实践

SSR 是一项资源密集型任务,要抵抗更大流量、提供更快的服务,缓存是其中的必修课。 而 CDN 缓存——作为静态资源的首要支撑,适合武装到 SSR 页面吗? 开始之前 大家对 CDN 应该已经耳熟能详,如果不甚了解也没关系,我们先通过一系列问答带诸位走近这个话题。 为什么接入 CDN? 抽象一个简单的请求链路,方便理解 CDN 的定位。 接入前: 用户 -> Nginx -> App Server 接入后: 用户 -> CDN -> Nginx -> App Server 看似增加了一层传输成本,其实不然。 CDN 利用自身广大的服务器资源,能动态优化访问路由、就近提供访问节点,以更低延迟、更高带宽从源站获取数据,优化了网络层面的用户体验。 为什么开启 CDN 缓存? 开启前:浏览器 -> CDN -> Nginx -> App Server1 -> App Server2 -> … 开启后:浏览器 <-> CDN CDN 能够缓存用户请求到的资源,并且可以包含 HTTP 响应头。在下一次任意用户请求同样的资源时,用缓存的资源直接响应用户,节省了本该由源站处理的所有后续步骤。 更直观的表达,就是截短了请求链路。 如何开启 CDN 缓存? 在不考虑自研 CDN 的情况下,开启 CDN 缓存的步骤非常简单: 域名接入 CDN 服务,同时针对路径启用缓存 在源站设置 Cache-Control 响应头,为了更灵活地控制缓存规则,但并不是必须 哪些服务可以开启 CDN 缓存? 大部分网站都适合接入 CDN,但 SSR 页面只有满足一定条件才可以开启 CDN 缓存 无用户状态 对时效性要求不高,至少能接受分钟级的延迟 怎样判断是否命中缓存? 不同 CDN 平台检测的方法略有不同,本质上都是判断响应头的标识字段。以腾讯 CDN 为例,响应头 X-Cache-Lookup 分别表示 Hit From MemCache: 命中 CDN 节点的内存 Hit From Disktank: 命中 CDN 节点的磁盘 Hit From Upstream: 未命中缓存,回源 如果该字段不存在,说明该页面没有配置 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 代理层的优化需要。

2020/7/3
articleCard.readMore

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

前言 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/cli、firebase-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 的差距已经大大收缩,两者都是不错的选择,我唯一建议是不要混合使用。 Yarn 的速度已经没有特别大的优势 还有像 PNPM 这类致力于改进 NPM 生态的努力,值得我们持续关注。 总结 当前仍在批判 NPM 生态的人群,大部分不会参与 JS 社区的建设,愿改善现状而贡献的更是凤毛麟角。 各位 NPM 用户无须危言耸听,人有失手,马有失蹄,只要规范流程,能够有效降低负面影响。 逆耳未必是忠言,希望更多有价值的声音能被发出。

2020/5/16
articleCard.readMore

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

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 包作为验证。 明明 beta 和 latest 属于不同的命名空间,为啥这里用 latest 就把 beta 装了? 原因很简单,npm 服务端在初始化一个包时,不论发布者使用了什么 dist-tag,都会同时把它添加到 latest 上。这的确是个不成文的 feature,甚至 Verdaccio 等私服方案也按此逻辑来实现了。 为了不污染 npm 环境(或承接相应的骂名),上面测试发布的包已经被笔者下架了 :P

2020/4/29
articleCard.readMore

从 Vimium 到 qutebrowser

过去两年,不论我安装、切换到哪家浏览器,除了已逝的 Vimperator,Vimium 都是第一个安装的插件。 曾经偶然听闻 qutebrowser 大名,但得知它没有让我难以割舍的 Dark Reader 插件,因此擦肩而过。 然而 Vimium 的小缺陷屡屡挑衅我的耐心,直到真正开始使用 qutebrowser,终于让我下定了迁移的决心。 对比 Vimium 只是一个浏览器插件,Firefox 和 Chrome 均有支持,可以说在不破坏原有操作体验的同时,补充了一些键盘操作的效率提升。 可它的工作方式是页面注入式的,必须等当前页面完成初始加载后才能使用键盘操作,又有一些页面注定无法完成注入,例如浏览器自己的插件商店、配置页。 qutebrowser 则是一个 PyQt 实现的轻量 GUI 跨平台浏览器,默认基于 Chromium 内核,并专注于键盘操作。不论页面是否完成加载,都可以随时使用键盘做出强大快捷的操作。 可惜缺少插件系统,且对 inspector 的支持很差,Web 开发者们可能难以接受。 它们最大的相同点,当属两者的按键都是 Vim 风格。即使之前从未体验过的用户,看看键位图也能理解它们的差异了。 文档 qutebrowser 自带离线文档,:help 即可快捷查看,深度使用的话,自然要过上几遍。 接下来,我会介绍几个要点信息,帮助其他感兴趣的 Vimium 用户无痛切换到 qutebrowser。 配置文件 qutebrowser 的配置管理十分方便,支持通过修改文件自定配置。 不仅可以用 yml 文件做基础定义,还能使用 python 满足更多的定制需要。因而更推崇直接使用 config.py 做配置管理,Linux 平台在 ~/.config/qutebrowser/config.py,Mac ~/.qutebrowser/config.py, Windows 是 %APPDATA%/qutebrowser/config/config.py。 如果一开始配置文件不存在,可执行 :config-write-py 初始化。另有可选参数 --force,强制用当前配置覆写磁盘文件。 搜索引擎 qutebrowser 默认搜索引擎为 duckduckgo,但可以按需增加自己的常用配置。 1 2 3 4 5 6 7 8 9 c.url.searchengines = { 'DEFAULT': 'https://google.com/search?q={}', 'google': 'https://google.com/search?q={}', 'duckduckgo': 'https://duckduckgo.com/?q={}', 'github': 'https://github.com/search?q={}', 'npm': 'https://npmjs.com/search?q={}', 'baidu': 'https://baidu.com/s?wd={}', 'mijisou': 'https://mijisou.com/search?q={}' } 键位迁移 作为 Vimium 老用户,我保留了之前的使用习惯,修改如下键位,并参考 Amos Bird 的配置,增加了在模式转换时自动切换中文输入法状态。这里使用的是 fcitx-remote,在 Mac 下,可以借助 fcitx-remote-for-osx 实现同样的效果。 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 # Bindings for normal mode config.bind('x', 'tab-close') config.bind('X', 'undo') config.bind('J', 'tab-prev') config.bind('K', 'tab-next') config.bind('d', 'scroll-page 0 0.5') config.bind('u', 'scroll-page 0 -0.5') config.bind('j', 'scroll-page 0 0.1') config.bind('k', 'scroll-page 0 -0.1') config.bind('i', 'enter-mode insert ;; spawn fcitx-remote -t') config.bind('gi', 'hint inputs --first ;; spawn fcitx-remote -t') config.bind('p', 'open -- {clipboard}') config.bind('P', 'open -t -- {clipboard}') config.unbind('gl') config.unbind('gr') config.bind('gj', 'tab-move -') config.bind('gk', 'tab-move +') config.bind('<Escape>', c.bindings.default['normal']['<Escape>'] + ' ;; fake-key <Escape> ;; clear-messages ;; jseval --quiet document.getSelection().empty()') # Bindings for insert mode config.bind('<Ctrl-a>', 'fake-key <Home>', mode='insert') config.bind('<Ctrl-e>', 'fake-key <End>', mode='insert') config.bind('<Ctrl-d>', 'fake-key <Delete>', mode='insert') config.bind('<Ctrl-h>', 'fake-key <Backspace>', mode='insert') config.bind('<Ctrl-k>', 'fake-key <Ctrl-Shift-Right> ;; fake-key <Backspace>', mode='insert') config.bind('<Ctrl-f>', 'fake-key <Right>', mode='insert') config.bind('<Ctrl-b>', 'fake-key <Left>', mode='insert') config.bind('<Ctrl-n>', 'fake-key <Down>', mode='insert') config.bind('<Ctrl-p>', 'fake-key <Up>', mode='insert') config.bind('<Escape>', 'spawn fcitx-remote -t ;; leave-mode ;; fake-key <Escape>', mode='insert') 代理配置 通过 c.content.proxy,可以轻松配置自己的代理,支持 http 和 socks 协议。 为了简化配置过程,我使用了 Privoxy 做了 http PAC 代理,可以参考我在 Mac 平台的 Shell 脚本。 窗口最大化 绝大部分时间,我的浏览器是处于窗口最大化的。当然不是 Mac 原生的全屏,私以为那种另开一个桌面的全屏模式体验太差,不仅窗口切换动画时间长,也无法与别的任务窗口叠加,总有需要抄点东西的时候。 在 Mac 上,qutebrowser 的 title bar 实在是又丑又大,可以通过 c.window.hide_decoration = True 来关闭它。但至今还存在的一个问题是关闭 title bar 之后,无法再调整窗口大小。 即使在更改设置之前 qutebrowser 窗口处于最大化状态,hide_decoration 只能起到隐藏 title bar 的效果,体现到界面上就是残缺的一条空白。身为强迫症简直不能忍! 一番琢磨,终于找到了临时的解决办法: 先把 hide_decoration 关掉,在浏览器上快捷执行 :set window.hide_decoration false 再将浏览器全屏,对应指令 :fullscreen 执行 :set window.hide_decoration true 按 Ctrl+Up 或用手势操作进入 Mission Control 界面,将最大化的 qutebrowser 从新桌面中拖拽到原来的桌面 这时 qutebrowser 进入短暂的“无响应”阶段,用鼠标点击或滚动一下窗口的任意地方即可重新激活 这样就获得了无边框最大化的 qutebrowser。经过检验,重启 qutebrowser,甚至重启系统之后均能保持窗口最大化。 我还加了 title bar 的热键简化操作(下面 Meta 实为 Command/Super): 1 2 3 c.window.hide_decoration = True config.bind('<Meta-Ctrl-f>', 'config-cycle window.hide_decoration false true') 总结 经过一个多星期的使用,qutebrowser 流畅的操作体验令我开怀不矣,现在彻底将它作为主力浏览器。 另一方面,它支持 insert 模式的按键定制,可以让我们在其他系统环境下,像 Mac 一样在浏览器中使用 Emacs-like 键位做行内编辑! 假如你之前从未使用过 Vim-like 的浏览器插件,可以先把 Vimium 装起来。即使键位需要一点时间适应,可它胜在有着极大的包容性——不存在模式切换,没有按键冲突,也就不用担心它会降低你既有的操作效率。 最后,我的配置都在自己的 dotfiles 仓库,希望对你有所帮助。

2020/1/1
articleCard.readMore

Verdaccio 性能优化:单机 Cluster

本篇将讨论如何解决 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 吧。

2019/12/31
articleCard.readMore

让 npm install 不使用缓存的方法

Why npm 的安装出错是屡见不鲜,往往是因为安装的环境不够 “clean”。 通常情况下,只要删除项目目录的 node_modules 和 package-lock.json,重新执行 install 就能解决。 偶尔也会出现上述操作解决不了的问题,譬如 npm 的缓存文件异常,就需要在安装前执行 npm cache clean --force 清空缓存目录。 但 npm cache clean 也存在两个未处理的缺陷,使它既不完全可靠又具备风险。 部分依赖会和 npm 共用缓存目录(终端下通过 npm config get cache 命令查看,默认 ~/.npm),用来存放自己的临时文件。 而 npm@5 之后,cache clean 只会清除该缓存目录下的 _cacahce 子目录,而忽视不在该子目录的缓存。 例如 @sentry/cli 将缓存放在了和 _cacache 同级的 sentry-cli 目录,clean cache 不会清除此处缓存。 此例有网友专门记录了排错经过 突然执行 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 再安装就会重新下载依赖啦,还起到了环境隔离的作用。

2019/12/6
articleCard.readMore

Verdaccio 性能优化:代理分流

前言 这里的 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 再到上游的最长请求路径简图,并忽略中间的安全验证环节,如下所示。 接口转发 有了代理层,就可以忽略 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 最新的代码再说吧~

2019/11/30
articleCard.readMore

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

背景 这里的 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。

2019/10/22
articleCard.readMore

CSAPP DataLab 题解

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 也的确简单,实际做到后面有许多卡壳的点,浮点数的概念都模糊了,真是一边翻书一边做,快两天才完成。书本的这章我还是甭跳了,继续刷去吧。

2019/10/2
articleCard.readMore

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

这是一篇面向 Egg.js 上层框架开发者的科普文。 Egg 官网基本做到了呈现所有“必知必会”的内容,再写一份 Egg 使用教程已经毫无必要,不如聊聊 Egg 上层框架开发过程中可能有用的技巧。 概览 文档 深入浅出的官网和专栏分享 eggjs.org yuque.com/egg/nodejs zhuanlan.zhihu.com/eggjs 核心 阅读源码的必经之路 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。 多进程模型 loader && 生命周期 深入 接下来是几个或多或少官网没有讲到的话题。 平滑重启 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_fails、fail_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 私服),安装、拷贝速度十分感人。 如何提速? 这里旨在提供解决思路,一定有更好的方案,欢迎交流指正 node_modules 不再每次都安装,打包平台和线上环境缓存第一次安装的依赖。(参考 travis-ci) 针对前一点的改进,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)}`); } npm 私服优化。修改上游镜像是一方面,自建的服务如果无法支持多节点多进程,也很容易成为安装依赖的性能瓶颈。假如使用 verdaccio 的本地存储模式,将很难得到官方 cluster 方案支持,除非你购买了 google cloud 或 aws s3。 Reference chenshenhai/eggjs-note koa-rapid-router

2019/9/17
articleCard.readMore

如何解决 Debian 系 Elastic apm-server 7.x 启动失败

本来是几个月前在 Ubuntu 部署 Elastic apm-server 遇到的问题,当时处理起来没遇到特别的卡点,就只是把解决过程丢到 Evernote 了。最近发现还有人在重复踩这个坑,因此我把笔记整理之后搬到这里作一个极简的分享。 apm-server 安装 实际步骤就不需要我复述了,官方提供现成的 deb 安装包。除了查看官方文档,更推荐使用 Kibana APM 看板自带的指南。 指南的 url 路径大概是 http://localhost:5601/app/kibana#/home/tutorial/apm?_g=() 不仅有安装引导,还提供按钮协助检查 apm-server 的服务状态。 启动异常 在 debian 系发行版安装 apm-server 后,执行 service apm-server start 报告失败,且切换到 systemctl 也无效。 service apm-server status报错如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ service apm-server status ● apm-server.service - Elastic APM Server Loaded: loaded (/lib/systemd/system/apm-server.service; enabled; vendor preset: enabled) Active: failed (Result: exit-code) since Tue 2019-04-16 14:44:42 CST; 3s ago Docs: https://www.elastic.co/solutions/apm Process: 4783 ExecStart=/usr/share/apm-server/bin/apm-server $BEAT_LOG_OPTS $BEAT_CONFIG_OPTS $BEAT_PATH_OPTS (code=ex Main PID: 4783 (code=exited, status=1/FAILURE) 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Service hold-off time over, scheduling restart. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Scheduled restart job, restart counter is at 5. 4 月 16 14:44:42 ray systemd[1]: Stopped Elastic APM Server. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Start request repeated too quickly. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Failed with result 'exit-code'. 4 月 16 14:44:42 ray systemd[1]: Failed to start Elastic APM Server. 检查日志 首先使用 journalctl 查看 systemd 的日志,如下 1 $ journalctl -u apm-server.service 打印日志 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 -- Logs begin at Wed 2019-04-10 09:30:25 CST, end at Tue 2019-04-16 14:44:42 CST. -- 4 月 16 13:43:23 ray systemd[1]: Started Elastic APM Server. 4 月 16 13:43:23 ray apm-server[2487]: Exiting: error loading config file: config file ("/etc/apm-server/apm-server.yml") 4 月 16 13:43:23 ray systemd[1]: apm-server.service: Main process exited, code=exited, status=1/FAILURE 4 月 16 13:43:23 ray systemd[1]: apm-server.service: Failed with result 'exit-code'. 4 月 16 13:43:23 ray systemd[1]: apm-server.service: Service hold-off time over, scheduling restart. 4 月 16 13:43:23 ray systemd[1]: apm-server.service: Scheduled restart job, restart counter is at 1. 4 月 16 13:43:23 ray systemd[1]: Stopped Elastic APM Server. 4 月 16 13:43:23 ray systemd[1]: Started Elastic APM Server. # ... ,笔者注释,省略中间的多次重启信息 4 月 16 14:44:42 ray apm-server[4783]: Exiting: error loading config file: config file ("/etc/apm-server/apm-server.yml") 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Main process exited, code=exited, status=1/FAILURE 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Failed with result 'exit-code'. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Service hold-off time over, scheduling restart. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Scheduled restart job, restart counter is at 5. 4 月 16 14:44:42 ray systemd[1]: Stopped Elastic APM Server. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Start request repeated too quickly. 4 月 16 14:44:42 ray systemd[1]: apm-server.service: Failed with result 'exit-code'. 4 月 16 14:44:42 ray systemd[1]: Failed to start Elastic APM Server. 这样找出真正的启动错误是 Exiting: error loading config file: config file ("/etc/apm-server/apm-server.yml") 解决方法 配置文件异常,采用 apm-server export config 进一步观察。提示如下: 1 error initializing beat: error loading config file: config file ("/etc/apm-server/apm-server.yml") must be owned by the beat user (uid=1000) or root github issues 上找到了类似的问题,但没有给出推荐的处理方案,所以决定自己动手解决。 ls -l 观察 /etc/apm-server/ 的信息,发现除了 apm-server.yml 之外,owner 都是 root 1 2 3 4 5 6 $ ls -l /etc/apm-server total 148K drwxr-xr-x 2 root root 4.0K 4 月 16 14:11 . drwxr-xr-x 142 root root 12K 4 月 16 14:11 .. -rw------- 1 apm-server apm-server 33K 4 月 6 05:48 apm-server.yml -rw-r--r-- 1 root root 94K 4 月 6 05:48 fields.yml 那么统一将权限变更到 root 吧! 1 $ sudo chown root:root /etc/apm-server/apm-server.yml 改之后测试 1 2 $ sudo apm-server test config Config OK 再尝试启动则提示成功。

2019/9/13
articleCard.readMore

Hexo NexT 主题升级 7.4

使用 7.1.2 才过了不到 3 个月,我又将博客主题升级了,不过这次是因为 sidebar 出现了统计隐藏的样式 bug,没想到意外赶上了几个特别明显的优化。这次是真的值得所有 NexT 老用户去尝试了。 性能优化 从我的体验来看,生成页面的耗时直接减半,对比 5 -> 7.1.2 版本升级的提速,效果相当可观。 定制代码注入 这个是绝对好评了!目前最常见的维护主题代码的方式就是人工 clone theme-next 到 themes/next 目录,除非 fork 一份仓库自己维护,所有定制的内容必须在 themes/next 目录修改,版本管理混在一起,一旦想升级主题,得挨个检查被自己修改过的文件。 现在可以将定制代码和原 NexT 主题代码完全隔离,自己添加的修改全都提取到 hexo 站点的 source/_data 目录下。只需要保管好 _config.yml,以后的主题更新方式就轻松地变为一键拉取最新代码。 注意需要配置开启对应的 custom_file_path,支持的模块如下,几乎全面满足定制需要。 1 2 3 4 5 6 7 8 9 10 11 12 13 custom_file_path: # 页面 #head: source/_data/head.swig #header: source/_data/header.swig #sidebar: source/_data/sidebar.swig #postMeta: source/_data/post-meta.swig #postBodyEnd: source/_data/post-body-end.swig #footer: source/_data/footer.swig #bodyEnd: source/_data/body-end.swig # 样式 #variable: source/_data/variables.styl #mixin: source/_data/mixins.styl #style: source/_data/styles.styl 可以参考我迁移后的扩展代码:https://github.com/Claude-Ray/claude-ray.github.io/tree/hexo/source/_data 使用 em 取代 px 扩大了自适应的范围,但我实在接受不了它在高分屏下的超大字体,没关系,上面提供的source/_data/variables.styl 可以用来重写 base.styl 中的变量。 1 2 3 4 5 6 7 // Font size $font-size-large = 1em; $font-size-larger = 1.125em; $font-size-largest = 1.25em; // Headings font size $font-size-headings-base = 1.6em; 以上配置差不多就可以恢复原来的视觉效果了。 配置结构优化 关于 sidebar 位置的配置终于可以在所有主题中统一生效了,还有一些其他的简化,迁移配置的时候务必注意对照着修改。 其他 除了以上明显的特性更新,还有一堆 bug 修复、渲染优化等等,没毛病! 结论 这次的更新不用等了,尤其前两个优化解决了长久以来的痛点,值得升级。 Reference Hexo NexT 主题升级 7.1.2 theme-next.org

2019/9/12
articleCard.readMore

From Journeyman to Master

《 The Pragmatic Programmer: From Journeyman to Master 》这本书已加入愿望单许久,最近终于在通勤路上读完了。对于处于或将处于熟练工阶段的程序员,毫无疑问这是本值得用心阅读的书,因此我也写一下短评和推荐理由。 作为思想指导性读物,首先它具备不挑剔阅读设备的优点,电子书随处可见的内链(实体书则是页码)可以方便地跳到引用的章节,也能帮读者构建知识的关联体系,因此你可以放心挑选你感兴趣的专题。 其次,它比《 Code Complete 》更凝练,比《 Unix 编程艺术》更平和。尤其推荐给所有所谓的中级程序员,这一点也体现在了书的副标题上 —— 面向读者为 journeyman,也就是训练有素的熟手。 虽然中文版将其译为“小工”,私以为也得是足够可靠的小工,更像是许多大牛自谦的说辞,而不代表毫无软件工程基础的 newbie。对于真正的新人而言,阅读此类书籍所遭受的影响可能是深远的,却也可能不及耳旁风。因为随着当代软件工程的普及程度,阅读时多半会觉得这些道理显而易见、理所当然,“几乎都是课堂和应试中学过的”,这种感觉在《 Code Complete 》更甚。建议没有多少工作经验的人,先去读《 Clean Code 》和 《 Refactor 》,实践更为重要,至于编程思考类的读物凭兴趣看一本足以。 上面是说对未出师的准程序员意义相对较小,而下山历练之人,亲历过绝望的 deadline、妥协的设计以及巨大的屎山,不论将它们克服还是被击败,都更能体会到为何如此常识便是属于大师的技巧与原则。在阅读过程中,就像面向一面镜子自我矫正,你可以不断反思近段时间的行为、状态是否得当。 接下来分享几个阅读本书的 tips: 类似《人月神话》,本书用到了大量的比喻,只凭目录的小标题无法直观地理解作者想表达的主旨。放在最后的附录《Quick Reference Guide》原本是用作复习的,但其实它非常值得在正文前初步阅读,作为章节目录的补充,非常契合跳越式阅读的需要。 英文原版更利于理解作者的思想,透过标题也得承认这一点。 由于个人记笔记经常疏于回顾,为了提高转化效率,就得强迫自己复习思考。我把《Quick Reference Guide》默背着一字一句地敲了下来,效率不算高,但终究是极短时间内让自己多了一些复习过程,所有不清晰的点也在这一步排除掉了。作为一种输出形式,时间性价比极高。 然后聊聊对个人印象最深的几个点,虽然不擅长写读后感,但假设有人翻到了这篇博客,希望可以抛砖引玉。 没有完美的软件,不要任何时候都奉行完美主义,尤其要知道什么时候该停下来。这个对我而言是最为欠缺的,吹毛求疵耗费了大量时间和精力,但从自身角度很难评估哪些时候哪种做法是正确的,正所谓当局者迷。我能想到的对策,除了自省,还必须要走出自己的小圈子,多吸取他人经验。 不要容忍低劣的设计,留着祸患无穷,盘它,一次不行多来几次,不需要犹豫。我经常认为是自己没有考量好才没有动手,回想起来其实就是犹豫,错失了很多磨练的机会。计算常有遗漏,而踩坑则不然,真动起手来才能弄明白设计者的初衷。只有你不希望为这个软件续命时才可以忽视它……(大误 提供解决方案,而不是借口。就算心底没想着找借口抵赖,表达方式上也要额外注意,为了提供更有价值的信息,最后解释甚至不解释。这一点可能我做的还好,甩锅是不存在的,而且非常抵触三句之内讲不到重点的沟通。 原本还想贴一下读书笔记,不然篇幅太短,认真考虑了一下决定不发了,毕竟博客只想放个人产出的东西,书中自有黄金屋,未经加工的笔记还是偷摸着恶心自己吧。 最后是想对自己说的话。 事实上我还远未达到熟练工的程度,遇到没挑战的事只能代表未跳出舒适区。矫正可以帮助自己找到舒适区,而破除壁障,需要在技术根本上出力。最近手痒又折腾起周边工具,看一些效率宝典(包括开篇这本书),想寻求新的突破,久而久之其实也是一种逃避。是时候直面最大的舒适区,把焦点回到编码上,突破更多的自我设限,Keep your mind sharp!

2019/9/10
articleCard.readMore

Mac 上移除 EasyConnect 常驻后台进程

想必大家已经知道,EasyConnect 会在后台强行添加名为 EasyMonitor 的开机自启守护进程,网上已经有关闭教程了 1 sudo launchctl unload /Library/LaunchDaemons/com.sangfor.EasyMonitor.plist 可实际上 EasyConnect 还启动了另一个“杀不掉”的后台进程 ECAgent,活动频率很低,似乎不会造成内存泄漏,略显不起眼。但这无法作为它肆意常驻的理由。 禁用 首先找到 plist 文件,在 /Library/LaunchAgents/com.sangfor.ECAgentProxy.plist。它无法被 launchctl unload,不过没关系,你可以直接把它挪走或删除,并且今后都不再需要它。 1 sudo mv /Library/LaunchAgents/com.sangfor.ECAgentProxy.plist ~ 当然这时候它还是不能被 kill 掉,要想从 launchctl 中删除而不重启电脑,可以采用 launchctl remove。 1 launchctl remove com.sangfor.ECAgentProxy 启用 关闭后台进程之后,启动 EasyConnect 会弹出警告: 1 2 3 Alert Initialization failed. Please try reinstalling! 没办法,只能向恶势力低头,需要使用时,必须重新加载 EasyMonitor。 1 2 # EasyConnect v7.6.7 开始 EasyMonitor 必须在 root 权限下运行,此前版本可以不加 sudo sudo launchctl load /Library/LaunchDaemons/com.sangfor.EasyMonitor.plist 而 ECAgent 就没这么麻烦了,它根本不必后台常驻 —— EasyConnect 启动时会自己创建一个,并且会随着 EasyConnect 进程一起退出。最终我删掉了 com.sangfor.ECAgentProxy.plist 文件的备份。 Reference Mac 下禁用开机自启软件

2019/8/24
articleCard.readMore

Nginx SWRR 算法解读

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 轮。即每个节点也不会被连续选择,平滑性得证。

2019/8/10
articleCard.readMore

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

前言 先把干货放前面,辅助排查的 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,排除了所有可疑迹象,剩下的连接就难排查了,迫于“生产力”,问题暂时追踪到这里。。。 亡羊补牢 思考了对应的解决方案: 将 ets 改成异步执行,但可能失去了前置检查的意义 分析并重写 elastic-apm-node 的连接机制 在 ets 执行时不启动 apm 做好连通性检测,确保 apm 的配置可靠再 apm.start(),但网络请求是异步的,会让 apm 之后整个项目的模块加载都在异步回调中处理 1、2 的成本太高,而 4 没有做过可靠的测试,不保证不会对模块加载和优化产生副作用,所以最可行的方案是 3。 找准方向就开搞,通过 process.env 入手,关注几个比较有用的环境变量: process.env.NODE_ENV process.env.ETS_REGISTER_PID,此变量存在时,证明有 ets 参与,不启动。但特别地,单独运行 ets 时没有此变量,也没有 NODE_ENV,应对方法是将 NODE_ENV 为 undefined 的环境也排除。 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 的处理。还是把时间留给更重要的事吧,衰!

2019/8/8
articleCard.readMore

警惕 Travis CI 的 npm 缓存

从 2019 年 7 月份开始,Travis CI 默认开启 npm 缓存。这意味着 node_module 和 package-lock.json 会在初次构建时缓存,倘若后续更新 npm 依赖而不刷新该缓存,可能带来构建失败的问题。 下面是发现问题的源头。 在为一个项目添加了新的依赖 rimraf 之后,Travis CI 意外地报错: 1 sh: 1: rimraf: not found 此时 .travis.yml 配置为 1 2 3 4 5 6 7 8 9 10 11 12 13 sudo: false language: node_js node_js: - '8' - '10' before_install: - npm i npminstall -g install: - npminstall script: - npm run ci after_script: - npminstall codecov && codecov 很明显新增的 npm 依赖没有安装上,但本地测试没有问题,于是替换 npminstall 为原生的 npm install,降低问题排查范围。 1 2 3 4 5 6 7 8 9 sudo: false language: node_js node_js: - '8' - '10' script: - npm run ci after_script: - npm install codecov && codecov 然而移除 npminstall 之后,报错变为 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Unhandled rejection RangeError: Maximum call stack size exceeded at RegExp.test (<anonymous>) at /home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/node_modules/aproba/index.js:38:16 at Array.forEach (<anonymous>) at module.exports (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/node_modules/aproba/index.js:33:11) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:37:3) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) at flatNameFromTree (/home/travis/.nvm/versions/node/v8.16.0/lib/node_modules/npm/lib/install/flatten-tree.js:39:14) 之后,去掉 rimraf 依赖于事无济,重跑其他 node 项目的 ci 却一切正常,因此最终确定是 travis 运行环境带来的问题。 果然,寻找刷新方法的过程中发现了右侧 More options 中的 Caches 选项,点击里面的删除键后,CI 重新运行成功。 Reference https://docs.travis-ci.com/user/caching/#npm-cache Please note that as of July 2019, npm is cached by default on Travis CI To disable npm caching, use: 1 2 cache: npm: false

2019/8/1
articleCard.readMore