Автор: Антон Козицкий
Создаем свой логгер с нуля
Интро
Идея написать эту главу у меня пришла не сразу. На самом деле кому нужно писать логгер, если есть готовые библиотеки, которые ты просто добавляешь в свой проект и все работает. Всё так и есть, но есть один момент: мы не собираемся использовать наш самописный логгер в реальных проектах — нам он нужен лишь как инструмент для обучения.
А всё началось с того, что в своей практике, когда мне нужно было добавить логирование в какой-то функционал в коде, я просто добавлял одну и ту же строчку кода в начало класса и далее уже расставлял логи. Это работало и мне этого хватало:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
public User getUser(UUID userId) {
log.info("Getting user by id={}", userId);
// other code
}
}
Это выводило лог наподобие этого:
2025-02-13 09:02:17 INFO Getting user by id=093d38ea-09fa-4a40-9f6a-ce758595ef68
Позже время от времени я сталкивался с различного рода проблемами в своей практике:
- Логи не выводились по непонятной для меня причине. То есть в коде они были добавлены, но после запуска программы их не было видно: ни в консоли, ни в файле для логов;
- Для меня всегда были "магией" файлы с конфигурацией логгеров. Часто я просто брал готовый конфиг и переиспользовал, так и не поняв что там и для чего в нём;
- Способ задания уровней логирования. Для меня это тоже было "некой" магией, когда мы в конфигурации
Spring Boot, в его
application.properties/application.yamlпросто указываем имена пакетов и напротив выставляем желаемый уровень логирования. Написал — работает, на этом моё погружение в тему и заканчивалось; - Для меня всегда было загадкой то, как логи из нашего приложения оказываются в агрегаторе логов (DataDog / GrayLog / Docker container logs).
Чтобы наконец-то раскрыть для себя эту тему, я решил углубиться в тему логирования, а также попробовать самостоятельно реализовать простейший логгер (без оптимизаций — просто базовую версию). Делюсь с вами кодом здесь. Очень надеюсь, что и для вас это сыграет ту же полезную роль, что и для меня.
Простой логгер
Прежде чем мы перейдем к коду, давайте сначала обсудим важные моменты о том, как логгеры устроены и почему они устроены так, а не иначе.
Один файл = один логгер
И на это есть свои причины.
Первая причина — иметь возможность видеть в логах контекст. Посмотрите на эти логи:
2026-02-13 09:13:57.533 [restartedMain] INFO org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean - Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-02-13 09:13:57.626 [restartedMain] INFO io.devmentor.dmweb.service.impl.CodeSnippetServiceImpl - Loading code snippets config from: file:/C:/Users/Anton/projects/dm/dm-web/target/classes/code-snippets/snippets-map.yaml
2026-02-13 09:13:57.631 [restartedMain] INFO io.devmentor.dmweb.dao.repository.impl.YamlArticleRepository - Loading articles from: file:/C:/Users/Anton/projects/dm/dm-web/target/classes/articles.yaml
2026-02-13 09:13:57.918 [restartedMain] INFO org.springframework.data.jpa.repository.query.QueryEnhancerFactory - Hibernate is in classpath; If applicable, HQL parser will be used.
Думаю, вы замечаете, что каждый лог ссылается на класс, из которого он был выброшен. Это очень удобно во время отладки, так как в голове у себя вы с легкостью можете состыковать лог с классом, из которого он был выброшен.
Вторая причина — возможность задавать уровни логирования гранулировано. Посмотрите на этот пример, чтобы понять что я имею в виду:
logging:
level:
ROOT: info
io:
devmentor:
dmweb:
controller:
exception: warn
org:
hibernate: warn
springframework:
web: error
web.servlet.DispatcherServlet: debug
security: trace
thymeleaf: info
thymeleaf.templateresolver: debug
Мы сейчас не будем углубляться в правила и синтаксис (этой цели будет посвящена отдельная статья в этом
туториале), а лишь посмотрим на то, что у нас есть возможность задавать разные уровни логирования как
на уровне пакетов, так и на уровне отдельных классов. Например, скажем, что мы хотим отладить
авторизацию — хорошо. Идём в конфиг и ставим уровень trace только для одного, конкретного
нужного нам пакета, в то время как остальные оставляем на более высоком уровне логирования. Итого, мы
полностью сфокусированы на security-логах.
"Фабрика" для управления логгерами
Мы используем паттерн "фабрика" для получения логгеров. В своём коде мы просто вызываем фабричный метод
LoggerFactory.getLogger(Our.class) и всё — всю остальную работу логгер делает "под капотом"
сам. Что именно он делает, мы рассмотрим дальше.
Имплементация
Полный код проекта вы найдете на GitHub — devmentor-io/simple-logger .
Структура проекта
Ниже представлена структура нашего логгера:
src/main/java/io/devmentor/learning/
├── simplelogger/
│ ├── LogLevel.java — enum, определяющий уровни логирования (TRACE, DEBUG, INFO, WARN, ERROR)
│ ├── Logger.java — класс логгера
│ └── LoggerFactory.java — фабрика логгеров
├── SimpleTestApplication.java — пример базового использования логгера
└── AdvancedTestApplication.java — пример расширенного использования логгера
По сути, наш проект будет состоять из трёх основных классов:
- ENUM - здесь мы опишем все уровни логирования, которые мы хотели бы поддерживать;
- Класс логгера, у которого будет имя, будет указан уровень логирования и ссылка на родительский логгер;
- Фабрика, которую мы будем использовать, чтобы получить логгер по имени.
Теперь давайте пройдёмся подробно по каждой части.
LogLevel
public enum LogLevel {
TRACE,
DEBUG,
INFO,
WARN,
ERROR;
/*
Вызывая этот метод мы будем решать: логировать или нет. И основано это решение
будет на "включенном" уровне логирования для конкретного лога из логгера.
Например, мы выставили на настройках логгера для класса UserService.java что
хотим логировать на уровне INFO. Но в коде у нас идет DEBUG-лог. В этом случае
лог не будет показан. Но будет показан, если мы изменим в конфигурации уровень
логирования на DEBUG или TRACE.
То есть здесь в методе мы сравниваем "заданный уровень логирования" с
фактическим "уровнем логирования" лога, который мы собираемся отобразить
*/
public boolean isEnabled(LogLevel other) {
return other.ordinal() >= this.ordinal();
}
}
Наш логгер поддерживает пять уровней логирования. Прошу заметить, что некоторые логгеры также
поддерживают уровень FATAL, но мы не будем его включать в нашу реализацию, так как
используется он достаточно редко. Если и используется, то не в бизнес-логике, а в системных
приложениях.
Logger
import java.time.LocalDateTime;
public class Logger {
private final String name;
private LogLevel level;
private Logger parent;
/*
Единственным обязательным параметром для логгера у нас является его имя, а
имя логгера - это его fully qualified name, например:
"io.devmentor.learning.SimpleTestApplication"
*/
public Logger(String name) {
this.name = name;
}
/*
В LoggerFactory мы будем устанавливать ссылку на родительский логгер
для текущего логгера
*/
public void setParent(Logger parent) {
this.parent = parent;
}
/*
Этот метод используется для задания уровня логирования для логгера.
Если не задан в какое-то особое желаемое значение, то по умолчанию
будет INFO (стандарт для продакшн)
*/
public void setLevel(LogLevel level) {
this.level = level;
}
public void trace(String message) {
log(LogLevel.TRACE, message);
}
public void debug(String message) {
log(LogLevel.DEBUG, message);
}
public void info(String message) {
log(LogLevel.INFO, message);
}
public void warn(String message) {
log(LogLevel.WARN, message);
}
public void error(String message) {
log(LogLevel.ERROR, message);
}
/*
Приватный метод, который вызывают все методы логирования, передавая
желаемый уровень в параметре метода. Если уровень включен (проверяем в LogLevel#isEnabled(LogLevel other)),
то отображаем. В противном случае - просто игнорируем (не выводим лог)
*/
private void log(LogLevel level, String message) {
LogLevel effectiveLevel = getEffectiveLevel();
if (!effectiveLevel.isEnabled(level)) {
return;
}
/*
Формируем лог по заданному шаблону. Пример получившегося лога:
2026-02-13T12:33:10.675385900 INFO [io.devmentor.learning.SimpleTestApplication] - Calling SimpleTestApplication.main() method
В настоящих логгерах эту конфигурацию можно задавать через файл конфигурации, подробно описывая шаблон
*/
System.out.printf(
"%s %-5s [%s] - %s%n",
LocalDateTime.now(),
level,
name,
message
);
}
/*
В этом методе пытаемся рекурсивно вычислить уровень логирования для текущего логгера.
У нас есть такие варианты:
1. текущий логгер имеет свой уровень логирования - будет использоваться он (задается через Logger#setLevel(LogLevel level) метод)
2. уровень логирования задан в одном из родительских логгеров (найдем через рекурсивный поиск)
3. уровень логирования не задан ни в этом логгере, ни в одном из родительских (будет использован
уровень логирования из ROOT-логгера, который является логгером по умолчанию и всегда существует)
*/
private LogLevel getEffectiveLevel() {
if (level != null) {
return level;
}
if (parent != null) {
return parent.getEffectiveLevel();
}
return LogLevel.INFO; // default logging level
}
}
LoggerFactory
Все логи мы храним в Java Map. А для того чтобы нашу реализацию сделать
потокобезопасной, мы будем использовать имплементацию ConcurrentHashMap.
У нас всегда будет существовать ROOT-логгер, который будет родительским для всех остальных логгеров, если не были заданы более младшие "родители" для них.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LoggerFactory {
private static final Map<String, Logger> LOGGERS = new ConcurrentHashMap<>();
private static final Logger ROOT_LOGGER = new Logger("ROOT");
/*
Задаем уровень логирования по умолчанию для ROOT-логгера.
Кладем ROOT-логгер в мапу с логгерами
*/
static {
ROOT_LOGGER.setLevel(LogLevel.INFO);
LOGGERS.put("ROOT", ROOT_LOGGER);
}
/*
Фабричный метод для получения логгера по его имени, в роли которого выступает
fully qualified name
*/
public static Logger getLogger(Class<?> clazz) {
String fullyQualifiedName = clazz.getName();
return getLogger(fullyQualifiedName);
}
/*
Пробуем получить логгер по имени из мапы. Если нет такого логгера еще, то создаем
*/
public static Logger getLogger(final String name) {
return LOGGERS.computeIfAbsent(name, LoggerFactory::createLogger);
}
/*
Создаем новый логгер, указывая его новое имя. Выглядит вот так: "io.devmentor.learning.SimpleTestApplication".
Затем находим родительский логгер, рекурсивно проходя по частям его имени
*/
private static Logger createLogger(String name) {
Logger logger = new Logger(name);
Logger parent = findParentLogger(name);
logger.setParent(parent);
return logger;
}
/*
Идентификатором логгера служит его fully qualified name,
которое состоит из частей, разделенных точками. Рекурсивно проходим и
ищем родительский лог:
"io.devmentor.learning.SimpleTestApplication"
"io.devmentor.learning"
"io.devmentor"
"io"
ROOT
*/
private static Logger findParentLogger(String name) {
int lastDot = name.lastIndexOf('.');
// если "SimpleTestApplication", то вернет -1
while (lastDot > 0) {
String parentName = name.substring(0, lastDot);
Logger parent = LOGGERS.get(parentName);
if (parent != null) {
return parent;
}
lastDot = parentName.lastIndexOf('.');
}
return ROOT_LOGGER;
}
}
Как работать с этим кодом
У вас есть несколько опций:
- Посмотреть на код здесь и все на этом — лёгкое погружение;
- Склонировать проект с GitHub, открыть в IntelliJ IDEA, попробовать запустить, посмотреть на результат;
- Можете к опции номер два добавить ещё отладку: поставить брекпоинты и посмотреть как код работает — это даст вам более глубокое представление;
- Попробовать реализовать логгер по памяти, не подсматривая сюда — самый эффективный способ обучения.
Выбирайте свой способ из предложенных выше, в зависимости от ваших целей и от важности этой темы для вас.
А я с вами не прощаюсь и мы увидимся в следующей статье, в которой поговорим про инструменты логирования - готовые решения, которые мы можем использовать в своих проектах.