Все части

Оглавление

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

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

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

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

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

(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 одновременно. Для эффективной работы требуется не так уж много команд: выполнить 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 и введем код на Clojure:

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

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

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

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

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

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

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

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

Технически nREPL — библиотека, доступная в Clojars. У нее нет зависимостей, что упрощает развитие и поддержку. Несмотря на свою роль в экосистеме, 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 работает в “безголовом” (headless) режиме, слушая только сетевой порт. Запустите утилиту 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 — своего рода песочницу с запуском системы, прогоном миграций другими служебными функциями.

Пример с нестандартными параметрами:

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

Вот что получим при запуске lein repl:

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

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

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

Дизайн nREPL включает три важные части: обработчик запроса, middleware и транспорт. Коротко опишем каждую из них.

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

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

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

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

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

Логично ожидать, что обработчик возвращает словарь сообщения подобно библиотеке Ring. Это приводит к ограничению “один запрос – один ответ”, что нарушает сказанное выше: на одно входящее сообщение может быть несколько исходящих.

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

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

Простейший случай, когда ответов несколько — вычисление двух форм за раз. Выделим в редакторе две формы и выполним M-x cider-eval-region:

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

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

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

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

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

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

Транспорт

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

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

Транспорт 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))

Функция connect открывает соединение с сервером, на базе которого работает клиент (в рамках одного соединения их может быть несколько). Клиент отвечает за отправку и получение сообщений. Код выше подключит вас к удаленному nREPL. Для начала сложим несколько чисел. Отправьте сообщение с операцией 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

Мы не будем перечислять все команды 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 перейдите в REPL и выполните несколько действий. Завершите 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? Причина в том, что, в отличие от него, 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. Это код, который отправляет сообщения по особому протоколу. Клиенты пишут на разных языках, в том числе встроенных в редактор. Так получаются плагины — программные модули, которые служат прослойкой между пользователем и 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 работает на сервере и не зависит от языка, на котором написан клиент. На него опирается не только 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.

Возможно, первый запуск cider-jack-in займет время. Его бóльшая часть уйдет на загрузку зависимостей.

При втором способе подключения шаги проделывают вручную: запускают nREPL в терминале и подключаются из редактора. Откройте глобальный профиль lein в файле ~/.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 — появится список с вариантами. Emacs покажет не только номера портов, но и названия проектов, с которыми они связаны.

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

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

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

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

В редких случаях к проекту можно подключиться только в удаленном режиме. Например, если сервер 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. Ей пользуются часто, поэтому команде назначено сочетание клавиш C-c C-k. Пространство имен, включая его зависимости, будут загружены на сервере.

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

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

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

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

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

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

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

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

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

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

Нажмите Enter. На сервер уйдет сообщение с этим кодом. Там он выполнится, и ниже появится результат.

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

Частый переход в буфер *cider-repl* и набор кода в нем неудобен. Гораздо лучше выполнить код из файла, где вы его набираете. Команда M-x 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. Откроется буфер *cider-inspect*, где данные напечатаны постранично с учетом вложенности. Для примера исследуем большой словарь:

(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

За навигацию по данным отвечают особые клавиши; приведем некоторые из них:

  • 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 переносят на новую строку.

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

Сниппеты

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

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

(clojure.java.jdbc/execute!
 {:dbtype "postgresql"
  :dbname "test"
  :host "127.0.0.1"
  ...}
 ["truncate users, orders, ... cascade"])

Другой пример — обращение ко внешнему REST API. Это вызов функции post из библиотеки clj-http с параметрами и заголовками:

(clj-http.client/post
 "https://internal.api.com/api/v1"
 {:as :json
  :content-type :json
  :headers {"Authorization" "Bearer ..."}
  :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. Разница в том, что *cider-scratch* ожидает код на Clojure и выполняет его в том проекте, в папке с которым находится.

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

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

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

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

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

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

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

Чтобы сниппеты не попали в историю git, добавьте имя файла в .gitignore проекта или глобальные настройки git (файл ~/.gitignore в домашней директории).

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

Все части