Как написать пользовательский 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 (и более ранних версиях):
- Имена свойств не совпадают: используйте
JsonPropertyNameAttributeилиJsonNamingPolicy. В preview 3 добавленыJsonNamingPolicy.PascalCaseи атрибут[JsonNamingPolicy]уровня члена, поэтому политики именования в System.Text.Json 11, скорее всего, покрывают то, что вам нужно. - Числа в виде строк:
JsonNumberHandling.AllowReadingFromStringвJsonSerializerOptions. - Перечисления в виде строк:
JsonStringEnumConverterвстроен. Существует даже совместимый с trim вариант для Native AOT. - Свойства только для чтения или параметры конструктора: генератор исходного кода (
[JsonSerializable]плюсJsonSerializerContext) обрабатывает записи и первичные конструкторы напрямую. - Полиморфизм по дискриминатору:
[JsonDerivedType]и[JsonPolymorphic](добавлены в .NET 7) избавляют почти от всех старых трюков с конвертерами.
Пользовательский конвертер — правильный инструмент, когда форма JSON и форма .NET по-настоящему расходятся. Примеры:
- Тип значения, который должен сериализоваться как примитив (
Moneyстановится"42.00 USD"). - Тип, чья форма JSON зависит от контекста (иногда строка, иногда объект).
- Дерево, где одно и то же имя свойства несёт разные типы в зависимости от соседнего поля.
- Формат данных, которым вы не владеете (суммы в стиле Stripe в центах, длительности ISO 8601, правила повторения RFC 5545).
Если ничего из этого не подходит, используйте встроенные средства и пропустите эту статью.
Контракт 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;
}
В этой сигнатуре две вещи легко сделать неправильно:
ReadполучаетUtf8JsonReaderпоref. Читатель — это изменяемая структура, владеющая курсором. Если вы передаёте его во вспомогательный метод, передавайте также поref, иначе курсор вызывающего не продвинется и вы будете читать один и тот же токен бесконечно.HandleNullпо умолчанию равенfalse, что означает, что сериализатор вернётdefault(T)для JSONnullи никогда не вызовет ваш конвертер. Если вам нужно сопоставить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);
}
}
Несколько деталей, на которые стоит обратить внимание:
reader.GetString()материализует управляемуюstring. Если вы десериализуете миллионы записей, а разобранное значение недолговечно, предпочтитеreader.ValueSpan(UTF-8 байты) плюсUtf8Parser, чтобы избежать выделения памяти.writer.WriteStringValue(ReadOnlySpan<char>)кодирует в UTF-8 напрямую в пулированный буфер писателя. Промежуточнойstringнет. Эта перегрузка плюсWriteStringValue(ReadOnlySpan<byte> utf8)— дешёвый путь.JsonException— каноническое исключение “данные неверны”. Сериализатор оборачивает его информацией о строке и позиции до того, как оно достигнет вызывающего, так что вам не нужно ничего добавлять.
Корректное чтение: дисциплина курсора
Самая частая ошибка в пользовательских конвертерах — не оставить читатель на правильном токене. Контракт такой:
Когда
Readвозвращает управление, читатель должен быть позиционирован на последнем токене, потреблённом вашим значением, а не на следующем.
Сериализатор вызывает reader.Read() один раз между значениями. Если ваш конвертер потребляет слишком много токенов, следующее свойство молча пропускается. Если он потребляет слишком мало, следующий вызов десериализатора видит некорректный поток и выбрасывает исключение на токене, которого не ожидал.
Два правила покрывают почти каждый случай:
- Для однотокенного значения (строка, число, логическое) ничего не делайте, кроме чтения из текущего токена. Курсор уже находится на правильном токене, когда вызывается
Read. - Для объекта или массива зацикливайтесь, пока не увидите соответствующий токен
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, должно оставаться вне кучи. Три правила:
- Предпочитайте типизированные перегрузки:
WriteNumber,WriteBoolean,WriteString(ReadOnlySpan<char>). Они форматируют прямо в буфер. - Для пар свойство+значение внутри объекта используйте
WriteString("name", value)и подобные. Они выдают имя свойства и значение за один вызов без выделения памяти. - Если вам нужно построить строку, используйте
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 против внутреннего журнала).
Порядок поиска, от наивысшего к наинизшему приоритету:
- Конвертер, переданный напрямую в вызов
JsonSerializer. [JsonConverter]на свойстве.Options.Converters(для совпадающих типов выигрывает добавленный последним).[JsonConverter]на типе.- Встроенное значение по умолчанию для этого типа.
Если два конвертера претендуют на один и тот же тип через разные механизмы, выигрывает тот, что выше в этом списке. Прикиньте это в голове, прежде чем отлаживать “почему мой конвертер не запускается”: почти всегда атрибут на свойстве или запись в опциях переопределяет атрибут на типе.
Генерация исходного кода и Native AOT
JsonConverter<T> работает с генератором исходного кода: объявите тип в своём JsonSerializerContext, и генератор выпустит провайдер метаданных, который делегирует вашему конвертеру там, где это уместно. То же самое не автоматически верно для JsonConverterFactory. Всё, что фабрика делает с MakeGenericType или Activator.CreateInstance, — это рефлексия, которую trim и AOT не могут увидеть статически.
Для совместимых с AOT фабрик сделайте одно из:
- Ограничьте фабрику известным конечным набором закрытых обобщённых и инстанциируйте их напрямую с
new ResultConverter<MyValue, MyError>()для каждой пары. - Пометьте фабрику атрибутами
[RequiresDynamicCode]и[RequiresUnreferencedCode], примите предупреждения trim и задокументируйте, что потребители AOT должны регистрировать закрытый конвертер вручную.
Шаблон использования интерсепторов, чтобы вызовы JsonSerializer.Serialize автоматически подбирали сгенерированный контекст, обсуждаемый в предложении интерсепторов C# 14 для сгенерированного исходным кодом JSON, независим от конвертеров: даже с ним вы всё равно пишете свой пользовательский JsonConverter<T> тем же способом.
Подводные камни, в порядке частоты возникновения
- Забыли продвинуть читатель за
EndObject/EndArray. Симптом: следующее свойство в родительском объекте молча пропускается или парсер выбрасывает запутанную ошибку двумя слоями выше. Проверяйте, написав тест конвертера, который десериализует{ "wrapped": <yourThing>, "next": 1 }и проверяет, чтоnextпрочитан. - Вызов
JsonSerializer.Deserialize<T>(ref reader, options)для того жеT, который обрабатывает ваш конвертер. Это бесконечная рекурсия. Рекурсия через сериализатор — для других типов (детей, вложенных значений). - Удержание
Utf8JsonReaderчерезawait. Читатель — этоref struct, компилятор вам не позволит, но у вас может возникнуть искушение скопировать значения в локальные переменные и переподключить позже. Не делайте этого. Читайте всё значение синхронно внутриRead. Если ваш источник данных асинхронный, сначала буферизуйте вReadOnlySequence<byte>и передайте это читателю. - Выбрасывание чего-либо кроме
JsonExceptionдля некорректных данных. Другие исключения пересекают границу сериализатора без обёртки и теряют контекст строки/позиции. - Изменение
JsonSerializerOptionsпосле первого вызова сериализации. Сериализатор кеширует разрешённые конвертеры на экземпляр опций; последующие изменения выбрасываютInvalidOperationException. Постройте свежий экземпляр опций или явно вызовитеMakeReadOnly(), когда закончите конфигурацию. - Использование
JsonConverterAttributeна интерфейсе или абстрактном типе с ожиданием полиморфизма бесплатно. Это не работает таким образом. Используйте[JsonPolymorphic]и[JsonDerivedType]для сериализации иерархии или напишите пользовательский конвертер, который сам выполняет диспетчеризацию по дискриминатору. - Выделение памяти в
Write. Легко написатьJsonSerializer.Serialize(value)рекурсивно и забыть, что он производитstring, которую вы затем записываете обратно в писатель. Используйте перегрузкуSerializeсref Utf8JsonWriter.
Если вы держите это в уме, конвертер редко занимает более 30 строк кода и работает в том же бюджете выделения памяти, что и встроенный сериализатор.
Похожее
- Как использовать Channels вместо BlockingCollection в C# — паттерны “async-first”, та же эпоха проектирования API.
- System.Text.Json в .NET 11 Preview 3 добавляет PascalCase и именование по членам — когда политики именования достаточно, а конвертера — нет.
- Как использовать JsonStringEnumConverter с Native AOT — история trim/AOT для встроенных конвертеров.
- Интерсепторы для генерации исходного кода System.Text.Json — параллельное направление эргономики, за которым стоит следить.
- Как вернуть несколько значений из метода в C# 14 — паттерны кортежей значений и записей, которым часто нужен конвертер.
Источники
- MS Learn: Write custom converters for JSON serialization
- MS Learn: How to use the source generator in System.Text.Json
- Справочник API:
Utf8JsonReader,Utf8JsonWriter - Трекер задач dotnet/runtime для области System.Text.Json: area-System.Text.Json