1. println як ліхтарик
Почнімо чесно: System.out.println() — не зло, не «погана практика за визначенням» і не привід для догани під час співбесіди (якщо ви не написали ним увесь мікросервіс). Це радше швидкий ліхтарик: посвітило — побачили, що змінна не null, і пішли далі. У навчальних проєктах і на ранніх прототипах це нормально й навіть корисно.
Найсильніша перевага println — він не потребує інфраструктури. Працює в будь-якому середовищі, у будь-якій IDE і навіть у ситуації «нічого не збирається, але хоча б у одному місці хочу побачити значення». Іноді це як запасний ключ під килимком: не дуже безпечно й не дуже красиво, але часом рятує.
Наприклад, коли ви щойно налаштовуєте конфіг і хочете переконатися, що значення справді прочиталося, рука тягнеться до простого виводу:
int port = 8080; // порт, на якому застосунок планує стартувати
System.out.println("Сервер стартує на порту " + port); // швидкий «ліхтарик»: переконуємося, що значення зчиталося
Або коли ви перевіряєте, що аргументи запуску справді розбираються в потрібному порядку:
String mode = "catalog"; // наприклад, режим застосунку (який сценарій запускаємо)
String command = "search"; // наприклад, команда всередині режиму
System.out.println("mode=" + mode); // перевіряємо, що правильно розібрали mode
System.out.println("command=" + command); // перевіряємо, що правильно розібрали command
Проблема починається не тому, що println «поганий», а тому, що він не масштабується разом із проєктом. У нього немає рівнів важливості, фільтрації, єдиного формату повідомлень і зручного налаштування того, що показувати, а що приховувати. І вже точно він не розрізняє «користувацький результат» і «діагностичну інформацію».
2. println заважає в ReadLater Starter
Зараз ReadLater Starter перебуває в цікавій точці: він уже робить досить багато, щоб ви відчули біль «консольного мислення», але ще достатньо простий, щоб ми могли свідомо вилікувати цю біль без магії фреймворка. У нас є різні режими запуску, є зовнішній HTTP-виклик на client-фазі, є real/mock, є конфіг і вже майже готовий перехід до server-фази.
Уявіть, що ви запускаєте:
./gradlew run --args="catalog search clean code"
# користувач очікує результат пошуку (список книжок або "нічого не знайдено")
# розробник паралельно хоче діагностику (режим, baseUrl, URI, статус, час)
Користувач очікує побачити зрозумілий результат: список книжок або повідомлення «нічого не знайдено». Але вам, як розробнику, водночас хочеться бачити діагностичну картину: який baseUrl застосувався, у якому режимі працює клієнт (real або mock), який URI справді було викликано, скільки зайняв запит і який статус повернувся.
І ось тут починається типова історія з println: ви додаєте «трохи» друку в одному місці, потім «ще один вивід» у другому, потім ловите виняток і друкуєте його десь у третьому, і дуже швидко консоль перетворюється на кашу, де важливе й неважливе виглядають однаково.
Погляньте на такий приклад (він навмисно перебільшений, але дуже життєвий):
// суміш "діагностики" і "користувацького виводу" в одному потоці — і все виглядає однаково важливим
System.out.println("Пошук у каталозі розпочато");
System.out.println("Виклик https://example.com/search?q=clean+code");
System.out.println("200 OK");
System.out.println("Знайдено 3 книжки");
System.out.println("- Clean Code");
Для користувача це виглядає дивно: чому його взагалі має хвилювати, що там «200 OK»? Для розробника — теж дивно: де час запиту? Який саме режим (mock/real)? Який limit? Які таймаути? І найголовніше: як тепер швидко вимкнути повідомлення «Calling …», але залишити, наприклад, помилки?
Найнеприємніший ефект println проявляється, коли застосунок робить кілька дій підряд. Припустімо, ви спочатку друкуєте «нічого не знайдено», а потім в іншому шарі — «request failed». У консолі ці повідомлення стоять поруч, і за ними взагалі незрозуміло: «нічого не знайдено» — це нормальний бізнес-результат чи наслідок мережевої помилки?
Ось приклад, який дуже добре показує проблему змішування:
System.out.println("Нічого не знайдено"); // бізнес-результат (для користувача)
System.out.println("Запит до каталогу завершився помилкою за 1370 мс"); // діагностика (для розробника), але стоїть поруч і плутає картину
У невеликій демоверсії таке ще терпимо, але в проєкті з кількома режимами це перетворюється на «вгадайку». А завтра, коли ми перейдемо до server-фази, консоль стане ще гучнішою: сервер прийматиме запити, і без нормального логування ви дуже швидко втратите зв’язок між «що клієнт запросив» і «що застосунок зробив».
3. Два канали: користувач і діагностика
Дуже корисно один раз (і бажано назавжди) розділити в голові два різні типи повідомлень, які ви виводите назовні. У консольній Java-програмі здається, що все однаково: написали рядок — і гаразд. Але в бекенд-підході важливо розуміти, хто читач вашого повідомлення.
Користувацький вивід відповідає на запитання: «Який результат я отримав?». Він має бути коротким, зрозумілим і максимально без технічних деталей. У нашому проєкті на client-фазі це список знайдених книжок або картка details. На server-фазі це взагалі не консоль, а HTTP response — JSON, який іде клієнту.
Діагностичні повідомлення (логи) відповідають на запитання: «Що застосунок робив, коли намагався дати результат, і де він спіткнувся?». Це повідомлення для розробника (або для людини, яка розбирає проблему), і вони мають містити контекст: режим, вхідні дані, час, статус, виняток.
Зручно порівняти ці два світи в одній таблиці:
| Характеристика | Користувацький вивід | Логи (діагностика / експлуатаційні) |
|---|---|---|
| Головний читач | користувач (або ваш CLI-сценарій) | розробник / оператор застосунку |
| Мета | показати підсумок | пояснити поведінку та причини проблем |
| Допустимий стиль | «людський», короткий | технічний, контекстний |
| Наявність деталей | мінімум | рівно стільки, щоб розслідувати |
| «Шум» | заборонений: дратує користувача | допустимий на debug, але контрольований |
Щоб це закріпилося не як теорія, а як картинка, давайте уявимо шлях одного сценарію catalog details і два «канали» повідомлень:
flowchart TD
A["Користувач запускає: ./gradlew run --args='catalog details OL12345M'"] --> B["ReadLaterApplication (точка входу)"]
B --> C["CatalogService"]
C --> D["CatalogClient HTTP"]
D --> E["Зовнішній Catalog API або mock"]
E --> D --> C --> B
B --> U["Користувацький вивід (консоль): картка книжки"]
B --> L["Логи (діагностика): режим, URI, статус, час, помилки"]
Ключова думка: один і той самий сценарій народжує два різні типи повідомлень. І якщо ви не розділяєте їх від самого початку, ви або засмічуєте користувача, або обрізаєте собі можливість розібратися, що пішло не так.
4. Без рівнів важливості — шум
Ще одна фундаментальна причина, чому println перестає працювати: у нього немає вбудованої ідеї «важливості» повідомлення. Для println усе однакове: «застосунок стартував», «ми зробили запит», «ми отримали 500», «ми зловили виняток» — усе однією й тією самою стрічкою в одному й тому самому стилі.
Коли важливість не позначена, у вас лишаються дві стратегії, і обидві погані. Перша — друкувати лише найважливіше і потім страждати, коли потрібніші деталі. Друга — друкувати все підряд і потім тонути в шумі.
Початківці часто намагаються «винаходити рівні важливості» вручну. Виходить щось на кшталт:
boolean debug = true; // саморобний прапорець "рівня логування" (на практиці швидко починає заважати)
if (debug) {
System.out.println("[DEBUG] Prepared request"); // діагностична деталь, яку хочеться вміти вимикати конфігом
}
System.out.println("[INFO] Catalog search started"); // "важливе" повідомлення, але все одно це той самий println
Це виглядає як логування, але насправді це саморобна система, яка швидко ламається. Вам потрібно змінювати прапорець, перезапускати, іноді перекомпілювати, ви не можете зручно налаштувати «хочу debug лише для catalog, але не хочу debug для config», і формат повідомлень у кожного класу починає «танцювати» як йому заманеться.
Так, технічно можна навісити на рядки префікси [INFO], [ERROR], додати час і ім’я класу вручну. Але тоді ви дуже швидко опинитеся в ситуації, де ваша бізнес-логіка перетворилася на принтер:
System.out.println("2026-03-19 12:00:00 INFO CatalogService search started"); // форматування логів вручну = боляче і дає розбіжності
І ось у цей момент у вас народжується правильне запитання: «А чому я це пишу вручну, якщо є інструменти, які вміють робити це краще, однаково й гнучко налаштовуватися?». Це і є правильний момент для переходу до нормального logging stack — і саме тому рівень 21 стоїть перед server-фазою. Нам дуже скоро знадобляться повідомлення з різною важливістю: від «сервер стартував» до «сталася неочікувана аварія» — і друкувати все однаково вже не можна.
5. Що робить діагностику корисною
Є ще одна пастка println: коли ви розумієте, що треба «щось писати», ви починаєте писати беззмістовні маркери. Типовий приклад:
System.out.println("Запит розпочато"); // незрозуміло: який запит? куди? з чим?
System.out.println("Запит завершено"); // незрозуміло: чим закінчився? скільки зайняло? який статус?
Виглядає бадьоро, але користі майже нуль. Який «запит»? Куди? Яким методом? У якому режимі? Успіх чи помилка? Скільки зайняло часу? Це приблизно як написати в щоденнику: «сьогодні щось сталося». Технічно правда, але розслідуванню не допомагає.
Хороше діагностичне повідомлення має відповідати хоча б на два запитання: «що ми намагалися зробити?» і «чим це закінчилося?». А ще краще — «скільки часу зайняло» і «якими були вхідні дані операції».
Наприклад, замість «Запит завершено» корисніше побачити щось у такому стилі:
Отримання деталей каталогу завершено, externalId=OL12345M status=200 durationMs=1370
Навіть без гарного формату і без логерів ви вже бачите структуру: дія, ідентифікатор, статус, час. І далі ви зможете швидко фільтрувати й аналізувати такі повідомлення (коли з’явиться нормальна система логування).
Тут важливо не впасти в іншу крайність: «раз уже ми робимо діагностику, давайте друкувати взагалі все». Корисна діагностика — це не потік свідомості коду, а набір ключових подій. У нашому проєкті такими подіями є старт застосунку і вибраний режим, старт зовнішнього виклику та його завершення, статус відповіді, час виконання і помилки, які справді заважають сценарію.
6. Дисципліна println: межі шарів
Поки ми ще не підключили Logger у коді (це буде в наступній лекції), нам потрібно запровадити дуже просту дисципліну, щоб проєкт не заростав println-ами, як кухня чашками після сесії. Сенс дисципліни не в тому, щоб узагалі заборонити друк, а в тому, щоб зафіксувати межі відповідальності.
Перше правило звучить так: користувацький вивід має жити в місці, яке відповідає за користувацький сценарій, а не в сервісах і клієнтах. Якщо CatalogService раптом сам друкує «пошук розпочато», а потім ReadLaterApplication друкує «знайдено 3 книжки», ви втрачаєте контроль над тим, що користувач бачить і в якому порядку.
Друге правило: «діагностичні println-и» — це тимчасова підпірка, яку ми викинемо. Якщо ви зловили себе на думці «зараз швиденько додам друк у CatalogClient», зупиніться і подумайте: це користувацький вивід чи діагностика? Якщо це діагностика, то правильно не «додати ще один println», а підготувати місце, де це стане логом.
Щоб відчуття було більш практичним, ось приклад правильної (для поточного етапу) межі: нехай сервіс повертає дані, а вивід робиться у «зовнішньому» шарі.
import java.util.List;
public class CatalogPresenter {
public void printTitles(List<String> titles) {
// користувацький вивід: говоримо лише про результат, без технічних деталей
System.out.println("Знайдено " + titles.size() + " книжок:");
titles.forEach(t -> System.out.println("- " + t)); // друкуємо рівно те, що має побачити користувач
}
}
І зверніть увагу на зміст: цей код «говорить» із користувачем. Він не друкує URI, не друкує статус зовнішнього API, не друкує stack trace. Він друкує результат.
А ось приклад того, чого ми намагаємося уникати вже зараз: друк із глибини застосунку, де він починає жити своїм життям.
public class CatalogService {
public void search(String query) {
System.out.println("Пошук за запитом=" + query); // діагностика, яка "протікає" до користувацького каналу
// ... реальна логіка пошуку
}
}
Зараз це здається зручним, але завтра ви захочете вимкнути «Пошук …», а залишити «помилку запиту». Потім захочете «Пошук …» лише для catalog, а не для readinglist. Потім захочете додати час і статус. І ви неминуче прийдете до логування — тому краще одразу не розпорошувати println по шарах.
У наступній лекції ми якраз зробимо наступний крок: діагностика переїде в нормальний стек логування, а користувацький вивід залишиться в тих місцях, де він справді потрібен користувачеві (на client-фазі), — і перестане заважати розбору проблем.
7. Типові помилки під час використання println
Коли ви вперше починаєте думати про логування, дуже легко припуститися кількох помилок, які майже непомітні на маленькому проєкті, але чудово вибухають на тому, що росте. Якщо ви впізнаєте себе хоча б в одній — вітаю, ви нормальна людина, а не робот з ідеальними звичками.
Помилка №1: змішування користувацького виводу та діагностики.
Найчастіший сценарій: ви друкуєте користувачеві «нічого не знайдено», а поруч — «запит не виконано». У підсумку навіть ви самі за тиждень не зрозумієте: «нічого не знайдено» — це нормальний результат пошуку чи наслідок помилки? Уведіть просте правило: користувацький вивід — про підсумок, діагностика — про процес, і ці речі житимуть у різних механізмах.
Помилка №2: повідомлення без контексту.
Фрази на кшталт «started», «finished», «error happened» швидко перетворюються на шум, бо вони не відповідають на головне запитання: що саме стартувало і що саме завершилося? Навіть якщо ви поки друкуєте в консоль, додавайте мінімум контексту: режим, ідентифікатор, статус, час. Тоді повідомлення хоча б можна буде читати.
Помилка №3: println замість обробки помилок.
Іноді здається, що можна «не ускладнювати» і просто написати System.out.println("Щось пішло не так"). Але друк не змінює поведінку застосунку: сценарій усе одно розвалюється, тільки тепер незрозуміло де і чому. Помилки мають оброблятися кодом (винятками, перевірками, контрактом), а логи — допомагати зрозуміти деталі.
Помилка №4: дублювання того самого повідомлення на кожному шарі.
Якщо ReadLaterApplication, CatalogService і CatalogClient усі друкують «search started», ви не отримуєте більше інформації — ви отримуєте три однакові стрічки. Діагностика має додавати цінність: кожен шар або дає унікальний контекст, або мовчить. Це особливо важливо, коли ви перейдете до error-логів: потрійне дублювання стектрейсів перетворює проблему на кошмар.
Помилка №5: «тимчасові відладочні println-и» стають постійними.
«Я зараз на хвилинку додам друк» — фраза, після якої в коді оселяється безсмертний println, який переживе вас, ваших тімлідів і пару епох Java. Якщо повідомлення діагностичне, його місце — в логах, і воно має бути керованим рівнем і конфігурацією, а не вічною стрічкою посеред бізнес-методу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