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 GuPhil Haack等人关于怎么实现ValidationDictionary的方法。他们就是通过一个IValidationDictionary来将本属于表现层的ModelState传递进业务层,但是让业务层脱离对于System.Web.Mvc的依赖。也不知道这是个什么“模式”,但是发现用起来还很方便。

最后照例来点总结吧,虽然都是废话。第一篇写MVC的东西,写着自己都心虚,生怕写出什么贻笑大方的来。几年前刚才博客园,因为在CSDN的VB.NET版混了一个星星出来,还得到了水如烟的鼓励,顿时飘飘然觉得自己是高手了,于是就大肆发帖。但是后来随着接触的人越来越多,发现高人实在是太多了,于是由狂妄自大变成了谨小慎微,什么都不敢发了——就连在牛人的Blog里面回复都要深思熟虑一番。这一次鼓足勇气再次发文,完全是和AnyTao的一次对话让我有了些新的认识。

这篇文章主要说了说我在项目中是怎么使用ModelBinder和一些其他相关的手法解决MVC中上传文件的问题。MVC的好处是扩展点很多,而且很方便。做一个好框架不容易,做一个易于扩展的框架更是困难。MVC让我觉得很成功,同时期待MVC2的到来。

posted @ 2009-09-01 13:45 妖居 阅读(...) 评论(...) 编辑 收藏