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

騰訊IMWeb團隊發表於2018-09-18

轉自IMWeb社群,作者:laynechen,原文連結

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

在上一篇 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, '    ')}`;
});
複製程式碼

介面輸出結果如下:

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

嗯..沒什麼問題,和預期一樣。由於程式通訊中資料傳遞經過了序列化和反序列化,渲染程式拿到的程式中的物件已經不是同一個物件,指向的記憶體地址不同。

使用 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();
...
複製程式碼

介面輸出結果如下:

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

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

我們發現,通過 remote 模組拿到的 remoteObj 居然和我們拿渲染程式中的物件一樣,是一份引用。難道實際上並沒有主程式和渲染程式?又或者說 remote 模組使用了什麼黑魔法,使得我們在渲染程式可以引用到主程式的物件?

Java's RMI

官方文件在 remote 模組的介紹中提到了它的實現類似於 Java 中的 RMI。

那麼 RMI 是什麼? remote 的黑魔法是否藏在這裡面?

RMI (Remote Method Invoke)

遠端方法呼叫是一種計算機之間利用遠端物件互相呼叫實現雙方通訊的一種通訊機制。使用這種機制,某一臺計算機上的物件可以呼叫另外一臺計算機上的物件來獲取遠端資料。

如果使用 http 協議來實現遠端方法呼叫,我們可能會這麼實現:

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

雖然 RMI 底層並不是使用 http 協議,但大致的思路是差不多的。和 remote 一樣,程式通訊離不開 IPC 模組。

但是 IPC 通訊是可以做到對使用者來說是隱藏的。RMI 的目的也一樣,要實現客戶端像呼叫本地方法一樣呼叫遠端物件上的方法,底層的通訊不應該暴露給使用者。

RMI 實現原理

RMI 並不是通過 http 協議來實現通訊的,而是使用了 JRMP (Java Remote Method Protocol)。下面是通過 JRMP 實現服務端和客戶端通訊的流程:

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

與 http 類似,但是這裡多了個登錄檔。

這裡的登錄檔可以類比於我們的 DNS 伺服器。

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

服務端需要告訴 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 的大致流程

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

比較懵逼?沒關係,看程式碼實現:

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 模組獲取主程式上的物件背後發生了什麼:

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

如果說 remote 只是幫我們遮蔽了 IPC 操作,那麼渲染程式拿到的主程式中的物件,應該與主程式中的物件是沒有任何關係的,不應該受到主程式的修改而影響。那麼 remote 還幫我們做了什麼呢?

你不知道的 Electron (一):神奇的 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,對主程式的物件進行了一層封裝,使得我們在訪問遠端物件上的屬性時,也需要向主程式傳送同步程式訊息來獲取到當前主程式上該物件實際的值。

【參考資料】

相關文章