NPM 的内部运作机制
NPM 是 NodeJS 生态系统的官方包管理器。自 NodeJS 首次发布以来,它就内置了 NPM。NPM 最初发布于 2010 年 1 月 12 日,此后发展成为全球最大的软件注册表。
我预计 JavaScript 生态系统中的大多数工程师都对 NPM 或 Yarn 非常熟悉,因为它们对于大多数本地开发、持续集成和持续交付 (CI/CD) 流程至关重要。不过,在概述部分,我将介绍其核心功能,因为这是我们后续将重点构建的内容。
这是我的“幕后揭秘”系列文章的一部分:
- Git
- GraphQL
- Web打包工具(例如Webpack)
- 类型系统(例如 TypeScript)
- 测试运行程序(例如 Mocha)
- 源地图
- React hooks
- 阿波罗
- 自动格式化工具(例如 Prettier)
今天的文章将分为以下几个部分:
1:概述
NPM包含3个部分。
命令行工具
CLI 是开源软件,发布在Github上。目前版本为 7,已有超过 700 位贡献者。CLI 命令是 NodeJS 脚本,需要特定的格式npm <command>。
您可以将 CLI 指向任何注册表,例如npm adduser --registry http://localhost:4873
最常用的命令是:
npm init
- 设置新软件包
- 创建
package.json(除其他事项外)
npm install
- 代码位于install.js中。
- 使用
npm-registry-fetchhttps://github.com/npm/npm-registry-fetch - 生成
node_modules - 下载在
package.json(见下文“模块解析”)中定义的依赖项 - 生成锁定文件(见下文“锁定文件”)
- 使用缓存(见下文“缓存”部分)
以下是示例输出:
锁文件
生成依赖package-lock.json树——描述已安装的依赖关系。用于确定性安装(后续安装)。如果依赖树存在,则安装将基于它进行。锁定文件存储每个依赖项的“完整性”哈希值。此哈希值(校验和)是上传到注册表的软件包压缩包的哈希值。可以是 SHA-1(旧版 NPM)或 SHA-512(新版 NPM)。它类似于HTML/浏览器中使用的子资源完整性。
模块分辨率
- NPM 按包顺序安装,即一个包安装完成后才安装下一个。这意味着安装过程可能会比较慢。
- 目前,它会尽可能高效(或扁平化)地安装所有嵌套依赖项。如果某个版本是某个依赖项的第一个版本,则它位于顶层;如果不是第一个版本,则会将其存储在需要它的父依赖项中。
- 旧版包解析(npm v5 之前)由 NodeJS (node_modules) 在磁盘上完成,速度慢得多,现在已经不再使用。
- 以下为示例解决方案
缓存
- 存储 HTTP 请求/响应数据和其他软件包相关数据
- 用途
pacote。负责获取软件包的库。 - 所有经过缓存的数据在插入和提取时都会进行完整性验证。
- 缓存损坏会触发重新获取,因此只有在需要回收磁盘空间时才需要清除缓存。
npm version
- 提升本地版本,更新
package.json和package-lock.json - 为 Git 创建版本提交和标签
npm pack
- 创建软件包的 tarball(压缩包)(
package.tar.gz) - 以下是示例屏幕截图:
npm publish
- 代码位于publish.js 文件中
npm pack作为其中的一部分运行- 将 tar 包发送到注册表
- 发布软件包至少包含一个步骤(HTTP PUT),其中包含元数据有效负载和 tarball 文件。
- 请查看打印的日志。
"npm http fetch PUT 200"
注册表
大型公共数据库,包含 JavaScript 包及其相关元信息。版本控制方式与 Git 类似。
它使用 Apache 的 NoSQL 数据库 CouchDB 来管理公开数据(它提供良好的存储性能和数据复制功能)。它包含一个“用户”数据库和一个“注册表”数据库,后者用于存储软件包。
焦油球
它使用 CouchDB 的附件功能来管理软件包 tarball。从 CouchDB 上传或下载附件极其简单,比大多数其他 NoSQL 数据库都要方便。
API
Couch 自然地暴露了 HTTP 端点,这意味着它默认内置了 API。对于身份验证,它提供了一个/_session端点(用于创建新的基于 cookie 的会话),或者接受一个Authentication用于基本身份验证的标头,两者都是原生支持的。使用设计文档Rewrites(见下文),您可以构建指向数据库不同部分的重定向。
查询
CouchDB 的动态特性在于,它允许你为特定部分创建一种称为“设计文档”的模式。这其中可以包含 JavaScript 函数(没错,它内部可以存储和运行 JS 代码),这些函数会在特定事件发生时执行,例如,Updates当文档更新时,会运行一系列函数。它还允许创建Views一些函数,这些函数接收文档数据,并根据文档内容生成可搜索的信息列表。此外,还有其他类型的动态机制,更多详情请参见此处。
NPM链接
CouchDB 表和注册表详细信息的 API 在这里(注册表文档仓库)。注册表使用的 CouchDB 视图位于npm-registry-couchapp中。npm -docker-couchdb提供了一个用于数据库设置的示例 Docker 镜像。它详细介绍了如何更新本地 NPM 客户端以使用本地 CouchDB 注册表(此处),示例如下。
npm config set registry=http://localhost:5984/registry/_design/app/_rewrite
有些应用程序使用流行的轻量级私有代理应用程序verdaccio,原因有很多,其中之一是在 NPM 宕机时提供独立的缓存。
网站
该网站位于[https://www.npmjs.com/域名],域名于2010年3月19日首次注册。
它使用Webpack、React和Lodash构建。其资源通过CDN CloudFlare提供服务。所有付款均通过Stripe处理。
连接并从注册表 CouchDB 实例读取数据。
包裹位于https://www.npmjs.com/package/<package name>。
README markdown 文件作为首页内容加载,markdown 被渲染为 HTML。
NPM 还会显示许多其他信息,例如每周下载量、最新版本、解压后的大小和 GitHub 位置。
2. 模块与包
模块是 Nodejs 可以加载的文件或目录,位于node_modules.
CLI 包不是模块,必须先解压缩到文件夹中才能被 NodeJS 加载。
同一个模块可以有两个版本(module@1.0.0和module@1.0.1),它们不会冲突。通常,npm 包都是模块,通过require()npm 或 npm加载。import
3:构建我们自己的 NodeJS 包管理器
我们将构建一个包含 NPM 所有三个方面的应用程序。
- 注册表
- 命令行界面
- 网站
目标是获得一个适用于所有三个系统的基本概念验证模型。
使用一个example-lib(返回一些文本的小包)和example-app一个(使用前面那个包的小型 Express 服务器),我们可以测试它是否有效。
将以上所有内容封装在一个具有多个的单一存储库中packages。
注册表
为此,我们将使用默认的 CouchDB Docker 镜像。它包含一个基本的、未经身份验证的 CouchDB 实例。
我们将使用 docker-compose 来搭建我们的应用程序。
我们的docker-compose.yml开端是这样的:
version: "3.0"
services:
couchdb_container:
image: couchdb:1.6.1
ports:
- 5984:5984
目前不需要挂载卷或其他任何东西。
我们将使用 Makefile 来辅助运行。首先,我们构建并创建注册表数据库。之后,我添加了一个 ` stopand` reset,以便我们可以快速终止 Docker 应用程序并重置数据库。
Makefile以下:
up:
docker-compose up --build
db:
curl -XPUT http://localhost:5984/registry
stop:
docker-compose stop
reset:
curl -X DELETE \
'http://localhost:5984/registry' \
-H 'content-type: application/json'
打开浏览器后确认数据库和 CouchDB 实例存在。
命令行界面
位于我们的单体仓库中packages/cli/。本次概念验证仅需创建 2 个命令。
- 安装
- 发布
效果package.json如下图所示
{
"bin": {
"our-npm-install": "./scripts/install.js",
"our-npm-publish": "./scripts/publish.js"
}
}
使用文件夹bin内的方法,packages/cli我们可以运行以下命令:
npm install -g .
在终端的任何目录中,我们都可以像这样运行命令。
我们的 npm 发布
我们的 npm 安装
我本可以选择将它们拆分成一个脚本并使用参数,就像真正的 NPM 那样,但对于这个概念验证来说,这样做似乎不值得。如果我选择那样做,我会使用yargs.
安装脚本
位于packages/cli/scripts/install.js
它包含 4 个步骤:
ourDeps从当前工作目录中获取包名称和对象package.json- 遍历中的每个项目
ourDeps,在我们的概念验证中忽略版本号。fetch向 couch-db tarball 附件发出请求(看起来像http://localhost:5984/registry/{repo-name}/{repo-name}.tar.gz)- 将文件写入本地
tmp.tar.gz文件(以便处理)
tmp.tar.gz使用该库将内容提取tar到当前工作目录中node_modules/{repo-name}。- 最后删除
tmp.tar.gz文件
代码在这里。
#!/usr/bin/env node
const fetch = require("node-fetch")
const { writeFile } = require("fs")
const { promisify } = require("util")
const tar = require("tar")
const fs = require("fs")
const writeFilePromise = promisify(writeFile)
const apiUrl = "http://localhost:5984/registry"
const outputPath = `${process.cwd()}/tmp.tar.gz`
async function extractPackage(repoName) {
const zipExtractFolder = `${process.cwd()}/node_modules/${repoName}`
if (!fs.existsSync(zipExtractFolder)) {
// create package in node_mods
fs.mkdirSync(zipExtractFolder)
}
try {
// Step 3
await tar.extract({
gzip: true,
file: "tmp.tar.gz",
cwd: zipExtractFolder, // current extract
})
console.log("Extract complete")
} catch (e) {
console.log("Extract error: ", e.message)
}
}
async function downloadPackage(repoName) {
// Step 2.1
return (
fetch(`${apiUrl}/${repoName}/${repoName}.tar.gz`)
.then(x => x.arrayBuffer())
// Step 2.2
.then(x => writeFilePromise(outputPath, Buffer.from(x)))
.catch(e => console.log("Download Error: ", e.message))
)
}
async function run() {
// Step 1
const package = require(`${process.cwd()}/package.json`)
// Step 2 - process each dep
Object.keys(package.ourDeps).map(async repoName => {
await downloadPackage(repoName)
await extractPackage(repoName)
// Step 4 - remove tar
fs.unlinkSync(outputPath)
console.log(`Downloaded: ${repoName}`)
})
}
run()
发布脚本
位于packages/cli/scripts/publish.js
它包含 4 个步骤:
- 从当前工作目录抓取
package.json文件name字段- 内容
README.md
- 创建当前工作目录的 tar 包
- 将软件包 tarball(作为附件)和 README 内容(纯文本)发送到我们的 CouchDB 实例,文档名称即为软件包名称。
- 删除本地 tarball 文件
#!/usr/bin/env node
const { unlinkSync, readFile } = require("fs")
const tar = require("tar")
const { promisify } = require("util")
const nano = require("nano")("http://localhost:5984")
const readFileAsync = promisify(readFile)
async function sendPackage(repoName, readmeContents) {
const tarballName = `${repoName}.tar.gz`
const filePath = `${process.cwd()}/${tarballName}`
const tarballData = await readFileAsync(filePath)
const registry = nano.db.use("registry")
let response
try {
const docName = repoName
// Step 3
const response = await registry.insert({ readmeContents }, docName)
await registry.attachment.insert(
docName,
tarballName,
tarballData,
"application/zip",
{ rev: response.rev }
)
} catch (e) {
console.log("Error:", e)
}
console.log("Response success: ", response)
}
async function packageRepo(repoName) {
try {
// Step 2
await tar.create(
{
gzip: true,
file: `${repoName}.tar.gz`,
cwd: process.cwd(),
},
["./"]
)
} catch (e) {
console.log("gzip ERROR: ", e.message)
}
}
async function run() {
// Step 1.1
const repoName = require(`${process.cwd()}/package.json`).name
// Step 1.2
const readmeContents = await readFileAsync(`${process.cwd()}/README.md`, {
encoding: "utf8",
})
await packageRepo(repoName)
await sendPackage(repoName, readmeContents)
// Step 4 - remove file
unlinkSync(`${repoName}.tar.gz`)
}
run()
网站
我们将使用packages/websiteDocker 创建一个基本的 NodeJS 网站。
我们的packages/website/Dockerfile长相。
FROM node:14-alpine
# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm install
# Bundle app source
COPY . ./
# Exports
EXPOSE 3000
CMD [ "npm", "run", "start.dev" ]
网站详情位于packages/website/src/server.js
/packages/:package-name对于向该URL发出的请求
- 查询 CouchDB 实例以获取包名
- 使用
showdown库将 README markdown 渲染为 HTML。
如果没有找到包裹,则会打印一条友好的信息。
// deps...
const nano = require("nano")("http://couchdb_container:5984") // no auth for GET
// Constants
const PORT = 3000
const HOST = "0.0.0.0"
// couchdb
async function findOne(packageName) {
try {
const registry = nano.db.use("registry")
// Step 1
const doc = await registry.get(packageName)
console.log("client result: ", doc)
return doc
} catch (err) {
console.log("ERROR: ", err.message)
}
}
// App
const app = express()
app.get("/packages/:packageName", async (req, res) => {
const packageName = req.params["packageName"]
const result = await findOne(packageName)
if (result) {
const converter = new showdown.Converter()
// Step 2
const html = converter.makeHtml(result.readmeContents)
res.send(html)
} else {
res.send("No package found")
}
})
app.listen(PORT, HOST)
console.log(`Running on http://${HOST}:${PORT}`)
最后,我们将把网站添加到我们的系统中docker-compose.yml,以便我们可以使用注册表数据库运行它。
现在docker-compose.yml看起来是这样的
version: "3.0"
services:
web:
build: packages/website
ports:
- "3000:3000"
restart: always
volumes:
- ./packages/website:/usr/src/app
couchdb_container:
image: couchdb:1.6.1
ports:
- 5984:5984
测试有效
现在 NPM 已经完成了 3 项核心任务中的 3 项,因此我们需要使用示例应用程序和示例库来测试它是否有效。
example-lib
该库只会返回一个字符串。
外观packages/example-lib/package.json如下图所示。
{
"name": "example-lib",
"main": "index.js"
}
下面这个packages/example-lib/index.js函数只会返回一个字符串。
module.exports = () => "example-lib data"
example-app
它将打印来自真实库(例如 express)和我们的示例库的信息。
我们的packages/example-app/package.json界面如下所示。如前所述,我们的概念验证忽略了版本号。
{
"ourDeps": {
"example-lib": null
}
}
内容packages/example-app/src/index.js如下。
const express = require("express")
const exampleLib = require("example-lib")
console.log("express function", express.urlencoded)
console.log("example-lib function", exampleLib())
最后,我们更新了reset配置文件Makefile,移除已安装的软件包并卸载全局二进制命令。最终文件如下:
up:
docker-compose up --build
db:
curl -XPUT http://localhost:5984/registry
stop:
docker-compose stop
reset:
curl -X DELETE \
'http://localhost:5984/registry' \
-H 'content-type: application/json'
rm -rf packages/example-app/node_modules/example-lib
cd packages/cli && npm uninstall -g our-npm-cli
运行示例
- 设置命令
化妆品
重置
- 请查看网站上的包裹信息。
GET http://localhost:3000/packages/example-lib-> 请注意,该软件包尚不存在。
- 设置数据库和 CLI
make dbcd packages/clinpm install -g .-> 全局安装这两个 CLI 脚本(详见bin内文package.json)
- 发布软件包后检查网站
cd ../example-libour-npm-publish-> 将example-lib软件包发布到我们的注册表。GET http://localhost:5984/registry/example-lib/example-lib.tar.gz-> 注册表软件包 tarball 的位置GET http://localhost:3000/packages/example-lib->README网站上的套餐(截图如下)
- 使用包
cd ../example-app
就这样🙌,我们的迷你版 NPM 运行正常。
我鼓励所有感兴趣的人都去查看代码,亲自体验一下这个机制。
我们错过了什么?
如前所述,NPM 的三个核心元素各自具有一些功能,但我们的应用程序中省略了这些功能。其中一些功能包括:
命令行界面
init包括和的各种命令pack- 能够通过参数下载软件包
- 创建锁定文件(包括版本和依赖信息)
- 缓存和请求/响应数据
- 例如:旗帜
--devDeps - 依赖模块解析(NPM 有许多逻辑用于管理此问题,请参阅概述部分)
注册表
- CouchDB 实例的身份验证机制得到改进
sha512sum软件包/tarball 内容的哈希值(“子资源完整性”检查)- 安装计数器
- Semvar 软件包版本
网站
- 显示安装增量
- 显示版本和完整性哈希
- 不错的用户界面
非常感谢您的阅读,我从这项研究中学到了很多关于 NPM 的知识,希望对您有所帮助。您可以在这里找到所有代码的仓库。
谢谢,克雷格😃
文章来源:https://dev.to/craigtaub/under-the-hood-of-npm-7ec







