This article has participated in the activity of “New person creation Ceremony”, and started the road of digging gold creation together.

Recently, I studied how to manage communication between modules in a large front-end project. This article records the process of studying the communication mechanism in VSCode, mainly including the IPC part.

Electron communication mechanism

We know Electron is based on Chromium + Node.js architecture. Also based on Chromium + Node.js, and nw.js, let’s take a look at the differences between them.

Electron with NW. Js

When it comes to desktop applications for Node.js, most people will know about Electron and nw.js. For example, VsCode is written based on Electron, while apet development tools are developed based on nw.js.

As we know, Node.js and Chromium run in different environments, and their JavaScript contexts have some unique global objects and functions. In Node.js you have Module, Process, require, etc. In Chromium you have Window, Documnet, etc.

So how do Electron and Nw.js manage Node.js and Chromium, respectively?

Nw.js internal architecture

Nw.js is the earliest Node.js desktop application framework, and its architecture is shown in Figure 1.

Using Node.js and Chromium together in nw.js does a few things, let’s take a look at each.

  1. Node.js and Chromium both use V8 to handle executing JavaScript, so in nw.js they use the same V8 instance.

  2. Node.js and Chromium both use event loop programming patterns, but they use different software libraries (Node.js uses Libuv, Chromium uses MessageLoop/ message-pump). Nw. js integrates Node.js and Chromium event loops by enabling Chromium to use a custom version of MessagePump built on top of Libuv (Figure 2).

  1. Integrate the Node.js context into Chromium to make Node.js available (see Figure 3).

So while nw.js is a combination of Node.js and Chromium, it’s closer to a front-end application development approach (Figure 4), and its entry point is index.html.

Electron internal structure

Electron emphasizes the separation of Chromium source and application, so it doesn’t integrate Node.js and Chromium together.

In Electron, it is divided into main process and renderer processes:

  • Main process: In a Electron application, there is one and only one main process (package.json’s main script)
  • Render process: Each page in Electron has its own process, called the render process. Since Electron uses Chromium to present Web pages, Chromium’s multi-process architecture is also used

Well, not being in one process certainly involves cross-process communication. Thus, in Electron, the main process can communicate with the renderer process in the following way:

  1. usingipcMainandipcRendererModules communicate IPC and are used to process application backends (ipcMain) and the front-end application window (ipcRenderer) is triggered by an event that communicates between processes.
  2. usingremoteThe module communicates in RPC mode.

Each object returned by the remote module, including functions, represents an object in the main process (called a remote object or remote function). When a method on a remote object is called, a remote function is called, or a new object is created using a remote constructor (function), you are actually sending a synchronous process message.

In Figure 5, any state sharing in Electron from the back end to the front end of the application (and vice versa) is done through the ipcMain and ipcRenderer modules. This way, the JavaScript contexts of the main and renderer processes remain independent, but data can be transferred explicitly between processes.

VSCode communication mechanism

VSCode is developed based on Electron, so let’s take a look at VSCode’s design.

VSCode multi-process architecture

VSCode adopts multi-process architecture. After VSCode is started, there are mainly the following processes:

  • The main process
  • Render process, multiple, including Activitybar, Sidebar, Panel, Editor, and so on
  • Plug-in host process
  • The Debug process
  • The Search process

The relationship between these processes is shown in Figure 6:

In VSCode, these processes also communicate through IPC and RPC, as shown in figure 7:

The IPC communication

As we can see, the main and render processes communicate based on Electron webContents. Send, ipcRender. Send, ipcmain.on.

Let’s take a look at the specific IPC communication mechanism design in VSCode, including: protocol, channel, connection, etc.

agreement

In IPC communication, protocol is the most basic. Just as we communicate with each other by convention (language, sign language), protocol in IPC can be regarded as convention.

As a communication capability, the most basic protocol range includes sending and receiving messages:

export interface IMessagePassingProtocol {
	send(buffer: VSBuffer): void;
	onMessage: Event<VSBuffer>;
}
Copy the code

As for the specific protocol content, it may include connection, disconnection, event, etc. :

export class Protocol implements IMessagePassingProtocol {
	constructor(private sender: Sender, readonly onMessage: Event<VSBuffer>){}// Send a message
	send(message: VSBuffer): void {
		try {
			this.sender.send('vscode:message', message.buffer);
		} catch (e) {
			// systems are going down}}// Disconnect the connection
	dispose(): void {
		this.sender.send('vscode:disconnect'.null); }}Copy the code

As we can see, IPC communication also uses Event/Emitter events in VSCode. For more information on events, please refer to VSCode source Code interpretation: Event System Design.

IPC is really the ability to send and receive information, and for communication to be accurate, the client and the server need to be on the same channel.

channel

As a channel, it has two functions, one is call on demand and the other is listen.

/** * IChannel is an abstraction from a collection of commands ** Call always returns a Promise */ with at most a single return value
exportinterface IChannel { call<T>(command: string, arg? : any, cancellationToken? : CancellationToken):Promise<T>; listen<T>(event: string, arg? : any): Event<T>; }Copy the code

Client and server

Generally speaking, the distinction between a client and a server is as follows: The client initiates a connection and the server is connected. In VSCode, the main process is the server, providing channels and services to subscribe to; The renderer process is the client that listens to the various channels/services provided by the server and can also send messages to the server (access, subscribe/listen, leave, etc.).

Both clients and servers need the ability to send and receive messages in order to communicate properly.

In VSCode, the client includes ChannelClient and IPCClient. The ChannelClient handles only the most basic channel-related functions, including:

  1. Get the channelgetChannel.
  2. Send channel requestsendRequest.
  3. Receive the request result and process itonResponse/onBuffer.
/ / the client
export class ChannelClient implements IChannelClient.IDisposable {
	getChannel<T extends IChannel>(channelName: string): T {
		const that = this;
		return {
			call(command: string, arg? : any, cancellationToken? : CancellationToken) {
				return that.requestPromise(channelName, command, arg, cancellationToken);
			},
			listen(event: string, arg: any) {
				returnthat.requestEvent(channelName, event, arg); }}as T;
	}
	private requestPromise(channelName: string, name: string, arg? : any, cancellationToken = CancellationToken.None):Promise<any> {}
	private requestEvent(channelName: string, name: string, arg? : any): Event<any> {} private sendRequest(request: IRawRequest):void {}
	private send(header: any, body: any = undefined) :void {}
	private sendBuffer(message: VSBuffer): void {}
	private onBuffer(message: VSBuffer): void {}
	private onResponse(response: IRawResponse): void {}
	private whenInitialized(): Promise<void> {}
	dispose(): void{}}Copy the code

Similarly, the server includes ChannelServer and IPCServer. ChannelServer also handles functions directly related to the channel, including:

  1. Registered channelregisterChannel.
  2. Listen for client messagesonRawMessage/onPromise/onEventListen.
  3. Process the client message and return the request resultsendResponse.
/ / the server
export class ChannelServer<TContext = string> implements IChannelServer<TContext>, IDisposable {
	registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
		this.channels.set(channelName, channel);
	}
	private sendResponse(response: IRawResponse): void {}
	private send(header: any, body: any = undefined) :void {}
	private sendBuffer(message: VSBuffer): void {}
	private onRawMessage(message: VSBuffer): void {}
	private onPromise(request: IRawPromiseRequest): void {}
	private onEventListen(request: IRawEventListenRequest): void {}
	private disposeActiveRequest(request: IRawRequest): void {}
	private collectPendingRequest(request: IRawPromiseRequest | IRawEventListenRequest): void {}
	public dispose(): void{}}Copy the code

We can see that ChannelClient and ChannelServer, as the directly connected object of channel, basically have one-to-one correspondence between sending and receiving, such as sendRequest and sendResponse. But ChannelClient can only send requests, and ChannelServer can only respond to requests. After reading the whole article, consider: What if we wanted two-way communication?

We also found that ChannelClient and ChannelServer serialize and deserialize the sending and receiving of messages:

// This is deserialize.
function deserialize(reader: IReader) :any {
	const type = reader.read(1).readUInt8(0);
	switch (type) {
		case DataType.Undefined: return undefined;
		case DataType.String: return reader.read(readSizeBuffer(reader)).toString();
		case DataType.Buffer: return reader.read(readSizeBuffer(reader)).buffer;
		case DataType.VSBuffer: return reader.read(readSizeBuffer(reader));
		case DataType.Array: {
			const length = readSizeBuffer(reader);
			const result: any[] = [];

			for (let i = 0; i < length; i++) {
				result.push(deserialize(reader));
			}

			return result;
		}
		case DataType.Object: return JSON.parse(reader.read(readSizeBuffer(reader)).toString()); }}Copy the code

So, what does IPCClient and IPCServer do?

The connection

You now have the ChannelClient part and the ChannelServer part that are directly related to the channel, but they need to be connected to each other to communicate. A Connection consists of the ChannelClient and the ChannelServer.

interface Connection<TContext> extends Client<TContext> {
	readonly channelServer: ChannelServer<TContext>; / / the server
	readonly channelClient: ChannelClient; / / the client
}
Copy the code

The connection is established by IPCServer and IPCClient. Among them:

  • IPCClientBased on theChannelClient, responsible for simple client-to-server one-to-one connections
  • IPCServerBased on thechannelServerIs responsible for the connection from the server to the client. Because a server can provide multiple services, there will be multiple connections
/ / the client
export class IPCClient<TContext = string> implements IChannelClient, IChannelServer<TContext>, IDisposable {
	private channelClient: ChannelClient;
	private channelServer: ChannelServer<TContext>;
	getChannel<T extends IChannel>(channelName: string): T {
		return this.channelClient.getChannel(channelName) as T;
	}
	registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
		this.channelServer.registerChannel(channelName, channel); }}// Because there are multiple services on the server, there may be multiple connections
export class IPCServer<TContext = string> implements IChannelServer<TContext>, IRoutingChannelClient<TContext>, IConnectionHub<TContext>, IDisposable {
	private channels = new Map<string, IServerChannel<TContext>>();
	private _connections = new Set<Connection<TContext>>();
	// Get the connection information
	get connections() :Connection<TContext> [] {}/** * Get channel from remote client. * Once through the router, you can specify which client it calls and listens to/from. * Otherwise, a random client will be selected when a call is made without a router, and each client will be listened on when a call is made without a router. * /
	getChannel<T extends IChannel>(channelName: string, router: IClientRouter<TContext>): T;
	getChannel<T extends IChannel>(channelName: string, clientFilter: (client: Client<TContext>) = > boolean): T;
	getChannel<T extends IChannel>(channelName: string, routerOrClientFilter: IClientRouter<TContext> | ((client: Client<TContext>) = > boolean)): T {}
	// Register channels
	registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
		this.channels.set(channelName, channel);
		// Add to the connection
		this._connections.forEach(connection= >{ connection.channelServer.registerChannel(channelName, channel); }); }}Copy the code

As mentioned earlier, the client can be understood as the renderer process and the server as the master process.

For details on how to set up the connection, see the article “VSCode – Communication Mechanism Design Interpretation (Electron)”. Here’s a picture from it:

reference

  • Vscode custom development – Workbench source code interpretation and practice
  • Vscode custom development – basic preparation
  • Explore the interior of NW.js and Electron
  • Vscode – Communication Mechanism Design Interpretation (Electron)
  • What you don’t know about Electron: The magical Remote module

conclusion

IPC and RPC communication is due to Electron cross-process communication. So, we can also think about, in the general front-end development scenario, is there any other scenario besides cross-process reference?

As for THE PART of RPC, since there is no strong business related at present, we will make an appointment next time when we are free.