今天不走,明天要跑
大家好,我是柒八九 。一个专注于前端开发技术/Rust
及AI
应用知识分享 的Coder
。
写在最前面
📢📢📢号外,号外。我们的f_cli
现在有了npm
版本了。有两种主流的方式来访问
- 全局安装
npm i -g f_cli_f
f_cli_f create 你的项目名称
- npx 操作
npx f_cli_f create 你的项目名称
随意选中任意一个方式,不出意外的话,就在指定的文件路径下,生成了一个功能完备的前端项目。
前言
在Rust 赋能前端-开发一款属于你的前端脚手架我们介绍了如何用Rust
来写一个前端脚手架,主要的精力放在了Rust
方面。
在前端项目里都有啥?我们主要的精力放在如何配置一个功能全备的前端项目。
然后,有些同学说,既然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在交叉编译时,能够转换的目标环境。
我们可以通过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
环境中。
rust
rustup target add xxxx
通过上述命令,我们就将xxxx
的环境加入到Rust
中。除了像上面使用rustup target list --installed
来查看已经安装的目标环境
我们也可以使用rustup show
来查看本机的工具环境。
执行编译
其实这步也没啥可说的。要想Rust
编译成目标环境我们仅需在cargo build
时,新增target
参数即可。
rust
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. 构建&发布目标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的字段信息
子包的目录结构
由于我们子包的作用就是存储二进制文件,所以我们采用最简单的目录结构
由于子包的处理逻辑很类似,我们下文中除了要特殊说明,都是按照一个子包的处理方式来讲解
go
"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
的。其他两个子包的信息也是大差不差的。
json
{
"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决定,用于获取操作系统平台信息。
- 值为
aix
,android
,darwin
,freebsd
,linux
,openbsd
,sunprocess
,win32
cpu
:指定代码只能在某些 CPU 架构上运行- 该值由node中的process.arch决定,用于获取操作系统平台信息。
- 值为
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 字段。包管理器通常会安装 optionalDependencies
中列出的所有软件包,但他们可能会根据某些条件选择不安装。
其中一个标准就是依赖项 package.json
文件中的 os
和 cpu
字段。(我们在处理子包时就已经把这些值赋值了)
只有当这些字段的值与当前系统的操作系统和架构相匹配时,才会安装依赖包 。这意味着我们可以发布单独的软件包,每个软件包只包含一个特定于平台的二进制文件,但其中的os
和cpu
字段指明了这些软件包适用的体系结构,软件包管理器将自动安装正确的软件包。
postinstall 脚本
如果在 package.json
中包含一个名为 postinstall
的脚本,则该脚本将在包安装后立即执行 ,即使它是作为安装包安装的一种依赖。(在前端项目里都有啥?,我们讲过prepare
,其实他们的作用是类似的)
我们可以使用 postinstall
脚本下载当前平台的二进制文件并将其存储在系统上的某个位置。其实我们可以把这个包的位置存放到任何你信得过的地方,此处我们为了方便将二进制文件都放置到了npm
仓库了。
最优解
这两种方法都有缺点,可能不适用于所有设置。
- 如果禁用
optionalDependencies
可能会遇到问题(例如,通过yarn
的--ignore-optional
标志)。 postinstall
脚本也可以被禁用,并且可能会出现更多问题,因为通常建议禁用它们,因为它们容易受到攻击。
为了最大限度地提高成功的可能性,我们将两种方式都融合进主包中。
目录结构
其实主包的目录结构也很简单。和子包类似,有package.json
/bin/二进制源文件
go
f_cli
├── install.js
├── package.json
└── bin/
└── f_cli
那么下面我们就依次解释上面文件的含义。
package.json
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
文件。
shell
# 方式1: 全局按照
$ npm i -g f_cli_f
$ f_cli_f create xxx
# 方式2:包管理器
$ npx f_cli_f
install.js
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 < 300) {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
resolve(Buffer.concat(chunks));
});
} else if (
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
// 跟随重定向
makeRequest(response.headers.location).then(resolve, reject);
} else {
reject(
new Error(
`npm在下载包时返回状态码 ${response.statusCode}!`
)
);
}
})
.on('error', (error) => {
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(/\0.*/g, '')
const fileSize = parseInt(header.toString('utf-8', 124, 136).replace(/\0.*/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
js
#!/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的解决方案
其中一个高赞回答就是让我们使用nvm
等node
版本管理工具。在之前我们写过文章如何更优雅的使用node
版本管理工具 - fnm 高阶版的nvm。
发布主包到npm
其实这步特别简单就是两个命令
npm login
npm publish
这样我们所有的资源都上传到npm
了。然后,我们就可以通过我们熟悉的包管理器yarn/npm
来安装了。
额外说明
在上面的处理逻辑中我们只依据process.platfrom
和process.arch
做了最简单的环境适配,其实这里还可以有很多的分支处理。
如果大家看过oxlint-npm的源码的话,它就对环境有很多的处理。
4. 本地应用
在npm
中我们已经看到我们的cli
已经上传成功了。
接下来,我们就可以利用yarn/npm
等执行下载操作了。
全局安装
shell
npm i -g f_cli_f
在控制台中执行上述操作,然后我们就将f_cli_f
安装到npm
全局环境了。
我们可以通过npm list -g
来查看是否在全局按照成功。
然后我们就可以下面的命令在本地使用我们的cli
创建项目了。
lua
f_cli_f create project
npx
除了全局安装,我们也可以使用npx f_cli_f create project
进行项目的初始化。
后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。