Раньше в веб-проектах на 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 явно не является :)