記一次 Electron + DLL/SO(node-ffi) + sqlite3 項目實踐

背景

這是一個全棧項目,後端使用 node 。項目須要提供B端與C端兩個版本:javascript

  • B端要求支持多實例環境
  • C端要求跨平臺離線獨立運行

項目中一些複雜的數據處理功能由 C語言 編譯而成的 動態連接庫(DLL) (在 Linux 下叫作 Shared Library , 簡稱 SO ,如下統稱 DLL),及 python 封裝的接口以 http 形式提供服務。在C端版本中,因爲須要知足離線獨立運行的需求,python 服務被打包成 可執行程序node 經過執行命令行方式調用。html

根據以上要求,咱們作了如下技術選型。前端

技術選型

  1. node 版本

    選用了當時的 LTS 版本 10.15.3,但因爲後來爲了和 Electron 中內置的 node 版本保持統一,修改成了 10.11.0java

    雖然 10.15.3 與 10.11.0 感受版本相差很少,但確實有一些特性不一樣。例如 fs.mkdir , 10.15.3 支持 recursive 選項,但 10.11.0 不支持,致使遷移到 Electron 時,運行結果不符預期。所以若是同時要開發 B 端與 C 端,爲了更方便遷移,最好一開始便肯定好 node 的具體版本。node

  2. 數據庫

    因爲須要支持C端 離線獨立運行 的需求,數據庫選用了 sqlitepython

    sqlite 是單文件數據庫,npm 包 sqlite3 ,是對 sqlite3 引擎的一層封裝。一個數據庫文件、一個包含數據庫引擎的 npm 包,使得C端打包成離線獨立運行程序成爲可能。linux

  3. DLL 的載入與調用c++

    node 中,DLL 的調用,主要藉助如下兩個 npm 包:git

    • ffi : 實現 node 加載並調用 DLL
    • ref : 提供強大的內存指針操做
  4. 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 打包

開發環境搭建及問題排查

  1. 依據 DLL位數 肯定開發環境

    DLL位數 對開發環境搭建有很大的影響。以本項目爲例,某 DLL windows 平臺上只提供了 32 位版本,致使咱們在開發時,必須選用 32 位 node。萬幸的是,不須要系統也爲 32 位。

    此處推薦使用 nvm 對 node 版本進行管理,當切換了 node 版本後,相似於 ffi ref sqlite3 node-sass 這樣的原生模塊都須要重裝,藉助 node-gyp 或同類工具進行從新編譯。

    參考:native module on node.js

  2. 安裝 ref ffi sqlite3 等原生模塊依賴時,須要藉助 node-gyp 來針對當前系統平臺進行編譯。所以 node-gyp 的安裝前置條件必須知足

    參考:github: node-gyp#Installation

    如下爲本項目中實踐步驟

    • Linux

      • 安裝 python v2.7
      • 安裝 make
      • 安裝 gcc gcc-c++

        • gcc 版本須要與使用的 DLL 依賴的 gcc 版本保持一致,gcc-c++ 須要與 gcc 版本保持一致
    • 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的安裝路徑
  3. Electron 開發環境

    Electron 開發環境與第 2 點要求一致,不一樣的是,咱們使用的 node 是 Electron 內置的 node ,而非系統安裝的 node 。所以須要爲 Electron 環境從新編譯 node 原生模塊。

    這一步能夠藉助npm包 electron-rebuildelectron-builder 完成

本項目使用的是第二種方式:

  • 安裝依賴 electron-builder
  • 添加 "postinstall": "electron-builder install-app-deps" 到 npm scripts 中,這樣每次安裝依賴時,將會自動爲咱們進行原生模塊編譯

Electron 版本選擇擴展資料:

    1. 安裝依賴(其中一些問題也可能出如今 Electron 打包中)

      npm install

    DLL 的使用

    相比起環境配置, DLL 的使用要更簡單。ffi 結合 ref ,基本使用可參考 ffi 官方示例 ref 官方示例

    常見問題

    1. DLL 依賴於其餘 DLL

      這個問題比較常見,咱們嘗試過兩種方案

      1. 使用 ffi.DynamicLibrary 引入依賴

        let { RTLD_NOW, RTLD_GLOBAL } = ffi.DynamicLibrary.FLAGS;
        // 有多個則執行屢次 ffi.DynamicLibrary 來引入多個
        ffi.DynamicLibrary(
            '/path/to/.dll/or/.so',
            RTLD_NOW | RTLD_GLOBAL
        );
      2. 使依賴可全局訪問,詳情見下文
    2. 遇到錯誤碼爲 126 的 win error

      這個表示沒法找到 DLL ,多是路徑錯誤,也多是其依賴未引入或沒法被程序在全局路徑訪問。開發與部署環境有差別時,很容易遇到這種問題。

      那麼咱們能夠怎樣肯定缺乏的依賴是哪個呢? Linux 提供了 ldd 支持咱們查看程序運行所需的 DLL ,若沒法找到某個依賴,其對應的地址將爲 not found 。 Windows 自帶的 CMD 不支持該命令,但 Git Bash 等工具爲咱們集成了該功能。另外 Windows 下也可使用 Dependency Walker 等工具獲取依賴。

    3. 想合併多個庫到一個對象上

      ffi.Library 第三個參數支持傳入一個對象,若傳入這個對象, ffi 會將該庫新增的方法添加到這個對象上,同名將被覆蓋,最終返回這個對象,沒有傳這個參數時,會返回一個新的對象。能夠這樣作,但通常來講沒有這種需求。

    4. 使用 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;
      }

      參考:Complex data structures with node-ffi

    使 DLL 或其依賴可全局訪問

    Windows 環境

    • 共享目錄方式
    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}`;

    Linux 環境

    主要是經過命令 ldconfigldconfig 是一個 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

    參考:

    一個 Linux 開機腳本配合 ldconfig 應用實踐

    項目中文件解析部分依賴了許多 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

    參考:

    Electron 主進程繁忙阻塞渲染進程問題

    C端進行文件解析測試時,使用了一個 node_modules 目錄打包而成的壓縮包,體積雖然不算很大,但小文件十分多。調用 DLL 解析時,大體耗時30分鐘,後續程序處理又花了較久時間。在此期間,C端應用界面失去響應。

    通過查詢,原來在 chromium 中,頁面渲染時,UI 進程須要和 main process 不斷的進行 sync IPC ,若此時 main process 忙,則 UI process 就會在 IPC 時阻塞。

    因此,不但願渲染進程被阻塞,就須要爲主進程減負。如

    • 在大量同步代碼中間斷地插入異步處理,將執行權暫時交出
    • 使用多進程

    參考:Electron的主進程阻塞致使UI卡頓的問題

    中斷同步代碼

    在一開始的實現中,文件解析後的結果處理是使用一個 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 */

    說明:

    1. 指定要執行的程序路徑,主進程中, process.execPath 表明的是 electron 程序路徑
    2. 啓動一個父子進程間的IPC通道方便咱們使用 process.send 進行通訊
    3. 設置子進程的當前工做目錄
    4. 同步父進程的環境變量到子進程

    參考:node文檔-child_process.spawn

    spawn 的子進程意外退出重啓處理探究

    主進程 index.js

    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();

    子進程 test1.js

    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 便可執行程序,而且程序中全部控制檯輸出都會打印在該終端中。咱們能夠藉助輸出信息進行調試。

    asar

    當 windows 打包開啓 asar 時,打包後文件資源被歸檔到一個 asar 檔案文件。若是恰好咱們須要調試的話,可能須要更改代碼後從新打包、安裝、運行。但實際上有更方便的方式。

    asar 提供命令行工具,經過命令行,咱們能夠打包、列出歸檔文件中的文件列表、解壓某個單文件、解壓整個檔案文件等功能。

    所以,咱們的打包後調試過程能夠被簡化爲:

    • 關閉應用,解壓打包後 asar 文件
    • 修改代碼,而後將修改後的文件整個從新打包,重啓應用

    參考:npm-asar

    雜項

    Linux 虛擬機的安裝及經常使用環境搭建

    虛擬機安裝:http://note.youdao.com/notesh...

    環境搭建:http://note.youdao.com/notesh...

    VS Code 調試 Electron 配置文件

    {
        "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 數

    參考:

    postgre 數據導出

    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 很是關鍵,沒有 awaittry 沒法捕獲到 catchthrow 的錯誤

      (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);
            }
        });
    }

    docker 經常使用命令

    http://note.youdao.com/notesh...

    相關文章
    相關標籤/搜索