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")
}
}
Зверніть увагу на стиль: ми використовуємо «умову-запобіжник» — якщо не можна, одразу виходимо. Це робить код прямолінійним: не потрібно вкладати все в 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: намагатися розвʼязати всі майбутні задачі логування одразу.
Дуже спокусливо в цьому місці почати робити «красиву дату», писати у файл, додавати контекст, стектрейси, метрики й ще десять речей. Але тоді замість простого й працездатного логера виходить конструктор болю. Мінімальний логер хороший тим, що його можна впровадити швидко й одразу отримати користь: рівні, фільтрація, єдиний формат.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