C#, Логи которые пишут себя сами

от автора

Всем привет.

Пару лет назад мне пришлось заняться интеграцией СБП с АБС банка, писать надо было на 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

Подсказки в интернете которыми был вдохновлён этот код:


ссылка на оригинал статьи https://habr.com/ru/articles/931416/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *