JavaRush /Курсы /Kotlin SELF /Уровни логов и минимальный Logger

Уровни логов и минимальный Logger

Kotlin SELF
60 уровень , 0 лекция
Открыта

1. Много println — это ещё не логирование

Когда приложение маленькое, кажется, что можно просто везде писать println("я тут"), println("а теперь тут"), println("а теперь уже не тут"). Работает же! Но в какой-то момент начинается классика: вы ловите странный баг, запускаете программу, видите десять строк «я тут», и… не понимаете, какой именно «тут» был важным и в каком порядке всё происходило.

Тут полезно разделить два вида вывода в консоль. Первый — пользовательский вывод: подсказки, результаты команд, сообщения «Введите число», «Добавлено», «Удалено». Второй — технический след работы программы: что она решила сделать, какие ветки логики выбрала, какие данные обработала, какие предупреждения заметила. Вот второй вид и называется логированием.

И важная мысль: логи не обязаны быть «красивыми» или «дружелюбными». Их задача — быть стабильными, единообразными и удобными для поиска глазами.

Чтобы логи стали управляемыми, нам нужны две вещи: уровни важности и единый формат.

2. Уровни логов и минимальный уровень

Если представить, что программа — это человек, то логи — это его дневник. Только дневник не про чувства, а про «что я делал и почему». И, как в любом дневнике, не все записи одинаково важны.

В этой лекции мы введём четыре классических уровня:

Уровень Смысл по-человечески Типичные ситуации
DEBUG
«Детали для разработчика» промежуточные значения, ветвления, подробности обработки
INFO
«Нормальная жизнь программы» старт/стоп, обработали команду, успешно выполнили действие
WARN
«Что-то подозрительное, но живём» странный ввод, пропуск данных, неидеальная ситуация без падения
ERROR
«Сценарий провалился» ошибка операции, исключение, невозможность продолжить сценарий

Главный практический смысл уровней — возможность сказать: «Показывай мне только предупреждения и ошибки, а подробности спрячь». Это называется минимальный уровень логирования (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: пытаться решить все будущие задачи логирования сразу.
Очень соблазнительно на этом месте начать делать «красивую дату», писать в файл, добавлять контекст, стек-трейсы, метрики и ещё десять вещей. Но тогда вместо простого и работающего логгера получается конструктор боли. Минимальный логгер хорош тем, что его можно внедрить быстро и сразу получить пользу: уровни, фильтрация, единый формат.

1
Задача
Kotlin SELF, 60 уровень, 0 лекция
Недоступна
Строка журнала
Строка журнала
1
Задача
Kotlin SELF, 60 уровень, 0 лекция
Недоступна
Фильтр уровней
Фильтр уровней
1
Задача
Kotlin SELF, 60 уровень, 0 лекция
Недоступна
Быстрые методы
Быстрые методы
1
Задача
Kotlin SELF, 60 уровень, 0 лекция
Недоступна
Пользователь и журнал
Пользователь и журнал
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