深入ASP.NET MVC 之八:Model Template与Html.Editor的实现

这部分的内容和前面的MVC页面的生命周期关系不是太紧密,但在开发中也是十分重要的部分,它可以帮助方便生成合适的html,包括自动填充model的值到表单中,这可以使得通过表单提交的数据在提交页面之后不会丢失,这在asp.net web form中是通过viewstate来实现的,asp.net mvc采用了完全不同的方式,个人认为mvc的方式更加好一些。本文将以Html.Editor,EditorFor为例分析其实现。ASP.NET MVC的Editor,Text,Display等这一系列的helper方法的扩展性都是非常好的,支持自定义显示的template,但是它也有默认的实现。 Editor和EditorFor是很类似的,只是一个接受字符串作为参数,一个用强类型的Lambda表达式。先看Editor:

public static MvcHtmlString Editor(this HtmlHelper html, string expression) {
            return TemplateHelpers.Template(html, expression, null /* templateName */, null /* htmlFieldName */, DataBoundControlMode.Edit, null /* additionalViewData */);
        }

类似Editor这一类的helper最终都是靠TemplateHelper这个类的方法来完成的,Editor方法只是把最基本的参数传给合适的方法,跳过一个TemplateHelper的简单重载的方法,直接到最核心的地方:

internal static string Template(HtmlHelper html, string expression, string templateName, string htmlFieldName,
                                        DataBoundControlMode mode, object additionalViewData, TemplateHelperDelegate templateHelper) {
            return templateHelper(html,
                                  ModelMetadata.FromStringExpression(expression, html.ViewData),
                                  htmlFieldName ?? ExpressionHelper.GetExpressionText(expression),
                                  templateName,
                                  mode,
                                  additionalViewData);
        }

这里调用了两个重要的方法,一个是获得ModelMeta的,Editor方法会根据Metadata显示合适的label等:

public static ModelMetadata FromStringExpression(string expression, ViewDataDictionary viewData) {
if (expression.Length == 0) {    // Empty string really means "model metadata for the current model"
                return FromModel(viewData);
            }
            ViewDataInfo vdi = viewData.GetViewDataInfo(expression);
            Type containerType = null;
            Type modelType = null;
            Func<object> modelAccessor = null;
            string propertyName = null;
            if (vdi != null) {
                if (vdi.Container != null) {
                    containerType = vdi.Container.GetType();
                }
                modelAccessor = () => vdi.Value;
                if (vdi.PropertyDescriptor != null) {
                    propertyName = vdi.PropertyDescriptor.Name;
                    modelType = vdi.PropertyDescriptor.PropertyType;
                }
                else if (vdi.Value != null) {  // We only need to delay accessing properties (for LINQ to SQL)
                    modelType = vdi.Value.GetType();
                }
            }
            //  Try getting a property from ModelMetadata if we couldn't find an answer in ViewData
            else if (viewData.ModelMetadata != null) {
                ModelMetadata propertyMetadata = viewData.ModelMetadata.Properties.Where(p => p.PropertyName == expression).FirstOrDefault();
                if (propertyMetadata != null) {
                    return propertyMetadata;
                }
            }
            return GetMetadataFromProvider(modelAccessor, modelType ?? typeof(string), propertyName, containerType);
        }

首先调用ViewDataDictionary的GetViewDataInfo方法来获得viewData中expression所代表的值。在这个方法内部,调用了一个帮助类ViewDataEvaluator的方法:

public static ViewDataInfo Eval(ViewDataDictionary vdd, string expression) {
                //Given an expression "foo.bar.baz" we look up the following (pseudocode):
                //  this["foo.bar.baz.quux"]
                //  this["foo.bar.baz"]["quux"]
                //  this["foo.bar"]["baz.quux]
                //  this["foo.bar"]["baz"]["quux"]
                //  this["foo"]["bar.baz.quux"]
                //  this["foo"]["bar.baz"]["quux"]
                //  this["foo"]["bar"]["baz.quux"]
                //  this["foo"]["bar"]["baz"]["quux"]

                ViewDataInfo evaluated = EvalComplexExpression(vdd, expression);
                return evaluated;
            }

这里的注释说明了一个expression是如何被解析的。

private static ViewDataInfo EvalComplexExpression(object indexableObject, string expression) {
                foreach (ExpressionPair expressionPair in GetRightToLeftExpressions(expression)) {
                    string subExpression = expressionPair.Left;
                    string postExpression = expressionPair.Right;
                    ViewDataInfo subTargetInfo = GetPropertyValue(indexableObject, subExpression);
                    if (subTargetInfo != null) {
                        if (String.IsNullOrEmpty(postExpression)) {
                            return subTargetInfo;
                        }
                        if (subTargetInfo.Value != null) {
                            ViewDataInfo potential = EvalComplexExpression(subTargetInfo.Value, postExpression);
                            if (potential != null) {
                                return potential;
                            }
                        }
                    }
                }
                return null;
            }

GetRightToLeftExpressions会将一个expression拆成左右两对,例如 foo.bar.baz会拆成

foo.bar.baz “”

foo.bar         baz

foo.              bar.baz

首先利用GetProperty方法获得左侧expression的值,如果右侧expression不为空串,则在右侧取得的值的基础上对右侧的expression再重复这个过程,就是:

ViewDataInfo potential = EvalComplexExpression(subTargetInfo.Value, postExpression);

这样最终实现的效果就是上文注释所描述的。

下面看下针对一个expression,它是如何获得他的值的,

private static ViewDataInfo GetPropertyValue(object container, string propertyName) {
                // This method handles one "segment" of a complex property expression

                // First, we try to evaluate the property based on its indexer
                ViewDataInfo value = GetIndexedPropertyValue(container, propertyName);
                if (value != null) {
                    return value;
                }

                // If the indexer didn't return anything useful, continue...

                // If the container is a ViewDataDictionary then treat its Model property
                // as the container instead of the ViewDataDictionary itself.
                ViewDataDictionary vdd = container as ViewDataDictionary;
                if (vdd != null) {
                    container = vdd.Model;
                }

                // If the container is null, we're out of options
                if (container == null) {
                    return null;
                }

                // Second, we try to use PropertyDescriptors and treat the expression as a property name
                PropertyDescriptor descriptor = TypeDescriptor.GetProperties(container).Find(propertyName, true);
                if (descriptor == null) {
                    return null;
                }

                return new ViewDataInfo(() => descriptor.GetValue(container)) {
                    Container = container,
                    PropertyDescriptor = descriptor
                };
            }

这里的注释写的很清楚,首先这个container可能就是一个IDictionary对象,那么就把这个expression来做为key来取得它的值,或者它是一个对象,那么就把这个expression当作它的一个property name去取值。这两种情况放在GetIndexedProperty方法里面实现了,这个方法思路很简单,实际上还是比较繁琐的,用到了很多反射的技巧,这里不展开。如果通过这两种方案没有获得到值,那么很有可能这个container是一个ViewDataDictionary,真正的值在它的Model属性中,因此将他的Model取出来,expression作为property name,获得它的值。

综上分析,Editor方法在获取值的时候有两个来源,一是ViewData中的数据,二是ViewData的Model中的数据,而且前者是优先的。例如有如下的action方法:

public ActionResult Index2()
        {
            var p = new Person { Name = "abc", Add = new Address { City = "city", Street = "st" } };
            ViewBag.Name = "view Bag";
            return View(p);
        }

在View中,有如下方法:

@Html.Editor("Name");

那么页面上显示的是什么?没错,是view Bag。

image

获得值之后,还需要获得ModelMetaData,这是在最后return的时候调用了GetMetadataFromProvider方法:

private static ModelMetadata GetMetadataFromProvider(Func<object> modelAccessor, Type modelType, string propertyName, Type containerType) {
            if (containerType != null && !String.IsNullOrEmpty(propertyName)) {
                return ModelMetadataProviders.Current.GetMetadataForProperty(modelAccessor, containerType, propertyName);
            }
            return ModelMetadataProviders.Current.GetMetadataForType(modelAccessor, modelType);
        }

这里先补充下ModelMetaData的基本知识,有一篇博客很好的介绍了ModelMetaData类中的各个属性的含义。ModelMetaData被View,具体的说,是各个html helper方法用来控制生成的html。这些信息是通过ModelMetadataProvider对象来获得的,asp.net mvc默认的ModelMetadataProvider是DataAnnotationsModelMetadataProvider,这个provider是通过读取model类上的attribute来获得信息的。DataAnnotationsModelMetadataProvider的实现本文不多做分析,以后另文介绍。

回到最早的Template方法中,第三个参数是,htmlFieldName ?? ExpressionHelper.GetExpressionText(expression), 不是很明白这个参数的含义,通常情况下htmlFieldName都是null,暂时不管它,后一个方法可以获得表单name属性的值。看这个Template方法,经过几个重载以后(这里的方法重载很多,有点绕),真正执行的是(有删节):

internal static string TemplateHelper(HtmlHelper html, ModelMetadata metadata, string htmlFieldName, string templateName, DataBoundControlMode mode, object additionalViewData, ExecuteTemplateDelegate executeTemplate) {
            // TODO: Convert Editor into Display if model.IsReadOnly is true? Need to be careful about this because
            // the Model property on the ViewPage/ViewUserControl is get-only, so the type descriptor automatically
            // decorates it with a [ReadOnly] attribute...
            if (metadata.ConvertEmptyStringToNull && String.Empty.Equals(metadata.Model)) {
                metadata.Model = null;
            }

            object formattedModelValue = metadata.Model;
            if (metadata.Model == null && mode == DataBoundControlMode.ReadOnly) {
                formattedModelValue = metadata.NullDisplayText;
            }
ViewDataDictionary viewData = new ViewDataDictionary(html.ViewDataContainer.ViewData) {
                Model = metadata.Model,
                ModelMetadata = metadata,
                TemplateInfo = new TemplateInfo {
                    FormattedModelValue = formattedModelValue,
                    HtmlFieldPrefix = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(htmlFieldName),
                    VisitedObjects = new HashSet<object>(html.ViewContext.ViewData.TemplateInfo.VisitedObjects),    // DDB #224750
                }
            };

            if (additionalViewData != null) {
                foreach (KeyValuePair<string, object> kvp in new RouteValueDictionary(additionalViewData)) {
                    viewData[kvp.Key] = kvp.Value;
                }
            }

            viewData.TemplateInfo.VisitedObjects.Add(visitedObjectsKey);    // DDB #224750

            return executeTemplate(html, viewData, templateName, mode, GetViewNames, GetDefaultActions);
        }

 

这个方法主要是做一些准备工作,将数据准备好,传给executeTemplate委托去执行,这个委托其实就是在同一个类中定义的一个方法,这里的代码大量使用了各种委托,看起来比较费劲,这个方法中的GetViewName,GetDefaultActions也都是委托。

下面是核心方法,此时各种数据都已经准备好,将生成html:

internal static string ExecuteTemplate(HtmlHelper html, ViewDataDictionary viewData, string templateName, DataBoundControlMode mode, GetViewNamesDelegate getViewNames, GetDefaultActionsDelegate getDefaultActions) {
            Dictionary<string, ActionCacheItem> actionCache = GetActionCache(html);
            Dictionary<string, Func<HtmlHelper, string>> defaultActions = getDefaultActions(mode);
            string modeViewPath = modeViewPaths[mode];

            foreach (string viewName in getViewNames(viewData.ModelMetadata, templateName, viewData.ModelMetadata.TemplateHint, viewData.ModelMetadata.DataTypeName)) {
                string fullViewName = modeViewPath + "/" + viewName;
                ActionCacheItem cacheItem;

                if (actionCache.TryGetValue(fullViewName, out cacheItem)) {
                    if (cacheItem != null) {
                        return cacheItem.Execute(html, viewData);
                    }
                }
                else {
                    ViewEngineResult viewEngineResult = ViewEngines.Engines.FindPartialView(html.ViewContext, fullViewName);
                    if (viewEngineResult.View != null) {
                        actionCache[fullViewName] = new ActionCacheViewItem { ViewName = fullViewName };

                        using (StringWriter writer = new StringWriter(CultureInfo.InvariantCulture)) {
                            viewEngineResult.View.Render(new ViewContext(html.ViewContext, viewEngineResult.View, viewData, html.ViewContext.TempData, writer), writer);
return writer.ToString();
                        }
                    }

                    Func<HtmlHelper, string> defaultAction;
                    if (defaultActions.TryGetValue(viewName, out defaultAction)) {
                        actionCache[fullViewName] = new ActionCacheCodeItem { Action = defaultAction };
                        return defaultAction(MakeHtmlHelper(html, viewData));
                    }

                    actionCache[fullViewName] = null;
                }
            }

            throw new InvalidOperationException(
                String.Format(
                    CultureInfo.CurrentCulture,
                    MvcResources.TemplateHelpers_NoTemplate,
                    viewData.ModelMetadata.RealModelType.FullName
                )
            );
        }

先忽略缓存的部分,它首先去找viewName,可以认为在调用Editor,或者EditorFor显示的这些html,其实也是一个Partial View,它和View初始化的时候一样,有一套寻找view文件的规则。看其查找View的代码:

internal static IEnumerable<string> GetViewNames(ModelMetadata metadata, params string[] templateHints) {
            foreach (string templateHint in templateHints.Where(s => !String.IsNullOrEmpty(s))) {
                yield return templateHint;
            }

            // We don't want to search for Nullable<T>, we want to search for T (which should handle both T and Nullable<T>)
            Type fieldType = Nullable.GetUnderlyingType(metadata.RealModelType) ?? metadata.RealModelType;

            // TODO: Make better string names for generic types
            yield return fieldType.Name;

            if (!metadata.IsComplexType) {
                yield return "String";
            }
            else if (fieldType.IsInterface) {
                if (typeof(IEnumerable).IsAssignableFrom(fieldType)) {
                    yield return "Collection";
                }

                yield return "Object";
            }
            else {
                bool isEnumerable = typeof(IEnumerable).IsAssignableFrom(fieldType);

                while (true) {
                    fieldType = fieldType.BaseType;
                    if (fieldType == null)
                        break;

                    if (isEnumerable && fieldType == typeof(Object)) {
                        yield return "Collection";
                    }

                    yield return fieldType.Name;
                }
            }
        }

从上面代码可以看到, ASP.NET MVC按照以下顺序去找template 的 viewName

1. 通过直接指定文件名.

2.Model的类型名

3. 默认的类型,比如string,object和collection。

得到了ViewName之后,通过

string fullViewName = modeViewPath + "/" + viewName;

获得完整的ViewName,看下这里的modeViewPath:

static readonly Dictionary<DataBoundControlMode, string> modeViewPaths =
            new Dictionary<DataBoundControlMode, string> {
                { DataBoundControlMode.ReadOnly, "DisplayTemplates" },
                { DataBoundControlMode.Edit,     "EditorTemplates" }
            };

 

得到完整的ViewName之后,如果对应view文件存在,就会调用ViewEngine的FindPartialView方法,这种情况下这个editor方法就相当于是一个partial view。举例说明下,根据前文的分析,如果要让asp.net找到自定义的模版,需要在类似这样的地方放置partial view文件,其他符合规则的地方也可以:

image

这个文件的内容如下:

<p>Hello @ViewData.ModelMetadata.SimpleDisplayText</p>

页面view文件:

@Html.Editor("Add","MyTemplate");

 

输出的结果自然是:

Hello city

自定义模版还有其他方法,过会儿再说,先看另一种情况,就是这个对应的view文件不存在,也就是默认的情形下,它是从defaultAction中找到合适的action,然后执行。defaultAction是这样定义的,这是Editor的,Display也有相似的一系列:

static readonly Dictionary<string, Func<HtmlHelper, string>> defaultEditorActions =
            new Dictionary<string, Func<HtmlHelper, string>>(StringComparer.OrdinalIgnoreCase) {
                { "HiddenInput",        DefaultEditorTemplates.HiddenInputTemplate },
                { "MultilineText",      DefaultEditorTemplates.MultilineTextTemplate },
                { "Password",           DefaultEditorTemplates.PasswordTemplate },
                { "Text",               DefaultEditorTemplates.StringTemplate },
                { "Collection",         DefaultEditorTemplates.CollectionTemplate },
                { typeof(bool).Name,    DefaultEditorTemplates.BooleanTemplate },
                { typeof(decimal).Name, DefaultEditorTemplates.DecimalTemplate },
                { typeof(string).Name,  DefaultEditorTemplates.StringTemplate },
                { typeof(object).Name,  DefaultEditorTemplates.ObjectTemplate },
            };

这里简单看下最简单的StringTemplate,其他的就不展开了,并不是很难,但是细节很多:

internal static string StringTemplate(HtmlHelper html) {
            return html.TextBox(String.Empty,
                                html.ViewContext.ViewData.TemplateInfo.FormattedModelValue,
                                CreateHtmlAttributes("text-box single-line")).ToHtmlString();
        }

这个TextBox实际上是调用了一个InputExtension类中的InputHelper方法生成了一个input,这里不再赘述。

下面再看下EditorFor,EditorFor和Editor的主要区别就是接受的表达式不一样。前者接受lambda表达式。因此,这两个方法的实现的不同也仅在很少的地方,前者在获得ModelMetadata的时候调用的是ModelMetaData.FromLambdaExpression方法:

public static ModelMetadata FromLambdaExpression<TParameter, TValue>(Expression<Func<TParameter, TValue>> expression,
                                                                             ViewDataDictionary<TParameter> viewData) {
            if (expression == null) {
                throw new ArgumentNullException("expression");
            }
            if (viewData == null) {
                throw new ArgumentNullException("viewData");
            }

            string propertyName = null;
            Type containerType = null;
            bool legalExpression = false;

            // Need to verify the expression is valid; it needs to at least end in something
            // that we can convert to a meaningful string for model binding purposes

            switch (expression.Body.NodeType) {
                // ArrayIndex always means a single-dimensional indexer; multi-dimensional indexer is a method call to Get()
                case ExpressionType.ArrayIndex:
                    legalExpression = true;
                    break;

                // Only legal method call is a single argument indexer/DefaultMember call
                case ExpressionType.Call:
                    legalExpression = ExpressionHelper.IsSingleArgumentIndexer(expression.Body);
                    break;

                // Property/field access is always legal
                case ExpressionType.MemberAccess:
                    MemberExpression memberExpression = (MemberExpression)expression.Body;
                    propertyName = memberExpression.Member is PropertyInfo ? memberExpression.Member.Name : null;
                    containerType = memberExpression.Expression.Type;
                    legalExpression = true;
                    break;

                // Parameter expression means "model => model", so we delegate to FromModel
                case ExpressionType.Parameter:
                    return FromModel(viewData);
            }

            if (!legalExpression) {
                throw new InvalidOperationException(MvcResources.TemplateHelpers_TemplateLimitations);
            }

            TParameter container = viewData.Model;
            Func<object> modelAccessor = () => {
                try {
                    return CachedExpressionCompiler.Process(expression)(container);
                }
                catch (NullReferenceException) {
                    return null;
                }
            };

            return GetMetadataFromProvider(modelAccessor, typeof(TValue), propertyName, containerType);
        }

这里的注释也写的比较清楚,主要是获得modelAccessor,也就是将lambda表达式转换成一个delegate的过程,这里出于效率的考虑,用了一个CachedExpressionCompiler 这个类来专门处理,这里用到的技术太技术性了,不在此分析,有时间再深入研究下。如果不考虑性能,这里其实并不复杂,最两段代码等效于:

            modelAccessor = ()=>expression.Compile()(container);
            return GetMetadataFromProvider(modelAccessor, typeof(TValue), propertyName, containerType);

 

最后,结合上面的分析,看下自定义template的一些方法。出于某些原因,我们可能需要重新生成所有asp.net mvc默认的html代码,例如,如果我们采用bootstrap作为我的前端样式,需要在生成的html中加上bootstrap规定的class,我们可以覆盖string的默认模版。如果DefaultTemplates可以重写那就最好了,不过至少目前他不是public的,此路不通,但是注意到GetViewNames的时候,它首先会返回类型名String,因此,我们可以创建一个名字为String的partial view,放在合适的路径下,就可以覆盖其默认的模版了。例如,在如下路径建一个String.cshtml

image

内容如下:

@{var display = ViewData.ModelMetadata.DisplayName ?? ViewData.ModelMetadata.PropertyName;}
<div class="control-group">
    <label class="control-label">@display</label>
    <div class="controls">
        <input type="text"   value="@ViewData.ModelMetadata.SimpleDisplayText" >
    </div>
</div>

那么,view中的

<form class="form-horizontal">
    @Html.EditorFor(p => p.Name)
</form>

就会显示成bootstrap默认的html样式。当然大多数情况下,直接用html标签来覆盖基础类型String,Object并不是一个好的选择,因为它默认的实现比较复杂和全面,自己的实现往往会失去一些默认的功能,这里仅是举例。在mvc自带的helper方法的基础上,重新实现类似Editor之类的方法是更好的选择。

下面再举一个例子,利用自定义的template实现对Enum的显示。在Shared->EditorTemplates新建一个Enum.cshtml的partial view, 内容如下:

@model Enum
@using System.Linq;

@Html.DropDownListFor(m=>m,Enum.GetValues(Model.GetType()).Cast<Enum>()
                               .Select(m=>{
                                   string val=Enum.GetName(Model.GetType(),m);
                                   return new SelectListItem(){ 
                                       Selected=(Model.ToString()==val),
                                       Text=val,
                                       Value=val
                               };})
                                   )             

假如有如下Enum:

public enum AddressType
    {
        BUSSINESS,
        HOME
    }
    
    public class Address
    {
        [Required]
        [Display(Name="City Name")]
        public string City { get; set; }
        public string Street { get; set; }
        [UIHint("Enum")]
        public AddressType AddressType
        {
            get;
            set;
        }
    }

 

在model上通过UIHint指定所要使用的template,那么@Html.EditorFor(p => p.Add.AddressType)生成的html效果就是如下:

image

posted @ 2012-12-18 07:34  yinzixin  阅读(4800)  评论(6编辑  收藏  举报