你不知道的 Electron (一):神奇的 remote 模块

4,284 阅读10分钟

转自IMWeb社区,作者:laynechen,原文链接

在上一篇 Electron 进程通信 中,介绍了 Electron 中的两种进程通信方式,分别为:

  1. 使用 ipcMainipcRenderer 两个模块
  2. 使用 remote 模块

相比于使用两个 IPC 模块,使用 remote 模块相对来说会比较自然一点。remote 模块帮我们屏蔽了内部的进程通信,使得我们在调用主进程的方法时完全没有感知到主进程的存在。

上一篇 Electron 进程通信 中,对 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 :

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Electron</title>
  <style>
    body {
      margin: 30px;
    }
    #container {
      font-weight: bold;
      font-size: 32px;
    }
  </style>
</head>
<body>
    <pre id="container"></pre> 
    <script src="./index.js"></script>
</body>
</html>

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}\n
After 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 也是这么实现的,不过分了几种情况:

  1. 简单数据类型 (int, boolean, double 等):无需序列化直接传递即可
  2. 对象:对象序列化来传递整个对象的副本
  3. 实现了 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,对主进程的对象进行了一层封装,使得我们在访问远程对象上的属性时,也需要向主进程发送同步进程消息来获取到当前主进程上该对象实际的值。

【参考资料】