ASP.NET MVC Tips #1 - 支持上传文件的ModelBinder
自从微软推出了ASP.NET MVC 1.0(此后简称MVC)这个新的网站框架之后,出现了一大批解读MVC的文章。拜读了老赵、AnyTao的一些文章,受益匪浅。本人自然没有这些大牛的实力,也不敢班门弄斧的进行所谓的深度剖析。自己的一个项目目前正在使用MVC,自然会有一些对应的代码和小窍门,于是规整了一下发表出来。一是可以让大家在使用MVC的时候有个捷径,二是自己总结,三是看看大家有什么看法和建议。
今天开篇第一个,不知道要写多少,也不知道能写多少。没有给自己定什么目标。虽然曾经和AnyTao说要多写点Blog混个MVP当当,至少Windows 7出的时候还能有个正版的号(寒自己一个 - -!!!),但是平心而论,自己还真没有到达MVP的境界。
废话说了很多,说说这篇文章吧。ModelBinder大家应该用了很多,特别是在Post Action函数里面绑定复杂的View Model的时候非常好用。微软自带的DefaultBinder几乎可以满足我们所有的要求了,但是在开发的时候发现有个需求,就是有些页面有上传文件的功能,而默认的ModelBinder似乎还不支持,于是就自己做了个ModelBinder。代码非常简单,如下。
public class UploadFilesModelBinder : IModelBinder { public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) { // Default binding the normal properties. var defaultBinder = new DefaultModelBinder(); var model = defaultBinder.BindModel(controllerContext, bindingContext); // Bind the image. var files = controllerContext.HttpContext.Request.Files; foreach (var property in bindingContext.ModelType.GetProperties()) { if (property.PropertyType == typeof(IHttpPostedFile)) { // Get the corresponding property name which type is IHttpPostedFile. var propertyName = property.Name; var file = files.Get(string.Format("{0}.{1}", bindingContext.ModelName, propertyName)); if (file == null) { file = files.Get(propertyName); } // Set the image into the property. if (file != null) { var fileWapper = new RequestPostedFileWrapper(file, controllerContext.HttpContext.Server); property.SetValue(model, fileWapper, null); } } } return model; } }
首先调用系统自己的DefaultModelBinder把普通的属性值绑定进去。然后开始绑定上传的文件(由于项目主要是上传图片,所以注释写的是‘图片’但是可以支持任意文件)。通过反射把当前Model里面的所有类型是IHttpPostedFile(稍后会解释这个接口)的属性取出来,然后通过属性名寻找Request.Files里面有没有对应的文件名(可以直接就是属性名,或者是[Model名].[属性名])。如果找到了,则实例化RequestPostedFileWrapper类然后设定到属性上面。
使用起来还算简单,比如我们现在有一个Product Creation页面,需要用户输入一些Product信息的同时上传一个Product的图片,那么我们的ViewModel可能是这样的。
public class StockProductCreateModel : ModelBase { public KeyValuePair<int, string> Category { get; set; } [Required(ErrorMessage = "Cateogry ID is mandatory.")] public int CateogryID { get; set; } [Required(ErrorMessage = "Name is mandatory.")] public string Name { get; set; } [Required(ErrorMessage = "Description is mandatory.")] public string Description { get; set; } public IHttpPostedFile MainImage { get; set; } }
我们的图片就是定义为Public IHttpPostedFile MainImage {get; set;}这个属性。相对应的只需要在我们的View的Form里面加入一个文件上传元素就可以了。
<% using (Html.BeginForm("ProductCreate", "Stock", FormMethod.Post, new { enctype = "multipart/form-data" })) { %> <%= Html.Hidden("model.CateogryID", Model.Category.Key)%> <p> <%= Html.Label("model.Name", "Product Name:")%> <%= Html.TextBox("model.Name", Model.Name, new { style = "width: 80%;" })%> <%= Html.ValidationMessage("model.Name")%> </p> <p> <%= Html.Label("model.Description", "Description:")%> <%= Html.TextArea("model.Description", Model.Description, new { style = "width: 80%;" }) %> <%= Html.ValidationMessage("model.Description")%> </p> <p> <%= Html.Label("model.MainImage", "Main Image:")%> <input name="model.MainImage" type="file" /> </p> <p> <%= Html.SubmitImage("Submit", "~/Content/submit.png")%> <%= Html.BackImage("/Content/back.png")%> </p> <% } %>
只需要<input type=”file” />这个元素的name属性值和刚才我们定义的Property的名字对应就可以了,比如这里定义为model.MainImage就是首先用ViewModel实例的名字(通过Controller传进来的参数名)然后是属性名;或者可以直接定义为属性名(MainImage)。
最后在Controller里面显示声明我们要用这个UploadFilesModelBinder进行数据绑定就可以把上传的文件绑定到我们的Model里面了。
[AcceptVerbs(HttpVerbs.Post)] public ActionResult ProductCreate([ModelBinder(typeof(UploadFilesModelBinder))] StockProductCreateModel model) { throw new NotImplementException(); }
最后谈到了这个IHttpPostedFile接口以及相对应的RequestPostedFileWrapper实现类。加入这个接口主要是分层的考虑。如果按照简单的三层架构来设计,一般分为表现层(网站所在的层)、业务层(基本上所有的业务逻辑)和数据层(和数据库进行交互),而这三层应该是相互独立的,我的理解就是业务层应该不依赖于表现层和数据层使用什么技术。比如现在我们的例子里面,业务逻辑就是“新建一个Product的时候保存数据的同时可以为他上传一个图片”。业务层就是要实现这个逻辑,但是他不能确定也不应该确定这个图片是怎么来的(通过网页上传进来的)以及怎么保存的(保存在服务器的某个目录下)。所以我们建立了这个接口IHttpPostedFile用来隔离和Web相关的操作来保证业务层的“纯洁”。
public interface IHttpPostedFile { bool IsAvailable { get; } string FileName { get; } void SaveAs(Size normalSize, Size thumbnailSize); }
接口的内容非常简单,IsAvailable表示了这个文件是不是有效的,FileName表示文件名,可以用于保存到数据库的响应字段比如ImagePath,SaveAs方法则负责将文件(图片)保存。这里由于要支持图片的尺寸重定义操作,所以提供了两个参数用来指示普通尺寸和缩略图尺寸。当然如果把这个接口修改为支持任意文件,则可以取消这两个参数。
这样在业务层进行保存操作的时候,只需要对这个接口进行操作就可以了,业务层完全不知道这个接口的实现类是什么样子的,自然也就隔离了实现的方法。比如
// Save the main image if specified. if (model.MainImage.IsAvailable) { // Insert the image record. var image = new ProductImages(); image.Products = product; image.ImagePath = model.MainImage.FileName; image.Description = model.MainImageDescription; image.EnteredDate = DateTime.Now; image.UpdatedDate = DateTime.Now; image.IsDeleted = false; Resolve<IProductImagesRepository>().InsertEntity(image); // Save the image file. model.MainImage.SaveAs(Resolve<ISettingService>().ImageNormalSize, Resolve<ISettingService>().ImageThumbnailSize); // Update the product record set the main image. product.MainImage = image; product.UpdatedDate = DateTime.Now; Resolve<IProductsRepository>().UpdateEntity(product); }
通过model.MainImage.FileName将文件名保存到数据库的ImagePath字段,然后通过model.MainImage.SaveAs保存图片本身。对于这个业务层中的函数,完全不知道这个图片的来源(显示层)和保存的方法(数据层)。
最后为了能让在显示层的文件上传对象传递进业务层,需要一个实现了IHttpPostedFile接口的类,我是通过RequestPostedFileWrapper这个类来实现的。它的内部使用了HttpPostedFileBase这个定义在System.Web下面的类来保存上传的文件对象,同时实现了IHttpPostedFile接口并使用HttpPostedFileBase的保存等操作来实现接口的方法体。具体代码比较长因为我还实现了图片变换尺寸的功能。
public class RequestPostedFileWrapper : IHttpPostedFile { public enum ImageType : int { Original = 0, Normal = 1, Thumbnail = 2 } private HttpPostedFileBase _file; private HttpServerUtilityBase _server; private string _fileName; public bool IsAvailable { get { return _file != null && _file.ContentLength > 0 && !string.IsNullOrEmpty(_file.FileName); } } public string FileName { get { return _fileName; } } public static string GetRelevantPath(ImageType type, string fileName) { return Path.Combine(Settings.ImageRoot, Path.Combine(type.ToString().ToLower(), fileName)); } private string GetServerMappedPath(ImageType type) { return _server.MapPath(GetRelevantPath(type, FileName)); } private Image GetResizedImage(Image originalImage, int width, int height) { Image ret = null; var originalWidth = originalImage.Width; var originalHeight = originalImage.Height; var rateWidth = (double)width / (double)originalWidth; var rateHeight = (double)height / (double)originalHeight; var rate = Math.Min(rateWidth, rateHeight); if (rate >= 1) { // The target size is bigger than the input image's size so no need to resize. ret = originalImage; } else { // The target size is smaller than the input image's size so resize the input image and returned. ret = new Bitmap(originalImage, (int)Math.Ceiling(originalWidth * rate), (int)Math.Ceiling(originalHeight * rate)); } return ret; } public void SaveAs(Size normalSize, Size thumbnailSize) { Image originalImage = Image.FromStream(_file.InputStream); var originalPath = GetServerMappedPath(ImageType.Original); // Save the normal file. var normalPath = GetServerMappedPath(ImageType.Normal); Image normalImage = GetResizedImage(originalImage, normalSize.Width, normalSize.Height); normalImage.Save(normalPath); // Save the thumbnail file. var thumbnailPath = GetServerMappedPath(ImageType.Thumbnail); Image thumbnailImage = GetResizedImage(originalImage, thumbnailSize.Width, thumbnailSize.Height); thumbnailImage.Save(thumbnailPath); } private void Initialize(HttpPostedFileBase file, string fileName, HttpServerUtilityBase server) { _file = file; _fileName = fileName; _server = server; } public RequestPostedFileWrapper(HttpFileCollectionBase files, HttpServerUtilityBase server, string fileName) { if (files != null && files.Count > 0) { Initialize(files[0], fileName, server); } else { Initialize(null, null, null); } } public RequestPostedFileWrapper(HttpPostedFileBase file, HttpServerUtilityBase server) { Initialize(file, Guid.NewGuid().ToString() + System.IO.Path.GetExtension(file.FileName), server); } public RequestPostedFileWrapper(HttpFileCollectionBase files, HttpServerUtilityBase server) { if (files != null && files.Count > 0) { Initialize(files[0], Guid.NewGuid().ToString() + System.IO.Path.GetExtension(files[0].FileName), server); } else { Initialize(null, null, null); } } }
其实这种实现方法也不是我的原创,参考了Scott Gu和Phil Haack等人关于怎么实现ValidationDictionary的方法。他们就是通过一个IValidationDictionary来将本属于表现层的ModelState传递进业务层,但是让业务层脱离对于System.Web.Mvc的依赖。也不知道这是个什么“模式”,但是发现用起来还很方便。
最后照例来点总结吧,虽然都是废话。第一篇写MVC的东西,写着自己都心虚,生怕写出什么贻笑大方的来。几年前刚才博客园,因为在CSDN的VB.NET版混了一个星星出来,还得到了水如烟的鼓励,顿时飘飘然觉得自己是高手了,于是就大肆发帖。但是后来随着接触的人越来越多,发现高人实在是太多了,于是由狂妄自大变成了谨小慎微,什么都不敢发了——就连在牛人的Blog里面回复都要深思熟虑一番。这一次鼓足勇气再次发文,完全是和AnyTao的一次对话让我有了些新的认识。
这篇文章主要说了说我在项目中是怎么使用ModelBinder和一些其他相关的手法解决MVC中上传文件的问题。MVC的好处是扩展点很多,而且很方便。做一个好框架不容易,做一个易于扩展的框架更是困难。MVC让我觉得很成功,同时期待MVC2的到来。