LISP-пакет OMGlib или вперёд к Web 3.0

Вместо предисловия

Зайдя, впервые за несколько лет, на Хабр, я был приятно удивлен тем, что а) он до сих пор существует и б) тут есть целое сообщество лисперов! Последний факт меня так обрадовал (оказывается, не я один использую LISP!), что я решил восстановить свой старый аккаунт и написать эту статью. Правда, все мои старые посты и карма пропали, ну да и ладно. Будем делать новые.

Немного об эволюции WWW

Все, кто застал начало развития всемирной Сети, помнят, что Web 1.0 был просто набором документов, связанных между собой гиперссылками. Вы могли открыть документ, используя его URL, и прочитать его, а также перейти к другим документам, используя ссылки. Еще сейчас на многих сайтах остался рудимент той эпохи, раздел «Links» или «Ссылки», где можно найти ссылки на самые разные сайты, большей частью уже мертвые. Ничего странного не было в том, что на сайте солидной конторы в разделе «Links» рядом находились ссылки на сайт Белого дома, NASA и чат Кроватка — просто это был единственный способ найти другие сайты до появления поисковиков.

Потом пришли поисковики, появились блоги, соцсети, сайты новостей и вместе с ними Web 2.0, который уже был не столько коллекцией документов, сколько базой данных. Вы заходите на сайт и получаете некую выборку записей из БД, например, посты в ленте на Хабре. Также вы можете создать новую запись, например, написав пост или комментарий и он попадет в ленты других пользователей. Web 2.0 настолько изменил способ пользования сетью, что не только раздел «Links» стал анахронизмом, люди даже перестали использовать адресную строку и URL, заходя вместо этого в яндекс или гугл и вбивая там «lenta.ru» или «habr» чтобы попасть на нужный сайт.

Шли годы. Браузер стал основным приложением на любом компьютере, что послужило его развитию. Из простого просмотрщика HTML-документов браузер превратился, практически, в операционную систему, внутри которой можно исполнять почти любой код, уже чуть ли не на ассемблере. Браузеры поселились везде, вплоть до телефонов и наручных часов. И стал постепенно вырисовываться Web 3.0, в котором сайты становятся неотличимы от приложений. Действительно, посмотрите на тот же VK или Facebook — там JS кода загружается больше, чем данных, которые он отображает. Всё стало динамичным, отзывчивым и сам отдельный браузер, в котором открываются сайты, уже выглядит немного анахронизмом. Даже уже появилась технология PWA, которая должна его похоронить.

OK, но при чем тут LISP?

Я довольно много программирую, но результатом моей работы обычно является то, что программа выдает, а не сама программа, как таковая. Потому, как правило, все мои программы консольные и не имеют никакого интерфейса. Однако, иногда интерфейс всё же нужен и я в таких случаях обычно конструирую простейший web-фроненд, бесконечно страдая в рамках модели MVC. Однажды, размышляя над эволюцией мировой Сети (см. предыдущий раздел) я подумал, а почему бы не довести идею сайта-приложения до логической завершенности, отказавшись от HTML вообще? Пусть, подумал я, страница открывает WebSocket-соединение, через которое получает JS-код, который сразу же исполняет, возвращая результат через то же соединение обратно. Тогда я смогу создавать DOM-элементы, манипулировать ими, ловить события и вообще рулить браузеом как мне хочется. Пусть это будет как X-терминал, только более продвинутый. В тот момент я немного увлекся Lisp и, разумеется, первой же мыслью было –, а не могу ли я исполнять в браузере прямо код на LISP? Оказалось, что могу.

Выяснилось, что вполне себе существует компилятор JSCL, который преобразует LISP в JS. Что важно, это self-hosted компилятор, он может работать как внутри обычной LISP-машины, например в SBCL, так и прямо в браузере, скомпилировав в JS сам себя. Дальше, в общем-то, было дело техники — написать несколько несложных макросов для компиляции кода в JS, некоторую обвязку, немного поковыряться грязными руками в коде JSCL и оформить всё это в виде небольшой библиотечки OMGlib или просто:OMG. Я долго думал над названием, но, наконец, эмоции взяли верх и я назвал ее так, как назвал. Сама библиотека доступна через QuickLisp, но QL обновляет код раз в месяц в лучшем случае, так что я порекомендовал бы взять OMGlib прямо c git и поместить ее в ~/quicklisp/local-projects

Как работает OMG

