C# / .NET Ввод-вывод, JSON и HTTP в .NET: потоки, System.Text.Json и IHttpClientFactory
0%

Ввод-вывод, JSON и HTTP в .NET: потоки, System.Text.Json и IHttpClientFactory

Ввод-вывод, JSON и HTTP: как .NET говорит с внешним миром

Почти любой сервис — это труба: снаружи приходят байты, внутри они превращаются в объекты, над объектами выполняется логика, и наружу снова уезжают байты. Мы уже разобрали логику (архитектура) и то, как не блокировать потоки на ожидании (конкурентность). Осталась самая прикладная часть, на которой в проде ломаются даже опытные команды: границы ввода-вывода.

Три темы этой статьи — потоки, JSON и HTTP-клиент — дают, по опыту инцидентов, самый плотный набор граблей: утечки дескрипторов, исчерпание сокетов, «сервис не видит новый IP после переезда базы», аллокации мегабайтных строк и падение по OOM на большом ответе.

Общая картина: путь байта

Главная идея: чем ближе вы к байтам, тем меньше копий. Наивный путь «поток → строка → объект» удваивает память (UTF-8 в потоке, UTF-16 в строке) и создаёт аллокацию размером с весь документ. Все современные API .NET умеют работать напрямую из Stream/ReadOnlySpan<byte> — пользуйтесь ими.

Stream: единая абстракция ввода-вывода

Stream — базовый класс для файлов, сети, памяти, сжатия и шифрования. Один и тот же код работает поверх FileStream, NetworkStream, MemoryStream, GZipStream.

// Копирование файла без загрузки в память целиком
public static async Task CopyAsync(string src, string dst, CancellationToken ct)
{
    // useAsync: true — важный флаг: без него FileStream работает синхронно поверх пула потоков
    var readOptions = new FileStreamOptions
    {
        Mode = FileMode.Open,
        Access = FileAccess.Read,
        Options = FileOptions.Asynchronous | FileOptions.SequentialScan,
        BufferSize = 64 * 1024
    };

    await using var input = new FileStream(src, readOptions);
    await using var output = new FileStream(dst, FileMode.Create, FileAccess.Write,
        FileShare.None, bufferSize: 64 * 1024, useAsync: true);

    await input.CopyToAsync(output, ct);   // потоковая передача фиксированным буфером
}

Что стоит запомнить про потоки:

  • Stream владеет ресурсом — файловым дескриптором или сокетом. Всегда using/ await using. Утечка дескрипторов проявляется как «Too many open files» под нагрузкой, причём далеко от места ошибки.
  • await using вместо using для потоков: DisposeAsync дожидается сброса буферов без блокировки потока.
  • Синхронные Read/Write в веб-приложении запрещены. ASP.NET Core по умолчанию запрещает синхронный ввод-вывод (AllowSynchronousIO = false) именно потому, что он блокирует поток пула — это прямой путь к thread starvation из статьи про конкурентность.
  • Не читайте файл целиком, если можно потоком. File.ReadAllBytesAsync на файле в 200 МБ — это 200 МБ в куче больших объектов (LOH) и почти гарантированная сборка Gen 2.

Файлы и пути кросс-платформенно

// ✅ Пути собираем через Path — разделитель подставится под ОС
var path = Path.Combine(AppContext.BaseDirectory, "data", "orders.json");

// ✅ Атомарная запись: пишем во временный файл и переименовываем.
//    Так читатель никогда не увидит наполовину записанный файл.
var tmp = Path.Combine(Path.GetDirectoryName(path)!, Path.GetRandomFileName());
await File.WriteAllTextAsync(tmp, json, ct);
File.Move(tmp, path, overwrite: true);

Кросс-платформенные грабли: Linux различает регистр в именах файлов, а Windows — нет; на Linux нет «дисков», зато есть права доступа; максимальная длина пути отличается. Если код собирает пути конкатенацией со слэшем — он сломается ровно в момент переезда в контейнер.

