Clojure часто воспринимают как академический язык для любителей скобок. На практике это рабочий инструмент для обработки данных: неизменяемые структуры убирают половину ошибок, REPL позволяет проверять гипотезы на живых данных, а JVM даёт доступ к проверенным библиотекам и предсказуемой работе с памятью. Разберём практический сценарий — разбор сжатого CSV-лога и агрегацию по сервисам.
Почему Clojure подходит для данных
Данные здесь представлены неизменяемыми (persistent) структурами: вектор, словарь, множество. Операция «изменить» возвращает новую версию и разделяет память с прежней, поэтому копирования целого массива не происходит. Код становится предсказуемым: функция не может незаметно испортить входной словарь.
Второй козырь — ленивые последовательности и трансдьюсеры. Первые позволяют описать конвейер и вычислить его позже, вторые собирают тот же конвейер без промежуточных коллекций. Для файла в несколько гигабайт это принципиально: иначе каждый шаг создаст свой список в памяти.
Установка
Официальный скрипт ставит clojure и clj в /usr/local/bin. Пакет из репозитория Debian тоже работает, но отстаёт по версии. Проверьте версию Java: Clojure 1.12 запускается и на восьмой, но современные библиотеки требуют 17 или новее.
curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh
chmod +x linux-install.sh
sudo ./linux-install.sh
clojure --version
В корпоративной сети скачайте скрипт заранее и сверьте контрольную сумму.
Проект и deps.edn
Файл deps.edn описывает пути, зависимости и алиасы. Алиас запуска задаёт главный namespace и параметры JVM, поэтому команда остаётся короткой.
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
org.clojure/data.csv {:mvn/version "1.1.0"}
cheshire/cheshire {:mvn/version "5.13.0"}}
:aliases
{:run {:jvm-opts ["-Xmx4g" "-XX:+UseG1GC"]
:main-opts ["-m" "logstat.core"]}}}
Зависимости скачиваются при первом запуске и кэшируются в ~/.m2. Команда clj -Spath покажет итоговый classpath — удобно, когда непонятно, откуда взялась конфликтующая версия библиотеки.
REPL как рабочий инструмент
REPL запускается командой clj в каталоге проекта. Здесь проверяют разбор строки, прежде чем писать файл целиком.
clj
user=> (require '[clojure.string :as str])
nil
user=> (def row "2026-03-01T10:00:00,api,500,145")
#'user/row
user=> (str/split row #",")
["2026-03-01T10:00:00" "api" "500" "145"]
Ошибка в шаблоне или формате поля видна сразу, а не после перезапуска программы.
Чтение сжатого файла
Файл читают построчно, не загружая целиком. Для gzip нужен поток GZIPInputStream, обёрнутый в reader; with-open гарантирует закрытие дескриптора даже при исключении.
(ns logstat.core
(:require [clojure.java.io :as io]
[clojure.string :as str])
(:import [java.util.zip GZIPInputStream]))
(defn parse-line [line]
(let [[ts svc code ms] (str/split line #"," 4)]
{:ts ts
:svc svc
:code (parse-long code)
:ms (parse-long ms)}))
(with-open [in (io/reader (GZIPInputStream. (io/input-stream "access.csv.gz")))]
(->> (line-seq in)
(map parse-line)
(filter #(= 500 (:code %)))
(take 5)
doall))
parse-long появился в Clojure 1.11 и ведёт себя строже, чем Integer/parseInt: на мусоре вернёт nil, а не бросит исключение. Это удобно для грязных логов. Для CSV с кавычками и переносами строк внутри поля самодельный split не годится: берите clojure.data.csv/read-csv, он разбирает такие случаи по правилам формата.
Трансдьюсеры вместо цепочек
Конвейер из map и filter создаёт ленивую последовательность: элементы вычисляются по одному, но накладные расходы на ячейки остаются. Трансдьюсер описывает преобразование отдельно от источника и не создаёт промежуточных структур.
(def xf
(comp
(map parse-line)
(filter #(<= 400 (:code %)))
(map :ms)))
(with-open [in (io/reader (GZIPInputStream. (io/input-stream "access.csv.gz")))]
(transduce (comp xf (take 100000)) + 0 (line-seq in)))
Композиция comp в трансдьюсерах читается сверху вниз: сначала map, затем filter. Для обычных функций порядок был бы обратным, но здесь он совпадает с порядком записи. take в трансдьюсере умеет останавливать источник досрочно, поэтому первые сто тысяч строк читаются без разбора всего файла.
Агрегация и top-N
Для подсчёта суммы по ключу достаточно reduce с изменяемым словарём. Transient-версия снижает число аллокаций, а persistent! возвращает обычную неизменяемую коллекцию.
(defn summarize [lines]
(persistent!
(reduce (fn [acc {:keys [svc ms]}]
(assoc! acc svc (+ ms (get acc svc 0))))
(transient {})
lines)))
(with-open [in (io/reader (GZIPInputStream. (io/input-stream "access.csv.gz")))]
(->> (line-seq in)
summarize
(sort-by val >)
(take 10)
doall))
Сортировка по значению с компаратором > даёт убывающий порядок. Если сервисов тысячи, а нужен только топ, замените полную сортировку на очередь с приоритетом или partial sort.
Результат удобно писать потоком, а не собирать в память. Функция clojure.data.csv/write-csv принимает writer и последовательность строк, поэтому отчёт уходит на диск по мере вычисления. Для JSON есть generate-stream в cheshire: объект пишется частями и не требует строки целиком. Это важно, когда отчёт читает другая система.
Скорость на JVM
Главный враг — reflection: вызов Java-метода без подсказки типа ищется в рантайме. Включите предупреждения и просмотрите вывод.
(set! *warn-on-reflection* true)
(defn ^Long parse-ms [^String s]
(Long/parseLong s))
clj -J-Xmx4g -M -m logstat.core
Флаг -J передаёт параметр JVM, -M запускает main. Для параллельной обработки используйте reducers/fold: он делит коллекцию на части и считает их в общем пуле, в отличие от pmap с его фиксированным числом потоков. Время замеряйте на одном и том же файле: иначе разница в кэше страницы исказит картину.
- Сначала снимите baseline на реальном файле, а не на синтетике.
- Включите warn-on-reflection и уберите горячие вызовы без подсказок типа.
- Проверьте, что конвейер не создаёт промежуточных коллекций.
- Сравнивайте время прогрева JVM с временем установившегося режима.
Для небольших скриптов, где не нужна вся JVM, есть Babashka — интерпретатор Clojure с быстрым стартом. Он поддерживает большинство функций ядра и хорош для cron-задач, но произвольные Java-библиотеки в нём недоступны.
Тесты и сборка
Модульные тесты пишут на clojure.test, запуская пространство имён с тестами. Для поставки собирают uberjar — один файл со всеми зависимостями.
(ns logstat.core-test
(:require [clojure.test :refer [deftest is]]
[logstat.core :refer [parse-line]]))
(deftest parse-line-test
(is (= 500 (:code (parse-line "2026-03-01T10:00:00,api,500,145")))))
С Leiningen сборка сводится к lein uberjar и запуску java -jar target/logstat-0.1.0-standalone.jar. В проектах на deps.edn ту же роль выполняет tools.build: функция uber из build.clj собирает архив, а версия библиотеки берётся из deps.edn.
Итог
Clojure хорош там, где важны корректность преобразований и скорость разработки: разбор логов, ETL, подготовка отчётов, прототипы API. Начните с deps.edn и REPL, перепишите горячий участок на трансдьюсеры, включите warn-on-reflection и измерьте время до и после. Такой порядок даёт выигрыш без преждевременной оптимизации, а код остаётся коротким и проверяемым в REPL.