Start Debugging

Как написать пользовательский JsonConverter в System.Text.Json

Полное руководство по написанию пользовательского JsonConverter<T> для System.Text.Json в .NET 11: когда он действительно нужен, как корректно работать с Utf8JsonReader, как обрабатывать обобщённые типы с помощью JsonConverterFactory и как оставаться совместимым с AOT.

Чтобы написать пользовательский конвертер для System.Text.Json, унаследуйтесь от JsonConverter<T>, переопределите Read и Write и либо пометьте целевой тип атрибутом [JsonConverter(typeof(MyConverter))], либо добавьте экземпляр в JsonSerializerOptions.Converters. Внутри Read нужно пройти по Utf8JsonReader ровно столько токенов, сколько занимает ваше значение, не больше и не меньше, иначе следующий вызов десериализатора увидит сломанный поток. Внутри Write вы вызываете методы Utf8JsonWriter напрямую и никогда не выделяете промежуточные строки, если этого можно избежать. Для обобщённых типов или полиморфизма используйте JsonConverterFactory, чтобы один класс мог производить конвертеры для множества закрытых обобщённых инстанциаций. Всё в этом руководстве рассчитано на .NET 11 (preview 3) и C# 14, но API стабилен с .NET Core 3.0, так что тот же код работает на каждой поддерживаемой среде выполнения.

Когда JsonConverter — правильный инструмент

Большинство команд берётся за пользовательский конвертер слишком рано. Прежде чем писать его, проверьте, можно ли решить вашу задачу встроенными возможностями, которые поставляются в .NET 11 (и более ранних версиях):

Пользовательский конвертер — правильный инструмент, когда форма JSON и форма .NET по-настоящему расходятся. Примеры:

Если ничего из этого не подходит, используйте встроенные средства и пропустите эту статью.

Контракт JsonConverter

System.Text.Json.Serialization.JsonConverter<T> имеет два абстрактных метода, которые вы должны переопределить, и пару необязательных хуков:

// .NET 11, C# 14
public abstract class JsonConverter<T> : JsonConverter
{
    public abstract T? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options);

    public abstract void Write(
        Utf8JsonWriter writer,
        T value,
        JsonSerializerOptions options);

    // Optional: opt in to dictionary-key handling.
    public virtual T ReadAsPropertyName(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options) => throw new NotSupportedException();

    public virtual void WriteAsPropertyName(
        Utf8JsonWriter writer,
        T value,
        JsonSerializerOptions options) => throw new NotSupportedException();

    public virtual bool HandleNull => false;
}

В этой сигнатуре две вещи легко сделать неправильно:

  1. Read получает Utf8JsonReader по ref. Читатель — это изменяемая структура, владеющая курсором. Если вы передаёте его во вспомогательный метод, передавайте также по ref, иначе курсор вызывающего не продвинется и вы будете читать один и тот же токен бесконечно.
  2. HandleNull по умолчанию равен false, что означает, что сериализатор вернёт default(T) для JSON null и никогда не вызовет ваш конвертер. Если вам нужно сопоставить null со значением, отличным от значения по умолчанию (или различать “отсутствует” и “null”), установите HandleNull => true и проверяйте reader.TokenType == JsonTokenType.Null самостоятельно.

Полный контракт описан на официальной странице MS Learn о написании пользовательских конвертеров. Остальная часть этого поста — практическая версия.

Рабочий пример: тип значения Money

Возьмём строго типизированное значение Money:

// .NET 11, C# 14
public readonly record struct Money(decimal Amount, string Currency)
{
    public override string ToString() =>
        $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}

Поведение System.Text.Json по умолчанию сериализует его как {"Amount":42.00,"Currency":"USD"}. Вместо этого мы хотим один строковый токен: "42.00 USD". Это именно то несоответствие формы, для которого нужен конвертер.

// .NET 11, C# 14
using System.Buffers;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class MoneyJsonConverter : JsonConverter<Money>
{
    public override Money Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.String)
            throw new JsonException(
                $"Expected string for Money, got {reader.TokenType}.");

        string raw = reader.GetString()!; // "42.00 USD"
        int space = raw.LastIndexOf(' ');
        if (space <= 0 || space == raw.Length - 1)
            throw new JsonException($"Invalid Money literal: '{raw}'.");

        decimal amount = decimal.Parse(
            raw.AsSpan(0, space),
            NumberStyles.Number,
            CultureInfo.InvariantCulture);
        string currency = raw[(space + 1)..];

        return new Money(amount, currency);
    }

    public override void Write(
        Utf8JsonWriter writer,
        Money value,
        JsonSerializerOptions options)
    {
        // Formats directly into the writer's UTF-8 buffer.
        Span<char> buffer = stackalloc char[64];
        if (!value.Amount.TryFormat(
                buffer, out int written,
                "0.00", CultureInfo.InvariantCulture))
        {
            writer.WriteStringValue(value.ToString());
            return;
        }

        // "<number> <currency>" without intermediate string allocation.
        Span<char> output = stackalloc char[written + 1 + value.Currency.Length];
        buffer[..written].CopyTo(output);
        output[written] = ' ';
        value.Currency.AsSpan().CopyTo(output[(written + 1)..]);
        writer.WriteStringValue(output);
    }
}

Несколько деталей, на которые стоит обратить внимание:

Корректное чтение: дисциплина курсора

Самая частая ошибка в пользовательских конвертерах — не оставить читатель на правильном токене. Контракт такой:

Когда Read возвращает управление, читатель должен быть позиционирован на последнем токене, потреблённом вашим значением, а не на следующем.

Сериализатор вызывает reader.Read() один раз между значениями. Если ваш конвертер потребляет слишком много токенов, следующее свойство молча пропускается. Если он потребляет слишком мало, следующий вызов десериализатора видит некорректный поток и выбрасывает исключение на токене, которого не ожидал.

Два правила покрывают почти каждый случай:

  1. Для однотокенного значения (строка, число, логическое) ничего не делайте, кроме чтения из текущего токена. Курсор уже находится на правильном токене, когда вызывается Read.
  2. Для объекта или массива зацикливайтесь, пока не увидите соответствующий токен EndObject или EndArray, и пусть финальный reader.Read() цикла оставит вас именно на этом закрывающем токене.

Вот канонический скелет для чтения объекта:

// .NET 11, C# 14
public override Foo Read(
    ref Utf8JsonReader reader,
    Type typeToConvert,
    JsonSerializerOptions options)
{
    if (reader.TokenType != JsonTokenType.StartObject)
        throw new JsonException();

    var result = new Foo();

    while (reader.Read())
    {
        if (reader.TokenType == JsonTokenType.EndObject)
            return result;

        if (reader.TokenType != JsonTokenType.PropertyName)
            throw new JsonException();

        string property = reader.GetString()!;
        reader.Read(); // advance to the value token

        switch (property)
        {
            case "id":
                result.Id = reader.GetInt32();
                break;
            case "name":
                result.Name = reader.GetString();
                break;
            case "child":
                // Recurse through the serializer so nested converters and
                // contracts apply.
                result.Child = JsonSerializer.Deserialize<Child>(
                    ref reader, options);
                break;
            default:
                reader.Skip(); // unknown field, advance past its value
                break;
        }
    }

    throw new JsonException(); // unexpected end of stream
}

reader.Skip() — недооценённый помощник: он проходит мимо всего, что вводит текущий токен, включая вложенный объект или массив, оставляя курсор на его закрывающем токене. Используйте его для всего, чего вы не понимаете, никогда не пишите собственный цикл пропуска.

Эффективная запись: оставайтесь на писателе

Utf8JsonWriter пишет напрямую в пулированный буфер UTF-8, поэтому всё, что не требует управляемой string, должно оставаться вне кучи. Три правила:

  1. Предпочитайте типизированные перегрузки: WriteNumber, WriteBoolean, WriteString(ReadOnlySpan<char>). Они форматируют прямо в буфер.
  2. Для пар свойство+значение внутри объекта используйте WriteString("name", value) и подобные. Они выдают имя свойства и значение за один вызов без выделения памяти.
  3. Если вам нужно построить строку, используйте string.Create или выделенный на стеке Span<char> вместо string.Format или интерполяции, которые обе выделяют память.

Для приведённого выше примера Money ещё более дешёвая версия использует UTF-8 напрямую:

// .NET 11, C# 14, micro-optimized hot path
public override void Write(
    Utf8JsonWriter writer,
    Money value,
    JsonSerializerOptions options)
{
    Span<byte> buffer = stackalloc byte[64];
    if (!value.Amount.TryFormat(
            buffer, out int written,
            "0.00", CultureInfo.InvariantCulture))
    {
        writer.WriteStringValue(value.ToString());
        return;
    }

    int currencyLen = Encoding.UTF8.GetByteCount(value.Currency);
    Span<byte> output = stackalloc byte[written + 1 + currencyLen];
    buffer[..written].CopyTo(output);
    output[written] = (byte)' ';
    Encoding.UTF8.GetBytes(value.Currency, output[(written + 1)..]);
    writer.WriteStringValue(output);
}

