這是一個全棧項目,後端使用 node
。項目須要提供B端與C端兩個版本:javascript
項目中一些複雜的數據處理功能由 C語言
編譯而成的 動態連接庫(DLL)
(在 Linux 下叫作 Shared Library
, 簡稱 SO
,如下統稱 DLL
),及 python
封裝的接口以 http
形式提供服務。在C端版本中,因爲須要知足離線獨立運行的需求,python
服務被打包成 可執行程序
, node
經過執行命令行方式調用。html
根據以上要求,咱們作了如下技術選型。前端
node
版本選用了當時的 LTS 版本 10.15.3
,但因爲後來爲了和 Electron 中內置的 node 版本保持統一,修改成了 10.11.0
。java
雖然 10.15.3 與 10.11.0 感受版本相差很少,但確實有一些特性不一樣。例如 fs.mkdir
, 10.15.3 支持 recursive
選項,但 10.11.0 不支持,致使遷移到 Electron 時,運行結果不符預期。所以若是同時要開發 B 端與 C 端,爲了更方便遷移,最好一開始便肯定好 node 的具體版本。node
因爲須要支持C端 離線獨立運行
的需求,數據庫選用了 sqlite
。python
sqlite
是單文件數據庫,npm 包 sqlite3
,是對 sqlite3 引擎的一層封裝。一個數據庫文件、一個包含數據庫引擎的 npm 包,使得C端打包成離線獨立運行程序成爲可能。linux
DLL
的載入與調用c++
在 node
中,DLL 的調用,主要藉助如下兩個 npm 包:git
C 端應用構建與打包github
Electron
是由 Github 開發,用 HTML,CSS 和 JavaScript 來構建跨平臺桌面應用程序的一個開源庫。Electron 經過將 Chromium 和 Node.js 合併到同一個運行時環境中,並將其打包爲 Mac,Windows 和 Linux 系統下的應用來實現這一目的。
Electron 對前端開發者的友好,及對跨平臺的支持,使得咱們決定使用 Electron 來對 B 端版本進行一次包裝,最大程度複用 B 端代碼。主要用到了這幾個依賴:
electron@4.2.0
: 4.x
版本的 electron 內置的 node 版本是 10.x
,能夠與 B 端所用 node 版本匹配electron-builder
: 用於 electron 打包DLL
的 位數
肯定開發環境DLL
的 位數
對開發環境搭建有很大的影響。以本項目爲例,某 DLL windows 平臺上只提供了 32 位版本,致使咱們在開發時,必須選用 32 位 node。萬幸的是,不須要系統也爲 32 位。
此處推薦使用 nvm
對 node 版本進行管理,當切換了 node 版本後,相似於 ffi
ref
sqlite3
node-sass
這樣的原生模塊都須要重裝,藉助 node-gyp
或同類工具進行從新編譯。
安裝 ref
ffi
sqlite3
等原生模塊依賴時,須要藉助 node-gyp
來針對當前系統平臺進行編譯。所以 node-gyp
的安裝前置條件必須知足
參考:github: node-gyp#Installation
如下爲本項目中實踐步驟
Linux
python v2.7
make
安裝 gcc
gcc-c++
Windows
npm install --global --production windows-build-tools
windows-build-tools
可以幫助咱們方便地配置好編譯 node 原生模塊須要的環境--vs2015
,默認狀況安裝的是 VS2017 ,一開始遇到了一些問題,後來換成 VS2015 過程要順暢一些,這個選項可看狀況選擇npm config set msvs_version 2015
,若是安裝的是 VS2017 ,則值設置爲 2017
npm config set python python2.7的安裝路徑
Electron 開發環境
Electron 開發環境與第 2 點要求一致,不一樣的是,咱們使用的 node
是 Electron 內置的 node ,而非系統安裝的 node 。所以須要爲 Electron 環境從新編譯 node 原生模塊。
這一步能夠藉助npm包 electron-rebuild
或 electron-builder
完成
本項目使用的是第二種方式:
electron-builder
"postinstall": "electron-builder install-app-deps"
到 npm scripts 中,這樣每次安裝依賴時,將會自動爲咱們進行原生模塊編譯Electron 版本選擇擴展資料:
安裝依賴(其中一些問題也可能出如今 Electron 打包中)
npm install
root
,則須要添加參數 --unsafe-perm
,不然會遇到 EACCES: permission denied
等權限不足錯誤v140 工具集
,可用的一個方法:經過 Visual Studio 安裝 若 MSBuild
時報錯信息出現了 Microsoft.Cpp.Default.props
相關信息,且發現尋找的地址並不正確
可嘗試設置環境變量:
set VCTargetsPath=C:\Program Files(x86)\MSBuild\Microsoft.Cpp\v4.0\V140
該路徑通常是這樣的,但也有可能有所不一樣,根據實際狀況設置
DLL
的使用相比起環境配置, DLL
的使用要更簡單。ffi
結合 ref
,基本使用可參考 ffi 官方示例 ref 官方示例。
DLL 依賴於其餘 DLL
這個問題比較常見,咱們嘗試過兩種方案
使用 ffi.DynamicLibrary
引入依賴
let { RTLD_NOW, RTLD_GLOBAL } = ffi.DynamicLibrary.FLAGS; // 有多個則執行屢次 ffi.DynamicLibrary 來引入多個 ffi.DynamicLibrary( '/path/to/.dll/or/.so', RTLD_NOW | RTLD_GLOBAL );
126
的 win error這個表示沒法找到 DLL ,多是路徑錯誤,也多是其依賴未引入或沒法被程序在全局路徑訪問。開發與部署環境有差別時,很容易遇到這種問題。
那麼咱們能夠怎樣肯定缺乏的依賴是哪個呢? Linux 提供了 ldd
支持咱們查看程序運行所需的 DLL ,若沒法找到某個依賴,其對應的地址將爲 not found
。 Windows 自帶的 CMD 不支持該命令,但 Git Bash 等工具爲咱們集成了該功能。另外 Windows 下也可使用 Dependency Walker
等工具獲取依賴。
ffi.Library
第三個參數支持傳入一個對象,若傳入這個對象, ffi 會將該庫新增的方法添加到這個對象上,同名將被覆蓋,最終返回這個對象,沒有傳這個參數時,會返回一個新的對象。能夠這樣作,但通常來講沒有這種需求。
使用 ffi
讀取複雜數據結構
舉一個簡單的例子——讀取字符串數組
function readStringArray (buffer, total) { let arr = []; for (let i = 0; i < total; i++) { arr.push(ref.get(buffer, ref.sizeof.pointer * i, ref.types.CString)); } return arr; }
Win 32 | Win 64 | |
---|---|---|
32-bit DLL | C:/Windows/System32 | C:/Windows/SysWOW64 |
64-bit DLL | C:/Windows/System32 |
根據 DLL 位數及操做系統位數的不一樣,將 DLL 放到以上某個目錄便可
環境變量 PATH
衆所周知, Windows 下 PATH 環境變量很是神奇, DLL 也不例外。將 DLL 所在目錄的絕對路徑加到 PATH 環境變量中,能夠實現 DLL 全局訪問。放在 node 裏面,能夠經過以下方式動態設置:
process.env.PATH += `${path.delimiter}${xxx}`;
主要是經過命令 ldconfig
。 ldconfig
是一個 SO 管理命令,可使 SO 爲系統所共享。 ldconfig
按照必定規則搜尋 SO 並建立連接、緩存,供程序使用。
如下爲搜尋範圍:
/lib
目錄/usr/lib
目錄/etc/ld.so.conf
文件中聲明的目錄一般狀況下, /etc/ld.so.conf
文件中會有一行以下內容:
include ld.so.conf.d/*.conf
所以,目錄 /etc/ld.so.conf.d
裏以 .conf
結尾的文件中聲明的目錄也在搜尋範圍
LD_LIBRARY_PATH
中設置的目錄所以,將 SO 放置到如上所說的目錄中,並執行 ldconfig
便可實現 SO 共享。咱們能夠修改 /etc/ld.so.conf
文件、新增 /etc/ld.so.conf.d/*.conf
文件或修改全局變量 LD_LIBRARY_PATH
。
參考:
項目中文件解析部分依賴了許多 DLL ,其中有部分對圖形界面產生了影響,致使用戶再次登陸時顯示黑屏。
文件解析的 DLL 依賴是經過 node 在程序啓動時寫了一個文件到 /etc/ld.so.conf.d
下,裏面聲明瞭依賴所在路徑,linux 開機時會自動加載。而解決黑屏則須要在開機時,自動去除該依賴聲明,避免用戶沒法進入系統桌面。
所以此處須要用到開機腳本在開機時爲咱們作一些處理,示例以下:
新建文件 delete-ldconfig.sh
內容以下:
#!/bin/bash # chkconfig: 5 90 10 # description: test rm -f /etc/ld.so.conf.d/test.conf ldconfig
執行以下命令:
cp ./delete-ldconfig.sh /etc/rc.d/init.d cd /etc/rc.d/init.d/ chmod +x delete-ldconfig.sh chkconfig --add delete-ldconfig.sh chkconfig delete-ldconfig.sh on
參考:
C端進行文件解析測試時,使用了一個 node_modules 目錄打包而成的壓縮包,體積雖然不算很大,但小文件十分多。調用 DLL 解析時,大體耗時30分鐘,後續程序處理又花了較久時間。在此期間,C端應用界面失去響應。
通過查詢,原來在 chromium 中,頁面渲染時,UI 進程須要和 main process 不斷的進行 sync IPC ,若此時 main process 忙,則 UI process 就會在 IPC 時阻塞。
因此,不但願渲染進程被阻塞,就須要爲主進程減負。如
在一開始的實現中,文件解析後的結果處理是使用一個 for 循環,無任何異步操做,是一個 CPU 密集型的任務。
使用如下代碼模擬該場景:
(async () => { setInterval(() => { console.log('====='); }, 60); while (true) { // 一些處理 } })();
爲了解決這塊的阻塞問題,咱們能夠進行以下改造:
(async () => { setInterval(() => { console.log('====='); }, 60); while (true) { await new Promise(resolve => { setImmediate(() => { // 一些處理 resolve(); }); }); } })();
在 Electron 中實現多進程有不少選擇,如 Web Workers、Node 的 child_process 模塊、 cluster 模塊、 worker_threads 模塊等。
因爲 Electron 項目安裝的原生模塊是通過從新編譯的,且應用運行時,會出現環境變量上的差別,致使某些系統程序沒法找到。所以,咱們不能直接用 child_process.exec
等相似方式來啓動咱們子進程。
這次實踐中,咱們通過多種嘗試,最終決定採用以下方式:
// 主進程 const bkWorker = child_process.spawn(process.execPath /* 1 */, ['./app.js'], { stdio: [0, 1, 2, 'ipc'], /* 2 */ cwd: __dirname, /* 3 */ env: process.env /* 4 */ }); bkWorker.on('message', (message) => { // ... }); // 子進程 process.send(/* ... */); /* 5 */
說明:
process.execPath
表明的是 electron 程序路徑process.send
進行通訊const { spawn } = require('child_process'); let worker; function serve () { worker = spawn(process.execPath, ['./test1.js'], { stdio: [0, 1, 2, 'ipc'], cwd: __dirname, env: process.env }); worker.on('message', (...args) => { console.log('message', ...args); }); worker.on('error', (...args) => { console.log('error', ...args); }); worker.on('exit', (code, signal) => { console.log('exit', code, signal); }); worker.on('disconnect', () => { console.log('disconnect'); }); // 基本子進程退出,都會觸發 worker.on('close') // 所以能夠在這裏作一些子進程重啓之類的事 worker.on('close', (code, signal) => { console.log('close', code, signal); // serve(); }); // 前後觸發 worker 的 disconnect exit close 事件, exit 參數爲 null SIGTERM // setTimeout(() => { // worker.kill(); // }, 2000); } // process.exit :主進程會立刻退出,不會影響子進程,要退出子進程須要另作處理 // process.abort :不會影響子進程,要退出子進程須要另作處理 // throw Error :不會影響子進程,要退出子進程須要另作處理 // setTimeout(() => { // // process.exit(); // // process.abort(); // // throw new Error('1231'); // }, 10000); setInterval(() => { console.log(process.pid, '==='); }, 1000); serve();
process.on('uncaughtException', (error) => { console.log('worker uncaughtException', error); process.send({ type: 'error', msg: error }); }); process.send('connected'); setInterval(() => { console.log(process.pid, '---'); }, 1000); // 會觸發 worker.on('disconnect') ,主 子 進程都不會退出,但鏈接中斷,不能使用 process.send ,會報錯 // setTimeout(() => { // process.disconnect(); // // process.send('hello?'); // }, 3000); // process.abort :前後觸發 worker 的 disconnect exit close 事件, exit 及 close 參數爲 null SIGABRT // process.exit :前後觸發 worker 的 disconnect exit close 事件, exit 及 close 參數爲 0 null // setTimeout(() => { // // process.abort(); // // process.exit(); // }, 10000); // 用 process.on('uncaughtException') 處理 // setTimeout(() =>{ // throw new Error('123'); // }, 1500);
進入打包後程序所在目錄,假設程序名爲「Test」,則執行命令 ./Test
便可執行程序,而且程序中全部控制檯輸出都會打印在該終端中。咱們能夠藉助輸出信息進行調試。
當 windows 打包開啓 asar 時,打包後文件資源被歸檔到一個 asar 檔案文件。若是恰好咱們須要調試的話,可能須要更改代碼後從新打包、安裝、運行。但實際上有更方便的方式。
asar 提供命令行工具,經過命令行,咱們能夠打包、列出歸檔文件中的文件列表、解壓某個單文件、解壓整個檔案文件等功能。
所以,咱們的打包後調試過程能夠被簡化爲:
參考:npm-asar
虛擬機安裝:http://note.youdao.com/notesh...
環境搭建:http://note.youdao.com/notesh...
{ "version": "0.2.0", "configurations": [ { "name": "Electron", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "program": "${workspaceFolder}/server/index.js", "runtimeExecutable": "${workspaceRoot}/server/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceRoot}/server/node_modules/.bin/electron.cmd" }, "args": [ "." ], "outputCapture": "std", "env": { "NODE_ENV": "development" } } ] }
文件連接有兩種:硬連接與符號連接。
硬連接直接指向數據,會增長該文件的 inode 計數,數據只會存在一份,全部指向該數據的連接是同步的。在文件系統中, inode 爲 0 的數據會被刪除。而符號連接是記錄的該文件位置,並不會增長文件的 inode 計數,平時咱們使用到的快捷方式就是符號連接,目標文件刪除,連接並不會消失,但這個連接指向的資源卻沒法再找到。
在項目中有這樣一個問題:整個系統是圍繞着文件資源進行的業務處理,各個模塊是串聯進行,上游輸出是下游輸入。但各個流程的資源須要容許用戶隔離管理,本模塊對某個資源的依賴不影響其餘模塊的刪除。
若是要作到相互不依賴,可能的辦法有:每一個環節都存一份文件。可是這種方式,會浪費大量的硬盤空間,數據同步也很差處理。所以最終咱們選擇使用硬連接來實現該部分需求。
硬連接操做起來很是簡單,在 node 中主要有如下幾個操做:
const fs = require('fs'); fs.link(existingPath, newPath, callback); // 建立硬連接 fs.unlink(path, callback); // 刪除硬連接 fs.stat(path[, options], callback).nlink; // 查看 inode 數
參考:
rm -f dlp.sql&&pg_dump -U 用戶名 -d 數據庫名 -f 文件名.sql -h 服務器host -p 端口 -s
參考:pg_dump
事件內拋出的錯誤會被外層捕獲
const { EventEmitter } = require('events'); const event = new EventEmitter(); event.on('test', () => { // throw new Error('1'); try { throw new Error('2'); } catch (e) { event.emit('error', e); } }); event.on('error', (e) => { console.log(e); // Error: 2 }); try { event.emit('test'); } catch (e) { console.log(e); // Error: 1 }
Promise 中的錯誤處理
await
很是關鍵,沒有 await
,try
沒法捕獲到 catch
中 throw
的錯誤
(async () => { try { let res = await new Promise(() => { throw new Error(2); }).catch(error => { if (error.message === '1') { return Promise.resolve('haha'); } else throw error; }); console.log(res); } catch (e) { debugger; } })();
function moveFileCrossDevice (source, target) { return new Promise((resolve, reject) => { try { if (!fs.existsSync(source)) reject(new BEKnownError('源文件不存在')); let readStream = fs.createReadStream(source); let writeStream = fs.createWriteStream(target); readStream.on('end',function(){ fs.unlinkSync(source); resolve(); }); readStream.on('error', (error) => { reject(error); }); writeStream.on('error', (error) => { reject(error); }); readStream.pipe(writeStream); } catch (e) { reject(e); } }); }