前言:来龙去脉,故事梗概
这个故事得从公司里的老收银项目讲起,它是一个.net开发的windows程序,CS架构的前辈,第一版问世的时候,我还在幼儿园里活泥巴。前辈大哥已经默默无闻的为公司奉献了快20年,再锋利的兵刃也生出了锈痕。公司为了给现有业务锦上添花,想搞推荐业务,类似于顾客来买了A,系统会给他推荐B,还会展示一些商品明细、积分、金额、提示等等,需要加工收银系统的数据,这时候就出现了两种方案。
再苦一苦前辈吧
第一种方案是把推荐系统作为一个子模块,放在收银系统里开发,这样可以做到数据的自产自销。但公司本身是一个传统公司,数字化信息化转型比较晚,收银项目也是外部公司搞的。本身我们自己团队内部没有.net/C++大神,所有需求都得付费,系统像个黑盒,出个故障排查起来也很费劲。加之十几年没有换过的UI和“元气”积累,费劲的很。最终放弃了这个方案。
新兄弟
第二种方案就是把推荐系统作为一个单独的应用,让收银系统和推荐系统做数据交互,全新的架构。随之也引出了两个比较尖锐的问题:
技术栈选型?
因为是运行在windows上的应用,这个事情又落在了前端开发团队身上,可选的主流方案做了一遍调研,最终决定用Electron做一个应用外壳,类似于webview嵌入h5的形式,H5就采用vue3 + vite这一套。这样做的好处:
- Electron只做最基础的功能,提供一些底层的支持,打包体积"不会很大"。
- H5发版更新比较方便,特别是拥有万级线下门店体量的公司,如果改一个字体色号都要下发更新包去更新,体验非常差。
- 技术栈比较成熟,社区的一些解决方案都很完善。
- 还是写js/ts,而不是cpp、dart等
其中主进程的基础模块包括:
- 与收银应用的数据通信
- 数据的加密与解密
- 数据的持久化存储
- 应用的自动更新、开机自启、窗口操作、快捷键等常见功能
数据交互方式?
WebSocket(被动接收)
这种方式存在几个弊端:
- 服务端需要与万级的客户端维持长连接,服务端压力大
- 需要去维护每个长连接的状态并且保证实时存活
- 客户端显示数据的速度对网络要求高
IPC管道通信(主动请求)
这种方式的好处:
- 可以毫秒级响应订单等原始数据,就算服务端挂掉也不影响一些数据的展示
- 对服务器压力小,不需要承担太多的心智成本和负担
所以,最终选择了IPC管道通信。
正文:Do it!
正如上面所说,项目是采用Electron + H5的形式,所以会新建两个独立的项目:Electron项目,主要是做应用程序的外壳,用来处理底层逻辑。另一个则是Vue3的H5项目,用来写业务逻辑,展示数据。
壳子
处理双面屏
因为推荐系统是面向顾客的,而收银系统面向店员。所以需要两块屏幕,副屏作为主屏的拓展屏来展示推荐系统。这就需要去找到主屏和副屏,在主屏上显示身份验证、消息弹窗等窗口,在副屏上全屏显示推荐界面窗口。
// 显示器屏幕识别
this.screen = this.displays.find(display => {
// 副屏,起始坐标不为0
return display.bounds.x !== 0 || display.bounds.y !== 0
})
this.otherScreen = this.displays.find(display => {
// 主屏,起始坐标为0
return display.bounds.x === 0 || display.bounds.y === 0
})
// 兼容如果只有一块屏幕
if (!this.screen && !!this.displays?.length) {
this.screen = this.displays[0]
if (this.displays?.length > 1) {
this.otherScreen = this.displays[1]
}
}
多窗口初始化
首先需要创建一个BrowserWindow(应用程序窗口),然后在窗口中加载需要渲染的h5页面。不同的窗口可能会根据情况去设置例如大小、位置、是否显示菜单栏、标题icon、是否自动化全屏等。
const windowConfigs = {
// 推荐窗口:全屏、副屏的左上坐标为起点、隐藏菜单
main: {
autoHideMenuBar: true,
fullscreen: true,
minimizable: false,
x: this.screen.bounds.x,
y: this.screen.bounds.y,
title: '推荐窗口',
icon: 'icon.ico'
},
// 校验窗口:固定宽高、主屏居中显示、不隐藏菜单
verification: {
autoHideMenuBar: false,
fullscreen: false,
width: 400,
height: 200,
center: true,
title: '验证程序',
icon: 'icon.ico'
}
//........
}
// 创建好窗口,就可以创建BrowserView(视图)并挂载到Window中了,然后加载对应H5页面的URL。
const createWindow = (type) => new BrowserWindow(widnowConfig[type])
IPC管道通信
const net = require('net')
const fs = require('fs')
const path = require('path')
const iconv = require('iconv-lite')
// 管道名称
const pipeName = 'test_pipe'
// 兼容32位系统
const pipePath =
process.platform === 'win32'
? path.join('\\\\?\\pipe', pipeName)
: `/tmp/${pipeName}`
// 移除管道
const removeServerPipePath = serverPath => {
try {
process.platform !== 'win32' && fs.unlinkSync(serverPath)
} catch (e) {}
}
// 创建
const createPipeMan = handler => {
const server = net.createServer((connc) /* connc: 监听器 */ => {
// 监听收银系统往管道里发送的消息
connc.on('data', buffer => {
try {
// 从字符编码解码为字符串
handler(iconv.decode(buffer, 'utf-8'))
connc.write(`{ success: true, message: '' }`)
} catch (err) {
connc.write(`{ success: false, message: ${err} }`)
}
})
})
server.on('close', () => {
removeServerPipePath(pipePath)
})
}
其他
一些其他的功能比如快捷键注册、程序的开机自启等,基本上都有现成的api
//快捷键注册例子:
globalShortcut.register('Control+Shift+K', () => win.close())
// 开机自启
const AutoLaunch = require('auto-launch')
const enableAutoStart = app => {
try {
// 创建一个 AutoLaunch 实例
const appAutoLauncher = new AutoLaunch({
name: 'cincofine-app', // 替换为你的应用名称
path: app.getPath('exe') // Electron 应用的执行路径
})
// 启动应用时,检查是否设置了开机自启
appAutoLauncher
.isEnabled()
.then(isEnabled => {
if (!isEnabled) {
appAutoLauncher.enable() // 如果没有启用,则启用
}
})
.catch(err => {
// 错误处理
console.error('Error checking auto-launch: ', err)
})
} catch (err) {
console.log(err)
}
}
H5
这就到我们更擅长的领域了,只要拿到主进程给到我们的数据,其他的开发就很简单了。
import { useIpcRenderer } from '@vueuse/electron';
onMounted(() => {
const ipcRenderer = useIpcRenderer()
ipcRenderer.on("test", (_: any, ...args: any[]) => {
// do something
ipcRenderer.send('test-cb', {
// 需要给主进程处理的数据
})
})
})
混淆"加密"
应用开发完成后还可能遇到安全问题,比如被人拿到了安装包,通过一系列骚操作把源码给反编译出来,甚至更改一些代码把有问题的安装包下放到了门店,虽然概率微乎其微,但是也要防患于未然。选择的方法是用javascript-obfuscator将主流程的代码做混淆。只需要在package.json里配置:
{
"name": "cincofine-app",
"version": "1.0.0",
"main": "obscure-main.js",
"scripts": {
"dev": "npm run obs && chcp 65001 && set NODE_ENV=development && electron .",
"start": "chcp 65001 && set NODE_ENV=development && electron .",
"obs": "javascript-obfuscator main.js --output obscure-main.js",
"update": "node version.js",
"build": "npm run obs && node version.js && set DEBUG=electron-builder && set NODE_ENV=production && electron-builder --win --x64 --ia32 --publish=always",
"only-build": "npm run obs && set DEBUG=electron-builder && set NODE_ENV=production && electron-builder --win --x64 --ia32 --publish=always"
}
- obs指令会将主文件main.js通过javascript-obfuscator混淆生成obscure-main.js
- 项目的主入口将main.js换成混淆过的obscure-main.js
- 当然,也可以通过配置文件做到更灵活的混淆和加密
打包构建
这里选择了electron-builder作为构建工具,package.json配置如下:
"build": {
"appId": "cincofine-app",
"productName": "cincofine-app",
"win": {
"target": [
{
"target": "nsis",
"arch": [
"ia32",
"x64"
]
},
"squirrel"
],
"icon": "logo.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "cincofine-app"
},
"squirrelWindows": {
"iconUrl": "https://xxxxxx/fs/logo.png",
"artifactName": "${productName}_${version}.${ext}"
},
"publish": [
{
"provider": "generic",
"url": "https://xxxxxxx/update/win32"
},
{
"provider": "generic",
"url": "https://xxxxxxx/update/win64"
}
]
}
}
版本服务器
选择的是开源的electron-release-server,支持docker部署,文件存储支持硬盘、pg数据库、亚马逊云(国内的云要重写skipper的adapter)。值得一提的是,ers上传文件时有一段提示,也规定了上传文件的类型:
在windows系统下,我们需要用户新下载的文件(第一版)必须是exe和msi类型,而升级文件必须是nupkg类型。然而我第一次选择的生成安装程序的框架是NSIS,无论怎么构建,最终产物都没有一个.nupkg的文件。后来才知道这个nupkg格式的文件得由Squirrel或者其他框架生成(感兴趣的可以搜一下这些东西的区别和概念),这是一个NuGet包。而NSIS它只生成是用于安装和卸载应用程序的可执行文件exe。
但是我们希望用户在更新和安装的时候能够灵活的选择磁盘,还有一些定制化的配置,这是Squirrel实现不了的,它主打一个简单、快捷,一个动画就给你装完了,不给你留太多的操作空间。所以最后我们做了妥协,采用了两种架构并行的方式构建产物,这样一来就同时拥有了灵活定制化的exe文件和支持ers升级对比的nupkg文件,把这两个文件同时管理到ers上。缺点就是每次都要构建两种产物且用户每次升级都是全量替换(本来就是个壳子,改动不是很大,能忍!!)。
自动更新
有了版本服务器,接下来就是在代码里支持自动更新,还有一些提示和异常处理。
const { autoUpdater } = require('electron-updater')
const { dialog } = require('electron')
const checkUpdate = (win, view) => {
autoUpdater.autoDownload = false
autoUpdater.checkForUpdates().then(res => {
if (!res) {
view.webContents.send('guide-done')
}
})
autoUpdater.on('error', () => {
dialog
.showMessageBox({
type: 'info',
title: '更新错误',
message: '请联系运营人员,尝试使用老版本',
buttons: ['是']
})
.then(res => {
if (res.response === 0) {
view.webContents.send('guide-done')
}
})
})
autoUpdater.on('update-not-available', info => {
view.webContents.send('guide-done')
})
autoUpdater.on('update-available', message => {
dialog
.showMessageBox({
type: 'info',
title: '更新可用',
message: '有一个新版本可用,是否现在下载并安装更新?',
buttons: ['是', '否']
})
.then(result => {
if (result.response === 0) {
// 用户选择了‘是’
autoUpdater.downloadUpdate()
} else {
const data = {
message: '有新版本,用户选择不更新',
code: -1,
error: ''
}
view.webContents.send('guide-done', data)
}
})
})
autoUpdater.on('download-progress', progressObj => {
// 发送进度到渲染进程
view.webContents.send('download-progress', progressObj)
})
autoUpdater.on('update-downloaded', () => {
dialog
.showMessageBox({
type: 'info',
title: '安装更新',
message: '更新已下载,应用将重启并进行更新。',
buttons: ['现在重启', '稍后重启']
})
.then(result => {
if (result.response === 0) {
// 用户选择了‘现在重启’
autoUpdater.quitAndInstall()
} else {
view.webContents.send('guide-done')
}
})
})
}
异常监控
比较常用的Sentry,不管是在electron还是H5页面都非常好用。支持被动上报,也支持主动上报异常,一些因为数据返回异常出现的问题可以选择手动上传到Sentry,留下跟后端同学扯皮的证据,方便甩锅。代码配置起来也很容易:
// sentry初始化,被动上报
try {
Sentry.init({
dsn: 'sentry dsn地址'
})
} catch {
console.log('sentry初始化失败')
}
// 主动上报
Sentry.captureException(err);
遇到的问题
electron兼容性
目前遇到比较棘手的系统配置是一台32位的win7,用25版本的electron会提示各种各样的dll有问题,尝试过修复,但最终弹了一个《无法识别的错误》,没办法把版本降到了21.4.4才正常安装。但是也会发现在这个版本的电脑上,有些配置是不生效的,比如窗体的透明背景,设置了以后发现还是白色的,在64位系统上是没有这个问题的。
发布版本的管理
我们希望在ers上的版本和gitlab代码仓库上的tag版本是一一对应的,并且防止发版忘了修改版本而替换了原来版本的问题,所以我们写了一个脚本来强制规范开发人员。
const inquirer = require('inquirer')
const { execSync } = require('child_process')
function getCurrentVersion() {
const packageJson = JSON.parse(
execSync('npm version --json', { encoding: 'utf8' })
)
return packageJson.version
}
function checkUncommittedChanges() {
const status = execSync('git status --porcelain', { encoding: 'utf8' })
return status !== ''
}
function commitChanges(commitMessage) {
execSync('git add -A')
execSync(`git commit -m "${commitMessage}"`)
}
function updateVersion(versionType) {
const command = `npm version ${versionType}`
try {
if (checkUncommittedChanges()) {
throw new Error(
'You have uncommitted changes. Please commit them before bumping the version.'
)
}
const output = execSync(command, { encoding: 'utf8' })
console.log(`Updated to new version: ${output}`)
execSync('git push')
execSync('git push --tags')
console.log('Changes pushed to remote repository.')
} catch (error) {
console.error(`Failed to update version: ${error}`)
}
}
async function main() {
console.log(`Current version is: ${getCurrentVersion()}`)
try {
const { versionType } = await inquirer.prompt([
{
type: 'list',
name: 'versionType',
message: 'Choose the version type to update:',
choices: ['patch', 'minor', 'major']
}
])
const { commitMessage } = await inquirer.prompt([
{
type: 'input',
name: 'commitMessage',
message: 'Enter a commit message for your changes:',
when: checkUncommittedChanges
}
])
if (commitMessage) {
commitChanges(commitMessage)
}
updateVersion(versionType)
} catch (error) {
console.error(error)
}
}
main()
- 首先通过obs对代码做混淆
- 提示用户选择版本升级的类型
- 填写commit,自动推送代码到仓库
- 采用新版本打包构建
结尾
Electron实践到此为止,可能存在很多瑕疵和问题,希望大家多多留言交流,感谢阅读。