В .Net Core есть встроенный механизм Model Binding, позволяющий не просто принимать входные параметры в контроллерах, а получать сразу объекты с заполненными полями. Это позволяет встроить в такой объект все нужные проверки с помощью Model Validation.

Вот только данные, нужные для работы API, приходят нам не только из Query или Body. Какие-то данные нужно получить из Headers (в моем случае там был json в base64), какие-то — из внешних сервисов или ActionRoute, если вы используете REST. Для получения данных оттуда можно использовать свой Binding. Правда и тут есть проблема: если вы решили не нарушать инкапсуляцию и инициализировать модель через конструктор, то придется пошаманить.

Для себя и для будущих поколений я решил написать что-то вроде инструкции по использованию Binding и шаманство с ним.

Проблема


Типичный контроллер выглядит как-то так:

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery[IncomeData someData)
{
    var moreData = GetFromHeaderAndDecode("X-Property");
    if (moreData.Id == 0)
    {
        return StatusCode(400, "Nginx doesnt know your id");
    }
    var externalData = GetFromExternalService("http://myservice.com/MoreData");
    if (externalData == null)
    {
        return StatusCode(500, "Cant connect to external service");
    }
    var finalData = new FinalData(someData, moreData, externalData);
    return _myService.Handle(finalData);
}

В итоге мы получаем следующие проблемы:

  1. Логика валидации размазана по объекту запроса, методу запроса из заголовка, методу запроса из сервиса и методу контроллера. Чтобы убедиться, что нужная проверка точно есть, нужно провести целое расследование!
  2. В соседнем методе контроллера будет точно такой же код. Копипаст программирование в атаке.
  3. Обычно проверок значительно больше, чем в примере, и в итоге единственная значимая строчка — вызов метода обработки бизнес-логики — спрятан в куче кода. Увидеть его и понять, что вообще тут происходит, требует определенных усилий.

Свой Binding (Easy Mode)


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

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery]FinalData finalData)
{
    return _myService.Handle(finalData);
}

Дальше создадим свой binder для типа MoreData.

public class MoreDataBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
        if (moreData != null)
        {
            bindingContext.Result = ModelBindingResult.Success(moreData);
        }
        return Task.CompletedTask;
    }
    private MoreData GetFromHeaderAndDecode(IHeaderDictionary headers) { ... }
}

Наконец поправим модель FinalData, добавив туда привязку binder к свойству:

public class FinalData
{
    public int SomeDataNumber { get; set; }

    public string SomeDataText { get; set; }

    [ModelBinder(BinderType = typeof(MoreDataBinder))]
    public MoreData MoreData { get; set; }
}

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

Создадим свой BinderProvider:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        var modelType = context.Metadata.UnderlyingOrModelType;
        if (modelType == typeof(MoreData))
        {
            return new BinderTypeModelBinder(typeof(MoreDataBinder));
        }
        return null;
    }
}

И зарегистрируем его в Startup:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMvc(options =>
    {
        options.ModelBinderProviders.Insert(0, new MoreDataBinderProvider());
     });
}

Провайдер вызывается для каждого объекта модели в порядке очереди. Если наш провайдер встретит нужный тип, он вернет нужный binder. А если нет, то сработает binder по умолчанию. Так что теперь всегда, когда мы будем указывать тип MoreData, он будет браться и декодироваться из Header и специальных атрибутов в моделях указывать не нужно.

Свой Binding (Hard Mode)


Все это здорово, но есть одно но: чтобы магия работала, наша модель должна иметь публичные свойства с set. А как же инкапсуляция? Что если я хочу передавать данные запроса в различные злачные места и знать, что они там не будут изменены?

Проблема в том, что дефолтный binder не работает для моделей, у которых нет конструктора по умолчанию. Но что нам мешает написать свой?
В сервисе, для которого я писал этот код, не используется REST, параметры передаются только через Query и Body, а так же используется только два типа запросов — Get
и Post. Соответственно, в случае REST API логика обработки будет немного отличаться.
В целом код останется без изменений, доработка нужна только нашему binder, чтобы он сам создавал объект и заполнял его приватные поля. Дальше я приведу куски кода с комментариями, кому не интересно — в конце статьи под катом весь листинг класса.

Для начала, определим, является ли MoreData единственным свойством класса. Если да, то объект нужно создать самому (привет, Activator), а если нет — то с созданием отлично справится JsonConvert, а мы просто подсунем нужные данные в свойство.

private static bool NeedActivator(IReflect modelType)
{
    var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
    var properties = modelType.GetProperties(propFlags);

    return properties.Select(p => p.Name).Distinct().Count() == 1;
}

Создать объект через JsonConvert просто, для запросов с Body:

private static object? GetModelFromBody(ModelBindingContext bindingContext, Type modelType)
{
    using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
    var jsonString = reader.ReadToEnd();
    var data = JsonConvert.DeserializeObject(jsonString, modelType);
    return data;
}

