第八章 SportsStore: 扩大数据规模
创建应用程序时,关注点通常是正确地打好地基,就是我在SportsStore项目用的方式。随着应用程序的发展,增加您正在处理的数据量是很有用的,这样您可以看到它对用户必须执行的操作的影响以及它们所花费的时间。
在本章中,我将测试数据添加到数据库中,以展示SportsStore显示数据方式的缺陷,并通过添加对分页、排序和搜索数据的支持来解决这些问题。我还向您展示了在使用EF Core时,如何提高操作的性能,比如使用高级数据模型配置选项,称为Fluent API。
8.1 准备工作
我接着前面章节使用SportsStore项目。在SportsStore项目文件夹中运行清单8-1所示的命令来删除和重新创建数据库。
1 Listing 8-1. Deleting and Re-creating the Database 2 dotnet ef database drop --force 3 dotnet ef database update
小贴士:您可以从本书的GitHub知识库中下载本章的SportsStore项目和其他章节的项目:
https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc.
8.1.1 创建一个种子数据控制器和视图
在本章中,我需要一个控制器,它可以用测试数据填充数据库。我添加了一个SeedController.cs到Controllers文件夹,并使用它来定义控制器,如清单8-2所示。
1 //Listing 8-2. The Contents of the SeedController.cs File in the Controllers Folder 2 using Microsoft.AspNetCore.Mvc; 3 using Microsoft.EntityFrameworkCore; 4 using SportsStore.Models; 5 using System.Linq; 6 namespace SportsStore.Controllers 7 { 8 public class SeedController : Controller 9 { 10 private DataContext context; 11 public SeedController(DataContext ctx) => context = ctx; 12 public IActionResult Index() 13 { 14 ViewBag.Count = context.Products.Count(); 15 return View(context.Products 16 .Include(p => p.Category).OrderBy(p => p.Id).Take(20)); 17 } 18 19 [HttpPost] 20 public IActionResult CreateSeedData(int count) 21 { 22 ClearData(); 23 if (count > 0) 24 { 25 context.Database.SetCommandTimeout(System.TimeSpan.FromMinutes(10)); 26 context.Database 27 .ExecuteSqlCommand("DROP PROCEDURE IF EXISTS CreateSeedData"); 28 context.Database.ExecuteSqlCommand($@" 29 CREATE PROCEDURE CreateSeedData 30 @RowCount decimal 31 AS 32 BEGIN 33 SET NOCOUNT ON 34 DECLARE @i INT = 1; 35 DECLARE @catId BIGINT; 36 DECLARE @CatCount INT = @RowCount / 10; 37 DECLARE @pprice DECIMAL(5,2); 38 DECLARE @rprice DECIMAL(5,2); 39 40 BEGIN TRANSACTION 41 WHILE @i <= @CatCount 42 BEGIN 43 INSERT INTO Categories (Name, Description) 44 VALUES (CONCAT('Category-', @i), 45 'Test Data Category'); 46 SET @catId = SCOPE_IDENTITY(); 47 DECLARE @j INT = 1; 48 WHILE @j <= 10 49 BEGIN 50 SET @pprice = RAND()*(500-5+1); 51 SET @rprice = (RAND() * @pprice) 52 + @pprice; 53 INSERT INTO Products (Name, CategoryId, 54 PurchasePrice, RetailPrice) 55 VALUES (CONCAT('Product', @i, '-', @j), 56 @catId, @pprice, @rprice) 57 SET @j = @j + 1 58 END 59 SET @i = @i + 1 60 END 61 COMMIT 62 END"); 63 context.Database.BeginTransaction(); 64 context.Database 65 .ExecuteSqlCommand($"EXEC CreateSeedData @RowCount = {count}"); 66 context.Database.CommitTransaction(); 67 } 68 return RedirectToAction(nameof(Index)); 69 } 70 71 [HttpPost] 72 public IActionResult ClearData() 73 { 74 context.Database.SetCommandTimeout(System.TimeSpan.FromMinutes(10)); 75 context.Database.BeginTransaction(); 76 context.Database.ExecuteSqlCommand("DELETE FROM Orders"); 77 context.Database.ExecuteSqlCommand("DELETE FROM Categories"); 78 context.Database.CommitTransaction(); 79 return RedirectToAction(nameof(Index)); 80 } 81 } 82 }
在生成大量测试数据时,创建. net对象并将其存储在数据库中是低效的。种子控制器使用EF Core功能来直接产生SQL语句来创建和执行一个存储过程,该存储过程可以更快地生成测试数据。(我将在第23章详细描述这些特性。)
不要在实际项目中这样做
我在清单8-2中采用的方法应该只用于生成测试数据,而不用于应用程序的任何其他部分。
对于本章,我需要一种机制,这样您就可以可靠地生成大量的测试数据,而不需要复杂的数据库任务或使用第三方工具。(有一些优秀的商业工具可用来生成SQl数据,但它们通常需要几百行的许可证。)
直接使用SQl应该非常小心,因为它绕过了entity Framework Core提供的许多有用的保护,而且很难测试和维护,而且常常在单个数据库服务器上工作。您还应该避免在c#代码中创建存储过程,为了简单省事,我在清单8-2中已经这样做了。
简而言之,不要在应用程序的生产部分中使用此技术的任何方面。
为了向控制器提供视图,我创建了Views/Seed文件夹,并向其添加了一个Index.cshtml文件,内容如清单8-3所示。
1 Listing 8-3. The Contents of the Index.cshtml File in the Views/Seed Folder 2 @model IEnumerable<Product> 3 <h3 class="p-2 bg-primary text-white text-center">Seed Data</h3> 4 <form method="post"> 5 <div class="form-group"> 6 <label>Number of Objects to Create</label> 7 <input class="form-control" name="count" value="50" /> 8 </div> 9 <div class="text-center"> 10 <button type="submit" asp-action="CreateSeedData" class="btn btn-primary"> 11 Seed Database 12 </button> 13 <button asp-action="ClearData" class="btn btn-danger"> 14 Clear Database 15 </button> 16 </div> 17 </form> 18 <h5 class="text-center m-2"> 19 There are @ViewBag.Count products in the database 20 </h5> 21 <div class="container-fluid"> 22 <div class="row"> 23 <div class="col-1 font-weight-bold">Id</div> 24 <div class="col font-weight-bold">Name</div> 25 <div class="col font-weight-bold">Category</div> 26 <div class="col font-weight-bold text-right">Purchase</div> 27 <div class="col font-weight-bold text-right">Retail</div> 28 </div> 29 @foreach (Product p in Model) 30 { 31 <div class="row"> 32 <div class="col-1">@p.Id</div> 33 <div class="col">@p.Name</div> 34 <div class="col">@p.Category.Name</div> 35 <div class="col text-right">@p.PurchasePrice</div> 36 <div class="col text-right">@p.RetailPrice</div> 37 </div> 38 } 39 </div>
该视图允许您指定应该生成多少测试数据,并显示前20个产品对象,这些对象由种子控制器的Index操作(Action方法)中的查询提供。为了使Seed控制器更易于使用,我将清单8-4中所示的元素添加到共享布局中。
1 Listing 8-4. Adding an Element in the _Layout.cshtml File in the Views/Shared Folder 2 <!DOCTYPE html> 3 <html> 4 <head> 5 <meta name="viewport" content="width=device-width" /> 6 <title>SportsStore</title> 7 <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> 8 <style> 9 .placeholder { 10 visibility: collapse; 11 display: none 12 } 13 14 .placeholder:only-child { 15 visibility: visible; 16 display: flex 17 } 18 </style> 19 </head> 20 <body> 21 <div class="container-fluid"> 22 <div class="row p-2"> 23 <div class="col-2"> 24 <a asp-controller="Home" asp-action="Index" 25 class="@GetClassForButton("Home")"> 26 Products 27 </a> 28 <a asp-controller="Categories" asp-action="Index" 29 class="@GetClassForButton("Categories")"> 30 Categories 31 </a> 32 <a asp-controller="Orders" asp-action="Index" 33 class="@GetClassForButton("Orders")"> 34 Orders 35 </a> 36 <a asp-controller="Seed" asp-action="Index" 37 class="@GetClassForButton("Seed")"> 38 Seed Data 39 </a> 40 </div> 41 <div class="col"> 42 @RenderBody() 43 </div> 44 </div> 45 </div> 46 </body> 47 </html> 48 @functions 49 { 50 string GetClassForButton(string controller) 51 { 52 return "btn btn-block " + (ViewContext.RouteData.Values["controller"] 53 as string == controller ? "btn-primary" : "btn-outline-primary"); 54 } 55 }
使用dotnet run启动应用程序,导航到http://localhost:5000,然后单击Seed Data按钮。将input元素中的值设置为1000,然后单击Seed Database按钮。生成数据需要一些时间,之后您将看到如图8-1所示的结果。
小贴士:测试数据的价格值是随机生成的,这意味着您可能得到一些与示例稍微不同的结果。

