В предыдущем посте я, немного сумбурно, рассказал про библиотечку 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 и установим как приложение!