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")
    }
}

Зверніть увагу на стиль: ми використовуємо «умову-запобіжник» — якщо не можна, одразу виходимо. Це робить код прямолінійним: не потрібно вкладати все в 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: намагатися розвʼязати всі майбутні задачі логування одразу.
Дуже спокусливо в цьому місці почати робити «красиву дату», писати у файл, додавати контекст, стектрейси, метрики й ще десять речей. Але тоді замість простого й працездатного логера виходить конструктор болю. Мінімальний логер хороший тим, що його можна впровадити швидко й одразу отримати користь: рівні, фільтрація, єдиний формат.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