Кодировки: в .NET (начиная с .NET Core) везде по умолчанию UTF-8 без BOM, а string внутри — UTF-16. Если вам нужен именно UTF-8 без BOM при записи, укажите new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)Encoding.UTF8 добавляет BOM.

System.Text.Json: сериализация по умолчанию

System.Text.Json (STJ) встроен в BCL, работает на UTF-8 напрямую, поддерживает генерацию кода и совместим с Native AOT. Newtonsoft.Json остаётся отличной библиотекой, но новые проекты начинают с STJ.

using System.Text.Json;
using System.Text.Json.Serialization;

public sealed record OrderDto(
    int Id,
    [property: JsonPropertyName("customer_name")] string CustomerName,
    decimal Total,
    DateTimeOffset CreatedAt,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Comment);

// ⚠️ Options создаём ОДИН раз и переиспользуем: внутри них кэш метаданных типов.
//    Новый экземпляр на каждый вызов = полная потеря кэша и заметная просадка.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter() },   // enum как строка, а не число
    // Опасные значения по умолчанию лучше ужесточить для внешнего ввода:
    MaxDepth = 32
};

string json = JsonSerializer.Serialize(order, JsonOptions);
var restored = JsonSerializer.Deserialize<OrderDto>(json, JsonOptions);

JsonSerializerDefaults.Web — это готовый набор «как в ASP.NET Core»: camelCase, нечувствительность к регистру при чтении, числа в кавычках разрешены. Именно эти настройки использует веб-стек, поэтому в фоновых сервисах имеет смысл брать их же, чтобы формат совпадал.

Потоковая сериализация и большие ответы

// Читаем поток, не материализуя строку целиком
await using var stream = await response.Content.ReadAsStreamAsync(ct);
var dto = await JsonSerializer.DeserializeAsync<OrderDto>(stream, JsonOptions, ct);

// Огромный JSON-массив — обрабатываем поэлементно, память O(1)
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<OrderDto>(stream, JsonOptions, ct))
{
    await ProcessAsync(item!, ct);
}

DeserializeAsyncEnumerable — недооценённый инструмент: он позволяет читать выгрузку в гигабайт, держа в памяти один элемент. Симметрично ASP.NET Core умеет отдавать IAsyncEnumerable<T> из эндпоинта, стримя JSON клиенту по мере готовности.

Кастомный конвертер

Когда доменный тип не ложится на JSON автоматически (например, Money с валютой в одной строке "1200.00 RUB"), пишут конвертер:

public sealed class MoneyJsonConverter : JsonConverter<Money>
{
    public override Money Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions o)
    {
        // Работаем со span-ом байтов — без промежуточных строк
        var s = reader.GetString() ?? throw new JsonException("Ожидалась строка суммы");
        var parts = s.Split(' ');
        if (parts.Length != 2 || !decimal.TryParse(parts[0], out var amount))
            throw new JsonException($"Некорректный формат суммы: {s}");
        return new Money(amount, parts[1]);
    }

    public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions o) =>
        writer.WriteStringValue($"{value.Amount:0.00} {value.Currency}");
}

Бросайте именно JsonException — ASP.NET Core превратит её в корректный 400-й ответ, а не в 500-й.

Полиморфизм

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(SbpPayment), "sbp")]
public abstract record PaymentDto;

Ключевое отличие от TypeNameHandling в Newtonsoft.Json: здесь набор допустимых типов закрыт и объявлен явно. Открытый список типов при десериализации недоверенного ввода — это классическая уязвимость десериализации (удалённое выполнение кода); подробности — в статье про инъекции и безопасности API.

Генерация кода вместо рефлексии

По умолчанию STJ строит метаданные типа через рефлексию при первом обращении. Source generator делает это на этапе компиляции:

