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