Сквозной проект: утилита статистики по логам
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Сквозной проект: утилита статистики по логам
Пора собрать пройденное в одну вещь. Утилита маленькая, около ста тридцати строк вместе с тестами, но проходит через весь раздел: 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, слишком мало для тяжёлого языка.
Упражнения
Проект намеренно оставлен с местом для роста. Каждое направление опирается на свой урок.
Дальше
Проект собран. Дальше урок-справочник по стандартной библиотеке, затем блок алгопрактики со структурами данных и задачами с собеседований, а закрывает раздел обзор экосистемы с идеями, что писать дальше.
домашка