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

p7fjg7o0my49qq15la--uwfemds.jpeg


Не так давно состоялся релиз седьмой версии платформы .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(baseObj);



Так мы явно скажем сериализатору, что хотим воспользоваться reflection-based instantiation. Ну, а после релиза 7-й версии такими уловками заниматься не придётся. Ведь появился…

▍ JsonDerivedTypeAttribute


Если коротко, то модифицируем пример выше согласно последним новшествам библиотеки:

[JsonDerivedType(typeof(Derived), typeDiscriminator: nameof(Derived))]
abstract record Base;

record Derived(string Property) : Base;

Base baseObj = new Derived("String Property");
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(baseObj));
// {"$type":"Derived","Property":"String Property"}


Ну, а если подробно, то стоит посетить страницу официальной документации в msdn.

О новом атрибуте там всё описано достаточно понятно и со множеством подробных примеров.

Главное понять следующее — теперь в результирующую json-строку добавляется специальное поле-дискриминатор, которое позволяет отличить тип объекта. Соответственно, для того, чтобы можно было полиморфно десериализовать json в экземпляр некоторого базового класса, там обязательно должно присутствовать это поле.

Также хотелось бы обратить внимание на несколько важных моментов, которые могут ускользнуть от самого строгого взора.

  1. Дискриминатор должен ставиться в начало JSON’a:
    The type discriminator must be placed at the start of the JSON object, grouped together with other metadata properties like $id and $ref.
  2. Рекомендуется использовать единый тип для всех дискриминаторов:
    While the API supports mixing and matching type discriminator configurations, it’s not recommended. The general recommendation is to use either all string type discriminators, all int type discriminators, or no discriminators at all.
  3. Полиморфная сериализация возможна только в случае, если сериализатору был передан «корневой» тип иерархии, от которого всё наследуются:
    For polymorphic serialization to work, the type of the serialized value should be that of the polymorphic base type. This includes using the base type as the generic type parameter when serializing root-level values, as the declared type of serialized properties, or as the collection element in serialized collections.


Хорошо, с этим разобрались, но…

▍ Причём тут Swagger?


Представим, что в вашем приложении существует множество сервисов, которые общаются друг с другом. Среди контрактов этих сервисов появились вариации. То есть в зависимости от наполнения сообщение может выражать один объект, а может — другой или третий.

За то, какую именно информацию будет содержать сообщение, отвечает композиция. То есть каждое поле объекта отвечает некому варианту, но в один момент времени заполнено только одно.

Например, сервис принимает сообщение с информацией о некотором животном. Животным может быть кот или собака.

Тогда собака будет выглядеть так:

{
  "dog": {
    "bark": true
  },
  "cat": null
}


А кот вот так:

{
  "dog": null,
  "cat": {
    "meow": true
  }
}


А представьте, что появится ещё лошадь, корова, свинья и множество других:

{
  "dog": ...,
  "cat": ...,
  "horse": ...,
  "cow": ...,
  "pig": ...
}

Тогда, с увеличением объекта, вызванным добавлением новых полей, пропорционально их количеству растёт инфраструктурный код, обслуживающий такую историю. Трудно спорить с тем, что это выглядит крайне не гибко.

С учётом того, что такие сообщения используются исключительно во внутреннем обмене данными, кажется уместным внедрение полиморфных контрактов. Тем более, наследование и полиморфизм теперь поддерживаются спецификацией OpenApi (OAS 3). А для различения типов существует специальный Discriminator Object.

▍ Внедряем полиморфные контракты


Опишем нашу «животную» иерархию:

[JsonDerivedType(typeof(Cat), typeDiscriminator: nameof(Cat))]
[JsonDerivedType(typeof(Dog), typeDiscriminator: nameof(Dog))]
public abstract record Animal;

public record Cat : Animal
{
    public bool Meow { get; set; } = true;
}

public record Dog : Animal
{
    public bool Bark { get; set; } = true;
}


Ну и затем сделаем простенький контроллер с двумя «ручками». Одна будет отдавать данные, вторая принимать:

[ApiController]
[Route("[controller]")]
public class AnimalsController : Controller
{
    [HttpGet]
    public IEnumerable GetAnimals() =>
        new List {new Dog(), new Cat()};

    [HttpPost]
    public void PostAnimal([FromBody] Animal animal, [FromServices] ILogger logger) =>
        logger.LogInformation("{Animal}", animal.ToString());
}


Ура, всё работает! /GET отдаёт такой json:

[
  {
    "$type": "Cat",
    "meow": true
  },
  {
    "$type": "Dog",
    "bark": true
  }
]


А отправка котика через /POST:

{
  "$type": "Cat",
  "meow": true
}


Даёт вот такой вывод:

info: PolymorphicContracts.AnimalsController[0]
      Cat { Meow = True }


Конец статьи? Нет, давайте взглянем на сгенерированный Swagger UI:

fgwo-iowuuvc-wwfpaaulvy_b58.png

Здесь нас ждёт разочарование…

Пока видно, что благодаря такой «описательной» документации, этим API никто не сможет воспользоваться, потому что не поймёт как. Может, баг в генерации UI. Давайте проверим schemas:

{
  "schemas": {
    "Animal": {
      "type": "object",
      "additionalProperties": false
    }
  }
}


Всё-таки сгенерированные схемы не соответствуют действительности. Swagger просто не понял, что мы используем наследование и полиморфизм в документируемых контрактах. Попробуем сконфигурировать его для работы с ним:

builder.Services.ConfigureSwaggerGen(opt => opt.UseOneOfForPolymorphism());


Поглядим на заново созданные схемы:

{
  "schemas": {
    "Animal": {
      "type": "object",
      "additionalProperties": false
    },
    "Cat": {
      "type": "object",
      "allOf": [
        {
          "$ref": "#/components/schemas/Animal"
        }
      ],
      "properties": {
        "meow": {
          "type": "boolean"
        }
      },
      "additionalProperties": false
    },
    "Dog": {
      "type": "object",
      "allOf": [
        {
          "$ref": "#/components/schemas/Animal"
        }
      ],
      "properties": {
        "bark": {
          "type": "boolean"
        }
      },
      "additionalProperties": false
    }
  }
}


Уже лучше, появились типы наследники. Но во всех схемах отсутствует поле дискриминатор $type, которое должно быть помечено как required. Теперь Swagger не догадался о его наличии, поскольку оно добавляется постфактум, в строку, и в самом типе его нет. Хотя документация утверждает, что такого быть не должно:

If UseAllOfForInheritance or UseOneOfForPolymorphism is enabled, and your serializer supports (and has enabled) emitting/accepting a discriminator property, then Swashbuckle will automatically generate the corresponding discriminator metadata on base schema definitions.


Окей, попробуем ручками указать механизм селекции этой информации:

builder.Services.ConfigureSwaggerGen(opt =>
{
    opt.UseOneOfForPolymorphism();

    opt.SelectDiscriminatorNameUsing(_ => "$type");
    opt.SelectDiscriminatorValueUsing(subType => subType.BaseType!
        .GetCustomAttributes()
        .FirstOrDefault(x => x.DerivedType == subType)?
        .TypeDiscriminator!.ToString());
});


Теперь схемы выглядят так:

{
  "schemas": {
    "Animal": {
      "required": [
        "$type"
      ],
      "type": "object",
      "properties": {
        "$type": {
          "type": "string"
        }
      },
      "additionalProperties": false,
      "discriminator": {
        "propertyName": "$type",
        "mapping": {
          "Cat": "#/components/schemas/Cat",
          "Dog": "#/components/schemas/Dog"
        }
      }
    },
    "Cat": {
      "type": "object",
      "allOf": [
        {
          "$ref": "#/components/schemas/Animal"
        }
      ],
      "properties": {
        "meow": {
          "type": "boolean"
        }
      },
      "additionalProperties": false
    },
    "Dog": {
      "type": "object",
      "allOf": [
        {
          "$ref": "#/components/schemas/Animal"
        }
      ],
      "properties": {
        "bark": {
          "type": "boolean"
        }
      },
      "additionalProperties": false
    }
  }
}


Снова шаг вперёд, но всё ещё не то, что хотелось бы видеть. Во-первых, мы жёстко ограничены в типе дискриминатора, поскольку согласно контракту библиотеки его значение всегда приводится к строке, хотя оно может быть и числом. Во-вторых, не отображаются возможные значения дискриминатора (хотя бы как enum) и соответствующий default для каждого наследника. Здесь уже библиотека бессильна и требуется вмешательство народного велосипедостроения.

▍ Время костылей


Идея заключается в том, чтобы просканировать приложение на предмет иерархий, сериализующихся полиморфно в json. А затем полученную информацию использовать в пользовательской реализации интерфейса ISchemaFilter для дополнения схемы информацией об иерархии.

Алгоритм прост, как три рубля:

  1. Получаем список сборок, содержащих контракты. Например, прописываем его в appsettings.json, а оттуда в IOptions<>.
  2. Сканируем сборки на предмет типов, помеченных атрибутом [JsonDerivedType]. Это будут корни иерархий. Поскольку в атрибуте явно указываются наследники и дискриминаторы, то дополнительных поисков не потребуется, а информацию можно сохранить для дальнейшего использования.
  3. С помощью полученных иерархий дополнительно обогащаем документацию в новом ISchemaFilter.
  4. PROFIT!


Чтобы этот подход сработал, и его было проще реализовать, придётся придерживаться ряда правил относительно создания иерархий:

  • Использовать дискриминаторы единообразно — либо все строки, либо все числа.
  • Корень иерархии не интерфейс — либо abstract class, либо abstract record.
  • Наследование происходит только от базового класса. То есть в иерархии один слой.


Пример иерархии, которая нарушает все правила:

[JsonDerivedType(typeof(DerivedFirst), typeDiscriminator: 1)]
[JsonDerivedType(typeof(DerivedSecond), typeDiscriminator: "2")]
interface IBase
{
}

