Всем привет.
Пару лет назад мне пришлось заняться интеграцией СБП с АБС банка, писать надо было на C#. Первое о чём я подумал, конечно, это было логирование. Писать логи руками это конечно не мой выбор. Конечно хотелось чтобы при вызове метода в логи записывались его входные аргументы и выходные результаты и конечно эксепшены.
В C#, на тот момент (конец 2023 года), для этого надо было делать обёртку над классом, пример обёртки я приведу ниже. Этот код был проверкой гипотезы, для использования в работе надо дописать обработку множество нюансов (работа с итераторами хотя бы).
Что делает обёртка ? Обёртка проксирует вызовы к вложенному классу. При вызове метода обёртки происходит вызов соответствующего метода вложенного класса и возврат результата. Для логирования мы можем до запуска метода вложенного класса, записать в логи аргументы вызова, после вызова - записать в логи результат этого вызова. Если случиться эксепшен, то запишем этот эксепшен, и прокинем его дальше для перехвата и обработки уже там где положено по архитектуре.
Класс обёртка
Что нужно нашей обёртке ?
Класс обёртка должен быть наследником от DispatchProxy (using System.Reflection;)
Свойства класса:
Конечно логгер (FileArchivist Archivist)
Собственно экземпляр вложенного класса (T? _decorated)
Идентификатор входящего вызова для отслеживания цепочки выполнения (_correlationId)
public class LoggingDecorator<T> : DispatchProxy{
private readonly FileArchivist Archivist = new();
private T? _decorated;
private string? _correlationId;
}
Реализацию логирования пишем в методе:
protected override object? Invoke(
MethodInfo? targetMethod,
object?[]? args
)
Выполнение логирования
Собственно реализация:
var shouldLogging = targetMethod?.Name != "ToString";
if (shouldLogging)
{
Archivist.Before(DateTime.Now, args);
}
object? result;
try
{
result = targetMethod?.Invoke(_decorated, args);
}
catch (Exception ex) when (ex is TargetInvocationException)
{
var realException = ex.InnerException ?? ex;
if (shouldLogging)
{
Archivist.Exception(DateTime.Now, realException);
}
throw realException;
}
if (shouldLogging)
{
Archivist.After(DateTime.Now, result);
}
return result;
Разберём построчно, что тут происходит.
var shouldLogging = targetMethod?.Name != "ToString";
Определяемся с тем надо ли нам логировать этот вызов (хотя бы что бы не попасть на рекурсию).
if (shouldLogging)
{
Archivist.Before(DateTime.Now, args);
}
Если логировать надо, то логируем аргументы вызова.
object? result;
try
{
result = targetMethod?.Invoke(_decorated, args);
}
Вызов оборачиваем в try / catch, что бы перехватить эксепшен, если он случиться.
catch (Exception ex) when (ex is TargetInvocationException)
{
var realException = ex.InnerException ?? ex;
if (shouldLogging)
{
Archivist.Exception(DateTime.Now, realException);
}
throw realException;
}
Перехватываем исключения только от механизма вызова нужного метода у заданного экземпляра. Логируем исключение, если надо.
if (shouldLogging)
{
Archivist.After(DateTime.Now, result);
}
return result;
Если надо логировать, то логируем результат.
Результат вызова возвращаем.
Как этим пользоваться
Теперь о том как этим пользоваться.
С использованием есть пара трудностей.
Во первых обёртка должна поддерживать точно тот же интерфейс что и вложенный класс. То есть, если у вложенного класса нет интерфейса, то его нельзя обернуть, то есть для всех классов который мы хотим автоматически логировать, придётся создать интерфейсы.
Второе, у класса обёртки в конструкторе не может быть ни каких аргументов, то есть инициализация состояния происходит отдельно, от создания экземпляра.
Собственно создание экземпляра происходит через статический метод, по сути заменяющий конструктор.
public static T Create(T decorated, string? correlationId)
{
object proxy = Create<T, LoggingDecorator<T>>() ??
throw new ArgumentNullException(
"Fail on create proxy for "
+ (decorated?.GetType().FullName ?? "")
);
((LoggingDecorator<T>)proxy)
.SetParameters(decorated, correlationId);
return (T)proxy;
}
С помощью рефлексии создаём экземпляр класса обёртки, и инициализируем состояние экземпляра.
Метод инициализации:
private void SetParameters(T decorated, string? correlationId)
{
if (decorated == null)
{
throw new ArgumentNullException(nameof(decorated));
}
_decorated = decorated;
_correlationId = correlationId;
Archivist.Initialize(
_correlationId,
_decorated);
}
Использование в коде
Интерфейс для класса, который хотим автоматически логировать:
public interface ICliProgram
{
public IArgumentStorage ProcureArguments(
string[] args,
ArgumentStorageFactory storageFactory
);
public string Run(IArgumentStorage arguments);
public IConfigurationRoot LoadProgramSettings();
public string[]? GatherArgumentsFromInput(
string? input,
string cliArgumentsSeparator,
string optionNumbers
);
public void SetCorrelationId(string correlationId);
}
В логах мы увидим только эти методы. Методы с модификатором доступа private и protected мы в логах не увидим. Из-за этого приходиться делать public методы, которые на самом деле private, но автоматически логи того стоят.
Собственно код с автоматическим логированием:
public static void Main(string[] args)
{
var correlationId = GetCorrelationId(args);
var app = archivist.
LoggingDecorator<ICliProgram>.
Create(new Program(SettingsKey), correlationId);
app.SetCorrelationId(correlationId);
var arguments = app.ProcureArguments(
args,
new ArgumentStorageFactory()
);
app.Run(arguments);
}
Построчный разбор кода с автоматическим логированием
Построчно:
var app = archivist.
LoggingDecorator<ICliProgram>.
Create(new Program(SettingsKey), correlationId);
Создали прокси класс над нужным классом (что бы автоматически логировать его вызовы).
var arguments = app.ProcureArguments(
args,
new ArgumentStorageFactory()
);
Это уже поехала логика архитектуры, в логах мы увидим с какими аргументами вызывали метод и какой результат он вернул.
Архитектура предполагает вызов каждого приложения (CLI программы), как с аргументами вызова из командной строки, так и с аргументами вызова из "консольного" ввода (из потока STD_IN программы), откуда брать аргументы задаётся в конфиге программы.
app.Run(arguments);
Запустили программу с надлежащими аргументами. В логах увидим с какими аргументами вызывали метод и какой результат он вернул.
Стоит ли так заморачиваться ради того, чтобы логи писали себя сами ? Я считаю оно того стоит. Такой стиль логирования побуждает дробить код на небольшие методы, потому что если писать стену текста, тогда и логи придётся писать вручную, а эта работа кажется лишней, когда эту работу за нас может сделать сам "компилятор".
Сериализация значений для записи в логи
Теперь про само логирование, о том как записывать в текстовый файл значения аргументов, которые не всегда строки. То есть каждый аргумент должен стать строкой, для этого можно у каждого класса переопределить метод ToString(). Но есть способ проще.
Воспользуемся сериализацией в JSON:
private static string serializeWithJson(object? obj)
{
var serilized = JsonConvert.SerializeObject(obj);
if (serilized == "{}")
{
serilized = obj?.ToString();
}
return serilized ?? "non-serializable-value";
}
Если после сериализации получили пустой объект, то вызываем встроенный метод приведения к строке - ToString().
Если не смогли получить строковое представление, то возвращаем "non-serializable-value". (Когда увидим в логах "non-serializable-value", тогда напишем "кастомный" ToString() для этого класса).
Сериализация входных аргументов
args ??= Array.Empty<object>();
var arguments = new List<string?>();
foreach (var obj in args)
{
string serialized = serializeWithJson(obj);
arguments.Add(serialized);
}
var values = new StringBuilder().
AppendJoin('\u00A0', arguments.ToArray());
Аргументы вызова могу отсутствовать, это не редкость, поэтому если массив аргументов не задан, то присваиваем значение Array.Empty().
Каждый аргумент сериализуем отдельно, и склеиваем всё в одну строку через символ неразрывный пробел ('\u00A0'). Логи надо писать так ,чтобы было потом удобно парсить, поэтому тут подумайте как будет лучше их писать - какой выбрать разделитель для отдельных частей лога, чтобы потом по этому разделителю, разбить строку на семантические части.
Сериализация результата на выходе
var jsonResult = serializeWithJson(result);
Куда писать логи: в файл, в STD_OUT, в какой то сервис по UDP дело вкуса.
Полный код классов
Приведу законченные примеры кода для класса обёртки и для класса логера.
Класс обёртка
Класс обёртка
using System.Reflection;
namespace archivist
{
/* base on https://learn.microsoft.com/ru-ru/archive/msdn-magazine/2014/february/aspect-oriented-programming-aspect-oriented-programming-with-the-realproxy-class */
/* base on https://stackoverflow.com/questions/38467753/realproxy-in-dotnet-core */
public class LoggingDecorator<T> : DispatchProxy
{
private readonly FileArchivist Archivist = new();
private T? _decorated;
private string? _correlationId;
protected override object? Invoke(
MethodInfo? targetMethod,
object?[]? args
)
{
var shouldLogging = targetMethod?.Name != "ToString";
if (shouldLogging && !Archivist.WasInitialized)
{
Archivist.Initialize(_correlationId, _decorated);
}
if (shouldLogging)
{
Archivist.GetReady(targetMethod);
}
if (shouldLogging)
{
Archivist.Before(DateTime.Now, args);
}
object? result;
try
{
result = targetMethod?.Invoke(_decorated, args);
}
catch (Exception ex) when (ex is TargetInvocationException)
{
var realException = ex.InnerException ?? ex;
if (shouldLogging)
{
Archivist.Exception(DateTime.Now, realException);
}
throw realException;
}
if (shouldLogging)
{
Archivist.After(DateTime.Now, result);
}
return result;
}
public static T Create(T decorated, string? correlationId)
{
object proxy = Create<T, LoggingDecorator<T>>() ??
throw new ArgumentNullException(
"Fail on create proxy for "
+ (decorated?.GetType().FullName ?? "")
);
((LoggingDecorator<T>)proxy)
.SetParameters(decorated, correlationId);
return (T)proxy;
}
private void SetParameters(T decorated, string? correlationId)
{
if (decorated == null)
{
throw new ArgumentNullException(nameof(decorated));
}
_decorated = decorated;
_correlationId = correlationId;
Archivist.Initialize(_correlationId, _decorated);
}
}
}
Класс логер
Класс логер
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using System.Reflection;
using System.Text;
namespace archivist
{
/* base on https://markpelf.com/1072/logging-proxy-in-c/ */
public class FileArchivist
{
private string CorrelationId = string.Empty;
private string InvokeId = string.Empty;
private string ClassName = string.Empty;
private string MethodName = string.Empty;
private Object LogLocker = new();
protected const string SettingsKey =
"integrated-logger-settings-file";
private string? LogFilename = null;
public bool WasInitialized { get; private set; }
public FileArchivist()
{
WasInitialized = false;
}
private static string ProcureLogFilename
{
get
{
var secretConfig = new ConfigurationBuilder()
.AddUserSecrets<FileArchivist>()
.Build();
var fileWithSettings = secretConfig[SettingsKey] ?? "";
var config = File.ReadAllText(fileWithSettings);
config = config.Trim(Environment.NewLine.ToArray<char>());
return config ?? string.Empty;
}
}
private void MakeLogFilename()
{
if (LogFilename == null)
{
var filename = ProcureLogFilename;
var isFileDefined = !string.IsNullOrEmpty(filename);
if (!isFileDefined)
{
throw new Exception("Log filename is not defined");
}
LogFilename =
new StringBuilder().
AppendJoin(
'_',
new string?[] {
filename,
CorrelationId,
DateTime.Now.Ticks.ToString(),
$"{Guid.NewGuid()}.log"
}
).
ToString();
}
}
public void Initialize(
string? correlationId,
object? classExemplar
)
{
CorrelationId = correlationId ?? string.Empty;
ClassName = classExemplar?.GetType().FullName ?? "";
MakeLogFilename();
WasInitialized = true;
}
public void GetReady(
MethodInfo? methodInfo
)
{
InvokeId = $"invokeId={Guid.NewGuid()}";
MethodName = methodInfo?.Name ?? string.Empty;
}
public async void Before(
DateTime now,
object?[]? args)
{
args ??= Array.Empty<object>();
var arguments = new List<string?>();
foreach (var obj in args)
{
string serialized = SerializeWithJson(obj);
arguments.Add(serialized);
}
var values = new StringBuilder().
AppendJoin('\u00A0', arguments.ToArray());
var message = new StringBuilder().
AppendJoin(
' ',
new string?[] {
now.Ticks.ToString(),
CorrelationId,
InvokeId,
"RUN",
ClassName,
"->",
MethodName,
$"input ({values})",
}
).
ToString();
await WriteLogToFile(message);
}
private static string SerializeWithJson(object? obj)
{
var serialized = JsonConvert.SerializeObject(obj);
if (serialized == "{}")
{
serialized = obj?.ToString();
}
return serialized ?? "non-serializable-value";
}
public async void After(
DateTime now,
object? result
)
{
var jsonResult = SerializeWithJson(result);
var message = new StringBuilder().
AppendJoin(
' ',
new string?[] {
now.Ticks.ToString(),
CorrelationId,
InvokeId,
"END",
ClassName,
"->",
MethodName,
$"output {jsonResult}",
}
).
ToString();
await WriteLogToFile(message);
}
public async void Exception(
DateTime now,
Exception exception)
{
var message = new StringBuilder().
AppendJoin(
' ',
new string?[] {
now.Ticks.ToString(),
CorrelationId,
InvokeId,
"EXC",
ClassName,
"->",
MethodName,
exception.Message,
}
).
ToString();
await WriteLogToFile(message);
}
private Task WriteLogToFile(string logMessage)
{
lock (LogLocker)
{
if (!string.IsNullOrWhiteSpace(LogFilename))
{
File.AppendAllText(
LogFilename,
logMessage + Environment.NewLine
);
}
}
return Task.CompletedTask;
}
}
}
Проверку гипотезы (рабочий код) можно посмотреть на Гитхабе: https://github.com/SbWereWolf/ips-gateway
Подсказки в интернете которыми был вдохновлён этот код:
Комментарии (10)
SpiderEkb
27.07.2025 06:38Вы не работаете с БД? Текстовые файлы парсить чтобы что-то там найти... Ну такое себе.
Есть ли у вас уровни логирования?
У нас есть отдельный сервис логирования. Поскольку вся работа у нас идет с БД, то и лог - это отдельная таблица в БД.
Каждая запись содержит
т.н. "узел логирования" - некий литерал, означающий к какому модулю (или части модуля) относится эта строка,
временную метку,
служебную информацию (это уже платформо-специфическое - номер задания, пользователь задания...)
уровень логирования (сейчас три уровня - ошибка, бизнес-сообщение, трейс)
ключ - произвольная информация для быстрого поиска. Например, идентификатор клиента - можно сделать выборку по узлу логирования и идентификатору клиента чтобы понять что было при его обработке
точка вызова - произвольный литерал, позволяющий идентифицировать в каком именно месте случилось событие.
собственно блок логируемой информации
Это основная таблица. Есть еще дополнительная таблица дополнительной информации для каких-то объемных данных. Она не обязательная, но, к примеру, если нужно скинуть в лог полный образ записи, то в основную таблицу пойдет запись с ключевой информацией, а полный образ обрабатываемой записи где случилась ошибка пойдет в дополнительную таблицу.
Плюс есть настроечная таблица где каждому узлу логирования сопоставляется допустимый уровень. Например, если для узла 'ABC' прописан уровень 'E', то в лог будут записываться только сообщения с уровнем 'E' - ошибки. Все остальное будет игнорироваться. А если поменять 'E' на 'T', то будут писаться все сообщения - ошибки, бизнес-сообщения, трейсы... Это позволяет "на лету" включать или выключать вывод в лог нужной информации. Т.е. когда в коде прописано логирование и ошибок, и бизнес-сообщений и трейсов, через настройки можно управлять что реально будет писаться в лог, а что нет.
Там же в настройках хранится глубина хранения для каждого узла логирования и каждого типа сообщений. Скажем, для узла 'ABC' ошибки хранятся неделю, бизнес - три дня, трейсы один день. Это для модуля прочистки логов который запускается раз в сутки.
Ну и поскольку все это хранится в БД, то не нужен никакой парсер - вся работа с логами делается скулем.
И лог - не на один какой-то модуль, а на "проект" содержащий сотни если не тысячи модулей (выборка нужной информации делается по узлу логирования, времени, полю ключевой информации...). И выборку можно сразу делать как по основной, так и по дополнительной таблице.
ValeriyPus
27.07.2025 06:38Все, что вы описали - крайне избыточно.
Большинство так называемых логгеров сами кладут все в БД.
Поиск обычно осуществляется ElasticSearch и подобными.
Хранение логов - ну сделать джоб - удалять Warning-и и Info раз в месяц\неделю.
(А Exception-ы мониторит DevOps, и заводит тикеты)
Сейчас HDD дешевые, можно логи вообще не чистить.
Хотя я видел команды, которые зачем-то заводят отдельную таблицу логов чуть ли не на каждый модуль(со своими таблицами), чтобы смотреть ошибки. (А не просто используют ChangeTracking для записи изменений обьектов в журнал, и отдельный журнал для событий).
SpiderEkb
27.07.2025 06:38Ага. Сколько может стоить 400Tb SSD Array?
И где его можно купить?
И глубина хранения для разных типов сообщений может быть разная. Тут еще требования бизнеса могут быть. Сталкивался с тем, что бизнес говорит что им какая-то история по какому-то конкретному модулю нужна год. В том и дело, что лог - это не только ошибки.
По ошибкам с прода идет рассылка ежедневная типа:
Но бывают ситуации когда нужно попросить сопровождение чтобы на короткий период времени для какого-то узла включили трейсинг, а потом сделали выгрузку (или сами на тестах можем использовать трейсинг, а в пром выкатывать с уровнем логирования "только ошибки"). Чтобы понять что там происходит (если вдруг происходит что-то не то). А потом выключили обратно чтобы не раздувать лог (чем больше таблица, тем выше накладные расходы на поддержание ее индексов - неоправданно большие таблицы могут производительность системы в целом снижать).
И ошибка - это не обязательно падение всего и вся. И не обязательно ошибка в коде.
Это может быть ошибка в данных вследствие которой они не проходят через соответствующий модуль валидации. Например. Это тоже требует внимания.
И каждая команда обычно делает свой лог, со структурой, соответствующей своей специфике того, с чем они работают. Когда-то была идея универсального сервиса логирования, но изжила себя когда попытались свести воедино требования всех команд (которых не один десяток).
ValeriyPus
27.07.2025 06:38А БД избавляет от необходимости 400 Tb SSD?
Вы же сами показали - у Вас один текст, и события - раз в два часа.
Текст в таблице и текст в JSON - разница процентов 10.
SpiderEkb
27.07.2025 06:38Раз в два часа - это то, что залогировалось когда все хорошо и ошибок мало.
А если что-то случается ("дефект промсреды"), то там может быт сразу тысячи записей. Или если трейс потребовалось включить. Когда трейсится все подряд практически чтобы понимать где именно что пошло не так (в штатном режиме это не нужно - только производительность снижает - я потому и спрашивал - поддерживаются ли уровни логирования и управление ими в реальном времени).
JSON - это вообще для тех приложений где не требуется высокая производительность. Слишком большие накладные расходы на кодирование-декодирование. У нас практически не используется в принципе.
А если надо - есть таблица допинфо куда можно просто сбросить образ записи или какой-то дамп as is. Правда, извлекать его оттуда уже потребуется специальным образом - это фактически BLOB. Но это в особо тяжелых случаях используется. В 99% случаев для расследований инцидентов хватает текста.
А для повышения производительности есть в модуле логирования AllowLog которая проверяет разрешен ли данный уровень для данного узла - нужно ли тратить время на формирование сложного текста.
Как альтернатива еще есть возможность выводить трейсы в очередь сообщений (*msgq - есть у нас на платформе такие системные объекты). Там вообще в реальном времени видно что происходит. В одном задании работает модуль, в другом мониторим соответствующую очередь сообщений и сразу видим что туда летит. Но это скорее отладочное, когда отладчиком сложно или невозможно подлезть (например, не доступа для отладки в нужном юните или есть какие-то таймауты которые под отладчиком не соблюдаются).
Вообще, принципы логирования которые используем, формировались не один год методом проб и ошибок. Есть общие требования, но формат логов в разных случаях может быть разный. Где-то хватает общего, а где-то приходится делать что-то особенное т.к. общий уже не подходит.
ValeriyPus
27.07.2025 06:38Я советую ElasticSearch потому что там есть шардинг - поднял на нескольких серверах и хоть 20 Гб\сек ошибок пиши.
Это - идеальный случай для 1 системы (пусть даже с сайтом/WPF и еще командами по шине).
Для локального логирования простых приложений - обычно все в таблицу\файл (или что там у логгера есть).
Проблема в том, что поиск что по БД, что в ElasticSearch скорее всего по скорости одинаковый. Последний разворачивается просто и поддерживает горизонтальное масштабирование.
авторизация\аутентификация - есть в ElasticSearch.
А вот специфических проверок и интерфейсов для логирования - нет.
Тут клиент-серверное приложение, как у вас.
bilayan
27.07.2025 06:38grep/tail -f/less. По удобству до этого ни эластик, ничего, не добрались до этой тройки).
При записи в файл есть такая вещь как контекст, в бд поиск работает совсем не так, не говоря уже о накладных расходах.
Ну и вполне себе нормальная практика - писать логи в файл, а потом асинхронно качать их уже куда надо.
ValeriyPus
Есть метод проще
https://github.com/vescon/MethodBoundaryAspect.Fody
Делаешь атрибут Log или WithLogging
В OnException вызываешь логирование.
В чем плюс Fody -
Навешиваешь атрибут Log на методы,
Бери все сервисы прямо из контейнера\откуда угодно,
Не нужно создавать никаких оберток для классов.
Единственное, что можно улучшить - сделать свой Weaver для оборачивания всех public методов по атрибуту уже на классе. (для тех кто любит писать код на C# как есть - Класс с логированием, Интерфейс с регистрацией реализаций в контейнере вот так, и т.д)
SbWereWolf Автор
волшебная штука, только ради неё стоило написать статью и получить такой полезный комент :)
ArkadiyShuvaev
Интересный подход. Не смог нагуглить, поддерживается ли AOT? Вы не тестировали?