В этой статье будет использоваться ГРЯЗНЫЙ, небезопасный, "костыльный", страшный и т. д. метод 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 (если недоступно создание "на лету"). Приходите в репозиторий!

Комментарии (13)


  1. Sirion
    01.08.2019 18:14
    +1

    Боюсь показаться глупым, но каков кейс использования сабжа?


    1. TsDaniil Автор
      01.08.2019 18:36
      -1

      Например библиотеки по считыванию QR кода сильно тормозят main процесс при своей работе, с помощью данной библиотеки ими можно пользоваться проще.
      В целом случаи когда нужны воркеры не очень часто встречаются, но неудобство их использования заставляет разработчиков искать решения без них. У нас были случаи когда пользоваться воркером удобно.


      1. Sirion
        01.08.2019 18:59

        Это кейс использования воркеров. Но почему не перенести весь необходимый код сразу в них, зачем жонглировать eval'ами?


        1. TsDaniil Автор
          01.08.2019 19:12

          Этот подход позволяет не менять архитектуру, не настраивать особым образом сборку проекта. Легко и просто перенести любой загруженный код в воркер, не переписывая всё вокруг.


          1. Sirion
            01.08.2019 19:23

            Если такая проблема вынести нужные части кода в воркер — возможно, проблема как раз с архитектурой?

            Нет, в принципе, я согласен, что было бы круто иметь возможность просто выполнить произвольную функцию в другом потоке. Но статья полна оговорок, примечаний и извинений. И либа, при всём уважении к вашей креативности, выглядит очень костыльно. И есть подозрение, что при дальнейшем её использовании будут найдены ещё не одни подводные грабли. Точно ли это адекватная цена за удобство?


            1. TsDaniil Автор
              01.08.2019 19:50

              Данная библиотека используется в проектах и хорошо справляется с поставленными задачами. По мере расширений требований к ней добавляется новый функционал. Самая большая проблема в ней — невозможность использования замыкания.

              А что в этой библиотеке кажется вам костыльным (кроме «eval»)?
              Получилась библиотека с удобным АПИ, которая позволяет

              просто выполнить произвольную функцию в другом потоке
              Она сырая, но только пользователи и issue на гитхабе помогут сделать её лучше.
              В целом я хотел получить способ удобно пользоваться многопоточностью в javascript. В каком-то виде это получилось, пользоваться или нет — личное дело каждого. Так как воркеры не особо востребованы — статья больше исследовательская, чем призыв к использованию.


              1. Sirion
                01.08.2019 23:20

                Ну, с моей точки зрения воркеры — это достаточно удобная многопоточность. Настройка сборки происходит один раз. А архитектурные ограничения — повод избавиться от спагетти, разделить код на менее связанные компоненты. Если думать о том и другом заранее, то всё хорошо. Соответственно, ваш проект (согласно вашему же описанию) мне видится как некий хак, чтобы быстро впихнуть многопоточность туда, где она изначально не задумывалась.


                1. TsDaniil Автор
                  02.08.2019 09:24

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


                  1. Sirion
                    02.08.2019 11:42
                    +1

                    В общем-то, я с вами согласен. И когда для этого появится механизм на уровне языка, с удовольствием им воспользуюсь при случае. Но я не хочу каждый раз, используя этот механизм, думать о том, какие ограничения есть у библиотеки, его реализующей (а они есть), и что в ней может пойти не так (а оно может). Проще настроить сборку и быть спокойным.

                    P.S. Ни в коем случае не имею в виду, что все должны думать так же, как я.


                    1. TsDaniil Автор
                      02.08.2019 11:45

                      Sirion, сам с нетерпением жду поддержки этого на уровне языка.


      1. Aingis
        02.08.2019 14:12

        Для библиотек считывания нужно передавать данные по изображению: они и так потребляют много памяти (даже в ч/б), а тут надо ещё сериализовывать данные чтобы передать тому же воркеру. Есть ли измерения на тему того, окупаются ли эти затраты?


  1. TsDaniil Автор
    02.08.2019 14:28
    +2

    Aingis, я не совсем понимаю в чём вопрос. Суммарное время работы кода увеличилось (не значительно, хотя это конечно зависит от объема передаваемых данных), так как нужно серриализовывать и парсить данные. Но за счёт того что обработка картинки происходит в воркере исчезло замирание интерфейса. Когда глазом перестали быть заметны тормоза — то да, я считаю что эти затраты окупаются. Возможно в дальнейшим мы добавим сущность типизированного массива, чтобы ускорить парсинг и серриализацию.


    1. LordWerter
      02.08.2019 21:21

      В дополнение к статье) Я думаю, что весь код воркера стоит разбивать на составляющие и, тем самым, параллелить выполнение наиболее затратных операций и методов в самих воркерах. Каждому воркеру стоит назначать не более трех функций. Т.о., весь код, перенесенный в воркеры, будет значительно быстрее отрабатывать.
      Т.е. можно запустить целую группу воркеров, которые, в целом, могут быть организованы так, как описывается в микросервисной архитектуре. Я, единственное, не знаю, как множество одновременно работающих воркеров будет потреблять ресурсы)))