Раньше в веб-проектах на asp.net mvc у меня часто встречался повторяющийся код типа:
public ActionResult UserProfile(int id) { var user = Session.Load<User>(id); // do something... }
Действительно, этот код очень типичен, обычно все объекты адресуются по ID и экшены обычно оперируют с этими объектами. Поэтому аргументами экшенов часто становятся идентификаторы, и где-то в начале экшенов мы запрашиваем из БД собственно объекты.
Задуматься над этим кодом меня заставил недавний пост Айенды. Немного подумав, стало ясно, что подобные участки очень просто автоматизировать и превратить во что-то вроде:
public ActionResult UserProfile(User user) { // do something... }
Дополнительным плюсом такой конструкции будет большая читаемость (очевидно, что метод работает именно с пользователем, а не с абстрактным int id) и строго-типизированность при вызове из Url.Action, RenderAction и тестов.
С технической стороны реализация также не представляется сложной: asp.net mvc прекрасно расширяется своими ModelBinder’ами, при этом URL запроса к этому экшену не изменится, оставшись похожим на нечто вроде: /Home/UserProfile?user=12. Как видим, мы по прежнему передаем ID в запросах, а в ModelBinder’е будем лишь загружать из базы требуемые сущности.
Упрощенный пример реализации ModelBinder’a:
public abstract class DomainEntitiesModelBinder : DefaultModelBinder { protected internal abstract object GetObject(Type type, object id); public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) { var modelName = bindingContext.ModelName; if (string.IsNullOrEmpty(modelName)) { //this part will be run on TryUpdateModel() return base.BindModel(controllerContext, bindingContext); } else { var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); var valueType = bindingContext.ModelType; if (valueResult != null) { var id = valueResult.ConvertTo(typeof(int)) as int?; if (id != null && id != 0) { return GetObject(valueType, id); } } return null; } } }
Пример упрощен для простоты понимания, полный текст базового байндера — в конце поста.
Для его использования необходимо отнаследоваться от приведенного базового байндера, например, таким образом (в случае NHibernate):
public class DomainEntitiesNHModelBinder : DomainEntitiesModelBinder { protected override object GetObject(Type type, object id) { return DependencyResolver.Current.GetService<ISession>().Load(type, id); } }
таким образом id типа int будет прочитан из запроса базовым байндером и передан в функцию GetObject наследника, который и загрузит сущность из БД.
Регистрация байндера в MVC будет выглядеть так (где-нибудь в Application_Start Global.asax’a:
ModelBinderProviders.BinderProviders.Add( new InheritanceAwareModelBinderProvider { { typeof (BaseEntity), new DomainEntitiesNHModelBinder() } });
И, наконец, код небольшого класса, который позволяет назначить байндер всем классам-наследникам базового (в моей практике доменные классы часто наследуются от базового — BaseEntity в примере выше)
/// <summary> /// Adds inheritance support when registering model binders. /// Any model binders added here will be invoked if the Type being bound inherits from the type registered. /// </summary> public class InheritanceAwareModelBinderProvider : Dictionary<Type, IModelBinder>, IModelBinderProvider { public IModelBinder GetBinder(Type modelType) { var binders = from binder in this where binder.Key.IsAssignableFrom(modelType) select binder.Value; return binders.FirstOrDefault(); } }
Использование подобного байндера особенно удобно в паре с опцией batch-size НХибернейта, поскольку в простых случаях позволяет получать значения связанных таблиц путем ленивой загрузки.
Конечно, данный способ может иметь и негативное влияние на производительность, если, в частности, запросы на сумму-количество и прочие аггрегатные функции будут производиться через linq по данному объекту, а не запросом к БД. Будьте осторожны и не забывайте думать при использовании любых решений :)
P.S. А вот и полный текст реализации реального model-binder’a. Он достаточно сложен и разрастался по мере столкновения с различными ситуациями, связанными с его использованием (использование из UrlAction/RenderAction/обычных запросов/TryUpdateModel/etc):
public abstract class DomainEntitiesModelBinder : DefaultModelBinder { protected internal abstract object GetObject(Type type, object id); [ThreadStatic] private static bool UseDefaultBinder; public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) { var modelName = bindingContext.ModelName; if (string.IsNullOrEmpty(modelName)) { //this part will be run on TryUpdateModel() try { UseDefaultBinder = true; var defaultResult = base.BindModel(controllerContext, bindingContext); return defaultResult; } finally { UseDefaultBinder = false; } } else { var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); var valueType = bindingContext.ModelType; if (valueResult != null) { if (valueType.IsInstanceOfType(valueResult.RawValue)) { return valueResult.RawValue; } var id = valueResult.ConvertTo(typeof(int)) as int?; if (id != null && id != 0) { var result = GetObject(valueType, id); if (UseDefaultBinder) { ModelBindingContext newBindingContext = CreateComplexElementalModelBindingContext(controllerContext, bindingContext, result); // validation if (OnModelUpdating(controllerContext, newBindingContext)) { BindProperties(controllerContext, newBindingContext); OnModelUpdated(controllerContext, newBindingContext); } } return result; } } else if (UseDefaultBinder) { return base.BindModel(controllerContext, bindingContext); } return null; } } internal ModelBindingContext CreateComplexElementalModelBindingContext(ControllerContext controllerContext, ModelBindingContext bindingContext, object model) { BindAttribute bindAttr = (BindAttribute)GetTypeDescriptor(controllerContext, bindingContext).GetAttributes()[typeof(BindAttribute)]; Predicate<string> newPropertyFilter = (bindAttr != null) ? propertyName => bindAttr.IsPropertyAllowed(propertyName) && bindingContext.PropertyFilter(propertyName) : bindingContext.PropertyFilter; ModelBindingContext newBindingContext = new ModelBindingContext() { ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => model, bindingContext.ModelType), ModelName = bindingContext.ModelName, ModelState = bindingContext.ModelState, PropertyFilter = newPropertyFilter, ValueProvider = bindingContext.ValueProvider }; return newBindingContext; } private void BindProperties(ControllerContext controllerContext, ModelBindingContext bindingContext) { IEnumerable<PropertyDescriptor> properties = GetFilteredModelProperties(controllerContext, bindingContext); foreach (PropertyDescriptor property in properties) { BindProperty(controllerContext, bindingContext, property); } } }
P.P.S. К сожалению, использование данного подхода в паре с T4MVC ведет к некоторым проблемам. Мы попробуем их решить в ближайшее время в рамках T4MVC
Молодец, что написал Дэвиду об этом. И, наверное, молодец, что написал не на SO. SO не располагает к подобным дискуссиям (что и подтвердил мой вопрос на аналогичную тему).
Ты так и не написал Дэвиду по поводу интеграции твоей дваваскриптовской фигни в T4MVC?
Пост интересный, я бы на твоем месте перепостил его на Хабр.
разбодаюсь с ModelUnbinder в t4mvc, поговорим о яваскрипте :)
для хабра слишком специфично, имхо.. на gotdotnet наверн стоит, спасибо :)
А что делать, если нужно задать ссылку на такой action через T4MVC (предположим, что фундаментальное ограничение modeldebinding’а я обошел)? То есть я просто хочу сгенерить некоторый ActionResult, и для этого мне достаточно id, но в случае же применения вышеописанного способа мне придется считать весь объект.
Собственно, одна из целей этого подхода — чтобы необходимость оперировать айдишниками не возникала, чтобы все операции проходили с доменными объектами.(id) — при этом фактического считывания объекта из БД не произойдет.
То есть по идее — ситуации «есть id, а объекта нет» возникать не должно.
Если же она всё же возникла, то при работе с NH я просто сделаю Session.Load
При работе с нереляционными БД, где ситуация «есть id, объекта нет» — типична, использование данного подхода, по большому счету, лишено смысла, так как изначальная идея (все операции — только с доменными объектами) противоречит концепции nosql.
Для устранения дублирования использовать, конечно, можно, но придется или допиливать Т4МВЦ, чтобы он генерил соответствующие методы, принимающие айдишник, а не доменный объект, или использовать что-то наподобие из моего соседнего поста.
>>Собственно, одна из целей этого подхода — чтобы необходимость оперировать айдишниками не возникала, чтобы все операции проходили с доменными объектами.
Разве? А по-моему цель этого подхода — иметь возможность оперировать айдишником или объектом по необходимости. Добавил этот момент в вашу дискуссию с Дэвидом.
Цель этого подхода — оперировать доменными объектами :)
Суть изменений, которые я планирую внести в Т4Мвц — возможность использования ModelUnbinder’ов, по аналогии с ModelBinder’ами.
ModelBinder преобразует значение из запроса в объект.
ModelUnbinder будет преобразовывать объект в значение запроса.
Трюк с айдишниками вместо доменных объектов частью ModelUnbinder’a явно не является :)