[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class AppJsonContext : JsonSerializerContext;

// Использование: сериализатор берёт готовый, сгенерированный код
string json = JsonSerializer.Serialize(order, AppJsonContext.Default.OrderDto);

Что это даёт: быстрее холодный старт (нет разбора типов в рантайме), меньше аллокаций, совместимость с trimming и Native AOT (рефлексию обрезать нельзя, сгенерированный код — можно). Для сервисов, которым важен холодный старт (serverless, CLI), это обязательная практика; подробнее — в статье про деплой.

Миграция с Newtonsoft.Json: где больно

  • Регистр свойств. Newtonsoft по умолчанию нечувствителен к регистру, STJ — чувствителен (если не взять JsonSerializerDefaults.Web). Молчаливо получаете null вместо значения.
  • Поля. STJ по умолчанию сериализует только свойства; поля — по IncludeFields.
  • Числа в кавычках ("total": "12.5") STJ по умолчанию не принимает.
  • Циклические ссылкиReferenceHandler.IgnoreCycles/Preserve.
  • dynamic и JObject — аналог JsonNode/JsonDocument, но API другой.

Если проект завязан на JsonConverter из Newtonsoft и JObject-манипуляции, миграция стоит времени — оценивайте её как задачу, а не как замену одной строки.

HttpClient: анатомия и два классических бага

Снаружи HttpClient выглядит просто, но внутри — цепочка обработчиков и пул TCP-соединений:

Отсюда два бага, которые встречаются в каждом втором легаси-проекте.

Баг 1: new HttpClient() на каждый запрос. HttpClient дёшев, а вот HttpMessageHandler внутри него держит пул соединений. Новый клиент = новый пул = новое TCP-соединение и новое TLS-рукопожатие. Закрытые соединения висят в состоянии TIME_WAIT десятки секунд — под нагрузкой порты кончаются, и вы получаете SocketException: Address already in use при живом сервисе.

// ❌ Классика жанра: каждый вызов — новый пул и новое соединение
public async Task<string> GetAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}

Баг 2: вечный статический HttpClient. Лечение из предыдущего пункта «сделаем один статический на всё приложение» порождает вторую проблему: соединение живёт вечно и не замечает изменения DNS. База или внешний сервис переехали на новый IP — ваш сервис продолжает стучаться на старый, пока его не перезапустят.

Правильное решение — IHttpClientFactory: клиенты создаются дёшево, а обработчики берутся из пула и периодически ротируются, что и решает проблему DNS.

// Регистрация типизированного клиента
builder.Services.AddHttpClient<PaymentClient>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(30);          // верхняя граница на всю операцию
    client.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0");
})
.AddHttpMessageHandler<AuthHeaderHandler>()             // свой обработчик в цепочку
.AddStandardResilienceHandler()                         // retry + circuit breaker + timeout
.SetHandlerLifetime(TimeSpan.FromMinutes(2));           // ротация обработчика (по умолчанию 2 мин)

// Сам клиент — обычный класс с внедрённым HttpClient
public sealed class PaymentClient(HttpClient http, ILogger<PaymentClient> logger)
{
    public async Task<Receipt> ChargeAsync(ChargeRequest req, CancellationToken ct)
    {
        using var response = await http.PostAsJsonAsync("/charge", req, JsonOptions, ct);

        if (response.StatusCode is HttpStatusCode.TooManyRequests)
        {
            var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
            logger.LogWarning("Платёжный шлюз просит подождать {Delay}", retryAfter);
            throw new RateLimitedException(retryAfter);
        }

        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<Receipt>(JsonOptions, ct))!;
    }
}

Жизненный цикл обработчика в фабрике

Отсюда два правила: типизированный клиент регистрируется как transient (его создание дёшево, а вот держать его в singleton — значит заморозить обработчик навсегда), и не вызывайте Dispose у внедрённого HttpClient — фабрика управляет временем жизни сама.

Альтернатива без фабрики — один статический SocketsHttpHandler с PooledConnectionLifetime = TimeSpan.FromMinutes(2): это тоже корректно и иногда удобнее в библиотеках.

Стриминг больших ответов

