这是一个全栈项目,后端使用 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); } }); }