如何在 Npm 上发布二进制文件?

2024年 2月 1日 116.7k 0

📢📢📢号外,号外。我们的f_cli现在有了npm版本了。有两种主流的方式来访问

  • 全局安装
  • npm i -g f_cli_f

    f_cli_f create 你的项目名称

  • npx 操作
  • npx f_cli_f create 你的项目名称

  • 随意选中任意一个方式,不出意外的话,就在指定的文件路径下,生成了一个功能完备的前端项目。

    前言

    我们主要的精力放在如何配置一个「功能全备」的前端项目。

    然后,有些同学说,既然cli都有了,但是下载二进制文件很麻烦。最好是将f_cli发布到npm上。毕竟,在前端开发中,npm大家都熟悉。

    所以,今天我们就来讲讲「如何将二进制文件发布到npm」。

    好了,天不早了,干点正事哇。

    我们能所学到的知识点

  • Rust项目交叉编译
  • 构建&发布目标npm项目
  • 构建&发布主包
  • 本地应用
  • 1. Rust项目交叉编译

    要将源代码编译到与本地平台不同的平台上,需要指定一个目标(target)。这将告诉编译器应该为哪个平台编译代码。

    确定target

    作为一个cli工具,我们的f_cli需要发配给团队伙伴使用。此时就会出现一个问题,团队伙伴的开发环境(处理器架构/操作系统)可能和我们本机不一样,所以我们需要将Rust编译成适配不同的处理器架构和操作系统。

    以下是我们工作中比较常见的开发环境。

    • Darwin(arm)
    • Darwin(arm64)
    • Darwin(x64)
    • Linux (arm)
    • Windows (i686)
    • Windows (x64)

    针对f_cli我们只兼容比较场景的开发环境。(后期有需要会兼容更多版本)

  • Darwin(arm64) - MacOS的M1版本
  • Darwin(x64) - MacOS的Intel版本
  • Windows (x64) - Windows
  • 安装指定target

    我们要想将Rust项目编译成指定的目标二进制,我们可以在cargo build时,使用--target xxx参数来指定目标环境。

    还记得rustup吗?我们在Rust环境配置和入门指南中有过介绍。

    rustup的命令行工具来完成Rust的下载和安装,这个工具被用来管理不同的Rust发行版本及其附带工具链。

    其实rustup除了安装和更新Rust,它还可以查看rust在交叉编译[1]时,能够转换的目标环境。

    我们可以通过rustup target list来查看这些信息。

    图片图片

    上图是我本机已经安装的target。(我多加了一个参数--installed)

  • aarch64-apple-darwin -支持Mac Arm
  • x86_64-apple-darwin - 支持Mac Intel(也是我本机环境)
  • x86_64-pc-windows-gnu - 支持Windows环境
  • 其中wasm32-unknown-unknown是我们处理Rust转WebAssembly时,才用到。关于这点,可以参考我们之前的文章Rust 编译为WebAssembly 在前端项目中使用

    既然,目标环境已经确定,那我们就需要将目标环境加入到Rust环境中。

    rustup target add xxxx

    通过上述命令,我们就将xxxx的环境加入到Rust中。除了像上面使用rustup target list --installed来查看已经安装的目标环境

    我们也可以使用rustup show来查看本机的工具环境。

    图片图片

    执行编译

    其实这步也没啥可说的。要想Rust编译成目标环境我们仅需在cargo build时,新增target参数即可。

    cargo build --release ----target = xxxx

    在执行完build后,会在Rust项目中target目录下生成对应的编译结果。

    图片图片

    由于我本机属于x86_64-apple-darwin,所以在build时可以不加target参数。

    然后我们可以在目标目录中的release中找到f_cli二进制文件。

    图片图片

    针对Windows环境的特殊处理

    在MacOS中将Rust编译为可以在Windows环境下执行的二进制时,需要做额外的处理。

    图片图片

    更多详情可以参考如何在 Mac 上为 Windows 编译 Rust 程序[2]

    2. 构建&发布目标npm项目

    我们的目标是- 将build后的二进制文件放置到npm包中,然后通过node进行下载安装。

    如果将所有平台的二进制放到一个npm是极其耗费流量的。所以,我们采用的是「按需下载」的方式。

    所以,我们就把上一节中交叉编译的三个二进制文件「分别发布」成一个npm包。

  • f_cli_darwin_arm64
  • f_cli_darwin_x64
  • f_cli_windows_x64
  • 对于快速构建一个npm目录我们可以使用npm init然后一路回车。但是,我们不这样做,我们这里采用手动构建package.json。然后配置一些参数即可。关于package.json中各个字段的含义,可以参考package.json的字段信息[3]

    子包的目录结构

    由于我们子包的作用就是存储二进制文件,所以我们采用最简单的目录结构

    由于子包的处理逻辑很类似,我们下文中除了要特殊说明,都是按照一个子包的处理方式来讲解

    "f_cli_darwin_arm64"/"f_cli_darwin_x64"
     ├── package.json
     └── bin/
         └── f_cli
    
    "f_cli_windows_x64"
     ├── package.json
     └── bin/
         └── f_cli.exe

    bin文件夹中就是存放我们二进制源文件的,这里没啥可说的。我们来简单聊聊package.json

    package.json

    下面的package.json的内容是f_cli_darwin_arm64的。其他两个子包的信息也是大差不差的。

    {
      "name": "f_cli_darwin_arm64",
      "version": "1.0.0",
      "description": "f_cli适配MACOS_ARM64架构",
      "keywords": [
        "f_cli",
        "MACOS_ARM64"
      ],
      "author": "",
      "license": "ISC",
      "os": ["darwin"],
      "cpu": ["arm64"]
    }

    其中有几个属性我们需要额外说明一下:

  • name该字段是我们发布npm包时,最主要的字段,你可以将起认为是数据库中的主键,我们平时通过npm install xxx安装包时,xxx就是此处的name的值
  • 在发布包之前,我们可以为其指定具有特殊含义的名称,同时该名称需要在npm仓库中唯一,不然在npm publish时就会发生错误

    同时该名称的格式也有要求,它需要符合^(?:(?:@(?:[a-z0-9-*~][a-z0-9-*._~]*)?/[a-z0-9-._~])|[a-z0-9-~])[a-z0-9-._~]*$正则规则

  • os:指定模块将在哪些操作系统上运行
    • 该值由node中的process.platform[4]决定,用于获取操作系统平台信息。

    • 值为aix, android, darwin, freebsd, linux, openbsd, sunprocess, win32

  • cpu:指定代码只能在某些 CPU 架构上运行

    • 该值由node中的process.arch[5]决定,用于获取操作系统平台信息。

    • 值为x32, x64, arm, arm64, s390, s390x, mipsel, ia32, mips, ppc, ppc64.

    我们后期会有关于package.json各个字段的介绍文章

    发布子包到npm

    其实这步特别简单就是两个命令

  • npm login
  • npm publish
  • 对于如何发布一个npm包,这里我们就不再赘述。后期如果有需求可以单写一篇。

    通过上述的操作,我们就把三个二进制文件发布到npm上了。

    图片图片

    上面还有一个f_cli_f,别着急,我们马上会讲到。

    3. 构建&发布主包

    上面我们通过各自上传子包到npm,实现了资源的分离处理。下面我们就需要通过一些方式让主包在被安装时,能够自动识别出工作平台所需要目标并且执行对应的下载和安装任务。

    简而言之,我们需要在主包被安装时,实现按需下载

    npm 按需下载原理

    在package.json中有两种方式可以下载特定于平台的二进制文件,而无需下载所有二进制文件。

    optionalDependencies

    所有常用的 JavaScript 包管理器都支持 package.json 中的 optionalDependencies[6] 字段。包管理器通常会安装 optionalDependencies 中列出的所有软件包,但他们可能会根据某些条件选择不安装。

    其中一个标准就是依赖项 package.json 文件中的 os 和 cpu 字段。(我们在处理子包时就已经把这些值赋值了)

    「只有当这些字段的值与当前系统的操作系统和架构相匹配时,才会安装依赖包」。这意味着我们可以发布单独的软件包,每个软件包只包含一个特定于平台的二进制文件,但其中的os和cpu字段指明了这些软件包适用的体系结构,软件包管理器将自动安装正确的软件包。

    postinstall 脚本

    如果在 package.json 中包含一个名为 postinstall 的脚本,则该脚本将在包安装后「立即执行」,即使它是作为安装包安装的一种依赖。(在前端项目里都有啥?,我们讲过prepare,其实他们的作用是类似的)

    我们可以使用 postinstall 脚本下载当前平台的二进制文件并将其存储在系统上的某个位置。其实我们可以把这个包的位置存放到任何你信得过的地方,此处我们为了方便将二进制文件都放置到了npm仓库了。

    最优解

    这两种方法都有缺点,可能不适用于所有设置。

    • 如果禁用optionalDependencies可能会遇到问题(例如,通过yarn的--ignore-optional标志)。
    • postinstall 脚本也可以被禁用,并且可能会出现更多问题,因为通常建议禁用它们,因为它们容易受到攻击。

    为了最大限度地提高成功的可能性,我们将两种方式都融合进主包中。

    目录结构

    其实主包的目录结构也很简单。和子包类似,有package.json/bin/二进制源文件

    f_cli
     ├── install.js
     ├── package.json
     └── bin/
         └── f_cli

    那么下面我们就依次解释上面文件的含义。

    package.json

    {
      "name": "f_cli_f",
      "version": "1.0.3",
      "description": "针对f_cli的npm 包",
      "scripts": {
        "postinstall": "node ./install.js"
      },
      "bin": {
        "f_cli_f": "bin/cli"
      },
      "optionalDependencies": {
        "f_cli_darwin": "1.0.0",
        "f_cli_linux": "1.0.0",
        "f_cli_win32": "1.0.0"
      }
    }

    上面出现的scripts.postinstall和optionalDependencies我们在本节刚开始就解释了。这里就不再啰嗦。

    在这里我们来讲讲bin字段。

    bin

    bin 字段允许将包中的特定文件链接到全局的可执行路径,使其成为全局命令,方便用户在命令行中直接调用。

    bin 是 package.json 文件中的一个字段,用于定义「将包安装为全局命令时的可执行文件」。

    bin 字段是一个对象,其中键是要创建的全局命令的名称,值是要执行的本地文件的路径。

    当用户全局安装该包时,bin 字段允许将指定的本地文件链接到全局的可执行路径,使用户可以在命令行中直接运行该文件。

    像上文中bin 字段为 { "f_cli_f": "bin/cli" },那么在全局安装该包后,用户可以直接在命令行中运行 f_cli_f,实际上会执行 bin/cli 文件。

    # 方式1: 全局按照
    $ npm i -g f_cli_f
    $ f_cli_f create xxx
    
    # 方式2:包管理器
    $ npx f_cli_f

    install.js

    // 引入必要的Node.js模块
    const fs = require('fs'); // 文件系统模块
    const path = require('path'); // 路径模块
    const zlib = require('zlib'); // 压缩模块
    const https = require('https'); // HTTPS模块
    
    
    // 所有平台和二进制分发包的查找表
    const BINARY_DISTRIBUTION_PACKAGES = {
      'darwin-x64': 'f_cli_darwin_x64',
      'darwin-arm64': 'f_cli_darwin_arm64',
      'win32-x64': 'f_cli_windows_x64',
    }
    
    // 调整你想要安装的版本。也可以将其设置为动态的。
    const BINARY_DISTRIBUTION_VERSION = '1.0.0';
    
    // Windows平台的二进制文件以.exe结尾,因此需要特殊处理。
    const binaryName = process.platform === 'win32' ? 'f_cli.exe' : 'f_cli';
    
    // 确定当前平台的包名
    const platformSpecificPackageName =
      BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`];
    
    // 计算我们要生成的备用二进制文件的路径
    const fallbackBinaryPath = path.join(__dirname, binaryName);
    
    // 创建HTTP请求的Promise函数
    function makeRequest(url) {
      return new Promise((resolve, reject) => {
        https
          .get(url, (response) => {
            if (response.statusCode >= 200 && response.statusCode  chunks.push(chunk));
              response.on('end', () => {
                resolve(Buffer.concat(chunks));
              });
            } else if (
              response.statusCode >= 300 &&
              response.statusCode  {
            reject(error);
          });
      });
    }
    
    // 从tarball中提取文件的函数
    function extractFileFromTarball(tarballBuffer, filepath) {
      let offset = 0
      while (offset < tarballBuffer.length) {
        const header = tarballBuffer.subarray(offset, offset + 512)
        offset += 512
    
        const fileName = header.toString('utf-8', 0, 100).replace(/.*/g, '')
        const fileSize = parseInt(header.toString('utf-8', 124, 136).replace(/.*/g, ''), 8)
    
        if (fileName === filepath) {
          return tarballBuffer.subarray(offset, offset + fileSize)
        }
    
        // 将offset固定到512的上限倍数
        offset = (offset + fileSize + 511) & ~511
      }
    }
    
    // 从Npm下载二进制文件的异步函数
    async function downloadBinaryFromNpm() {
      // 下载正确二进制分发包的tarball
      const tarballDownloadBuffer = await makeRequest(
        `https://registry.npmjs.org/${platformSpecificPackageName}/-/${platformSpecificPackageName}-${BINARY_DISTRIBUTION_VERSION}.tgz`
      )
    
      const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer)
    
      // 从软件包中提取二进制文件并写入磁盘
      fs.writeFileSync(
        fallbackBinaryPath,
        extractFileFromTarball(tarballBuffer, `package/bin/${binaryName}`),
        { mode: 0o755 } // 使二进制文件可执行
      )
    }
    
    // 检查是否已安装平台特定的软件包
    function isPlatformSpecificPackageInstalled() {
      try {
        // 如果optionalDependency未安装,解析将失败
        require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`)
        return true
      } catch (e) {
        return false
      }
    }
    
    // 如果不支持当前平台,抛出错误
    if (!platformSpecificPackageName) {
      throw new Error('不支持的平台!')
    }
    
    // 如果通过optionalDependencies已安装二进制文件,则跳过下载
    if (!isPlatformSpecificPackageInstalled()) {
      console.log('未找到平台特定的软件包。将手动下载二进制文件。')
      downloadBinaryFromNpm()
    } else {
      console.log(
        '平台特定的软件包已安装。将回退到手动下载二进制文件。'
      )
    }

    这段代码的作用是根据当前的操作系统和架构,从 Npm 下载特定平台的二进制文件,并将其写入磁盘。

    大部分的代码都有注释,具体的功能也一目了然,这里就不再过多解释。我们挑几个比较重要的点来说明一下。

  • BINARY_DISTRIBUTION_PACKAGES: 用于存储所有平台和二进制包的信息
  • 使用process.platform和process.arch用于确定符合当前工作环境的二进制包名称
  • isPlatformSpecificPackageInstalled方法用于判断是否根据optionalDependency安装了指定的包,如果因为特殊原因没安装成功,我们就需要执行手动下载操作(downloadBinaryFromNpm)
  • 如果上述操作一切顺利的话,我们就会在主包的根目录下,按照了我们的二进制文件。

    bin/cli

    #!/usr/bin/env node
    
    const path = require("path");
    const childProcess = require("child_process");
    
    // 存储所有平台和二进制分发包的查找表
    const BINARY_DISTRIBUTION_PACKAGES = {
      'darwin-x64': 'f_cli_darwin_x64',
      'darwin-arm64': 'f_cli_darwin_arm64',
      'win32-x64': 'f_cli_windows_x64',
    };
    
    // Windows平台的二进制文件以.exe结尾,因此需要特殊处理
    const binaryName = process.platform === "win32" ? "f_cli.exe" : "f_cli";
    
    // 确定此平台的软件包名称
    const platformSpecificPackageName =
      BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`]
    
    function getBinaryPath() {
      try {
        // 如果optionalDependency未安装,解析将失败
        return require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`);
      } catch (e) {
        // 如果未安装,返回二进制文件的路径
        return path.join(__dirname, "..", binaryName);
      }
    }
    
    // 使用child_process模块执行二进制文件并传递命令行参数
    childProcess.execFileSync(getBinaryPath(), process.argv.slice(2), {
      stdio: "inherit",
    });

    上面的具体逻辑和我们install.js是类似的,都是基于process.platform和process.arch确定当前工作环境匹配的二进制源文件,并且执行下载操作。

    就像上面说的一样,bin/cli这个方式是可以在命令行直接执行的。npx f_cli_f create xxx。

    有一个点还是忍不住的想介绍一下

  • #!/usr/bin/env node 是一个称为"shebang"的特殊注释,通常出现在Unix或类Unix系统中的脚本文件的开头。
  • 这行代码告诉操作系统使用/usr/bin/env来查找node命令,并使用它来解释和执行该脚本文件。这样做的好处是,它允许脚本在不同的系统上找到正确的node解释器,而不需要硬编码node的路径。

    注意点

    像使用bin/cli这种方式在命令行执行命令时,有一点需要额外的注意。如果你当前工作环境中只有一个Node环境,因为我们cli中存在文件的写入操作,此时在执行命令时,会有一个写入操作权限的错误警告。

    其实这是一类错误,也就是npm在执行时候需要sudo的操作权限。

    图片图片

    在stackoverflow中有很多关于npmthrowing error without sudo的解决方案[7]

    其中一个高赞回答就是让我们使用nvm等node版本管理工具。在之前我们写过文章如何更优雅的使用node版本管理工具 - fnm 高阶版的nvm。

    发布主包到npm

    其实这步特别简单就是两个命令

  • npm login
  • npm publish
  • 这样我们所有的资源都上传到npm了。然后,我们就可以通过我们熟悉的包管理器yarn/npm来安装了。

    额外说明

    在上面的处理逻辑中我们只依据process.platfrom和process.arch做了最简单的环境适配,其实这里还可以有很多的分支处理。

    如果大家看过oxlint-npm的源码[8]的话,它就对环境有很多的处理。

    4. 本地应用

    在npm中我们已经看到我们的cli已经上传成功了。

    接下来,我们就可以利用yarn/npm等执行下载操作了。

    全局安装

    npm i -g f_cli_f

    在控制台中执行上述操作,然后我们就将f_cli_f安装到npm全局环境了。

    我们可以通过npm list -g来查看是否在全局按照成功。

    然后我们就可以下面的命令在本地使用我们的cli创建项目了。

    f_cli_f create project

    npx

    除了全局安装,我们也可以使用npx f_cli_f create project进行项目的初始化。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论