← Назад к вопросам

ASP.NET Core: Custom Middleware для логирования

2.0 Middle🔥 181 комментариев
#Основы C# и .NET

Условие

Создайте custom Middleware для ASP.NET Core, который логирует информацию о каждом HTTP-запросе.

Требования:

  1. Логировать: HTTP метод, URL, время выполнения запроса, статус-код ответа
  2. Не логировать запросы к статическим файлам
  3. Для ошибок (статус >= 400) добавлять тело запроса в лог

Структура Middleware:

public class RequestLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<RequestLoggingMiddleware> _logger;

// TODO: реализовать

}

Критерии оценки:

  • Корректная работа с pipeline (вызов _next)
  • Правильное измерение времени (Stopwatch)
  • Обработка исключений
  • Регистрация в DI-контейнере

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

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

Анализ задачи

Middleware в ASP.NET Core — это компонент, который обрабатывает HTTP-запросы и ответы. Pipeline работает как матрёшка: каждое middleware может:

  1. Обработать входящий запрос
  2. Передать управление следующему middleware через _next(context)
  3. Обработать исходящий ответ

Ключевые моменты:

  • Middleware выполняется в порядке регистрации в Program.cs
  • Нужно правильно вызвать _next() для продолжения pipeline
  • Нужно исключить статические файлы из логирования
  • Нужно захватить время выполнения
  • Нужно обработать исключения

Базовая реализация

using System.Diagnostics;

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Пропускаем статические файлы
        if (IsStaticFile(context.Request.Path))
        {
            await _next(context);
            return;
        }

        var stopwatch = Stopwatch.StartNew();
        var originalBodyStream = context.Response.Body;  // Сохраняем оригинальный поток

        try
        {
            // Создаём temp stream для захвата тела ответа
            using (var memoryStream = new MemoryStream())
            {
                context.Response.Body = memoryStream;

                // Переводим входящий поток в позицию 0 (если нужно читать)
                var requestBody = string.Empty;
                if (context.Request.ContentLength > 0)
                {
                    context.Request.EnableBuffering();  // Позволяет прочитать Body несколько раз
                    using (var reader = new StreamReader(context.Request.Body))
                    {
                        requestBody = await reader.ReadToEndAsync();
                        context.Request.Body.Position = 0;  // Возвращаемся в начало для контроллера
                    }
                }

                // Вызываем следующее middleware в pipeline
                await _next(context);

                // Копируем тело ответа из памяти в оригинальный поток
                memoryStream.Seek(0, SeekOrigin.Begin);
                await memoryStream.CopyToAsync(originalBodyStream);
            }

            stopwatch.Stop();

            // Логируем результат
            LogRequest(context, stopwatch.ElapsedMilliseconds, requestBody);
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex, "Exception occurred during request handling. " +
                $"{context.Request.Method} {context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms");
            throw;  // Пробрасываем дальше для обработки
        }
        finally
        {
            context.Response.Body = originalBodyStream;  // Возвращаем оригинальный поток
        }
    }

    private void LogRequest(HttpContext context, long elapsedMilliseconds, string requestBody)
    {
        var request = context.Request;
        var response = context.Response;

        var logLevel = response.StatusCode >= 400 ? LogLevel.Warning : LogLevel.Information;

        var logMessage = $"{request.Method} {request.Path}{request.QueryString} " +
                        $"- {response.StatusCode} ({elapsedMilliseconds}ms)";

        if (response.StatusCode >= 400 && !string.IsNullOrEmpty(requestBody))
        {
            logMessage += $" | RequestBody: {requestBody}";
        }

        _logger.Log(logLevel, logMessage);
    }

    private bool IsStaticFile(PathString path)
    {
        var staticExtensions = new[] { ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2" };
        var pathValue = path.Value?.ToLower();

        return staticExtensions.Any(ext => pathValue?.EndsWith(ext) ?? false);
    }
}

Улучшенная версия с настройками

