Работа с Worker “как хочется“, а не “как можно”

от автора

В этой статье будет использоваться ГРЯЗНЫЙ, небезопасный, «костыльный», страшный и т. д. метод eval. Слабонервным не читать!

Сразу скажу, что некоторые проблемы удобства использования решить не удалось: в коде, который будет передан в worker, нельзя использовать замыкание.
Работа с Worker "как хочется", а не "как можно"

Всем нам нравятся новые технологии, и нравится, когда этими технологиями удобно пользоваться. Но в случае с worker это не совсем так. Worker работает с файлом или ссылкой на файл, но это неудобно. Хочется иметь возможность засунуть в worker любую задачу, а не только специально запланированный код.

Что нужно, чтобы сделать работу с worker удобнее? На мой взгляд, следующее:

  • Возможность запускать в worker произвольный код в произвольный момент времени
  • Возможность передавать в worker сложные данные (экземпляры классов, функции)
  • Возможность получения Promise с ответом из worker.

Для начала нам понадобится протокол общения между worker и основным окном. В целом протокол — это просто структура и типы данных, с помощью которых будут общаться окно браузера и worker. Тут нет ничего сложного. Можно использовать что-то типа этого или написать свою версию. В каждом сообщении у нас будет ID, и данные, характерные для конкретного типа сообщения. Для начала у нас будет два типа сообщений для worker:

  • добавление библиотек/файлов в worker
  • запуск работы

Файл внутри worker

Перед тем как приступить к созданию worker, нужно описать файл, который будет работать в worker и поддерживать описанный нами протокол. Я люблю ООП, поэтому это будет класс с названием WorkerBody. Этот класс должен подписаться на событие от родительского окна.

self.onmessage = (message) => {     this.onMessage(message.data); };

Теперь мы можем слушать события от родительского окна. События у нас есть двух видов: те, на которые подразумевается ответ, и все остальные. Обработаем события.
Добавление библиотек и файлов в worker делается при помощи API importScripts.

И самое страшное: для запуска произвольной функции мы будем использовать eval.

...  onMessage(message) {   switch (message.type) {     case MESSAGE_TYPE.ADD_LIBS:         this.addLibs(message.libs);         break;     case MESSAGE_TYPE.WORK:         this.doWork(message);         break;     } }  doWork(message) {     try {         const processor = eval(message.job);         const params = this._parser.parse(message.params);         const result = processor(params);         if (result && result.then && typeof result.then === 'function') {              result.then((data) => {                  this.send({ id: message.id, state: true, body: data });              }, (error) => {                  if (error instanceof Error) {                       error = String(error);                  }                  this.send({ id: message.id, state: false, body: error });              });         } else {            this.send({ id: message.id, state: true, body: result });         }     } catch (e) {        this.send({ id: message.id, state: false, body: String(e) });     } }  send(data) {     data.body = this._serializer.serialize(data.body);     try {         self.postMessage(data);     } catch (e) {         const toSet = {           id: data.id,           state: false,           body: String(e)         };         self.postMessage(toSet);     } } 

Метод onMessage отвечает за получение сообщения и выбор обработчика, doWork — запускает переданную функцию, а send отправляет ответ в родительское окно.

Парсер и сериализатор

Теперь, когда у нас есть содержимое worker, надо научиться сериализовать и парсить любые данные, чтобы передавать их в worker. Начнем с сериализатора. Мы хотим иметь возможность передавать в worker любые данные, в том числе — экземпляры классов, классы и функции. Но с помощью нативных возможностей worker мы можем передать только JSON-like данные. Чтобы обойти этот запрет, нам понадобится eval. Все, что не может принять JSON, мы обернем в соответствующие строковые конструкции и запустим на другой стороне. Чтобы сохранить иммутабельность, полученные данные клонируются на лету, и то, что не может быть сериализовано обычными способами, заменяется служебными объектами, а они в свою очередь заменяются обратно парсером на другой стороне. На первый взгляд может показаться, что эта задача несложная, но существует множество подводных камней. Самое страшное ограничение такого подхода — невозможность использовать замыкание, что несет в себе несколько иной стиль написания кода. Начнем с самого простого, с функции. Для начала надо научиться отличать функцию от конструктора класса.

Попробуем отличить:

