发布于 2026-01-06 0 阅读
0

NPM 的内部运作机制

NPM 的内部运作机制

NPM 是 NodeJS 生态系统的官方包管理器。自 NodeJS 首次发布以来,它就内置了 NPM。NPM 最初发布于 2010 年 1 月 12 日,此后发展成为全球最大的软件注册表。

我预计 JavaScript 生态系统中的大多数工程师都对 NPM 或 Yarn 非常熟悉,因为它们对于大多数本地开发、持续集成和持续交付 (CI/CD) 流程至关重要。不过,在概述部分,我将介绍其核心功能,因为这是我们后续将重点构建的内容。

这是我的“幕后揭秘”系列文章的一部分

今天的文章将分为以下几个部分:

  1. 概述

  2. 模块与软件包

  3. 我们自己构建 NodeJS 包管理器


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-fetch https://github.com/npm/npm-registry-fetch
  • 生成node_modules
  • 下载在package.json(见下文“模块解析”)中定义的依赖项
  • 生成锁定文件(见下文“锁定文件”)
  • 使用缓存(见下文“缓存”部分)

以下是示例输出:

npm 安装日志

锁文件

生成依赖package-lock.json树——描述已安装的依赖关系。用于确定性安装(后续安装)。如果依赖树存在,则安装将基于它进行。锁定文件存储每个依赖项的“完整性”哈希值。此哈希值(校验和)是上传到注册表的软件包压缩包的哈希值。可以是 SHA-1(旧版 NPM)或 SHA-512(新版 NPM)。它类似于HTML/浏览器中使用的子资源完整性。

模块分辨率
  • NPM 按包顺序安装,即一个包安装完成后才安装下一个。这意味着安装过程可能会比较慢。
  • 目前,它会尽可能高效(或扁平化)地安装所有嵌套依赖项。如果某个版本是某个依赖项的第一个版本,则它位于顶层;如果不是第一个版本,则会将其存储在需要它的父依赖项中。
  • 旧版包解析(npm v5 之前)由 NodeJS (node_modules) 在磁盘上完成,速度慢得多,现在已经不再使用。
  • 以下为示例解决方案

npm3-module-resolution

缓存
  • 存储 HTTP 请求/响应数据和其他软件包相关数据
  • 用途pacote。负责获取软件包的库。
  • 所有经过缓存的数据在插入和提取时都会进行完整性验证。
  • 缓存损坏会触发重新获取,因此只有在需要回收磁盘空间时才需要清除缓存。

npm version

  • 提升本地版本,更新package.jsonpackage-lock.json
  • 为 Git 创建版本提交和标签

npm pack

  • 创建软件包的 tarball(压缩包)( package.tar.gz)
  • 以下是示例屏幕截图:

npm-pack

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
Enter fullscreen mode Exit fullscreen mode

有些应用程序使用流行的轻量级私有代理应用程序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 位置。

npm 包


2. 模块与包

模块是 Nodejs 可以加载的文件或目录,位于node_modules.

CLI 包不是模块,必须先解压缩到文件夹中才能被 NodeJS 加载。

同一个模块可以有两个版本(module@1.0.0module@1.0.1),它们不会冲突。通常,npm 包都是模块,通过require()npm 或 npm加载。import


3:构建我们自己的 NodeJS 包管理器

我们将构建一个包含 NPM 所有三个方面的应用程序。

  1. 注册表
  2. 命令行界面
  3. 网站

目标是获得一个适用于所有三个系统的基本概念验证模型。

使用一个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
Enter fullscreen mode Exit fullscreen mode

目前不需要挂载卷或其他任何东西。

我们将使用 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'
Enter fullscreen mode Exit fullscreen mode

打开浏览器后确认数据库和 CouchDB 实例存在。

npm-couch-db

命令行界面

位于我们的单体仓库中packages/cli/。本次概念验证仅需创建 2 个命令。

  1. 安装
  2. 发布

效果package.json如下图所示

