Swagger и полиморфные контракты в .NET 7

Не так давно состоялся релиз седьмой версии платформы .NET. Он привнёс множество изменений и интересных нововведений, по которым уже успели пробежаться в рамках новостного обзора.
В этой статье мы рассмотрим развитие сериализации платформы (System.Text.Json
) вместе с возможностями, которые она открывает.
▍ До релиза .NET 7
Платформа взяла плотный курс на «импортозамещение», так сказать.
Множество NuGet-пакетов, ранее популярных среди C#-программистов, теперь имеют аналоги внутри платформы .NET, предлагая альтернативное и, самое главное, коробочное решение.
Собственно, в репозитории dotnet/runtime
все эти библиотеки и хранятся:
dotnet/runtime/src/libraries.
Самый очевидный пример консолидации усилий на замену популярного open-source решения платформенным — JSON-сериализация.
Вспомните, что раньше гуглили почти все перекладыватели данных из кафки в базу? Правильно — Json.NET
, он же Newtonsoft.Json
, он же просто Newtonsoft
. Это сверхпопулярная библиотека — почти 2.5 миллиарда скачиваний. Доходило даже до того, что платформа (до версии 3.0) нативно её поддерживала через разного рода адаптеры. Разве это технологический суверенитет? Конечно, нет.
Вот и Microsoft решили, что:
As part of the work to improve the ASP.NET Core shared framework, Newtonsoft.Json (Json.NET) has been removed from the ASP.NET Core shared framework.The default JSON serializer for ASP.NET Core is now System.Text.Json, which is new in .NET Core 3.0. Consider using System.Text.Json when possible. It’s high-performance and doesn’t require an additional library dependency. However, since System.Text.Json is new, it might currently be missing features that your app needs. For more information, see How to migrate from Newtonsoft.Json to System.Text.Json.
Сейчас можно с уверенностью сказать, что пакет System.Text.Json
покрывает 100% потребностей ежедневных задач при работе с форматом обмена данными JSON. При этом он работает эффективнее, чем 3rd-party аналог, как по времени, так и по памяти.
▍ Полиморфная сериализация
Это одна из тех крупных аналогичных возможностей, которые не были доступны сразу. Что-то приходилось дорабатывать напильником. Где-то нужно было клепать наши любимые костыли да велосипеды.
Работа с иерархиями наследования устроена по-разному в двух библиотеках.
Допустим, что у нас есть следующая элементарная иерархия:
abstract record Base;
record Derived(string Property) : Base;
Поведение из коробки двух сериализаторов будет кардинально отличаться при работе с ней:
Base baseObj = new Derived("String Property");
Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(baseObj));
// {"Property":"String Property"}
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(baseObj));
// {}
Разница в поведении вызвана тем, как каждый из сериализаторов коллекционирует информацию об обрабатываемом типе. Если взглянуть на исходные коды библиотек, то это становится очевидно.
Newtonsoft
строит контракты на лету на основе информации, получаемой исключительно в runtime. Ему нужен просто экземпляр типа Type
, и дальше всё случится само:
protected virtual JsonContract CreateContract(Type objectType)
{
Type t = ReflectionUtils.EnsureNotByRefType(objectType);
if (IsJsonPrimitiveType(t))
return CreatePrimitiveContract(objectType);
t = ReflectionUtils.EnsureNotNullableType(t);
JsonContainerAttribute? containerAttribute = JsonTypeReflector.GetCachedAttribute(t);
if (containerAttribute is JsonObjectAttribute)
return CreateObjectContract(objectType);
if (containerAttribute is JsonArrayAttribute)
return CreateArrayContract(objectType);
if (containerAttribute is JsonDictionaryAttribute)
return CreateDictionaryContract(objectType);
if (t == typeof(JToken) || t.IsSubclassOf(typeof(JToken)))
return CreateLinqContract(objectType);
if (CollectionUtils.IsDictionaryType(t))
return CreateDictionaryContract(objectType);
if (typeof(IEnumerable).IsAssignableFrom(t))
return CreateArrayContract(objectType);
if (CanConvertToString(t))
return CreateStringContract(objectType);
return CreateObjectContract(objectType);
}
Советую на досуге ознакомиться с методом CreateObjectContract
. Он очень показательный в плане работы рефлексии под капотом.
System.Text.Json
же, в свою очередь, устроен интереснее. Он до последнего роется в конвертерах, которые у него есть на руках:
- пользовательские,
- из атрибута
JsonConverterAttribute
, - встроенные,
- из фабрик.
Конвертеров там действительно много. Просто взгляните внутрь этой папки. И только по окончании поисков он пытается создать конвертер, если не вышло «вынуть козырь из рукава»:
internal static JsonConverter GetConverterForType(Type typeToConvert, JsonSerializerOptions options, bool resolveJsonConverterAttribute = true)
{
RootDefaultInstance(); // Ensure default converters are rooted.
// Priority 1: Attempt to get custom converter from the Converters list.
JsonConverter? converter = options.GetConverterFromList(typeToConvert);
// Priority 2: Attempt to get converter from [JsonConverter] on the type being converted.
if (resolveJsonConverterAttribute && converter == null)
{
JsonConverterAttribute? converterAttribute = typeToConvert.GetUniqueCustomAttribute(inherit: false);
if (converterAttribute != null)
{
converter = GetConverterFromAttribute(converterAttribute, typeToConvert: typeToConvert, memberInfo: null, options);
}
}
// Priority 3: Query the built-in converters.
converter ??= GetBuiltInConverter(typeToConvert);
// Expand if factory converter & validate.
converter = options.ExpandConverterFactory(converter, typeToConvert);
if (!converter.TypeToConvert.IsInSubtypeRelationshipWith(typeToConvert))
{
ThrowHelper.ThrowInvalidOperationException_SerializationConverterNotCompatible(converter.GetType(), converter.TypeToConvert);
}
JsonSerializerOptions.CheckConverterNullabilityIsSameAsPropertyType(converter, typeToConvert);
return converter;
}
Но почему именно конвертеры? Дело в том, что они предоставляют информацию о типе, используемую для сериализации. Но только в том случае, если тип, поданный на вход сериализатора, совпал с типом конвертера:
if (converter.TypeToConvert == type)
{
// For performance, avoid doing a reflection-based instantiation
// if the converter type matches that of the declared type.
jsonTypeInfo = converter.CreateCustomJsonTypeInfo(options);
}
else
{
Type jsonTypeInfoType = typeof(CustomJsonTypeInfo<>).MakeGenericType(type);
jsonTypeInfo = (JsonTypeInfo)jsonTypeInfoType.CreateInstanceNoWrapExceptions(
parameterTypes: new Type[] { typeof(JsonConverter), typeof(JsonSerializerOptions) },
parameters: new object[] { converter, options })!;
}
Эта внутренняя разница видна и снаружи. Достаточно взглянуть на сигнатуры вызванных методов:
public static string Newtonsoft.Json.JsonConvert.SerializeObject(object? value);
public static string System.Text.Json.JsonSerializer.Serialize(TValue value, JsonSerializerOptions? options = null);
Соответственно, чтобы привести поведение библиотек в примере выше к общему знаменателю, до 7-й версии, в случае System.Text.Json
достаточно вызвать метод Serialize
с указанием типа object
(рекомендация msdn, кстати):
System.Text.Json.JsonSerializer.Serialize