8.2 扩大数据规模
不需要太多数据就可以展示出SportsStore应用程序,在呈现数据的方式上的缺陷。录入1000个对象,数据呈现给用户的方式就变得不可用了,对于多数应用程序来说,这仍然是很少的数据量。在下面的小节中,我将更改SportsStore应用程序显示数据的方式(即分页显示),以帮助用户执行基本操作并定位所需的对象。
8.2.1 添加分页功能
我要解决的第一个问题是分页显示数据,这样它就不是一个长长的列表。在构建应用程序的基础时,使用显示出所有对象的简单表格是一种有用的方式,但是显示数千行的表格在大多数应用程序中不是这么干的。为了解决这个问题,我将添加对查询数据库中更少的数据的支持,并允许用户在分页中导航(上页、下页、第几页...),每页显示更少的数据。
在处理大量数据时,重要的是确保对这些数据的访问是一致的,这样应用程序的某个部分就不会意外地查询数百万个对象。我将要采用的方式是创建一个用于分页功能的集合类。
8.2.1.1 创建分页的容器类
为了定义用于分页访问的集合类,我创建了Models/Pages文件夹,并向其添加了一个名为PagedList.cs的类。并使用它来定义如清单8-5所示的类。(译者述:实际上网上有很多第三方的分页支持包可以拿来用)
1 //Listing 8-5. The Contents of the PagedList.cs File in the Models/Pages Folder 2 using System.Collections.Generic; 3 using System.Linq; 4 namespace SportsStore.Models.Pages 5 { 6 public class PagedList<T> : List<T> 7 { 8 public PagedList(IQueryable<T> query, QueryOptions options = null) 9 { 10 CurrentPage = options.CurrentPage; //想显示第几页(即当前页) 11 PageSize = options.PageSize; //每页显示多少条 12 TotalPages = query.Count() / PageSize; //第一条SQL,获取总条数 13 AddRange(query.Skip((CurrentPage - 1) * PageSize).Take(PageSize));//第二条SQL,获取当前页面的几行记录 14 } 15 public int CurrentPage { get; set; } 16 public int PageSize { get; set; } 17 public int TotalPages { get; set; } 18 public bool HasPreviousPage => CurrentPage > 1; 19 public bool HasNextPage => CurrentPage < TotalPages; 20 } 21 }
我使用了一个强类型List<T>作为基类,这将使我能够轻松地继承基类行为并扩展。构造函数接受IQueryable<T>,它表示一个SQL查询。这个查询将执行两次——一次是获取需要查询的对象总个数(就是所有分页里记录条数的总和),一次是仅获取当前页面上的对象。这是分页中固有的权衡,在分页中,增加额外的COUNT查询和较少对象的全部查询耗时一样少。构造函数其他参数,用来指定查询第几页和每个页面显示多少条记录。
为了表示查询所需的选项,我添加了一个名为QueryOptions.cs类文件,放在Models/Pages文件夹,代码如清单8-6所示。
1 //Listing 8-6. The Contents of the QueryOptions.cs File in the Models/Pages Folder 2 namespace SportsStore.Models.Pages 3 { 4 public class QueryOptions 5 { 6 public int CurrentPage { get; set; } = 1; //要查看第几页 7 public int PageSize { get; set; } = 10; //每页要显示几条记录 8 } 9 }
8.2.1.2 更改仓储
为了确保分页的使用是一致的,我将在仓储类中让执行查询的方法返回一个PagedList对象。如清单8-7中,我添加了一个名为GetProduct的方法,它返回一个分页。
1 //Listing 8-7. Returning Pages of Data in the IRepository.cs File in the Models Folder 2 using System.Collections.Generic; 3 using SportsStore.Models.Pages; 4 namespace SportsStore.Models 5 { 6 public interface IRepository 7 { 8 IEnumerable<Product> Products { get; } 9 10 PagedList<Product> GetProducts(QueryOptions options); 11 12 Product GetProduct(long key); 13 void AddProduct(Product product); 14 void UpdateProduct(Product product); 15 void UpdateAll(Product[] products); 16 void Delete(Product product); 17 } 18 }
在清单8-8中,我对存储库的实现类做了相应的更改。
1 //Listing 8-8. Returning Pages of Data in the DataRepository.cs File in the Models Folder 2 using System.Collections.Generic; 3 using System.Linq; 4 using Microsoft.EntityFrameworkCore; 5 using SportsStore.Models.Pages; 6 namespace SportsStore.Models 7 { 8 public class DataRepository : IRepository 9 { 10 private DataContext context; 11 public DataRepository(DataContext ctx) => context = ctx; 12 public IEnumerable<Product> Products => context.Products 13 .Include(p => p.Category).ToArray(); 14 public PagedList<Product> GetProducts(QueryOptions options) 15 { 16 return new PagedList<Product>(context.Products 17 .Include(p => p.Category), options); 18 } 19 // ..other methods omitted for brevity... 20 } 21 }
新方法返回一个PagedList 集合,里面存的是由参数指定第几页的若干产品对象。
8.2.1.3 更改控制器和视图
为了向Home控制器添加分页支持,我更新了Index操作(Action方法),以便它接受选择分页所需的参数,并使用新的存储库方法,如清单8-9所示。
1 //Listing 8-9. Using Paged Data in the HomeController.cs File in the Controllers Folder 2 using Microsoft.AspNetCore.Mvc; 3 using SportsStore.Models; 4 using SportsStore.Models.Pages; 5 namespace SportsStore.Controllers 6 { 7 public class HomeController : Controller 8 { 9 private IRepository repository; 10 private ICategoryRepository catRepository; 11 public HomeController(IRepository repo, ICategoryRepository catRepo) 12 { 13 repository = repo; 14 catRepository = catRepo; 15 } 16 public IActionResult Index(QueryOptions options) 17 { 18 return View(repository.GetProducts(options)); 19 } 20 public IActionResult UpdateProduct(long key) 21 { 22 ViewBag.Categories = catRepository.Categories; 23 return View(key == 0 ? new Product() : repository.GetProduct(key)); 24 } 25 [HttpPost] 26 public IActionResult UpdateProduct(Product product) 27 { 28 if (product.Id == 0) 29 { 30 repository.AddProduct(product); 31 } 32 else 33 { 34 repository.UpdateProduct(product); 35 } 36 return RedirectToAction(nameof(Index)); 37 } 38 [HttpPost] 39 public IActionResult Delete(Product product) 40 { 41 repository.Delete(product); 42 return RedirectToAction(nameof(Index)); 43 } 44 } 45 }
分页集合的基类(即本章一开始示例代码中的List<T>)实现了IEnumerable<T>接口,该接口定义了最小化的功能。Home控制器的Index操作(Action方法)对应的视图,唯一要做的更改是,显示一个分部视图,提供分页的详细信息。如清单8-10所示。视图的其余部分不需要更改,因为它将以相同的方式遍历(列举)数据,而不管它操作的序列是查询的所有数据还是仅是其中的一个分页。
1 Listing 8-10. Using a Partial View in the Index.cshtml File in the Views/Home Folder 2 @model IEnumerable<Product> 3 <h3 class="p-2 bg-primary text-white text-center">Products</h3> 4 <div class="text-center"> 5 @Html.Partial("Pages", Model) 6 </div> 7 <div class="container-fluid mt-3"> 8 <div class="row"> 9 <div class="col-1 font-weight-bold">Id</div> 10 <div class="col font-weight-bold">Name</div> 11 <div class="col font-weight-bold">Category</div> 12 <div class="col font-weight-bold text-right">Purchase Price</div> 13 <div class="col font-weight-bold text-right">Retail Price</div> 14 <div class="col"></div> 15 </div> 16 @foreach (Product p in Model) 17 { 18 <div class="row p-2"> 19 <div class="col-1">@p.Id</div> 20 <div class="col">@p.Name</div> 21 <div class="col">@p.Category.Name</div> 22 <div class="col text-right">@p.PurchasePrice</div> 23 <div class="col text-right">@p.RetailPrice</div> 24 <div class="col"> 25 <form asp-action="Delete" method="post"> 26 <a asp-action="UpdateProduct" asp-route-key="@p.Id" 27 class="btn btn-outline-primary"> 28 Edit 29 </a> 30 <input type="hidden" name="Id" value="@p.Id" /> 31 <button type="submit" class="btn btn-outline-danger"> 32 Delete 33 </button> 34 </form> 35 </div> 36 </div> 37 } 38 <div class="text-center p-2"> 39 <a asp-action="UpdateProduct" asp-route-key="0" 40 class="btn btn-primary">Add</a> 41 </div> 42 </div>
为了完成对产品对象的分页支持,我在Views/Shared 文件夹下添加了一个Pages.cshtml文件,定义了分部视图。见清单8-11。
1 //Listing 8-11. The Contents of the Pages.cshtml File in the Views/Shared Folder 2 <form id="pageform" method="get" class="form-inline d-inline-block"> 3 <button name="options.currentPage" value="@(Model.CurrentPage -1)" 4 class="btn btn-outline-primary @(!Model.HasPreviousPage ? "disabled" : "")" 5 type="submit"> 6 Previous 7 </button> 8 @for (int i = 1; i <= 3 && i <= Model.TotalPages; i++) 9 { 10 <button name="options.currentPage" value="@i" type="submit" 11 class="btn btn-outline-primary @(Model.CurrentPage == i ? "active" : "")"> 12 @i 13 </button> 14 } 15 @if (Model.CurrentPage > 3 && Model.TotalPages - Model.CurrentPage >= 3) 16 { 17 @:... 18 <button class="btn btn-outline-primary active">@Model.CurrentPage</button> 19 } 20 @if (Model.TotalPages > 3) 21 { 22 @:... 23 @for (int i = Math.Max(4, Model.TotalPages - 2); 24 i <= Model.TotalPages; i++) 25 { 26 <button name="options.currentPage" value="@i" type="submit" 27 class="btn btn-outline-primary 28 @(Model.CurrentPage == i ? "active" : "")"> 29 @i 30 </button> 31 } 32 } 33 <button name="options.currentPage" value="@(Model.CurrentPage +1)" type="submit" 34 class="btn btn-outline-primary @(!Model.HasNextPage? "disabled" : "")"> 35 Next 36 </button> 37 <select name="options.pageSize" class="form-control ml-1 mr-1"> 38 @foreach (int val in new int[] { 10, 25, 50, 100 }) 39 { 40 <option value="@val" selected="@(Model.PageSize == val)">@val</option> 41 } 42 </select> 43 <input type="hidden" name="options.currentPage" value="1" /> 44 <button type="submit" class="btn btn-secondary">Change Page Size</button> 45 </form>
该视图包含一个HTML表单,用于将GET请求发送回action方法,请求获取分页数据,并指定每页显示条数。Razor表达式看起来很凌乱,但它们根据可用页面的数量调整了显示给用户的分页按钮。要查看效果,请启动应用程序,执行dotnet run命令并导航到http://localhost:5000。产品列表将被分成10个条目的页面,可以使用一系列按钮进行分页,如图8-2所示。