// ❌ Тело целиком в память: файл на 500 МБ = 500 МБ в LOH
var bytes = await http.GetByteArrayAsync(url, ct);
await File.WriteAllBytesAsync(path, bytes, ct);

// ✅ ResponseHeadersRead: возвращаемся сразу после заголовков и копируем потоком
using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(ct);
await using var target = File.Create(path);
await source.CopyToAsync(target, ct);

По умолчанию HttpCompletionOption.ResponseContentRead буферизует весь ответ. Для скачивания файлов, SSE и длинных выгрузок это неприемлемо.

Таймауты и отмена — не одно и то же

  • HttpClient.Timeout — общий таймаут на всю операцию, включая ретраи; при срабатывании бросается TaskCanceledException, что путает при чтении логов.
  • CancellationToken — отмена по инициативе вызывающего (клиент отключился, сервис останавливается). Прокидывайте его во все вызовы, как и в остальном коде.
  • В AddStandardResilienceHandler есть отдельные таймауты: на попытку и на весь запрос. Общее правило: таймаут на попытку заметно меньше внешнего таймаута, иначе ретраи никогда не успеют выполниться.

Протокольная часть — коды, заголовки, keep-alive, HTTP/2 и HTTP/3 — подробно разобрана в статье про HTTP; а TLS-рукопожатие, из-за которого дорого создавать новые соединения, — в статье про TLS.

Наблюдаемость границ ввода-вывода

IHttpClientFactory уже пишет логи по категориям System.Net.Http.HttpClient.<имя>.LogicalHandler и ...ClientHandler, а инструментирование OpenTelemetry (AddHttpClientInstrumentation) даёт метрики длительности и коды ответов плюс автоматические span-ы с прокидыванием traceparent. Настройка — в статье про наблюдаемость.

Что стоит вынести в дашборд по каждому внешнему клиенту:

  • p95/p99 длительности запроса и доля 5xx/429 — первые признаки деградации соседа;
  • число открытых соединений и время ожидания соединения из пула;
  • срабатывания circuit breaker — сигнал, что зависимость упала и вы её больше не добиваете.

Типичные ошибки

  1. using var client = new HttpClient() — исчерпание сокетов под нагрузкой.
  2. Статический клиент навсегда — застрявший DNS после переезда зависимости.
  3. JsonSerializerOptions на каждый вызов — потеря внутреннего кэша, кратная просадка.
  4. Чтение всего тела в строку — лишняя копия и мусор в LOH.
  5. Игнорирование кода ответаEnsureSuccessStatusCode() или явная обработка, но не молчаливое «наверное, всё хорошо».
  6. Отсутствие таймаута — запрос висит, поток занят, очередь растёт.
  7. .Result на HTTP-вызове — дедлок в средах с контекстом синхронизации.
  8. Полиморфная десериализация недоверенного ввода без белого списка типов — уязвимость.
  9. Синхронное чтение файла в обработчике запроса — блокировка потока пула.
  10. Отсутствие ограничения на размер тела запроса — тривиальный DoS.

Итог

  • Stream — общая абстракция; работайте потоками, а не полными копиями в памяти.
  • В System.Text.Json переиспользуйте JsonSerializerOptions, включайте генератор кода и явно объявляйте допустимые типы при полиморфизме.
  • IHttpClientFactory решает обе классические проблемы HttpClient разом: пул соединений переиспользуется, обработчики ротируются.
  • Большие ответы читайте с ResponseHeadersRead и копируйте потоком.
  • Таймаут и отмена — разные механизмы; нужны оба.

Источники: документация System.Text.Json, руководство по IHttpClientFactory, «You’re using HttpClient wrong» — статья, с которой началось общее осознание проблемы, и разборы Steve Gordon про внутренности фабрики (stevejgordon.co.uk).

Что дальше

ASP.NET Core изнутри — конвейер middleware, маршрутизация, модельное связывание, аутентификация и авторизация: что именно происходит с запросом между сокетом и вашим обработчиком.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов