Демистификация удаленного модуля Electron

JavaScript
Демистификация удаленного модуля Electron

Удаленный модуль Электрона — довольно волшебная штука, ибо渲染进程а также主进程Коммуникация инкапсулирует простой способ, через удаленный вы можете «напрямую» получить объект основного процесса или вызвать функцию или метод основного процесса объекта, без необходимости явно отправлять межпроцессные сообщения, подобные RMI Java, например:

const { remote } = require('electron')
const myModal = remote.require('myModal') // 让主进程require指定模块,并返回到渲染进程
myModal.dosomething()                     // 调用方法

По сути, удаленный модуль основан на механизме IPC Electron, и данные, передаваемые между процессами, должны быть сериализуемыми, например сериализация JSON.. Итак, цель этой статьи — представить, как Electron спроектировал удаленный модуль и какие в нем есть ямки.



Схема статьи


Определение протокола связи

Как упоминалось выше, дистанционное управление по существу основано на сериализованной связи IPC, поэтому, прежде всего, ключ должен бытьОпределите протокол для описания внешнего вида модуля/объекта., который содержит следующие типы:

  • Исходное значение. например, строки, числа, логические значения
  • множество.
  • объект. Свойства объекта, методы объекта и прототипы объектов
  • функция. Обычные функции и конструкторы, обработка исключений
  • специальный объект. Дата, буфер, обещание, объект исключения и т. д.

Electron использует MetaData (метаданные) для описания протокола формы этих объектов.Вот несколько примеров преобразований:

  • базовый объект: С базовыми объектами легко обращаться, просто копируйте и передавайте напрямую по значению.

    • войти

      1;
      new Date();
      Buffer.from('hello world');
      new Error('message');
      
    • выход

      {type: "value", value: 1};
      {type: "date", value: 1565002306662};  // 序列化为时间戳
      {type: "buffer", value: {data: Uint8Array(11), length: 11, type: "Buffer"}}; // 序列化为数组
      {
        members: [
          {
            name: "stack",
            value: "Error: message\n    at Object.<anonymous> (省略调用栈)"
          },
          { name: "message", value: "message" },
          { name: "name", value: "Error" }
        ],
        type: "error"
      }
      

  • множество: Массивы также копируются по значению.

    • войти

      [1, 2, 3];
      
    • выход

      Массив будет рекурсивно преобразовывать элементы.Обратите внимание, что массив ничем не отличается от базового типа, это также копия значения, что означает, что изменение массива не повлияет на значение массива однорангового процесса.

      {
        "members": [
          {"type":"value","value":1},
          {"type":"value","value":2},
          {"type":"value","value":3}
        ],
        "type":"array"
      }
      

  • чистый объект:

    • войти

      {
        a: 1,
        b: () => {
          this.a;
        },
        c: {
          d: 'd'
        }
      }
      
    • выход

      {
        // 这里有一个id,用于标识主进程的一个对象
        id: 1,
        // 对象成员
        members: [
          { enumerable: true, name: "a", type: "get", writable: true },
          { enumerable: true, name: "b", type: "method", writable: false },
          // electron只会转换一层,不会递归转换内嵌对象
          { enumerable: true, name: "c", type: "get", writable: true },
        ],
        name: "Object",
        // 对象的上级原型的MetaData
        proto: null,
        type: "object"
      }
      

  • функция:

    • войти

      function foo() {
        return 'hello world';
      };
      
    • выход

      {
        // 函数也有一个唯一id标识,因为它也是对象,主进程需要保持该对象的引用
        id: 2,
        // 函数属性成员
        members: [],
        name: "Function",
        type: "function"
        // Electron解析对象的原型链
        proto: {
          members: [
            // 构造函数
            {
              enumerable: false,
              name: "constructor",
              type: "method",
              writable: false
            },
            { enumerable: false, name: "apply", type: "method", writable: false },
            { enumerable: false, name: "bind", type: "method", writable: false },
            { enumerable: false, name: "call", type: "method", writable: false },
            { enumerable: false, name: "toString", type: "method", writable: false }
          ],
          proto: null
        },
      }
      

  • Promise: Обещаю просто описать тогда функцию

    • войти:

      Promise.resolve();
      
    • войти:

      // Promise这里关键在于then,详见上面的函数元数据
      {
        type: "promise"
        then: {
          id: 2,
          members: [],
          name: "Function",
          proto: {/*见上面*/},
          type: "function"
        },
      };
      

