背景

在编写Alemon桌面端的时候,遇到这么两个需求:

  1. 运行alemon实例需要单独运行一个js(运行环境是独立的),并且要实现多个实例互不冲突

  2. 对各种插件(Github仓库的克隆)进行依赖环境的安装

上面两个需求都需要考虑到客户机没有安装NodeJS。

问题

我们又知道Electron是内嵌了一个NodeJS环境,只不过:

  1. 没有npm包管理

  2. 默认不能在外部通过直接调用可执行文件的方法调用NodeJS

突破点

也就是说,我们只需要解决上面两个问题,就能通过Electron自带的NodeJS功能在客户机没有安装NodeJS的情况下使用大部分的独立NodeJS功能。

我们知道,NodeJS使用子进程通常使用child_processfork 方法进行创建:

import { join, resolve } from 'node:path';
import { fork } from 'child_process';

const defaultDir = app.isPackaged ? resolve(installDir, './resources') : resolve(process.cwd(), './out'); // 判断是否为生产环境,开发环境与生产环境的执行目录不同
const child = fork(join(resolve(defaultDir), '/example/index.js'), [], {
  execArgv: [],
});

Electron也提供了子进程:utilityProcess,官网给的解释是这样的:

utilityProcess 使用 Node.js 和 Message 端口创建了一个子进程。 它提供一个相当于 Node.js 的 child_process.fork API,但使用 Chromium 的 Services API 代替来执行子进程。

也就是说,Electron提供的创建子进程方法与NodeJS自带的方法的唯一区别是:Electron使用的是Chromium的Services API。

我们的业务进程目前用不到Chromium的Services API,因此我们还是选择使用NodeJS的child_process

子进程执行JS

注意:以下仅以alemonjs为例子介绍,方法是通用的,只不过alemonjs比较典型。

AlemonJS就是通过NodeJS调用js文件创建机器人实例的。也就是说我们需要编写一个js文件来配置并运行机器人。我们通过Alemon官网给出的配置方法写了一个运行文件,并且考虑到不同实例的配置也不同,我们还需要主进程-子进程通信进行数据的传递。

子进程

在运行子进程时,我们通过进程通信进行配置的参数传递。

在实际业务开发中,我们应该等待主进程传递配置后再进行操作,我们可以通过execArgv进行参数传递,但是我不推荐这样进行配置参数的传递,我使用的是主进程-子进程通信,只有子进程接收到启动消息(启动消息携带了配置参数),才会创建一个机器人实例。

// 子进程js(alemon启动js文件)
process.on('message', async (message: IMessage) => {
  // 判断消息类型是否为“启动”机器人
  if ('type' in message && message?.type === 'start') {
    //如果是启动机器人,通常会携带启动参数(账号密码等),在这里面进行判断并且创建机器人实例
    //根据配置创建机器人实例
  }

  // 其他类型消息,建议拆分写
});

主进程

在主进程中,通过child_processfork方法创建一个子进程:

const child = fork(join(resolve(defaultDir), '/alemon/index.js'), [], {
  execArgv: [],
  cwd: resolve(defaultDir, './root/'),
  //....子进程其他配置
});

其中,fork方法的参数可以参考NodeJS文档:https://nodejs.org/api/child_process.html#child_processforkmodulepath-args-options

上面的代码执行了我们刚才编写的alemon启动js文件,并且将执行目录设置到了./root目录。

为什么要修改执行目录?

在实际的开发中,我们的alemon运行需要引入alemonjs依赖,也就是说要在一个包环境下(包含node_modules),而我们的./root目录就是我们所指定的包含包环境的文件夹,我们的插件也放在这个目录的子目录下,共用一个包环境,也方便后面依赖的安装和管理。

同时我们也可以指定运行参数,在这里就不加演示了,运行参数直接为空。

通过上面的子进程js可以看出,只有当父进程对子进程发送消息,并且typestart,才会启动机器人,在业务中我们也应该等待配置传入到子进程再创建实例。

因此,主进程要在子进程启动的时候就发送启动消息:

const sendData = {
  type: 'start',
  data: bot,
};
child.send(sendData);

这样,我们就通过子进程运行了一个js文件,如果我们运行多个js实例,只需要写一个公共的方法进行创建和管理。这里看个人喜好。

当然,光创建还是不够的,我们还要对子进程进行管理,包括消息监听和进程(异常)退出监听。

这里面就不再进行演示,可以参考一下官方文档。

child.on('message', (message: IBotMessage) => {
  switch (message.type) {
    case MessageType.console:
      // 如果是控制台打印
      break;
    case MessageType.lackOfPackage:
      // 如果是缺失依赖
      break;
    // 其他情况
  }
});
child.on('error', error => {
  // 错误处理
});
child.on('exit', () => {
  // 进程退出
});
child.on('close', () => {
  // 进程关闭
});
// 其他事件

在子进程alemonjs中,我对控制台console进行了拦截,并且判断打印信息和类型,通过父子进程通信传递给主进程,在主进程中接收子进程的消息,如上面的代码所示,我对父子进程通信的数据结构进行了统一,通过type判断消息类型,对之进行相应的处理。

当然,既然可以通过子进程运行一个我们写好的js,也可以让用户运行自己写的js,以替代NodeJS的作用,我们可以写一个方法接收Electron可执行文件的运行参数(其中就包括了js文件路径),然后进行处理,子进程运行。不过在业务中这样是不推荐的,容易造成bug漏洞,被有心人所利用。这样问题2也就解决了,需求1也实现了。

包管理

Electron内嵌的NodeJS是没有包管理的功能的,我们可以自己提取包管理的文件运行。原理同上面介绍的子进程运行js。

yarn为例:

首先到yarn的发布页下载最新版yarn.js:https://github.com/yarnpkg/yarn/releases

然后在主进程中进行调用:

import { fork } from 'child_process';
import { join } from 'node:path';
/**
 * yarn 安装依赖
 * @param dir 路径
 * @returns 子进程实例
 */
export const installDependencies = (dir: string) => {
  // 新建子进程,执行 yarn install
  const child = fork(join(__dirname, '../../yarn/yarn.js'),
  ['install'],  // 运行命令,此处相当于 yarn install
  {
    execArgv: [],
    // 运行目录
    cwd: dir,  // 设置yarn执行目录
  });
  // 返回子进程实例
  return child;
};

/**
 * yarn 安装 <包名>
 * @param dir 路径
 * @param packageName 包名
 * @returns 子进程实例
 */
export const installPackage = (packageName: string, dir: string) => {
  // 新建子进程,执行 yarn add <包名>
  const child = fork(
    join(__dirname, '../../yarn/yarn.js'),
    ['add', packageName], // 此处相当于 yarn add packageName(传入的参数)
    {
      execArgv: [],
      // 运行目录
      cwd: dir,
    }
  );
  // 返回子进程实例
  return child;
};

经过试验,这种方法是可行的,并且在没有安装NodeJS的客户机上也能够正常进行包管理。

上面封装成了两个函数,可以进行调用,也能够实现对插件依赖的安装和管理。