Ввод-вывод, JSON и HTTP: как .NET говорит с внешним миром
Почти любой сервис — это труба: снаружи приходят байты, внутри они превращаются в объекты, над объектами выполняется логика, и наружу снова уезжают байты. Мы уже разобрали логику (архитектура) и то, как не блокировать потоки на ожидании (конкурентность). Осталась самая прикладная часть, на которой в проде ломаются даже опытные команды: границы ввода-вывода.
Три темы этой статьи — потоки, JSON и HTTP-клиент — дают, по опыту инцидентов, самый плотный набор граблей: утечки дескрипторов, исчерпание сокетов, «сервис не видит новый IP после переезда базы», аллокации мегабайтных строк и падение по OOM на большом ответе.
Общая картина: путь байта
абстракция потока байт"] S --> B["Буфер
byte[] / IMemoryOwner"] B --> R["Utf8JsonReader
парсит прямо из байтов"] R --> O["Объект C#
OrderDto"] O --> W["Utf8JsonWriter"] W --> S2["Stream ответа"] B -.->|"наивный путь"| STR["string
полная копия в UTF-16"] STR -.->|"×2 памяти,
лишняя аллокация"| O
Главная идея: чем ближе вы к байтам, тем меньше копий. Наивный путь
«поток → строка → объект» удваивает память (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-соединений:
(PaymentClient) participant DH as DelegatingHandler
(логи, авторизация, retry) participant SH as SocketsHttpHandler
(пул соединений) participant Srv as Внешний сервис App->>F: запрос PaymentClient через DI F->>TC: новый HttpClient (дёшево) F-->>TC: handler из пула (переиспользуется!) App->>TC: PostAsJsonAsync("/charge", req, ct) TC->>DH: HttpRequestMessage DH->>DH: добавить заголовок Authorization,
начать span трейсинга DH->>SH: SendAsync SH->>SH: взять живое TCP+TLS соединение из пула SH->>Srv: HTTP-запрос Srv-->>SH: ответ (заголовки) SH-->>DH: HttpResponseMessage DH-->>App: ответ; тело читается потоком по требованию
Отсюда два бага, которые встречаются в каждом втором легаси-проекте.
Баг 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 — сигнал, что зависимость упала и вы её больше не добиваете.
Типичные ошибки
using var client = new HttpClient()— исчерпание сокетов под нагрузкой.- Статический клиент навсегда — застрявший DNS после переезда зависимости.
JsonSerializerOptionsна каждый вызов — потеря внутреннего кэша, кратная просадка.- Чтение всего тела в строку — лишняя копия и мусор в LOH.
- Игнорирование кода ответа —
EnsureSuccessStatusCode()или явная обработка, но не молчаливое «наверное, всё хорошо». - Отсутствие таймаута — запрос висит, поток занят, очередь растёт.
.Resultна HTTP-вызове — дедлок в средах с контекстом синхронизации.- Полиморфная десериализация недоверенного ввода без белого списка типов — уязвимость.
- Синхронное чтение файла в обработчике запроса — блокировка потока пула.
- Отсутствие ограничения на размер тела запроса — тривиальный 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, маршрутизация, модельное связывание, аутентификация и авторизация: что именно происходит с запросом между сокетом и вашим обработчиком.