static isFunction(Factory){          if (!Factory.prototype) {             // Arrow function has no prototype             return true;         }          const prototypePropsLength = Object.getOwnPropertyNames(Factory.prototype)                     .filter(item => item !== 'constructor')                     .length;          return prototypePropsLength === 0 && Serializer.getClassParents(Factory).length === 1; }  static getClassParents(Factory) {         const result = [Factory];         let tmp = Factory;         let item = Object.getPrototypeOf(tmp);          while (item.prototype) {             result.push(item);             tmp = item;             item = Object.getPrototypeOf(tmp);         }          return result.reverse();     } 

Первым делом мы выясним, есть ли у функции прототип. Если его нет — это точно функция. Затем мы смотрим на количество свойств в прототипе, и, если в прототипе только конструктор и функция не является наследником другого класса, мы считаем, что это — функция.

Обнаружив функцию, мы просто заменяем ее служебным объектом с полями __type = "serialized-function" и template, который равен шаблону данной функции (func.toString()).

Пока что пропустим класс и разберем экземпляр класса. Далее в данных нам необходимо отличать обычные объекты от экземпляров классов.

static isInstance(some) {         const constructor = some.constructor;         if (!constructor) {             return false;         }         return !Serializer.isNative(constructor);     }  static isNative(data) {         return /function .*?\(\) \{ \[native code\] \}/.test(data.toString()); } 

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

  • __type — ‘serialized-instance’
  • data — данные, которые были в экземпляре
  • index — индекс класса этого экземпляра в служебном списке классов.

Чтобы передать данные, нам необходимо сделать дополнительное поле: в нем мы будем хранить список всех уникальных классов, которые мы передаем. Самое сложное заключается в том, чтобы при обнаружении класса брать не только его шаблон, но и шаблон всех родительских классов и сохранять их как самостоятельные классы — чтобы каждый «родитель» был передан не более одного раза, — и сохранить проверку на instanceof. Определить класс несложно: это — функция, которая не прошла нашу проверку Serializer.isFunction. При добавлении класса мы проверяем наличие такого класса в списке сериализованных данных и добавляем только уникальные. Код, который собирает класс в шаблон, — довольно большой и лежит тут.

В парсере мы сначала обходим все переданные нам классы и компилируем их, если ранее они не были переданы. Затем мы рекурсивно обходим каждое поле данных и заменяем служебные объекты на скомпилированные данные. Самое интересное — в экземпляре класса. У нас есть класс и есть данные, которые были в его экземпляре, но мы не можем просто так создать экземпляр, ведь вызов конструктора может иметь параметры, которых у нас нет. На помощь нам приходит почти забытый метод Object.create, который возвращает объект с заданным прототипом. Так мы избегаем вызова конструктора и получаем экземпляр класса, а затем просто переписываем в экземпляр свойства.

Создание worker

Для успешной работы worker нам необходимо иметь парсер и сериализатор внутри worker и снаружи, поэтому мы берем сериализатор, и превращаем в шаблон сериализатор, парсер и тело worker. Из шаблона делаем блоб и создаем ссылку на скачивание через URL.createObjectURL (данный способ может не работать при некоторых «Content-Security-Policy»). Данный способ также подходит для запуска произвольного кода из строки.

_createWorker(customWorker) {     const template = `var MyWorker = ${this._createTemplate(customWorker)};`;     const blob = new Blob([template], { type: 'application/javascript' });     return new Worker(URL.createObjectURL(blob)); }  _createTemplate(WorkerBody) {     const Name = Serializer.getFnName(WorkerBody);     if (!Name) {       throw new Error('Unnamed Worker Body class! Please add name to Worker Body class!');     }      return [       '(function () {',       this._getFullClassTemplate(Serializer, 'Serializer'),       this._getFullClassTemplate(Parser, 'Parser'),       this._getFullClassTemplate(WorkerBody, 'WorkerBody'),       `return new WorkerBody(Serializer, Parser)})();`     ].join('\n'); } 

Результат

Таким образом, у нас получилась простая в использовании библиотека, которая может запустить произвольный код в worker. Она поддерживает классы из TypeScript. Например:

const wrapper = workerWrapper.create();  wrapper.process((params) => {     // This code in worker. Cannot use closure!     // do some hard work     return 100; // or return Promise.resolve(100) }, params).then((result) => {     // result = 100; });  wrapper.terminate() // terminate for kill worker process

Дальнейшие планы

Данная библиотека, к сожалению, далека от идеала. Необходимо добавить поддержку сеттеров и геттеров на классах и объектах, экземпляры классов и ссылки на конструкторы внутри классов, прототипов, статичных свойств. Мы также хотели бы добавить кэширование, сделать альтернативный запуск скриптов без eval через URL.createObjectURL и добавить в сборку файл с содержимым worker (если недоступно создание «на лету»). Приходите в репозиторий!


ссылка на оригинал статьи https://habr.com/ru/company/waves/blog/462155/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *