
Привет Хабр! Это моя первая статья тут, я надеюсь получилось читабельно и интересно :-)
Как и следует из названия, в этой статье мы рассмотрим небольшой проект логера, написанный мною на языке golang. За основу взят встроенный и знакомый многим пакет log/slog.
Почему Мульти-логер? Все просто, как и следует из названия, пакет содержит функционал записи логов в несколько хранилищ одновременно. Что нужно для подключения нового хранилища? Для подключения сначала нужно реализовать интерфейс, предоставляемый пакетом, а затем собственно, подключить свою реализацию к мульти-логеру. В общем "стандартный стандарт" во взаимодействии современных компонентов программных систем между собой.
В данной статье, для демонстрации функционала мульти-логера, в качестве хранилищ, я использую нереляционную базу данных - Mongo DB, а также самый простой православный txt файл. Такой выбор обусловлен простотой настройки, а также удобством хранения, каждый лог представляет из себя json структуру, хранение которой, например в реляционной БД, затруднителен и избыточен. По мимо логирования в хранилища, логи, пишутся и в консоль приложения, куда же без этого. А так, при желании, можно подключить любое другое хранилище, при этом различные схемы безопасности доступа, методы записи и т.п - уже лажат на плечах пользователя пакета. В данном случае описан концепт, который конечно же можно развивать.
Всё описанное в этой статье, строго моё видение такой системы, критика так же принимается, всегда интересно услышать мнение читателей.
Система - прототип, цель написания - демонстрация некоторых функций slog пакета, а также своих реализаций функционала мульти-логирования.
Содержание
Структура проекта.
Инициализация логера.
Логика. Handler и Writer.
Заключение.
Структура
Представлю Вам, небольшую таблицу, содержащую структуру проекта (см. ниже).
Название |
Путь |
Описание |
|---|---|---|
main.go |
cmd/main.go |
Точка входа приложения. |
docker-compose.yml |
Docker/docker-compose.yml |
Конфигурация Docker с MongoDB. |
connect.go |
internal/connect/connect.go |
Настройки источников логирования. |
handler.go |
internal/handler/handler.go |
Обработчик логирования. |
logger.go |
internal/logger/logger.go |
Логер. |
log.go |
internal/model/log.go |
Внутренняя модель лога. |
repository.go |
internal/repository/repository.go |
Интерфейсы источников логирования. |
writer.go |
internal/writer/writer.go |
Компонент записи логов. |
log_save.go |
pkg/entity/log_save.go |
Сущность для сохранения лога в MongoDB. |
mongo.go |
pkg/storage/mongo.go |
Подключение к MongoDB. |
log_utils.go |
pkg/utils/log_utils.go |
Внешние утилиты логера. |
log.txt |
test/log.txt |
Файл для записи логов. |
go.mod |
go.mod |
Модуль зависимостей Go. |
Пройдёмся по структуре, по порядку и по делу :-)
Сначала была точка!, точка входа! в cmd/main.go файле, выполняется вызов нашего пакета. Абстрактно можно представить, что это сторонняя программа, использующая логер. Ниже представлен код из main файла (описание работы которого будет дальше по ходу статьи)
ctx, cansel := context.WithCancel(context.TODO())
mongoStg := repository.Settings{
Addr: "127.0.0.1:27018",
Data: storage.MongoSettings{
User: "root",
Password: "12345",
Timeout: 2 * time.Second,
},
}
mongoAlias := make(map[string][]string)
mongoAlias["System"] = []string{"ERROR", "WARN"}
mLvl := []slog.Level{slog.LevelError, slog.LevelInfo, slog.LevelWarn}
gr := connect.NewGroup([]connect.LogConnect{
connect.NewConnect("mongo", mLvl, storage.InitMongoClient("log", mongoAlias), mongoStg, ctx),
})
stg := model.SettingsLogger{
Conv: utils.LogConvert,
AppLog: []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelError},
HdlOpt: &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
},
File: "./test/log.txt",
Flag: os.O_CREATE | os.O_WRONLY | os.O_APPEND,
Mode: os.ModePerm,
}
mLogger := logger.NewMultiLogger(stg, gr)
/*Тестовый вызов логов*/
mLogger.Info("Hello Info")
mLogger.Debug("Hello Debug")
mLogger.Error("Hello Error")
mLogger.Warn("Hello Warn")
excMongo := context.WithValue(ctx, handler.ExcSource, "mongo")
mLogger.InfoContext(excMongo, "Hello Info none BD mongo")
mLogger.ErrorContext(excMongo, "Hello Error none BD mongo")
/*Тестовый вызов логов*/
/*Закрываем подключения*/
defer cansel()
Продолжим описание структуры.
Всё что лежит в каталоге pkg, переиспользуемый набор пакетов, относящийся к абстрактной сторонней программе и нужен также для демонстрации функционала логера.
Ну и собственно каталог internal, содержимое которого и является нашим "виновником торжества" логером.
Инициализация логера
Создадим новый логер (31 стр.). Сигнатура функции NewMultiLogger пакета internal/logger ниже.
func NewMultiLogger(stg model.SettingsLogger, gr *connect.GroupConnect) MultiLogger {}
В качестве аргументов функция принимает два параметра, а именно: настройки (stg) и группы (gr) подключений.
Пройдемся по аргументам функции и опишем их.
-
Группа (
gr). Представляет из себя срез структур, где каждая структура содержит схему для настройки нового подключения логера. При создании новой группы, происходит автоматическая базовая валидация всех подключений, на предмет дублирования их адресов и наименований. Остальная защита от "задубливания" ложится на плечи разработчика, использующего пакет.gr := connect.NewGroup([]connect.LogConnect{})Группа создана, нужно её заполнить. Ниже представлена структура, с описанием полей нового подключения.
type LogConnect struct { /*Название подключения*/ Name string /*Уровни логирования для подключения*/ Levels []slog.Level /*Репозиторий*/ rep repository.Repository /*Настройки репозитория*/ stg repository.Settings /*Ошибка*/ err error }Создадим новую группу и добавим в неё подключение логер (15 стр.). Для создания подключения используется отдельный метод
NewConnectпакета internal/connect ниже его сигнатура:func NewConnect(name string, lvs []slog.Level, conn repository.Connect, stg repository.Settings, ctx context.Context) LogConnectФункция содержит ряд аргументов, а именно:
name- наименование подключения,lvs- перечень уровней, которые будут использоваться этим подключением,conn- реализация подключения,stg- структура настроек подключения, а такжеctx- контекст для управления как подключениями в группе, так и при реализации конкретным подключением.При инициализации новой схемы подключения, в коде выполняются следующие действия: происходит валидация аргументов, инициализация подключения, а также создание отдельного канала со слушателем в лице горутины, для реализации закрытия подключения (пример горутины ниже).
go func() { select { case <-ctx.Done(): err := conn.Close() panic(err) } }()Закрытие подключений инициализируется общим контекстом верхнего уровня, который необходимо передать в каждое подключение группы. В примере выше, ошибка закрытия, заканчивается паникой =) Чтобы страшно стало!
Как и упомяналось раннее, в проекте, в качестве хранилища логов, я использую Mongo DB. Поэтому, после того как мы определились с хранилищем, необходимо написать функционал для взаимодействия его с логером, определиться с уровнями логирования и настройками подключения. Для этого идём в пакет internal/repository/repository и смотрим что нам необходимо реализовать для этого. Ниже как раз представлены описания интерфейсов и структуры настроек, предоставляемые этим пакетом.
// Settings структура параметров type Settings struct { /*Адрес */ Addr string /*Любые необходимые данные для настройки подключения*/ Data interface{} } // Connect - интерфейс для создания нового репозитория type Connect interface { // NewConnect Функция создания нового подключения NewConnect(stg Settings, ctx context.Context) (Repository, error) // Close Функция для закрытия подключения Close() error } // Repository - интерфейс репозитория type Repository interface { // InsertLog Функция для записи лога InsertLog(part string, data []byte) error }Стоит сказать пару слов о методе
InsertLog(part string, data []byte) error, а именно о его аргументах,data- это слайс байтов лога, аpart- это имя раздела, сюда попадёт строковое наименование лога, после отсева его в обработчике. Эти параметры конечно же стоит учитывать при написании своей реализации.
Для статьи написан такой пример реализации тестового подключения к MongoDB.
mongoStg := repository.Settings{ Addr: "127.0.0.1:27018", Data: storage.MongoSettings{ User: "root", Password: "12345", Timeout: 2 * time.Second, }, } mongoAlias := make(map[string][]string) mongoAlias["System"] = []string{"ERROR", "WARN"} mLvl := []slog.Level{slog.LevelError, slog.LevelInfo, slog.LevelWarn} stg := storage.InitMongoClient("log", mongoAlias)В этом подключении реализуется независимая от логгера схема записи логов по разделам. Уровни Error и Warn попадают в раздел System, что определено переменной
mongoAlias. ПеременнаяmLvlв данном случае, содержит перечень уровней, которые будут сохранены в бд.Понятное дело, что все параметры желательно делать настраиваемыми через конфиг. файлы, однако для демонстрации работы логера вполне подойдет и такой хардкод.
-
Настройки (
stg). Из примера выше, Вы уже увидили, что помимо реализации подключения, необходимо так же передать в схему настроек, и сами настройки подключения, в логере это реализовано ввиде структурыrepository.Settings{}пакета internal/model.Ниже, собственно и представлена структура настроек возможных.
type SettingsLogger struct { /*Функция для конвертирования уровня логов*/ Conv func(level string) slog.Level /*Функция для доп. обработки лога перед сохранением*/ PreSave func(log Log) Log /*Уровни логирования в консоль*/ AppLog []slog.Level /*Настройки для slog handler*/ HdlOpt *slog.HandlerOptions /*Путь до файла (если задано значение, то ДОПОЛНИТЕЛЬНО перенаправляем в файл)*/ File string /*Флаг для файла*/ Flag int /*Режим для файла*/ Mode os.FileMode /*Валидность*/ valid bool }Рассмотрим поля подробнее.
Поле
Convожидает, некоторую функцию, которая будет использована логером для преобразования уровней лога из string в формат slog.Level. Такой функционал выделен для возможной реализации своих уровней, так как в таком случае сравнение уже требуется выполнить самостоятельно.Поле
PreSaveпозволяет передать функцию, со своей реализацией обработки лога перед его непосредственной фильтрацией и сохранением.Описание других полей приведено в коде, и вряд ли как то требует дополнительных объяснений. Единственное надо сказать, поле
valid, это внутреннее поле структуры и оно заполняется автоматически при вызове метода валидации (метод так же вызывается логером автоматически).Ах, да, точно! Еще одно поле
HdlOpt, если Вы знакомы с библиотекой slog, то должны быть в курсе, о встроенных опциях handler (обработчика), так вот, они передаются также через эту структуру. P.s самым верхним уровнем логирования приложения я установил уровень Debug.По итогу вот такая структура настроек у меня получилась.
stg := model.SettingsLogger{ Conv: utils.LogConvert, AppLog: []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelError}, HdlOpt: &slog.HandlerOptions{ AddSource: true, Level: slog.LevelDebug, }, File: "./test/log.txt", Flag: os.O_CREATE | os.O_WRONLY | os.O_APPEND, Mode: os.ModePerm, }P.s Утилита
utils.LogConvertв моём случае обычный switch, по этому я не вижу смысла дополнительно удлиннять её описанием эту статью.
Логика работы. Handler и Writer
Вот мы и добрались до самого вкусного, внутреннее устройство логера :-)
Сам по себе логер "наследуется" методом встраивания стандартого slog.Logger. После чего методом NewMultiLogger,сигнатура которого была рассмотрена выше, выполняется валидация параметров, а так же создание нового handler, в данном случае handler.NewMultiHandler. Ниже представлена часть кода из пакета internal/logger.
type MultiLogger struct {
*slog.Logger
}
logger := MultiLogger{
Logger: slog.New(handler.NewMultiHandler(opts)),
}
Handler представлен двумя структурами, непосредственно самим handler и опциями для его настройки.
type LogHandler struct {
/*Наследование через встраивание*/
slog.Handler
/*Канал для исключения источников записи логов*/
excSource chan string
}
// LogHandlerOptions - настройки обработчика
type LogHandlerOptions struct {
/*Настройки logger*/
Stg model.SettingsLogger
/*Настройки handler*/
Opt slog.HandlerOptions
/*Группы подключений*/
Gr connect.GroupConnect
}
Следует подробнее рассказать про поле, excSource - это поле, как и написано в его описании, является каналом string, который слушаеся Writer для исключения конкретного лога из записи. Данные для этого канала, извлекаются из контекста лога в методе func (ch *LogHandler) Handle(ctx context.Context, record slog.Record) error. Пример ниже, демонстрирует логи такого вида, в данном случае логи с контекстом "mongo", будут исключены из записи в MongoDB.
excMongo := context.WithValue(ctx, handler.ExcSource, "mongo")
mLogger.InfoContext(excMongo, "Hello Info none BD mongo")
mLogger.ErrorContext(excMongo, "Hello Error none BD mongo")
Вернёмся к описанию Handler, ниже представлена сигнатура метода создания нового обработчика.
func NewMultiHandler(opts LogHandlerOptions) LogHandler
При создании нового обработчика, создаётся новый канал для исключения уровней, а так же новый "экземпляр" slog.NewJSONHandler (см. код ниже).
lh := &LogHandler{
Handler: slog.NewJSONHandler(wr, &opts.Opt),
excSource: excSource,
}
Основное действие мульти-записи логов, разворачивается в Writer, переданном первым аргументом JsonHandler (см. код ниже).
Структура LogWriter.
type LogWriter struct {
Callback func(log model.Log) error
}
Структура LogWriter, реализует интерфейс io.Writer, таким образом в методе Write, слайс байтов лога преобразуется (json.Unmarshal(p, &log)) в техническую модель лога, которая затем попадает в callback-функцию. Функция в свою же очередь как раз и инициализирует запись логов в хранилища, файл (file.Write) и консоль приложения (os.Stdout.Write) (см. код ниже).
Callback-функция записи логов.
wr := &writer.LogWriter{
Callback: func(log model.Log) error {
var err error
var errConn error
var errFile error
/*Получаем значение из канала (для проверки, нужно ли исключать уровень)*/
val := <-excSource
/*Обработка лога, перед его записью*/
log = opts.Stg.PreSave(log)
if len(opts.Gr.Connect) > 0 {
for _, lc := range opts.Gr.Connect {
for _, level := range lc.Levels {
/*Извлекаем функцию для конвертации уровня*/
if opts.Stg.Conv(log.Level) == level {
if len(val) == 0 || lc.Name != val {
/*Извлекаем репозиторий, и записываем лог*/
errConn = lc.GetRep().InsertLog(log.Level, log.P)
}
break
}
}
}
}
var file *os.File
for _, level := range opts.Stg.AppLog {
if opts.Stg.Conv(log.Level) == level {
/*Пишем в консоль*/
_, _ = os.Stdout.Write(log.P)
if len(opts.Stg.File) > 0 {
/*Открываем, либо создаём новый файл*/
file, errFile = os.OpenFile(opts.Stg.File, opts.Stg.Flag, opts.Stg.Mode)
if errFile == nil {
_, _ = file.Write(log.P)
}
}
break
}
}
/*джойним ошибки*/
err = errors.Join(errConn, errFile)
return err
},
В завершении
Какие можно сделать выводы.
Описан основной функционал мульти-логера.
Функционал не претендует на полноценную систему логирования, это скорее прототип, то видение, как это можно реализовать.
По моему мнению, мульти-логер вышел достаточно компактным, что касается количества строк кода, а также не содержащим никаких лишних реализаций.
Описание может быть не совсем полное, некоторая часть кода, осталась не описанной, но это по большей части касается раздела
pkg, который косвенно относится к логеру (есть публичная ссылка на репозиторий).
paramtamtam
Нет CI
Нет тестов
Комментарии в коде на русском языке
Раздувает зависимости (мне не нужна монго в моем проекте, но заюзав ваш пакет - клиент для монги все равно будет тянуться), идеал - 0 зависимостей
Когда напишите тесты, увидите где у вас гонки (даже в stderr/stdout нельзя писать без синхронизации)
Удачи с проектом!