1. Много println — это ещё не логирование
Когда приложение маленькое, кажется, что можно просто везде писать println("я тут"), println("а теперь тут"), println("а теперь уже не тут"). Работает же! Но в какой-то момент начинается классика: вы ловите странный баг, запускаете программу, видите десять строк «я тут», и… не понимаете, какой именно «тут» был важным и в каком порядке всё происходило.
Тут полезно разделить два вида вывода в консоль. Первый — пользовательский вывод: подсказки, результаты команд, сообщения «Введите число», «Добавлено», «Удалено». Второй — технический след работы программы: что она решила сделать, какие ветки логики выбрала, какие данные обработала, какие предупреждения заметила. Вот второй вид и называется логированием.
И важная мысль: логи не обязаны быть «красивыми» или «дружелюбными». Их задача — быть стабильными, единообразными и удобными для поиска глазами.
Чтобы логи стали управляемыми, нам нужны две вещи: уровни важности и единый формат.
2. Уровни логов и минимальный уровень
Если представить, что программа — это человек, то логи — это его дневник. Только дневник не про чувства, а про «что я делал и почему». И, как в любом дневнике, не все записи одинаково важны.
В этой лекции мы введём четыре классических уровня:
| Уровень | Смысл по-человечески | Типичные ситуации |
|---|---|---|
|
«Детали для разработчика» | промежуточные значения, ветвления, подробности обработки |
|
«Нормальная жизнь программы» | старт/стоп, обработали команду, успешно выполнили действие |
|
«Что-то подозрительное, но живём» | странный ввод, пропуск данных, неидеальная ситуация без падения |
|
«Сценарий провалился» | ошибка операции, исключение, невозможность продолжить сценарий |
Главный практический смысл уровней — возможность сказать: «Показывай мне только предупреждения и ошибки, а подробности спрячь». Это называется минимальный уровень логирования (minLevel): лог печатается, только если его уровень не ниже заданного.
3. Минимальный Logger: уровни, формат и фильтрация
LogLevel как enum class
Когда мы задаём уровни строками ("WARN", "ERROR"), мы сами себе создаём ловушку: "WARM", "ERORR", "warning" — компилятор пропустит, а вы потом будете искать, почему фильтрация «иногда работает».
Поэтому уровни логов — идеальный кандидат на enum class: это фиксированный набор вариантов, который компилятор контролирует.
enum class LogLevel {
DEBUG, INFO, WARN, ERROR
}
Здесь нам удобно то, что enum хранит элементы в порядке объявления, и у каждого элемента есть ordinal (0, 1, 2, 3…). Этот порядок можно использовать для сравнения уровней: чем «правее» уровень, тем он серьёзнее.
Сразу маленькое предупреждение на будущее (без паники): если вы потом поменяете порядок уровней в enum class, то поменяется и смысл фильтрации по ordinal. В рамках учебного мини-логгера это нормально, но помнить полезно.
Единый формат лога
Когда логи печатаются «кто во что горазд», они плохо читаются. Один пишет "[INFO] started", другой — "INFO: start", третий — "start ok". В итоге даже глазами сложно понять, где что.
Минимальный полезный формат (которого уже достаточно, чтобы жить) такой:
timestamp [LEVEL] message
Почему timestamp полезен? Потому что он даёт порядок событий и позволяет хотя бы грубо оценить паузы. Почему мы берём простой System.currentTimeMillis()? Потому что он доступен сразу, без красивого форматирования даты, и сейчас нам важнее дисциплина, чем красота. Красоту легко добавить, а вот привычку писать логи единым способом — чуть сложнее.
Сделаем маленькую функцию форматирования строки лога. Она не обязана быть отдельной функцией, но так легче читать.
fun formatLogLine(ts: Long, level: LogLevel, message: String): String {
return "$ts [$level] $message"
}
Если вызвать formatLogLine(123, LogLevel.WARN, "Suspicious input"), получится предсказуемая строка:
123 [WARN] Suspicious input
И вот это «предсказуемая» — ключевое слово.
Класс Logger: фильтрация и печать в одном месте
Теперь соберём всё в класс Logger. Его идея очень простая: в одном месте решать три задачи.
Первая задача: разрешено ли печатать сообщение такого уровня при текущем minLevel.
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean {
return level.ordinal >= minLevel.ordinal
}
}
Вторая задача: если разрешено — собрать строку по единому формату и вывести.
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean =
level.ordinal >= minLevel.ordinal
fun log(level: LogLevel, message: String) {
if (!enabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message")
}
}
Обратите внимание на стиль: мы используем guard clause — «если нельзя, сразу выходим». Это делает код прямолинейным: не нужно вкладывать всё в if.
Теперь добавим маленький тест, чтобы почувствовать, как работает minLevel:
fun main() {
val logger = Logger(minLevel = LogLevel.WARN)
logger.log(LogLevel.DEBUG, "Details") // не выведется
logger.log(LogLevel.INFO, "Started") // не выведется
logger.log(LogLevel.WARN, "Odd input") // выведется
logger.log(LogLevel.ERROR, "Failed") // выведется
}
Вывод будет примерно таким (timestamp у вас, конечно, будет другой):
1700000000000 [WARN] Odd input
1700000000001 [ERROR] Failed
И это уже огромная победа над «сотней println по всему коду».
Методы debug/info/warn/error
Если каждый раз писать logger.log(LogLevel.INFO, "..."), это быстро надоедает. Кроме того, код читается хуже: глаз постоянно спотыкается о повторяющийся LogLevel.
Поэтому в логгерах почти всегда делают методы-обёртки.
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean =
level.ordinal >= minLevel.ordinal
fun log(level: LogLevel, message: String) {
if (!enabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message")
}
fun debug(message: String) = log(LogLevel.DEBUG, message)
fun info(message: String) = log(LogLevel.INFO, message)
fun warn(message: String) = log(LogLevel.WARN, message)
fun error(message: String) = log(LogLevel.ERROR, message)
}
Теперь код становится похож на рассказ о том, что происходит:
fun main() {
val logger = Logger(LogLevel.INFO)
logger.info("App started") // выведется
logger.debug("x=10, y=20") // не выведется
}
Именно ради этого фильтра мы всё и затевали: debug — для «копания», info — для нормальной жизни.
Пользовательский вывод и логи
Очень легко случайно смешать «сообщения пользователю» и «логи» в одну кашу. В консольных приложениях это особенно коварно: и то, и другое — println.
Договоримся о простом правиле: пользовательский вывод остаётся обычным println, а технические события идут через Logger.
Представим, что у нас есть учебное CLI-приложение со сценариями вроде «добавить запись» и «показать список» (вы уже делали похожее в курсе). Мы не будем усложнять доменную модель — нам важен именно поток сообщений.
fun handleAdd(rawAmount: String, logger: Logger) {
logger.info("Handle add started")
val amount = rawAmount.toIntOrNull()
if (amount == null) {
println("Ошибка: сумма должна быть числом") // сообщение пользователю
logger.warn("Add failed: amount is not a number: '$rawAmount'")
return
}
println("Добавлено: $amount") // сообщение пользователю
logger.info("Add finished: amount=$amount")
}
Заметьте разницу по тону. Пользовательскому сообщению не нужно знать, что «handle started». Ему важно коротко: что не так или что получилось. А логам нормально быть «сухими» и техническими: они нужны не пользователю, а вам (и вашей будущей версии, которая будет вспоминать, что вы имели в виду).
Проверим мини-сценарий:
fun main() {
val logger = Logger(LogLevel.INFO)
println("Введите сумму:")
val raw = readln()
handleAdd(raw, logger)
}
Если пользователь введёт abc, вывод будет выглядеть примерно так:
Введите сумму:
abc
1700000000000 [INFO] Handle add started
Ошибка: сумма должна быть числом
1700000000001 [WARN] Add failed: amount is not a number: 'abc'
Да, пользователь увидит и лог, и сообщение. В реальных приложениях логи часто отправляют в отдельный поток (например, stderr) или в файл — но это уже другая история. Сейчас нам важно научиться разделять смысл.
Приоритет вместо ordinal
Мы уже используем ordinal, и это честно работает. Но есть одна тонкость: ordinal привязан к порядку объявления в enum. Если кто-то в команде решит «давайте сделаем ERROR первым, потому что он самый важный» — фильтрация сломается.
Чтобы поведение было стабильнее, можно задать явный приоритет числом. Всё ещё просто, но уже чуть «взрослее».
enum class LogLevel(val priority: Int) {
DEBUG(10),
INFO(20),
WARN(30),
ERROR(40)
}
И тогда проверка становится независимой от порядка:
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean =
level.priority >= minLevel.priority
fun log(level: LogLevel, message: String) {
if (!enabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message")
}
fun debug(message: String) = log(LogLevel.DEBUG, message)
fun info(message: String) = log(LogLevel.INFO, message)
fun warn(message: String) = log(LogLevel.WARN, message)
fun error(message: String) = log(LogLevel.ERROR, message)
}
Вы можете оставить ordinal (для учебного проекта это норм), но понимать, что происходит, — полезно.
Схема работы
Иногда проще один раз увидеть глазами, чем десять раз прочитать текст. В логировании нам важно, что всё решение о печати сосредоточено в одном месте.
flowchart TD
A["Код приложения
logger.info(...)"] --> B["Logger.enabled(level)"]
B -->|false| C["Ничего не печатаем"]
B -->|true| D["Форматируем строку
ts + [LEVEL] + message"]
D --> E["println(...) в одном месте"]
Если вы привыкнете к этой дисциплине, у вас появится очень приятная суперспособность: когда вы захотите поменять формат логов или добавить новые детали, вы сделаете это в одном месте, а не будете бегать по проекту с поиском println("DEBUG").
4. Типичные ошибки
Ошибка №1: превращать все сообщения в INFO, потому что «так проще».
Так действительно проще… на один вечер. Потом у вас будет миллион INFO, среди которых прячется важное предупреждение или ошибка. Уровни придуманы не для красоты, а чтобы вы могли регулировать шум: DEBUG — детали, INFO — нормальные события, WARN — странности, ERROR — провал операции.
Ошибка №2: форматировать лог-сообщения в разных местах по-разному.
Когда часть кода пишет "ts=$ts level=$level msg=$message", а другая часть пишет "$level: $message", логи невозможно «сканировать» глазами. Правильный подход — формат строки должен жить внутри Logger, а внешний код должен передавать только смысловое сообщение.
Ошибка №3: смешивать пользовательский вывод и логирование так, что пользователю показываются «внутренности».
Сообщение пользователю должно отвечать на вопрос «что мне делать/что произошло», а лог — на вопрос «что делала программа». Если вы в интерфейсе для человека печатаете HandleAdd started; parsing raw=..., пользователь почувствует себя тестировщиком поневоле. Лучше держать println для UX, а logger.* — для диагностики.
Ошибка №4: делать правило фильтрации неочевидным.
Если логгер иногда печатает, иногда нет, а правило выглядит как «если сегодня вторник и строка не пустая, то печатай», вы перестанете доверять логам. На базовом уровне правило должно быть простым и единственным: печатаем, если level >= minLevel.
Ошибка №5: использовать строки вместо enum class для уровней.
Строки удобны ровно до первой опечатки. enum class даёт вам типобезопасность: компилятор не позволит передать «левый» уровень. Это именно тот случай, когда строгие типы делают жизнь проще, а не сложнее.
Ошибка №6: забыть, что ordinal зависит от порядка значений в enum.
Пока у вас четыре уровня и они объявлены в правильном порядке, всё хорошо. Но если порядок поменять, фильтрация поменяется вместе с ним. Если хотите чуть больше стабильности, задайте явный priority и сравнивайте по нему.
Ошибка №7: пытаться решить все будущие задачи логирования сразу.
Очень соблазнительно на этом месте начать делать «красивую дату», писать в файл, добавлять контекст, стек-трейсы, метрики и ещё десять вещей. Но тогда вместо простого и работающего логгера получается конструктор боли. Минимальный логгер хорош тем, что его можно внедрить быстро и сразу получить пользу: уровни, фильтрация, единый формат.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