8.2.2 搜索和排序
分页显示是一个很好的开始,但是仍然很难将注意力集中到一组特定的对象上。为了给用户提供快速定位,我将在分页的基础上添加排序和搜索功能。
首先扩展PagedList类,这样就可以使用属性名对查询结果搜索和排序,而不是使用lambda表达式选择属性。如清单8-12所示。这需要一些复杂的代码来执行操作,但是结果可以应用到任何数据模型类,并且更容易与应用程序的ASP.NET Core MVC部分进行集成。
1 //Listing 8-12. Adding Features in the PagedList.cs File in the Models Pages Folder 2 using System.Collections.Generic; 3 using System.Linq; 4 using System; 5 using System.Linq.Expressions; 6 namespace SportsStore.Models.Pages 7 { 8 public class PagedList<T> : List<T> 9 { 10 public PagedList(IQueryable<T> query, QueryOptions options = null) 11 { 12 CurrentPage = options.CurrentPage; 13 PageSize = options.PageSize; 14 Options = options; 15 if (options != null) 16 { 17 if (!string.IsNullOrEmpty(options.OrderPropertyName)) 18 { 19 query = Order(query, options.OrderPropertyName, 20 options.DescendingOrder); 21 } 22 23 if (!string.IsNullOrEmpty(options.SearchPropertyName) 24 && !string.IsNullOrEmpty(options.SearchTerm)) 25 { 26 query = Search(query, options.SearchPropertyName, 27 options.SearchTerm); 28 } 29 } 30 TotalPages = query.Count() / PageSize; 31 AddRange(query.Skip((CurrentPage - 1) * PageSize).Take(PageSize)); 32 } 33 public int CurrentPage { get; set; } 34 public int PageSize { get; set; } 35 public int TotalPages { get; set; } 36 public QueryOptions Options { get; set; } 37 public bool HasPreviousPage => CurrentPage > 1; 38 public bool HasNextPage => CurrentPage < TotalPages; 39 private static IQueryable<T> Search(IQueryable<T> query, string propertyName, 40 string searchTerm) 41 { 42 var parameter = Expression.Parameter(typeof(T), "x"); 43 var source = propertyName.Split('.').Aggregate((Expression)parameter, 44 Expression.Property); 45 var body = Expression.Call(source, "Contains", Type.EmptyTypes, 46 Expression.Constant(searchTerm, typeof(string))); 47 var lambda = Expression.Lambda<Func<T, bool>>(body, parameter); 48 49 return query.Where(lambda); 50 } 51 private static IQueryable<T> Order(IQueryable<T> query, string propertyName, 52 bool desc) 53 { 54 var parameter = Expression.Parameter(typeof(T), "x"); 55 var source = propertyName.Split('.').Aggregate((Expression)parameter, 56 Expression.Property); 57 var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(typeof(T), 58 source.Type), source, parameter); 59 60 return typeof(Queryable).GetMethods().Single( 61 method => method.Name == (desc ? "OrderByDescending" 62 : "OrderBy") 63 && method.IsGenericMethodDefinition 64 && method.GetGenericArguments().Length == 2 65 && method.GetParameters().Length == 2) 66 .MakeGenericMethod(typeof(T), source.Type) 67 .Invoke(null, new object[] { query, lambda }) as IQueryable<T>; 68 } 69 } 70 }
清单8-13显示了对QueryOptions类的相应更改。
1 //Listing 8-13. Adding Properties in the QueryOptions.cs File in the Models/Pages Folder 2 namespace SportsStore.Models.Pages 3 { 4 public class QueryOptions 5 { 6 public int CurrentPage { get; set; } = 1; 7 public int PageSize { get; set; } = 10; 8 public string OrderPropertyName { get; set; } 9 public bool DescendingOrder { get; set; } 10 public string SearchPropertyName { get; set; } 11 public string SearchTerm { get; set; } 12 } 13 }
为了创建将搜索和排序选项呈现给用户的通用视图,我在Views/Shared文件夹里添加了一个名为PageOptions.cshtml的文件。并添加了如清单8-14所示的内容。
1 Listing 8-14. The Contents of the PageOptions.cshtml File in the Views/Shared Folder 2 <div class="container-fluid mt-2"> 3 <div class="row m-1"> 4 <div class="col"></div> 5 <div class="col-1"> 6 <label class="col-form-label">Search</label> 7 </div> 8 <div class="col-3"> 9 <select form="pageform" name="options.searchpropertyname" 10 class="form-control"> 11 @foreach (string s in ViewBag.searches as string[]) 12 { 13 <option value="@s" 14 selected="@(Model.Options.SearchPropertyName == s)"> 15 @(s.IndexOf('.') == -1 ? s : s.Substring(0, s.IndexOf('.'))) 16 </option> 17 } 18 </select> 19 </div> 20 <div class="col"> 21 <input form="pageform" class="form-control" name="options.searchterm" 22 value="@Model.Options.SearchTerm" /> 23 </div> 24 <div class="col-1 text-right"> 25 <button form="pageform" class="btn btn-secondary" type="submit"> 26 Search 27 </button> 28 </div> 29 <div class="col"></div> 30 </div> 31 <div class="row m-1"> 32 <div class="col"></div> 33 <div class="col-1"> 34 <label class="col-form-label">Sort</label> 35 </div> 36 <div class="col-3"> 37 <select form="pageform" name="options.OrderPropertyName" 38 class="form-control"> 39 @foreach (string s in ViewBag.sorts as string[]) 40 { 41 <option value="@s" 42 selected="@(Model.Options.OrderPropertyName == s)"> 43 @(s.IndexOf('.') == -1 ? s : s.Substring(0, s.IndexOf('.'))) 44 </option> 45 } 46 </select> 47 </div> 48 <div class="col form-check form-check-inline"> 49 <input form="pageform" type="checkbox" name="Options.DescendingOrder" 50 id="Options.DescendingOrder" 51 class="form-check-input" value="true" 52 checked="@Model.Options.DescendingOrder" /> 53 <label class="form-check-label">Descending Order</label> 54 </div> 55 <div class="col-1 text-right"> 56 <button form="pageform" class="btn btn-secondary" type="submit"> 57 Sort 58 </button> 59 </div> 60 <div class="col"></div> 61 </div> 62 </div>
这个视图依赖于HTML 5一个特性,将表单与它之外的元素相关联,这意味着我可以使用特定的搜索和排序元素扩展在Pages视图中定义的表单。
我不想硬编码搜索或排序数据用的属性名列表,因此,为了简单起见,我从ViewBag中获取这些值。这不是一个理想的解决方案,但是它确实提供了很大的灵活性,并且允许我轻松地将相同的代码(ViewBag是个动态对象,不同的场合里面的属性可以是动态的)适应不同的视图和数据。为了在产品列表旁边向用户显示搜索和排序元素,我将清单8-15所示的内容添加到Home控制器使用的Index视图中。
1 Listing 8-15. Displaying the Product Features in the Index.cshtml File in the Views/Home Folder 2 @model IEnumerable<Product> 3 <h3 class="p-2 bg-primary text-white text-center">Products</h3> 4 <div class="text-center"> 5 @Html.Partial("Pages", Model) 6 @{ 7 ViewBag.searches = new string[] { "Name", "Category.Name" }; 8 ViewBag.sorts = new string[] { "Name", "Category.Name", "PurchasePrice", "RetailPrice"}; 9 } 10 @Html.Partial("PageOptions", Model) 11 </div> 12 <div class="container-fluid mt-3"> 13 <!-- ...other elements omitted for brevity... --> 14 </div>
代码块用来指定产品的属性,如此一来用户就能够搜索和订购产品对象,而@Html.Partial
表达式为这些功能渲染出元素。
要查看结果,请使用dotnet run启动应用程序并导航到http://localhost:5000。您将看到一系列新的元素,它们使数据导航更加容易,如图8-3所示。

