Все части

Оглавление

Отладка в Cider

Мы исследовали отладку так долго, чтобы читатель убедился: в ней нет никакой магии. Отладчик — это код, который внедряется в исходный код и заставляет его работать с паузами. Во время паузы отладчик ждет команду пользователя и исполняет ее.

Теперь когда вы знакомы с самодельным отладчиком, рассмотрим, что предлагает Cider. В нашем распоряжении два тега: #break и #dbg. Первый тег означает точку останова (брейкпоинт) в месте, где он расположен. Поставьте #break в середину произвольного кода. Перед тем как запустить код, выполните его командой cider-eval-..., иначе эффект не вступит в силу.

Тег #break ссылается на функцию breakpoint-reader из модуля cider.nrepl.middleware.debug. Она принимает форму и добавляет в ее метаданные особое поле – признак отладки. Далее сработает оснащение (или инструментирование) — алгоритм, который ищет отмеченные формы и оборачивает их кодом, который запускает отладку.

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

В Emacs нет графических средств отладки. Информация выводится либо рядом с кодом, либо в отдельных буферах. В режиме отладки файл нельзя редактировать; клавиши не вводят текст, а вызывают команды. Ошибка новичков в том, что, попав в отладку, они нажимают все подряд, и процесс протекает с ошибками. Ниже мы рассмотрим процедуру так, чтобы с вами этого не случилось.

Вернемся к функции get-joke для поиска шуток. Освежим в памяти ее код:

(defn get-joke [lang]
  (let [request
        {:url "https://v2.jokeapi.dev/joke/Programming"
         :method :get
         :query-params {:contains lang}
         :as :json}

        response
        (client/request request)

        {:keys [body]}
        response

        {:keys [setup delivery]}
        body]
    #break
    (format "%s %s" setup delivery)))

Добавьте тег #break перед (format ...) и выполните cider-eval-defun-at-point. Обратите внимание, что тег может стоять как на одной строке с формой:

#break (format ...)

, так или на предыдущей:

#break
(format ...)

Плюс второго способа в том, что тег легко удалить командами kill-line или kill-whole-line, не затрагивая код.

Запустите функцию с любым аргументом, например (get-joke "python"). Буфер перейдет в режим отладки. Над функцией появится меню действий. Это команды continue, next, locals и другие, которые мы рассмотрим позже. После стрелки => показан результат формы, на который вы остановились.

continue next in out here eval inspect locals inject stacktrace trace quit
(defn get-joke [lang]
  (let [request
        {:url "https://v2.jokeapi.dev/joke/Programming"
         :method :get
         :query-params {:contains lang}
         :as :json}

        response
        (client/request request)

        {:keys [body]}
        response

        {:keys [setup delivery]}
        body]
    #break
    (format "%s %s" setup delivery)))
 => "why do python programmers wear glasses? Because they can't C#."

В режиме отладки Emacs прослушивает клавиши c, n, l и другие, связанные с отладкой. Например, l (locals) покажет локальные переменные. Нажмите ее, и появится буфер с содержимым:

Class: clojure.lang.PersistentArrayMap
Contents:
  body = { :category "Programming", :delivery "Because they can't C#.", :type "twopart", :setup "why do python programmers wear glasses?", :lang "en", ... }
  response = { :cached nil, :request-time 285, :repeatable? false, :protocol-version { :name "HTTP", :major 1, :minor 1 }, :streaming? true, ... }
  request = { :url "https://v2.jokeapi.dev/joke/Programming", :method :get, :query-params { :contains "python" }, :as :json }
  delivery = "Because they can't C#."
  lang = "python"
  setup = "why do python programmers wear glasses?"

Команда p (inspect) исследует значение под курсором. Если нажать p, откроется буфер:

Class: java.lang.String
Value: "why do python programmers wear glasses? Because they can't C#."

Клавиша P исследует произвольное значение. По ее нажатию Emacs запросит значение в минибуфере ввода. Введите response, чтобы исследовать ответ сервера:

Class: clojure.lang.PersistentHashMap
Contents:
  :cached = nil
  :request-time = 285
  :repeatable? = false
  :protocol-version = { :name "HTTP", :major 1, :minor 1 }
  :streaming? = true
  :http-client = org.apache.http.impl.client.InternalHttpClient@284cd4fa
  :chunked? = false
  :reason-phrase = "OK"
  :headers = { "ratelimit-remaining" "118", "referrer-policy" "no-referrer, strict-origin-when-cross-origin", "access-control-allow-headers" "*", "Server" "cloudflare", "ratelimit-limit" "120", ... }
  :orig-content-encoding = nil
  :status = 200
  :length = 401
  :body = { :category "Programming", :delivery "Because they can't C#.", :type "twopart", :setup "why do python programmers wear glasses?", :lang "en", ... }
  :trace-redirects = []

Поле :body не уместилось целиком. Подведите курсор к фигурным скобкам и нажмите Enter — оно откроется во вложенном буфере:

Class: clojure.lang.PersistentHashMap
Contents:
  :category = "Programming"
  :delivery = "Because they can't C#."
  :type = "twopart"
  :setup = "why do python programmers wear glasses?"
  :lang = "en"
  :id = 294
  :error = false
  :safe = true
  :flags = { :nsfw false, :religious false, :political false, :racist false, :sexist false, ... }

Команда eval (клавиша e) выполнит произвольный код. Cider запросит его в минибуфере:

Expression to evaluate: (keys body)
=> (:category :delivery :type :setup ...)

Недостаток минибуфера в том, что он принимает текст в одну строку и поэтому не подходит для сложных выражений.

Клавиша s (stacktrace) открывает стек вызовов. С его помощью мы узнаем, как пришли в текущее место. Вывод ниже, хоть и кажется шумным, верно показывает историю вызовов. Сократим его, оставив только значимую часть:

    debug.clj:  294  cider.nrepl.middleware.debug/debug-stacktrace
    debug.clj:  368  cider.nrepl.middleware.debug/read-debug-command
    debug.clj:  519  cider.nrepl.middleware.debug/break
         REPL:  128  sample/get-joke
         REPL:   78  sample/eval9967
Compiler.java: 7131  clojure.lang.Compiler/eval
     core.clj: 3210  clojure.core/eval

Мы вызвали в REPL форму (get-joke "python"), что соответствует sample/eval9967. Далее шагнули в функцию sample/get-joke. Функция оснащена отладкой, поэтому в ней был макрос, который вызывает break. В функции break случился вызов read-debug-command, которая отвечает за обработку команды отладчика. По нажатию s и поступила команда на вывод стектрейса. Этим занимается функция debug-stacktrace, которая оказалась последней (на вершине стека).

Клавиша q (quit) завершит отладку, и код выполнится до конца без остановок.

Кроме перечисленных команд, доступны навигация, изменение локальных переменных, трассировщик и многое другое. Кое-что из этого мы рассмотрим ниже.

Когда код отлажен, удалите тег #break и выполните форму еще раз. При новом запуске отладки не будет.

Точка останова работает с любыми модулями. По аналогии с печатью (вставкой println), откройте пространство из jar-файла. Снимите режим “только для чтения”, добавьте тег #break в нужную функцию и выполните ее на сервере. Запустите код, который вызывает эту функцию, и вы окажетесь в отладке.

Кроме #break, Cider предлагает #dbg — более мощный тег, который поддерживает навигацию по коду. Под навигацией имеют в виду команды next (переход к следующей форме), step in (шаг внутрь), step out (выход из текущей формы), продолжение до курсора и другие. Подход напоминает отладку в современных IDE.

“Зарядите” функцию отладкой, поставив перед формой (defn ...) тег #dbg. Чтобы не смещать код вправо, расположите его отдельной строке выше:

#dbg
(defn get-joke [lang]
  ...)

Того же эффекта можно добиться командой cider-debug-defun-at-point; курсор может быть в любом месте формы. Команда ведет себя так, словно перед функцией (макросом, переменной) указан тег #dbg. Когда функция заряжена, запустите еще:

(get-joke "C#")

Вы окажетесь в отладке, но не в конце функции, где раньше стоял #break, а на этапе вычисления request. Обозначим комментарием вашу позицию:

(defn get-joke [lang]
  (let [request
        {:url "https://v2.jokeapi.dev/joke/Programming"
         :method :get
         :query-params {:contains lang} => "C#" ;; <
         :as :json}

Из локальных переменных доступна только lang. Нажмите n (next), и управление перейдет к отправке запроса:

response
(client/request request) => {:url "https:..." :method :get ...} ;; <

Справа от стрелки показано значение текущей формы, в нашем случае request. Продолжайте отладку нажатием n, и постепенно вы обойдете всю функцию. В теле (format ...) будет две точки останова: на месте setup и delivery. Обозначим их вертикальной чертой:

(format "%s %s" setup| delivery) => "Why do programmers wear glasses?"
(format "%s %s" setup delivery|) => "Because they need C#"

Перечислим другие команды навигации. Наиболее важные из них это in (ступить на уровень ниже) и out (подняться выше). Запустите отладку еще раз и дождитесь, пока курсор не окажется на форме (client/request ...). Нажмите i, чтобы управление перешло в функцию request из модуля clj-http.client. Вызвав in несколько раз, вы окажетесь на нижнем уровне HTTP-запроса — в функции request из clj-http.core. С помощью l (locals) исследуйте локальные переменные, доступные в этой области. Последующие команды o (out) постепенно вернут вас на уровень get-joke.

Отладку сложно передать словами, и мы советуем читателю закрепить ее практикой.

Заглянем в технические недра отладки. Тег #dbg устроен как множество точек остановки. Если предварить тегом форму, он расставит в ней столько точек, сколько это возможно. Каждая точка знает свой уровень вложенности, что важно для навигации. За счет этого можно пропустить точки текущего уровня, ступить ниже или выше.

Отладка работает в несколько этапов. Теги #break и #dbg помечают метаданные формы особым ключом. Убедимся в этом функцией meta:

(-> "#break (+ 1 2)"
    read-string
    meta)

#:cider.nrepl.middleware.util.instrument
{:breakfunction
 #'cider.nrepl.middleware.debug/breakpoint-with-initial-debug-bindings}

Далее форма попадает в функцию instrument-tagged-code, которая оснащает ее — внедряет код для взаимодействия с пользователем.

(cider.nrepl.middleware.util.instrument/instrument-tagged-code
 (read-string "#break (+ 1 2)"))

Результат для помеченной формы:

(#'cider.nrepl.middleware.debug/breakpoint-with-initial-debug-bindings
 (+ 1 2)
 {:coor []}
 (+ 1 2))

Вместо (+ 1 2) получили вызов макроса breakpoint-with-initial-debug-bindings с тремя аргументами. Это форма вычисления, состояние отладчика и первичная форма. В нашем случае первый и третий параметры одинаковы, но на практике бывает обратное: из-за раскрытия макросов форма вычисляется не так, как мы ее видим.

Состояние отладчика изначально пустое. В поле :coor хранятся координаты формы, необходимые для переходов.

Проверим, что станет с функцией при оснащении ее отладкой. Для этого вызовем instrument-tagged-code с формой, покрытой тегом #dbg:

(require
 '[cider.nrepl.middleware.util.instrument :refer [instrument-tagged-code]])

(instrument-tagged-code
 (read-string "#dbg (defn add [a b] (+ a b))"))

Результат получился объемный. Чтобы сократить его, заменим пространство cider.nrepl.middleware.debug на сочетание c.n.m.d:

(#'c.n.m.d/breakpoint-with-initial-debug-bindings
 (def
  add
  (fn*
   ([a b]
    (#'c.n.m.d/breakpoint-if-interesting
     (+
      (#'c.n.m.d/breakpoint-if-interesting
       a
       {:coor [3 1]}
       a)
      (#'c.n.m.d/breakpoint-if-interesting
       b
       {:coor [3 2]}
       b))
     {:coor [3]}
     (+ a b)))))
 {:coor []}
 (defn add [a b] (+ a b)))

Обратите внимание, что форма (defn ...) превратилась в комбинацию (def ...) и (fn* ...). В общем плане это явление называют раскрытием макросов. Раскрытие необходимо, чтобы расставить как можно больше точек останова. Третьим аргументом указана исходная форма (defn ...). Она нужна, чтобы сопоставить отладку с кодом как мы его видим в редакторе.

Расстановка точек останова учитывает формы, синтаксис которых нельзя нарушать. Например, в объявлении функции нетронуты ее название и вектор аргументов. В форме let предваряются только правые элементы (значения) и так далее.

Макрос breakpoint-if-interesting называется так потому, что не каждая форма нуждается в точке останова. В следующем разделе мы коротко рассмотрим, что именно не подлежит отладке.

Cider запоминает, какие функции оснащены отладкой. По команде M-x cider-browse-instrumented-defs вы увидите их список. Протоколы и типизированные записи тоже работают с отладкой, но в отличии от функций не видны в этом списке.

Ограничения

Чтобы пользоваться отладчиком эффективно, важно знать его особенности. Прежде всего, Cider не ставит точки останова перед литералами, которые выражаются в сами себя: числами, строками, кейвордами. Сравним отладку вектора с символами:

(instrument-tagged-code
 (read-string "#dbg [a b c]"))

[(#'.../breakpoint-if-interesting a {:coor [0]} a)
 (#'.../breakpoint-if-interesting b {:coor [1]} b)
 (#'.../breakpoint-if-interesting c {:coor [2]} c)]

и литералами:

(instrument-tagged-code
 (read-string "#dbg [1 \"hello\" :foobar]"))

[1 "hello" :foobar]

В первом случае точки оказались перед каждым элементом. Во втором вектор остался не тронут.

Множества не поддаются отладке. Аналогично ведут себя словари длиннее восьми элементов.

(instrument-tagged-code
 (read-string "#dbg #{a b c d e}"))
#{a e c b d}

Трудности могут возникнуть с рекурсией (формы loop и recur). Компилятор требует, чтобы recur была строго в конце loop, но при установке точек это правило нарушается. Например, вместо (recur (inc x)) образуется код:

(#'c.n.m.d/breakpoint-if-interesting
 (recur (inc x))
 {:coor [3 2]}
 (recur (inc x)))

Чтобы не вызвать ошибку компиляции, отладчик не ставит точку перед recur. В примере ниже тег #break не имеет эффекта:

(loop [x 0]
  #break
  (when (< x 10)
    (println x)
    (recur (inc x))))

Итог

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

Некоторые библиотеки предлагают свои отладчики. Коротко рассмотрим их.

Библиотека Bogus появилась по мере написания этой книги. Ее тег #bogus открывает окно Swing, где доступны локальные переменные и выполнение кода. Bogus можно считать минимальным графическим отладчиком для Clojure.

Библиотека scope-capture служит для работы с локальными переменными. Кроме просмотра и отладки, можно сохранить их в файл и позже воссоздать для участка кода. Scope-capture сочетается с nREPL при помощи отдельной библиотеки scope-capture-nrepl.

Еще один отладчик для Clojure называется clj-debugger. Он устроен как REPL, в котором доступны локальные переменные, выполнение кода и другие возможности.

Даже уделив отладчику Cider столько времени, мы не покрыли его целиком. В числе прочего мы не коснулись профилировщика (profiler) и трассировщика (tracing). Первый служит для поиска медленного кода. Профилировщик оборачивает функции в макрос, который собирает метрики и выводит сводную таблицу. В ней указано, сколько времени занял код в целом и функции по отдельности.

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

На этом мы закончим тему отладки и двинемся дальше: рассмотрим способы необычного подключения к nREPL.

nREPL в Docker

Чтобы запустить проект на Clojure, устанавливают Java SDK, утилиты lein, Clojure CLI, maven и другие. Они написаны на Java и работают на всех платформах, потому с окружением редко бывают проблемы. Если все-таки вы не можете что-то установить, остается запасной вариант — запустить проект в Docker.

Программу Docker мы упоминали в первой книге, поэтому не будем разбирать все сначала. Запуск проекта в контейнере сводится к шагам:

  1. Скачать образ с нужной версией Java, lein, deps.edn и прочими утилитами. Репозиторий Clojure на Docker Hub предлагает более сотни образов с различными SDK, утилитами и их версиями.

  2. При запуске образа смонтировать в него папку проекта и указать ее как рабочую (work dir).

  3. Сопоставить локальный порт nREPL с портом в Docker. В настройках nREPL явно указать порт.

  4. Запустить образ и подключиться к локальному порту из Emacs.

Пройдя эти шаги, вы получите работающий nREPL, при этом в системе не останется следов установки Java и прочего.

Подготовим проект к запуску в Docker. Откройте файл project.clj и добавьте профиль :docker с настройками как в примере ниже. Профиль нужен, чтобы не нарушить запуск проекта в обычном режиме.

:profiles
{:docker
 {:repl-options {:port 9911
                 :host "0.0.0.0"}
  :plugins [[cider/cider-nrepl "0.28.3"]]}}

Обратите внимание, что мы указали хост и порт явным образом. Если бы хост был 127.0.0.1 или localhost, к нему нельзя было бы подключиться извне сети Docker. То же самое относится к порту: он должен быть известен заранее, чтобы объявить его в списке открытых (exposed) портов.

Команда для запуска образа:

docker run -it --rm \
  -p 9911:9911 \
  -v `pwd`:/project \
  -w /project \
  clojure \
  lein with-profile +docker repl

Прокомментируем основные моменты:

(1) аргумент -p сопоставляет внутренний порт контейнера с портом операционной системы. Чтобы избежать путаницы, мы указали одинаковые значения. Позже мы рассмотрим случай, когда порты отличаются.

(2) Опция -v (volume) сопоставляет пути локальной машины и контейнера. Выше мы смонтировали папку с проектом на путь /project в контейнере. Docker требует абсолютный путь, поэтому нельзя указать его точкой (например, -v .:/project). Выражение pwd в обратных кавычках выполняет pwd в отдельном шелле и поставляет результат. В случае автора это следующий путь:

/Users/ivan/work/book-sessions/repl-chapter

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

(3) Параметр clojure означает имя образа. Он указан без тега, и по умолчанию будет использован тег latest. На момент написания книги latest включает в себя OpenJDK 17, Clojure 1.11.1 и lein 2.9.8. С выходом новых версий тег latest будет перезаписан. Изменится его отпечаток, что приведет к повторному скачиванию. Чтобы этого избежать, задайте образ явно, например clojure:openjdk-17-lein-2.9.6.

(4) Выражение lein with-profile +docker repl означает команду, которую выполнит контейнер после запуска. По умолчанию она равна lein run, но мы указали lein repl с профилем docker, в котором особые настройки nREPL.

Очевидно, набрать команду docker run в учетом всех аргументов трудно. Запишите ее в шелл-скрипт или добавьте цель в Makefile.

Когда контейнер запущен, перейдите в Emacs и откройте любой файл проекта. Подключитесь к nREPL следующим образом:

M-x cider-connect RET 127.0.0.1 RET 9911 RET

В папке проекта появится файл .nrepl-port. Он создан внутри контейнера, но из-за монтирования путей доступен снаружи. При подключении к nREPL сработает автодополнение: когда редактор запросит порт, нажмите TAB, и появится вариант с портом 9911.

Дальнейшие шаги аналогичны тем, что мы рассмотрели выше. Загрузите пространства командой cider-ns-refresh. Выполните несколько функций или тестов. Проверьте функцию get-joke — сработает HTTP-запрос к сервису v2.jokeapi.dev.

Когда проект запущен в Docker, вы заметите легкую задержку на каждое действие. В системах, отличных от Linux, задержка будет ощутимой. Причина в том, что контейнер запускается не в cgroups (встроенной возможности Linux), а в виртуальной машине, накладные расходы на которую выше. За абстракцию приходится платить ресурсами.

Запуск проекта в Docker — тот случай, когда команда cider-connect необходима. Вы как будто подключаетесь к удаленной машине, хоть она и запущена локально.

Docker особенно полезен для систем интеграции (Continuous Integration, CI). С его помощью прогоняют тесты и собирают проект. Если ставить на каждой машине Java и утилиты, это займет время. Иногда нужны разные версии SDK, чтобы проверить совместимость. Их совместная установка ведет к ошибкам путей и окружения, которые трудно расследовать. Docker сводит эти факторы на нет: достаточно сменить имя образа.

Теперь улучшим конфигурацию Docker по некоторым пунктам.

Зависимости. При запуске проекта lein скачает библиотеки из Clojars и Maven Central:

Retrieving cider/cider-nrepl/0.28.3/cider-nrepl-0.28.3.pom from clojars
Retrieving nrepl/nrepl/0.9.0/nrepl-0.9.0.pom from clojars
Retrieving cider/cider-nrepl/0.28.3/cider-nrepl-0.28.3.jar from clojars
Retrieving nrepl/nrepl/0.9.0/nrepl-0.9.0.jar from clojars
Retrieving clj-http/clj-http/3.9.1/clj-http-3.9.1.pom from clojars
...

Однако при следующем запуске загрузка начнется опять, что отнимает время и трафик. Хотелось бы, чтоб это был разовый шаг.

Причина в том, что по умолчанию зависимости оседают в папке контейнера /root/.m2. Поскольку контейнер не имеет состояния, при новом запуске папка окажется пуста, что вынудит lein скачать зависимости. Проблему решают двумя способами:

  1. Сопоставьте локальный путь Maven с папкой контейнера. Для этого добавьте в команду docker run... параметр -v ~/.m2:/root/.m2. Теперь зависимости, загруженные локально, будут видны в Docker и наоборот.

  2. Добавьте в профиль :docker опцию :local-repo, которая меняет стандартный путь Maven.

{:profiles
 {:docker {:local-repo ".docker/m2"}}}

При запуске docker run будет создана папка .docker/m2, куда Maven скачает jar-файлы. Во второй раз проект подхватит их без новой загрузки. Добавьте путь .docker/m2 в .gitignore, чтобы случайно не добавить зависимости в историю репозитория.

Локальный профиль. Выше мы несколько раз упоминали файл ~/.lein/profiles.clj, который хранит локальные профили. Мы поместили в него зависимость cider/cider-nrepl и другие служебные библиотеки. Хотелось бы, чтобы Docker подхватил этот файл. Добавьте сопоставление путей:

docker run ... -p ~/.lein/profiles.clj:/etc/leiningen/profiles.clj

Теперь плагин cider/cider-nrepl можно удалить из профиля :docker — он будет прочитан из файла profiles.clj. Утилита lein проверяет путь /etc/leiningen/ на наличие профилей и загружает их.

Следующее улучшение — сделать так, чтобы порт nREPL можно было задать произвольно. На текущий момент порт “захардкожен”, что не совсем удобно. Исправим это в несколько этапов. Во-первых, укажем, что порт приходит их переменной среды NREPL_PORT:

:repl-options
{:port ~(some-> "NREPL_PORT" System/getenv Integer/parseInt)
 :host "0.0.0.0"}

Если переменная не задана, форма (some-> ...) вернет nil, и будет выбран случайный порт. Объявим в терминале переменную с желаемым портом:

export NREPL_PORT=9955

Доработаем команду docker run: в сопоставлении портов заменим значение на переменную. Доллар перед переменной означает вернуть ее значение. Кроме портов (параметр -p), переменную нужно передать параметром -e, чтобы сделать ее доступной контейнеру.

docker run ... -p $NREPL_PORT:$NREPL_PORT -e NREPL_PORT=$NREPL_PORT ...

Подключитесь из редактора к порту 9955, и вы окажетесь в рабочем сеансе. По аналогии запустите контейнер с другим портом.

Еще один способ упростить работу с Docker — составить конфигурацию для Docker Compose. Эта программа, которая запускает контейнеры по описанию на языке YAML. С ней не нужно запоминать все параметры docker run. Создайте файл docker-compose.yaml:

version: '3.8'
services:
  nrepl:
    container_name: my_project
    image: clojure
    volumes:
      - .:/project
    ports:
      - $NREPL_PORT:$NREPL_PORT
    environment:
      NREPL_PORT: $NREPL_PORT
    working_dir: /project
    command: ["lein", "with-profile", "+docker", "repl", ":headless"]

Выполните команду ниже, после чего подключитесь к сеансу nREPL в Docker.

> docker-compose up

Обратите внимание на аргумент :headless команды lein repl. С ним nREPL запускается в “безголовом” режиме, когда ввода с клавиатуры нет, а обмен происходит только по сети. Если не добавить :headless, nREPL завершится с ошибкой, потому что стандартный источник ввода (stdin) будет недоступен.

Docker крайне полезен для разработки, прогона тестов и сборки проекта. В проектах, где участвуют базы данных, очереди задач и другие сервисы, работа без него практически невозможна.

nREPL в боевом режиме

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

Когда проект запущен локально при помощи lein repl, подключение работает как обычно. Однако если собрать и запустить jar-файл, в нем не будет сервера nREPL. Так происходит потому, что nREPL запускается силами lein. Считайте его вспомогательным средством, доступным только в разработке.

Когда на сервере случается ошибка, у новичка возникает шальная мысль: если бы можно было подключиться, я бы все исправил. И хотя это возможно технически, предостережем читателя от такого подхода.

Ошибки на сервере говорят о том, что код недостаточно покрыт тестами. Если код завершился аварийно, повторите ситуацию локально и добавьте тест. Чаще всего причина в том, что из источников приходят не те данные, что ожидали. Например, сервис jokeapi.dev изменил структуру ответа, и теперь нужные поля не подхватываются. Добавьте отладочный лог как в примере ниже:

(defn get-joke [lang]
  (let [request
        {...}

        response
        (client/request request)

        {:keys [body]}
        response

        _ (log/debugf "Data from jokeapi.dev: %s" body)

        {:keys [setup delivery]}
        body]
    (format "%s %s" setup delivery)))

Измените настройки так, чтобы логи с уровнем debug оседали в файле. Перезапустите проект. Спровоцируйте вызов get-joke, и вы узнаете, что пришло от сервиса. Исправьте код под новые данные, соберите uberjar и загрузите на сервер.

Если возникло исключение, соберите как можно больше данных о нем. Исключение — сложный объект: иногда одно исключение ссылается на второе, то — на третье и так далее. В каждом звене нас интересует его класс, сообщение и данные ex-info. В первой книге мы рассмотрели, как все это собрать куда передать.

Логирование, тесты и сбор ошибок полезней отладки на боевом сервере. За годы работы автор ни разу не подключался продакшену по nREPL — в этом не было нужды. Современные практики — тесты, логи, CI, деплой — в корне противоречат горячей перезагрузке кода — приему, когда приложение обновляют в обход стандартных процедур.

Возможно, читатель слышал романтические истории о программистах, которые устраняли катастрофы в другой части света. Например, инженеры NASA исправили код космического корабля, когда он пролетал мимо Юпитера. Это исключительный случай, и вряд ли в NASA были рады инциденту. Гораздо лучше отловить ошибку до запуска проекта.

Текст ниже предполагает, что автор либо не убедил читателя, либо не знает о случаях, когда nREPL на сервере необходим. Если это так, напишите автору письмо с описанием ситуации, и она займет место в книге.

Итак, чтобы nREPL стал частью приложения, выполните следующее. Сперва переместите пакет nrepl/nrepl из dev-зависимостей в основные:

  :dependencies
  [[org.clojure/clojure "1.10.1"]
   [nrepl/nrepl "0.9.0"]]

Добавьте модуль, который запускает сервер nREPL. В примере ниже функция start-server включает сервер и возвращает его объект; stop-server выключает его. Глобальная переменная server служит для хранения экземпляра.

(ns nrepl-prod.core
  (:gen-class)
  (:require
   [nrepl.server :refer [start-server stop-server]]))

(defonce server nil)

(defn nrepl-start! []
  (alter-var-root
   #'server
   (constantly
    (start-server :bind "0.0.0.0" :port 9911))))

(defn nrepl-stop! []
  (alter-var-root #'server stop-server))

(defn -main
  [& _]
  (nrepl-start!)
  (println "The nREPL server has been started"))

Скомпилируйте проект командой lein uberjar. Готовый jar-файл находится в папке target/uberjar, если не задано иное опцией :target-path.

Далее понадобится удаленная машина с доступом по SSH. Для краткости опустим настройку окружения: создание пользователя, sudo, SSH-ключи и прочее. Считаем, вы достигли этапа, когда команда ssh <IP> открывает сеанс bash на удаленной машине. При этом учетная запись отличается от root, ubuntu и прочих системных.

Установите виртуальную машину Java командами:

> sudo apt update
> sudo apt install default-jre

Проверьте установку:

> java -version
openjdk version "11.0.15" 2022-04-19

Загрузите jar-файл на сервер с локальной машины:

> scp target/uberjar/nrepl_prod-0.1.0-standalone.jar <IP>:/home/<user>/

Вернитесь к сеансу SSH. Перейдите в домашнюю папку пользователя и выполните:

> java -jar nrepl_prod-0.1.0-standalone.jar

Появится сообщение, что сервер nREPL запущен. Откройте Emacs и подключитесь к nREPL:

M-x cider-connect <RET> <IP> <RET> 9911 <RET>

Откроется сеанс nREPL на удаленной машине. Выполните выражение:

(.println System/out "hello")

Во вкладке терминала с SSH, где запущен jar-файл, появится “hello”. Проверьте переменную server:

(in-ns 'nrepl-prod.core)
server

;; #nrepl.server.Server{...addr=0.0.0.0, localport=9911...}

При помощи функции sh выполните системную команду на удаленной машине. Это может быть чтение каталога, удаление файлов, сбор информации о системе и много другое.

(require '[clojure.java.shell :refer [sh]])

Для начала выполним uname — утилиту, которая выводит сведения об операционной системе:

=> (:out (sh "uname" "-a"))

"Linux 5-63-153-107 5.4.0-117-generic #132-Ubuntu SMP Thu Jun 2 00:39:06 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux\n"

Прочитаем корневой каталог командой ls:

(println (:out (sh "ls" "-l" "/")))

total 60
lrwxrwxrwx   1 root root     7 Jun  9 01:11 bin -> usr/bin
drwxr-xr-x   3 root root  4096 Jun  9 01:18 boot
drwxr-xr-x  19 root root  3840 Jun  9 17:02 dev
drwxr-xr-x  84 root root  4096 Jun  9 17:13 etc
drwxr-xr-x   3 root root  4096 Jun  9 17:06 home
lrwxrwxrwx   1 root root     7 Jun  9 01:11 lib -> usr/lib
lrwxrwxrwx   1 root root     9 Jun  9 01:11 lib32 -> usr/lib32
lrwxrwxrwx   1 root root     9 Jun  9 01:11 lib64 -> usr/lib64
lrwxrwxrwx   1 root root    10 Jun  9 01:11 libx32 -> usr/libx32
drwx------   2 root root 16384 Jun  9 01:10 lost+found
drwxr-xr-x   3 root root  4096 Jun  9 01:10 media
drwxr-xr-x   2 root root  4096 Jun  9 01:12 mnt
drwxr-xr-x   2 root root  4096 Jun  9 01:12 opt
dr-xr-xr-x 140 root root     0 Jun  9 17:02 proc
drwx------   6 root root  4096 Jun  9 19:13 root
drwxr-xr-x  20 root root   600 Jun 11 11:14 run
lrwxrwxrwx   1 root root     8 Jun  9 01:11 sbin -> usr/sbin
drwxr-xr-x   2 root root  4096 Jun  9 01:12 srv
dr-xr-xr-x  13 root root     0 Jun  9 17:02 sys
drwxrwxrwt  13 root root  4096 Jun 11 10:56 tmp
drwxr-xr-x  13 root root  4096 Jun  9 01:12 usr
drwxr-xr-x  11 root root  4096 Jun  9 01:12 var

После экспериментов с sh завершите Java-процесс. Выполните (nrepl-stop!) в пространстве nrepl-prod.core, и сервер остановится. При этом не останется потоков, которые ожидает главный поток JVM, и процесс завершится.

Итак, подключение к удаленному nREPL прошло успешно. Отложив вопросы безопасности до следующего раздела, рассмотрим, что можно улучшить с технической точки зрения.

На текущий момент наш nREPL “голый”, то есть не оснащенный возможностями Cider. Должно быть, при подключении в Emacs вы видели строку:

WARNING: CIDER requires cider-nrepl to be fully functional.
Some features will not be available without it!

Из-за этого в Cider доступны только базовые команды вроде eval и lookup. Чтобы это исправить, добавьте в зависимости библиотеку cider/cider-nrepl:

  :dependencies
  [[org.clojure/clojure "1.10.1"]
   [nrepl/nrepl "0.9.0"]
   [cider/cider-nrepl "0.28.3"]]

Импортируйте ее в наш модуль:

(ns nrepl-prod.core
  (:gen-class)
  (:require [cider.nrepl]
            ...))

В функцию start-server передайте обработчик cider-nrepl-handler. Это обычный обработчик nREPL, “заряженный” многими middleware Cider:

(start-server :bind "0.0.0.0" :port 9911
              :handler cider.nrepl/cider-nrepl-handler)

При новом запуске вы получите доступ ко всем возможностям Cider, что мы рассмотрели: переходу к определениям, тестам, отладке и остальному. Почти все эти средства пригодны только для локальных нужд; на боевом сервере они принесут больше вреда, чем пользы.

nREPL в системе

Способ с alter-var-root, которым мы запускаем nREPL выше, оставляет желать лучшего. Это неуклюжее решение, пригодное только для демонстрации. В реальных проектах избегают глобального состояния. Объекты с семантикой “включить и выключить” оборачивают в компоненты, а управляет ими система.

Мы подробно рассмотрели системы в первой книге, поэтому не будем начинать все с нуля. Покажем только, как представить сервер nREPL в виде компонента. В качестве системы выберем библиотеку Component. Добавьте ее в зависимости:

​​[com.stuartsierra/component "0.4.0"]

Код компонента:

(defrecord nREPLServer
    [options
     server]

  component/Lifecycle

  (start [this]
    (let [options
          (update options :handler #(-> % resolve deref))

          arg-list
          (mapcat identity options)

          server
          (apply start-server arg-list)]

      (assoc this :server server)))

  (stop [this]
    (when server
      (stop-server server))
    (assoc this :server nil)))

Его конструктор и пример вызова:

(defn make-nrepl-server [options]
  (map->nREPLServer {:options options}))

(make-nrepl-server
 {:port 9911
  :handler 'cider.nrepl/cider-nrepl-handler})

Комментария заслуживает вызов (update ...), где поле :handler превращается в функцию комбинацией resolve и deref. Это нужно затем, что :handler содержит символ, указывающий на функцию:

;; config.edn
{:handler cider.nrepl/cider-nrepl-handler}

Почему бы не передать сразу функцию, а не символ? Дело в том, что конфигурацию часто хранят в *.edn-файле, в котором нельзя сослаться на объект Clojure. При чтении файла мы получим символ; далее компонент получит из него функцию.

В идеале компонент должен поддерживать как символ, так и функцию в поле :handler. Доработайте код, чтобы это требование выполнялось.

Выражение (mapcat ...) превращает словарь в плоский список ключей и значений:

=> {k1 v1 k2 v2 ...}
(k1 v1 k2 v2 ...)

Далее его передают в start-server при помощи apply. Это преобразование вызвано тем, что start-server принимает остаточные аргументы, а не словарь:

(start-server :port 9911 :host "..." :handler ...)

Опытный читатель заметит, что в последних версиях Clojure эта проблема решена: можно передать словарь в функцию start-server, и он преобразуется в список. Но чтобы код не зависел от версии Clojure, проделаем то же самое явно.

Приведем код запуска минимальной системы. Вынесем конфигурацию в файл resources/config.edn:

;; config.edn
{:nrepl {:bind "0.0.0.0"
         :port 9911
         :handler cider.nrepl/cider-nrepl-handler}}

Прочитаем его и построим систему:

(def system-config
  (-> "config.edn"
      io/resource
      slurp
      edn/read-string))

(def system-init
  (component/system-map
   :nrepl (make-nrepl-server (:nrepl system-config))))

Запустите систему в функции -main , и nREPL готов к подключению:

(defn -main
  [& _]
  (let [system-started
        (component/start system-init)]
    (println "The nREPL server has been started")))

В работе с компонентами проступает важное свойство: все задано конфигурацией. Если понадобится другой порт, измените настройки и перезагрузите приложение, не меняя кода. Тонкости конфигурации мы рассмотрели в первой книге.

Выше мы использовали библиотеку Component, однако это не ограничивает ваш выбор. Компонент nREPL легко изменить под Mount или Integrant. В качестве упражнения перепишите код из этого раздела под ту систему, что удобна вам.

Безопасность

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

Даже если вы никому не сказали, что на сервере запущен nREPL, это легко обнаружить. Существуют сканеры портов — программы, которые перебирают порты веб-сервисов (8080, 8888), баз данных (5432, 3306) и других служб на удаленной машине. Продвинутые сканеры определяют программу за тем или иным портом, посылая различные сообщения.

nREPL не предлагает проверки доступа по логину и паролю. И хотя ее легко написать (это будет лишнее middleware в стеке), будет правильно защитить nREPL другим способом — сетевыми настройками. Ниже мы рассмотрим два подхода: iptables и SSH-туннель.

Программа iptables задает правила обмена трафиком. Сделаем так, чтобы к nREPL можно было подключиться только с определенного IP (или диапазона), например из офиса. Пусть порт nREPL задан 9911, а внешний IP офиса — 178.210.54.129. Первое правило запрещает доступ к порту 9911 с любого IP:

sudo iptables -A INPUT -p tcp -s 0.0.0.0/0 --dport 9911 -j DROP

Второе правило в порядке исключения открывает доступ с адреса 178.210.54.129:

sudo iptables -A INPUT -p tcp -s 178.210.54.129 --dport 9911 -j ACCEPT

Введите эти правила на удаленной машине. После этого запустите проект на сервере командой java -jar ... и подключитесь с локальной машины. Если ваш IP совпадает с тем, что задан в правилах, подключение пройдет без ошибок. Сделайте то же самое с другим IP: включите VPN или раздайте интернет с телефона. В этом случае подключение не состоится.

Правила iptables действуют до перезагрузки операционной системы, и в следующий раз придется ввести их снова. Чтобы этого избежать, воспользуйтесь утилитой iptables-persistent. Установите ее командой:

sudo apt install iptables-persistent

После ввода правил выполните:

sudo netfilter-persistent save

, и после перезагрузки утилита восстановит их.

Подключение по SSH-туннелю безопасней и поэтому предпочтительней. На удаленной машине порт nREPL доступен только для локального подключения (с 127.0.0.1 или localhost). Утилита ssh устанавливает шифрованный туннель между локальной и удаленной машинами. Каждому концу туннеля назначается порт. Покажем это на схеме:

┌─────────────────────────────────────────────────────────┐
│ ┌───────────┐      ┌───────────────┐      ┌───────────┐ │
│ │  Laptop   │      │      SSH      │      │  Server   │ │
│ │           │◀────▶│               │◀────▶│           │ │
│ │port 19911 │      │    port 22    │      │ port 9911 │ │
│ └───────────┘      └───────────────┘      └───────────┘ │
└─────────────────────────────────────────────────────────┘

Трафик локальной машины, переданный в порт 19911, поступит по туннелю на порт удаленной машины 9911 и обратно. С точки зрения обеих машин это будут локальные подключения. Все вопросы безопасности — авторизацию, шифрование, отзыв ключа — берет на себя SSH.

Откройте конфигурацию проекта. Укажите, что сервер nREPL прослушивает только локальный хост:

;; config.edn
{:nrepl {:bind "127.0.0.1"
         :port 9911
         :handler cider.nrepl/cider-nrepl-handler}}

Скомпилируйте jar-файл, перенесите на сервер и запустите командой java -jar .... Откройте новую вкладку терминала и выполните:

ssh -N -L 19911:127.0.0.1:9911 <IP>

Разберем параметры этой команды:

  • -L — установить туннель;
  • 19911 — локальный порт вашей машины;
  • 9911 — локальный порт удаленной машины;
  • -N — не выполнять на сервере никаких действий, а просто ждать;
  • <IP> — адрес удаленной машины.

Команда запустит процесс ssh без ввода с клавиатуры. Туннель работает до тех пор, пока вы не нажмете Ctrl+C. Перейдите в Emacs и подключитесь к порту 19911:

M-x cider-connect <RET> 127.0.0.1 <RET> 19911 <RET>

Поскольку система не знает о туннеле, в папке проекта не будет файла .nrep-port. Автодополнение не сработает, и порт придется ввести вручную. Это легко исправить, создав файл вручную:

echo 19911 > .nrepl-port

После легкой задержки вы подключитесь удаленному nREPL, словно он запущен локально. Этот способ лучше прямого подключения, потому что SSH берет на себя вопросы безопасности. Например, легко отозвать доступ к nREPL у пользователя, удалив его ключ на сервере.

Приемы, что мы рассмотрели выше — iptables и SSH-туннель — справедливы для запуска на чистом Линуксе (без виртуализации и контейнеров). Если вы пользуетесь решениями вроде Kubernetes или Elastic Container Service, настройки будут другими. Конфигурация этих программ выходит за рамки главы.

Перед тем как закончить раздел, напомним читателю о спорной природе nREPL на сервере. Идите на этот шаг только если понимаете все риски.

REPL в других средах

До сих пор мы изучали REPL, который выполняет код в виртуальной машине Java (JVM). Это популярный, но не единственный вариант. Теперь мы рассмотрим схему, где вычисления берет на себя платформа JavaScript. Этот способ сложнее и хрупче, он задействует больше технологий и протоколов. В то же время он открывает новые возможности.

Язык JavaScript недаром называют платформой. В широком плане это набор стандартов и реализаций под разные устройства. JavaScript выполняют браузеры, мобильные устройства, одноплатные компьютеры. Были попытки сделать на нем операционную систему для телефонов и ноутбуков. Популярность JavaScript не нужно доказывать — он повсюду.

Со временем JavaScript стал платформой для программ, написанных на других языках. Пионером в этой области был CoffeeScript — легковесный язык, который сглаживал острые углы прародителя: сравнение, проверку на null/undefined, классы и так далее. Позже появились Elm и PureScript, вдохновленные строгостью Haskell. В последние годы набирает силу TypeScript — статически типизированный язык Microsoft.

Экосистема Clojure предлагает ClojureScript — компилятор языка, близкого к Clojure, в JavaScript. Мы не случайно написали “близкого”: хотя Clojure и ClojureScript похожи, между ними есть отличия, которые выступают в глубокой работе. ClojureScript опирается на компилятор Google, названный Closure Compiler. Обратите внимание разницу в написании: Closure не имеет отношения к Clojure.

Особенность компилятора в том, что при должных настройках он эффективно сжимает код и удаляет его неиспользуемые части. В Closure Compiler принята система пространств имен, близкая к Clojure, что облегчает интеграцию двух проектов.

Важно помнить, что ClojureScript — только компилятор, но не интерпретатор. Это вводит новичков в заблуждение: загрузив ClojureScript, они ищут исполняемый файл, который бы выполнил скрипт на Clojure. Такого файла нет, и неясно, что делать.

На самом деле ClojureScript только перестраивает код на Clojure в JavaScript. Рассмотрим простой модуль foo с функцией add:

(ns foo)

(defn add [a b]
  (+ a b))

После обработки его силами ClojureScript получим код на JavaScript:

// Compiled by ClojureScript 1.10.758 {}
goog.provide('foo');
goog.require('cljs.core');
foo.add = (function foo$add(a,b){
return (a + b);
});

Как именно его выполнить — в какой среде и настройками — остается на ваше усмотрение.

Мы подводим читателя к понятию рантайма — среде исполнения JavaScript. Недостаточно написать код на этом языке: необходима среда, в которой он запуститься. Ниже мы рассмотрим две популярных среды: браузер и движок node.js. Мы увидим разницу между ними: проделаем в каждой то, что невозможно в другой.

Покажем отличие в вычислениях между Clojure и ClojureScript. В первом случае код выполняется в JVM:

┌───────────────────────────────────────────────────┐
│  ┌──────────┐     ┌──────────┐      ┌──────────┐  │
│  │          │────▶│          │─────▶│          │  │
│  │   REPL   │     │ Clojure  │      │   JVM    │  │
│  │          │◀────│          │◀─────│          │  │
│  └──────────┘     └──────────┘      └──────────┘  │
└───────────────────────────────────────────────────┘

Для ClojureScript платформа JVM выступает в роли посредника. Ее задача — обеспечить связь между REPL и JavaScript. Связь работает поверх какого-то транспорта. Это может быть REST API, веб-сокеты, TCP-соединение или что-то другое.

┌────────────────────────────────────────────────────────────────────────────────┐
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐  │
│  │          │───▶│          │───▶│          │───▶│  REST    │───▶│          │  │
│  │   REPL   │    │ Clojure  │    │   JVM    │    │  ws      │    │JavaScript│  │
│  │          │◀───│          │◀───│          │◀───│  socket  │◀───│          │  │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘    └──────────┘  │
└────────────────────────────────────────────────────────────────────────────────┘

Рассмотрим, как собрать эту схему на практике. Для начала понадобится ClojureScript. Это обычная библиотека на Clojure, поэтому добавим ее в зависимости. Также сделаем уточнение: до текущего момента мы в основном пользовались lein. Чтобы не формировать у читателя предвзятость, проведем эксперименты в Clojure CLI.

Создайте файл deps.edn следующего содержания:

{:deps
 {org.clojure/clojurescript {:mvn/version "1.10.758"}}}

Запустите REPL командой clj (или clojure). Когда появится приглашение, введите код:

(require '[cljs.repl :as repl])
(require '[cljs.repl.node :as node])

(def env (node/repl-env))
(repl/repl env)

Пока мы не ушли дальше, разберемся, что происходит. Пространства имен ClojureScript начинаются с cljs, чтобы не было путаницы с Clojure. Функция repl из последней формы запускает внутренний REPL для ClojureScript. Она принимает обязательный аргумент — окружение, которое мы создали вызовом (node/repl-env).

Окружение отвечает за взаимодействие с платформой JavaScript. В техническом плане это объект, реализующий протокол IJavaScriptEnv с методами -setup, -evaluate и другими. Выше мы создали окружение для движка Node.js. Оно ищет программу node, установленную локально, и запускает ее. Процесс Node.js легко обнаружить командой:

ps aux | grep node

Как только процесс запущен, REPL готов принять команду. В терминале появится версия ClojureScript и приглашение:

ClojureScript 1.10.758
cljs.user=>

Введите (+ 1 2), чтобы убедиться — схема работает.

Исследуйте объект js/process — центральный элемент движка node.js. Он содержит информацию о системе, переменных среды и многое другое. Если обратиться к полю js/process.env, получим загадочный вывод:

=> js/process.env
#object[Object [object Object]]

Напишем функцию environment, которая вернет словарь Clojure, где ключи — кейворды, а значения — переменные среды. Вот что получилось у автора:

(defn environment []
  (persistent!
   (reduce
    (fn [result var-name]
      (assoc! result
              (keyword var-name)
              (aget js/process.env var-name)))
    (transient {})
    (js-keys js/process.env))))

Наберите эту функцию в редакторе и скопируйте в REPL, после чего обратитесь к ней:

(environment)
;; {:HOME "/Users/ivan", :USER "ivan", :TERM_PROGRAM_VERSION "3.3.12" ...}

Обратите внимание на функцию js-keys, доступную только в ClojureScript. Функция получает свойства JavaScript-объекта; префикс js- означает, что аргумент – значение JavaScript, например объект или массив.

Когда эксперименты с node.js закончены, перейдем к браузеру. Запуск браузера отличается только окружением. Подключите модуль cljs.repl.browser и выполните код:

(require '[cljs.repl.browser :as browser])
(def env (browser/repl-env))
(repl/repl env)

Откроется браузер, назначенный в системе по умолчанию. Адрес страницы будет http://localhost:9000 (порт и другие параметры можно задать в параметрах окружения). Вы увидите логотип ClojureScript и краткую справку. Вернитесь в терминал, где запущен REPL, и введите:

(js/alert "Hello REPL!")

Перейдите в браузер и проверьте, что появилось окно с приветствием. Исследуйте объект localStorage: установите значение по ключу и прочитайте его:

(js/window.localStorage.setItem "key-1" "val-1")
(js/window.localStorage.getItem "key-1")
;; "val-1"

Измените заголовок страницы. Для этого присвойте свойству document.title произвольную строку:

(set! js/window.document.title "New Title")

Убедитесь, что название вкладки в браузере поменялось.

Разберемся, как связаны между собой браузер и REPL. Откройте консоль разработчика и перейдите на закладку Network. Видно, что браузер посылает POST-запрос по адресу http://localhost:9000/. Эту технику называют long polling, (долгий опрос), потому что запрос длится до тех пор, пока вы не введете что-то в REPL и нажмете Enter.

Как только REPL получил выражение на Clojure, он компилирует его в ClojureScript и отправляет браузеру. Для ввода (+ 1 2) браузер получит следующий код:

{"repl":"main", "form":"(function () {
  try {
    return cljs.core.pr_str.call(null, (function () {
      var ret__6698__auto__ = ((1) + (2));
      (cljs.core._STAR_3 = cljs.core._STAR_2);
      (cljs.core._STAR_2 = cljs.core._STAR_1);
      (cljs.core._STAR_1 = ret__6698__auto__);
      return ret__6698__auto__;
    })());
  } catch (e617) {
    var e__6699__auto__ = e617;
    (cljs.core._STAR_e = e__6699__auto__);
    throw e__6699__auto__;
  }
})()"}

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

Выражение (+ 1 2) стало ((1) + (2)). Оно выполняется в анонимной функции без параметров. Форма cljs.core.pr_str.call означает функцию pr-str в Clojure, которая приводит объект к строке.

Переменные _STAR_ с номером на конце — это *1, *2 и *3 для хранения последних результатов. Код обернут в try/catch. В случае ошибки переменной *e (после компиляции — _STAR_e) назначается последнее исключение.

Полученный код выполняется в браузере обычным eval. На сервер уходит отчет о вычислении:

{:repl "main",
 :type :result,
 :content "{:status :success, :value \"3\"}",
 :order 3}

В зависимости от статуса терминал покажет результат или сведения об ошибке.

Запустить REPL в node.js или браузере можно и без кода. Для этого служат параметры командной строки:

clj -M -m cljs.main --repl-env node
clj -M -m cljs.main --repl-env browser

В обоих случаях вы получите готовый REPL в нужном окружении.

Поддержка Cider

До сих пор мы вводили команды в терминале, что непривычно после Emacs и Cider. Наверняка читателю интересно, как связать ClojureScript с редактором. Для этого служит библиотека с забавным названием Piggieback, что означает “нести на закорках”.

Piggieback служит мостом между nREPL и JavaScript. В техническом плане это middleware, которое передает сообщения от клиента к JavaScript и обратно. С Piggieback схема усложняется еще больше и выглядит так:

┌────────────────────────────────────────────────────────────────────────────────┐
│ ┌─────────────────┐   ┌─────────────────────────┐   ┌────────────────────────┐ │
│ │Client           │   │Server                   │   │JS environment          │ │
│ │                 │   │                         │   │                        │ │
│ │  ┌───────────┐  │   │  ┌───────────┐     ┌────┴───┴────┐     ┌───────────┐ │ │
│ │  │           │  │   │  │           │     │ Piggieback  │     │           │ │ │
│ │  │   Emacs   │  │   │  │  Clojure  │◀═══▶│             │◀═══▶│  Browser  │ │ │
│ │  │           │  │   │  │           │     │   HTTP/ws   │     │           │ │ │
│ │  └───────────┘  │   │  └───────────┘     └────┬───┬────┘     └───────────┘ │ │
│ │        ▲        │   │        ▲                │ ▲ │                        │ │
│ │        ║        │   │        ║                │ ║ │          ┌───────────┐ │ │
│ │        ║        │   │        ║                │ ║ │          │           │ │ │
│ │        ▼        │   │        ▼                │ ╚═╬═════════▶│  Node.js  │ │ │
│ │  ┌───────────┐  │   │  ┌───────────┐          │   │          │           │ │ │
│ │  │   CIDER   │  │   │  │   nREPL   │          │   │          └───────────┘ │ │
│ │  │  plugin   │◀═╬═══╬═▶│  server   │          │   │                        │ │
│ │  │           │  │   │  │           │          │   │                        │ │
│ │  └───────────┘  │   │  └───────────┘          │   │                        │ │
│ │                 │   │                         │   │                        │ │
│ │                 │   │                         │   │                        │ │
│ │                 │   │                         │   │                        │ │
│ └─────────────────┘   └─────────────────────────┘   └────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────┘

Чтобы подключиться к ClojureScript из редактора, задайте в файле deps.edn профиль :nrepl/piggieback. Профиль добавляет в стек middleware звено wrap-cljs-repl:

{:aliases
 {:nrepl/piggieback
  {:extra-deps
   {nrepl/nrepl {:mvn/version "0.8.3"}
    cider/piggieback {:mvn/version "0.5.3"}
    cider/cider-nrepl {:mvn/version "0.28.3"}}
   :main-opts
   ["-m" "nrepl.cmdline"
    "--bind" "localhost"
    "--middleware" "[cider.piggieback/wrap-cljs-repl,cider.nrepl/cider-middleware]"]}}
 :deps
 {org.clojure/clojurescript {:mvn/version "1.10.758"}}}

Запустите проект командой:

clj -M:nrepl/piggieback

На первый взгляд не произойдет ничего особенного: запустится обычный сеанс nREPL. Перейдите в Emacs и подключитесь командой cider-connect-cljs (клавиши C-c M-c). Обратите внимание, что это новая команда, которой мы еще не пользовались. Если все настроено без ошибок, Emacs запросит тип REPL: браузер, node.js, shadow-clj и другие, которых мы не касались в этой главе.

Выберите пункт “node” или “browser”. В зависимости от типа запустится процесс node.js или браузер. Выполните код командами cider-eval-... или в буфере *cider-repl* — он будет вычислен в окружении JavaScript, а не JVM. Проверьте объекты, доступные только в конкретной среде (js/window или js/process).

Вам по-прежнему доступен переход к определению (cider-find-var), загрузка буфера (cider-load-buffer), запуск тестов и многое то, что мы рассмотрели выше. Исключением станет отладка и профилирование: эти техники не работают в ClojureScript, поскольку полагаются на низкоуровневые детали.

Прочие сведения

REPL в ClojureScript слишком заманчив, чтобы так быстро расстаться с ним. Перечислим библиотеки из этой области, не погружаясь в них слишком глубоко. Раздел носит обзорный характер и направлен на то, чтобы разжечь в читателе интерес.

Проект Re-Natal служит для разработки приложений под iOS и Android на ClojureScript. Это обертка над фреймворком React Native фирмы Facebook. Сильно упрощая, React Native можно описать как процесс на Node.js, который управляет деревом компонентов. Мобильная платформа отображает это дерево нативными виджетами.

Re-Natal устанавливает сеанс REPL с процессом Node. Подключившись из редактора, вы получите полный контроль над устройством или эмулятором. Представьте, насколько удобно выполнять код на устройстве, не дожидаясь сборки приложения. Например, послать HTTP-запрос или получить снимок с камеры. Это упрощает разработку, позволяет проверить разные сценарии, в том числе негативные (проблемы связи, нет доступа к камере и другие).

Разработка на React Native противоречива: в ней есть как преимущества, так и недостатки. Предлагаем посмотреть доклад Андрея Мелихова “Как я полюбил и возненавидел React Native”. Автор взвешенно объясняет, почему Яндекс инвестировал усилия в React Native, но в итоге отказался от него. Впрочем, вы не обязаны следовать IT-гигантам. Если нужно простое приложение под обе платформы и вы знаете Clojure, рассмотрите Re-Natal: возможно, он сэкономит время.

Еще одна адаптация React Native называется Krell. Библиотека компилирует код на ClojureScript и загружает в устройство. Как и Re-Natal, Krell запускает REPL с полным доступом к среде исполнения.

Если мы заговорили об устройствах, нельзя обойти стороной Espruino — микроконтроллер стоимостью около 20 долларов. От Arduino и аналогов он отличается тем, что код под него пишут не на C/C++, а на JavaScript. В силу ограничений Espruino покрывает не все возможности JavaScript, но основную их часть. Технические детали вы найдете на странице проекта.

Российская фирма Амперка выпускает набор Йодо для обучения детей программированию. В набор входят плата Espruino, датчики, провода и брошюра с проектами. В Амперке отлично адаптировали Espruino для детей. К каждому датчику прилагается модуль с удобными функциями и документация на русском языке. Модули загружаются с npm-сервера Амперки и оперативно обновляются. Работает форум и команда поддержки.

Запуск ClojureScript на устройстве со множеством датчиков — крайне интересное занятие. Можно сделать игрушку, будильник, телеграф или подобие умного дома. Более амбициозный проект — инкубатор яиц, где влажность и температура выводятся на дисплей, а при отклонении от нормы включаются звуковые сигналы.

Сказанное выше относится к устройствам, в сердце которых лежит Node.js. Еще больше разнообразия ждет вас в разработке под браузер.

Проект Shadow CLJS можно описать как улучшенный компилятор ClojureScript. Он предлагает ускоренную компиляцию, когда собирается не весь проект, а только измененные файлы. Shadow CLJS поддерживает кэш компиляции, живую перезагрузку кода в браузере, удобный REPL, сборку под разные платформы (браузер, node, расширение Chrome) и многое другое.

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

Weasel — еще один REPL для браузера. Для транспорта он использует веб-сокеты, а не долгие POST-запросы, как это делает обычный браузерный REPL. Weasel работает в паре с Piggieback, и его настройка аналогична шагам, что мы рассмотрели.

Lumo — мощный инструмент для разработки на ClojureScript. Одно из главных его преимуществ — REPL, скомпилированный под Node.js. Чтобы запустить его, не нужно устанавливать JVM и компилятор ClojureScript. Достаточно скачать пакет lumo-cljs из npm и вызвать одну функцию.

Другая эксклюзивная черта Lumo — компиляция силами JavaScript без участия Java. Это возможно при помощи клона Closure Compiler, переписанного на JavaScript. К сожалению, оба репозитория сданы в архив и, скорее всего, не будут развиваться.

REBL

Наш обзор замыкает проект REBL — графический REPL фирмы Cognitect. Буква B в названии означает browse — обозревать, что указывает на богатые возможности REBL в работе с данными.

REBL берет начало от базы данных Datomic, точнее ее графической консоли. Со временем консоль вынесли в отдельную библиотеку, а веб-интерфейс заменили на JavaFx — так и получился REBL. Он нацелен на общую работу с данными, а не только Datomic.

Особенность REBL в том, что его код закрыт. Это слегка непривычно в мире Clojure, где преобладает открытый код. По той же причине усложнена его установка: чтобы получить REBL, нужно заполнить форму на сайте Cognitect, после чего на почту придет ссылка на архив. Распакуйте его и запустите скрипт install. Он скопирует jar-файлы в локальную папку Maven (по умолчанию ~/.m2).

Приведем минимальный deps.edn для запуска REBL. Основную его часть занимают зависимости JavaFx:

{:aliases
 {:rebl
  {:extra-deps {com.cognitect/rebl          {:mvn/version "0.9.245"}
                org.openjfx/javafx-fxml     {:mvn/version "15-ea+6"}
                org.openjfx/javafx-controls {:mvn/version "15-ea+6"}
                org.openjfx/javafx-swing    {:mvn/version "15-ea+6"}
                org.openjfx/javafx-base     {:mvn/version "15-ea+6"}
                org.openjfx/javafx-web      {:mvn/version "15-ea+6"}}
   :main-opts ["-m" "cognitect.rebl"]}}}

Выполните clj -M:rebl и дождитесь загрузки артефактов. Появится окно, разбитое на несколько частей: ввод кода, просмотр результата и другие. Область ввода напоминает простой редактор: в ней работает подсветка синтаксиса и балансировка скобок.

Кроме ввода с клавиатуры, REBL поддерживает интеграцию с nREPL и Cider, горячие клавиши, просмотр метаданных. Эти и другие возможности перечислены на странице Cognitect. В одноименном видео Стюарт Хэллоуэй (Stuart Halloway), основатель фирмы, вживую показывает, как пользоваться REBL.

На этом мы закончим обзор библиотек и подведем итоги главы.

Заключение

Аббревиатура REPL означает Read, Eval, Print, Loop — прочитать, выполнить, напечатать, повторить. В широком плане это режим программы, когда введенный код сразу выполняется. С помощью REPL программист проверяет код по мере его написания. Так он раньше поймет, какие данные приводят к ошибкам и куда двигаться дальше.

REPL — один из столпов в языках семейства Lisp. Первые Лисп-машины работали в режиме приглашения, и это правило сохранилось до наших дней. От любой Лисп-системы ожидают интерактивный режим. Современные языки тоже предлагают интерактивные сеансы (python, irb), но их возможности крайне скудны.

Первые машины читали ввод с клавиатуры, но со временем стало ясно: с REPL удобней работать по сети. Появились сетевые версии REPL со своими протоколами. В мире Common Lisp это проекты Slime и Swank, в Clojure — nREPL. В отличие от клавиатурного REPL, сетевая версия поддерживает несколько клиентов одновременно. Сервер отвечает на сообщения асинхронно; на один запрос клиент может получить несколько сообщений.

Чтобы подключиться к REPL из редактора, нужен специальный модуль (плагин, расширение). Наиболее продвинутый клиент для nREPL называется Cider – модуль для Emacs. В свою очередь Emacs — один из самых старых редакторов. Порог входа в него выше, чем у современных VS Code или Sublime Text, но и возможностей, накопленных за сорок лет развития, гораздо больше.

Сетевой REPL полезен в виртуальном окружении, например Docker. В редких случаях его оставляют в промышленном запуске, однако это небезопасно и требует особых настроек.

nREPL расширяют при помощи middleware — промежуточных обработчиков, похожих на аналоги из пакета Ring. Библиотека cider-nrepl добавляет в протокол поддержку тестов, навигацию по коду, отладку, трассировку, словом, все то, что предлагают современные IDE.

ClojureScript — это язык, похожий на Clojure, который компилируется в JavaScript. В работе с ним REPL важен в той же мере, что и с Clojure. Выражения выполняются в среде JavaScript, роль которой играет браузер, движок Node.js или устройство (телефон, одноплатный компьютер).

Из-за разнообразия платформ JavaScript создано множество REPL-ов к нему. Все они предлагают те или иные преимущества при работе с проектом. С помощью библиотеки Piggieback легко связать ClojureScript с редактором. С ней код на устройстве можно выполнить прямо из Emacs, что упрощает работу.

Опытные программисты знают, что REPL — не просто приятная возможность языка. Это иной процесс разработки: он кардинально отличается от привычной модели, когда код сперва пишут, а потом запускают. В REPL написание кода и запуск чередуются мелкими итерациями. Так мы исследуем код задолго до запуска в бою.

Если вы интересуетесь языками семейства Lisp, потратьте время на настройку редактора и окружения. Эти затраты окупаются: с REPL ваша производительность возрастет многократно. Когда вы овладели REPL, разработка на других языках без него покажется немыслимой.