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.

Насколько публикация полезна?

Нажмите на звезду, чтобы оценить!

Средняя оценка 0 / 5. Количество оценок: 0

Оценок пока нет. Поставьте оценку первым.

Поделиться: Telegram ВКонтакте