Раздел 31 · Janet на практике

Сквозной проект: утилита статистики по логам

middle-senior~45 мин

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

Сквозной проект: утилита статистики по логам

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

Задача

Утилита stats читает лог-файлы и считает записи по уровням. Формат строки:

2026-08-29 14:23:01 INFO запрос обработан
2026-08-29 14:23:02 ERROR соединение потеряно

Что она должна уметь:

  • принимать несколько файлов;
  • считать записи по уровням;
  • фильтровать по уровню;
  • выводить текстом или JSON;
  • не падать на битых строках, а сообщать, сколько их было.

Последний пункт важнее, чем кажется. Логи всегда грязные: обрезанная строка, чужой формат, пустое сообщение. Утилита, которая падает на первой такой строке, бесполезна.

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

Шаг 1: файл, который уже работает

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

Сохрани тестовый лог из раздела «Задача» в app.log (добавь туда одну битую строку) и начни с девяти строк:

#!/usr/bin/env janet

(defn main [& args]
  (def file (get args 1))
  (unless file
    (eprint "использование: stats <файл>")
    (os/exit 2))
  (def lines (string/split "\n" (string (slurp file))))
  (printf "%d строк" (length lines)))
$ janet stats.janet app.log
7 строк

Сейчас у тебя работает: чтение файла и честный код возврата при неправильном вызове. Статистики ещё нет, но запускать уже есть что.

Шаг 2: PEG и подсчёт по уровням

Дописываем в тот же файл грамматику:

(def- line-peg
  (peg/compile
    ~{:spaces (some (set " \t"))
      :date (<- (* :d :d :d :d "-" :d :d "-" :d :d))
      :time (<- (* :d :d ":" :d :d ":" :d :d))
      :level (<- (some (range "AZ")))
      :message (<- (any 1))
      :main (* :date :spaces :time :spaces
               :level :spaces :message -1)}))

Три решения, которые стоит разобрать.

Грамматика компилируется один раз, на уровне модуля, а не при каждом вызове. Она применяется к каждой строке файла, и peg/compile здесь окупается.

Сообщение берётся как (any 1), то есть сколько угодно любых байтов. Не (some :a): классы символов не ловят кириллицу, мы разбирали это в уроке про захваты. Заодно any, а не some, разрешает пустое сообщение.

-1 в конце требует, чтобы строка кончилась. Без него разбор принял бы любой хвост.

Дальше обычная функция:

(defn parse-line [line]
  (when-let [result (peg/match line-peg line)]
    (def [date time level message] result)
    {:date date
     :time time
     :level (keyword (string/ascii-lower level))
     :message message}))

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

Не разобравшаяся строка даёт nil. Это не ошибка, а нормальная ситуация:

(string text) это не лишняя скобка. slurp возвращает буфер. string/split с ним работает, но приводить к строке в начале обработки хорошая привычка: дальше по цепочке значение может попасть в сравнение или в ключ, где разница типов уже выстрелит.

Подсчёт по уровням, тоже в этот же файл:

Третий аргумент get, значение по умолчанию, избавляет от проверки “встречали ли мы уже этот уровень”. Параметр назван entry-list, а не entries, намеренно: рядом живёт переменная цикла entry, и в выражении (length entries) глаз легко принял бы список за одну запись.

Осталось связать и напечатать:

(defn main [& args]
  (def result (parse-text (slurp (get args 1))))
  (def stats (summary (result :entries)))
  (printf "всего: %d" (stats :total))
  (eachp [level num] (stats :by-level)
    (printf "  %s: %d" (string level) num))
  (printf "пропущено: %d" (result :skipped)))
$ janet stats.janet app.log
всего: 5
  error: 1
  warn: 1
  info: 3
пропущено: 1

Сейчас у тебя работает: разбор настоящей грамматикой, подсчёт по уровням и устойчивость к битым строкам.

Шаг 3: разносим по модулям

Один файл дорос до предела. Режем:

stats/
├── project.janet         # описание пакета
├── stats.janet           # точка входа
├── src/
│   ├── parse.janet       # line-peg, parse-line, parse-text
│   └── report.janet      # summary и форматирование
└── test/
    ├── parse-test.janet
    └── report-test.janet

Разделение на два модуля не формальность: parse ничего не знает про вывод, report ничего не знает про PEG. Благодаря этому каждый тестируется отдельно, а точка входа остаётся тонкой. Это тот же принцип, который мы обсуждали в уроке про границы модулей: зависимость идёт в одну сторону, и её видно из структуры каталогов.

(declare-project
  :name "stats"
  :description "Анализатор логов"
  :dependencies ["https://github.com/janet-lang/spork.git"])

(declare-source
  :source ["src/parse.janet" "src/report.janet"])

(declare-binscript
  :main "stats.janet"
  :is-janet true)

В точке входа функции сменяются импортами:

(import ./src/parse :as parse)
(import ./src/report :as report)

Запусти ещё раз: вывод тот же, что на шаге 2. Сейчас у тебя работает то же самое, но разбор и отчёт можно тестировать по отдельности.

Шаг 4: аргументы, фильтр и два формата

Форматирование выносим в src/report.janet рядом со сводкой, и это позволяет иметь два вывода без дублирования логики:

