Главная мысль урока: данные Clojure выходят за пределы программы в том же виде, в каком живут внутри. Это EDN, литералы языка как формат файла. Писать его может кто угодно, а читать чужое можно только через
clojure.edn, который строит данные и никогда не исполняет код.
Игрок «Хроник Пограничья» нажимает «Сохранить и выйти». Мир игры надо записать в файл, а завтра прочитать обратно и получить тех же героев с теми же сумками. На JavaScript ты бы взял JSON.stringify, написал функцию, которая превращает множества в массивы, а потом ещё одну, которая превращает их обратно. В Clojure сохранение занимает одну строку, чтение ещё одну:
pr-str печатает данные так, как они записываются в коде. edn/read-string читает такую строку и строит те же данные. Результат равен исходнику по значению: множества остались множествами, ключевые слова ключевыми словами, а ключи-векторы в :world/fog никуда не делись. В уроке про мапы мы уже делали такой круг через read-string и обещали вернуться к нему. Вот этот урок, и первое, что в нём выяснится: read-string для чужих данных брать нельзя.
Литералы как формат файла
EDN расшифровывается как «расширяемая запись данных». Это подмножество синтаксиса Clojure без всего, что относится к коду: нет функций, нет #(...), нет syntax-quote. Остались литералы, которые ты знаешь с урока про формы, и теги, о которых ниже.
Сравни с JSON, который знает каждый:
| Что | JSON | EDN |
|---|---|---|
| Ключи мапы | только строки | любое значение: ключевое слово, вектор, число |
| Множество | нет, пишут массивом | #{:scout :archer} |
| Ключевые слова | нет, пишут строкой | :hero/name |
| Комментарии | нет | ; до конца строки, #_ выкидывает следующую форму |
| Разделители | запятые обязательны | запятая это пробел |
| Дата и идентификатор | строка, договорённость на словах | #inst, #uuid |
| Свои типы | нет | свои теги #hero/pos |
Комментарии и #_ особенно ценны в файлах настроек. Строку можно отключить, не удаляя её:
Вторая строка важнее, чем кажется. (+ 1 2) прочитался как список из символа и двух чисел, а не как тройка. EDN ничего не вычисляет: что записано, то и получишь.
Что теряется в JSON
Сниппеты урока работают в браузере, поэтому JSON у нас под рукой. Отдадим ему героя:
JSON молча потерял три вещи: пространство имён ключа (:hero/name стал "name"), разницу между ключевым словом и строкой и разницу между множеством и массивом. Чтобы вернуть их, на другой стороне нужна схема и код, который её применяет. В Go это теги полей структуры и json.Unmarshal из урока про стандартную библиотеку. EDN несёт всю эту информацию в самом тексте.
Заодно посмотри на запись #:hero{...}. Когда у всех ключей мапы одно пространство имён, принтер выносит его за скобки, чтобы не повторять :hero/ трижды. Это та же мапа с ключами :hero/name, :hero/tags и :hero/hp, и clojure.edn читает такую запись.
Теги: #inst и #uuid
Дата сохранения и идентификатор слота в JSON живут строками, и только договорённость говорит, что "2026-05-14T10:00:00Z" это время, а не имя. В EDN у значения бывает тег: решётка, имя и обычное значение после него. Два тега встроены в формат:
#inst превратил строку в настоящее время, #uuid в настоящий UUID. С ними работают функции, а не регулярки: inst-ms даёт миллисекунды от начала эпохи, и разница двух таких чисел это прошедшее время.
В браузере #inst читается в js/Date, на JVM в java.util.Date. Файл при этом один и тот же:
(require '[clojure.edn :as edn])
(def at (edn/read-string "#inst \"2026-05-14T10:00:00Z\""))
(println (class at)) ; java.util.Date
(assert (inst? at))
(assert (= 1778752800000 (inst-ms at)))
Свои теги через :readers
Позиция героя в сохранении встречается сотни раз, и {:x 3 :y 4} удобнее записать короче. Заведём тег #hero/pos [3 4]. Читателю надо сказать, какой функцией его разбирать, это опция :readers: мапа из символа тега в функцию от значения.
Имя тега пишут с пространством имён, как и ключи: теги без слэша зарезервированы за самим форматом. Если в файле встретился тег, которого читатель не знает, чтение падает. Иногда это то, что нужно: старая версия игры не должна молча проглотить сохранение новой. Если же хочется дочитать файл и разобраться потом, есть опция :default, функция от тега и значения. Встроенная tagged-literal заворачивает их в объект, который печатается обратно тем же тегом:
Последняя строка подсказывает, как записать свой тег. Нужно положить в данные объект tagged-literal, и pr-str напечатает его с решёткой. На JVM для своих типов чаще расширяют мультиметод print-method, тогда запись печатается тегом сама:
(require '[clojure.edn :as edn])
(defrecord Pos [x y])
(defmethod print-method Pos [pos ^java.io.Writer w]
(.write w (str "#hero/pos " (pr-str [(:x pos) (:y pos)]))))
(def text (pr-str {:hero/position (->Pos 3 4)}))
(println text) ; {:hero/position #hero/pos [3 4]}
(def back (edn/read-string {:readers {'hero/pos (fn [[x y]] (->Pos x y))}} text))
(assert (= back {:hero/position (->Pos 3 4)}))
Чужие данные читает только clojure.edn
В clojure.core тоже есть read-string, и выглядит он так же. Разница в том, что он читает не EDN, а весь синтаксис Clojure. Это ридер языка, тот же, которым компилятор читает твой исходник. А у ридера языка есть форма #=: вычислить выражение прямо во время чтения. На JVM она включена по умолчанию:
(require '[clojure.edn :as edn])
(defn fails? [thunk]
(try (thunk) false (catch Exception _ true)))
;; Полный ридер языка исполняет #= прямо при чтении.
(assert (= 3 (read-string "#=(+ 1 2)")))
;; Вместо (+ 1 2) в «сохранении» могло быть удаление файлов.
(assert (fails? #(edn/read-string "#=(+ 1 2)")))
;; *read-eval* false выключает #=, но это одна защита из нескольких нужных.
(assert (fails? #(binding [*read-eval* false] (read-string "#=(+ 1 2)"))))
(println "clojure.edn не исполняет код")
Строка из сохранения, из сети, от другого сервиса это чужие данные, даже если сохранение писала твоя же игра: файл мог поправить кто угодно. Правило короткое: clojure.core/read-string только для текста, который ты написал сам, например для кода в REPL. Всё, что пришло снаружи, читает clojure.edn/read-string или clojure.edn/read для потока.
В браузере ридер ClojureScript не знает #=, поэтому сниппеты урока такой дыры не покажут. Привычку это не отменяет: тот же код однажды поедет на сервер.
EDN на границе с другими языками
EDN хорош, пока по обе стороны Clojure. Браузерному фронтенду на TypeScript, сервису на Go или аналитику с pandas его читать нечем, им нужен JSON. Тогда на границе ставят перевод: ключевые слова становятся строками на выходе и ключевыми словами на входе. В babashka для этого встроен cheshire:
(require '[cheshire.core :as json])
(def hero {:hero/name "Ирма" :hero/tags #{:scout}})
(def text (json/generate-string hero))
(println text) ; {"hero/name":"Ирма","hero/tags":["scout"]}
;; true на входе превращает ключи обратно в ключевые слова,
;; но множество и ключевые слова в значениях уже не вернуть.
(def back (json/parse-string text true))
(assert (= "Ирма" (:hero/name back)))
(assert (= ["scout"] (:hero/tags back)))
Есть и промежуточный вариант, Transit: формат команды Cognitect, который кодирует данные Clojure поверх JSON и сохраняет ключевые слова, множества и теги. Его выбирают, когда фронтенд на ClojureScript, а по проводу удобнее гнать JSON.
Порядок выбора такой:
- Обе стороны на Clojure, данные лежат в файле или идут между своими сервисами: EDN.
- Обе стороны на Clojure, но транспорт ждёт JSON (браузер, очередь): Transit.
- На другой стороне другой язык: JSON и явный перевод на границе.
В каждом случае прочитанное ещё надо проверить: EDN гарантирует, что ты получил данные, но не что это правильный герой. Проверкой формы займётся следующий урок.
Попробуй руками
В задаче ты напишешь сохранение игры: чтение файла с тегами #hero/pos, #inst и #uuid, запись обратно с тем же тегом и подсчёт минут с момента сохранения.
Проверь себя
Задание
Дальше
EDN доставил данные через границу, но не сказал, правильные ли они. В уроке про spec и malli опишем форму героя и научимся объяснять, что в нём не так. А в уроке про Ring EDN и JSON встретятся в теле HTTP-ответа.
домашка