После понимания протокола удаленной передачи данных, опытные разработчики должны иметь практический результат, его принцип, вероятно, таков:

Объекты должны быть сериализованы в описания метаданных между основным процессом и процессом рендеринга, и правила преобразования были более четко объяснены выше. Объекты и функции, которые здесь требуют специальной обработки, должны быть инкапсулированы в теневой объект/функцию после того, как процесс рендеринга получит метаданные, которые могут быть вызваны приложением процесса рендеринга.

Среди них более сложная обработка объектов и функций.Чтобы предотвратить сборку мусора, Electron должен поместить эти объекты в реестр.В этой таблице каждый объект идентифицируется уникальным идентификатором. Этот идентификатор чем-то похож на «указатель», и процесс рендеринга будет использовать этот идентификатор для запроса доступа к объекту у основного процесса.

Когда вам нужно освободить эти объекты? Конкретные детали реализации описаны ниже.

Еще одна деталь, не показанная на приведенном выше рисунке, заключается в том, что Electron не рекурсивно конвертирует объекты, а это означает, что он конвертирует только один слой. Это может безопасно ссылаться на объекты с циклическими ссылками, а все значения свойств должны получать самое последнее значение с удаленного, и нельзя предполагать, что его структура неизменяема.



сериализация объектов

Давайте сначала посмотрим на реализацию основного процесса, его код находится в/lib/browser/rpc-server.js, код небольшой и понятный, читатель может прочитать его сам.

Здесь мы не будем обращать внимание на детали сериализации объекта, сосредоточимся на жизненном цикле объекта и процессе вызова.


кremote.requireНапример, этот метод используется, чтобы попросить основной процесс потребовать указанный модуль, а затем вернуть содержимое модуля в процесс рендеринга:

handleRemoteCommand('ELECTRON_BROWSER_REQUIRE', function (event, contextId, moduleName) {
  // 调用require
  const returnValue = process.mainModule.require(moduleName)

  // 将returnValue序列化为MetaData
  return valueToMeta(event.sender, contextId, returnValue)
})

handleRemoteCommandиспользоватьipcMainСлушайте запрос, отправленный рендерером,contextIdИспользуется для идентификации процесса рендеринга.


valueToMetaметод сериализации значения в метаданные:

const valueToMeta = function (sender, contextId, value, optimizeSimpleObject = false) {
  // Determine the type of value.
  const meta = { type: typeof value }
  if (meta.type === 'object') {
    // Recognize certain types of objects.
    if (value === null) {
      meta.type = 'value'
    } else if (bufferUtils.isBuffer(value)) {
      // ... 🔴 基本类型
    }
  }

  if (meta.type === 'array') {
    // 🔴 数组转换
    meta.members = value.map((el) => valueToMeta(sender, contextId, el, optimizeSimpleObject))
  } else if (meta.type === 'object' || meta.type === 'function') {
    meta.name = value.constructor ? value.constructor.name : ''
    // 🔴 将对象保存到注册表中,并返回唯一的对象id.
    // Electron会假设渲染进程会一直引用这个对象, 直到渲染进程退出
    meta.id = objectsRegistry.add(sender, contextId, value)
    meta.members = getObjectMembers(value)
    meta.proto = getObjectPrototype(value)
  } else if (meta.type === 'buffer') {
    meta.value = bufferUtils.bufferToMeta(value)
  } else if (meta.type === 'promise') {
    // 🔴promise
    value.then(function () {}, function () {})
    meta.then = valueToMeta(sender, contextId, function (onFulfilled, onRejected) {
      value.then(onFulfilled, onRejected)
    })
  } else if (meta.type === 'error') {
    // 🔴错误对象
    meta.members = plainObjectToMeta(value)
    meta.members.push({
      name: 'name',
      value: value.name
    })
  } else if (meta.type === 'date') {
    // 🔴日期
    meta.value = value.getTime()
  } else {
    // 其他
    meta.type = 'value'
    meta.value = value
  }
  return meta
}