{
  "bin": {
    "our-npm-install": "./scripts/install.js",
    "our-npm-publish": "./scripts/publish.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

使用文件夹bin内的方法,packages/cli我们可以运行以下命令:

npm install -g .
Enter fullscreen mode Exit fullscreen mode

在终端的任何目录中,我们都可以像这样运行命令。

我们的 npm 发布

我们的 npm 安装

我本可以选择将它们拆分成一个脚本并使用参数,就像真正的 NPM 那样,但对于这个概念验证来说,这样做似乎不值得。如果我选​​择那样做,我会使用yargs.

安装脚本

位于packages/cli/scripts/install.js

它包含 4 个步骤:

  1. ourDeps从当前工作目录中获取包名称和对象package.json
  2. 遍历中的每个项目ourDeps,在我们的概念验证中忽略版本号。
    1. fetch向 couch-db tarball 附件发出请求(看起来像http://localhost:5984/registry/{repo-name}/{repo-name}.tar.gz
    2. 将文件写入本地tmp.tar.gz文件(以便处理)
  3. tmp.tar.gz使用该库将内容提取tar到当前工作目录中node_modules/{repo-name}
  4. 最后删除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()
Enter fullscreen mode Exit fullscreen mode

发布脚本

位于packages/cli/scripts/publish.js

它包含 4 个步骤:

  1. 从当前工作目录抓取
    1. package.json文件name字段
    2. 内容README.md
  2. 创建当前工作目录的 tar 包
  3. 将软件包 tarball(作为附件)和 README 内容(纯文本)发送到我们的 CouchDB 实例,文档名称即为软件包名称。
  4. 删除本地 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()
Enter fullscreen mode Exit fullscreen mode

网站

我们将使用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" ]
Enter fullscreen mode Exit fullscreen mode

网站详情位于packages/website/src/server.js

/packages/:package-name对于向该URL发出的请求

  1. 查询 CouchDB 实例以获取包名
  2. 使用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}`)
Enter fullscreen mode Exit fullscreen mode

最后,我们将把网站添加到我们的系统中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
Enter fullscreen mode Exit fullscreen mode

测试有效

现在 NPM 已经完成了 3 项核心任务中的 3 项,因此我们需要使用示例应用程序和示例库来测试它是否有效。

example-lib

该库只会返回一个字符串。

外观packages/example-lib/package.json如下图所示。

{
  "name": "example-lib",
  "main": "index.js"
}
Enter fullscreen mode Exit fullscreen mode

下面这个packages/example-lib/index.js函数只会返回一个字符串。

module.exports = () => "example-lib data"
Enter fullscreen mode Exit fullscreen mode

example-app

它将打印来自真实库(例如 express)和我们的示例库的信息。

我们的packages/example-app/package.json界面如下所示。如前所述,我们的概念验证忽略了版本号。

{
  "ourDeps": {
    "example-lib": null
  }
}
Enter fullscreen mode Exit fullscreen mode

内容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())
Enter fullscreen mode Exit fullscreen mode

最后,我们更新了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
Enter fullscreen mode Exit fullscreen mode

运行示例

  1. 设置命令

化妆品

重置

  1. 请查看网站上的包裹信息。
  • GET http://localhost:3000/packages/example-lib-> 请注意,该软件包尚不存在。
  1. 设置数据库和 CLI
  • make db
  • cd packages/cli
  • npm install -g .-> 全局安装这两个 CLI 脚本(详见bin内文package.json
  1. 发布软件包后检查网站
  • cd ../example-lib
  • our-npm-publish-> 将example-lib软件包发布到我们的注册表。
  • GET http://localhost:5984/registry/example-lib/example-lib.tar.gz-> 注册表软件包 tarball 的位置
  • GET http://localhost:3000/packages/example-lib->README网站上的套餐(截图如下)npm-website-markdown
  1. 使用包
  • cd ../example-app
  • npm start-> 请查看缺失软件包错误(截图如下)
    npm 启动错误

  • our-npm-installpackage.json- >从列表中安装软件包ourDeps(截图如下)
    npm-install-works

  • npm start-> 已找到软件包,现在可以正常工作了(截图如下)
    npm-start-works

就这样🙌,我们的迷你版 NPM 运行正常。

我鼓励所有感兴趣的人都去查看代码,亲自体验一下这个机制。


我们错过了什么?

如前所述,NPM 的三个核心元素各自具有一些功能,但我们的应用程序中省略了这些功能。其中一些功能包括:

命令行界面

  • init包括和的各种命令pack
  • 能够通过参数下载软件包
  • 创建锁定文件(包括版本和依赖信息)
  • 缓存和请求/响应数据
  • 例如:旗帜--devDeps
  • 依赖模块解析(NPM 有许多逻辑用于管理此问题,请参阅概述部分)

注册表

  • CouchDB 实例的身份验证机制得到改进
  • sha512sum软件包/tarball 内容的哈希值(“子资源完整性”检查)
  • 安装计数器
  • Semvar 软件包版本

网站

  • 显示安装增量
  • 显示版本和完整性哈希
  • 不错的用户界面

非常感谢您的阅读,我从这项研究中学到了很多关于 NPM 的知识,希望对您有所帮助。您可以在这里找到所有代码的仓库。

谢谢,克雷格😃

文章来源:https://dev.to/craigtaub/under-the-hood-of-npm-7ec