Эта версия никогда не производит управляемую строку для отформатированного значения. Для сервиса, сериализующего десятки тысяч экземпляров Money в секунду, это измеримая разница в темпе выделения памяти.

Обобщённые типы и JsonConverterFactory

JsonConverter<T> — закрытый тип. Если вам нужен конвертер для Result<TValue, TError>, который работает для каждого закрытого обобщённого, вы пишете JsonConverterFactory, который производит закрытые конвертеры по требованию:

// .NET 11, C# 14
public sealed class ResultJsonConverterFactory : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert) =>
        typeToConvert.IsGenericType
        && typeToConvert.GetGenericTypeDefinition() == typeof(Result<,>);

    public override JsonConverter CreateConverter(
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        Type[] args = typeToConvert.GetGenericArguments();
        Type closed = typeof(ResultConverter<,>).MakeGenericType(args);
        return (JsonConverter)Activator.CreateInstance(closed)!;
    }

    private sealed class ResultConverter<TValue, TError>
        : JsonConverter<Result<TValue, TError>>
    {
        public override Result<TValue, TError> Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
            throw new NotImplementedException(); // exercise for the reader

        public override void Write(
            Utf8JsonWriter writer,
            Result<TValue, TError> value,
            JsonSerializerOptions options) =>
            throw new NotImplementedException();
    }
}

Фабрика регистрируется так же, как обычный конвертер (атрибут или Options.Converters.Add). Сериализатор кеширует закрытый конвертер для каждого закрытого обобщённого, так что CreateConverter выполняется один раз на пару (TValue, TError) на экземпляр JsonSerializerOptions.

Activator.CreateInstance плюс MakeGenericType — это рефлексия, враждебная Native AOT и trim. Если вы нацеливаетесь на AOT, см. раздел про AOT ниже.

Регистрация конвертера

Два способа, и у них разный приоритет:

// .NET 11, C# 14
[JsonConverter(typeof(MoneyJsonConverter))]
public readonly record struct Money(decimal Amount, string Currency);

Атрибут привязывает конвертер к типу и учитывается каждым вызовом JsonSerializer без настройки на уровне опций. Используйте его для типов значений, которыми вы владеете.

// .NET 11, C# 14
var options = new JsonSerializerOptions
{
    Converters = { new MoneyJsonConverter() }
};

string json = JsonSerializer.Serialize(invoice, options);

Регистрация на уровне опций — правильный ответ, когда вы не владеете целевым типом, когда конвертер специфичен для среды (тест против прода) или когда одному типу нужны разные формы в разных контекстах (публичный API против внутреннего журнала).

Порядок поиска, от наивысшего к наинизшему приоритету:

  1. Конвертер, переданный напрямую в вызов JsonSerializer.
  2. [JsonConverter] на свойстве.
  3. Options.Converters (для совпадающих типов выигрывает добавленный последним).
  4. [JsonConverter] на типе.
  5. Встроенное значение по умолчанию для этого типа.

Если два конвертера претендуют на один и тот же тип через разные механизмы, выигрывает тот, что выше в этом списке. Прикиньте это в голове, прежде чем отлаживать “почему мой конвертер не запускается”: почти всегда атрибут на свойстве или запись в опциях переопределяет атрибут на типе.

Генерация исходного кода и Native AOT

JsonConverter<T> работает с генератором исходного кода: объявите тип в своём JsonSerializerContext, и генератор выпустит провайдер метаданных, который делегирует вашему конвертеру там, где это уместно. То же самое не автоматически верно для JsonConverterFactory. Всё, что фабрика делает с MakeGenericType или Activator.CreateInstance, — это рефлексия, которую trim и AOT не могут увидеть статически.

Для совместимых с AOT фабрик сделайте одно из:

Шаблон использования интерсепторов, чтобы вызовы JsonSerializer.Serialize автоматически подбирали сгенерированный контекст, обсуждаемый в предложении интерсепторов C# 14 для сгенерированного исходным кодом JSON, независим от конвертеров: даже с ним вы всё равно пишете свой пользовательский JsonConverter<T> тем же способом.

Подводные камни, в порядке частоты возникновения

Если вы держите это в уме, конвертер редко занимает более 30 строк кода и работает в том же бюджете выделения памяти, что и встроенный сериализатор.

Похожее

Источники

< Назад