А вот с Query мне пришлось накостылять. Буду рад, если кто-то сможет подсказать более красивое решение.

При передаче массива получается несколько параметров с одинаковым именем. Приведение к «плоскому» типу помогает, но сериализация ставит лишние кавычки к массиву [], которые приходится убирать вручную.

private static object? GetModelFromQuery(ModelBindingContext bindingContext, Type modelType)
{
    var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);
    var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

    var jsonStr = JsonConvert.SerializeObject(jsonDictionary).Replace("\"[", "[").Replace("]\"", "]");
    var data = JsonConvert.DeserializeObject(jsonStr, modelType);
    return data;
}

Наконец, создав объект, необходимо в его приватное свойство записать наши данные. Именно про это шаманство я и говорил в начале статьи. Нашел это решение вот тут, за что автору большое спасибо.

private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
{
    var propName = $"<{propertyInfo.Name}>k__BackingField";
    var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
    obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
}

Ну и объединяем эти методы в едином вызове:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
    if (moreData == null)
    {
        return Task.CompletedTask;
    }

    var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
    if (NeedActivator(modelType))
    {
        var data = Activator.CreateInstance(modelType, moreData);
        bindingContext.Result = ModelBindingResult.Success(data);

        return Task.CompletedTask;
    }

    var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

    if (model is null)
    {
        throw new Exception("Невозможно сериализовать запрос");
    }

    var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
    var dataProperty = modelType.GetProperties()
                            .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

    if (dataProperty != null)
    {
        ForceSetValue(dataProperty, model, moreData);
    }

    bindingContext.Result = ModelBindingResult.Success(model);
    return Task.CompletedTask;
}

Осталось поправить BinderProvider, чтобы он реагировал на любые классы с нужным свойством:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
       var modelType = context.Metadata.UnderlyingOrModelType;
       if (HasDataProperty(modelType))
       {
           return new BinderTypeModelBinder(typeof(PrivateDataBinder<MoreData>));
       }
       return null;
    }
    private bool HasDataProperty(IReflect modelType)
    {
        var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
        var properties = modelType.GetProperties(propFlags);

        return properties.Select(p => p.Name) .Contains(nameof(MoreData));
    }
}


Вот собственно и все. Binder получился несколько сложнее чем в Easy Mode, зато теперь мы можем привязывать «внешние» свойства во всех методах всех контроллеров без дополнительных усилий. Из минусов:

  1. Нужно у конструктора объектов с приватными полями обязательно указывать атрибут [JsonConstrustor]. Но это вполне ложится в логику модели и никак не мешает ее восприятию.
  2. Где-то вам может потребоваться получить MoreData не из заголовка. Но это лечится созданием отдельного класса.
  3. Остальные члены команды должны быть в курсе наличия магии. Но документация спасет человечество.

Полный листинг получившегося Binder здесь:

PrivateMoreDataBinder.cs
public class PrivateDataBinder<T> : IModelBinder
    {
        /// <summary></summary>
        /// <param name="bindingContext">Модель</param>
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
            if (moreData == null)
            {
                return Task.CompletedTask;
            }

            var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
            if (NeedActivator(modelType))
            {
                var data = Activator.CreateInstance(modelType, moreData);
                bindingContext.Result = ModelBindingResult.Success(data);

                return Task.CompletedTask;
            }

            var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

            if (model is null)
            {
                throw new Exception("Невозможно сериализовать запрос");
            }

            var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
            var dataProperty = modelType.GetProperties()
                                        .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

            if (dataProperty != null)
            {
                ForceSetValue(dataProperty, model, moreData);
            }

            bindingContext.Result = ModelBindingResult.Success(model);

            return Task.CompletedTask;
        }

        private static object? GetModelFromQuery(ModelBindingContext bindingContext,
                                                 Type modelType)
        {
            var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);

            var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

            var jsonStr = JsonConvert.SerializeObject(jsonDictionary)
                                     .Replace("\"[", "[")
                                     .Replace("]\"", "]");

            var data = JsonConvert.DeserializeObject(jsonStr, modelType);

            return data;
        }

        private static object? GetModelFromBody(ModelBindingContext bindingContext,
                                                Type modelType)
        {
            using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
            var jsonString = reader.ReadToEnd();
            var data = JsonConvert.DeserializeObject(jsonString, modelType);

            return data;
        }

        private static bool NeedActivator(IReflect modelType)
        {
            var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
            var properties = modelType.GetProperties(propFlags);

            return properties.Select(p => p.Name).Distinct().Count() == 1;
        }

        private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
        {
            var propName = $"<{propertyInfo.Name}>k__BackingField";
            var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
            obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
        }

        private T GetFromHeaderAndDecode(IHeaderDictionary headers) { return (T)Activator.CreateInstance(typeof(T), new object[] { "ok" }); }
    }