8.2.3 将数据表示特性应用于Category
将分页、搜索和排序功能放置的过程一直很笨拙,但是现在基础功能已经OK了,我可以将它们应用于应用程序中的其他数据类型,例如管理Category对象。首先,我修改了repository接口和实现类,以添加一个接受QueryOptions对象并返回PagedList结果的方法,如清单8-16所示。
1 //Listing 8-16. Adding Page Support in the CategoryRepository.cs File in the Models Folder 2 using System.Collections.Generic; 3 using SportsStore.Models.Pages; 4 namespace SportsStore.Models 5 { 6 public interface ICategoryRepository 7 { 8 IEnumerable<Category> Categories { get; } 9 PagedList<Category> GetCategories(QueryOptions options); 10 void AddCategory(Category category); 11 void UpdateCategory(Category category); 12 void DeleteCategory(Category category); 13 } 14 public class CategoryRepository : ICategoryRepository 15 { 16 private DataContext context; 17 public CategoryRepository(DataContext ctx) => context = ctx; 18 public IEnumerable<Category> Categories => context.Categories; 19 public PagedList<Category> GetCategories(QueryOptions options) 20 { 21 return new PagedList<Category>(context.Categories, options); 22 } 23 public void AddCategory(Category category) 24 { 25 context.Categories.Add(category); 26 context.SaveChanges(); 27 } 28 public void UpdateCategory(Category category) 29 { 30 context.Categories.Update(category); 31 context.SaveChanges(); 32 } 33 public void DeleteCategory(Category category) 34 { 35 context.Categories.Remove(category); 36 context.SaveChanges(); 37 } 38 } 39 }
在清单8-17中,我向管理Category对象的控制器的Index操作(Action方法)添加了QueryOptions形参,并使用它查询存储库。
1 //Listing 8-17. Adding Page Support in the CategoriesController.cs File in the Controllers Folder 2 using Microsoft.AspNetCore.Mvc; 3 using SportsStore.Models; 4 using SportsStore.Models.Pages; 5 namespace SportsStore.Controllers 6 { 7 public class CategoriesController : Controller 8 { 9 private ICategoryRepository repository; 10 public CategoriesController(ICategoryRepository repo) => repository = repo; 11 public IActionResult Index(QueryOptions options) => View(repository.GetCategories(options)); 12 13 [HttpPost] 14 public IActionResult AddCategory(Category category) 15 { 16 repository.AddCategory(category); 17 return RedirectToAction(nameof(Index)); 18 } 19 public IActionResult EditCategory(long id) 20 { 21 ViewBag.EditId = id; 22 return View("Index", repository.Categories); 23 } 24 [HttpPost] 25 public IActionResult UpdateCategory(Category category) 26 { 27 repository.UpdateCategory(category); 28 return RedirectToAction(nameof(Index)); 29 } 30 [HttpPost] 31 public IActionResult DeleteCategory(Category category) 32 { 33 repository.DeleteCategory(category); 34 return RedirectToAction(nameof(Index)); 35 } 36 } 37 }
最后,将清单8-18所示的元素添加到Categories控制器使用的Index视图中,就可以向用户展示这些功能了。
1 Listing 8-18. Adding Features in the Index.cshtml File in the Views/Categories Folder 2 @model IEnumerable<Category> 3 <h3 class="p-2 bg-primary text-white text-center">Categories</h3> 4 <div class="text-center"> 5 @Html.Partial("Pages", Model) 6 @{ 7 ViewBag.searches = new string[] { "Name", "Description" }; 8 ViewBag.sorts = new string[] { "Name", "Description" }; 9 } 10 @Html.Partial("PageOptions", Model) 11 </div> 12 <div class="container-fluid mt-3"> 13 <!-- ...other elements omitted for brevity... --> 14 </div>
要查看新功能,请启动应用程序,导航到http://localhost:5000,并单击Category按钮。类别列表分页显示,用户可以根据需要进行搜索和排序,如图8-4所示。

