Все части

Оглавление

REPL в редакторе

До сих пор мы набирали код в терминале, что не совсем удобно. Терминал подходит для коротких команд, но плохо справляется с многострочным кодом. Будет правильно набрать код в редакторе, а затем скопировать в терминал. Заодно сохраните код в файл, чтобы не набирать его в следующий раз.

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

Описанный способ предлагает Emacs — текстовый редактор с историей более сорока лет. Emacs может запустить любой Лисп, будь то Common Lisp, Scheme или Clojure, и управлять им из редактора. В терминах Emacs запущенный Лисп называется внешним (external) в противоположность встроенному диалекту ELisp. Режим, когда код вычисляется внешним Лиспом, назывыется inferior lisp mode (анг. inferior — низший). Название объясняется тем, что, поскольку режим нацелен на любой Лисп, он поддерживает только базовые операции.

Проведем короткий сеанс REPL из Emacs. Запустите редактор и выполните команду:

M-x inferior-lisp

Emacs запросит путь к программе, которая запускает Лисп. Введите clojure или lein repl в зависимости от того, какая утилита у вас установлена. Чтобы не указывать программу каждый раз, объявите в настройках переменную inferior-lisp-program. Того же эффекта можно добиться, выполнив одно из выражений в буфере *scratch*:

(setq inferior-lisp-program "clojure") ;; C-j
(setq inferior-lisp-program "lein repl") ;; C-j

Emacs запустит процесс соединится с его каналами ввода и вывода. В буфере inferior-lisp появится сеанс REPL. Он работает как в терминале: ожидает выражение, вычисляет, печатает и снова ожидает.

Clojure 1.10.1
user=> (+ 1 2 3)
6

Но поскольку это не терминал, а буфер Emacs, нам доступно больше возможностей. Можно свободно перемещаться по всему буферу, копировать и вставлять код, искать в прямом и обратном направлении, сохранить буфер в файл и многое другое.

Особые команды передают код из редактора в REPL без ручного копирования. Переключитесь в буфер с кодом на Clojure и включите режим Lisp командой:

M-x lisp-mode

Установите курсор после закрывающей скобки любого выражения, например (+ 1 2). Выполните команду

M-x lisp-eval-last-sexp

, которая означает выполнить последнее S-выражение. В буфере *inferior-lisp* появится результат:

user=> 3

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

(let [a 1 b 2]
  (println "inner form") |
  (+ a b))

Подведите курсор за выражение ...form") на место вертикальной черты. Выполните lisp-eval-last-sexp, и REPL вычислит форму (println "inner form"). Таким образом можно выполнить форму в любом месте кода, если она не зависит от внешних переменных.

Команды с приставкой lisp-eval-... отвечают за то, какую часть файла выполнить в REPL. Например, lisp-eval-region отправит только выделенную область, а lisp-eval-defun — функцию, на которой сейчас установлен курсор. Команды с окончанием ...-and-go делают то же самое, но дополнительно переключат вас в REPL.

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

Обратите внимание, что команды lisp-eval-... вычисляют код без учета текущего пространства. Контроль за тем, в каком пространстве пребывает REPL, ложится на вас. Если вы работаете с двумя и более модулями, это станет проблемой. Легко объявить функцию в одном пространстве:

(ns test1)

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

и вызвать в другом, что приведет к ошибке:

(ns test2)

(add 1 2)

test2=> Syntax error compiling at (REPL:1:1).
Unable to resolve symbol: add in this context

Эта проблема решена в более продвинутых системах. О них мы поговорим уже скоро.

Запуск REPL из Emacs может показаться примитивным подходом, но на самом деле это не так. Вам доступны все возможности Clojure и Emacs одновременно. Для эффективной работы с Clojure требуется не так уж много команд: выполнить s-выражение, регион или def-определение.

По словам Рича Хикки, автора Clojure, он работал над языком, используя Emacs и режим inferior-lisp. Это подтверждает: можно достичь значимых результатов малыми средствами. И хотя сегодня для Clojure созданы более мощные инструменты, полезно знать этот спартанский метод.

Недостатки

Способ, когда Emacs запускает внешний Лисп, не лишен недостатков. Перечислим наиболее важные их них.

Обмен данными между средами происходит по стандартным каналам операционной системы (stdin, stdout и stderr). Скорость их передачи ниже, чем по сети, что особенно заметно на больших файлах.

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

Данные, что передают друг другу Emacs и REPL, никак не структурированы — это текст. Из-за этого Emacs выводит ответы как есть без каких-либо улучшений. Это сделано намеренно, поскольку режим inferior-lisp рассчитан на любой REPL, будь то Common Lisp, Racket или Clojure.

Ради справедливости заметим, что в Clojure REPL поддерживает сетевой режим. Чтобы его включить, передайте следующие аргументы:

clojure -J-Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}"

Теперь REPL принимает данные не только с клавиатуры, но и с порта 5555. Чтобы это проверить, подключимся к локальному серверу через telnet:

> telnet 127.0.0.1 5555

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
user=> (defn add [a b]
         (+ a b))
#'user/add

В результате telnet работает как обычный REPL; разница в том, что данные передаются по сети. Подключитесь по telnet с другой машины, на которой нет Clojure. Введите несколько выражений: сумму чисел, объявление def и другие. Перейдите к машине, где запущен REPL и убедитесь, что изменения, проделанные удаленно, вступили в силу.

Хотя сетевой режим снимает одну из проблем, озвученных выше, особой популярности он не получил. Данные по-прежнему передаются плоским текстом, что мешает эффективному обмену. Со временем появился проект nREPL, который закрывает этот недостаток, а также многие другие.

Знакомство с nREPL

В названии nREPL буква n означает network, то есть сетевой REPL. Проект нацелен на то, чтобы обеспечить работу в REPL по сети. В отличие от терминала, nREPL обладает более сложной архитектурой; перечислим ее главные свойства.

