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

Зайдя, впервые за несколько лет, на Хабр, я был приятно удивлен тем, что а) он до сих пор существует и б) тут есть целое сообщество лисперов! Последний факт меня так обрадовал (оказывается, не я один использую 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 #<SB-THREAD:THREAD "clack-handler-hunchentoot" RUNNING
                    {1007320F53}>)
  4. Переходим на http://127.0.0.1:7500 и... видим пустую страницу. Но, если открыть консоль браузера, то там жизнь бьет ключом:

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

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

    (jslog "Hello World!")
    (NIL)

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

    ...само сообщение, а также, внезапно, предупреждение о синхронном 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!"))

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

    А 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! Исключения на стороне сервера обрабатываются как обычно.

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

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

<script src='http://localhost:7500/j' type='text/javascript'></script>

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

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

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

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

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

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

Что дальше?

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

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


  1. Durilka96
    20.04.2022 20:20

    Хм... Я к сожалению только начинающий в web разработке, но успел немного почитать первые статьи Пол Грэма, которые очень много говорят о Lisp, но в довольно старые времена, также Пол Грэм рассказывает о проблемах и перспективах Lisp и Web вцелом, затрагивает проблемы популярности новых ЯП и их использовании в разработке web-приложений. К сожалению проблему, которую обозначил Пол Грэм еще в далекие времена до сих пор актуальна – Web- приложения разрабатываются и поддерживаются на тех яп и технологиях, которые или популярны или по стечению обстоятельств должны использовать только их и никакие иначе, также сюда подключается проблема стоймости разработки, поддержки и переписывания какого-либо уже работающего продукта на непопулярный яп.

    В силу недостаточных знаний, в общих чертах я понял Вашу идею. На мой взгляд довольно интересное решение. Очень интересно почитать продолжение Вашей разработки современного web-приложения на Lisp, особенно интересен результат в том на сколько конкурентноспособно будет приложение по сравнению с современными фреймворками???? Большой удачи и терпения Вам, надеюсь найдутся компетентные помошники✊


    1. Hemml Автор
      20.04.2022 20:26

      Я не собираюсь конкурировать с другими фреймворками, боже упаси) Вообще, мое глубокое убеждение в том, что с языком, средой и библиотеками не нужно бороться, нужно их использовать! OMG можно прикрутить к любому уже существующему сайту, добавив загрузку одного js-файла и дальше выполнять на странице любой LISP-код, параллельно со всем остальным кодом. Также, поскольку мы имеем полный доступ к DOM и JS, можно использовать любые JS-фреймворки, просто вызывая их функции. Я не стал раздувать статью, но в omgui.lisp есть код, подключающий API YouTube и позволяющий добавить плеер на страницу. То же можно сделать и со всякими React-ами и прочими jquery, не к ночи будь помянуты.


  1. addewyd
    20.04.2022 21:04

    что-то я подзабыл как с quicklisp работать.

    (ql:quickload :omg)

    System «omg» not found

    а может просто нет там омг

    (ql:system-apropos «omg»)
    ничего не выдаёт.

    а как поместить в local-projects не знаю

    сейчас там лежит пустой system-index.txt

    УПД

    надо было сделать (ql:update-dist «quicklisp»)

    Sorry

    УПД2

    И даже всё работает!

    Windows 10, sbcl 2.0.0


    1. Hemml Автор
      20.04.2022 21:47

      О! Библиотека работает на Windows! Неожиданно)


      1. addewyd
        21.04.2022 05:37

        Почему бы и нет? У sbcl какие-то проблемы c threading были под windows, вроде бы починили, точно не скажу. А так, если не хотеть чего-нибудь странного… Например omgdaemon — мельком заглянул внутрь. Увидел страшные слова. Типа «докер». И закрыл. Но всё же, думаю, что решаемо.


        1. Hemml Автор
          21.04.2022 14:56

          Просто я ни разу не тестировал на Windows. Та часть, за которую отвечает omgdaemon, практически наверняка не будет работать сейчас ни на чем кроме связки Linux/SBCL и MacOS/SBCL, уж больно она завязана на fork/exec. Но я работаю над этим)


  1. mmans
    22.04.2022 07:29

    Возможно вас заинтересует PicoLisp (https://picolisp.com), самодостаточный и уже очень давно дружит с вебом и умеет много интересных вещей.


    1. addewyd
      22.04.2022 09:47

      Нууу… Где пико, а где sbcl, практически полная реализация стандарта ANSI с кучей либ, CLOS, компилятором итд итп.


    1. Hemml Автор
      22.04.2022 14:14

      Интересная реализация, спасибо! Но я пока еще не готов заниматься портированием. Есть шанс, конечно, что там всё само заработает, но я сомневаюсь. Похоже, там свой FFI, если он не поддерживается нужными мне пакетами (в основном, clack, bordeaux-threads и пр.), то придется затратить довольно много усилий.