теневой объект

Объекты или функции, которые процесс рендеринга десериализует из метаданных, но это всего лишь «тень», мы также можем их назватьтеневой объектилипрокси-объект,заменять, Подобно теневому аватару в Наруто, основное тело хранится в основном процессе, а теневой объект не содержит никаких данных сущности.При доступе к этим объектам или вызове функций/методов теневые объекты запрашиваются напрямую удаленно.

Код процесса рендеринга можно увидетьздесь

Давайте посмотрим, как процесс рендеринга создает «теневой объект»:

Обработка функций:

  if (meta.type === 'function') {
    // 🔴创建一个'影子'函数
    const remoteFunction = function (...args) {
      let command
      // 通过new Obj形式调用
      if (this && this.constructor === remoteFunction) {
        command = 'ELECTRON_BROWSER_CONSTRUCTOR'
      } else {
        command = 'ELECTRON_BROWSER_FUNCTION_CALL'
      }
      // 🔴同步IPC远程
      // wrapArgs将函数参数序列化为MetaData
      const obj = ipcRendererInternal.sendSync(command, contextId, meta.id, wrapArgs(args))
      // 🔴反序列化返回值
      return metaToValue(obj)
    }
    ret = remoteFunction


Обработка членов объекта:

function setObjectMembers (ref, object, metaId, members) {
  for (const member of members) {
    if (object.hasOwnProperty(member.name)) continue

    const descriptor = { enumerable: member.enumerable }
    if (member.type === 'method') {
      // 🔴创建‘影子’方法. 和上面的函数调用差不多
      const remoteMemberFunction = function (...args) {
        let command
        if (this && this.constructor === remoteMemberFunction) {
          command = 'ELECTRON_BROWSER_MEMBER_CONSTRUCTOR'
        } else {
          command = 'ELECTRON_BROWSER_MEMBER_CALL'
        }
        const ret = ipcRendererInternal.sendSync(command, contextId, metaId, member.name, wrapArgs(args))
        return metaToValue(ret)
      }
      // ...

    } else if (member.type === 'get') {
      // 🔴属性的获取
      descriptor.get = () => {
        const command = 'ELECTRON_BROWSER_MEMBER_GET'
        const meta = ipcRendererInternal.sendSync(command, contextId, metaId, member.name)
        return metaToValue(meta)
      }

      // 🔴属性的设置
      if (member.writable) {
        descriptor.set = (value) => {
          const args = wrapArgs([value])
          const command = 'ELECTRON_BROWSER_MEMBER_SET'
          const meta = ipcRendererInternal.sendSync(command, contextId, metaId, member.name, args)
          if (meta != null) metaToValue(meta)
          return value
        }
      }
    }

    Object.defineProperty(object, member.name, descriptor)
  }
}


жизненный цикл объекта

основной процессvalueToMetaпоместит каждый объект и функцию в реестр, включая возвращаемое значение каждого вызова функции.

Означает ли это, что если функция вызывается часто, это приведет к резкому увеличению размера реестра и займет слишком много памяти? Когда эти объекты будут выпущены?


первыйКогда процесс рендеринга уничтожается, основной процесс централизованно уничтожает все ссылки на объекты процесса.:

// 渲染进程退出时会通过这个事件告诉主进程,但是这个并不能保证收到
handleRemoteCommand('ELECTRON_BROWSER_CONTEXT_RELEASE', (event, contextId) => {
  // 清空对象注册表
  objectsRegistry.clear(event.sender, contextId)
  return null
})

потому чтоELECTRON_BROWSER_CONTEXT_RELEASEНет гарантии, что он будет получен, поэтомуobjectsRegistryОн также прослушивает событие уничтожения соответствующего процесса рендеринга:

class ObjectsRegistry {
    registerDeleteListener (webContents, contextId) {
    // contextId => ${processHostId}-${contextCount}
    const processHostId = contextId.split('-')[0]
    const listener = (event, deletedProcessHostId) => {
      if (deletedProcessHostId &&
          deletedProcessHostId.toString() === processHostId) {
        webContents.removeListener('render-view-deleted', listener)
        this.clear(webContents, contextId)
      }
    }
    //🔴 监听渲染进程销毁事件, 确保万无一失
    webContents.on('render-view-deleted', listener)
  }
}

ЖдатьЯвно недопустимо освобождать эти объекты после уничтожения процесса рендеринга., В отличие от веб-страниц, настольные приложения могут работать без перерыва 7 часов в сутки 24. Если вы дождетесь завершения процесса рендеринга для повторного использования объектов, системные ресурсы в конечном итоге будут исчерпаны.

такElectron будет отслеживать событие сборки мусора объекта в процессе рендеринга, а затем уведомлять основной процесс через IPC об уменьшении счетчика ссылок соответствующего объекта., чтобы увидеть, как происходит процесс рендеринга:

/**
 * 渲染进程,反序列化
 */
function metaToValue (meta) {
  // ...
  } else {
    // 对象类型转换
    let ret
    if (remoteObjectCache.has(meta.id)) {
      // 🔴 对象再一次被访问,递增对象引用计数. 
      // v8Util是electron原生模块
      v8Util.addRemoteObjectRef(contextId, meta.id)
      return remoteObjectCache.get(meta.id)
    }

    // 创建一个影子类表示远程函数对象
    if (meta.type === 'function') {
      const remoteFunction = function (...args) {
        // ...
      }
      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 })

    // 🔴 监听对象的生命周期,当对象被垃圾回收时,通知到主进程
    v8Util.setRemoteObjectFreer(ret, contextId, meta.id)
    v8Util.setHiddenValue(ret, 'atomId', meta.id)
    // 🔴 添加对象引用计数
    v8Util.addRemoteObjectRef(contextId, meta.id)
    remoteObjectCache.set(meta.id, ret)
    return ret
  }
}