record DerivedFirst : IBase;

record DerivedSecond : DerivedFirst;


После траты какого-то времени на написание кода под эту историю удалось добиться удовлетворительного результата:

{
  "schemas": {
    "Animal": {
      "required": [
        "$type"
      ],
      "type": "object",
      "properties": {
        "$type": {
          "enum": [
            "Cat",
            "Dog"
          ],
          "type": "string"
        }
      },
      "additionalProperties": false,
      "discriminator": {
        "propertyName": "$type",
        "mapping": {
          "Cat": "#components/schemas/Cat",
          "Dog": "#components/schemas/Dog"
        }
      }
    },
    "Cat": {
      "required": [
        "$type"
      ],
      "type": "object",
      "allOf": [
        {
          "$ref": "#/components/schemas/Animal"
        }
      ],
      "properties": {
        "meow": {
          "type": "boolean"
        },
        "$type": {
          "type": "string",
          "default": "Cat"
        }
      },
      "additionalProperties": false
    },
    "Dog": {
      "required": [
        "$type"
      ],
      "type": "object",
      "allOf": [
        {
          "$ref": "#/components/schemas/Animal"
        }
      ],
      "properties": {
        "bark": {
          "type": "boolean"
        },
        "$type": {
          "type": "string",
          "default": "Dog"
        }
      },
      "additionalProperties": false
    }
  }
}


Спасибо, дорогой читатель, что добрался вместе со мной до этого момента. Мы уже близки к завершению, но осталось уточнить пару дополнительных вопросов.

▍ Валидация


Библиотека FluentValidation является одним из самых гибких и удобных инструментов в .NET разработке для написания рутинного проверочного кода. Всё абстрактно, полиморфно, типизировано, хоть и неявно (в том смысле, что [UsedImplicitly]). Сложно сосчитать, в скольких коммерческих проектах dto-шки покрывают гибкими валидаторами.

Так вот, возвращаясь к теме статьи, и такое библиотека умеет. Спокойно продолжаем писать валидаторы на наши конкретные типы, а регистрируем их таким образом (даже не придётся оборачивать во что-то):

public class AnimalValidator : AbstractValidator
{
    public AnimalValidator()
    {
        RuleFor(x => x)
            .SetInheritanceValidator(v => v
                .Add(new CatValidator())
                .Add(new DogValidator())
            );
    }
}


▍ Генераторы клиентов


Допустим, вы стали использовать полиморфные клиенты. Создали ни один сервис с применением такого подхода. И теперь потребовалось активно общаться с таким сервисом. Конечно, писать интеграции руками не хочется, люди прибегают к разного рода решениям по автоматизации создания клиентов.

Но, вспомните. Чуть выше было написано, что вызов полиморфной сериализации осуществляется с передачей базового типа в сериализатор:

JsonSerializer.Serialize(derivedTypeObj);


Так вот, решения вроде Refit, RestEase, RestSharp и прочие грешат тем, что когда отправляют запрос (например, /POST), передают в сериализатор неправильный тип (иногда вообще object). Из-за этого десереализация на стороне сервиса ломается, а клиент ловит 500-й код, так как:

System.NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported.


Здесь надо смотреть точечно для каждой библиотеки, как переопределить такое поведение. Например, в Refit есть такая вещь, как IHttpContentSerializer. Достаточно реализовать интерфейс и пробросить новую реализацию в настройки во время создания клиента:


var refitSettings = new RefitSettings
{
    ContentSerializer = new PolymorphicSerializer(
        new SystemTextJsonContentSerializer(),
        SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()
    )
};

internal class PolymorphicSerializer : IHttpContentSerializer
{
    private readonly IHttpContentSerializer _defaultSerializer;
    private readonly JsonSerializerOptions _serializerOptions;

    public PolymorphicSerializer(
        IHttpContentSerializer defaultSerializer,
        JsonSerializerOptions serializerOptions) =>
        (_defaultSerializer, _serializerOptions) =
        (defaultSerializer, serializerOptions);

    public HttpContent ToHttpContent(T item) =>
        JsonContent.Create(item, item!.GetType().BaseType!, options: _serializerOptions);

    public Task FromHttpContentAsync(HttpContent content, CancellationToken cancellationToken = default) =>
        _defaultSerializer.FromHttpContentAsync(content, cancellationToken);

    public string? GetFieldNameForProperty(PropertyInfo propertyInfo) =>
        _defaultSerializer.GetFieldNameForProperty(propertyInfo);
}


▍ Итого


Во время исследования вопроса полиморфных контрактов было обнаружено, что платформа и сторонние библиотеки не до конца поддерживают их внедрение как формата данных межсервисного взаимодействия. Скорее всего, это связано с тем, что в самой платформе они появились не так давно. С точки зрения инфраструктуры всё ещё требовались доработки напильником, но уже в гораздо меньшей степени, чем раньше. Также в процессе написания статьи был разработан NuGet-пакет для решения задачки со Swagger.

Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку, C# и мир IT глазами эксперта.

© Habrahabr.ru