深入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。
获得值之后,还需要获得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文件,其他符合规则的地方也可以:
这个文件的内容如下:
<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
内容如下:
@{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效果就是如下: