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

public record ProductId(int Value);

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

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

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

public record ProductId(int Value);

public class Product
{
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public decimal UnitPrice { get; set; }
}

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

[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
    ...

    [HttpGet("{id}")]
    public ActionResult<Product> GetProduct(ProductId id)
    {
        // implementation not relevant...
    }
}

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

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.13",
    "title": "Unsupported Media Type",
    "status": 415,
    "traceId": "00-3600640f4e053b43b5ccefabe7eebd5a-159f5ca18d189142-00"
}

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

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

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

public class ProductIdConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
        sourceType == typeof(string);
    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) =>
        destinationType == typeof(string);

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        return value switch
        {
            string s => new ProductId(int.Parse(s)),
            null => null,
            _ => throw new ArgumentException($"Cannot convert from {value} to ProductId", nameof(value))
        };
    }

    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
    {
        if (destinationType == typeof(string))
        {
            return value switch
            {
                ProductId id => id.Value.ToString(),
                null => null,
                _ => throw new ArgumentException($"Cannot convert {value} to string", nameof(value))
            };
        }

        throw new ArgumentException($"Cannot convert {value ?? "(null)"} to {destinationType}", nameof(destinationType));
    }
}

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

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

[TypeConverter(typeof(ProductIdConverter))]
public record ProductId(int Value);

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

{
    "id": {
        "value": 1
    },
    "name": "Apple",
    "unitPrice": 0.8
}

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

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

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

public abstract record StronglyTypedId<TValue>(TValue Value)
    where TValue : notnull
{
    public override string ToString() => Value.ToString();
}

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

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

public record ProductId(int Value) : StronglyTypedId<int>(Value);

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

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

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

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

  • Проверки, является ли тип строго типизированным идентификатором, и получения типа значения
  • Создания и кэширования делегата для создания экземпляра строго типизированного идентификатора из значения
public static class StronglyTypedIdHelper
{
    private static readonly ConcurrentDictionary<Type, Delegate> StronglyTypedIdFactories = new();

    public static Func<TValue, object> GetFactory<TValue>(Type stronglyTypedIdType)
        where TValue : notnull
    {
        return (Func<TValue, object>)StronglyTypedIdFactories.GetOrAdd(
            stronglyTypedIdType,
            CreateFactory<TValue>);
    }

    private static Func<TValue, object> CreateFactory<TValue>(Type stronglyTypedIdType)
        where TValue : notnull
    {
        if (!IsStronglyTypedId(stronglyTypedIdType))
            throw new ArgumentException($"Type '{stronglyTypedIdType}' is not a strongly-typed id type", nameof(stronglyTypedIdType));

        var ctor = stronglyTypedIdType.GetConstructor(new[] { typeof(TValue) });
        if (ctor is null)
            throw new ArgumentException($"Type '{stronglyTypedIdType}' doesn't have a constructor with one parameter of type '{typeof(TValue)}'", nameof(stronglyTypedIdType));

        var param = Expression.Parameter(typeof(TValue), "value");
        var body = Expression.New(ctor, param);
        var lambda = Expression.Lambda<Func<TValue, object>>(body, param);
        return lambda.Compile();
    }

    public static bool IsStronglyTypedId(Type type) => IsStronglyTypedId(type, out _);

    public static bool IsStronglyTypedId(Type type, [NotNullWhen(true)] out Type idType)
    {
        if (type is null)
            throw new ArgumentNullException(nameof(type));

        if (type.BaseType is Type baseType &&
            baseType.IsGenericType &&
            baseType.GetGenericTypeDefinition() == typeof(StronglyTypedId<>))
        {
            idType = baseType.GetGenericArguments()[0];
            return true;
        }

        idType = null;
        return false;
    }
}

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

public class StronglyTypedIdConverter<TValue> : TypeConverter
    where TValue : notnull
{
    private static readonly TypeConverter IdValueConverter = GetIdValueConverter();

    private static TypeConverter GetIdValueConverter()
    {
        var converter = TypeDescriptor.GetConverter(typeof(TValue));
        if (!converter.CanConvertFrom(typeof(string)))
            throw new InvalidOperationException(
                $"Type '{typeof(TValue)}' doesn't have a converter that can convert from string");
        return converter;
    }

    private readonly Type _type;
    public StronglyTypedIdConverter(Type type)
    {
        _type = type;
    }

    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return sourceType == typeof(string)
            || sourceType == typeof(TValue)
            || base.CanConvertFrom(context, sourceType);
    }

    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
    {
        return destinationType == typeof(string)
            || destinationType == typeof(TValue)
            || base.CanConvertTo(context, destinationType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        if (value is string s)
        {
            value = IdValueConverter.ConvertFrom(s);
        }

        if (value is TValue idValue)
        {
            var factory = StronglyTypedIdHelper.GetFactory<TValue>(_type);
            return factory(idValue);
        }

        return base.ConvertFrom(context, culture, value);
    }

    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
    {
        if (value is null)
            throw new ArgumentNullException(nameof(value));

        var stronglyTypedId = (StronglyTypedId<TValue>)value;
        TValue idValue = stronglyTypedId.Value;
        if (destinationType == typeof(string))
            return idValue.ToString()!;
        if (destinationType == typeof(TValue))
            return idValue;
        return base.ConvertTo(context, culture, value, destinationType);
    }
}

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

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

public class StronglyTypedIdConverter : TypeConverter
{
    private static readonly ConcurrentDictionary<Type, TypeConverter> ActualConverters = new();

    private readonly TypeConverter _innerConverter;

    public StronglyTypedIdConverter(Type stronglyTypedIdType)
    {
        _innerConverter = ActualConverters.GetOrAdd(stronglyTypedIdType, CreateActualConverter);
    }

    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
        _innerConverter.CanConvertFrom(context, sourceType);
    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) =>
        _innerConverter.CanConvertTo(context, destinationType);
    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) =>
        _innerConverter.ConvertFrom(context, culture, value);
    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) =>
        _innerConverter.ConvertTo(context, culture, value, destinationType);


    private static TypeConverter CreateActualConverter(Type stronglyTypedIdType)
    {
        if (!StronglyTypedIdHelper.IsStronglyTypedId(stronglyTypedIdType, out var idType))
            throw new InvalidOperationException($"The type '{stronglyTypedIdType}' is not a strongly typed id");

        var actualConverterType = typeof(StronglyTypedIdConverter<>).MakeGenericType(idType);
        return (TypeConverter)Activator.CreateInstance(actualConverterType, stronglyTypedIdType)!;
    }
}

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

[TypeConverter(typeof(StronglyTypedIdConverter))]
public abstract record StronglyTypedId<TValue>(TValue Value)
    where TValue : notnull
{
    public override string ToString() => Value.ToString();
}

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

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