public class RequestLoggingOptions
{
    public bool LogRequestBody { get; set; } = true;
    public bool LogResponseBody { get; set; } = false;
    public int MaxBodyLength { get; set; } = 1000;  // Максимум символов в логе
    public string[] ExcludedPaths { get; set; } = { "/health", "/metrics" };
    public string[] ExcludedExtensions { get; set; } = 
    { 
        ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2"
    };
}

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;
    private readonly RequestLoggingOptions _options;

    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger,
        IOptions<RequestLoggingOptions> options)
    {
        _next = next;
        _logger = logger;
        _options = options.Value;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Проверяем, нужно ли логировать этот запрос
        if (ShouldSkipLogging(context.Request.Path))
        {
            await _next(context);
            return;
        }

        var stopwatch = Stopwatch.StartNew();
        var originalBodyStream = context.Response.Body;
        var requestBody = string.Empty;
        var responseBody = string.Empty;

        try
        {
            // Читаем тело запроса (если логируем)
            if (_options.LogRequestBody && context.Request.ContentLength > 0)
            {
                context.Request.EnableBuffering();
                using (var reader = new StreamReader(context.Request.Body))
                {
                    requestBody = await reader.ReadToEndAsync();
                    if (requestBody.Length > _options.MaxBodyLength)
                        requestBody = requestBody.Substring(0, _options.MaxBodyLength) + "...";
                    context.Request.Body.Position = 0;
                }
            }

            // Захватываем тело ответа (если логируем)
            if (_options.LogResponseBody)
            {
                using (var memoryStream = new MemoryStream())
                {
                    context.Response.Body = memoryStream;
                    await _next(context);
                    memoryStream.Seek(0, SeekOrigin.Begin);
                    using (var reader = new StreamReader(memoryStream))
                    {
                        responseBody = await reader.ReadToEndAsync();
                        if (responseBody.Length > _options.MaxBodyLength)
                            responseBody = responseBody.Substring(0, _options.MaxBodyLength) + "...";
                    }
                    memoryStream.Seek(0, SeekOrigin.Begin);
                    await memoryStream.CopyToAsync(originalBodyStream);
                }
            }
            else
            {
                await _next(context);
            }

            stopwatch.Stop();
            LogRequest(context, stopwatch.ElapsedMilliseconds, requestBody, responseBody);
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex, "Exception: {Method} {Path} ({ElapsedMs}ms)",
                context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds);
            throw;
        }
        finally
        {
            context.Response.Body = originalBodyStream;
        }
    }

    private void LogRequest(
        HttpContext context,
        long elapsedMilliseconds,
        string requestBody,
        string responseBody)
    {
        var request = context.Request;
        var response = context.Response;
        var statusCode = response.StatusCode;

        var logLevel = statusCode >= 400 ? LogLevel.Warning : LogLevel.Information;

        var logMessage = $"{request.Method} {request.Path}{request.QueryString} " +
                        $"→ {statusCode} ({elapsedMilliseconds}ms)";

        if (statusCode >= 400)
        {
            if (!string.IsNullOrEmpty(requestBody))
                logMessage += $" | Req: {requestBody}";
            if (!string.IsNullOrEmpty(responseBody))
                logMessage += $" | Resp: {responseBody}";
        }

        _logger.Log(logLevel, logMessage);
    }

    private bool ShouldSkipLogging(PathString path)
    {
        var pathValue = path.Value?.ToLower();

        // Проверяем исключённые пути
        if (_options.ExcludedPaths.Any(p => pathValue?.StartsWith(p.ToLower()) ?? false))
            return true;

        // Проверяем исключённые расширения
        if (_options.ExcludedExtensions.Any(ext => pathValue?.EndsWith(ext) ?? false))
            return true;

        return false;
    }
}

Регистрация в Program.cs

Вариант 1: Простая регистрация

var builder = WebApplicationBuilder.CreateBuilder(args);

