В предыдущем посте я, немного сумбурно, рассказал про библиотечку OMGlib, которая позволяет создавать полностью динамические веб-приложения. Вкратце, идея состоит в следующем: приложение использует браузер для взаимодействия с пользователем, для этого браузер открывает WebSocket-соединение с сервером, после чего просто выполняет получаемый от него javascript-код, отправляя результаты обратно через это же соединение. Все DOM-элементы при этом создаются динамически, через соответствующие функции javascript, без использования HTML вообще. Также, сервер может создавать функции в браузере и вызывать их, сгружая, таким образом, всю логику и механику, связанную с интерфейсом, прямо в браузер. Библиотечка реализована на языке Common Lisp, развитая система макросов которого позволяет писать код единообразно, просто помечая часть функций как browser-side, а компиляция их в JS происходит при помощи JSCL прозрачно для программиста. Казалось бы, что тут может пойти не так?

Ущербная однопоточная модель Javascript

Увы, пойти не так может много что. Javascript – очень хороший язык, но у него есть один фатальный недостаток – этот язык принципиально однопоточный. Чаще всего это не является недостатком (а иногда даже является преимуществом!) но в модели OMGlib однопоточность сильно портит жизнь. Как я уже упоминал в предыдущем посте, OMGlib не грузит сразу весь код в браузер. Когда мы выполняем на стороне браузера какую-либо функцию, она может вызывать какие-либо другие функции, не все из которых могут быть уже загружены в браузер. Когда происходит вызов неопределенной функции, браузер должен запросить ее код у сервера. Казалось бы, у нас есть открытый WebSocket, мы можем послать туда запрос, дождаться ответа и продолжить выполне... Стоп. Ключевое слово тут "дождаться ответа" – в JS это, увы, невозможно.

На самом деле, возможно, конечно, но путем большого оверхэда, заменив все функции на генераторы, что как бы реализуемо, раз уж мы сами генерируем код, но... Я считаю, что не надо бороться с языком и средой выполнения, это контрпродуктивно. Надо их использовать! Но что мы можем использовать в данном случае? Синхронный XHR, разумеется! Это, конечно, порицаемая практика, но в данном случае она не должна доставлять много проблем -- каждая функция загружается только один раз, когда весь нужный (и только нужный!) код подгрузится, синхронные XHR станут не нужны.

Отлично, нашли неопределенную функцию, сделали запрос, выполнили полученный JS, функция определилась, мы ее вызвали и продолжили выполнение дальше. Я думал, что ловко обошел ограничение JS, всё было хорошо, пока я не обнаружил, что теперь не работают макросы в браузере. Макросы – это важно, макросы, это то, за что мы любим Common Lisp! Макрос вызывается при компиляции кода, по сути, это функция, которая возвращает код, который встраивается на место макроса при компиляции. Компиляция происходит на стороне сервера, а макросы, по логике, должны вызываться на стороне браузера, чтобы всё работало ожидаемым образом. Казалось бы, у нас есть открытый WebSocket, мы можем послать запрос в браузер, дождаться ответа и продолжить клмпиля... Стоп. Если мы начали компиляцию, когда браузер запросил код функции, то сам браузер в этот момент ждет ответа от XHR, ему не до обработки сообщений вебсокета. Потом, когда XHR закончится, он всё обработает и ответит, но как же он закончится, если для завершения компиляции нам нужно выполнить в браузере некоторый код?

Честно говоря, в этот момент я немного приуныл и даже идея с генераторами на короткое время перестала казаться мне кощунственной. Однако, помрачение длилось недолго и я нашел красивый выход. У нас же браузер сидит и ждет ответа на XHR, потом он выполнит полученный код и пойдет дальше всё исполнять. Давайте мы ему вместо ожидаемого кода дадим код макроса, а в конец добавим снова вызов eval(xhr(результат_выполнения_макроса)), который (если повезет) уже получит искомый код. Ну или получит очередной код макроса, если макросов было больше одного и снова eval(xhr...). Таким образом, диалог сервера и браузера выглядит примерно так:

Сервер (через WS): выполни мне код (SOME-FUNC "Hello World!")

Браузер (открывает XHR1): WTF #'SOME-FUNC?!

Сервер (про себя): хм, #'SOME-FUNC, #'SOME-FUNC... – есть такой код, давайте скомпили... Так, тут макрос (SOME-MACRO ...), надо его выполнить в браузере!

Сервер (отвечает на XHR1): выполни мне код (SOME-MACRO ...)и пришли ответ через XHR!

Браузер (открывает XHR2): WTF #'SOME-MACRO?!

Сервер (отвечает на XHR2): да блин! Вот тебе код для #'SOME-MACRO

Браузер (открывает XHR3): а, спасибо, вот, извольте, результат (SOME-MACRO ...)!

Сервер (отвечает на XHR3): вот, другое дело, получи и исполни код для #'SOME-FUNC!

Браузер (через WS): код (SOME-FUNC "Hello World!") выполнил, получите ваш результат!

Выглядит немного сложно. Но на стороне браузера код до примитивности простой – делаем запрос, выполняем то, что получено, всё. В полученном коде тоже может быть запрос и так далее. Давайте посмотрим, что будет если сервер снова захочет выполнить код:

Сервер (через WS): выполни мне код (SOME-FUNC "Hello World Again!")

Браузер (через WS): код выполнил, получите результат!

Как видим, браузер уже знает, что делает функция #'SOME-FUNCи возвращает результат сразу же. При открытии страницы клиент может испытывать легкие подвисания, когда грузится куча кода, но в дальнейшем всё будет работать гладко. Учитывая, что обновление страницы тут не только не требуется, но даже вредно, первоначальные задержки не особо влияют на user experience.

Если браузеру вдруг внепланово потребовалось что-то от сервера, то вызов RPC-функции происходит аналогичным образом – браузер запрашивает (в первый раз) код этой функции и получает код, который будет делать XHR-запрос на сервер каждый раз при ее вызове. Потом этот код выполняется, запрос уходит на сервер, результат возвращается. Если в процессе обработки запроса серверу вдруг потребуется что-то от браузера, произойдет примерно то же, что и при компиляции кода с макросом – последовательность запросов.

А Lisp что? Вообще не создает проблем?

Lisp – лучший! Но проблемы, разумеется, создает и он. В данном случае, главная проблема – как определить, нужно ли нам запрашивать код функции с сервера? Идея просто запрашивать всё подряд разбилась об суровую реальность – в JSCL поддерживается не вся стандартная библиотека, так что запрашивались порой весьма странные вещи, вроде CONCATENATE (сейчас уже добавлено). В итоге я решил запрашивать только символы из пакетов, которые добавляются пользователем. То есть из CL-USER и JSCL ничего не запрашиваем. Соответственно, весь код должен быть в отдельном пакете (пакетах). Когда на стороне браузера встречается символ из неизвестного пакета, этот пакет автоматически создается через DEFPACKAGE, при этом в него импортируются CL и JSCL.

Вторая проблема связана с определением браузерных функций в виде макросов. Сперва это казалось отличной идеей – сделать возможность вызова браузерных функций просто из кода, чтобы они вообще никак не отличались от обычных. Правда, сразу выяснился один нюанс – если функции вложенные, например:

(some-browser-func (other-browser-func agr))

то сначала будет в браузере (через вебсокет) будет вызвана (other-browser-func agr), результат вернется на сервер, потом он снова будет передан в браузер и там уже выполнится (some-browser-func ...). Мало того, если other-browser-func вернет DOM-объект, то произойдет ошибка, так как DOM-объекты не сериализуются как следует. Вполне логично было бы при вызове передавать в браузер только те параметры, которые могут быть вычислены на сервере, а остальные пусть браузер сам вычисляет. В Lisp это возможно сделать! Но браузерные функции должны быть определены как макросы, тогда они смогут получить список параметров "как есть", до их вычисления. Но тут есть определенная засада. Вот этот код не будет работать:

(let ((x 10))
  (some-browser-func x))

Долго объяснять, почему, потому что это макросы, если коротко. Теоретически, при помощи очень злой магии, это можно заставить работать, но тут я снова не могу переступить через свой принцип – не бороться, но использовать.

Вот так будет работать:

(let ((x 10))
  (remote-exec `(some-browser-func ,x))

Почему не работает CLOS?

Просто я пока не понимаю, как запрашивать с сервера методы классов. У разных классов могут быть методы с одинаковым названием. Если делать как с обычными функциями, то при первой встрече с неизвестным методом он будет запрошен с сервера и после этого уже всё, символ определен, новых запросов не будет. В принципе, если залезть достаточно глубоко в недра CLOS, можно отлавливать такие ситуации, но я пока не преисполнился в достаточной степени, чтобы решиться.

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

Что дальше?

В следующей статье я расскажу о более приятных и практичных вещах, которые можно сделать при помощи этой библиотечки. Мы вместе напишем простенькую программу, а потом добавлением одной строки превратим ее в PWA и установим как приложение!

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


  1. IAMBIRD
    26.04.2022 19:46

    Вкратце, идея состоит в следующем: приложение использует браузер для взаимодействия с пользователем, для этого браузер открывает WebSocket-соединение с сервером, после чего просто выполняет получаемый от него javascript-код, отправляя результаты обратно через это же соединение. Все DOM-элементы при этом создаются динамически, через соответствующие функции javascript, без использования HTML вообще. 

    По описанию похоже на гибрид старого доброго ASP.NET WebForms и JSONP.

    Что первый, что второй вызывали у меня лично дикое отвращение.


    1. Hemml Автор
      26.04.2022 19:57

      Ну, я бы не стал сравнивать. Весь код "серверной части" у меня занимает <1000 строк, а первая версия заработала при размере в 600 строк примерно. То есть это принципиально разного класса системы)

      Эта библиотечка принципиально не про API и не про форматы передачи данных – просто вызываешь функцию и она выполняется где-то там, у клиента в браузере. Можно нарисовать на экране кнопку и перехватить событие нажатия. И обработать это событие прямо внутри браузера, если хочется. Нет никаких скриптов, препроцессоров, компиляции и сборки. Всё просто работает)


  1. Moraiatw
    26.04.2022 21:29

    Почему LISP?


    1. Hemml Автор
      26.04.2022 21:39

      По нескольким причинам:

      1. Он мне нравится

      2. Для него есть self-hosted компилятор в JS (JSCL)

      3. Через систему макросов можно легко работать с исходным кодом функций, прозрачно передавая код browser-side функций компилятору JSCL

      4. REPL -- можно отлаживать приложение "в живую", не занимаясь постоянно компиляцией, сборкой, даже обновлять страницу не нужно -- я меняю функцию, перекомпилирую ее одним нажатием и вот у меня уже в браузере новая функция, сразу видно, что изменилось и как работает.

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


      1. Moraiatw
        27.04.2022 09:07

        А что с производительностью?


        1. Hemml Автор
          27.04.2022 16:02

          Я, понятное дело, не тестировал это с миллионом клиентов, но, не думаю, что с производительностью будут какие-то серьезные проблемы на стороне сервера. На стороне клиента надо соблюдать осторожность, так как JSCL, например, очень медленно работает со строками, тут лучше использовать строковые функции javascript, если строк очень много. Пока я не упирался в производительность и на клиенте тоже, для своих задач.


  1. Recosh
    26.04.2022 22:30

    Как мне кажется в python idom похожая технология. Только там просто по веб сокетам dom деревом управляем. Можно конечно и всякий код в браузер отправить.


    1. Hemml Автор
      27.04.2022 02:39

      Это немного другое. Если я правильно понимаю, idom просто генерирует HTML из python-кода. Я когда-то использовал питоновскую библиотеку Nagare, где это доведено до абсолюта, а код выполняется через систему continuations. Даже когда-то писал статью на Хабре про нее, но она куда-то пропала вместе со всем содержимым моего аккаунта.


  1. pfffffffffffff
    26.04.2022 22:40

    Хорошо ли это будет работать с мобильным медленным интернетом?

    Синхронный xhr блокирует отклик от интерфейса, разве нет?


    1. Hemml Автор
      27.04.2022 02:34

      там довольно небольшой трафик каждый раз, если не писать код функции на 10 экранов, конечно. В принципе, ничего не мешает заливать в клиента весь код сразу (в случае продакшен-версии), просто это пока не реализовано.