Вот только данные, нужные для работы 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" }); } }
ссылка на оригинал статьи https://habr.com/ru/post/492820/
Добавить комментарий