В прошлый раз я объяснил, насколько просто использовать типы записей C # 9 в качестве строго типизированных идентификаторов:

[pastacode lang=»c» manual=»public%20record%20ProductId(int%20Value)%3B» message=»» highlight=»» provider=»manual»/]

Но, к сожалению, мы еще не закончили: нужно исправить несколько проблем, прежде чем наши строго типизированные идентификаторы станут действительно пригодными для использования. Например, ASP.NET Core не знает, как обрабатывать их в параметрах маршрута или параметрах строки запроса. В этом посте я покажу, как решить эту проблему.

Связывание модели параметров маршрута и строки запроса

Допустим, у нас есть такая сущность:

[pastacode lang=»c» manual=»public%20record%20ProductId(int%20Value)%3B%0A%0Apublic%20class%20Product%0A%7B%0A%20%20%20%20public%20ProductId%20Id%20%7B%20get%3B%20set%3B%20%7D%0A%20%20%20%20public%20string%20Name%20%7B%20get%3B%20set%3B%20%7D%0A%20%20%20%20public%20decimal%20UnitPrice%20%7B%20get%3B%20set%3B%20%7D%0A%7D» message=»» highlight=»» provider=»manual»/]

И конечная точка API вроде этого:

[pastacode lang=»c» manual=»%5BApiController%5D%0A%5BRoute(%22api%2F%5Bcontroller%5D%22)%5D%0Apublic%20class%20ProductController%20%3A%20ControllerBase%0A%7B%0A%20%20%20%20…%0A%0A%20%20%20%20%5BHttpGet(%22%7Bid%7D%22)%5D%0A%20%20%20%20public%20ActionResult%3CProduct%3E%20GetProduct(ProductId%20id)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%2F%2F%20implementation%20not%20relevant…%0A%20%20%20%20%7D%0A%7D» message=»» highlight=»» provider=»manual»/]

Теперь попробуем вызвать эту конечную точку с помощью запроса GET на / api / product / 1…

[pastacode lang=»c» manual=»%7B%0A%20%20%20%20%22type%22%3A%20%22https%3A%2F%2Ftools.ietf.org%2Fhtml%2Frfc7231%23section-6.5.13%22%2C%0A%20%20%20%20%22title%22%3A%20%22Unsupported%20Media%20Type%22%2C%0A%20%20%20%20%22status%22%3A%20415%2C%0A%20%20%20%20%22traceId%22%3A%20%2200-3600640f4e053b43b5ccefabe7eebd5a-159f5ca18d189142-00%22%0A%7D» message=»» highlight=»» provider=»manual»/]

Ой! Не очень обнадеживает … Проблема в том, что ASP.NET Core не знает, как преобразовать 1 в URL-адресе в экземпляр ProductId. Поскольку это не примитивный тип и не имеет связанного преобразователя типов, ASP.NET предполагает, что этот параметр должен быть прочитан из тела запроса. Но у нас нет тела, поскольку это запрос GET.

Реализация преобразователя типов

Решение здесь — реализовать преобразователь типов для ProductId. Это достаточно просто:

[pastacode lang=»c» manual=»public%20class%20ProductIdConverter%20%3A%20TypeConverter%0A%7B%0A%20%20%20%20public%20override%20bool%20CanConvertFrom(ITypeDescriptorContext%20context%2C%20Type%20sourceType)%20%3D%3E%0A%20%20%20%20%20%20%20%20sourceType%20%3D%3D%20typeof(string)%3B%0A%20%20%20%20public%20override%20bool%20CanConvertTo(ITypeDescriptorContext%20context%2C%20Type%20destinationType)%20%3D%3E%0A%20%20%20%20%20%20%20%20destinationType%20%3D%3D%20typeof(string)%3B%0A%0A%20%20%20%20public%20override%20object%20ConvertFrom(ITypeDescriptorContext%20context%2C%20CultureInfo%20culture%2C%20object%20value)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20return%20value%20switch%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20string%20s%20%3D%3E%20new%20ProductId(int.Parse(s))%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20null%20%3D%3E%20null%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20_%20%3D%3E%20throw%20new%20ArgumentException(%24%22Cannot%20convert%20from%20%7Bvalue%7D%20to%20ProductId%22%2C%20nameof(value))%0A%20%20%20%20%20%20%20%20%7D%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20override%20object%20ConvertTo(ITypeDescriptorContext%20context%2C%20CultureInfo%20culture%2C%20object%20value%2C%20Type%20destinationType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20if%20(destinationType%20%3D%3D%20typeof(string))%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20value%20switch%0A%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20ProductId%20id%20%3D%3E%20id.Value.ToString()%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20null%20%3D%3E%20null%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20_%20%3D%3E%20throw%20new%20ArgumentException(%24%22Cannot%20convert%20%7Bvalue%7D%20to%20string%22%2C%20nameof(value))%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20throw%20new%20ArgumentException(%24%22Cannot%20convert%20%7Bvalue%20%3F%3F%20%22(null)%22%7D%20to%20%7BdestinationType%7D%22%2C%20nameof(destinationType))%3B%0A%20%20%20%20%7D%0A%7D» message=»» highlight=»» provider=»manual»/]

(Обратите внимание, что для краткости я обрабатывал только преобразование в строку и из строки. В реальном сценарии мы, вероятно, также хотели бы поддерживать преобразование в и из int.)

Мы связываем этот конвертер с записью ProductId с помощью атрибута TypeConverter:

[pastacode lang=»c» manual=»%5BTypeConverter(typeof(ProductIdConverter))%5D%0Apublic%20record%20ProductId(int%20Value)%3B» message=»» highlight=»» provider=»manual»/]

Теперь давайте попробуем снова вызвать нашу конечную точку API:

[pastacode lang=»c» manual=»%7B%0A%20%20%20%20%22id%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22value%22%3A%201%0A%20%20%20%20%7D%2C%0A%20%20%20%20%22name%22%3A%20%22Apple%22%2C%0A%20%20%20%20%22unitPrice%22%3A%200.8%0A%7D» message=»» highlight=»» provider=»manual»/]

Это… вроде как работает. Тот факт, что id отображается как объект в JSON, конечно, прискорбен, но мы рассмотрим это позже. Еще одна неприятная проблема — это объем кода, который нам приходилось писать только для одного строго типизированного идентификатора. Если нам нужно сделать это для каждого типа идентификатора, мы потеряем все преимущества краткого синтаксиса для их объявления. Нам нужен какой-то универсальный конвертер, который может обрабатывать любой строго типизированный идентификатор.

Общий базовый тип для строго типизированных идентификаторов

Чтобы иметь возможность написать единый преобразователь, работающий для любого строго типизированного идентификатора, наши идентификаторы должны иметь что-то общее, например базовую запись или интерфейс. Базовая запись делает синтаксис немного более громоздким, потому что нам нужно передавать аргументы базовому типу, но есть и другие преимущества, так что давайте пока сделаем это.

[pastacode lang=»c» manual=»public%20abstract%20record%20StronglyTypedId%3CTValue%3E(TValue%20Value)%0A%20%20%20%20where%20TValue%20%3A%20notnull%0A%7B%0A%20%20%20%20public%20override%20string%20ToString()%20%3D%3E%20Value.ToString()%3B%0A%7D» message=»» highlight=»» provider=»manual»/]

Обратите внимание, что нам нужно переопределить ToString (), чтобы вернуть строковое представление значения: реализация записи по умолчанию вернет что-то вроде «ProductId {Value = 1}», что удобно для отладки, но вызовет проблемы в будущем (например, в генерации URL).

Теперь мы можем объявить наш строго типизированный идентификатор следующим образом:

[pastacode lang=»c» manual=»public%20record%20ProductId(int%20Value)%20%3A%20StronglyTypedId%3Cint%3E(Value)%3B» message=»» highlight=»» provider=»manual»/]

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

Универсальный преобразователь строго типизированных идентификаторов

Теперь, когда у нас есть общий базовый тип, мы можем написать универсальный преобразователь. Это будет немного сложнее, чем просто для ProductId, но нам нужно будет написать его только один раз.

Во-первых, давайте создадим вспомогательный класс для

  • Проверки, является ли тип строго типизированным идентификатором, и получения типа значения
  • Создания и кэширования делегата для создания экземпляра строго типизированного идентификатора из значения

[pastacode lang=»c» manual=»public%20static%20class%20StronglyTypedIdHelper%0A%7B%0A%20%20%20%20private%20static%20readonly%20ConcurrentDictionary%3CType%2C%20Delegate%3E%20StronglyTypedIdFactories%20%3D%20new()%3B%0A%0A%20%20%20%20public%20static%20Func%3CTValue%2C%20object%3E%20GetFactory%3CTValue%3E(Type%20stronglyTypedIdType)%0A%20%20%20%20%20%20%20%20where%20TValue%20%3A%20notnull%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20return%20(Func%3CTValue%2C%20object%3E)StronglyTypedIdFactories.GetOrAdd(%0A%20%20%20%20%20%20%20%20%20%20%20%20stronglyTypedIdType%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20CreateFactory%3CTValue%3E)%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20private%20static%20Func%3CTValue%2C%20object%3E%20CreateFactory%3CTValue%3E(Type%20stronglyTypedIdType)%0A%20%20%20%20%20%20%20%20where%20TValue%20%3A%20notnull%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20if%20(!IsStronglyTypedId(stronglyTypedIdType))%0A%20%20%20%20%20%20%20%20%20%20%20%20throw%20new%20ArgumentException(%24%22Type%20’%7BstronglyTypedIdType%7D’%20is%20not%20a%20strongly-typed%20id%20type%22%2C%20nameof(stronglyTypedIdType))%3B%0A%0A%20%20%20%20%20%20%20%20var%20ctor%20%3D%20stronglyTypedIdType.GetConstructor(new%5B%5D%20%7B%20typeof(TValue)%20%7D)%3B%0A%20%20%20%20%20%20%20%20if%20(ctor%20is%20null)%0A%20%20%20%20%20%20%20%20%20%20%20%20throw%20new%20ArgumentException(%24%22Type%20’%7BstronglyTypedIdType%7D’%20doesn’t%20have%20a%20constructor%20with%20one%20parameter%20of%20type%20’%7Btypeof(TValue)%7D’%22%2C%20nameof(stronglyTypedIdType))%3B%0A%0A%20%20%20%20%20%20%20%20var%20param%20%3D%20Expression.Parameter(typeof(TValue)%2C%20%22value%22)%3B%0A%20%20%20%20%20%20%20%20var%20body%20%3D%20Expression.New(ctor%2C%20param)%3B%0A%20%20%20%20%20%20%20%20var%20lambda%20%3D%20Expression.Lambda%3CFunc%3CTValue%2C%20object%3E%3E(body%2C%20param)%3B%0A%20%20%20%20%20%20%20%20return%20lambda.Compile()%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20static%20bool%20IsStronglyTypedId(Type%20type)%20%3D%3E%20IsStronglyTypedId(type%2C%20out%20_)%3B%0A%0A%20%20%20%20public%20static%20bool%20IsStronglyTypedId(Type%20type%2C%20%5BNotNullWhen(true)%5D%20out%20Type%20idType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20if%20(type%20is%20null)%0A%20%20%20%20%20%20%20%20%20%20%20%20throw%20new%20ArgumentNullException(nameof(type))%3B%0A%0A%20%20%20%20%20%20%20%20if%20(type.BaseType%20is%20Type%20baseType%20%26%26%0A%20%20%20%20%20%20%20%20%20%20%20%20baseType.IsGenericType%20%26%26%0A%20%20%20%20%20%20%20%20%20%20%20%20baseType.GetGenericTypeDefinition()%20%3D%3D%20typeof(StronglyTypedId%3C%3E))%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20idType%20%3D%20baseType.GetGenericArguments()%5B0%5D%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20true%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20idType%20%3D%20null%3B%0A%20%20%20%20%20%20%20%20return%20false%3B%0A%20%20%20%20%7D%0A%7D» message=»» highlight=»» provider=»manual»/]

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

[pastacode lang=»c» manual=»public%20class%20StronglyTypedIdConverter%3CTValue%3E%20%3A%20TypeConverter%0A%20%20%20%20where%20TValue%20%3A%20notnull%0A%7B%0A%20%20%20%20private%20static%20readonly%20TypeConverter%20IdValueConverter%20%3D%20GetIdValueConverter()%3B%0A%0A%20%20%20%20private%20static%20TypeConverter%20GetIdValueConverter()%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20var%20converter%20%3D%20TypeDescriptor.GetConverter(typeof(TValue))%3B%0A%20%20%20%20%20%20%20%20if%20(!converter.CanConvertFrom(typeof(string)))%0A%20%20%20%20%20%20%20%20%20%20%20%20throw%20new%20InvalidOperationException(%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24%22Type%20’%7Btypeof(TValue)%7D’%20doesn’t%20have%20a%20converter%20that%20can%20convert%20from%20string%22)%3B%0A%20%20%20%20%20%20%20%20return%20converter%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20private%20readonly%20Type%20_type%3B%0A%20%20%20%20public%20StronglyTypedIdConverter(Type%20type)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20_type%20%3D%20type%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20override%20bool%20CanConvertFrom(ITypeDescriptorContext%20context%2C%20Type%20sourceType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20return%20sourceType%20%3D%3D%20typeof(string)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7C%7C%20sourceType%20%3D%3D%20typeof(TValue)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7C%7C%20base.CanConvertFrom(context%2C%20sourceType)%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20override%20bool%20CanConvertTo(ITypeDescriptorContext%20context%2C%20Type%20destinationType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20return%20destinationType%20%3D%3D%20typeof(string)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7C%7C%20destinationType%20%3D%3D%20typeof(TValue)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7C%7C%20base.CanConvertTo(context%2C%20destinationType)%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20override%20object%20ConvertFrom(ITypeDescriptorContext%20context%2C%20CultureInfo%20culture%2C%20object%20value)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20if%20(value%20is%20string%20s)%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20value%20%3D%20IdValueConverter.ConvertFrom(s)%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20if%20(value%20is%20TValue%20idValue)%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20var%20factory%20%3D%20StronglyTypedIdHelper.GetFactory%3CTValue%3E(_type)%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20factory(idValue)%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20return%20base.ConvertFrom(context%2C%20culture%2C%20value)%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20override%20object%20ConvertTo(ITypeDescriptorContext%20context%2C%20CultureInfo%20culture%2C%20object%20value%2C%20Type%20destinationType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20if%20(value%20is%20null)%0A%20%20%20%20%20%20%20%20%20%20%20%20throw%20new%20ArgumentNullException(nameof(value))%3B%0A%0A%20%20%20%20%20%20%20%20var%20stronglyTypedId%20%3D%20(StronglyTypedId%3CTValue%3E)value%3B%0A%20%20%20%20%20%20%20%20TValue%20idValue%20%3D%20stronglyTypedId.Value%3B%0A%20%20%20%20%20%20%20%20if%20(destinationType%20%3D%3D%20typeof(string))%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20idValue.ToString()!%3B%0A%20%20%20%20%20%20%20%20if%20(destinationType%20%3D%3D%20typeof(TValue))%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20idValue%3B%0A%20%20%20%20%20%20%20%20return%20base.ConvertTo(context%2C%20culture%2C%20value%2C%20destinationType)%3B%0A%20%20%20%20%7D%0A%7D» message=»» highlight=»» provider=»manual»/]

Этот конвертер может преобразовывать в строку и TValue и обратно, что должно удовлетворить наши потребности.

Хорошо, выглядит неплохо, но как применить этот преобразователь ко всем строго типизированным идентификаторам? Что ж, мы, конечно же, применим его к базовой записи StronglyTypedId <TValue>! Но… преобразователь универсальный. Если мы попытаемся установить typeof (StronglyTypedIdConverter <>) в качестве преобразователя, мы получим ошибку, поскольку тип преобразователя не может быть открытым универсальным типом. Итак, нам нужен не универсальный промежуточный преобразователь, который создаст фактический преобразователь и делегирует ему:

[pastacode lang=»c» manual=»public%20class%20StronglyTypedIdConverter%20%3A%20TypeConverter%0A%7B%0A%20%20%20%20private%20static%20readonly%20ConcurrentDictionary%3CType%2C%20TypeConverter%3E%20ActualConverters%20%3D%20new()%3B%0A%0A%20%20%20%20private%20readonly%20TypeConverter%20_innerConverter%3B%0A%0A%20%20%20%20public%20StronglyTypedIdConverter(Type%20stronglyTypedIdType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20_innerConverter%20%3D%20ActualConverters.GetOrAdd(stronglyTypedIdType%2C%20CreateActualConverter)%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20override%20bool%20CanConvertFrom(ITypeDescriptorContext%20context%2C%20Type%20sourceType)%20%3D%3E%0A%20%20%20%20%20%20%20%20_innerConverter.CanConvertFrom(context%2C%20sourceType)%3B%0A%20%20%20%20public%20override%20bool%20CanConvertTo(ITypeDescriptorContext%20context%2C%20Type%20destinationType)%20%3D%3E%0A%20%20%20%20%20%20%20%20_innerConverter.CanConvertTo(context%2C%20destinationType)%3B%0A%20%20%20%20public%20override%20object%20ConvertFrom(ITypeDescriptorContext%20context%2C%20CultureInfo%20culture%2C%20object%20value)%20%3D%3E%0A%20%20%20%20%20%20%20%20_innerConverter.ConvertFrom(context%2C%20culture%2C%20value)%3B%0A%20%20%20%20public%20override%20object%20ConvertTo(ITypeDescriptorContext%20context%2C%20CultureInfo%20culture%2C%20object%20value%2C%20Type%20destinationType)%20%3D%3E%0A%20%20%20%20%20%20%20%20_innerConverter.ConvertTo(context%2C%20culture%2C%20value%2C%20destinationType)%3B%0A%0A%0A%20%20%20%20private%20static%20TypeConverter%20CreateActualConverter(Type%20stronglyTypedIdType)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20if%20(!StronglyTypedIdHelper.IsStronglyTypedId(stronglyTypedIdType%2C%20out%20var%20idType))%0A%20%20%20%20%20%20%20%20%20%20%20%20throw%20new%20InvalidOperationException(%24%22The%20type%20’%7BstronglyTypedIdType%7D’%20is%20not%20a%20strongly%20typed%20id%22)%3B%0A%0A%20%20%20%20%20%20%20%20var%20actualConverterType%20%3D%20typeof(StronglyTypedIdConverter%3C%3E).MakeGenericType(idType)%3B%0A%20%20%20%20%20%20%20%20return%20(TypeConverter)Activator.CreateInstance(actualConverterType%2C%20stronglyTypedIdType)!%3B%0A%20%20%20%20%7D%0A%7D» message=»» highlight=»» provider=»manual»/]

Теперь мы можем применить этот преобразователь к нашему базовому типу записи:

[pastacode lang=»c» manual=»%5BTypeConverter(typeof(StronglyTypedIdConverter))%5D%0Apublic%20abstract%20record%20StronglyTypedId%3CTValue%3E(TValue%20Value)%0A%20%20%20%20where%20TValue%20%3A%20notnull%0A%7B%0A%20%20%20%20public%20override%20string%20ToString()%20%3D%3E%20Value.ToString()%3B%0A%7D» message=»» highlight=»» provider=»manual»/]

И мы можем удалить ProductIdConverter, в котором больше нет необходимости. Привязка модели параметров маршрута или строки запроса к строго типизированным идентификаторам теперь работает правильно.

Эта статья уже достаточно длинная, поэтому давайте на ней остановимся. В следующий раз мы займемся сериализацией JSON!