Сервер nREPL принимает команды по сетевому протоколу TCP. С одним проектом могут работать несколько клиентов. Сервер может быть запущен на удаленной машине или в изолированном окружении (Docker, VirtualBox).

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

nREPL опирается на транспорт сообщений. Транспортом называют соглашение о том, как писать и читать сообщения. По умолчанию nREPL предлагает транспорты Bencode, EDN и TTY. Создать новый транспорт означает расширить протокол из библиотеки.

Обычный REPL работает синхронно: получив команду с клавиатуры, он не принимает текст до тех пор, пока не вычислит и напечатает результат. nREPL устроен асинхронно: в ответ на одну операцию он может прислать несколько сообщений. По специальному полю клиент определяет, последнее это сообщение или нет.

Технически nREPL — библиотека, доступная в Clojars. У нее нет зависимостей, что упрощает развитие и поддержку. Несмотря на свою роль в экосистеме Clojure, nREPL остается отдельным, а не встроенным модулем. С таким подходом он не зависит от цикла версий Clojure.

Запуск nREPL

Чтобы запустить сеанс nREPL вместо обычного REPL, добавьте библиотеку в проект. Если вы пользуетесь lein, откройте файл project.clj и расширьте зависимости:

{...
 :dependencies
 [... [nrepl/nrepl "0.9.0"]]}

Сохраните файл и выполните lein repl. Утилита lein устроена так, что если nREPL найден в зависимостях, предпочтение отдается ему. Убедиться, что вы запустили именно nREPL можно по фразе “nREPL server started”, которая появится в терминале:

> lein repl

nREPL server started on port 52002 on host 127.0.0.1 - nrepl://127.0.0.1:52002
REPL-y 0.4.4, nREPL 0.8.3
Clojure 1.10.1
OpenJDK 64-Bit Server VM 11.0.12+6-jvmci-21.2-b08
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

user=>

В случае с deps.edn укажите профиль :nrepl:

{:aliases
 {:nrepl
  {:extra-deps
   {nrepl/nrepl {:mvn/version "0.9.0"}}
   :main-opts ["-m" "nrepl.cmdline" "-i"]}}}

Ключ -i в :main-opts означает интерактивный режим, то есть с вводом с клавиатуры. Без него nREPL работает в “безголовом” режиме, слушая только сетевой порт. Запустите утилиту clj с профилем :nrepl:

> clj -M:nrepl

nREPL server started on port 55113 on host localhost - nrepl://localhost:55113
nREPL 0.9.0
Clojure 1.11.1
OpenJDK 64-Bit Server VM 11.0.12+6-jvmci-21.2-b08
Interrupt: Control+C
Exit:      Control+D or (exit) or (quit)
user=>

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

После запуска nREPL вы обнаружите файл .nrepl-port в папке проекта. Если не указать порт явно, nREPL случайно выберет свободный порт для подключения. Дополнительно он запишет порт в файл, чтобы клиент прочитал его, не запрашивая у пользователя.

Выше мы указали nREPL в главных зависимостях проекта — векторе :dependencies формы defproject. Поскольку nREPL относится к разработке, поместим его в профиль :dev:

:profiles
{:dev {:dependencies [[nrepl/nrepl "0.9.0"]]}

В этом случае nREPL доступен при запуске lein repl, потому что профиль :dev подключается по умолчанию. При сборке проекта его не окажется в зависимостях. Это легко проверить, вызвав команду deps :tree с профилем uberjar:

> lein with-profile uberjar deps :tree | grep nrepl
;; nothing

Быстро окажется, что nREPL нужен во всех проектах.Чтобы не добавлять его в каждый project.clj, прибегают к пользовательскому профилю. Создайте файл ~/.lein/profiles.clj со словарем внутри. В поле :user разместите вложенный словарь с зависимостями. Утилита lein объединит его с полем :profiles при запуске.

{:user {:dependencies [[nrepl/nrepl "0.9.0"]]}}

Теперь по команде lein repl запусится nREPL, неважно указан ли он в project.clj или нет. Это особенно полезно, когда с проектом работают несколько человек и их редакторы требуют разные версии nREPL (например, Cider и Calva). Каждый укажет свою версию в файле ~/.lein/profiles.clj, избежав конфликта.

Если вы используете Clojure CLI, похожий файл называется ~/.clojure/deps.edn. При запуске clj или clojure он дополняет текущий файл deps.edn. Перенесите профиль :nrepl, что мы создали выше, в этот файл. Чтобы подчеркнуть, что это локальный профиль, добавьте ему пространство local:

{:aliases
 {:local/nrepl
  {:extra-deps {nrepl/nrepl {:mvn/version "0.9.0"}}
   :main-opts ["-m" "nrepl.cmdline"]}}}

Включите проект командой:

> clj -M:local/nrepl

Поведение nREPL меняют с помощью параметров проекта. В lein для этого служит ключ :repl-options. Перечислим опции, которые понадобятся чаще других.

  • :port — сетевой порт, по которому nREPL принимает сообщения от клиентов. Если не задан, будет случайно выбран любой свободный порт. В редких случаях порт указывают явно, например когда nREPL запущен в Docker или на удаленной машине — эти случаи мы рассмотрим в конце главы).

  • :prompt — функция приглашения. Принимает один аргумент — текущее пространство имен — и по умолчанию выводит его имя.

  • :init-ns — символ пространства, которое nREPL загрузит при запуске. В разработке указывают пространства dev, user или sandbox — своего рода песочницу, в которой собраны служебные функции. Например, запуск системы, прогон миграций, ping-запросы к серверам и многое другое.

Конфигурация nREPL с нестандартными параметрами:

{...
  :repl-options {:port 9911
                 :init-ns dev
                 :prompt (fn [current-ns]
                           (format "[%s] >> " current-ns))}}

Вот как с ними выглядит приглашение:

[dev] >> (+ 1 2 3)
6

Остальные параметры вы найдете в документации проектов lein и nREPL.

Внутреннее устройство

Архитектура nREPL состоит из нескольких частей: обработчика запроса, middleware и транспорта. В этом плане nREPL напоминает Ring: большую роль в нем играет спецификация — соглашение о том, как устроена та или иная часть. При этом код относительно мал в сравнении со смысловой нагрузкой.

Коротко опишем каждый уровень nREPL. Обработчик (handler) — это функция одного аргумента, которая принимает словарь сообщения. В nREPL сообщения структурированы, то есть разбиты на поля. По полю :op (operation) функция понимает, что от нее требуется, и выполняет действие. Вот как выглядит сообщение с командой выполнить код:

{:op "eval" :code "(+ 1 2 3)"}

и ответ на него:

{:id "..." :session "..." :value 6}

Мы привели сообщения в формате EDN для читаемости. На уровне транспорта они кодируются и выглядят иначе. На месте многоточий должны быть длинные идентификаторы, которые мы сократили за ненадобностью.

Хотя nREPL сравнивают с Ring, обработчик в nREPL устроен сложнее. В Ring обработчик возвращает словарь ответа. Некоторые обработчики бывают чистыми функциями, то есть такими, чей результат зависит только от входных данных, например сумма параметров a и b. Логично ожидать, что обработчик nREPL возвращает словарь, который будет передан в транспорт, но практике это не так.

Сообщение, переданное обработчику, содержит поле :transport с текущим объектом транспорта. Чтобы отправить ответ клиенту, нужно вызвать метод send транспорта со словарем ответа:

(defn handler [{:as message
                :keys [transport op code]}]
  (let [value (eval ...)]
    (t/send transport {:value value})))

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

Напомним, что на одно сообщение nREPL может вернуть несколько ответов. Передача может быть асинхронной и с задержками между ответами. Простейший случай, когда ответов несколько — вычисление двух форм за раз. Для этого выделим в редакторе две формы и выполним M-x cider-eval-region:

(+ 1 2 3)
(+ 1 2 3 4)

На сервер уйдет сообщение:

{:id "..." :op "eval" :code "(+ 1 2 3)(+ 1 2 3 4)"}

В ответ получим три сообщения: по одному на каждую форму плюс завершающее, которое означает, что задача окончена:

{:id "..." :session "..." :value "6"}
{:id "..." :session "..." :value "10"}
{:id "..." :session "..." :status ["done"]}

У всех трех сообщений одинаковый ID, чтобы понять, к какому запросу они относятся.

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

Транспорт

В терминах nREPL транспорт — это соглашение о том, в каком виде передавать сообщения. Чтобы клиент и сервер понимали друг друга, они должны использовать одинаковый транспорт. На уровне кода это объект, реализующий протокол nrepl.transport.Transport. В него входят методы recv и send, которые отвечают за прием и отправку сообщений.

nREPL предлагает три транспорта: Bencode, EDN и TTY. Мы перечислили их по убыванию важности. Большинство клиентов используют Bencode, поэтому он задан по умолчанию, если не указан иной транспорт. Bencode опирается на одноименный формат данных, популярный в сети BitTorrent. Мы рассмотрим его чуть позже.

Транспорт EDN передает данные в формате, принятом в Clojure. Его используют в ClojureScript, поскольку там возможностей Bencode не хватает, чтобы покрыть все типы данных. Транспорт TTY предназначен для подключения из терминала. Это наиболее скудный формат, которым пользуются в крайних случаях.

Middleware

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

Предположим, мы бы хотели, чтобы команда “classpath” вернула текущий список путей JVM. Для этого напишем middleware с логикой: если поле :op сообщения равно “classpath”, отправить сообщение со списком строк. В противном случае вызвать обработчик по умолчанию:

(defn wrap-classpath [handler]
  (fn [{:as msg :keys [op transport]}]
    (if (= "classpath" op)
      (let [paths (get-classpath ...)]
        (t/send transport {... :classpath paths}))
      (handler msg))))

Как и в Ring, цепочка middleware образует стек. В примере выше переменная handler не обязательно конечный обработчик nREPL. Скорей всего, он многократно обернут другими middleware, что идут ниже по стеку.

Проект Cider, о который мы скоро рассмотрим, предлагает множество подобных middleware. Вместе они радикально расширяют возможности nREPL.

Подключение из Clojure

Опробуем nREPL на практике: подключимся к серверу и выполним несколько выражений. Пока что мы не знаем, как подключиться из редактора, поэтому воспользуемся встроенным клиентом на Clojure. У нас будет два сеанса nREPL: первый в роли сервера, второй в качестве клиента.

Запустите оба сеанса командой lein repl или clj -M:nrepl. Напомним, библиотека nrepl/nrepl должна быть указана в профиле по умолчанию (файлы ~/.lein/profiles.clj и ~/.clojure/deps.edn). Запомните порт первого сеанса (в случае автора это 50411). Его можно увидеть в терминале при nREPL или в файле .nrepl-port той директории, где запущен сеанс.

Во втором сеансе выполните:

=> (require '[nrepl.core :as nrepl])

=> (def conn (nrepl/connect :port 50411))
=> (def client (nrepl/client conn 1000))

Код подключит вас к удаленному nREPL. Объект client отвечает за отправку и получение сообщений. Для начала сложим несколько чисел. Отправим сообщение с операцией eval:

=> (nrepl/message client {:op "eval" :code "(+ 1 2 3)"})

Получим два ответа: первый с результатом, второй с признаком окончания:

({:id "1ac6cbc4-74d4-4b3a-bf3f-97dcf7ca07c2"
  :ns "my-repl"
  :session "fec9d5a3-3c22-4640-b089-c1cecc041068"
  :value "6"}
 {:id "1ac6cbc4-74d4-4b3a-bf3f-97dcf7ca07c2"
  :session "fec9d5a3-3c22-4640-b089-c1cecc041068"
  :status ["done"]})

Проверим, что случится, если возникнет исключение. Поделим число на ноль:

=> (nrepl/message client {:op "eval" :code "(/ 0 0)"})

Ответ:

({:err "Execution error (ArithmeticException) at my-repl/eval5984 (form-init9833672407535844907.clj:1).\nDivide by zero\n"
  :id "a8444b3c-7b54-4e04-9b48-04b8bda170f4"
  :session "17d02cb6-45ff-464c-a01c-c87da89cdfa7"}
 {:ex "class java.lang.ArithmeticException"
  :id "a8444b3c-7b54-4e04-9b48-04b8bda170f4"
  :root-ex "class java.lang.ArithmeticException"
  :session "17d02cb6-45ff-464c-a01c-c87da89cdfa7"
  :status ["eval-error"]}
 {:id "a8444b3c-7b54-4e04-9b48-04b8bda170f4"
  :session "17d02cb6-45ff-464c-a01c-c87da89cdfa7" :status ["done"]})

Мы получили краткие сведения об исключении: класс, текст и последний элемент стектрейса. Сбором этих данных занимается функция, которую можно переопределить параметром :nrepl.middleware.caught/caught. Функция должна быть объявлена на сервере, поэтому в сообщении передают путь к ней, а не объект функции:

{:nrepl.middleware.caught/caught 'project.util/caught-func}

Убедимся, что изменения доступны на сервере. С помощью клиента объявите функцию add:

=> (nrepl/message client {:op "eval" :code "
(defn add [a b]
  (+ a b))
"})

Переключитесь в первый терминал с сервером. Выполните в терминале (add 1 2) — функция сработает без ошибок.

Другая полезная команда называется lookup. Она принимает символ и возвращает данные о переменной, связанной с ним. Данные содержат путь к исходному файлу, позиция в нем, документация и сигнатуры. На lookup завязаны важные функции редактора: переход к определению, вывод документации, всплывающее окно с сигнатурой вызова по мере набора. Запросим информацию о символе +:

=> (nrepl/message client {:op "lookup" :sym "+"})

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

({:id "e66092b8-c6aa-49a9-9cff-cc3557e421c6",
  :info {:protocol "",
         :added "1.2",
         :ns "clojure.core",
         :name "+",
         :file "jar:file:/Users/ivan/.m2/repository/org/clojure/clojure/1.10.0/clojure-1.10.0.jar!/clojure/core.clj",
         :arglists-str "([] [x] [x y] [x y & more])",
         :column 1,
         :line 984,
         :arglists "([] [x] [x y] [x y & more])",
         :doc "Returns the sum of nums. (+) returns 0. Does not auto-promote\n  longs, will throw on overflow. See also: +'"},
  :session "63a4ba3f-6a19-4597-919d-3136ebbfa1cb",
  :status ["done"]})

Команда completions возвращает список определений по префиксу. Это полезно, когда пользователь ввел часть текста и ожидает выпадающее окно с вариантами. Например, функции семейства ex-... служат для работы с исключениями. Проверим, что найдет сервер по префиксу ex-:

(nrepl/message client {:op "completions" :prefix "ex-"})

({:completions
  [{:candidate "ex-cause", :type "function"}
   {:candidate "ex-data", :type "function"}
   {:candidate "ex-info", :type "function"}
   {:candidate "ex-message", :type "function"}],
  :id "e6f3b77b-5689-484e-8743-76f9682caaa7",
  :session "f15bb73c-e3f9-49c8-8aa5-0ebc592d038d",
  :status ["done"]})

Получили четыре функции для работы с исключениями.

nREPL поддерживает другие полезные команды, например :load-file для загрузки кода из файла. При этом сообщение передает не путь к файлу на сервере, а его содержимое и метаданные. Создайте файл src/sample.clj с кодом:

(ns sample)

(defn multiply [a b]
  (* a b))

После чего отправьте его на сервер (функция slurp читает файл в строку):

(nrepl/message client {:op "load-file"
                       :file (slurp "src/sample.clj")})

Код из файла будет скомпилируется на сервере. Появится новое пространство имен sample. Перейдите в терминал сервера и обратитесь к новой функции:

=> (sample/multiply 3 4)
12

При помощи :load-file редактор загружает код на сервер. По аналогии с eval, load-file принимает дополнительные параметры, которые вы найдете в документации.

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

(nrepl/message client
               {:op "close"
                :session "97ec6c4b-28ee-4402-87e3-43f3275a7430"})

Коротко о Bencode

Давайте исследуем трафик, которым обмениваются клиент и сервер nREPL. Пока открыты оба сеанса, запустите команду tcpdump для записи трафика в файл. В системах Linux и MacOS утилита доступна по умолчанию. Tcpdump требует привилегий суперпользователя, поэтому запускается через sudo:

sudo tcpdump port 50411 -i lo0 -w nrepl.log

Число 50411 — это порт сервера nREPL, а nrepl.log — файл, куда писать TCP-пакеты. После запуска tcpdump переключитесь в терминал клиента и выполните несколько действий. Завершите tcpdump нажатием Ctrl+C.

Изучите полученный файл в программе Wireshark. Это бесплатное приложение для анализа сетевого трафика. Содержимое файла в этой программе выглядит примерно так (переносы строк отделяют запрос и ответ):

d4:code9:(+ 1 2 3)2:id36:c88f2dcb-d502-4c90-9529-2dd843bc56d02:op4:evale
d2:id36:c88f2dcb-d502-4c90-9529-2dd843bc56d02:ns7:my-repl7:session36:1fdd9575-881e-4be1-80ca-dd8e64bdff185:value1:6e

Каждая строка — структура данных в формате Bencode. Формат пришел из протокола BitTorrent, и позже его переняли другие системы, в том числе nREPL. Bencode передает числа, строки, словари и списки. Несмотря на меньший по сравнению с JSON набор типов, у формат обладает решительным преимуществом — простотой.

Описание формата займет не больше страницы. Числа записываются в виде i<число>e, например i2020e означает 2020. Цепочка байтов — в виде <длина>:<содержимое>; строка "hello" становится "5:hello". Выражение l<...>e означает список. Значения, найденные между l и e, станут его содержимым. Например, в строке "l5:helloi42ee" записан список с элементами "hello" и 42. Форма d<...>e служит для словаря. От списка он отличается тем, что перед каждым значением идет строка с именем ключа. Сообщение:

"d5:title4:19844:yeari1948ee"

означает словарь

{:title "1984" :year 1948}

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

{:title "1984"
 :year 1948
 :tags ["novel" "fiction" "dystopia"]
 :author {:fname "George" :lname "Orwell"}}

Результат:

"d6:authord5:fname6:George5:lname6:Orwelle4:tagsl5:novel7:fiction8:dystopiae5:title4:19844:yeari1948ee"

Всё, вы знаете Bencode!

Этих правил достаточно для передачи сообщений в nREPL. Конечно, при кодировании данных теряется их семантика: тип Keyword становится строкой, но обратной операции не предусмотрено — получив строку, нельзя определить, была ли она раньше кейвордом. По аналогии вектор, список и множество становятся в Bencode списком. Но чаще всего это неважно.

Почему бы не использовать более продвинутый формат, например JSON? Причина в том, что, в отличие от JSON, Bencode экстремально прост: код упаковки и чтения данных займет не более ста строк. Это важно для встроенных в редактор языков, которые не всегда поддерживают JSON по умолчанию. Разработка JSON-парсера потребует в разы больше кода и сил.

Транспорт Bencode назначен в nREPL по умолчанию. Когда nREPL работает с ClojureScript, используется EDN. Сообщения в ClojureScript требуют больше типов данных, и здесь Bencode уже недостаточно.

Ради эксперимента укажите серверу и клиенту транспорт EDN. Для сервера это делается ключом :repl-options:

:repl-options {...
               :transport nrepl.transport/edn}

Если выполнить lein repl, вы увидите в консоли фразу nrepl+edn://127.0.0.1:<port>, при этом ввод с клавиатуры будет недоступен. Чтобы подключиться к серверу, измените параметры клиента:

(require '[nrepl.transport :as transport])

(def conn (nrepl/connect
           :port 61093
           :transport-fn transport/edn))

Запишите трафик в файл и исследуйте в Wireshark. Вы увидите привычные данные в формате Clojure:

{:op "eval"
 :code "(+ 1 2 3)"
 :id "f4fcb617-74a7-4be8-978a-6f788787d360"}

{:id "f4fcb617-74a7-4be8-978a-6f788787d360"
 :session "29e04d1a-06b2-463b-8b5b-878424958780"
 :ns "my-repl"
 :value "6"}

Bencode для Clojure доступен в двух вариантах: как часть nREPL и в виде отдельной библиотеки nrepl/bencode. Последняя является копией модуля из nREPL и обновляется параллельно с ним.

Клиенты nREPL для редакторов

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

Плагины пишут на разных языках. На GitHub вы найдете клиенты к nREPL, написанные на Clojure, Emacs Lisp, Java, Python, Lua, JavaScript, TypeScript, VimScript и других языках. Это многообразие объясняется тем, каждый редактор использует свой язык внутренних нужд. Например, плагины для Emacs пишут на старом диалекте Лиспа Elisp; редактор VS Code поддерживает JavaScript и TypeScript; модули к продуктам JetBrains создают на Java, и так далее.

В этом разделе мы не будем перебирать все клиенты. Наоборот, остановим выбор на модуле Cider для редактора Emacs. В его пользу говорят следующие факты.

Долгая история. Первый коммит в репозиторий Cider сделан в 2012 году. На момент написания книги проекту полных десять лет. Cider давно вышел из стадии любительского решения. У него обширное сообщество и документация.

Популярность. Согласно ежегодному опросу Clojure Survey, связка Cider/Emacs держит первое место по популярности у разработчиков. Процент голосов в пользу Cider превышает 40% (хоть и плавно снижается из-за развития других проектов).

Компетенция в сообществе. Исторически сложилось, что Emacs в большей степени подходит для разработки на Лиспе, чем другие редакторы. За долгие годы его адаптировали под разные диалекты — Common Lisp, Racket, Scheme и другие. Cider опирается на этот опыт: большая часть его функций — повтор удачных решений для других Лиспов.

Если вы пользуетесь другим редактором, не спешите пропускать раздел. Возможно, вы откроете подходы, о которых не знали раньше. Также вы заочно познакомитесь с Emacs. Это сложный редактор, но он стоит потраченных сил.

Emacs и Cider

Проект Cider состоит из двух частей. Первая — одноименный модуль для Emacs, чтобы подключаться к nREPL из редактора. Вторая часть — библиотека на Clojure под названием cider-nrepl. Это набор middleware, которые дополняют nREPL: добавляют запуск тестов, отладку, переходы по коду, профилирование и многое другое.

Взаимодействие Emacs и сервера выглядит так:

┌────────────────────────────────────────────────────────────────┐
│                                                                │
│  ┌─────────────────┐   ┌────────────────────────────────────┐  │
│  │Client           │   │Server                              │  │
│  │                 │   │                                    │  │
│  │  ┌───────────┐  │   │  ┌───────────┐     ┌───────────┐   │  │
│  │  │           │  │   │  │   nREPL   │     │           │   │  │
│  │  │   Emacs   │  │   │  │  server   │◀═══▶│  Clojure  │   │  │
│  │  │           │  │   │  │           │     │           │   │  │
│  │  └───────────┘  │   │  └───────────┘     └───────────┘   │  │
│  │        ▲        │   │        ▲                 ▲         │  │
│  │        ║        │   │        ║                 ║         │  │
│  │        ║        │   │        ║                 ║         │  │
│  │        ▼        │   │        ▼                 ▼         │  │
│  │  ┌───────────┐  │   │  ┌───────────┐     ┌───────────┐   │  │
│  │  │           │  │   │  │Cider/nrepl│     │           │   │  │
│  │  │   CIDER   │◀═╬═══╬═▶│middleware │     │    JVM    │   │  │
│  │  │           │  │   │  │           │     │           │   │  │
│  │  └───────────┘  │   │  └───────────┘     └───────────┘   │  │
│  │                 │   │                                    │  │
│  │                 │   │                                    │  │
│  │                 │   │                                    │  │
│  └─────────────────┘   └────────────────────────────────────┘  │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Cider-nrepl работает на сервере и не зависит от языка, на котором написан клиент. Поэтому он используется не только вместе с Cider (модулем Emacs), но и в плагинах для Vim и других редакторов.

Есть несколько способов начать работу над проектом в Emacs. Первый — поручить запуск проекта и подключение модулю Cider. Для этого откройте любой файл и выполните:

M-x cider-jack-in

Произойдет следующее: Emacs начнет искать файл project.clj в текущей папке, а затем все выше и выше. Если он найден, Emacs запустит процесс lein repl. В зависимостях окажутся библиотека nrepl и служебные плагины. Приведем итоговую команду в сокращении:

> /usr/local/bin/lein
  update-in :dependencies conj [nrepl/nrepl "0.9.0"] \
  update-in :plugins conj [cider/cider-nrepl "0.28.3"] \
  update-in :plugins conj [mx.cider/enrich-classpath "1.9.0"] \
  update-in :middleware conj cider.enrich-classpath/middleware \
  repl :headless :host localhost

Cider различает системы управления проектом: lein, Clojure CLI или Boot. Для каждой из них он выполнит разные команды. Если найдены файлы нескольких утилит, Emacs спросит у вас, что именно запустить.

После запуска nREPL редактор подключится к нему. Откроется буфер *cider-repl <project>* для ввода выражений. Еще один буфер *nrepl-server <project>* отображает вывод процесса lein repl.

Возможно, первый запуск проекта займет около тридцати секунд. Большая часть времени уйдет на то, чтобы скачать пакеты установить их локально.

Второй способ подключения в том, чтобы проделать шаги вручную: запустить nREPL в терминале и подключиться из редактора самостоятельно. Откройте глобальный профиль, расположенный в файле ~/.lein/profiles.clj. В вектор :user:plugins добавьте плагин cider/cider-nrepl. Плагин зависит от модуля nrepl/nrepl, поэтому последний указывать не нужно — он загрузится как транзитивная зависимость.

{:user
 {:plugins
  [[cider/cider-nrepl "0.28.3"]]}}

Запустите в терминале процесс lein repl. Перейдите в Emacs и выполните M-x cider-connect. Редактор запросит у вас хост и порт сервера. В нашем случае хост будет localhost или 127.0.0.1. Вводить порт вручную необязательно: Cider найдет его в файле .nrep-port. Для этого нажмите в минибуфере TAB — появится список с вариантами:

Click on a completion to select it.
In this buffer, type RET to select the completion near point.

Possible completions are:
- etaoin:54446
- pact:64187

Emacs показывает не только номера портов, но и названия проектов, с которыми они связаны. После соединения появится буфер *cider-repl <project>* с приглашением. Буфера *nrepl-server <project>* не будет, поскольку сервер запущен вне Emacs и между ними нет связи по стандартным каналам.

Для Clojure CLI файл deps.edn будет как в примере ниже. Запустите проект командой clojure -M:cider. По аналогии с lein, поместите профиль :cider в локальный файл ~/.clojure/deps.edn, чтобы применить его к любому проекту.

{:aliases
 {:cider
  {:extra-deps
   {cider/cider-nrepl {:mvn/version "0.25.9"}}
   :main-opts
   ["-m" "nrepl.cmdline"
    "--bind" "localhost"
    "--middleware" "[cider.nrepl/cider-middleware]"]}}}

Вопрос: зачем нужно ручное подключение, если доступно автоматическое? Пока мы не ушли дальше, объясним разницу между подходами.

В автоматическом режиме (cider-jack-in) процесс lein repl запускается силами Emacs. Если редактор “упадет”, завершатся открытые им процессы. Те, кто работает с Clojure постоянно, держат несколько запущенных проектов одновременно. Восстанавливать сеансы после перезапуска редактора утомительно.

Если же сеансы запущены вручную в терминале, выключение редактора не скажется на них. Более того — они сохранят изменения, которые вы внесли раньше. Достаточно включить редактор и подключится к запущенным проектам.

В редких случаях к проекту можно подключиться только в удаленном режиме. Например, если сервер nREPL запущен на другой машине или в виртуальном окружении (Docker, VirtualBox). Эти сценарии мы рассмотрим ближе к концу главы.

Первые шаги

Итак, если подключение состоялось, откроется буфер *cider-repl* приглашением. Введите что-нибудь вроде (+ 1 2), чтобы убедиться в его работе. В верхней части буфера приведена краткая справка. Если вы только знакомитесь с Cider, прочитайте ее. Опытные разработчики отключают справку, назначив nil специальной переменной Emacs:

(setq cider-repl-display-help-banner nil)

Дальнейшие шаги зависят от конфигурации проекта. Если не задано пространство по умолчанию (параметры :main или :repl-options:init-ns), модули проекта не будут загружены, и вы окажетесь в пространстве user. Загрузить код в nREPL можно двумя способами: вручную и автоматически.

В первом случае откройте файл с главным модулем. Как правило, это пространства <project>.core или <project>.main. Выполните команду M-x cider-load-buffer. Ей пользуются часто, поэтому команде назначено сочетание клавиш С-с С-k. Пространство имен, включая его зависимости, будут загружены на сервере.

В боевых проектах создают модуль dev (он же local, sandbox), который доступен только в разработке (профиль uberjar его игнорирует). Модуль указывают в опциях nREPL, чтобы загрузить его при запуске сервера. В свою очередь dev зависит от других модулей проекта, и они загружаются транзитивно.

Кроме импортов, в dev размещают служебные функции, например:

  • прогон миграций;
  • запуск системы компонентов (веб-сервер, база, кэш и другие) и ее остановка;
  • выгрузку данных с боевых сервисов.

Вот как выглядят настройки REPL с модулем dev по умолчанию:

{:profiles
 {:dev
  {:repl-options {:init-ns dev}}}}

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

Чтобы этого избежать, Cider предлагает автоматическую загрузку командой M-x cider-ns-refresh. Она перебирает пути classpath и принудительно загружает все файлы Clojure. Как только вы подключились к nREPL, выполните M-x cider-ns-refresh, и проект готов к работе.

Выполнение кода

После загрузки кода его можно выполнить. Перейдите в буфер *cider-repl* и вызовите любую функцию проекта:

(my.project.util/some-func {:message "hello"})

Нажмите Enter. На сервер уйдет сообщение с этим кодом. Там он выполнится, и вам придет ответ. Чтобы быстро перейти в буфер REPL, связанный с текущим проектом, наберите команду cider-switch-to-repl-buffer и по возможности задайте ей комбинацию клавиш.

Частый переход в буфер *cider-repl* и набор кода в нем неудобен. Гораздо лучше выполнить код из файла, где вы его набираете. Команда cider-eval-last-sexp, назначенная на комбинацию C-x C-e, выполняет последнее перед курсором S-выражение. Предположим, вы написали следующий код:

(let [name "John"
      email "test@test.com"]
  (format "%s <%s>" name email))

Поместите курсор за последнюю скобку или ниже и выполните C-x C-e. Справа от формы появится результат:

(let [name "John"
      email "test@test.com"]
  (format "%s <%s>" name email)) => "John <test@test.com>"

По аналогии с inferior-mode, S-выражение может быть где угодно: не только на верхнем уровне модуля, но и внутри другой формы. В выражении:

(let [id (java.util.UUID/randomUUID) |
      name "John"]
  {:id id
   :name name})

подведите курсор на место вертикальной черты и выполните форму (java.util...). Вы получите случайный идентификатор, экземпляр класса UUID.

Команды cider-eval-region, cider-eval-buffer и другие выполняют код из разных областей. Как следует из названий, -region выполняет выделенный код, в котором может быть несколько форм. Команда -buffer выполняет содержимое буфера.

Команда cider-eval-defun-at-point выполняет определение — формы def, defn, defmacro и другие. Ее особенность в том, курсор может быть в любом месте формы, а не обязательно на конце. В примере ниже установите курсор на место черты и выполните команду. Результатом станет переменная #'user-description (объект Var).

(def user-description
  (let [name "John" |
        email "test@test.com"]
    (format "%s <%s>" name email)))

=> #'user-description

Этот прием крайне полезен в работе. Большую часть времени мы проводим в функциях, и перемещать курсор в конец формы неудобно. С помощью cider-eval-defun-at-point легко обновить определение, находясь в любом месте его кода.

Если результат вычислений велик (например, выборка из базы данных), Cider покажет усеченную версию. Чтобы исследовать данные, выполните cider-inspect-last-result. Откроется буфер, где данные напечатаны постранично с учетом вложенности. Для примера исследуем большой словарь:

(ns-map 'clojure.core)

Class: clojure.lang.PersistentHashMap
Contents:
  sort-by = #'clojure.core/sort-by
  contains? = #'clojure.core/contains?
  every? = #'clojure.core/every?
  proxy-mappings = #'clojure.core/proxy-mappings
  keep-indexed = #'clojure.core/keep-indexed
  ...
  Page size: 32, showing page: 1 of 29

Буфер *cider-inspect* нельзя редактировать. За навигацию по данным отвечают особые клавиши; приведем некоторые из них:

  • SPC (пробел) — перейти на следующую страницу результата;
  • M-SPC — вернуться на предыдущую;
  • RET (Enter) — открыть вложенную структуру данных;
  • l — подняться на уровень ниже

Команда cider-inspect-last-sexp (C-x TAB) совмещает два шага: выполнить форму и открыть инспектор с результатом. С ней не понадобиться вызывать их по отдельности.

Полное описание инспектора, его команд и клавиш вы найдете на сайте Cider в одноименном разделе.

Dev-секции

Опытные программисты оставляют в файлах так называемые dev sections — области разработки. Это код, который легко выполнить в REPL, чтобы проверить результат. Dev-секция помещается в макрос comment, чтобы не влиять на компиляцию.

Предположим, вы написали функцию ->fahr для перевода температуры между шкалой Цельсия и Фаренгейта:

(defn ->fahr [cel]
  (+ (* cel 1.8) 32))

Чтобы ее проверить, добавьте в конец файла отладочный код:

(comment
  (->fahr 36.6)
  (->fahr 0)
  (->fahr nil)
  )

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

Обратите внимание на следующие моменты. Отладочный код находятся в макросе comment, который игнорирует содержимое: при компиляции он вырождается в пустоту. Не путайте макрос comment и комментирование строки точкой с запятой. Во втором случае форму нельзя выполнить: команда cider-eval-last-sexp не сработает.

;; cannot be evaluated
;; (->fahr 36.6) |

Закрывающая скобка comment стоит отдельно, чтобы по ошибке не выполнить его вместо последней формы. Покажем это на примере. Предположим, скобка стоит по правилам Lisp-синтаксиса на той же строке:

(comment
  ...
  (->fahr nil))

Чтобы выполнить (->fahr nil), поставим курсор между двумя последними скобками:

(comment
  ...
  (->fahr nil)|)

На практике легко промахнуться и поставить курсор в конце:

(comment
  ...
  (->fahr nil)) |

В этом случае команда cider-eval выполнит форму comment, которая вернет nil вне зависимости от содержимого. Сложится ложное ощущение, что (->fahr nil) возвращает nil, что на самом деле не так. Чтобы этого не случилось, идут на легкое нарушение правил.

Некоторые редакторы выделяют блок comment цветом, чтобы подчеркнуть — это не боевой код, а пример или справка. Если у вас это не работает, попробуйте список с тегом игнорирования #_:

#_
((->fahr 36.6)
  (->fahr 0)
  (->fahr nil)
)

В результате dev-секция будет окрашена в нейтральный цвет. Так вы легко отделите ее от кода.

Код dev-секции может быть с побочным эффектом, например когда вы тестируете HTTP-запросы или базу данных. Проследите, чтобы в коде не было паролей или ключей доступа. Если они необходимы, считайте их в переменную из файла или переменной среды:

(comment
  (def -api-key
    (slurp "API_KEY"))
  (def -response
    (make-http-request ... -api-key)))

Dev-секции встречаются во многих библиотеках, в том числе Clojure. Например, модуль clojure.zip содержит блок comment с набором шагов, где проверяется логика зипперов.

Сниппеты

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

На роль сниппета подойдет сброс данных в json-файл с отступами. Набирать этот код утомительно, поэтому полезно держать его под рукой.

(-> data
    (cheshire.core/generate-string {:pretty true})
    (->> (spit "output.json")))

Другой пример — обращение к сервису по протоколу HTTP. Это вызов функции post из библиотеки Clj-HTTP с нужными параметрами:

(clj-http.client/post
 "https://internal.site.com/api/v1"
 {:as :json
  :content-type :json
  :form-params {:event "user-created"
                :user_id 10099}})

Обратите внимание, что в сниппетах используют полные пространства имен. Такой код сработает даже в модуле, где нет импортов clj-http.client или cheshire.core. Достаточно, чтобы библиотеки были загружены в другом месте.

Чем опытней разработчик, тем больше у него сниппетов на разные случаи. Удобно хранить их в отдельном .clj-файле, чтобы выполнять оттуда, не копируя в REPL. С таким подходом получается личная среда разработки.

Cider предлагает особый буфер, чтобы выполнять код на Clojure. Когда вы подключены к nREPL, наберите команду M-x cider-scratch. Откроется буфер *cider-scratch*, связанный с текущим проектом. Скопируйте в него любой код. Поместите курсор за нужной формой и нажмите C-j — на следующей строке появится ее результат:

(+ 1 2) | ;; press C-j

(+ 1 2)
3

Преимущество *cider-scratch* в том, что результат не пропадает, а остается в файле для дальнейшей работы. Выполните более сложный пример, нажимая C-j после каждой формы:

(require '[clojure.walk :as walk])
nil

(walk/stringify-keys {:hello {:test 33}})
{"hello" {"test" 33}}

Комбинация C-u C-j печатает результат при помощи clojure.pprint, то есть с отступами и переносами строк. Опробуйте ее на больших данных, например словаре переменных среды:

(into {} (System/getenv)) | ;; C-j

Пользователи Emacs догадались, что буфер *cider-scratch* — это аналог обычного scratch. Так называется встроенный буфер Emacs, который выполняет код на ELisp. Разница в том, что вместо ELisp *cider-scratch* принимает код на Clojure. Режим, в котором он работает, называется cider-clojure-interaction-mode.

Если сохранить *cider-scratch*, Emacs запросит путь на диске, потому что по умолчанию буфер не связан с файлом. Введите любое имя; автор предпочитает такое же, что и у буфера, то есть *cider-scratch* без расширения со звездочками.

Когда вы откроете буфер *cider-scratch* с диска, он будет вести себя как обычный текст: нажатие C-j перенесет каретку без выполнения кода. Так происходит потому, что при открытии буфер получит базовый режим (fundamental mode). Чтобы вернуть интерактивность, смените режим командой M-x cider-clojure-interaction-mode, и выполнение кода заработает.

Чтобы не вводить команду каждый раз, воспользуйтесь одним из двух способов. Первый — поместите в начале файла строку:

; -*- mode: cider-clojure-interaction -*-

При открытии файлов Emacs учитывает выражения, заключенные в -*-, и выполняет их. Метка mode означает сменить главный режим буфера.

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

(add-to-list 'auto-mode-alist '("\*cider-scratch\*" . cider-clojure-interaction-mode))

У звездочки особая роль в регулярных выражениях. Чтобы задать символ звездочки, ее экранируют обратной чертой. Выполните форму выше в буфере *scratch*, и изменения вступят в силу немедленно (не забудьте добавить ее в конфигурацию Emacs). Закройте и откройте файл *cider-scratch* — он сразу перейдет в интерактивный режим.

Сниппеты удобно хранить в личном репозитории. Чтобы они были доступны в разных проектах, расставьте мягкие ссылки (symlink). Ниже мы полагаем, что в папке ~/dotfiles находится ваш личный репозиторий настроек, а в ~/work/acme/backend — серверный код заказчика. Команда ln -s добавит ссылку в папку проекта:

cd ~/dotfiles
ln -s ~/dotfiles/scratch.clj ~/work/acme/backend/scratch.clj

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

Все части