ASP.NET MVC with Entity Framework and CSS一书翻译系列文章之第六章:管理产品图片——多对多关系(上篇)
在这章中,我们将学习如何创建一个管理图片的新实体,如何使用HTML表单上传图片文件,并使用多对多关系将它们和产品关联起来,如何将图片存储在文件系统中。在这章中,我们还会学习更加复杂的异常处理,如何向模型添加自定义错误,然后向用户显示错误信息。在本章使用的产品图片可以在Apress站点中的第6章的代码中获得。
注意:如果你想按照本章的代码编写示例,你必须完成第五章或者直接从www.apress.com下载第五章的源代码。
6.1 创建一个用于存储图片名称的实体
对于本项目,我们打算使用文件系统将图片文件保存在Web项目中。数据库存储与一个或多个产品相关的图片文件的名称。我们在Models文件夹下添加一个名为ProductImage的类来对图片存储建模。
1 using System.ComponentModel.DataAnnotations; 2 3 namespace BabyStore.Models 4 { 5 public class ProductImage 6 { 7 public int ID { get; set; } 8 [Display(Name = "File")] 9 public string FileName { get; set; } 10 } 11 }
我们可能会困惑,为什么仅仅为了将一个字符串映射到一个产品,我们需要添加一个额外的类,而不是在Product类上直接添加一个字符串类型的集合。当使用Entity Framework框架时,这是一个经常被开发人员问起的问题。原因是Entity Framework不能在数据库中对字符串集合建模,它需要被建模的字符串集合存储在一个单独的类中。
现在,修改DAL\StoreContext.cs文件以添加一个名为ProductImage属性,如下列代码所示:
1 using BabyStore.Models; 2 using System.Data.Entity; 3 4 namespace BabyStore.DAL 5 { 6 public class StoreContext : DbContext 7 { 8 public DbSet<Product> Products { get; set; } 9 public DbSet<Category> Categories { get; set; } 10 public DbSet<ProductImage> ProductImages { get; set; } 11 } 12 }
下一步,我们在程序包管理器控制台中输入下列命令,然后回车,创建一个迁移,以将新ProductImage实体作为一个表。
1 add-migration ProductImages
然后,使用下列命令更新数据库,创建一个新的ProductImage表。
1 update-database
6.2 上传图片
在我们能够上传图片之前,我们需要在某个地方存储这些图片。如前所述,我们打算在文件系统中而不是数据库中存储它们,因此,在Content文件夹下创建一个名为ProductImages的文件夹。在ProductImages文件夹下再创建一个名为Thumbnails的文件夹。
在ProductImages文件夹下将存储上传的图片,在Thumbnails文件夹下将存储较小一点的图片,以便允许用户使用Carousel特性导航每个产品图片。
6.2.1 定义可重用的常量
在这个项目中,我们将在多个文件中引用这些文件夹,因此,我们需要一种方法来存储每个文件夹的路径,以方便我们很容易地引用它们。为此,我们在项目的根目录下创建一个名为Constants的静态类,然后在其中添加两个常量保存这两个文件夹的路径。在解决方案资源管理器中,右键单击BabyStore项目,然后选择【添加】->【类】,创建一个名为Constants的类,该类中的代码如下所示:
1 namespace BabyStore 2 { 3 public static class Constants 4 { 5 public const string ProductImagePath = "~/Content/ProductImages/"; 6 public const string ProductThumbnailPath = "~/Content/ProductImages/Thumbnails"; 7 } 8 }
当我们需要引用保存图片的文件路径时,我们现在就可以引用Constants类了。这个类声明为static,以便我们不需要实例化即可使用它。
现在,我们在添加一个名为PageItems(目前是定义在ProductsController类中)的常量,如下高亮代码所示:
1 namespace BabyStore 2 { 3 public static class Constants 4 { 5 public const string ProductImagePath = "~/Content/ProductImages/"; 6 public const string ProductThumbnailPath = "~/Content/ProductImages/Thumbnails"; 7 public const int PageItems = 3; 8 } 9 }
更新ProductsController类的Index方法,以使用刚刚定义的PageItems常量,删除原来的pageItems常量,然后使用下列代码替换它:
1 public ActionResult Index(string category, string search, string sortBy, int? page) 2 { 3 // instantiate a new view model 4 ProductIndexViewModel viewModel = new ProductIndexViewModel(); 5 6 // select the products 7 var products = db.Products.Include(p => p.Category); 8 9 // perform the search and save the search string to the vieModel 10 if (!string.IsNullOrEmpty(search)) 11 { 12 products = products.Where(p => p.Name.Contains(search) || p.Description.Contains(search) || p.Category.Name.Contains(search)); 13 viewModel.Search = search; 14 } 15 16 // group search results into categories and count how many item in each category 17 viewModel.CatsWithCount = from matchinngProducts in products 18 where matchinngProducts.CategoryID != null 19 group matchinngProducts by matchinngProducts.Category.Name into catGroup 20 select new CategoryWithCount() 21 { 22 CategoryName = catGroup.Key, 23 ProductCount = catGroup.Count() 24 }; 25 26 if (!string.IsNullOrEmpty(category)) 27 { 28 products = products.Where(p => p.Category.Name == category); 29 viewModel.Category = category; 30 } 31 32 // sort the results 33 switch (sortBy) 34 { 35 case "price_lowest": 36 products = products.OrderBy(p => p.Price); 37 break; 38 case "price_highest": 39 products = products.OrderByDescending(p => p.Price); 40 break; 41 default: 42 products = products.OrderBy(p => p.Name); 43 break; 44 } 45 46 // const int pageItems = 3; 47 int currentPage = (page ?? 1); 48 viewModel.Products = products.ToPagedList(currentPage, Constants.PageItems); 49 viewModel.SortBy = sortBy; 50 51 viewModel.Sorts = new Dictionary<string, string> 52 { 53 { "Price low to high", "price_lowest" }, 54 { "Price high to low", "price_highest" } 55 }; 56 57 return View(viewModel); 58 }
6.2.2 添加ProductImage控制器和视图
编译项目,然后添加一个ProductImage控制器以及和它相关的视图。右键单击Controllers文件夹,然后选择【添加】->【控制器】菜单项。在添加基架窗体中选择“包含视图的 MVC 5 控制器(使用 Entity Framework)”,然后点击“添加”按钮。在添加控制器窗体中,指定模型类为ProductImage,数据上下文类为StoreContext,确保针对视图的所有复选框都被勾选,如图6-1所示。
图6-1:添加ProductImages控制器的选项
一旦创建完毕,新的ProductImageController类应该出现在Controllers文件夹中,与CRUD相关的视图会出现在Views\ProductImages文件夹中。
首先要做的事情是修改与ProductImages相关的Create方法以及Create视图。如果我们启动站点,然后导航到/ProductImages/Create,我们会看到一个与分类的创建(Create)页面极其相似的页面。它允许用户输入一个字符串,这不是我们想要的。我们想要的是一个能够上传文件、保存文件到磁盘以及将文件名保存到数据库中的Create方法和Create视图。
6.2.3 更新ProductImageController类以实现文件上传
开始上传文件之前,我们打算向ProductImagesController类添加一些方法,利用这些方法我们可以用来验证文件的大小、格式化上传文件以及调整图片的大小,以便更好地将图片显示在我们站点中。一般情况下,我们应该将验证文件的功能放在一个单独的、可以重用的类中,但是,对于该示例项目,我们将其放在ProductImagesController类中,以使事情简单化。
将以下方法添加到Controllers/ProductsImagesController.cs文件中的Dispose()方法之后:
1 private bool ValidateFile(HttpPostedFileBase file) 2 { 3 string fileExtension = System.IO.Path.GetExtension(file.FileName).ToLower(); 4 string[] allowedFileTypes = { ".gif", ".png", ".jpeg", ".jpg" }; 5 6 if ((file.ContentLength > 0 && file.ContentLength < 2097152) && allowedFileTypes.Contains(fileExtension)) 7 { 8 return true; 9 } 10 11 return false; 12 }
这个方法返回一个布尔类型的值,并且接收一个名为file的HttpPostedFileBase类型的输入参数。这个方法获取文件的扩展名,然后检查该文件的扩展名是不是我们允许的扩展名类型(GIF、PNG、JPEG和JPG)。它还检查该文件的大小是不是在0字节到2MB字节之间,如果是,该方法返回true(否则,返回false)。需要注意的一点是,我们没有使用循环变量allowedFileTypes数组,而是使用了LINQ的contains操作符来简化代码的数量。
下一步,我们在ValidateFile()方法下面添加一个新方法,该方法用于调整图片的大小(如果需要),然后将图片保存到磁盘,如下列代码所示:
1 private void SaveFileToDisk(HttpPostedFileBase file) 2 { 3 WebImage img = new WebImage(file.InputStream); 4 5 if (img.Width > 190) 6 { 7 img.Resize(190, img.Height); 8 } 9 10 img.Save(Constants.ProductImagePath + file.FileName); 11 12 if (img.Width > 100) 13 { 14 img.Resize(100, img.Height); 15 } 16 17 img.Save(Constants.ProductThumbnailPath + file.FileName); 18 }
为确保这段代码能够编译,我们要在该文件的顶部添加一条using语句:
1 using System.Web.Helpers;
SaveFileToDisk()方法也接收一个HttpPostedFileBase类型的输入参数。如果图片宽度大于190像素,则使用WebImage类调整图片大小,并将其保存到ProductImages目录下。然后,如果图片宽度大于100像素,则再次调用图片大小,并将其保存到Thumbnails目录。
我们已经完成了辅助方法的编写,现在,我们需要修改ProductImageController(译者注:原书写的是ProductController类,应该是笔误)的Create()方法,使用该方法可以在以ProductImage被创建时上传文件。首先,我们将两个Create()方法都重命名为Upload()。然后将HttpPost版本的Upload()方法修改为下列高亮显示的代码:
1 // GET: ProductImages/Create 2 // public ActionResult Create() 3 public ActionResult Upload() 4 { 5 return View(); 6 } 7 8 // POST: ProductImages/Create 9 // 为了防止“过多发布”攻击,请启用要绑定到的特定属性,有关 10 // 详细信息,请参阅 http://go.microsoft.com/fwlink/?LinkId=317598。 11 [HttpPost] 12 [ValidateAntiForgeryToken] 13 // public ActionResult Create([Bind(Include = "ID,FileName")] ProductImage productImage) 14 public ActionResult Upload(HttpPostedFileBase file) 15 { 16 // check the user has entered a file 17 if (file != null) 18 { 19 // check if the file is valid 20 if (ValidateFile(file)) 21 { 22 try 23 { 24 SaveFileToDisk(file); 25 } 26 catch (Exception) 27 { 28 ModelState.AddModelError("FileName", "Sorry an error occurred saving the file to disk, please try again"); 29 } 30 } 31 else 32 { 33 ModelState.AddModelError("FileName", "The file must be gif, png, jpeg or jpg and less than 2MB in size"); 34 } 35 } 36 else 37 { 38 // if the user has not entered a file return an error message 39 ModelState.AddModelError("FileName", "Please choose a file"); 40 } 41 42 if (ModelState.IsValid) 43 { 44 db.ProductImages.Add(new ProductImage { FileName = file.FileName }); 45 db.SaveChanges(); 46 return RedirectToAction("Index"); 47 } 48 49 return View(); 50 }
第一处改动是我们完全移除了Bind部分,这是因为ID是数据库为其设值,FileName是我们手动为其设值。然后,我们移除了productImage参数,并添加了一个新的输入参数:HttpPostedFileBase file(译者注:原书此处有错误,原书写的是HttpPostedFileBase[] file)。这个参数是被用户提交的文件,它是视图中的文件上传控件生成的。在这个方法中,我们没有依赖模型绑定,而是使用手动的方式为其执行赋值,因此,我们不需要productImage参数。相反,当我们向数据库添加ProductImage时,我们在该方法中使用下面的代码创建了一个ProductImage对象:
1 db.ProductImages.Add(new ProductImage { FileName = file.FileName });
然后我们添加了一条if语句来检查用户确实输入了一个文件。如果没有,我们使用ModelState.AddError()方法添加一条错误信息来提醒用户需要输入一个文件,如下代码所示:
1 //check the user has entered a file 2 if (file != null) 3 { 4 } 5 else 6 { 7 //if the user has not entered a file return an error message 8 ModelState.AddModelError("FileName", "Please choose a file"); 9 }
如果用户已经输入了一个文件,我们执行一个检查以确保该文件是有效的,也就是说,它的大小不超过2MB,而且是一个被允许的文件类型。如果该文件有效,则会使用SaveFileToDisk()方法将其保存到磁盘中。如果该文件无效,则会添加一条错误信息通知用户“The file must be gif, png, jpeg or jpg and less than 2MP in size”。所有这些都是使用下列代码实现的:
1 // check if the file is valid 2 if (ValidateFile(file)) 3 { 4 try 5 { 6 SaveFileToDisk(file); 7 } 8 catch (Exception) 9 { 10 ModelState.AddModelError("FileName", "Sorry an error occurred saving the file to disk, please try again"); 11 } 12 } 13 else 14 { 15 ModelState.AddModelError("FileName", "The file must be gif, png, jpeg or jpg and less than 2MB in size"); 16 }
最后,如果一切都执行成功,并且ModelState的状态依然是有效的,那么,我们会创建一个新的ProductImage对象,并将提交文件的FileName属性赋值给ProductImage对象的FileName属性,然后将ProductImage对象保存到数据库中,用户会被重定向到索引(Index)视图。否则,如果在ModelState中有错误,那么上传(Upload)视图会被返回给用户,并且显示错误信息,如下代码所示:
1 if (ModelState.IsValid) 2 { 3 db.ProductImages.Add(new ProductImage { FileName = file.FileName }); 4 db.SaveChanges(); 5 return RedirectToAction("Index"); 6 } 7 8 return View();
6.2.4 更新视图
我们已经对控制器做了更新,现在,我们需要更新视图文件,以便它包含一个提交表单的控件,而不是一个字符串。首先,我们将\Views\ProductImages\Create.cshtml文件重命名为Upload.cshtml。接着,修改文件以允许用户通过一个HTML表单提交一个文件:
1 @model BabyStore.Models.ProductImage 2 3 @{ 4 ViewBag.Title = "Upload Product Image"; 5 } 6 7 <h2>@ViewBag.Title</h2> 8 9 10 @using (Html.BeginForm("Upload", "ProductImages", FormMethod.Post, new { enctype = "multipart/form-data" })) 11 { 12 @Html.AntiForgeryToken() 13 14 <div class="form-horizontal"> 15 <h4>ProductImage</h4> 16 <hr /> 17 @Html.ValidationSummary(true, "", new { @class = "text-danger" }) 18 <div class="form-group"> 19 @Html.LabelFor(model => model.FileName, htmlAttributes: new { @class = "control-label col-md-2" }) 20 <div class="col-md-10"> 21 <input type="file" name="file" id="file" class="form-control" /> 22 @Html.ValidationMessageFor(model => model.FileName, "", new { @class = "text-danger" }) 23 </div> 24 </div> 25 26 <div class="form-group"> 27 <div class="col-md-offset-2 col-md-10"> 28 <input type="submit" value="Upload" class="btn btn-default" /> 29 </div> 30 </div> 31 </div> 32 } 33 34 <div> 35 @Html.ActionLink("Back to List", "Index") 36 </div> 37 38 @section Scripts { 39 @Scripts.Render("~/bundles/jqueryval") 40 }
第一处改动是更新页面的标题,使用的方法和前面章节更新其它视图的方法类似。我们还更新了表单,使其具有一个HTML特性enctype="multipart/form-data,这是上传文件所必须的。我们使用下面的代码完成这一更新:
1 @using (Html.BeginForm("Upload", "ProductImages", FormMethod.Post, new { enctype = "multipart/form-data" }))
下一处需要改动的是使用下列代码将表单的input元素改为一个HTML文件上传控件:
1 <input type="file" name="file" id="file" class="form-control" />
其它需要改动的就是将按钮的text属性改为“Upload”。图6-2显示了修改后的HTML页面。
图6-2:带有上传控件的ProductImages上传页面
最后需要修改的是添加一些到新视图的链接。修改\View\Shared\_Layout.cshtml文件添加一个到ProductImages索引(Index)页面的链接,按如下代码修改该文件中的无序列表(<ul>标签),并使其使用nav navbar-nav类:
1 <ul class="nav navbar-nav"> 2 <li>@Html.ActionLink("主页", "Index", "Home")</li> 3 <li>@Html.ActionLink("分类", "Index", "Categories")</li> 4 <li>@Html.RouteLink("产品", "ProductsIndex")</li> 5 <li>@Html.ActionLink("管理图片", "Index", "ProductImages")</li> 6 </ul>
接着,按下列所示的代码修改Views\ProductImages\Index.cshtml文件,以便可以创建一个到新Upload视图的链接:
1 <p> 2 @*@Html.ActionLink("Create New", "Create")*@ 3 @Html.ActionLink("Upload New Images", "Upload") 4 </p>
6.2.5 测试文件上传
启动应用程序,并且导航到ProductImages的Upload页面,不输入文件然后点击Upload按钮,页面将显示一个错误信息,如图6-3所示。
图6-3:用户没有选择上传文件,点击Upload按钮时的错误信息
从Apress站点下载本书的第6章源码,开始下面的测试。现在,我们试着将下载的第6章源码中的ProductImages文件中的Bitmap01文件进行上传,应用程序应该响应一个错误,如图6-4所示。再试着上传LargeImage.jpg文件,这个文件是一个JPG格式的文件,大小超过2MB,因此,应用程序也会显示一个如图6-4所示的错误。
图6-4:当用户上传一个无效文件时,所显示的错误信息
下一步,上传Image01文件,此次上传将会成功。数据库表dbo.ProductImages将会包含一条针对Image01的记录,如图6-5所示。如果需要,这张图片会被调整大小,并被保存到应用程序的Content\ProductImages和Content\ProductImages\Thumbnails文件夹下。这些图片会出现在解决方案浏览器的相关目录中,如图6-6所示。Content\ProductImages文件夹中的图片会被调整为190像素宽,Content\ProductImages\Thumbnails文件夹中的图片会被调整为100像素宽,它们会保持图片的纵横比。
图6-5:表示Image01.jpg文件的实体被保存在ProductImages表中
图6-6:图片Image01.jpg保存在Content\ProductImages和Content\ProductImages\Thumbnails目录下
6.2.6 使用Entity Framework检查记录唯一性
上传文件现在已经能工作了,但是,用户可以在该系统中红上传同一文件多次,实际上,这是不被允许的,为了阻止这种事情的发生,我们打算向数据库中不是键的类添加一个唯一约束。为了完成这个工作,我们在FileName字段中使用Index。更新Models\ProductImage.cs文件中的代码如下所示:
1 using System.ComponentModel.DataAnnotations; 2 using System.ComponentModel.DataAnnotations.Schema; 3 4 namespace BabyStore.Models 5 { 6 public class ProductImage 7 { 8 public int ID { get; set; } 9 [Display(Name = "File")] 10 [StringLength(100)] 11 [Index(IsUnique = true)] 12 public string FileName { get; set; } 13 } 14 }
这段代码对FileName属性添加了一个唯一性约束并且应用了最大长度特性,以便它的长度不能设置为nvarchar[MAX]。这是必须的,因为SQL Server要求应用索引的列的最大长度为900字节。如果我们不使用StringLength特性,那么FileName列在数据库中将是nvarchar[MAX],这会导致创建索引失败。
在程序包管理器控制台中输入以下命令来创建一个新的迁移:
1 add-migration UniqueFileName
然后,允许下列命令来更新数据库:
1 update-database
现在,数据库有了一个应用在指定列FileName上的一个索引来保证该列的唯一性。这个索引会阻止FileName列出现重复值,并且当试图添加一个重复实体时,SQL Server会抛出一个异常。
试着再次上传Image01文件,图6-7显示了现在应用程序的响应情况。
图6-7:在SQL Server中尝试着向具有唯一性索引的列添加重复记录时的标准站点异常响应
为了向用户显示一条更加有意义的信息,我们需要添加一些异常处理以使站点能够在视图中响应异常,而不是产生一个标准的异常信息。为了添加异常处理,我们修改ProductImagesController类中的Upload方法(HttpPost版本),使其修改代码所下列所示的高亮代码:
1 [HttpPost] 2 [ValidateAntiForgeryToken] 3 // public ActionResult Create([Bind(Include = "ID,FileName")] ProductImage productImage) 4 public ActionResult Upload(HttpPostedFileBase file) 5 { 6 // check the user has entered a file 7 if (file != null) 8 { 9 // check if the file is valid 10 if (ValidateFile(file)) 11 { 12 try 13 { 14 SaveFileToDisk(file); 15 } 16 catch (Exception) 17 { 18 ModelState.AddModelError("FileName", "Sorry an error occurred saving the file to disk, please try again"); 19 } 20 } 21 else 22 { 23 ModelState.AddModelError("FileName", "The file must be gif, png, jpeg or jpg and less than 2MB in size"); 24 } 25 } 26 else 27 { 28 // if the user has not entered a file return an error message 29 ModelState.AddModelError("FileName", "Please choose a file"); 30 } 31 32 if (ModelState.IsValid) 33 { 34 db.ProductImages.Add(new ProductImage { FileName = file.FileName }); 35 36 try 37 { 38 db.SaveChanges(); 39 } 40 catch (DbUpdateException ex) 41 { 42 SqlException innerException = ex.InnerException.InnerException as SqlException; 43 if (innerException != null && innerException.Number == 2601) 44 { 45 ModelState.AddModelError("FileName", "The file " + file.FileName + " already exists in the system. Please delete it and try again if you wish to re-add it"); 46 } 47 else 48 { 49 ModelState.AddModelError("FileName", "Sorry an error has occurred saving to the database, please try again"); 50 } 51 52 return View(); 53 } 54 55 return RedirectToAction("Index"); 56 } 57 58 return View(); 59 }
我们在ProductImagesController类的顶部添加下面两条using语句,以便可以使用DbUpdateException和SqlException:
1 using System.Data.SqlClient; 2 using System.Data.Entity.Infrastructure;
这段代码使用try/catch语句尝试在保存对数据库的修改时,捕获一个DBUpdateException类型的异常。然后检查该异常的InnerException属性的InnerException属性的Number的属性值是不是2601(当试图向具有唯一性索引的表插入重复记录时,SQL Exception的编号为2601)。如果这个异常的Number的属性值是2601,那么将向ModelState添加一个错误以通知用户该文件已经存在。如果这个异常的Number属性值是其他值,则显示一个更加泛泛的异常信息。最后,如果发生一个异常,将会向用户显示Update视图,并且显示异常信息。
现在,如果我们试着上传Image01文件,站点的响应如图6-8所示,而不是抛出一个标准异常信息。
图6-8:当尝试上传一个重复文件时,使用新的try/catch语句产生的异常信息
6.2.7 允许多文件上传
目前的文件上传系统工作的很好,但是只允许用户一次上传一个图片文件。一个内容编辑人员可能会上传多个图片文件,因此,不断打开上传页面每次上传一个图片将会浪费大量时间。为了帮助加快该工作流,我们打算让用户一次上传10个文件。为了达到此目的,我们需要修改ProductImagesController类的Upload方法,以便它能够处理多个文件作为其输入参数,验证每个文件,然后将它们的名字保存到数据库。我们也需要修改Views\ProductImages\Upload.cshtml文件以允许一次提交多个文件。
代码比本书之前的代码都要复杂,因此,我们打算先大体上解释下其算法:
- 用户必须一次上传至少一个文件,但是不能超过10个文件。
- 所有的文件必须都是有效的(也就是说,每个文件都要小于2MB,并且文件格式为GIF、PNG、JPEG或JPG)。
- 如果有任何文件不可用,那么所有的文件都不会被上传,并且会向用户返回一个无效文件的列表。
- 如果所有的文件都是有效的,那么它们每个都会保存到磁盘中。如果在保存文件的过程中发生问题,将会抛出一个异常,并且询问用户是不是再上传一次。这个代码只是简单地覆盖已经存在的文件,而不会尝试移除已经存在的任何文件。当用户再次尝试的时候,它们只是简单地被覆盖。
- 如果所有的文件都有效,并且都已被成功上传,那么系统会尝试将每个文件的名称保存到数据库中。
- 如果有任何文件重复,也就是说在数据库中它们已经存在,那么它们不会被添加到数据库中,并且会向用户显示一个错误列表,该列表包含重复文件的名称。其它没有重复的文件将会保存到数据库中。我将使用这种方法演示如何使用数据库上下文对象操纵数据存储。稍后,我们还会解释在上下文对象中的数据存储不一定是相同的,因为它存储在数据库中,并且需要一些本地管理。
- 如果有错误抛出,将会返回到Upload视图。否则,返回到索引(Index)视图。如果一些文件是因为重复没有被上传成功,Upload视图也会被返回,并且会在该视图中显示一个错误信息,在该错误信息中指出了那些文件由于重复没有上传成功。
6.2.7.1 更新ProductImagesController类以实现多文件上传
为了允许多文件上传,我们修改ProductImagesController类的Upload方法(HttpPost版本)。首先,清除掉该方法的所有内容,并更新输入参数为HttpPostedFileBase类型的数组,如下列代码所示:
1 [HttpPost] 2 [ValidateAntiForgeryToken] 3 public ActionResult Upload(HttpPostedFileBase[] files) 4 { 5 return View(); 6 }
接着,我们在该方法的的顶部添加一对变量,一个用于跟踪所有文件是否都是可用的,另一个用于保存无效文件的名称:
1 [HttpPost] 2 [ValidateAntiForgeryToken] 3 public ActionResult Upload(HttpPostedFileBase[] files) 4 { 5 bool allValid = true; 6 string inValidFiles = ""; 7 8 return View(); 9 }
紧接着这些变量的代码用于检查用户是否提交了任何文件。检查files变量的第一个元素是不是null值,然后检查files的元素是否超过了10个。
1 [HttpPost] 2 [ValidateAntiForgeryToken] 3 public ActionResult Upload(HttpPostedFileBase[] files) 4 { 5 bool allValid = true; 6 string inValidFiles = ""; 7 8 // check the user has entered a file 9 if (files[0] != null) 10 { 11 // if the user has entered less than 10 files 12 if (files.Length <= 10) 13 { 14 15 } 16 else 17 { 18 // the user has entered more than 10 files 19 ModelState.AddModelError("FileName", "Please only upload up to 10 files at a time"); 20 } 21 } 22 else 23 { 24 // if the user has not entered a file return an error message 25 ModelState.AddModelError("FileName", "Please choose a file"); 26 } 27 28 if (ModelState.IsValid) 29 { 30 31 } 32 33 return View(); 34 }
接着,如果提交的文件个数大于0并且不超过10个,那么使用下面的循环来检查所有的文件是否有效。如果有任何一个文件无效,变量allValid就会被设置为false,并且该无效文件的文件名也会追加到inValidFiles字符串后面。如果所有的文件都是有效的,然后循环遍历每个文件,并将它们保存到磁盘中。如果它们不是全部有效,则向ModelState添加一条包含所有无效文件名称的错误信息,具体代码如下所示:
1 // if the user has entered less than 10 files 2 if (files.Length <= 10) 3 { 4 // check they are all valid 5 foreach(var file in files) 6 { 7 if (!ValidateFile(file)) 8 { 9 allValid = false; 10 inValidFiles += ", " + file.FileName; 11 } 12 } 13 14 // if they are all valid then try to save them to disk 15 if (allValid) 16 { 17 foreach(var file in files) 18 { 19 try 20 { 21 SaveFileToDisk(file); 22 } 23 catch (Exception) 24 { 25 ModelState.AddModelError("FileName", "Sorry an error occurred saving the files to disk, please try again"); 26 } 27 } 28 } 29 else 30 { 31 ModelState.AddModelError("FileName", "All files must be gif, png, jpeg or jpg and less than 2MB in size. The following files" + inValidFiles + " are not valid"); 32 } 33 } 34 else 35 { 36 // the user has entered more than 10 files 37 ModelState.AddModelError("FileName", "Please only upload up to 10 files at a time"); 38 }
如果ModelState中不包含任何错误,代码的最后部分尝试着将每个文件都保存到数据库中。首先在检查ModelState是否包含错误的代码中添加下列代码,其中的duplicates用于指示是否在保存文件时发生重复错误,otherDbError用于指示在保存文件时是否发生了其他错误,duplicateFiles用于保存重复文件的名称。
1 if (ModelState.IsValid) 2 { 3 bool duplicates = false; 4 bool otherDbError = false; 5 string duplicateFiles = ""; 6 }
接着,循环遍历每个文件,将每个文件添加到数据库上下文中,然后尝试着将它们保存到数据库中。注意,和以前该方法中的代码相比,我们这次在创建一个ProductImage对象时,没有使用未命名的匿名对象,这是因为在本章的后面,我们需要返回来引用我们尝试添加的ProductImage对象。和上传单个文件一样,如果由于数据库错误引起更新失败,这个错误将会被捕获。如果这个错误是因为在数据库中已经存在同一个实体而造成的,那么这个文件名将会被追加到duplicateFiles字符串中,并且duplicates被设置为true。如果这个错误是其它原因造成的,那么otherDbError会被设置为true。代码这样写是因为,即使某个文件在保存到数据库时失败,其它提交的文件依然可以被保存到数据库中。
1 if (ModelState.IsValid) 2 { 3 bool duplicates = false; 4 bool otherDbError = false; 5 string duplicateFiles = ""; 6 7 foreach (var file in files) 8 { 9 // try and save each file 10 var productToAdd = new ProductImage { FileName = file.FileName }; 11 12 try 13 { 14 db.ProductImages.Add(productToAdd); 15 db.SaveChanges(); 16 } 17 catch (DbUpdateException ex) 18 { 19 // if there is an exception check if it is caused by a duplicate file 20 SqlException innerException = ex.InnerException.InnerException as SqlException; 21 if (innerException != null && innerException.Number == 2601) 22 { 23 duplicateFiles += ", " + file.FileName; 24 duplicates = true; 25 } 26 else 27 { 28 otherDbError = true; 29 } 30 } 31 } 32 }
最后,如果duplicates或otherDbError被设置为true,则相应的错误消息被添加到ModelState中。如果在数据库中已经存在同样的文件,则重复文件列表会显示给用户。
1 if (ModelState.IsValid) 2 { 3 bool duplicates = false; 4 bool otherDbError = false; 5 string duplicateFiles = ""; 6 7 foreach (var file in files) 8 { 9 // try and save each file 10 var productToAdd = new ProductImage { FileName = file.FileName }; 11 12 try 13 { 14 db.ProductImages.Add(productToAdd); 15 db.SaveChanges(); 16 } 17 catch (DbUpdateException ex) 18 { 19 // if there is an exception check if it is caused by a duplicate file 20 SqlException innerException = ex.InnerException.InnerException as SqlException; 21 if (innerException != null && innerException.Number == 2601) 22 { 23 duplicateFiles += ", " + file.FileName; 24 duplicates = true; 25 } 26 else 27 { 28 otherDbError = true; 29 } 30 } 31 } 32 33 // add a list of duplicate files to the error message 34 if (duplicates) 35 { 36 ModelState.AddModelError("FileName", "All files uploaded except the files" + duplicateFiles + ", which already exist in the system. Please delete them and try again if you wish to re-add them"); 37 return View(); 38 } 39 else if (otherDbError) 40 { 41 ModelState.AddModelError("FileName", "Sorry an error has occurred saving to the database, please try again"); 42 return View(); 43 } 44 45 return RedirectToAction("Index"); 46 }
完整的HttpPost版本的Upload方法现在应该如下列代码所示的一样:
1 [HttpPost] 2 [ValidateAntiForgeryToken] 3 public ActionResult Upload(HttpPostedFileBase[] files) 4 { 5 bool allValid = true; 6 string inValidFiles = ""; 7 8 // check the user has entered a file 9 if (files[0] != null) 10 { 11 // if the user has entered less than 10 files 12 if (files.Length <= 10) 13 { 14 // check they are all valid 15 foreach (var file in files) 16 { 17 if (!ValidateFile(file)) 18 { 19 allValid = false; 20 inValidFiles += ", " + file.FileName; 21 } 22 } 23 24 // if they are all valid then try to save them to disk 25 if (allValid) 26 { 27 foreach (var file in files) 28 { 29 try 30 { 31 SaveFileToDisk(file); 32 } 33 catch (Exception) 34 { 35 ModelState.AddModelError("FileName", "Sorry an error occurred saving the files to disk, please try again"); 36 } 37 } 38 } 39 else 40 { 41 ModelState.AddModelError("FileName", "All files must be gif, png, jpeg or jpg and less than 2MB in size. The following files" + inValidFiles + " are not valid"); 42 } 43 } 44 else 45 { 46 // the user has entered more than 10 files 47 ModelState.AddModelError("FileName", "Please only upload up to 10 files at a time"); 48 } 49 } 50 else 51 { 52 // if the user has not entered a file return an error message 53 ModelState.AddModelError("FileName", "Please choose a file"); 54 } 55 56 if (ModelState.IsValid) 57 { 58 bool duplicates = false; 59 bool otherDbError = false; 60 string duplicateFiles = ""; 61 62 foreach (var file in files) 63 { 64 // try and save each file 65 var productToAdd = new ProductImage { FileName = file.FileName }; 66 67 try 68 { 69 db.ProductImages.Add(productToAdd); 70 db.SaveChanges(); 71 } 72 catch (DbUpdateException ex) 73 { 74 // if there is an exception check if it is caused by a duplicate file 75 SqlException innerException = ex.InnerException.InnerException as SqlException; 76 if (innerException != null && innerException.Number == 2601) 77 { 78 duplicateFiles += ", " + file.FileName; 79 duplicates = true; 80 } 81 else 82 { 83 otherDbError = true; 84 } 85 } 86 } 87 88 // add a list of duplicate files to the error message 89 if (duplicates) 90 { 91 ModelState.AddModelError("FileName", "All files uploaded except the files" + duplicateFiles + ", which already exist in the system. Please delete them and try again if you wish to re-add them"); 92 return View(); 93 } 94 else if (otherDbError) 95 { 96 ModelState.AddModelError("FileName", "Sorry an error has occurred saving to the database, please try again"); 97 return View(); 98 } 99 100 return RedirectToAction("Index"); 101 } 102 103 return View(); 104 }
6.2.7.2 更新Upload视图以实现多文件上传
完成对控制器的大修改之后,我们应该很高兴听到更新视图的工作十分简单。按照下列代码更新Views\ProductImages\Upload.cshtml视图的标题:
1 ViewBag.Title = "Upload Product Images";
然后修改HTML的类型为file的input元素:
1 <input type="file" name="files" id="files" multiple="multiple" class="form-control" />
控件的name和id值被更改为files,而不是file,条目multiple="multiple"意味着控件允许用户一次选择多个文件。
我们也要修改Views\ProductImages\Index.cshtml文件中的ActionLink,使其向下面代码所示的一样:
1 <p> 2 @Html.ActionLink("Upload New Images", "Upload") 3 </p>
6.2.7.3 测试多文件上传
不调试启动应用程序,然后导航到ProductImages/Upload视图。
首先,我们测试用户一次至少要上传一个文件,同时不能超过十个文件。直接点击Upload按钮,应用程序应该提示“Please choose a file”的消息,如图6-3所示。
接着,尝试着一次上传超过十个文件,我们将下载的第6章源代码中的image01到image11进行上传,应用程序应该如图6-9所示。检查数据库和文件系统确保没有文件被上传。
图6-9:当尝试一次上传超过十个文件时的错误信息
下一个场景是测试如何有任何文件无效的话,那么所有文件都不会上传,并且会将一个无效文件列表返回给用户。试着上传Image02和Bitmap01文件,应用程序的响应如图6-10所示。
图6-10:试图上传包含至少一个无效文件的响应
现在试着上传Image02、Image03和Image04文件。它们应该会被上传成功,并且索引(Index)试图会显示上传文件的一个列表,如图6-11所示。这些文件应该被保存在数据库和文件系统中,类似图6-5和6-6。
图6-11:成功上传多个文件
最后,我们需要测试重复文件的上传。试着上传Image04和Image05文件,Image05会被添加,但是Image04会返回一个图片已经存在的信息,结果如图6-12所示。
图6-12:当上传一个已存在文件时的错误信息
在这有一个错误,因为错误信息提示我们有两个文件是重复的,实际上不是。这个问题之所以发生是因为ProductImagesController类中的StoreContext对象的工作方式。我们将在下一节中解释为什么会出现这个问题,以及如何解决这个问题。
6.2.8 使用DbContext对象和实体状态(Entity States)
在测试多文件上传的时候,当其中有一个文件的名称在数据库中已经存在时,会发生一个错误。这个错误就是跟随在其后的文件都会被当作重复文件,即使它们实际上不是。为什么会发生这样的问题?答案在于添加ProductImage实体到数据库中的代码,以及DbContext对象原本的工作方式。我们知道在ProductImagesController类中的StoreContext继承自DbContext。此时,包含在HttpPost版本的Upload方法内的和检测重复文件相关的代码如下所示:
1 foreach (var file in files) 2 { 3 // try and save each file 4 var productToAdd = new ProductImage { FileName = file.FileName }; 5 6 try 7 { 8 db.ProductImages.Add(productToAdd); 9 db.SaveChanges(); 10 } 11 catch (DbUpdateException ex) 12 { 13 // if there is an exception check if it is caused by a duplicate file 14 SqlException innerException = ex.InnerException.InnerException as SqlException; 15 if (innerException != null && innerException.Number == 2601) 16 { 17 duplicateFiles += ", " + file.FileName; 18 duplicates = true; 19 } 20 else 21 { 22 otherDbError = true; 23 } 24 } 25 }
为了查看这段代码哪儿出现了错误,我们在foreach (var file in files)这行代码处添加一个断点。
提示:在Visual Studio中添加一个断点,左键点击代码左边的灰色区域,然后会出现一个红色远点即可完成断点的添加。
现在,在Visual Studio菜单栏中点击【调试】->【开始调试】菜单,启动应用程序。导航到ProductImages/Upload页面,然后再次上传Image04和Image05。当我们点击Upload按钮后,Visual Studio将会在断点处打开。
按住F10键,开始第一次循环,代码将会移动到catch语句。继续按住F10键,直到我们再次到达db.SaveChanges()行代码,然后暂停。现在左键单击db.ProductImages.Add(productToAdd);这行代码的ProductImages,然后展开第一个菜单,会显示db.ProductImages的项目数是2。如图6-13所示。
图6-13:使用调试显示db.ProductImages的数目
这意味着,尽管Image04导致抛出一个异常,并且没被保存到数据库中,但是它的条目依然存在于本地上下文实例中(db.ProductImages)。按下F10继续移动代码,我们会注意到异常会被再次抛出,尽管第二个文件不是重复的。这是因为第一个文件(重复的)依然存在于当前DbContext实例中。这就是为什么会在图6-12中,错误信息会显示有两个重复文件的原因。尽管在第一次循环时,添加Image04到DbContext会抛出一个异常,但是它并没有从db.ProductImages中移除,当进行第二次循环时,Entity Framework追踪到它没有被保存到数据库,会再次尝试将其保存到数据库中。
为了解决这个问题,我们要从DbContext实例中移除所有抛出异常的文件。可以有两种方法解决这个问题,第一种方法就是在每次循环中先通过调用db.Dispose()方法释放上下文实例,然后再创建一个新的实例。另一种可选的方法是操纵当前的dbContext实例。我们使用第二种方法确保抛出异常的文件都会在第二次循环之前进行移除。
为了从DbContext中移除一个实体,实体的状态必须被设置为detached。为了分离当前文件,我们可以使用下面的代码:
1 db.Entry(productToAdd).State = EntityState.Detached;
为了解决当前重复文件的问题,在Controllers\ProductImageController.cs文件中的Upload方法中的catch语句中添加以下代码:
1 catch (DbUpdateException ex) 2 { 3 // if there is an exception check if it is caused by a duplicate file 4 SqlException innerException = ex.InnerException.InnerException as SqlException; 5 if (innerException != null && innerException.Number == 2601) 6 { 7 duplicateFiles += ", " + file.FileName; 8 duplicates = true; 9 db.Entry(productToAdd).State = EntityState.Detached; 10 } 11 else 12 { 13 otherDbError = true; 14 } 15 }
现在,因为在系统中已经存在而抛出异常的文件都会从DbContext中移除。我们再次上传Image04和Image05文件,结果如图6-14所示,只有重复文件Image04.jpg被返回到错误列表中。
图6-14:期望的重复文件错误信息
图6-15显示索引(Index)视图中,Image05.jpg文件已经被上传成功。
图6-15:尽管Image04.jpg在数据库中已经存在,但是Image05.jpe文件依然被上传成功
这个场景只涵盖了将实体添加到DbContext中,但是实体也可能被删除或修改。也可以将当前状体为EntityState.Deleted或EntityState.Modified的实体状体重置为EntityState.Unchanged。也可以将修改后的实体设置为原来的值。例如,如果我们向重置ProductToAdd实体的值,我们可以使用下列代码:
1 db.Entry(productToAdd).CurrentValues.SetValues(db.Entry(productToAdd).OriginalValues);
未完待续!
敬请期待ASP.NET MVC with Entity Framework and CSS一书翻译系列文章之第六章:管理产品图片——多对多关系(下篇)