Краткий взгляд на код ObjectFreer:

// atom/common/api/remote_object_freer.cc
// 添加引用计数
void RemoteObjectFreer::AddRef(const std::string& context_id, int object_id) {
  ref_mapper_[context_id][object_id]++;
}

// 对象释放事件处理器
void RemoteObjectFreer::RunDestructor() {
  // ...
  auto* channel = "ELECTRON_BROWSER_DEREFERENCE";
  base::ListValue args;
  args.AppendString(context_id_);
  args.AppendInteger(object_id_);
  args.AppendInteger(ref_mapper_[context_id_][object_id_]);

  // 🔴 清空引用表
  ref_mapper_[context_id_].erase(object_id_);
  if (ref_mapper_[context_id_].empty())
    ref_mapper_.erase(context_id_);

  // 🔴 ipc通知主进程
  electron_ptr->Message(true, channel, args.Clone());
}

Назад к основному процессу, основной процесс слушаетELECTRON_BROWSER_DEREFERENCEсобытие и уменьшает счетчик ссылок указанного объекта:

handleRemoteCommand('ELECTRON_BROWSER_DEREFERENCE', function (event, contextId, id, rendererSideRefCount) {
  objectsRegistry.remove(event.sender, contextId, id, rendererSideRefCount)
})

Если вас смущают преимущества приведенного выше кода, то взгляните на блок-схему ниже и переварите ее:



Как процесс рендеринга передает обратные вызовы основному процессу

В процессе рендеринга также можно передавать callback-функции основного процесса через remote. Фактически, по тому же принципу, что и основной процесс, предоставляющий функции/объекты процессу рендеринга, процесс рендеринга поместит обратный вызов в основной процесс перед передачей обратного вызова в основной процесс.реестр обратного вызова, а затем предоставить идентификатор обратного вызова основному процессу.

Процесс рендеринга вызоветwrapArgsСериализировать параметры вызова функции в метаданные:

function wrapArgs (args, visited = new Set()) {
  const valueToMeta = (value) => {
    // 🔴 防止循环引用
    if (visited.has(value)) {
      return {
        type: 'value',
        value: null
      }
    }

    // ... 省略其他类型的处理,这些类型基本都是值拷贝
    } else if (typeof value === 'function') {
      return {
        type: 'function',
        // 🔴 给主进程传递callbackId,并添加到回调注册表中
        id: callbacksRegistry.add(value),
        location: v8Util.getHiddenValue(value, 'location'),
        length: value.length
      }
    } else {
      // ...
    }
  }
}

Вернемся к основному процессу, также есть соответствующийunwrapArgsfunction для десериализации аргументов функции:

const unwrapArgs = function (sender, frameId, contextId, args) {
  const metaToValue = function (meta) {
    switch (meta.type) {
      case 'value':
        return meta.value
      // ... 省略
      case 'function': {
        const objectId = [contextId, meta.id]
        // 回调缓存
        if (rendererFunctions.has(objectId)) {
          return rendererFunctions.get(objectId)
        }

        // 🔴 封装影子函数
        const callIntoRenderer = function (...args) {
          let succeed = false
          if (!sender.isDestroyed()) {
            // 🔴 调用时,通过IPC通知渲染进程
            // 忽略回调返回值
            succeed = sender._sendToFrameInternal(frameId, 'ELECTRON_RENDERER_CALLBACK', contextId, meta.id, valueToMeta(sender, contextId, args))
          }

          if (!succeed) {
            // 没有发送成功则表明渲染进程的回调可能被释放了,输出警告信息
            // 这种情况比较常见,比如被渲染进程刷新了
            removeRemoteListenersAndLogWarning(this, callIntoRenderer)
          }
        }

        v8Util.setHiddenValue(callIntoRenderer, 'location', meta.location)
        Object.defineProperty(callIntoRenderer, 'length', { value: meta.length })

        // 🔴 监听回调函数垃圾回收事件
        v8Util.setRemoteCallbackFreer(callIntoRenderer, contextId, meta.id, sender)
        rendererFunctions.set(objectId, callIntoRenderer)
        return callIntoRenderer
      }
      default:
        throw new TypeError(`Unknown type: ${meta.type}`)
    }
  }

  return args.map(metaToValue)
}

Ответ процесса рендеринга проще:

handleMessage('ELECTRON_RENDERER_CALLBACK', (id, args) => {
  callbacksRegistry.apply(id, metaToValue(args))
})

Когда будет выпущен обратный вызов? Это намного проще, чем ссылка на объект процесса рендеринга, потому что основной процесс имеет только одну. Из приведенного выше кода вы можете узнать,setRemoteCallbackFreerБудет отслеживать, является ли теневой обратный вызов сборщиком мусора, и уведомлять процесс рендеринга после сбора мусора:

// 渲染进程
handleMessage('ELECTRON_RENDERER_RELEASE_CALLBACK', (id) => {
  callbacksRegistry.remove(id)
})

Как обычно, вот блок-схема:



некоторые недостатки

Удаленный механизм является всего лишь «теневым клоном» удаленного объекта и не может на 100 % соответствовать поведению удаленного объекта. Ниже приведены некоторые из наиболее распространенных дефектов:

  • Когда процесс рендеринга вызывает метод/функцию удаленного объекта для синхронизации связи IPC. Другими словами, синхронный вызов IPC блокирует выполнение пользовательского кода, и эффективность связи нельзя сравнивать в конце вызовов собственных функций, поэтому частые вызовы IPC будут влиять на производительность основного процесса и процесса рендеринга.
  • Основной процесс будет хранить ссылки на объекты, к которым обращается каждый процесс рендеринга, включая возвращаемые значения функций. Точно так же частые запросы к удаленным объектам вызывают большую нагрузку на использование памяти и сборку мусора.
  • Невозможно полностью эмулировать поведение объектов JavaScript. Например, в удаленном модуле есть такие проблемы:
    • Массивы — это «примитивные объекты», которые передаются одноранговому узлу путем копирования по значению. Другими словами, это не «эталонный объект», и когда партнер изменяет их, он не может отражать исходный массив.
    • При первом обращении к объекту удаленный доступ возможен только к перечисляемым свойствам. Это также означает, что форма объекта определяется в начале.Если удаленный объект динамически расширяет свои атрибуты, удаленный доступ к нему невозможен.
    • Обратный вызов, переданный процессом рендерера, будет вызываться асинхронно, а его возвращаемое значение будет игнорироваться основным процессом. Асинхронный вызов, чтобы избежать взаимоблокировки
  • Протечки объекта.
    • Если происходит утечка удаленного объекта в процессе рендеринга (например, он хранится на карте, но никогда не освобождается), соответствующий объект в основном процессе также будет утечкой, поэтому вы должны быть очень осторожны, чтобы не допустить утечки удаленных объектов.
    • Будьте осторожны при передаче обратного вызова основному процессу, и основной процесс сохраняет ссылку обратного вызова до тех пор, пока она не будет освобождена. Поэтому, когда вы используете удаленный модуль для выполнения какой-либо «подписки на события», не забудьте освободить подписки на события.
    • Есть еще один сценарий, о котором будет сказано ниже.


