В .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);
}
В итоге мы получаем следующие проблемы:
- Логика валидации размазана по объекту запроса, методу запроса из заголовка, методу запроса из сервиса и методу контроллера. Чтобы убедиться, что нужная проверка точно есть, нужно провести целое расследование!
- В соседнем методе контроллера будет точно такой же код. Копипаст программирование в атаке.
- Обычно проверок значительно больше, чем в примере, и в итоге единственная значимая строчка — вызов метода обработки бизнес-логики — спрятан в куче кода. Увидеть его и понять, что вообще тут происходит, требует определенных усилий.
Свой 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, зато теперь мы можем привязывать «внешние» свойства во всех методах всех контроллеров без дополнительных усилий. Из минусов:
- Нужно у конструктора объектов с приватными полями обязательно указывать атрибут [JsonConstrustor]. Но это вполне ложится в логику модели и никак не мешает ее восприятию.
- Где-то вам может потребоваться получить MoreData не из заголовка. Но это лечится созданием отдельного класса.
- Остальные члены команды должны быть в курсе наличия магии. Но документация спасет человечество.
Полный листинг получившегося Binder здесь:
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" }); }
}
Автор: VolshRus