background

Recently, the team has a new Node.js module to be developed, which involves the management and communication of multiple processes. The simplified model can be understood as the need to call some methods of worker process frequently from the master process. The simple design implements an Event-Invoke library, which can be called simply and elegantly.

Node.js provides child_process module, which can create worker process and obtain its object (cp for short) by invoking methods such as fork/spawn in master process. Parent processes will establish IPC channels. The master process can use cp.send() to send IPC messages to worker processes, and the worker process can also use process.send() to send IPC messages to parent processes. Achieve the purpose of duplex communication. (Process management involves more complex work, which is not covered in this article.)

Minimum implementation

Based on the above premise, with the help of IPC channels and process objects, we can implement interprocess communication in an event-driven manner with a few simple lines of code to implement basic call logic, such as:

// 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 });
});
Copy the code

A close look at the above code implementation shows that the Invoke calls are not elegant, and when the call volume is high, a lot of Message listeners are created, and a lot of extra design is required to ensure a one-to-one correspondence between requests and responses. You want to design a simple and ideal way to simply provide an Invoke method, pass in the method name and parameters, return a Promise, and make an IPC call as if it were a local method call, without worrying about the details of message communication.

// Invoke 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);Copy the code

Process design

In terms of the invocation model, the roles can be abstracted into Invoker and Callee, corresponding to the service caller and provider respectively, and the details of message communication can be encapsulated internally. 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 objects serve as the two ends of the intermediate duplex channel, tentatively named InvokerChannel and CalleeChannel.

Key entities and processes are as follows:

  • All methods that can be called are registered in Callee and saved in functionMap

  • When the user invokes invoker.invoke () :

  • Create a Promise object, return it to the user, and store it in the promiseMap

  • Each call generates an ID, ensuring a one-to-one correspondence between the call and execution results

  • The timeout control is performed, and the timeout task is executed reject the promise

  • Invoker sends the call method message to Callee via a Channel

  • Callee parses the received message, executes the corresponding method through Name, and sends the result and completion status (success or exception) to Invoker via Channel

  • Invoker parses the message and finds the corresponding promise object by ID +name. Resolve if it succeeds, reject if it fails

In fact, this design applies not only to IPC calls, but also directly to browser scenarios, such as cross-iframe calls wrapping window.postmessage (), cross-tab calls using storage events, And worker.postMessage() can be used as a communication bridge in Web workers.

Quick start

Based on the above design, the implementation of coding must be no problem, while not working time to quickly complete the development and documentation of the work, the source code: github.com/x-cold/even…

Install dependencies

npm i -S event-invoke
Copy the code

Instance of parent-child process communication

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(); ``` ```js // 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();Copy the code

A custom Channel implements PM2 interprocess invocation

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);Copy the code

The next step

Currently, Event-Invoke has the basic ability to gracefully invoke “IPC” calls, with 100% code coverage and relatively complete type descriptions. If you are interested, you can use it directly. If you have any questions, you can directly Issue it.

Some other things that still need to be improved:

  • Richer examples covering cross-iframe, cross-tab, Web worker, and other usage scenarios

  • Provide generic channels out of the box

  • Friendlier exception handling