Практика и оптимизация удаленного модуля

Выше приведена диаграмма архитектуры программного обеспечения проекта, в котором я участвовал,HybridСлой написан на C/C++, который инкапсулирует основную бизнес-логику кроссплатформенности и строит представления каждой платформы поверх этого. Среди них мы используем технологию Electron на стороне рабочего стола.

Как показано выше, мост — это слой инкапсуляции моста узла для гибридного типа. В приложении может быть только один экземпляр Bridge, поэтому наш подход заключается в использовании удаленного модуля Electron, чтобы разрешить процессу рендеринга косвенный доступ к Bridge через основной процесс.


Страницы моста должны слушать некоторые события, первый из нашего кода, как это:

// bridge.ts
// 使用remote的一个好处时,可以配合Typescript实现较好的类型检查
const bridge = electron.remote.require('bridge') as typeof import('bridge')

export default bridge

Отслеживание событий моста:

import bridge from '~/bridge'

class Store extends MobxStore {
  // 初始化
  pageReady() {
    this.someEventDispose = bridge.addListener('someEvent', this.handleSomeEvent)
  }

  // 页面关闭
  pageWillClose() {
    this.someEventDispose()
  }
  // ...
}

Блок-схема выглядит следующим образом:

С этим подходом связано много проблем:

  • Основной процесс должен поддерживать ссылку для каждого обратного вызова addListener. Приведенный выше код освобождает подписку при закрытии страницы, но не учитывает сценарии, когда пользователь обновляет страницу или происходит сбой страницы. Это приведет к утечке обратного вызова в основной процесс.

    Однако, даже если Electron может обнаружить, что callback был освобожден в процессе рендеринга при вызове callback, но разработчик не может получить эту информацию, Bridge всегда будет хранить ссылку на shadow callback.

  • Еще одна очевидная проблема — эффективность вызовов. Предполагая, что страница прослушивает событие A N раз, когда событие A запускается, основной процесс должен отправить N уведомлений на страницу.


Позже мы отказались от метода использования удаленного для подписки на событие и позволить основным процессам поддерживать эту подписку, как показано на следующем рисунке:

Мы многое улучшили:

Основной процесс теперь поддерживает только то, на какое событие «какая страница» подписывается, переходя от «связанного обратного вызова» к «связанной странице».. Это может решить вышеупомянутые проблемы эффективности вызовов и утечки обратного вызова.Например, утечка обратного вызова не будет вызвана обновлением страницы, и страница будет уведомлена только один раз при срабатывании события.

Кроме того, здесь упоминается сама реализация remote, и все подписки на страницу удаляются при уничтожении страницы. По сравнению с удаленным черным ящиком нам лучше реализовать это отношение подписки на события самостоятельно, чем раньше.



Суммировать

Удаленный модуль очень важен для разработки Electron, ведь многие модули доступны только в основном процессе, например BrowserWindow, dialog.

По сравнению с ipc-связью удаленная связь имеет много аспектов. Благодаря вышеизложенному мы также понимаем его основные принципы и недостатки, поэтому, хотя удаленный доступ — это хорошо, не злоупотребляйте им.

Исходный код удаленки также прост для понимания и достоин изучения, ведь межсетевое взаимодействие очень распространено на фронтенде, например, WebViewBridge, Worker.

remote может вдохновить вас, но полностью скопировать его невозможно, потому что, например, он полагается на какой-то v8 «Hack» для прослушивания сборки мусора объектов, что невозможно сделать в обычных сценариях разработки.

Эта статья закончилась.



расширять