1. Две части logging stack
Мы уже разделили пользовательский вывод и диагностику. Теперь нужен инструмент именно для диагностического канала: с уровнями, форматом и настройкой, чтобы всё не скатывалось обратно в новый println().
Когда говорят “подключи логирование”, новичок часто представляет что-то вроде: «ну это ещё один способ печатать текст». На самом деле logging stack — это маленькая система, где у каждого участника своя работа. И хорошая новость: она не сложная, если не пытаться изучить всё сразу, как будто сдаёшь экзамен по “Логам и страданиям”.
В нашем курсе logging stack будет состоять из двух ролей. Первая роль — это единый API в коде, через который мы пишем сообщения (так, чтобы потом можно было поменять “движок” логов и не переписывать проект). Вторая роль — это реальная реализация, которая решает, куда эти сообщения попадут (в консоль, файл и т.п.) и как будет выглядеть каждая строка лога.
Давайте посмотрим на эту систему как на “трубу”, по которой ваши сообщения идут наружу:
flowchart LR A["Ваш Java-код
log.info(...)"] --> B["SLF4J API
(facade)"] B --> C["Logback
(implementation)"] C --> D["Appender
Console"] D --> E["stderr / terminal diagnostics"]
Важная мысль: в коде приложения мы хотим видеть только SLF4J, а не логбековские классы. Logback должен жить “под капотом” и не смешиваться с бизнес-кодом. Это как с розеткой: вам важно, что в стене стандартная розетка, а не то, как именно электростанция вырабатывает энергию.
2. SLF4J: единый фасад
Если прочитать SLF4J вслух, получится что-то вроде “эс-эль-эф-четыре-джей”. Название звучит как имя робота из “Звёздных войн”, но это всего лишь аббревиатура от Simple Logging Facade for Java. Ключевое слово — Facade (фасад): то есть единый “вход” в логирование для вашего кода.
Зачем нужен фасад? Потому что вы не хотите, чтобы ваш проект был “женат” на конкретной реализации логирования. Сегодня вы используете Logback (и в курсе это фиксировано), завтра в другой компании будет Log4j2 или что-то ещё. Если код завязан на реализацию, менять становится больно. Если код завязан на SLF4J, менять можно без переписывания сотен классов.
Минимальный “правильный” импорт, который нас интересует:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
И типовой шаблон в классе:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CatalogService {
// Логгер создаём один раз на класс, а не в каждом методе
private static final Logger log = LoggerFactory.getLogger(CatalogService.class);
public void search(String query) {
// {} — плейсхолдер SLF4J: строка склеится только если уровень логирования включён
log.info("Catalog search started, query={}", query);
}
}
Обратите внимание на три детали. Во-первых, мы не создаём Logger “на лету” внутри метода — он как бы “прикручен” к классу. Во-вторых, мы пишем через log.info(...), а не через System.out.println(...). В-третьих, мы используем плейсхолдер {} — это чуть-чуть магии ради того, чтобы не склеивать строки вручную (и не платить за конкатенацию, когда уровень логирования отключён).
Пока держим в голове простое правило: в коде приложения — только SLF4J. Это одна из тех привычек, которые потом очень помогут, когда вы придёте в Spring-проекты и увидите ровно такой же подход.
3. Logback: реализация логов
SLF4J сам по себе ничего не “печатает”. Он похож на пульт управления, который должен быть подключён к телевизору. Если телевизора нет — вы нажимаете кнопки, а в комнате просто становится чуть грустнее. Поэтому нам нужна реализация — Logback.
Logback отвечает за практические вопросы: куда писать сообщения (в консоль), как форматировать строку, какие уровни включены по умолчанию. И самое приятное: всё это настраивается вне кода, в XML-конфиге, который лежит в ресурсах проекта.
В нашем курсе мы используем Logback не потому что он “самый лучший в мире” (в мире Java это опасная фраза), а потому что он популярный, стабильный и отлично дружит со SLF4J. Плюс многие библиотеки уже логируют через SLF4J, а значит наши настройки автоматически влияют и на логи библиотек.
Чтобы картинка окончательно сложилась, можно проговорить это человеческим языком: мы пишем в SLF4J, а Logback читает это и реально выводит.
4. Gradle: SLF4J и Logback
Сейчас будет небольшая “инженерная дисциплина”, но без неё логирование иногда превращается в загадку: «я пишу log.info(...), а где мои сообщения?». Ответ часто в зависимостях.
В ReadLater Starter нам нужен SLF4J как API на этапе компиляции (ведь мы импортируем Logger и LoggerFactory), поэтому он обычно подключается через implementation. А Logback — это runtime-реализация; по смыслу ему достаточно быть на classpath во время запуска, поэтому его часто кладут в runtimeOnly. Это не “обязательный закон”, но хороший стиль: вы явно показываете, что приложение компилируется от SLF4J, а Logback — сменяемая реализация.
Пример фрагмента build.gradle.kts (как ориентир):
dependencies {
// SLF4J — это API, от него компилируется код (Logger/LoggerFactory)
implementation("org.slf4j:slf4j-api:2.0.17")
// Logback — реализация, нужна на этапе запуска (runtime classpath)
runtimeOnly("ch.qos.logback:logback-classic:1.5.32")
// Вынесено отдельно, чтобы было явно видно: classic подтягивает core, но мы фиксируем версию осознанно
runtimeOnly("ch.qos.logback:logback-core:1.5.32")
}
Если у вас Logback объявлен как implementation, проект тоже будет работать. Но runtimeOnly более честно отражает идею “фасад в коде — реализация на запуске”.
И ещё один нюанс. Если вдруг вы подключите несколько реализаций одновременно (например, Logback и другую), SLF4J начнёт ругаться на “несколько провайдеров”. Мы это не делаем, но полезно знать: иногда такие предупреждения — не “страшная ошибка”, а сигнал, что в classpath лишнее.
5. Logger и LoggerFactory
Когда вы пишете System.out.println, вы напрямую обращаетесь к одному глобальному потоку вывода. У Logger подход другой: он как “микрофон”, привязанный к конкретному источнику. Источник обычно — это класс (или пакет), и это очень удобно: потом по имени логгера можно фильтровать и включать/выключать детализацию.
Типовой шаблон (который мы будем использовать по всему проекту) выглядит так:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ReadLaterApplication {
private static final Logger log = LoggerFactory.getLogger(ReadLaterApplication.class);
public void run(String[] args) {
log.info("ReadLater started");
}
}
Почему private static final? Потому что логгер не должен создаваться много раз, он не меняется, и он не зависит от состояния объекта. Вам не нужно 50 логгеров на 50 экземпляров класса — достаточно одного на класс.
Почему LoggerFactory.getLogger(SomeClass.class), а не getLogger("какая-то строка")? Можно и строкой, но привязка к классу работает как “самодокументирование”: читаешь лог и сразу видишь источник сообщения. Это экономит нервы (а нервы, как известно, невосполнимы; по крайней мере так говорит каждый разработчик после третьего продакшн-инцидента).
И ещё маленькая, но важная практика: логгер должен быть в каждом классе, где есть полезные диагностические события. Не нужно пытаться сделать “один общий логгер на весь проект” — это быстро уничтожает ценность логов.
6. Подключение Logback через SLF4J
Сейчас будет короткая “магия без магии”. Вопрос: вы написали log.info(...). Как SLF4J понимает, кто должен это обработать? Ответ: он ищет провайдера (implementation) в classpath. В нашем случае этим провайдером является Logback.
Если Logback отсутствует, SLF4J обычно ведёт себя как “тихий человек”: он может вывести предупреждение при старте и дальше ничего не писать, потому что реализации нет. Это выглядит так, будто ваш код “не логирует”, хотя на самом деле он логирует в пустоту.
Если реализаций несколько, SLF4J тоже предупредит. Типичный сценарий для новичка: вы случайно подключили лишнюю зависимость, и теперь в консоли сообщение вроде “Multiple SLF4J providers were found”. Это не конец света, но это знак, что пора прибраться в зависимостях.
Нам в курсе важно не запоминать точный текст предупреждений, а понять принцип. SLF4J — это интерфейс и маршрутизатор. Logback — это “двигатель”. Если двигатель не подключён или подключено два двигателя одновременно, SLF4J честно сигнализирует, что ситуация странная.
7. logback.xml в src/main/resources
До этого момента мы говорили в основном о Java-коде, но важная часть logging stack — конфигурация. И тут опять хорошая новость: мы не будем хардкодить уровни логирования и формат сообщений внутри классов. Это быстро превращает код в “конфиг-кашку” и делает изменения неудобными.
Logback по умолчанию ищет конфигурацию в classpath. Самый привычный вариант — файл logback.xml, который лежит в корне ресурсов. В нашем проекте это означает путь src/main/resources/logback.xml. Он попадёт в classpath при запуске через Gradle, и Logback автоматически его подхватит.
Этот файл описывает именно диагностический канал. Пользовательский CLI-вывод можно оставлять отдельно, чтобы результат команды и технические сообщения не спорили за одно и то же место в консоли.
Покажу в виде “куда положить файл”:
Файл: src/main/resources/logback.xml
<!-- тут будет конфигурация -->
Почему это место важно? Потому что ресурсы — это часть артефакта приложения. Вы собираете jar, запускаете его, и настройки логов едут вместе с ним. Это базовая инженерная привычка: конфигурация должна жить рядом с приложением, а не быть спрятанной в IDE-настройках или в вашем личном “волшебном” файле где-то на диске.
8. Минимальный logback.xml
Logback умеет много, но мы — в bridge-курсе, и нам нужна минимально полезная конфигурация. Обычно достаточно консольного appender-а, понятного формата строк и одного уровня по умолчанию.
Возьмём компактный стартовый конфиг, который сразу отправляет диагностику в stderr:
Файл: src/main/resources/logback.xml
<configuration>
<!-- Appender — это "куда писать". Здесь пишем в stderr -->
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<!-- Pattern — это "как выглядит строка лога" -->
<pattern>%d{HH:mm:ss.SSS} %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<!-- root level — уровень по умолчанию для всех логгеров -->
<root level="INFO">
<!-- Подключаем appender к root, иначе он не будет использоваться -->
<appender-ref ref="STDERR"/>
</root>
</configuration>
Теперь разберём термины максимально приземлённо.
Appender — это “куда писать”. Здесь пишем в stderr, чтобы диагностический поток не смешивался с пользовательским stdout.
Pattern — это “как выглядит одна строка”. Здесь мы выводим время, уровень, имя логгера (обычно класс), сообщение и перевод строки.
root level — это уровень по умолчанию для всех логгеров, если вы отдельно ничего не настроили.
Если вы запустите приложение, логи начнут выглядеть примерно так (формат зависит от pattern):
12:10:03.512 INFO com.example.readlater.app.ReadLaterApplication - ReadLater started
Суперважная мысль: конфиг можно менять без правки Java-кода. Например, если вам нужно больше деталей, можно временно поставить DEBUG на root — и сразу увидите больше сообщений (но в следующей лекции мы отдельно поговорим о том, почему DEBUG на root — это иногда как включить пожарную сигнализацию просто потому что вам скучно).
Так stdout остаётся свободным для пользовательского вывода из CLI-режимов, а диагностика идёт своей дорогой.
9. root level и уровни пакетов
В реальном проекте вам часто нужна асимметрия. Например, каталоговый клиент (catalog) хочется видеть подробнее (там внешние вызовы, таймауты, статусы), а всё остальное — оставить на спокойном INFO, чтобы консоль не превратилась в “пулемёт логов”.
Logback позволяет настроить уровень для конкретного пакета:
<!-- Включаем DEBUG только для конкретного пакета -->
<logger name="com.example.readlater.catalog" level="DEBUG"/>
И при этом оставить root на INFO. Тогда от catalog вы получите и DEBUG, и INFO, а от остальных пакетов — только INFO и выше.
Пример целиком (покажу фрагмент, который добавляется в logback.xml):
Файл: src/main/resources/logback.xml (фрагмент)
<!-- Подробные логи только для каталога -->
<logger name="com.example.readlater.catalog" level="DEBUG"/>
<!-- Для всего остального оставляем более "тихий" уровень -->
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
Почему это важно для новичка? Потому что это дисциплина: “подкрутить детализацию там, где болит”, вместо того чтобы “включить всё везде” и потом утонуть в шуме.
В ReadLater Starter такой подход особенно удобен в client-фазе: когда вы отлаживаете catalog details, вы можете временно дать DEBUG только catalog.client или catalog.service, не трогая остальные пакеты.
10. Logging stack в ReadLater Starter
Теперь соберём всё в единый, узнаваемый кусок проекта. Пусть у нас есть CatalogService, который вызывает клиент и возвращает результаты. Мы хотим, чтобы service честно писал “ключевые события”, но не превращался в печатный станок.
Например так:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CatalogService {
// Логгер на класс: по нему удобно фильтровать логи в конфигурации
private static final Logger log = LoggerFactory.getLogger(CatalogService.class);
public void search(String query) {
// Важно логировать входные параметры (в разумных пределах), чтобы потом можно было воспроизвести ситуацию
log.info("Catalog search started, query={}", query);
// Здесь реальный вызов клиента и обработка результата
// Симметричное сообщение "закончили" помогает видеть длительность/границы операции в потоке логов
log.info("Catalog search finished, query={}", query);
}
}
И в точке входа мы логируем старт режима:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ReadLaterApplication {
// В логах будет видно имя класса — это почти всегда то, что нужно на старте
private static final Logger log = LoggerFactory.getLogger(ReadLaterApplication.class);
public void run(String[] args) {
log.info("Application started");
// Разбор args и запуск нужного режима (не забывайте: если логов будет мало, отлаживать будет больно)
}
}
Что важно в этом примере. Мы не делаем “универсальный логгер” в common, мы не тащим Logback-классы в код, и мы не пытаемся решать уровни через if-ы в Java. Java-код сообщает факты и контекст, а Logback решает, как это показывать.
Если вы сейчас включите logback.xml, запустите приложение через Gradle и выполните какую-нибудь команду, вы получите нормальные, ровные строки логов. И самое приятное — вы сможете договариваться о стиле сообщений, потому что все сообщения идут через один и тот же механизм.
11. Типичные ошибки при настройке SLF4J + Logback
Ошибка №1: писать в коде на классах Logback вместо SLF4J.
Иногда хочется “подлезть поближе к железу” и импортировать что-то из ch.qos.logback.*. На практике это сразу привязывает ваш код к реализации и ломает идею фасада. В приложении держите импорты только org.slf4j.Logger и org.slf4j.LoggerFactory, а Logback оставляйте в зависимостях и logback.xml.
Ошибка №2: создавать Logger внутри каждого метода.
Код вида Logger log = LoggerFactory.getLogger(...) в начале каждого метода выглядит безобидно, но это лишний шум и плохая привычка. Логгер должен быть полем класса, обычно private static final. Так код проще читать, и вы точно не забудете, что логгер есть.
Ошибка №3: положить logback.xml не туда и потом “искать баг в Java”.
Logback ищет конфиг в classpath. Если вы положили файл, например, в src/main/java или в корень репозитория, он просто не попадёт в ресурсы, и Logback его не увидит. Правильное место — src/main/resources/logback.xml. Это звучит как мелочь, но такие мелочи чаще всего и съедают время.
Ошибка №4: подключить SLF4J, но забыть реализацию (Logback), и удивляться, что логов нет.
SLF4J без реализации — это фасад без дома. Приложение компилируется (потому что API есть), но на запуске сообщения могут исчезнуть в “NOP”. Проверяйте, что в зависимостях есть logback-classic (и что он реально попал в runtime classpath).
Ошибка №5: случайно подключить две реализации и игнорировать предупреждения.
Если вы увидели предупреждение про несколько SLF4J providers, это почти всегда значит, что в classpath лишняя зависимость. В учебном проекте лучше сразу приучаться к чистоте: одна реализация — один предсказуемый вывод. Иначе часть логов может пойти “не туда” или формат неожиданно поменяется.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