8.3 索引数据库
向数据库中添加1000个测试对象,足以暴露应用程序中数据呈现给用户的方式的伸缩性限制,但不足以暴露数据库的限制。为了查看处理大量数据的效果,我在PagedList构造函数中添加了语句,这些语句测量执行查询所需的时间,并将执行耗时写入控制台,如清单8-19所示。
小贴士:有许多方法可以测量性能,大多数数据库服务器都有小工具可以帮助您了解执行查询需要多长时间。对于SQl Server数据库服务,有SQl Server profiler和SQl Server Management Studio等工具可以提供大量详细信息。这些工具很有用,但我通常用清单8-19中的方式,因为它足够简单和准确,可以理解任何性能问题的量级。
1 //Listing 8-19. Timing the Query in the PagedList.cs File in the Models/Pages Folder 2 using System.Collections.Generic; 3 using System.Linq; 4 using System; 5 using System.Linq.Expressions; 6 using System.Diagnostics; 7 namespace SportsStore.Models.Pages 8 { 9 public class PagedList<T> : List<T> 10 { 11 public PagedList(IQueryable<T> query, QueryOptions options = null) 12 { 13 CurrentPage = options.CurrentPage; 14 PageSize = options.PageSize; 15 Options = options; 16 if (options != null) 17 { 18 if (!string.IsNullOrEmpty(options.OrderPropertyName)) 19 { 20 query = Order(query, options.OrderPropertyName, 21 options.DescendingOrder); 22 } 23 if (!string.IsNullOrEmpty(options.SearchPropertyName) 24 && !string.IsNullOrEmpty(options.SearchTerm)) 25 { 26 query = Search(query, options.SearchPropertyName, 27 options.SearchTerm); 28 } 29 } 30 Stopwatch sw = Stopwatch.StartNew(); 31 Console.Clear(); 32 TotalPages = query.Count() / PageSize; 33 AddRange(query.Skip((CurrentPage - 1) * PageSize).Take(PageSize)); 34 Console.WriteLine($"Query Time: {sw.ElapsedMilliseconds} ms"); 35 } 36 // ...other members omitted for brevity... 37 } 38 }
1 ... 2 SELECT COUNT(*) 3 FROM [Products] AS [p] 4 ... 5 SELECT [p].[Id], [p].[CategoryId], [p].[Name], [p].[PurchasePrice], 6 [p].[RetailPrice], [p.Category].[Id], [p.Category].[Description], 7 [p.Category].[Name 8 FROM [Products] AS [p] 9 INNER JOIN [Categories] AS [p.Category] ON [p].[CategoryId] = [p.Category].[Id] 10 ORDER BY [p].[PurchasePrice] DESC 11 OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY 12 ... 13 Query Time: 14 ms 14 ...
表8-1显示了我在开发机上对不同数量的测试数据执行这些查询所需的时间。在执行测试时,您可能会看到不同的时间,但重要的是执行查询所需的时间随着数据量的增加而增加。

8.3.1 创建和应用索引
1 //Listing 8-20. Creating Indexes in the DataContext.cs File in the Models Folder 2 using Microsoft.EntityFrameworkCore; 3 namespace SportsStore.Models 4 { 5 public class DataContext : DbContext 6 { 7 public DataContext(DbContextOptions<DataContext> opts) : base(opts) { } 8 public DbSet<Product> Products { get; set; } 9 public DbSet<Category> Categories { get; set; } 10 public DbSet<Order> Orders { get; set; } 11 public DbSet<OrderLine> OrderLines { get; set; } 12 13 protected override void OnModelCreating(ModelBuilder modelBuilder) 14 { 15 modelBuilder.Entity<Product>().HasIndex(p => p.Name); 16 modelBuilder.Entity<Product>().HasIndex(p => p.PurchasePrice); 17 modelBuilder.Entity<Product>().HasIndex(p => p.RetailPrice); 18 modelBuilder.Entity<Category>().HasIndex(p => p.Name); 19 modelBuilder.Entity<Category>().HasIndex(p => p.Description); 20 } 21 } 22 }
小贴士: 当数据库中已有大量数据时,应用创建索引的迁移可能需要一些时间,因为所有的现有数据(指定了索引的列的值)都必须添加到索引中。在执行迁移命令之前,您应该使用Seed控制器减少录入测试数据的数量。(没创建索引之前,测试数据应该少录入一些,不然创建索引时会需要等待较长时间)
Listing 8-21. Creating and Applying a Database Migration
dotnet ef migrations add Indexes
dotnet ef database update
应用迁移之后,重新启动应用程序并重复查询测试,以查看对性能的影响。表8-2显示了为我的PC添加索引之前和之后的查询时间。

理解Count查询的性能
随着数据量的增加,所花费的时间仍有小幅增加。我查询数据库中数据条数的语句被转换成SQL的Select Count语句,对于大量数据,该命令的性能会下降。数据库服务通常提供其他计数方法,对于SQL Server,您可以查询数据库服务维护的关于数据库的元数据,如下所示:
... select sum (spart.rows) from sys.partitions spart where spart.object_id = object_id('Products') and spart.index_id < 2 ...
无法使用LINQ执行此类查询。如何直接执行SQL语句的EF Core功能,详见第23章。
8.4 常见错误和解决方案
扩大应用程序的数据规模需要认真调整ASP.NET Core MVC部分的代码,尽量让EF Core处理较少的数据,并为数据的排序和搜索提供工具。在下面的部分中,我将描述您最可能遇到的问题,并解释如何解决它们。
8.4.1 分页查询太慢
Queries for Pages Are Too Slow
8.4.2 应用索引迁移超时
8.4.3 创建索引没能提高性能
8.5 本章小结
在本章中,我展示了如何调整SportsStore应用程序来处理大量数据。我添加了对分页、排序和搜索数据的支持,这允许用户一次能处理可调整数量的对象。我还使用Fluent API定制数据模型并添加索引以提高查询性能。 在下一章中,我将向SportsStore应用程序添加面向客户的特性。
浙公网安备 33010602011771号