JSON здесь собирается вручную. Для трёх полей это дешевле, чем зависимость, но как только структура вырастет, бери spork/json.

Аргументы разбирает spork/argparse из урока про скриптинг. Точка входа целиком:

#!/usr/bin/env janet

(import spork/argparse :prefix "")
(import ./src/parse :as parse)
(import ./src/report :as report)

(def spec
  ["Статистика по лог-файлам."
   "level"  {:kind :option :short "l" :help "Оставить только этот уровень"}
   "format" {:kind :option :short "f" :default "text" :help "Формат: text или json"}
   :default {:kind :accumulate}])

(defn main [& args]
  (def opts (argparse ;spec))
  (unless opts (os/exit 2))
  (def files (opts :default))
  (when (or (nil? files) (empty? files))
    (eprint "stats: укажи хотя бы один файл")
    (os/exit 2))
  (each file files
    (unless (os/stat file)
      (eprintf "stats: файл не найден: %s" file)
      (os/exit 2)))
  (var entries @[])
  (var skipped 0)
  (each file files
    (def result (parse/parse-text (slurp file)))
    (array/concat entries (result :entries))
    (+= skipped (result :skipped)))
  (def level (when-let [lvl (opts "level")]
               (keyword (string/ascii-lower lvl))))
  (def filtered
    (if level (filter |(= level ($ :level)) entries) entries))
  (def stats (report/summary filtered))
  (print (case (opts "format")
           "json" (report/format-json stats skipped)
           (report/format-text stats skipped))))

Заметь: ручного (slice args 1) нет, argparse сам берёт аргументы из (dyn :args) и отбрасывает имя скрипта, а на --help печатает справку и возвращает nil, отсюда (unless opts (os/exit 2)).

Три вещи здесь отличают утилиту от скрипта.

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

Ошибки в stderr, а не stdout. Иначе они попадут в конвейер вместе с отчётом.

Осмысленные коды возврата. Ноль это успех, два это ошибка использования. Без этого утилиту нельзя поставить в && или в скрипт CI.

$ janet stats.janet --level error app.log
всего: 1
  error: 1
пропущено: 1
$ janet stats.janet --format json app.log
{"total": 5, "by-level": {"error": 1, "warn": 1, "info": 3}, "skipped": 1}
$ janet stats.janet missing.log
stats: файл не найден: missing.log
$ echo $?
2

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

Шаг 5: тесты и установка

Два набора, по одному на модуль. Покрывать надо в первую очередь края, а не счастливый путь: счастливый путь ты и так проверил руками на каждом шаге, края нет. Вот test/parse-test.janet, по одной проверке на каждый край:

(import ../src/parse :as parse)
(import spork/test)

(test/start-suite "parse")

(test/assert (nil? (parse/parse-line "это не лог"))
             "битая строка даёт nil, а не ошибку")
(test/assert (= "" (get (parse/parse-line "2026-08-29 14:23:01 INFO ") :message))
             "пустое сообщение допустимо")
(test/assert (= :warn (get (parse/parse-line "2026-08-29  14:23:03   WARN   медленно") :level))
             "лишние пробелы между полями не ломают разбор")
(test/assert (deep= @{:entries @[] :skipped 0} (parse/parse-text ""))
             "пустой ввод: ноль записей, ноль пропущенных")
(test/assert (= 1 (get (parse/parse-text "мусор\n") :skipped))
             "битая строка считается пропущенной")

(test/end-suite)

В test/report-test.janet та же схема: summary на трёх записях, и обязательно последний край из списка, отсутствующий уровень при фильтрации, то есть сводка и форматирование на пустом списке записей:

(test/assert (= 0 (get (report/summary []) :total))
             "фильтр по отсутствующему уровню даёт пустой список, сводка не падает")
(test/assert (= "всего: 0" (report/format-text (report/summary []) 0))
             "текстовый вывод на пустой сводке")
$ jpm -l test
test suite parse finished in 0.000 seconds - 5 of 5 tests passed.
test suite report finished in 0.000 seconds - 4 of 4 tests passed.

Осталась установка:

jpm install        # declare-binscript кладёт скрипт в каталог исполняемых
stats app.log

Альтернатива это самодостаточный бинарник с вкомпилированным интерпретатором:

jpm quickbin stats.janet stats

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

Что мы использовали

ЧтоИз какого урока
Ключевые слова как ключи, структуры записейзначения и ссылки
PEG-грамматика, -1, отрицание вместо классовPEG и захваты
each, unless, if-let, when-let, caseветвления и циклы
Модули, declare-source, приватные определениямодули и jpm
jpm test, spork/test, тесты на краяхтестирование
main, аргументы, os/exit, slurp, eprintfскриптинг
Файберы, ev/spawn, каналы (упражнение e24.6)событийный цикл

Сто тридцать строк вместе с тестами задействовали почти весь раздел. Это и есть обычный размер задачи, на которой Janet раскрывается: слишком много для shell, слишком мало для тяжёлого языка.

Упражнения

Проект намеренно оставлен с местом для роста. Каждое направление опирается на свой урок.

Дальше

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

домашка

Домашка