在這章中,我們將學習如何創建一個管理圖片的新實體,如何使用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一書翻譯系列文章之第六章:管理產品圖片——多對多關系(下篇)
