3
头图

background

The team recently developed a new Node.js module that involves the management and communication of multiple processes. The simplified model can be understood as some methods that require frequent calls to the worker process from the master process. The simple design implements a event-invoke A library that can be called simply and elegantly.

Node.js provides the child_process module, which can create a worker process and obtain its object (referred to as cp) by calling methods such as fork / spawn in the master process. The parent and child processes will establish an IPC channel. In the master process, cp.send() can be used to send IPC messages to the worker process, and in the worker process, IPC messages can also be sent to the parent process through process.send() to achieve duplex communication. Purpose. (Process management involves more complex work, which is not covered in this article for the time being)

minimal implementation

Based on the above premise, with the help of IPC channel and process object, we can realize the communication between processes in an event-driven way, and only need a few lines of code to realize the basic calling logic, for example:

// master.js
const child_process = require('child_process');
const cp = child_process.fork('./worker.js');

function invoke() {
    cp.send({ name: 'methodA', args: [] });
  cp.on('message', (packet) => {
      console.log('result: %j', packet.payload);
  });
}

invoke();

// worker.js
const methodMap = {
  methodA() {}
}

cp.on('message', async (packet) => {
  const { name, args } = packet;
  const result = await methodMap[name)(...args);
  process.send({ name, payload: result });
});

Carefully analyze the above code implementation, intuitively feel that the invoke call is not elegant, and when the call volume is large, a lot of message listeners will be created, and to ensure that the request and response are in one-to-one correspondence, a lot of additional design is required. hopes to design a simple and ideal way, just provide the invoke method, pass in the method name and parameters, return a Promise, and make an IPC call like a local method, regardless of the details of message communication.

// 假想中的 IPC 调用
const res1 = await invoker.invoke('sleep', 1000);
console.log('sleep 1000ms:', res1);
const res2 = await invoker.invoke('max', [1, 2, 3]); // 3
console.log('max(1, 2, 3):', res2);

Process Design

From the calling model, the roles can be abstracted into Invoker and Callee, which correspond to the service caller and the provider respectively, and the details of message communication can be encapsulated inside. The communication bridge between parent_process and child_process is the IPC channel provided by the operating system. From the perspective of API, it can be simplified into two Event objects (the main process is cp, and the child process is process). The Event object serves as the two ends of the middle duplex channel, and is temporarily named InvokerChannel and CalleeChannel.

The key entities and processes are as follows:

825cb24fe4bb2cc7f9713606d8594a77.png

  • All methods that can be called are registered in Callee and saved in functionMap
  • When the user calls Invoker.invoke():

    • Create a promise object, return it to the user, and store it in a promiseMap
    • Each call generates an id to ensure that the call and the execution result are in a one-to-one correspondence
    • Perform timeout control, and the timeout task directly executes the rejection of the promise
  • Invoker sends the call method message to Callee through Channel
  • Callee parses the received message, executes the corresponding method by name, and sends the result and completion status (success or exception) to the Invoker through Channel
  • Invoker parses the message, finds the corresponding promise object through id+name, resolves if it succeeds, and rejects if it fails

In fact, this design is not only suitable for IPC calls, but can also be directly applied in browser scenarios. For example, cross-iframe calls can wrap window.postMessage(), cross-tab calls can use storage events, and Web Workers can use worker.postMessage() as a bridge for communication.

quick start

Based on the above design, the realization of coding is inevitable, and the development and documentation work can be completed quickly during non-working hours. Source code: https://github.com/x-cold/event-invoke

Install dependencies

npm i -S event-invoke

Parent-child process communication example

Example code: Example code
// parent.js
const cp = require('child_process');
const { Invoker } = require('event-invoke');

const invokerChannel = cp.fork('./child.js');

const invoker = new Invoker(invokerChannel);

async function main() {
  const res1 = await invoker.invoke('sleep', 1000);
  console.log('sleep 1000ms:', res1);
  const res2 = await invoker.invoke('max', [1, 2, 3]); // 3
  console.log('max(1, 2, 3):', res2);
  invoker.destroy();
}

main();
// child.js
const { Callee } = require('event-invoke');

const calleeChannel = process;

const callee = new Callee(calleeChannel);

// async method
callee.register(async function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
});

// sync method
callee.register(function max(...args) {
  return Math.max(...args);
});

callee.listen();

Custom Channel to implement PM2 inter-process calls

Example code: Example code
// pm2.config.cjs
module.exports = {
  apps: [
    {
      script: 'invoker.js',
      name: 'invoker',
      exec_mode: 'fork',
    },
    {
      script: 'callee.js',
      name: 'callee',
      exec_mode: 'fork',
    }
  ],
};
// callee.js
import net from 'net';
import pm2 from 'pm2';
import {
  Callee,
  BaseCalleeChannel
} from 'event-invoke';

const messageType = 'event-invoke';
const messageTopic = 'some topic';

class CalleeChannel extends BaseCalleeChannel {
  constructor() {
    super();
    this._onProcessMessage = this.onProcessMessage.bind(this);
    process.on('message', this._onProcessMessage);
  }

  onProcessMessage(packet) {
    if (packet.type !== messageType) {
      return;
    }
    this.emit('message', packet.data);
  }

  send(data) {
    pm2.list((err, processes) => {
      if (err) { throw err; }
      const list = processes.filter(p => p.name === 'invoker');
      const pmId = list[0].pm2_env.pm_id;
      pm2.sendDataToProcessId({
        id: pmId,
        type: messageType,
        topic: messageTopic,
        data,
      }, function (err, res) {
        if (err) { throw err; }
      });
    });
  }

  destory() {
    process.off('message', this._onProcessMessage);
  }
}

const channel = new CalleeChannel();
const callee = new Callee(channel);

// async method
callee.register(async function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
});

// sync method
callee.register(function max(...args) {
  return Math.max(...args);
});

callee.listen();

// keep your process alive
net.createServer().listen();
// invoker.js
import pm2 from 'pm2';
import {
  Invoker,
  BaseInvokerChannel
} from 'event-invoke';

const messageType = 'event-invoke';
const messageTopic = 'some topic';

class InvokerChannel extends BaseInvokerChannel {
  constructor() {
    super();
    this._onProcessMessage = this.onProcessMessage.bind(this);
    process.on('message', this._onProcessMessage);
  }

  onProcessMessage(packet) {
    if (packet.type !== messageType) {
      return;
    }
    this.emit('message', packet.data);
  }

  send(data) {
    pm2.list((err, processes) => {
      if (err) { throw err; }
      const list = processes.filter(p => p.name === 'callee');
      const pmId = list[0].pm2_env.pm_id;
      pm2.sendDataToProcessId({
        id: pmId,
        type: messageType,
        topic: messageTopic,
        data,
      }, function (err, res) {
        if (err) { throw err; }
      });
    });
  }

  connect() {
    this.connected = true;
  }

  disconnect() {
    this.connected = false;
  }

  destory() {
    process.off('message', this._onProcessMessage);
  }
}

const channel = new InvokerChannel();
channel.connect();

const invoker = new Invoker(channel);

setInterval(async () => {
  const res1 = await invoker.invoke('sleep', 1000);
  console.log('sleep 1000ms:', res1);
  const res2 = await invoker.invoke('max', [1, 2, 3]); // 3
  console.log('max(1, 2, 3):', res2);
}, 5 * 1000);

Next step

At present event-invoke has the basic ability to elegantly call "IPC" calls, with 100% code coverage, and provides a relatively complete type description . Interested students can use it directly. If you have any questions, you can directly ask Issue .

Other parts that still need to be improved in the future:

  • Richer examples covering cross-Iframe, cross-tab, web worker, etc. usage scenarios
  • Provides out-of-the-box generic Channel
  • more friendly exception handling

xcold
2.2k 声望1.6k 粉丝