Используем инфраструктуру MVC3 — скажи НЕТ повторяющимся session.Load(id);

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

Опубликовать в Facebook
Опубликовать в Google Plus

6 комментариев

  1. Молодец, что написал Дэвиду об этом. И, наверное, молодец, что написал не на SO. SO не располагает к подобным дискуссиям (что и подтвердил мой вопрос на аналогичную тему).

    Ты так и не написал Дэвиду по поводу интеграции твоей дваваскриптовской фигни в T4MVC?

    Пост интересный, я бы на твоем месте перепостил его на Хабр.

  2. разбодаюсь с ModelUnbinder в t4mvc, поговорим о яваскрипте :)
    для хабра слишком специфично, имхо.. на gotdotnet наверн стоит, спасибо :)

  3. А что делать, если нужно задать ссылку на такой action через T4MVC (предположим, что фундаментальное ограничение modeldebinding’а я обошел)? То есть я просто хочу сгенерить некоторый ActionResult, и для этого мне достаточно id, но в случае же применения вышеописанного способа мне придется считать весь объект.

  4. Собственно, одна из целей этого подхода — чтобы необходимость оперировать айдишниками не возникала, чтобы все операции проходили с доменными объектами.
    То есть по идее — ситуации «есть id, а объекта нет» возникать не должно.
    Если же она всё же возникла, то при работе с NH я просто сделаю Session.Load(id) — при этом фактического считывания объекта из БД не произойдет.

    При работе с нереляционными БД, где ситуация «есть id, объекта нет» — типична, использование данного подхода, по большому счету, лишено смысла, так как изначальная идея (все операции — только с доменными объектами) противоречит концепции nosql.
    Для устранения дублирования использовать, конечно, можно, но придется или допиливать Т4МВЦ, чтобы он генерил соответствующие методы, принимающие айдишник, а не доменный объект, или использовать что-то наподобие из моего соседнего поста.

  5. >>Собственно, одна из целей этого подхода — чтобы необходимость оперировать айдишниками не возникала, чтобы все операции проходили с доменными объектами.

    Разве? А по-моему цель этого подхода — иметь возможность оперировать айдишником или объектом по необходимости. Добавил этот момент в вашу дискуссию с Дэвидом.

  6. Цель этого подхода — оперировать доменными объектами :)

    Суть изменений, которые я планирую внести в Т4Мвц — возможность использования ModelUnbinder’ов, по аналогии с ModelBinder’ами.
    ModelBinder преобразует значение из запроса в объект.
    ModelUnbinder будет преобразовывать объект в значение запроса.
    Трюк с айдишниками вместо доменных объектов частью ModelUnbinder’a явно не является :)

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

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