转自IMWeb社区,作者:laynechen,
在上一篇 中,介绍了 Electron 中的两种进程通信方式,分别为:
- 使用
ipcMain
和ipcRenderer
两个模块 - 使用 remote 模块
相比于使用两个 IPC 模块,使用 remote
模块相对来说会比较自然一点。remote
模块帮我们屏蔽了内部的进程通信,使得我们在调用主进程的方法时完全没有感知到主进程的存在。
上一篇 中,对 remote
的实现只是简单的说了下它底层依旧是通过 ipc 模块来实现通信:
通过 remote 对象,我们可以不必发送进程间消息来进行通信。但实际上,我们在调用远程对象的方法、函数或者通过远程构造函数创建一个新的对象,实际上都是在发送一个同步的进程间消息(官方文档 上说这类似于 JAVA 中的 RMI)。
也就是说,remote 方法只是不用让我们显式的写发送进程间的消息的方法而已。在上面通过 remote 模块创建 BrowserWindow 的例子里。我们在渲染进程中创建的 BrowserWindow 对象其实并不在我们的渲染进程中,它只是让主进程创建了一个 BrowserWindow 对象,并返回了这个相对应的远程对象给了渲染进程。
但是只是这样吗?
这篇文章会从 remote
模块的源码层面进行分析该模块的实现。
"假" 的多进程?
我们看一个例子,来了解直接使用 IPC 通信和使用 remote
模块的区别:
分别通过 IPC 模块和 remote
模块实现在渲染进程中获取主进程的一个对象,再在主进程中修改该对象的属性值,看下渲染进程中的对象对应的属性值是否会跟着改变。
逻辑比较简单,直接看代码。
使用 IPC 模块
主进程代码:
const remoteObj = { name: 'remote',};const getRemoteObject = (event) => { // 一秒后修改 remoteObj.name 的值 // 并通知渲染进程重新打印一遍 remoteObj 对象 setTimeout(() => { remoteObj.name = 'modified name'; win.webContents.send('modified'); }, 1000); event.returnValue = remoteObj;}复制代码
渲染进程代码:
index.html
:
Electron 复制代码
index.js
:
const { remote, ipcRenderer } = window.require('electron');const container = document.querySelector('#container');const remoteObj = ipcRenderer.sendSync('getRemoteObject');container.innerText = `Before modified\n${ JSON.stringify(remoteObj, null, ' ')}`;ipcRenderer.on('modified', () => { container.innerText = `${container.innerText}\nAfter modified\n${ JSON.stringify(remoteObj, null, ' ')}`;});复制代码
界面输出结果如下:
嗯..没什么问题,和预期一样。由于进程通信中数据传递经过了序列化和反序列化,渲染进程拿到的进程中的对象已经不是同一个对象,指向的内存地址不同。
使用 remote
模块
主进程代码:
const remoteObj = { name: 'remote',};const getRemoteObject = (event) => { // 一秒后修改 remoteObj.name 的值 // 并通知渲染进程重新打印一遍 remoteObj 对象 setTimeout(() => { remoteObj.name = 'modified name'; win.webContents.send('modified'); }, 1000); return remoteObj;}// 挂载方法到 app 模块上,供 remote 模块使用app.getRemoteObject = getRemoteObject;复制代码
渲染进程代码:
index.html
文件同上。
index.js
修改为通过 remote
模块获取 remoteObj :
...const remoteObj = remote.app.getRemoteObject();...复制代码
界面输出结果如下:
我们发现,通过 remote
模块拿到的 remoteObj
居然和我们拿渲染进程中的对象一样,是一份引用。难道实际上并没有主进程和渲染进程?又或者说 remote
模块使用了什么黑魔法,使得我们在渲染进程可以引用到主进程的对象?
Java's RMI
官方文档在 remote
模块的介绍中提到了它的实现类似于 Java 中的 RMI。
那么 RMI 是什么? remote
的黑魔法是否藏在这里面?
RMI (Remote Method Invoke)
远程方法调用是一种计算机之间利用远程对象互相调用实现双方通讯的一种通讯机制。使用这种机制,某一台计算机上的对象可以调用另外一台计算机上的对象来获取远程数据。
如果使用 http 协议来实现远程方法调用,我们可能会这么实现:
虽然 RMI 底层并不是使用 http 协议,但大致的思路是差不多的。和 remote
一样,进程通信离不开 IPC 模块。
但是 IPC 通信是可以做到对用户来说是隐藏的。RMI 的目的也一样,要实现客户端像调用本地方法一样调用远程对象上的方法,底层的通信不应该暴露给用户。
RMI 实现原理
RMI 并不是通过 http 协议来实现通信的,而是使用了 JRMP (Java Remote Method Protocol)
。下面是通过 JRMP 实现服务端和客户端通信的流程:
与 http 类似,但是这里多了个注册表。
这里的注册表可以类比于我们的 DNS 服务器。
服务端需要告诉 DNS 服务器,xxx 域名应该指向这台服务器的 ip,客户端就可以通过域名向 DNS 服务器查询服务器的 ip 地址来实现访问服务器。在 RMI 中,服务端向注册表注册,rmi://localhost:8000/hello
指向服务端中的某个对象 A,当客户端通过 rmi://localhost:8000/hello
查找服务端的对象时,就返回这个对象 A。
数据传递
注册表返回对象 A 是怎么传递给客户端的呢?首先想到的自然是序列化 & 反序列化。 RMI 也是这么实现的,不过分了几种情况:
- 简单数据类型 (int, boolean, double 等):无需序列化直接传递即可
- 对象:对象序列化来传递整个对象的副本
- 实现了
java.rmi.Remote
接口的对象(!!重点):远程引用
RMI 里面另一个比较重要的点就是这个远程对象。RMI 对这些实现了 Remote
接口的对象,进行了一些封装,为我们屏蔽了底层的通信,达到客户端调用这些远程对象上的方法时像调用本地方法一样的目的。
RMI 的大致流程
比较懵逼?没关系,看代码实现:
RMI 简单实现
(建议大家一起运行下这个例子~不动手实现怎么会有成就感!!)
客户端和服务端都有的远程对象接口文件 HelloRMI.java
:
import java.rmi.Remote;import java.rmi.RemoteException;public interface HelloRMI extends Remote { String sayHi(String name) throws RemoteException;}复制代码
服务端实现 HelloRMI
接口的 HelloImpl.java
:
import java.rmi.RemoteException;import java.rmi.server.ServerNotActiveException;import java.rmi.server.UnicastRemoteObject;public class HelloRMIImpl extends UnicastRemoteObject implements HelloRMI { protected HelloRMIImpl() throws RemoteException { super(); } @Override public String sayHi(String name) throws RemoteException { try { System.out.println("Server: Hi " + name + " " + getClientHost()); } catch (ServerNotActiveException e) { e.printStackTrace(); } return "Server"; }}复制代码
服务端测试程序 Server.java
:
import java.net.MalformedURLException;import java.rmi.AlreadyBoundException;import java.rmi.Naming;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;public class Server { public static void main(String[] args) { try { // 创建远程服务对象实例 HelloRMI hr = new HelloRMIImpl(); // 在注册表中注册 LocateRegistry.createRegistry(9999); // 绑定对象到注册表中 Naming.bind("rmi://localhost:9999/hello", hr); System.out.println("RMI Server bind success"); } catch (RemoteException e) { e.printStackTrace(); } catch (MalformedURLException e) { e.printStackTrace(); } catch (AlreadyBoundException e) { e.printStackTrace(); } }}复制代码
客户端测试程序 Client.java
:
import java.net.MalformedURLException;import java.rmi.Naming;import java.rmi.NotBoundException;import java.rmi.RemoteException;public class Client { public static void main(String[] args) { try { HelloRMI hr = (HelloRMI) Naming.lookup("rmi://localhost:9999/hello"); System.out.println("Client: Hi " + hr.sayHi("Client")); } catch (NotBoundException e) { e.printStackTrace(); } catch (MalformedURLException e) { e.printStackTrace(); } catch (RemoteException e) { e.printStackTrace(); } }}复制代码
先运行 Server.java
,开启注册表并向注册表绑定远程对象。然后运行客户端就可以查找和运行服务端上的远程对象了。
remote
中的 RMI
我们看下前面的例子,使用 remote
模块获取主进程上的对象背后发生了什么:
如果说 remote
只是帮我们屏蔽了 IPC 操作,那么渲染进程拿到的主进程中的对象,应该与主进程中的对象是没有任何关系的,不应该受到主进程的修改而影响。那么 remote
还帮我们做了什么呢?
其实重点不在于 remote
背后帮我们做了 IPC,而是在于数据的传递。前面的 RMI 中说到,数据传递分为简单数据类型、没有继承 Remote
的对象和继承了 Remote
的远程对象。继承了 Remote
的远程对象在数据传递的时候是通过远程引用传递而非简单的序列化和反序列化。在 remote
模块中,它相当于帮我们将所有的 Object
都给转换为了远程对象。
通过源码学习下 remote
是如何进行这种转换的:
lib/renderer/api/remote.js
:
...const addBuiltinProperty = (name) => { Object.defineProperty(exports, name, { get: () => exports.getBuiltin(name) })}const browserModules = require('../../common/api/module-list').concat( require('../../browser/api/module-list'))// And add a helper receiver for each one.browserModules .filter((m) => !m.private) .map((m) => m.name) .forEach(addBuiltinProperty)复制代码
这段代码做的事情是把主进程才可以使用的模块添加到了 remote
模块的属性在中。
...exports.getBuiltin = (module) => { const command = 'ELECTRON_BROWSER_GET_BUILTIN' const meta = ipcRenderer.sendSync(command, module) return metaToValue(meta)}...复制代码
getBuiltin
的处理方法就是发送一个同步的进程间消息,向主进程请求某个模块对象。最后会将返回值 meta
调用 metaToValue
后再返回。一切秘密都在 这个方法中了。
// Convert meta data from browser into real value.function metaToValue (meta) { const types = { value: () => meta.value, array: () => meta.members.map((member) => metaToValue(member)), buffer: () => bufferUtils.metaToBuffer(meta.value), promise: () => resolvePromise({ then: metaToValue(meta.then)}), error: () => metaToPlainObject(meta), date: () => new Date(meta.value), exception: () => { throw metaToException(meta) } } if (meta.type in types) { return types[meta.type]() } else { let ret if (remoteObjectCache.has(meta.id)) { return remoteObjectCache.get(meta.id) } // A shadow class to represent the remote function object. if (meta.type === 'function') { let remoteFunction = function (...args) { let command if (this && this.constructor === remoteFunction) { command = 'ELECTRON_BROWSER_CONSTRUCTOR' } else { command = 'ELECTRON_BROWSER_FUNCTION_CALL' } const obj = ipcRenderer.sendSync(command, meta.id, wrapArgs(args)) return metaToValue(obj) } ret = remoteFunction } else { ret = {} } setObjectMembers(ret, ret, meta.id, meta.members) setObjectPrototype(ret, ret, meta.id, meta.proto) Object.defineProperty(ret.constructor, 'name', { value: meta.name }) // Track delegate obj's lifetime & tell browser to clean up when object is GCed. v8Util.setRemoteObjectFreer(ret, meta.id) v8Util.setHiddenValue(ret, 'atomId', meta.id) remoteObjectCache.set(meta.id, ret) return ret }}复制代码
对不同类型进行了不同的处理。在对函数的处理中,将原本的函数外封装了一个函数用于发送同步的进程间消息,并将返回值同样调用 metaToValue
进行转换后返回。
另外,对 Object
类型对象,还需要对他们的属性进行类似函数一样的封装处理:
function metaToValue (meta) { ... setObjectMembers(ret, ret, meta.id, meta.members) setObjectPrototype(ret, ret, meta.id, meta.proto) ...}复制代码
对返回对象属性重写 get、set 方法。对调用远程对象上的属性,同样是通过发送同步的进程间消息来获取,这也就是为什么主进程修改了值,渲染进程就也能感知到的原因了。
还有一个需要注意的地方是,为了不重复获取远程对象,对返回的对象 remote
是会进行缓存的,看 metaToValue
的倒数第二行:remoteObjectCache.set(meta.id, ret)
读者思考
到这里我们知道了文章开头遇到的神奇现象的原因。这里抛出个问题给读者:思考下如果是主进程的函数是异步的(函数返回一个 Promise 对象),Promise 对象是如何实现数据传递的?是否会阻塞渲染进程?
总结
通过上述分析我们知道,remote
模块不仅帮我们实现了 IPC 通信,同时为了达到类似引用传递的效果,使用了类似 Java 中的 RMI,对主进程的对象进行了一层封装,使得我们在访问远程对象上的属性时,也需要向主进程发送同步进程消息来获取到当前主进程上该对象实际的值。