Сериализация: System.Text.Json и Newtonsoft

↑ 2.1 C♯: продвинутый язык · ← Предыдущая · → Следующая

Обязательно~7 мин чтенияУровень: middle

Зачем это на собесе

JSON — формат обмена почти в каждом API. Спросят, чем System.Text.Json отличается от Newtonsoft, как настроить имена и регистр, как сериализовать enum и даты, как работать с большими JSON потоково, что такое source generation и чем опасна десериализация типов из данных.

Объяснение

System.Text.Json (STJ) — встроенный

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)   // camelCase, регистронезависимость при чтении
{
    WriteIndented = false,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter() },                   // enum как строки
};
 
string json = JsonSerializer.Serialize(patient, options);
var back = JsonSerializer.Deserialize<PatientDto>(json, options);

Атрибуты:

public record PatientDto(
    [property: JsonPropertyName("last_name")] string LastName,
    [property: JsonIgnore] string Secret,
    DateOnly BirthDate);
 
public class Item
{
    [JsonRequired] public string Code { get; set; } = "";                // обязательное поле (или C# required)
    [JsonPropertyOrder(1)] public int Qty { get; set; }
    [JsonConverter(typeof(JsonStringEnumConverter))] public Status Status { get; set; }
}

В ASP.NET Core настройки по умолчанию — JsonSerializerDefaults.Web. Свои: builder.Services.ConfigureHttpJsonOptions(o => ...) (Minimal API) или AddJsonOptions (MVC).

Newtonsoft.Json (Json.NET)

Исторически самая популярная библиотека, богаче по возможностям: JObject/JToken, [JsonProperty], TypeNameHandling, NullValueHandling, гибкие конвертеры, поддержка ISerializable, IDictionary со сложными ключами, ReferenceLoopHandling из коробки.

var s = JsonConvert.SerializeObject(obj, new JsonSerializerSettings
{
    ContractResolver = new CamelCasePropertyNamesContractResolver(),
    NullValueHandling = NullValueHandling.Ignore,
});

Сравнение

System.Text.JsonNewtonsoft.Json
Скорость и памятьвыше, меньше аллокаций (UTF-8, Span)ниже
Возможностистроже, меньше «магии», быстро развиваетсябогаче, более терпим к формату
Регистр имёнточное сравнение (в Web-режиме нечувствителен)нечувствителен
Циклические ссылкиReferenceHandler.Preserve / IgnoreCyclesReferenceLoopHandling
ДинамикаJsonNode, JsonDocumentJObject, dynamic
Source generation / AOTданет
Полиморфизм[JsonDerivedType] (.NET 7+)TypeNameHandling (рискованно)

Выбор: STJ по умолчанию; Newtonsoft — когда нужна совместимость, сложные конвертеры или устаревшие сценарии (некоторые библиотеки всё ещё требуют).

Даты, числа, enum

  • DateTime/DateTimeOffset пишутся в ISO 8601. DateOnly/TimeOnly поддерживаются (.NET 7+).
  • decimal сохраняет точность (число в JSON); для больших чисел в JS-клиенте возможна потеря точности (long > 2^53) → передавайте строкой (JsonNumberHandling.WriteAsString).
  • Enum по умолчанию числом. Для устойчивого API — строки (JsonStringEnumConverter) и явные значения, чтобы порядок не менялся.

Полиморфизм

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Card), "card")]
[JsonDerivedType(typeof(Sbp), "sbp")]
public abstract record Payment(decimal Amount);
public sealed record Card(decimal Amount, string Last4) : Payment(Amount);

Потоковая работа и низкий уровень

  • JsonSerializer.DeserializeAsyncEnumerable<T>(stream) — читает массив элементов по одному без загрузки всего в память.
  • Utf8JsonReader / Utf8JsonWriter — чтение и запись токенов без аллокаций.
  • JsonDocument — только чтение с деревом DOM (требует Dispose), JsonNode — изменяемое дерево.
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<Item>(stream, options, ct)) { }

Source generation

[JsonSerializable(typeof(PatientDto))]
[JsonSerializable(typeof(List<PatientDto>))]
public partial class AppJsonContext : JsonSerializerContext;
 
var json = JsonSerializer.Serialize(patient, AppJsonContext.Default.PatientDto);

Быстрее старт и меньше памяти, совместимо с trimming и Native AOT, нет рефлексии в рантайме.

Нюансы и подводные камни

  • Циклические ссылки (Parent.Children[0].Parent): STJ бросает исключение; включите ReferenceHandler.IgnoreCycles или разрывайте цикл в DTO.
  • Регистр: без Web-режима lastName не десериализуется в LastName (сравнение чувствительно).
  • Неизвестные поля по умолчанию игнорируются; строгий режим — UnmappedMemberHandling.Disallow.
  • Null в не-nullable свойстве: NRT не защищает; нужны required и валидация.
  • Приватные сеттеры/конструкторы: STJ использует публичные сеттеры и конструктор с параметрами (для record удобно). Нужен [JsonConstructor] при нескольких конструкторах.
  • Безопасность: TypeNameHandling у Newtonsoft в сочетании с недоверенным вводом — известная уязвимость (выполнение произвольных типов). Не включайте для внешних данных, используйте белые списки или [JsonDerivedType].
  • Большие JSON (сотни МБ) не читайте через ReadAsStringAsync: потоково и без буферизации всего.
  • Смена сериализатора в API меняет формат (даты, enum, null) и ломает клиентов: фиксируйте контракт тестами.

Практика

  • Настроить JsonSerializerOptions для API: camelCase, enum строками, игнор null, DateOnly.
  • Написать конвертер для типа-обёртки Money.
  • Прочитать файл на 500 МБ через DeserializeAsyncEnumerable и замерить память.

Вопросы с ответами

Связанные темы