Рассмотрим простой пример:

  1. Загрузим саму библиотеку и ее зависимости:

    (ql:quickload :omg)
  2. По некоторым причинам весь код должен быть в отдельном package (CL-USER не подходит!), так что создаем его:

    (defpackage :my-test  
    	(:use cl omg omgui jscl))
    (in-package :my-test)
  3. Запускаем сервер:

    (start-server)
    Hunchentoot server is started.
    Listening on 127.0.0.1:7500.
    #S(CLACK.HANDLER::HANDLER
       :SERVER :HUNCHENTOOT
       :ACCEPTOR #)
  4. Переходим на http://127.0.0.1:7500 и… видим пустую страницу. Но, если открыть консоль браузера, то там жизнь бьет ключом:

    30fdede6b932bae64b9dbd1df319e791.png

    Последнее сообщение означает, что соединение установлено и мы можем выполнять код!

  5. Давайте выполним что-нибудь:

    (jslog "Hello World!")
    (NIL)

    В консоли браузера мы видим…

    f74a2a6f684fa1d33ab90fe59edf1d33.png

    …само сообщение, а также, внезапно, предупреждение о синхронном XHR. Откуда же оно? Дело в том, что :OMG не грузит в браузер сразу весь код. Это не имеет большого смысла и может очень замедлить загрузку. Вместо этого вызов (jslog ...) будет скомпилирован в JS и отправлен в браузер, который (LISP среда которого) пока не в курсе, что это за функция, #'jslog. Вместо того, чтобы сразу выбросить исключение, браузер сделает запрос к серверу, который вернет ему код для этой функции. Если бы внутри jslog использовались еще какие-то функции или символы, были бы, в нужное время, запрошены и они. Понятно, что эти функции и символы запрашиваются один раз, потом уже сколько бы мы не делали их вызовов, они будут происходить практически мгновенно. Таким образом, в браузер грузится только нужный ему код и только тогда, когда он потребуется. По-моему, идеально.

    Функция вернула список с одним элементом: (NIL). То, что это именно список, объясняется тем, что сессий может быть открыто множество и, если мы выполняем функцию таким образом, то она запустится во всех подключенных браузерах и будет возвращен список результатов. Можно, разумеется, выполнить функцию в конкретном браузере и получить одно значение (даже несколько через values), см. секцию Sessions в README.md репозитория OMGlib.

  6. Давайте создадим какой-нибудь элемент:

    (append-element (create-element "div" :|style.border| "1px solid red" 
    																		  :|style.padding| "1em"
                                          :|style.display| "inline-block"
                                          :|innerHTML| "Hello World!"))

    В браузере появится новый элемент:

    610639795e35731480340bb31bb0d259.png

    А REPL ваш вывалится в отладчик, с ошибкой типа: illegal sharp macro character: #< ... Это произошло потому, что функция, совершенно не стесняясь, вернула DOM-объект и библиотека не смогла его сериализовать, чтобы пропихнуть через WebSocket. Если бы эта функция вызывалась из другой функции внутри браузера, то никакой ошибки бы не было, DOM-объекты вполне законно можно возвращать и использовать, главное, не пытаться передать его в бэкенд.

    Прошу обратить внимание, как передаются атрибуты при создании элемента. Фактически, это обычный plist, где имя параметра должно соответствовать имени атрибута DOM. Так как для некоторых атрибутов регистр символов важен, необходимо использовать нотацию :|SymbolName| вместо кондового :symbolname. Не очень красиво, но работает. Если вы эстет, можете переключить readtable-case в :preserve.

  7. Давайте создадим browser-side функцию:

    (defun-f add-url (url content)
      (append-element
        (create-element "a" :|href| url :append-element content))
      nil) ;; Не пытаемся вернуть DOM-объект!

    и вызовем ее:

    (add-url "http://habr.com" "Ссылка на Хабр!")

    На экране появится ссылка на Хабр. Обратите внимание на параметр :append-element — это просто короткий способ сделать следующее:

    (let ((a (create-element "a" :|href| url)))   
      (append-element content a)
      (append-element a))

    Также, заметьте, мы можем в append-element использовать DOM-элемент или строку, библиотека всё правильно распознает. Например, давайте покрасим фон ссылки в зеленый цвет:

    (add-url "http://habr.com" 
    				 (create-element "span" :|style.background| "green" 
             												:|innerHTML| "Ссылка на Хабр!"))

    Тут внимательный читатель должен заметить, что выполняя функцию на стороне сервера мы передаем в качестве параметра, фактически, DOM-объект, как так?! На самом деле, (defun-f add-url создает на хосте не функцию, а макрос, который сам определяет, что create-element это browser-side функция и выполнит ее в браузере. Это удобно для тестирования и отладки, но в реальной жизни, скорее, создает проблемы, поскольку add-url нельзя вызвать внутри let, например. Чтобы обойти это ограничение, можно использовать функцию remote-exec:

    (remote-exec '(add-url "http://habr.com" "Ссылка на Хабр!"))
  8. А что если браузерной функции потребуется вызвать какую-нибудь фнкциию на стороне сервера и получить результат? Для этого мы можем определить RPC-функцию, назовем ее, например give-me-an-integer:

    (defun-r give-me-an-integer ()
      42)

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

Функции, которые использованы выше, определены в пакете omgui, где собраны всякие полезности, но вы можете использовать функции JSCL напрямую. Например, код append-element в реальности выглядит так:

(defun-f append-element (el &optional parent)
  "Append the element el as a child to the parent"
  (let ((el1 (if (stringp el)
                 ((jscl::oget (jscl::%js-vref "document") "createTextNode") el)
                 el)))
    (if parent
      ((jscl::oget parent "appendChild") el1)
      ((jscl::oget (jscl::%js-vref "document")
                   "body"
                   "appendChild")
       el1))
    el1))

Кроме defun-f и defun-r, есть еще defparameter-f и defvar-f, работающие предсказуемым образом. Ну, как предсказуемым. Когда браузер запрашивает определенный таким образом символ, он получает его со значением соответствующего символа на хосте. Но дальше уже вся связь между ними теряется, изменение значения в браузере не затронет значение на хосте, а изменения на хосте (через setf) не затронут браузеры, если они уже успели получить этот символ ранее.

Небольшой нюанс: если вы переопределите browser-side функцию на хосте, то всем подключенным браузерам будет разослана команда на удаление этой функции. Когда в следующий раз исполнение дойдет до ее вызова, код будет запрошен заново. Очень удобно для отладки.

А как же макросы?

Макросы на стороне браузера тоже работают! Но работают они немного нетривиально. Когда браузер запрашивает функцию, внутри которой есть макрос, функция компилируется на хосте, но код макроса при этом отправляется для выполнения в браузер, чтобы все side-эффекты относились к браузеру, а не к хосту. То есть, макросы стоят дороже, чем простые функции. Не стоит вычислять числа Фибоначи на макросах.

А как же CLOS?

Увы, я пока не придумал, как красиво реализовать CLOS в этой постановке. Если вы знаете как — напишите мне. Или сделайте прямо PR, я буду счастлив.

А обработка ошибок?

Увы еще раз, в браузере обработки ошибок пока нет. Смотрите в консоль, там обычно видно, где произошел взрыв. Если захотите исправить это — you are welcome! Исключения на стороне сервера обрабатываются как обычно.

У меня есть уже готовый сайт, хочу добавить туда немного лиспа…

Это легко можно сделать! Фактически, достаточно добавить одну строчку в код, примерно такого вида:

После этого вы сможете исполнять код и манипулировать DOM-объектами как вам заблагорассудится.

Можно ли сделать еще круче?

Можно :) Прямо сейчас я работаю над подсистемой omgdaemon, которая работает следующим образом:

  1. На хосте запускается reverse-proxy, принимающий соединения от клиентов и соединяющий их с нужным рабочим процессом, на основе значения куки OMGVERSION (если ее нет, то сам же добавляет ее). Цель в том, чтобы пользователь, соединившись в первый раз, попадал в самую свежую (на текущий момент) версию кода.

  2. Для разработчиков запускается своя development-версия, куда пользователь так просто попасть не может и с которой можно соединиться через SWANK-сервер. Таким образом, все REPL-манипуляции не затрагивают пользователей.

  3. Когда development-версия дорастает до уровня production-ready, выполняется функция (commit-production) и development-версия сбрасывает образ, который получает свой номер версии, теперь все новые клиенты будут присоединяться к нему, параллельно запускается новая development-версия, точно такая же, как старая. Все уже соединенные пользователи получают уведомление о том, что страницу неплохо было бы обновить, но могут остаться работать в текущей версии, до закрытия страницы.

Что дальше?

Ну, the sky is the limit! На самом деле я сознательно опустил в этой статье большую часть нюансов и тонкостей, она и так уже слишком большая. Если будет интерес у сообщества, я готов писать еще об ее использовании. Сейчас эта библиотечка еще довольно маленькая и я вполне тяну разработку в одно лицо (благо, LISP этому способствует), но со временем, я надеюсь, что количество пользователей и разработчиков увеличится. Давайте делать революцию вместе!

© Habrahabr.ru