如何在 Npm 上發(fā)布二進(jìn)制文件?
??????號(hào)外,號(hào)外。我們的f_cli現(xiàn)在有了npm版本了。有兩種主流的方式來(lái)訪問。
- 全局安裝
npm i -g f_cli_f
f_cli_f create 你的項(xiàng)目名稱
- npx 操作
- npx f_cli_f create 你的項(xiàng)目名稱
隨意選中任意一個(gè)方式,不出意外的話,就在指定的文件路徑下,生成了一個(gè)功能完備的前端項(xiàng)目。
前言
我們主要的精力放在如何配置一個(gè)「功能全備」的前端項(xiàng)目。
然后,有些同學(xué)說(shuō),既然cli都有了,但是下載二進(jìn)制文件很麻煩。最好是將f_cli發(fā)布到npm上。畢竟,在前端開發(fā)中,npm大家都熟悉。
所以,今天我們就來(lái)講講「如何將二進(jìn)制文件發(fā)布到npm」。
好了,天不早了,干點(diǎn)正事哇。
我們能所學(xué)到的知識(shí)點(diǎn)
- Rust項(xiàng)目交叉編譯
- 構(gòu)建&發(fā)布目標(biāo)npm項(xiàng)目
- 構(gòu)建&發(fā)布主包
- 本地應(yīng)用
1. Rust項(xiàng)目交叉編譯
要將源代碼編譯到與本地平臺(tái)不同的平臺(tái)上,需要指定一個(gè)目標(biāo)(target)。這將告訴編譯器應(yīng)該為哪個(gè)平臺(tái)編譯代碼。
確定target
作為一個(gè)cli工具,我們的f_cli需要發(fā)配給團(tuán)隊(duì)伙伴使用。此時(shí)就會(huì)出現(xiàn)一個(gè)問題,團(tuán)隊(duì)伙伴的開發(fā)環(huán)境(處理器架構(gòu)/操作系統(tǒng))可能和我們本機(jī)不一樣,所以我們需要將Rust編譯成適配不同的處理器架構(gòu)和操作系統(tǒng)。
以下是我們工作中比較常見的開發(fā)環(huán)境。
- Darwin(arm)
- Darwin(arm64)
- Darwin(x64)
- Linux (arm)
- Windows (i686)
- Windows (x64)
針對(duì)f_cli我們只兼容比較場(chǎng)景的開發(fā)環(huán)境。(后期有需要會(huì)兼容更多版本)
- Darwin(arm64) - MacOS的M1版本
- Darwin(x64) - MacOS的Intel版本
- Windows (x64) - Windows
安裝指定target
我們要想將Rust項(xiàng)目編譯成指定的目標(biāo)二進(jìn)制,我們可以在cargo build時(shí),使用--target xxx參數(shù)來(lái)指定目標(biāo)環(huán)境。
還記得rustup嗎?我們?cè)赗ust環(huán)境配置和入門指南中有過(guò)介紹。
rustup的命令行工具來(lái)完成Rust的下載和安裝,這個(gè)工具被用來(lái)管理不同的Rust發(fā)行版本及其附帶工具鏈。
其實(shí)rustup除了安裝和更新Rust,它還可以查看rust在交叉編譯[1]時(shí),能夠轉(zhuǎn)換的目標(biāo)環(huán)境。
我們可以通過(guò)rustup target list來(lái)查看這些信息。
圖片
上圖是我本機(jī)已經(jīng)安裝的target。(我多加了一個(gè)參數(shù)--installed)
- aarch64-apple-darwin -支持Mac Arm
- x86_64-apple-darwin - 支持Mac Intel(也是我本機(jī)環(huán)境)
- x86_64-pc-windows-gnu - 支持Windows環(huán)境
其中wasm32-unknown-unknown是我們處理Rust轉(zhuǎn)WebAssembly時(shí),才用到。關(guān)于這點(diǎn),可以參考我們之前的文章Rust 編譯為WebAssembly 在前端項(xiàng)目中使用。
既然,目標(biāo)環(huán)境已經(jīng)確定,那我們就需要將目標(biāo)環(huán)境加入到Rust環(huán)境中。
rustup target add xxxx
通過(guò)上述命令,我們就將xxxx的環(huán)境加入到Rust中。除了像上面使用rustup target list --installed來(lái)查看已經(jīng)安裝的目標(biāo)環(huán)境。
我們也可以使用rustup show來(lái)查看本機(jī)的工具環(huán)境。
圖片
執(zhí)行編譯
其實(shí)這步也沒啥可說(shuō)的。要想Rust編譯成目標(biāo)環(huán)境我們僅需在cargo build時(shí),新增target參數(shù)即可。
cargo build --release ----target = xxxx
在執(zhí)行完build后,會(huì)在Rust項(xiàng)目中target目錄下生成對(duì)應(yīng)的編譯結(jié)果。
圖片
由于我本機(jī)屬于x86_64-apple-darwin,所以在build時(shí)可以不加target參數(shù)。
然后我們可以在目標(biāo)目錄中的release中找到f_cli二進(jìn)制文件。
圖片
針對(duì)Windows環(huán)境的特殊處理
在MacOS中將Rust編譯為可以在Windows環(huán)境下執(zhí)行的二進(jìn)制時(shí),需要做額外的處理。
圖片
更多詳情可以參考如何在 Mac 上為 Windows 編譯 Rust 程序[2]
2. 構(gòu)建&發(fā)布目標(biāo)npm項(xiàng)目
我們的目標(biāo)是- 將build后的二進(jìn)制文件放置到npm包中,然后通過(guò)node進(jìn)行下載安裝。
如果將所有平臺(tái)的二進(jìn)制放到一個(gè)npm是極其耗費(fèi)流量的。所以,我們采用的是「按需下載」的方式。
所以,我們就把上一節(jié)中交叉編譯的三個(gè)二進(jìn)制文件「分別發(fā)布」成一個(gè)npm包。
- f_cli_darwin_arm64
- f_cli_darwin_x64
- f_cli_windows_x64
對(duì)于快速構(gòu)建一個(gè)npm目錄我們可以使用npm init然后一路回車。但是,我們不這樣做,我們這里采用手動(dòng)構(gòu)建package.json。然后配置一些參數(shù)即可。關(guān)于package.json中各個(gè)字段的含義,可以參考package.json的字段信息[3]
子包的目錄結(jié)構(gòu)
由于我們子包的作用就是存儲(chǔ)二進(jìn)制文件,所以我們采用最簡(jiǎn)單的目錄結(jié)構(gòu)
由于子包的處理邏輯很類似,我們下文中除了要特殊說(shuō)明,都是按照一個(gè)子包的處理方式來(lái)講解
"f_cli_darwin_arm64"/"f_cli_darwin_x64"
├── package.json
└── bin/
└── f_cli
"f_cli_windows_x64"
├── package.json
└── bin/
└── f_cli.exe
bin文件夾中就是存放我們二進(jìn)制源文件的,這里沒啥可說(shuō)的。我們來(lái)簡(jiǎn)單聊聊package.json
package.json
下面的package.json的內(nèi)容是f_cli_darwin_arm64的。其他兩個(gè)子包的信息也是大差不差的。
{
"name": "f_cli_darwin_arm64",
"version": "1.0.0",
"description": "f_cli適配MACOS_ARM64架構(gòu)",
"keywords": [
"f_cli",
"MACOS_ARM64"
],
"author": "",
"license": "ISC",
"os": ["darwin"],
"cpu": ["arm64"]
}
其中有幾個(gè)屬性我們需要額外說(shuō)明一下:
- name該字段是我們發(fā)布npm包時(shí),最主要的字段,你可以將起認(rèn)為是數(shù)據(jù)庫(kù)中的主鍵,我們平時(shí)通過(guò)npm install xxx安裝包時(shí),xxx就是此處的name的值
在發(fā)布包之前,我們可以為其指定具有特殊含義的名稱,同時(shí)該名稱需要在npm倉(cāng)庫(kù)中唯一,不然在npm publish時(shí)就會(huì)發(fā)生錯(cuò)誤
同時(shí)該名稱的格式也有要求,它需要符合^(?:(?:@(?:[a-z0-9-*~][a-z0-9-*._~]*)?/[a-z0-9-._~])|[a-z0-9-~])[a-z0-9-._~]*$正則規(guī)則
- os:指定模塊將在哪些操作系統(tǒng)上運(yùn)行
- 該值由node中的process.platform[4]決定,用于獲取操作系統(tǒng)平臺(tái)信息。
- 值為aix, android, darwin, freebsd, linux, openbsd, sunprocess, win32
- cpu:指定代碼只能在某些 CPU 架構(gòu)上運(yùn)行
- 該值由node中的process.arch[5]決定,用于獲取操作系統(tǒng)平臺(tái)信息。
- 值為x32, x64, arm, arm64, s390, s390x, mipsel, ia32, mips, ppc, ppc64.
我們后期會(huì)有關(guān)于package.json各個(gè)字段的介紹文章
發(fā)布子包到npm
其實(shí)這步特別簡(jiǎn)單就是兩個(gè)命令
- npm login
- npm publish
對(duì)于如何發(fā)布一個(gè)npm包,這里我們就不再贅述。后期如果有需求可以單寫一篇。
通過(guò)上述的操作,我們就把三個(gè)二進(jìn)制文件發(fā)布到npm上了。
圖片
上面還有一個(gè)f_cli_f,別著急,我們馬上會(huì)講到。
3. 構(gòu)建&發(fā)布主包
上面我們通過(guò)各自上傳子包到npm,實(shí)現(xiàn)了資源的分離處理。下面我們就需要通過(guò)一些方式讓主包在被安裝時(shí),能夠自動(dòng)識(shí)別出工作平臺(tái)所需要目標(biāo)并且執(zhí)行對(duì)應(yīng)的下載和安裝任務(wù)。
簡(jiǎn)而言之,我們需要在主包被安裝時(shí),實(shí)現(xiàn)按需下載
npm 按需下載原理
在package.json中有兩種方式可以下載特定于平臺(tái)的二進(jìn)制文件,而無(wú)需下載所有二進(jìn)制文件。
optionalDependencies
所有常用的 JavaScript 包管理器都支持 package.json 中的 optionalDependencies[6] 字段。包管理器通常會(huì)安裝 optionalDependencies 中列出的所有軟件包,但他們可能會(huì)根據(jù)某些條件選擇不安裝。
其中一個(gè)標(biāo)準(zhǔn)就是依賴項(xiàng) package.json 文件中的 os 和 cpu 字段。(我們?cè)谔幚碜影鼤r(shí)就已經(jīng)把這些值賦值了)
「只有當(dāng)這些字段的值與當(dāng)前系統(tǒng)的操作系統(tǒng)和架構(gòu)相匹配時(shí),才會(huì)安裝依賴包」。這意味著我們可以發(fā)布單獨(dú)的軟件包,每個(gè)軟件包只包含一個(gè)特定于平臺(tái)的二進(jìn)制文件,但其中的os和cpu字段指明了這些軟件包適用的體系結(jié)構(gòu),軟件包管理器將自動(dòng)安裝正確的軟件包。
postinstall 腳本
如果在 package.json 中包含一個(gè)名為 postinstall 的腳本,則該腳本將在包安裝后「立即執(zhí)行」,即使它是作為安裝包安裝的一種依賴。(在前端項(xiàng)目里都有啥?,我們講過(guò)prepare,其實(shí)他們的作用是類似的)
我們可以使用 postinstall 腳本下載當(dāng)前平臺(tái)的二進(jìn)制文件并將其存儲(chǔ)在系統(tǒng)上的某個(gè)位置。其實(shí)我們可以把這個(gè)包的位置存放到任何你信得過(guò)的地方,此處我們?yōu)榱朔奖銓⒍M(jìn)制文件都放置到了npm倉(cāng)庫(kù)了。
最優(yōu)解
這兩種方法都有缺點(diǎn),可能不適用于所有設(shè)置。
- 如果禁用optionalDependencies可能會(huì)遇到問題(例如,通過(guò)yarn的--ignore-optional標(biāo)志)。
- postinstall 腳本也可以被禁用,并且可能會(huì)出現(xiàn)更多問題,因?yàn)橥ǔ=ㄗh禁用它們,因?yàn)樗鼈內(nèi)菀资艿焦簟?/li>
為了最大限度地提高成功的可能性,我們將兩種方式都融合進(jìn)主包中。
目錄結(jié)構(gòu)
其實(shí)主包的目錄結(jié)構(gòu)也很簡(jiǎn)單。和子包類似,有package.json/bin/二進(jìn)制源文件
f_cli
├── install.js
├── package.json
└── bin/
└── f_cli
那么下面我們就依次解釋上面文件的含義。
package.json
{
"name": "f_cli_f",
"version": "1.0.3",
"description": "針對(duì)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"
}
}
上面出現(xiàn)的scripts.postinstall和optionalDependencies我們?cè)诒竟?jié)剛開始就解釋了。這里就不再啰嗦。
在這里我們來(lái)講講bin字段。
bin
bin 字段允許將包中的特定文件鏈接到全局的可執(zhí)行路徑,使其成為全局命令,方便用戶在命令行中直接調(diào)用。
bin 是 package.json 文件中的一個(gè)字段,用于定義「將包安裝為全局命令時(shí)的可執(zhí)行文件」。
bin 字段是一個(gè)對(duì)象,其中鍵是要?jiǎng)?chuàng)建的全局命令的名稱,值是要執(zhí)行的本地文件的路徑。
當(dāng)用戶全局安裝該包時(shí),bin 字段允許將指定的本地文件鏈接到全局的可執(zhí)行路徑,使用戶可以在命令行中直接運(yùn)行該文件。
像上文中bin 字段為 { "f_cli_f": "bin/cli" },那么在全局安裝該包后,用戶可以直接在命令行中運(yùn)行 f_cli_f,實(shí)際上會(huì)執(zhí)行 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'); // 文件系統(tǒng)模塊
const path = require('path'); // 路徑模塊
const zlib = require('zlib'); // 壓縮模塊
const https = require('https'); // HTTPS模塊
// 所有平臺(tái)和二進(jìn)制分發(fā)包的查找表
const BINARY_DISTRIBUTION_PACKAGES = {
'darwin-x64': 'f_cli_darwin_x64',
'darwin-arm64': 'f_cli_darwin_arm64',
'win32-x64': 'f_cli_windows_x64',
}
// 調(diào)整你想要安裝的版本。也可以將其設(shè)置為動(dòng)態(tài)的。
const BINARY_DISTRIBUTION_VERSION = '1.0.0';
// Windows平臺(tái)的二進(jìn)制文件以.exe結(jié)尾,因此需要特殊處理。
const binaryName = process.platform === 'win32' ? 'f_cli.exe' : 'f_cli';
// 確定當(dāng)前平臺(tái)的包名
const platformSpecificPackageName =
BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`];
// 計(jì)算我們要生成的備用二進(jìn)制文件的路徑
const fallbackBinaryPath = path.join(__dirname, binaryName);
// 創(chuàng)建HTTP請(qǐng)求的Promise函數(shù)
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在下載包時(shí)返回狀態(tài)碼 ${response.statusCode}!`
)
);
}
})
.on('error', (error) => {
reject(error);
});
});
}
// 從tarball中提取文件的函數(shù)
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的上限倍數(shù)
offset = (offset + fileSize + 511) & ~511
}
}
// 從Npm下載二進(jìn)制文件的異步函數(shù)
async function downloadBinaryFromNpm() {
// 下載正確二進(jìn)制分發(fā)包的tarball
const tarballDownloadBuffer = await makeRequest(
`https://registry.npmjs.org/${platformSpecificPackageName}/-/${platformSpecificPackageName}-${BINARY_DISTRIBUTION_VERSION}.tgz`
)
const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer)
// 從軟件包中提取二進(jìn)制文件并寫入磁盤
fs.writeFileSync(
fallbackBinaryPath,
extractFileFromTarball(tarballBuffer, `package/bin/${binaryName}`),
{ mode: 0o755 } // 使二進(jìn)制文件可執(zhí)行
)
}
// 檢查是否已安裝平臺(tái)特定的軟件包
function isPlatformSpecificPackageInstalled() {
try {
// 如果optionalDependency未安裝,解析將失敗
require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`)
return true
} catch (e) {
return false
}
}
// 如果不支持當(dāng)前平臺(tái),拋出錯(cuò)誤
if (!platformSpecificPackageName) {
throw new Error('不支持的平臺(tái)!')
}
// 如果通過(guò)optionalDependencies已安裝二進(jìn)制文件,則跳過(guò)下載
if (!isPlatformSpecificPackageInstalled()) {
console.log('未找到平臺(tái)特定的軟件包。將手動(dòng)下載二進(jìn)制文件。')
downloadBinaryFromNpm()
} else {
console.log(
'平臺(tái)特定的軟件包已安裝。將回退到手動(dòng)下載二進(jìn)制文件。'
)
}
?
這段代碼的作用是根據(jù)當(dāng)前的操作系統(tǒng)和架構(gòu),從 Npm 下載特定平臺(tái)的二進(jìn)制文件,并將其寫入磁盤。
?
大部分的代碼都有注釋,具體的功能也一目了然,這里就不再過(guò)多解釋。我們挑幾個(gè)比較重要的點(diǎn)來(lái)說(shuō)明一下。
- BINARY_DISTRIBUTION_PACKAGES: 用于存儲(chǔ)所有平臺(tái)和二進(jìn)制包的信息
- 使用process.platform和process.arch用于確定符合當(dāng)前工作環(huán)境的二進(jìn)制包名稱
- isPlatformSpecificPackageInstalled方法用于判斷是否根據(jù)optionalDependency安裝了指定的包,如果因?yàn)樘厥庠驔]安裝成功,我們就需要執(zhí)行手動(dòng)下載操作(downloadBinaryFromNpm)
如果上述操作一切順利的話,我們就會(huì)在主包的根目錄下,按照了我們的二進(jìn)制文件。
bin/cli
#!/usr/bin/env node
const path = require("path");
const childProcess = require("child_process");
// 存儲(chǔ)所有平臺(tái)和二進(jìn)制分發(fā)包的查找表
const BINARY_DISTRIBUTION_PACKAGES = {
'darwin-x64': 'f_cli_darwin_x64',
'darwin-arm64': 'f_cli_darwin_arm64',
'win32-x64': 'f_cli_windows_x64',
};
// Windows平臺(tái)的二進(jìn)制文件以.exe結(jié)尾,因此需要特殊處理
const binaryName = process.platform === "win32" ? "f_cli.exe" : "f_cli";
// 確定此平臺(tái)的軟件包名稱
const platformSpecificPackageName =
BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`]
function getBinaryPath() {
try {
// 如果optionalDependency未安裝,解析將失敗
return require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`);
} catch (e) {
// 如果未安裝,返回二進(jìn)制文件的路徑
return path.join(__dirname, "..", binaryName);
}
}
// 使用child_process模塊執(zhí)行二進(jìn)制文件并傳遞命令行參數(shù)
childProcess.execFileSync(getBinaryPath(), process.argv.slice(2), {
stdio: "inherit",
});
上面的具體邏輯和我們install.js是類似的,都是基于process.platform和process.arch確定當(dāng)前工作環(huán)境匹配的二進(jìn)制源文件,并且執(zhí)行下載操作。
就像上面說(shuō)的一樣,bin/cli這個(gè)方式是可以在命令行直接執(zhí)行的。npx f_cli_f create xxx。
有一個(gè)點(diǎn)還是忍不住的想介紹一下
- #!/usr/bin/env node 是一個(gè)稱為"shebang"的特殊注釋,通常出現(xiàn)在Unix或類Unix系統(tǒng)中的腳本文件的開頭。
這行代碼告訴操作系統(tǒng)使用/usr/bin/env來(lái)查找node命令,并使用它來(lái)解釋和執(zhí)行該腳本文件。這樣做的好處是,它允許腳本在不同的系統(tǒng)上找到正確的node解釋器,而不需要硬編碼node的路徑。
注意點(diǎn)
像使用bin/cli這種方式在命令行執(zhí)行命令時(shí),有一點(diǎn)需要額外的注意。如果你當(dāng)前工作環(huán)境中只有一個(gè)Node環(huán)境,因?yàn)槲覀僣li中存在文件的寫入操作,此時(shí)在執(zhí)行命令時(shí),會(huì)有一個(gè)寫入操作權(quán)限的錯(cuò)誤警告。
其實(shí)這是一類錯(cuò)誤,也就是npm在執(zhí)行時(shí)候需要sudo的操作權(quán)限。
圖片
在stackoverflow中有很多關(guān)于npmthrowing error without sudo的解決方案[7]
其中一個(gè)高贊回答就是讓我們使用nvm等node版本管理工具。在之前我們寫過(guò)文章如何更優(yōu)雅的使用node版本管理工具 - fnm 高階版的nvm。
發(fā)布主包到npm
其實(shí)這步特別簡(jiǎn)單就是兩個(gè)命令
- npm login
- npm publish
這樣我們所有的資源都上傳到npm了。然后,我們就可以通過(guò)我們熟悉的包管理器yarn/npm來(lái)安裝了。
額外說(shuō)明
在上面的處理邏輯中我們只依據(jù)process.platfrom和process.arch做了最簡(jiǎn)單的環(huán)境適配,其實(shí)這里還可以有很多的分支處理。
如果大家看過(guò)oxlint-npm的源碼[8]的話,它就對(duì)環(huán)境有很多的處理。
4. 本地應(yīng)用
在npm中我們已經(jīng)看到我們的cli已經(jīng)上傳成功了。
接下來(lái),我們就可以利用yarn/npm等執(zhí)行下載操作了。
全局安裝
npm i -g f_cli_f
在控制臺(tái)中執(zhí)行上述操作,然后我們就將f_cli_f安裝到npm全局環(huán)境了。
我們可以通過(guò)npm list -g來(lái)查看是否在全局按照成功。
然后我們就可以下面的命令在本地使用我們的cli創(chuàng)建項(xiàng)目了。
f_cli_f create project
npx
除了全局安裝,我們也可以使用npx f_cli_f create project進(jìn)行項(xiàng)目的初始化。