builder.Services.AddLogging();

var app = builder.Build();

// Регистрируем middleware
app.UseMiddleware<RequestLoggingMiddleware>();

app.MapGet("/api/test", () => Results.Ok(new { message = "Hello" }));

app.Run();

Вариант 2: С настройками

var builder = WebApplicationBuilder.CreateBuilder(args);

builder.Services.AddLogging();

// Регистрируем middleware с опциями
builder.Services.Configure<RequestLoggingOptions>(options =>
{
    options.LogRequestBody = true;
    options.LogResponseBody = false;
    options.MaxBodyLength = 500;
    options.ExcludedPaths = new[] { "/health", "/metrics" };
});

var app = builder.Build();

app.UseMiddleware<RequestLoggingMiddleware>();

app.MapGet("/api/test", () => Results.Ok(new { message = "Hello" }));

app.Run();

Альтернатива: Extension Method для удобства

public static class RequestLoggingExtensions
{
    public static IApplicationBuilder UseRequestLogging(
        this IApplicationBuilder builder,
        Action<RequestLoggingOptions>? configureOptions = null)
    {
        if (configureOptions != null)
        {
            builder.ApplicationServices
                .GetRequiredService<IOptionsChangeTokenSource<RequestLoggingOptions>>()
                .GetChangeToken();
        }

        return builder.UseMiddleware<RequestLoggingMiddleware>();
    }
}

// Использование:
app.UseRequestLogging(options =>
{
    options.LogRequestBody = true;
    options.MaxBodyLength = 1000;
});

Пример вывода логов

info: RequestLoggingMiddleware[0]
      GET /api/products?pageSize=10 → 200 (125ms)

warn: RequestLoggingMiddleware[0]
      POST /api/products → 400 (85ms) | Req: {"name":""}

error: RequestLoggingMiddleware[0]
      DELETE /api/products/999 → 500 (212ms)
      Exception: Null reference exception

Обработка особых случаев

1. Логирование чувствительных данных (пароли, токены)

private string MaskSensitiveData(string body)
{
    // Маскируем пароли
    var masked = System.Text.RegularExpressions.Regex.Replace(
        body,
        @"\"password\"\s*:\s*\"[^\"]*\"",
        "\"password\": \"***\"",
        System.Text.RegularExpressions.RegexOptions.IgnoreCase);

    return masked;
}

2. Логирование с контекстом (User ID, Request ID)

var userId = context.User?.FindFirst("sub")?.Value ?? "anonymous";
var requestId = context.TraceIdentifier;

var logMessage = $"[{requestId}] User: {userId} | " +
                $"{request.Method} {request.Path}{statusCode} ({elapsedMilliseconds}ms)";

_logger.LogInformation(logMessage);

3. Асинхронное логирование (чтобы не блокировать запрос)

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await Task.Run(() => LogRequest(context, elapsedMilliseconds), cts.Token)
    .ConfigureAwait(false);

Порядок middleware в pipeline

// ВАЖНО! Порядок регистрации влияет на выполнение
var app = builder.Build();

// 1. Логирование — должно быть одним из первых
app.UseMiddleware<RequestLoggingMiddleware>();

// 2. Обработка исключений
app.UseExceptionHandling();

// 3. Аутентификация / Авторизация
app.UseAuthentication();
app.UseAuthorization();

// 4. Маршрутизация
app.MapControllers();

app.Run();

Выводы

Правильный middleware:

  1. Регистрируется в Program.cs через UseMiddleware<T>()
  2. Должен вызвать _next(context) для продолжения pipeline
  3. Использует Stopwatch для измерения времени
  4. Обрабатывает исключения через try/catch
  5. Логирует на соответствующем уровне (Warning для ошибок)
  6. Исключает статические файлы и health-checks
  7. Кэширует тело запроса/ответа для анализа
  8. Маскирует чувствительные данные
ASP.NET Core: Custom Middleware для логирования | PrepBro