.Net Core Api: получение данных в запросе из разных источников

от автора

В .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" }); }     } 

ссылка на оригинал статьи https://habr.com/ru/post/492820/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *