Где разместить логгер в Golang?
Ведение журнала является неотъемлемой частью любого приложения. Однако правильное расположение и использование логгера в структуре проекта вызывает вопросы даже у опытных разработчиков.
Есть несколько способов сделать это, из которых я отдаю предпочтение одному. Я объясню почему.
При разработке приложения разработчик выбирает один из нескольких явных вариантов:
- сохранить регистратор в глобальной переменной;
- получить логгер из библиотеки логирования;
- добавить логгер в структуры данных;
- явно передать регистратор в вызове функции.
В этой статье я буду использовать регистратор Zap в качестве примера, но вы можете использовать любой.
Сохраните регистратор в глобальной переменной
Это самый простой вариант, который приходит на ум.
var l, _ = zap.NewProduction()
Он хорош своей простотой, но его реализация может быть неэффективной:
- использование глобальных переменных увеличивает связность в программе, ухудшая структуру кода;
- глобальный логгер неудобно подстраивать под конкретный контекст, например — добавлять в логгер общие поля для набора сообщений;
- определение глобальной области действия может оказаться сложной задачей для приложения с несколькими исполняемыми файлами.
Получить регистратор из библиотеки журналов
Эта опция почти аналогична глобальной переменной, только переменная скрыта внутри импортируемого модуля.
l := zap.L()
Это немного удобнее в использовании, потому что вам не нужно искать глобальную переменную в коде и импортировать ее. С другой стороны, при подключении внешних зависимостей возможен конфликт версии библиотеки.
Добавьте регистратор в структуры данных
Это довольно распространенная альтернатива, используемая многими разработчиками. Однако его использование добавляет к структурам элемент, который относится не к структуре данных, а к их инструментированию.
type UserRepository struct {
l *zap.Logger
/*
other fields
*/
}
func (ur *UserRepository) SaveUser(ctx context.Context, u user.User) error {
ur.l.Info("saving user")
// ...
}
Другими словами, логирование связано не с данными, а с операциями, поэтому имеет смысл вести его в функциях и методах, а не в структурах.
Явно передать регистратор в вызове функции
Получается, что самое логичное место для логгера — в самих методах или функциях. Однако передача регистратора в каждую функцию, очевидно, приводит к огромному усложнению кода:
- Добавление параметра к функции ухудшит читабельность подписи;
- явная передача логгера по цепочке вызовов добавляет много копипаста;
- функции с фиксированной сигнатурой (например, обработчик HTTP) потребуют, чтобы вы передали регистратору обходные пути.
func CreateUser(ctx context.Context, u user.User, l *zap.Logger) error {
l = l.With(zap.String("userID", u.ID))
l.Info("user creation started")
if err := SaveUser(ctx, u, l); err != nil {
l.Error("user creation failed", zap.Error(err))
return err
}
l.Info("user creation done")
return nil
}
Это делает подход неприменимым на практике. Но есть ли способ совместить плюсы разных подходов, избегая минусов?
Беспроигрышный контекст
Мне как разработчику хотелось бы передать логгер функции без добавления новых параметров в ее сигнатуру. Но эти требования кажутся противоречивыми.
К счастью, в Golang есть сущность, которая часто передается каждой функции и выполняет множество утилитарных задач — это context.Context
.
Context в Go решает очень важные проблемы:
- изящное прерывание обработки при завершении работы приложения;
- ограничение времени выполнения функции;
- управление асинхронными операциями;
- выход из бесконечных циклов;
- распределенная трассировка.
Как видим, эти задачи чисто утилитарные, то есть не относятся к бизнес-логике приложения. Такая же задача логирования (если мы не говорим о специальных видах логирования, таких как журнал аудита).
Таким образом, передача журнала через контекст не нарушит сигнатуру функции (поскольку большинство функций уже принимают контекст в качестве первого параметра, а другие от этого выиграют). Это можно сделать следующим образом.
package log
import (
"context"
"go.uber.org/zap"
)
type ctxLogger struct{}
// ContextWithLogger adds logger to context
func ContextWithLogger(ctx context.Context, l *zap.Logger) context.Context {
return context.WithValue(ctx, ctxLogger{}, l)
}
// LoggerFromContext returns logger from context
func LoggerFromContext(ctx context.Context) *zap.Logger {
if l, ok := ctx.Value(ctxLogger{}).(*zap.Logger); ok {
return l
}
return zap.L()
}
Это значительно упростит использование логгера и даст дополнительные преимущества:
- добавить в лог общие поля по цепочке вызовов, например добавление userID из запроса;
- внедрить промежуточное ПО для http/grpc, которое будет добавлять в лог информацию о запросе;
- интегрировать журнал с другими инструментами, использующими контекст, такими как распределенная трассировка.
Кроме того, подход решает проблему доступа к глобальным данным — теперь логгер всегда локален по отношению к вызываемой функции, и его можно использовать без ограничений.
func CreateUser(ctx context.Context, u user.User) error {
l := log.LoggerFromContext(ctx).With(zap.String("userID", u.ID))
l.Info("user creation started")
ctx = log.ContextWithLogger(ctx, l)
if err := SaveUser(ctx, u); err != nil {
l.Error("user creation failed", zap.Error(err))
return err
}
l.Info("user creation done")
return nil
}
На практике не всегда удобно использовать такой подход, но он покрывает примерно 90%-95% случаев в серверном приложении. Для остальных случаев можно использовать другой подход из перечисленных выше.
Это также удобно в командной работе, когда разные члены команды могут добавлять контекст вызова в регистратор на разных уровнях приложения, когда они работают над своими изменениями. Это в конечном итоге будет объединено в информативные и полезные записи, с которыми легко работать.