当 Electron 遇到 Nest.JS
ElectronNest.js技术分享
Sep 13, 2021 · 30min
前置知识:熟悉 Typescript,有 Electron 项目,Nest.js 项目的经验。
在之前的文章《用装饰器给 Electron 提供一个基础 API 框架》中,介绍了通过装饰器来加强 Electron 主进程的代码框架,使得其像一个后端框架那样来对各个模块进行功能安排。文章的最后,我也提到了我的一个项目模板: fast-vite-nestjs-electron,这个模板集成了当前流行的nodejs后端框架 Nest.js,如果未来我们的 C/S 应用要转换为 B/S 应用时,可以快速通过这种架构模式将它们进行分离。这篇文章将介绍 Electron 主进程和 Nest.js 的集成过程。
# 项目结构
项目结构其实和《用装饰器给 Electron 提供一个基础 API 框架》中使用的项目模板 fast-vite-electron 一样,src/render
目录存放渲染进程的代码,为了快速的开发体验,我们依然是选择 Vite.js 来构建前端部分。script
目录用于存放主进程的构建脚本,我们还是要快速,所以选择了 esbuild 来构建。另外就是一些根路径下的配置性文件,当然,这些不是本文的重点,这里不作过多介绍,感兴趣的朋友可以阅读之前的文章《极速DX Vite + Electron + esbuild》。
src/main
目录用于存放 Electron 主进程的代码,这里是本文的重点。虽然看上去这将会是一个 Electron 应用,但是实际上,我的集成方案里是将 Electron 集成到 Nest.js 中,所以这个目录的内部结构和 Nest.js 项目的目录机构一样,一个 index.ts
文件作为框架的启动入口,内部的子目录都是 Nest.js 的各个 modules。
src
├─main # electron 主进程目录
│ ├─global # 全局模块
│ ├─transport # electron ipc 转换器
│ ├─app.module.ts # 默认的 app module,包含一个 controller 和 service
│ ├─app.controller.ts
│ ├─app.service.ts
│ └─index.ts # 启动入口
│
└─render # vite 构建的前端目录
├─api
├─assets
├─components
├─plugins
└─public
# 用 Nest.js 启动 Electron
上面说到,我的集成方案里是将 Electron 集成到 Nest.js 中,所以应用的启动也和 Nest.js 一样,下面是 Nest.js 文档中的默认启动方法:
async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
bootstrap()
在一个 bootstrap
方法中,创建一个实例,然后监听某个端口,当然这是 Nest.js 作为后端框架的常规启动方式,默认情况[1]下内部其实是启动了一个 Express。不过我们和前端的通信可不是 HTTP 而是通过 Electron 的 IPC,所以需要换一种方式来创建实例。
# Electron Ipc 的传输器
除了传统的应用程序架构之外,Nest.js 还支持微服务架构风格的开发。Nest.js 的依赖项注入、装饰器、异常过滤器、管道、保护和拦截器同样适用于微服务。而且其微服务还支持自定义的传输器,可以方便的让我们实现一个基于 Electron IPC 的传输器,用于和渲染进程进行 Nest.js 风格的通信。
根据文档[2],这个传输器需要继承 @nestjs/microservices
的 Server
,实现 CustomTransportStrategy
:
export class ElectronIpcTransport extends Server implements CustomTransportStrategy {
listen(callback: () => void): any {
callback()
}
close(): any { }
}
listen
方法会在app.listen()
时调用,我们可以在这个方法里把各个请求的方法注册到 IPC 通道上。close
方法则在应用销毁时调用。
请求方法的绑定我们还是需要借助装饰器:
export function IpcInvoke(messageChannel: string) {
ipcMain.handle(messageChannel, (...args) => ipcMessageDispatcher.emit(messageChannel, ...args))
return applyDecorators(
MessagePattern(messageChannel),
)
}
上面这个方法是一个装饰器构造器,其中出现了一些莫名的东西:
首先是 ipcMessageDispatcher
这个是一个 EventEmitter
,主要是为了简化我们代码的结构。而 ipcMain.handle
,就是一个 Electron IPC 的常规操作,将事件和具体的处理方法进行绑定,当渲染进程触发这个事件时,主进程用对应的方法进行处理。ipcMessageDispatcher
的内部实现下文中将进行介绍,这里你只要知道,当渲染进程触发某个 IPC 的事件名时,都会被 ipcMessageDispatcher.emit
到某个黑箱中进行处理,我们会在黑箱中对这些事件进行分发,交由具体的方法进行处理。
接下来是返回具体的装饰器,applyDecorators
可以将多个装饰器组合在一起,形成一个新的装饰器,目前就一个装饰器 MessagePattern
。这个装饰器用于将事件名和装饰器具体装饰的方法进行绑定,并在某个地方进行注册,后续我们会在那个黑箱中用到它。
这个自定义装饰器 IpcInvoke
可以像 Nest.js 的那些 @Get(), @Post()
那样放在 Controller
的成员方法上,将事件名和方法注册到 IPC 通道上:
@Controller()
export class AppController {
constructor() { }
@IpcInvoke('msg')
public async handleSendMsg(msg: string): Promise<string> {
return `The main process received your message: ${msg}`
}
}
现在我们来说说这个 ipcMessageDispatcher
:
class IPCMessageDispatcher extends EventEmitter {
// @ts-expect-error
async emit(messageChannel: string, ...args: any[]): Promise<any> {
const [ipcHandler] = this.listeners('ipc-message')
if (ipcHandler)
return Reflect.apply(ipcHandler, this, [messageChannel, ...args])
}
}
它的 emit
方法内,获取了监听 "ipc-message" 事件名的方法 ipcHandler
,然后将 IPC 用到的事件名 messageChannel
和相关的参数用这个 ipcHandler
进行处理。(Reflect.apply
:通过指定的参数列表发起对目标函数的调用[3])。所以,当渲染进程触发 IPC 事件时,ipcMessageDispatcher
会将对应的事件名和相关的参数(如果有的话),统一交给监听了 "ipc-message" 事件的 ipcHandler
处理,这个 ipcHandler
就是我们上面说到的黑箱。下面,将揭开这个黑箱的真面目。
首先,我们在传输器的 listen
方法中加点东西:
listen(callback: () => void): any {
ipcMessageDispatcher.on('ipc-message', this.onMessage.bind(this));
callback();
}
ipcMessageDispatcher
监听了一个 “ipc-message” 事件,就是上面 emit
中获取 ipcHandler
这个监听者时所查询的事件名,ipcHandler
就是传输器的自定义方法 onMessage
,这个 onMessage
是一个总的调度器,用于对 IPC 的事件进行分发。下面是 onMessage
方法的实现:
export class ElectronIpcTransport extends Server implements CustomTransportStrategy {
async onMessage(messageChannel: string, ...args: any[]): Promise<any> {
const handler: MessageHandler | undefined = this.messageHandlers.get(messageChannel)
if (!handler)
return
const [ipcMainEventObject, ...payload] = args
const data = (!payload || payload.length === 0) ? undefined : payload.length === 1 ? payload[0] : payload
const result = await handler(data, {
evt: ipcMainEventObject,
})
return {
data: result,
}
}
}
this.messageHandlers
就是上面 MessagePattern
装饰器注册事件和处理方法的地方,我们通过 IPC 的事件名 messageChannel
获取到 对应的处理方法 handler
。有了 handler
后,就是调用它,然后返回结果,这个和常规的 IPC 调用一样。
这就是整个 Electron IPC 传输器的逻辑,在实现的过程中我参考了 nestjs-electron-ipc-transport 这个库,非常感谢作者 NimitzDEV 提供的方案。
# 创建实例和 window 模块
完成上面的微服务传输器和相关的装饰器以后,我们就可以继续来创建应用实例了:
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
strategy: new ElectronIpcTransport(),
},
)
await app.listen()
}
我们使用工厂函数创建一个微服务,包含app module(默认module),使用 Electron IPC 的传输器策略,然后通过 listen()
方法将各个事件方法注册到 IPC 通道。
到这里为止,我们只是启动了 Nest.js 的框架,以及相关通信方法的注册,而 Electron 的主窗口还没有启动。Electron 的主窗口可以选择在 bootstrap()
启动方法里进行创建,但是我这里习惯将其作为一个 module 来启动,在 src/main/global
目录下,新建一个 win.module.ts
文件:
// win.module.ts
import { join } from 'path'
import { Module } from '@nestjs/common'
import { BrowserWindow, app } from 'electron'
@Module({
providers: [{
provide: 'WEB_CONTENTS',
async useFactory() {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin')
app.quit()
})
await app.whenReady()
const win = new BrowserWindow({
width: 1000,
height: 800,
webPreferences: {
nodeIntegration: true,
webSecurity: false,
contextIsolation: false,
},
})
win.maximize()
const URL = !app.isPackaged
? `http://localhost:${process.env.PORT}`
: `file://${join(app.getAppPath(), 'dist/render/index.html')}`
win.loadURL(URL)
win.on('closed', () => {
win.destroy()
})
return win.webContents
}
}],
exports: ['WEB_CONTENTS']
})
export class WinModule { }
为什么将窗口作为一个 module ?上面的代码中可以发现,我在 providers
中使用工厂函数创建窗口,顺便将窗口的 webContents
提供了出来作为 “WEB_CONTENTS” 提供项,这样一来,我们不仅仅创建了窗口,还将这个窗口的 webContents
作为一个提供项可以在项目的其他 module 中进行注入,就可以在其他的 module 中使用 webContents
向该窗口的渲染进程发送消息了。
如此一来,我们不但通过 Electron IPC 传输器完成了类似于“请求/回复”的后端 API 结构,也完成了像 WebSocket 那样可以主动让主进程(后端)推送消息给渲染进程(前端)的方法。
最后,只要将这个 WinModule
导入到 AppModule
中,当 Nest.js 框架启动后,窗口也将进行创建,整个 Electron 应用就启动了:
import { Module } from '@nestjs/common'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { WinModule } from './global/win.module'
@Module({
imports: [WinModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule { }
# 总结
Electron 集成 Nest.js后,我们可以像写 Nest.js 后端那样来写其主进程,将主进程的功能结构整理的更加规范,也方便我们在后期的 C/S 到 B/S 过程中进行转换。
本文的模板仓库是: fast-vite-nestjs-electron