«Жизнь требует движения» (Аристотель)
«Жизнь требует движения» (Аристотель)

Привет Хабр! Это моя первая статья тут, я надеюсь получилось читабельно и интересно :-)

Как и следует из названия, в этой статье мы рассмотрим небольшой проект логера, написанный мною на языке golang. За основу взят встроенный и знакомый многим пакет log/slog.

Почему Мульти-логер? Все просто, как и следует из названия, пакет содержит функционал записи логов в несколько хранилищ одновременно. Что нужно для подключения нового хранилища? Для подключения сначала нужно реализовать интерфейс, предоставляемый пакетом, а затем собственно, подключить свою реализацию к мульти-логеру. В общем "стандартный стандарт" во взаимодействии современных компонентов программных систем между собой.

В данной статье, для демонстрации функционала мульти-логера, в качестве хранилищ, я использую нереляционную базу данных - Mongo DB, а также самый простой православный txt файл. Такой выбор обусловлен простотой настройки, а также удобством хранения, каждый лог представляет из себя json структуру, хранение которой, например в реляционной БД, затруднителен и избыточен. По мимо логирования в хранилища, логи, пишутся и в консоль приложения, куда же без этого. А так, при желании, можно подключить любое другое хранилище, при этом различные схемы безопасности доступа, методы записи и т.п - уже лажат на плечах пользователя пакета. В данном случае описан концепт, который конечно же можно развивать.

Ссылка на проект

Всё описанное в этой статье, строго моё видение такой системы, критика так же принимается, всегда интересно услышать мнение читателей.

Система - прототип, цель написания - демонстрация некоторых функций slog пакета, а также своих реализаций функционала мульти-логирования.

Содержание

  1. Структура проекта.

  2. Инициализация логера.

  3. Логика. Handler и Writer.

  4. Заключение.

Структура

Представлю Вам, небольшую таблицу, содержащую структуру проекта (см. ниже).

Название

Путь

Описание

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) подключений.

Пройдемся по аргументам функции и опишем их.

  1. Группа (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 в данном случае, содержит перечень уровней, которые будут сохранены в бд. 

    Понятное дело, что все параметры желательно делать настраиваемыми через конфиг. файлы, однако для демонстрации работы логера вполне подойдет и такой хардкод. 

  2. Настройки (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
},

В завершении

Какие можно сделать выводы.

  1. Описан основной функционал мульти-логера.

  2. Функционал не претендует на полноценную систему логирования, это скорее прототип, то видение, как это можно реализовать.

  3. По моему мнению, мульти-логер вышел достаточно компактным, что касается количества строк кода, а также не содержащим никаких лишних реализаций.

  4. Описание может быть не совсем полное, некоторая часть кода, осталась не описанной, но это по большей части касается раздела pkg, который косвенно относится к логеру (есть публичная ссылка на репозиторий).

Комментарии (4)


  1. paramtamtam
    14.12.2025 09:11

    критика так же принимается, всегда интересно услышать мнение читателей

    • Нет CI

    • Нет тестов

    • Комментарии в коде на русском языке

    • Раздувает зависимости (мне не нужна монго в моем проекте, но заюзав ваш пакет - клиент для монги все равно будет тянуться), идеал - 0 зависимостей

    • Когда напишите тесты, увидите где у вас гонки (даже в stderr/stdout нельзя писать без синхронизации)

    Удачи с проектом!


  1. andrdru
    14.12.2025 09:11

    А ещё для slog fanout реализует вот такая библиотека

    https://github.com/samber/slog-multi