Все части

Оглавление

Отладка в 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 и выполните cider-eval-defun-at-point. Чтобы не смещать код вправо, поместите тег на отдельной строке выше:

#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 хранятся координаты формы, которые служат для навигации.

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

(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 ...), чтобы сопоставить код в редакторе и его исполнение.

Алгоритм расстановки учитывает формы, синтаксис которых нельзя нарушать. Например, в объявлении функции нетронуты ее название (add) и вектор аргументов ([a b]). В форме 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 не является последней формой loop. Отладчик знает об этом случае и поэтому не ставит перед ним точку. В примере ниже тег #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 SDK, 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. Важно понимать разницу в монтировании и копировании файлов. В первом случае файлы остаются на локальной машине, а контейнер получает к ним доступ. Изменения с файлами, проделанные в контейнере, видны локальной системе и наоборот. Например, если код в контейнере создает файлы, вы увидите их локально. Если же копировать файлы в контейнер, они будут жить отдельно от оригиналов, что помешает разработке.

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

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

Очевидно, набрать эту команду без ошибок трудно. Запишите ее в шелл-скрипт или добавьте цель в 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). С его помощью прогоняют тесты и собирают проект. Если ставить на каждой машине JDK и прочие утилиты, это займет время. Порой требуются разные версии пакетов, чтобы проверить совместимость. Их совместная установка ведет к ошибкам путей и окружения, которые трудно расследовать. Docker сводит эти факторы на нет: достаточно указать нужный контейнер.

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

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

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 — он будет прочитан из профиля :user. Утилита 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

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

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

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

Еще один способ упростить работу с Docker — составить конфигурацию для Docker Compose. Утилита идет в поставке Docker и служит, чтобы описывать сложные настройки на языке 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"]

Выполните команду:

> docker-compose up

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

Обратите внимание на аргумент :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 и загрузите на сервер.

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

Логирование, тесты и сбор ошибок полезней отладки на боевом сервере. За годы работы автор ни разу не подключался продакшену по 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 и выполните:

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{:server-socket #object[java.net.ServerSocket 0x7f9150c7 "ServerSocket[addr=/0.0.0.0,localport=9911]"], :port 9911, :open-transports #object[clojure.lang.Atom 0x52a2dbd4 {:status :ready, :val #{#object[nrepl.transport.FnTransport 0x1c7ed088 "nrepl.transport.FnTransport@1c7ed088"]}}], :transport #object[nrepl.transport$bencode 0x1b1ddbf4 "nrepl.transport$bencode@1b1ddbf4"], :greeting nil, :handler #object[nrepl.server$default_handler$fn__1634 0x39fba4ac "nrepl.server$default_handler$fn__1634@39fba4ac"]}

При помощи функции 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!

Из-за этого доступны только базовые команды вроде 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:

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

При новом запуске nREPL вы получите доступ ко всем возможностям 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 содержит символ, указывающий на функцию:

{:handler '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:

{: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")))

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

Выше мы использовали библиотеку 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 прослушивает только локальный хост:

{: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))

После компиляции получим следующий код на 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/ws/ │───▶│          │  │
│  │   REPL   │    │ Clojure  │    │   JVM    │    │  socket  │    │JavaScript│  │
│  │          │◀───│          │◀───│          │◀───│          │◀───│          │  │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘    └──────────┘  │
└────────────────────────────────────────────────────────────────────────────────┘

Рассмотрим, как собрать эту схему на практике. Для начала понадобится 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).

Окружение отвечает за взаимодействие REPL с платформой 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" ...}

Обратите внимание на функции aget и js-keys — они доступны только в среде ClojureScript. Первая получает элемент массива по индексу, а вторая — свойства JavaScript-объекта. Массивы и объекты отличаются от вектора и словаря в Clojure, поэтому для работы с ними служат отдельные функции.

ClojureScript предлагает тег #js, чтобы описать JavaScript-объект синтаксисом Clojure. Например, запись:

#js {:foo [1 2 3]}

при компиляции станет объектом, внутри которого массив:

{"foo": [1, 2, 3]}

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

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

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

Вернитесь в терминал, где запущен 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 и другие, которых мы не касались в этой главе.

Выберите пункт browser или node. В зависимости от типа запустится процесс node или браузер. Выполните код командами 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 — промежуточных обработчиков, похожих на middleware из пакета Ring. Библиотека cider-nrepl добавляет в протокол поддержку тестов, навигацию по коду, отладку, трассировку, словом, все то, что предлагают современные IDE.

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

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

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

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