第五章 SportsStore数据存储

  在本章中,我将演示如何把SportsStore应用程序的数据存储到数据库中。我将向您展示如何向项目中添加EF Core,如何准备数据模型Data Model,如何创建和使用数据库,以及如何调整应用程序,使其能够进行高效的SQL查询。我还将描述向项目中添加实体框架核心时最可能遇到的问题,并解释如何解决这些问题。

5.1准备本章

  我继续使用在第4章中创建的SportsStore项目,本章不需要更改。打开命令提示符或PowerShell窗口,导航到SportsStore项目文件夹(其中包含bower.json文件),并使用dotnet run命令启动应用程序。使用浏览器导航到http://localhost:5000,您将看到如图5-1所示的内容。您可以使用HTML表单存储产品对象,但是当应用程序停止或重新启动时,它们将丢失,因为数据只存储在内存中。

小贴士:您可以从这里下载SportsStore项目——以及本书所有其他章节的项目。https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc.

 

 

5.2 配置实体框架核心

  Visual Studio为ASP.NET Core项目创建的默认配置包含运行实体框架核心应用程序所需的NuGet包。另外还需要一个单独的包来添加管理数据库的命令行工具,必须手动安装它。在“解决方案资源管理器”窗口中右键单击“SportsStore”项目,从弹出菜单中选择“SportsStore.csproj”,并添加如清单5-1所示的配置。

 1 Listing 5-1. Adding a Package in the SportsStore.csproj File in the SportsStore Folder
 2 <Project Sdk="Microsoft.NET.Sdk.Web">
 3   <PropertyGroup>
 4     <TargetFramework>netcoreapp2.0</TargetFramework>
 5   </PropertyGroup>
 6   <ItemGroup>
 7     <Folder Include="wwwroot\" />
 8   </ItemGroup>
 9   <ItemGroup>
10     <PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.3" />
11     <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet"
12 Version="2.0.0" />
13   </ItemGroup>
14 </Project>

包含命令行工具的包必须使用DotNetCliToolReference元素(上面XML中)手动添加到项目。清单中显示的包中有dotnet ef命令,该命令用来管理EF Core项目的数据库。

 

5.2.1 配置EF Core的日志

  理解实体框架核心发送给数据库服务的SQL查询和命令是很重要的,即使在一个只存储少量数据的项目中也是如此。为了配置实体框架生成日志,以显示它使用的SQL查询,我在SportsStore项目文件夹添加了一个appsettings.json文件(使用ASP.NET配置文件项模板添加),并添加了清单5-2所示的配置语句。

//Listing 5-2. The Contents of the appsettings.json File in the SportsStore Folder
{
    "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=_CHANGE_ME;
Trusted_Connection=True;MultipleActiveResultSets=true"
   },
     
"Logging": { "LogLevel": { "Default": "None", "Microsoft.EntityFrameworkCore": "Information" } } }

Visual Studio对这种文件填写默认内容,包括数据库的连接字符串,稍后我将对此进行更改。清单5-2中高亮部分将默认日志级别设置为None,这将禁用所有日志。不过,若使用Microsoft.EntityFrameworkCore
(级别设置为Information),它将记录EF Core生成的SQL语句。您不必在实际项目中禁用所有其他日志消息,但是这种组合将使您更容易理解本示例。

(原文:The default content that Visual Studio uses for this type of file includes a connection string for a
database, which I’ll change shortly. The highlighted additions in Listing 5-2 set the default logging level to
None, which disables all logging messages. This is then overridden for the Microsoft.EntityFrameworkCore
package using the Information setting, which will provide details of the SQL that Entity Framework Core
uses. You don’t have to disable all other logging messages in real projects, but this combination will make it
easier to follow the examples.

 

5.3 准备数据模型

在下面的小节中,我仍使用SportsStore项目中已存在的数据模型,不过会用上EF Core。

5.3.1 定义主键属性

  要将数据存储在数据库中,实体框架核心需要能够惟一标识每个对象,这就需要有一个主键属性。对于大多数项目,定义主键最简单的方式是向数据模型类中添加一个名为Id的long类型属性,如清单5-3所示。

//Listing 5-3. Adding a Primary Key Property in the Product.cs File in the Models Folder
namespace SportsStore.Models {
    public class Product {
        public long Id { get; set; }   //主键
        public string Name { get; set; }
        public string Category { get; set; }
        public decimal PurchasePrice { get; set; }
        public decimal RetailPrice { get; set; }
    }
}

这意味着Entity Framework Core将配置数据库自动产生主键值,这样您就不必担心主键重复。使用long数据类型可以确保主键值有很大的取值范围,意味着大多数项目都可以存储无限条数据,而不用担心主键值用完。

 

5.3.2 创建数据库上下文类

  应用程序要使用EF Core访问数据库,必须创建“数据库上下文类”。我在Models文件夹添加了一个DataContext.cs文件(DataContext类)。其中的代码如清单5-4所示。

1 //Listing 5-4. The Contents of the DataContext.cs File in the Models Folder
2 using Microsoft.EntityFrameworkCore;
3 namespace SportsStore.Models {
4     public class DataContext : DbContext {
5         public DataContext(DbContextOptions<DataContext> opts) : base(opts) {}
6         public DbSet<Product> Products { get; set; }
7     }
8 }

  当您使用Entity Framework Core来存储一个简单的数据模型(如SportsStore应用程序中的数据模型)时,数据库上下文类也相应地很简单——尽管在后面的章节中,随着数据模型变得更加复杂,这种情况会发生变化。目前,数据库上下文类有三个重要特征。

  第一个特征是基类DbContext,它在Microsoft.EntityFrameworkCore名称空间中。使用DbContext作为基类可以继承访问实体框架核心的功能。

  第二个特征是构造器形参接受一个DbContextOptions <T>对象(其中T是上下文类),构造器必须使用base关键字将形参传递给基类的构造器,如下所示:

1 ...
2 public DataContext(DbContextOptions<DataContext> opts) : base(opts) { }
3 ...

构造器形参将为实体框架核心提供连接到数据库服务所需的配置。如果不定义该构造器形参或不传递对象,就会收到报错。

  第三个特征是类型为DbSet<T>的属性,其中T是将要存储在数据库中的类(数据模型类)。

1 ...
2 public DbSet<Product> Products { get; set; }
3 ...

数据模型类是Product,因此清单5-4中的属性返回一个DbSet<Product>对象。属性必须同时含有get和set子句。set子句允许实体框架核心分配一个对象,该对象提供对数据的便捷访问。get子句向应用程序的其余代码提供对该数据的访问。

 

5.3.3 更新仓储实现

下一步是更改仓储实现类,该类使用前一节定义的上下文类访问数据,如清单5-5所示。

 1 //Listing 5-5. Using the Context Class in the DataRepository.cs File in the Models Folder
 2 using System.Collections.Generic;
 3 namespace SportsStore.Models {
 4 public class DataRepository : IRepository {
 5         //private List<Product> data = new List<Product>();//之前存储在内存中
 6         private DataContext context;
 7         public DataRepository(DataContext ctx) => context = ctx;
 8         public IEnumerable<Product> Products => context.Products;
 9         
10         public void AddProduct(Product product) {
11             this.context.Products.Add(product);
12             this.context.SaveChanges();
13         }
14     }
15 }

  在一个ASP.NET Core MVC应用程序使用依赖注入管理对数据上下文对象的访问,我为DataRepository类添加了一个构造器,它的形参接受一个DataContext对象,该对象将由依赖注入在运行时提供。

  仓储接口定义的Product属性可以实现为:直接返回上下文对象的Products属性。类似地,AddProduct方法也很容易实现,因为上下文对象的Products属性(DbSet<Product>类型)有一个Add方法,该Add方法形参接受Product对象并且会持久化存储它们(即插入到数据库)。

  意义重大更改是对SaveChanges方法的调用,调用该方法告诉Entity Framework Core将任何未决(pending operations )操作(例如调用Add方法新增数据)发送到数据库执行。

 

5.4 准备数据库

  在下面几节中,我通过SportsStore应用程序的配置过程,来描述我要用的数据库,然后让Entity Framework Core创建这个数据库。这称为代码优先(Code First)项目——先从若干C#类开始,使用它们创建和配置数据库。另一种方法称为数据库优先(Database First)项目——从现有数据库创建数据模型——我会在第17和18章中描述这一过程。

5.4.1 配置连接字符串

  EF Core靠连接字符串为上下文类提供详细信息,即如何访问数据库服务。连接字符串的格式因库而异,但通常包含数据库服务器的服务器名和网络端口、数据库名和身份验证凭据。连接字符串在appsettings.json中定义。在清单5-6中,我为SportsStore数据库定义了连接字符串。在本书中,我使用的是LocalDB版本的SQL Server,它是专门为开发人员设计的,不需要任何配置或凭证。

小贴士:您必须把连接字符串写到同一行(不能换行)。因图书页面的固定宽度使显示连接字符串变得困难,但是如果将连接字符串拆分为多行虽使其更易于阅读,却会出现错误。

  连接字符串的格式因库而异。清单5-6中有4个配置属性用于配置连接字符串,列于表5-1。

 1 //Listing 5-6. Adding a Connection String in the appsettings.json File in the SportsStore Folder
 2 {
 3     "ConnectionStrings": {
 4         "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=SportsStore;Trusted_
 5             Connection=True;MultipleActiveResultSets=true"
 6      },
 7     "Logging": {
 8         "LogLevel": {
 9             "Default": "None",
10             "Microsoft.EntityFrameworkCore": "Information"
11         }
12      }
13 }

 

5.4.2 配置数据库提供程序和上下文类

  我将清单5-7所示的配置语句添加到Startup类中,以告知EF Core如何使用连接字符串、应该使用哪个数据库提供程序以及如何管理上下文类。

 1 //Listing 5-7. Configuring Entity Framework Core in the Startup.cs File in the SportsStore Folder
 2 using System;
 3 using System.Collections.Generic;
 4 using System.Linq;
 5 using System.Threading.Tasks;
 6 using Microsoft.AspNetCore.Builder;
 7 using Microsoft.AspNetCore.Hosting;
 8 using Microsoft.AspNetCore.Http;
 9 using Microsoft.Extensions.DependencyInjection;
10 using SportsStore.Models;
11 using Microsoft.EntityFrameworkCore;
12 using Microsoft.Extensions.Configuration;
13 namespace SportsStore {
14     public class Startup {
15         public Startup(IConfiguration config) => Configuration = config;
16         public IConfiguration Configuration { get; }
17         public void ConfigureServices(IServiceCollection services) {
18             services.AddMvc();
19             services.AddTransient<IRepository, DataRepository>();  //设置依赖注入绑定关系(及方式)
20             string conString = Configuration["ConnectionStrings:DefaultConnection"];
21             services.AddDbContext<DataContext>(options => options.UseSqlServer(conString));
22 23         }
24         public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
25             app.UseDeveloperExceptionPage();
26             app.UseStatusCodePages();
27             app.UseStaticFiles();
28             app.UseMvcWithDefaultRoute();
29         }
30     }
31 }

  构造器和Configuration属性用于访问appsettings.json配置文件中的配置,读取连接字符串。AddDbContext<T>扩展方法用于设置上下文类,告诉EF Core使用哪种数据库提供程序(本例中通过UseSqlServer方法,但是其他数据库提供程序因该调用不同的UseXXX方法),并提供连接字符串。

注意,我还为IRepository接口配置了依赖注入的实现类,如下所示:

1 ...
2 services.AddTransient<IRepository, DataRepository>(); //配置依赖注入的瞬态解析(即对于所有构造器形参为IRepository类型的,new一个DataRepository对象传入构造器)
3 ...

  在第4章中,我使用AddSingleton方法设置用单例DataRepository对象来解析IRepository接口上的所有依赖注入(即所有构造器形参为IRepository接口类型的类),这一点非常重要,因为当时应用程序数据存储在一个List字段中,我希望始终使用同一个对象。而现在我使用实体框架核心,我已经换为AddTransient方法,它确保每次解析IRepository的依赖注入时都创建一个新的DataRepository对象。这也很重要,因为在ASP.NET Core MVC应用程序中,EF Core应该为每个HTTP请求创建一个新的上下文对象。

 

5.4.3 创建数据库

上一节中的步骤告诉Entity Framework Core我想要存储的数据类型以及如何连接到数据库服务。下一步是创建数据库。

  Entity Framework Core通过一个名为migrations(迁移)的功能管理数据库,它是创建或修改数据库来和数据模型同步的一组更改(第13章详细描述)。要创建migration(由它来设置数据库),请打开一个新的命令提示符或PowerShell窗口,导航到SportsStore项目文件夹(包含bower.json的文件夹)。运行清单5-8所示的命令。

Listing 5-8. Creating a Migration
dotnet ef migrations add Initial

  dotnet ef命令访问清单5-1中添加的包中的功能。migrations add 参数告诉Entity Framework Core创建新的迁移,最后一个参数指定迁移的名称为Initial,这是首次配置数据库时惯用的迁移名称。

  当您运行清单5-8中的命令时,Entity Framework Core将检查项目,找到上下文类,并使用它创建migrations迁移,你可以看到在解决方案资源管理器中创建出一个Migrations文件夹,其中包含若干类,用来配置(创建)数据库。

  仅仅创建迁移是不够的,它只是一组指令而已。必须执行这些指令才能创建数据库,以便它能够存储应用程序数据。为执行Initial迁移中的指令,在SportsStore项目文件夹下运行清单5-9所示的命令。

小贴士:如果您已经遵循了本章中的示例,看到一个错误告诉您已经有一个名为Products的对象(即数据库表),那么在运行清单5-9中的命令之前,请在项目文件夹下运行dotnet ef database drop --force删除数据库。

Listing 5-9. Applying a Migration
dotnet ef database update

实体框架核心将连接到连接字符串中指定的数据库服务,并执行迁移中的指令。结果将创建一个用来存储Product对象的数据库。

 

5.5 运行应用程序

  对Product对象持久化(存储到数据库)的基本支持已经搞定,应用程序已经可以试试了,尽管还有一些工作要做。在SportsStore项目文件夹下使用dotnet run 命令启动应用程序。浏览器导航到http://localhost:5000,并在HTML表单中创建Product对象,使用表5-2所示的值。

为每组数据值单击Add按钮,Entity Framework Core将在数据库中存储该对象,生成如图5-2所示的结果。

用户体验没变不变,但在幕后,数据是由EF Core存储到数据库中的。使用dotnet run停止并重新启动应用程序,您输入的数据仍然可用。

 

5.6 避免查询陷阱

应用程序已经可以运行,数据已经存储到数据库。但要从实体框架核心中获得最佳效果,还有很多工作要做。特别是有两个常见的陷阱需要避免,当检查EF Core发给数据库执行的SQL语句时,可以发现这些问题。列表5-10中我在Home控制器的Index() Action方法里添加了一条语句,它帮我们更容易地看到由HTTP请求触发的查询。

 1 //Listing 5-10. Adding a Console Statement in the HomeController.cs File in the Controllers Folder
 2 using Microsoft.AspNetCore.Mvc;
 3 using SportsStore.Models;
 4 namespace SportsStore.Controllers
 5 {
 6     public class HomeController : Controller
 7     {
 8         private IRepository repository;
 9         public HomeController(IRepository repo) => repository = repo;
10         public IActionResult Index()
11         {
12             System.Console.Clear();
13             return View(repository.Products);
14         }
15         [HttpPost]
16         public IActionResult AddProduct(Product product)
17         {
18             repository.AddProduct(product);
19             return RedirectToAction(nameof(Index));
20         }
21     }
22 }

  当Index() Action方法被执行(Home\Index页面被请求)时,System.Console.Clear()可以清除控制台的内容,清除掉以前的SQL查询语句。启动应用程序,导航到http://localhost:5000,并检查控制台上显示的日志。

 

注意:只有当使用dotnet run从powerShell或命令提示符启动应用程序时,System.Console.Clear()方法才会工作。如果使用Visual Studio调试器启动应用程序,则会产生异常。

 

您将看到有两条日志,即发送到数据库的两个查询,如下所示:

 

1 ...
2 SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
3 FROM [Products] AS [p]
4 ...
5 SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
6 FROM [Products] AS [p]
7 ...

接下来的小节,我将解释为什么会有两个请求,为什么其中一个没有充分利用数据库服务的性能优势。

 

5.6.1 理解IEnumerable<T>陷阱

EF Core用上LINQ后,查询数据库变得很容易,尽管它并不总是按照您期望的那样工作。在Home控制器使用的Index视图中,我使用LINQ的Count方法来确定数据库中存储了多少Product对象,如下所示:

 1 ...
 2 @if (Model.Count() == 0) {
 3   <div class="row">
 4     <div class="col text-center p-2">No Data</div>
 5   </div>
 6 } else {
 7     @foreach (Product p in Model) {
 8         <div class="row p-2">
 9             <div class="col">@p.Name</div>
10             <div class="col">@p.Category</div>
11             <div class="col text-right">@p.PurchasePrice</div>
12             <div class="col text-right">@p.RetailPrice</div>
13             <div class="col"></div>
14         </div>
15     }
16 }
17 ...

  为了确定数据库中存储了多少产品对象,Entity Framework Core使用SQL SELECT语句获取所有的产品数据,使用这些数据创建一系列产品对象,然后对它们进行计数。一旦计数完成,内存中这些Product对象就会被丢弃。

  当数据库中只有三个对象时,这不是问题(不影响性能)。但是随着数据量的增加,以这种低效方式计算对象个数所需的工作量也会增加。一种更有效的方法是让数据库服务自己进行计数(用sql语句中的count函数),这将大大减掉EF Core传输所有数据(查询数据库)和创建对象的消耗。这可以通过对视图model类型的简单更改来实现,如清单5-11所示。

1 Listing 5-11. Changing the View Model in the Index.cshtml File in the Views/Home Folder
2 @model IQueryable<Product>  //注意清单4-7写的是 @model IEnumerable<Product>
3 <h3 class="p-2 bg-primary text-white text-center">Products</h3>
4 <div class="container-fluid mt-3">
5 <!-- ...other elements omitted for brevity... -->
6 </div>

如果重新加载浏览器窗口,您将看到EF Core发送到数据库服务的两个查询中的第一个SQL查询语句已更改。

1 ...
2 SELECT COUNT(*)
3 FROM [Products] AS [p]
4 ...
5 SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
6 FROM [Products] AS [p]
7 ...

  SELECT COUNT 让数据库服务对产品记录进行计数,而不会检索(取回)数据或在应用程序中创建产品对象。

  为不同的视图model类型(一个是IEnumerable<Product>,另一个是IQueryable<Product>类型)生成不同的查询似乎违反直觉,理解为什么会发生这种情况对于确保EF Core能够高效地查询数据库是至关重要的。

   LINQ是作为一组扩展方法实现的,这些扩展方法可以操作实现了IEnumerable<T>接口的(集合)对象。该接口表示一序列对象,一般是由泛型集合类和数组来实现的。

  实体框架核心包括一组重复的LINQ扩展方法,这些方法可以操作实现了IQueryable<T>接口的(结果集)对象。这个接口表示一个数据库查询,这些重复的方法意味着像Count这样的操作,访问数据库中的数据就像访问内存中的对象一样容易。

  清单5-4中创建的数据库上下文类中使用的DbSet类,同时实现了这两个接口,如Products属性(DbSet<Product>类型)就同时实现了IEnumerable<Product>和IQueryable<Product>接口。当Index视图中的视图模型(view model)被指定为IEnumerable<Product>类型时,标准版的Count方法被使用。该标准版Count实现不了解实体框架核心,只是按顺序计算对象个数。这将触发SELECT查询,并产生低效的行为,即读取所有数据并用于创建对象(集合中的元素),而后被计算一下个数,最后被丢弃。

  当我将视图模型(view model)更改为 IQueryable<Product>类型时,EF Core版的Count方法被使用。这个版本的方法允许Entity Framework Core将完整的查询转换成SQL语句,即生成了更高效的SQL语句——使用SELECT COUNT获取存储的对象的个数(记录条数而已),而不需要检索(取回)任何数据。

  译者述:简单地说IEnumerable<Product>接口里定义的方法,操作的是已加载到内存中的集合对象(以及集合里面的元素)。这些方法本身和SQL语句没有半毛钱关系,所以当执行Count方法之前需要先将查询的所有记录都加载到内存中,才好再做Count计数。。而IQueryable<Product>接口里定义的方法不是这样运行的,该接口的延迟加载方法只会产生和影响相应的SQL语句,直到执行该接口的立即加载方法时才会查询数据库,执行所产生的SQL语句。比如IQueryable<Product>接口里定义的Count会先产生SELECT COUNT...的SQL语句,之后发给数据库服务去执行。

理解Razor视图模型的类型转换

您可能会惊讶,我可以将视图模型对象视为一个 IQueryable<Product> 对象,尽管仓储类的Products属性返回值类型是IEnumerable<Product>。在编译视图时,Razor生成的c#类包含了对视图模型指定类型的显式转换,等效于在action方法中包含了以下这条语句:

1 ...
2 public IActionResult Index()
3 {
4     System.Console.Clear();
5     return View(repository.Products as IQueryable<Product>);
6 }
7 ...

在本例中,这个特性意味着我可以在视图的@model表达式中选择使用IQueryable<T>或IEnumerable<T>接口。类型转换是在运行时完成的,这就是为什么在应用程序运行之前,控制器提供的对象与视图所期望的之间的这个不一致情况,不会产生编译错误。

 

5.6.2 理解重复的查询陷阱

前面我们只是提高了其中一个查询的效率(从获取全部结果集再计数,改为在数据库服务器上执行计数),但仍没有解释为什么会有两个查询。我在上一节中解释过,DbSet<T>类实现了IQueryable<T>接口,它表示了一个对数据库的查询,甚至允许在数据库数据上使用LINQ查询。

  默认情况下,EF Core在你的代码遍历(列举)IQueryable<T>对象之前不会立即执行查询。这样允许逐步组织查询语句,而且还可以在已存在的查询上调用一个LINQ方法再创建一个新的查询,而不是只能在上一个查询已返回的数据上进行新查询。当然这也意味着每次遍历(列举)IQueryable<T>,都会向数据库发送新的SQL语句。对于某些应用程序很有帮助,因为这意味着您可以每次使用同一条LINQ查询从数据库中获取最新的数据,但是对于一个ASP.NET Core MVC应用程序,这通常会在几毫秒的时间内对相同的数据产生多个查询。

  在示例应用程序中,IQueryable<T>视图模型对象在Index视图中被遍历(列举)了两次,如下所示:

 1 ...
 2 @if (Model.Count() == 0) {
 3 <div class="row">
 4     <div class="col text-center p-2">No Data</div>
 5 </div>
 6 } else {
 7 @foreach (Product p in Model) {
 8 <div class="row p-2">
 9     <div class="col">@p.Name</div>
10     <div class="col">@p.Category</div>
11     <div class="col text-right">@p.PurchasePrice</div>
12     <div class="col text-right">@p.RetailPrice</div>
13     <div class="col"></div>
14 </div>
15 }
16 }
17 ...

  遍历(列举)集合不仅仅发生在foreach循环(循环开始之初查询数据库获取结果集,然后循环访问内存中的结果集),而且像Count这样产生单个结果的LINQ方法也会触发数据库查询。这就是为啥产生两条SQL语句。(第一条让我们优化过之后是Select Count,另一条是Select所有结果,前者用于上例的Model.Count(),后者用于foreach循环)

  现在,由于优化了第一条查询,性能似乎有所改善,但还可以进一步改进,后面部分再述。

 

避免意想不到的查询

如果你真是这样设计的,那么从一个IQueryable<T>对象触发多次查询并没有错。但是,您如果忘记IQueryable<T>对象的行为方式,以为它们是IEnumerable<T>对象(遍历不查询数据库),在不经意间进行查询,这才是应该注意的问题。因为在繁忙的应用程序中,意外查询所浪费的资源可能非常大,并且会增加性能上的成本。

 

5.6.2.1 避免使用CSS查询

上面的Index视图代码显示了ASP.NET Core MVC应用里导致重复请求(SQL查询数据库)的最常见原因之一。其中Count方法用于查看数据库表中是否存在指定的数据记录,以便向用户显示占位符内容(即:有数据记录显示数据,没记录显示“无数据”)。提供“无数据”占位符的另一种方式是通过CSS使其成为浏览器的责任。在清单5-12中,我向示例应用程序中的视图所使用的布局页中添加了一个CSS样式元素定义了两个自定义样式。

 1 Listing 5-12. Defining Styles 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="p-2">
22         @RenderBody()
23     </div>
24 </body>
25 </html>

分配给占位符HTML元素的class样式,visibility属性设为collapse 折叠,默认情况下display 属性设为none,这会使用户看不到它。但是,当HTML元素是其包含元素的唯一子元素时,属性值将被更改,这是通过使用only-child pseudoclass 样式实现的。在清单5-13中,我修改了Home控制器对应的Index视图,删除对LINQ Count方法的调用,转而使用CSS样式。

 1     Listing 5-13. Relying on CSS Classes in the Index.cshtml File in the Views/Home Folder
 2     @model IQueryable<Product>
 3     <h3 class="p-2 bg-primary text-white text-center">Products</h3>
 4     <div class="container-fluid mt-3">
 5         <div class="row">
 6             <div class="col font-weight-bold">Name</div>
 7             <div class="col font-weight-bold">Category</div>
 8             <div class="col font-weight-bold text-right">Purchase Price</div>
 9             <div class="col font-weight-bold text-right">Retail Price</div>
10             <div class="col"></div>
11         </div>
12         <form asp-action="AddProduct" method="post">
13             <div class="row">
14                 <div class="col"><input name="Name" class="form-control" /></div>
15                 <div class="col"><input name="Category" class="form-control" /></div>
16                 <div class="col">
17                     <input name="PurchasePrice" class="form-control" />
18                 </div>
19                 <div class="col">
20                     <input name="RetailPrice" class="form-control" />
21                 </div>
22                 <div class="col">
23                     <button type="submit" class="btn btn-primary">Add</button>
24                 </div>
25             </div>
26         </form>
27         <div>
28             <div class="row placeholder">
29                 <div class="col text-center p-2">No Data</div>
30             </div>
31             @foreach (Product p in Model) {
32             <div class="row p-2">
33                 <div class="col">@p.Name</div>
34                 <div class="col">@p.Category</div>
35                 <div class="col text-right">@p.PurchasePrice</div>
36                 <div class="col text-right">@p.RetailPrice</div>
37                 <div class="col"></div>
38             </div>
39             }
40         </div>
41     </div>

我添加了一个div元素,以便only-child元素能够正常工作(将.placeholder:only-child 样式应用到它上面),并且删除了调用了Count方法的if子句。结果是,分配了placeholder class 样式的占位符元素会始终存在于发送给浏览器的HTML中,但只有在foreach循环不生成任何元素时才可见,这种情况在数据库表中没有存储任何产品对象时才会发生。如果重新加载浏览器窗口,您将看到到现在就只有一个查询发送到数据库了。

译者述:已经把if判断干掉了,当然就只产生一条SQL查询了。而通过CSS可以使上面5-13中标黄代码<div>里面如果只剩<div class="row placeholder">独子元素(也就是说数据库表中无数据,foreach没创建出任何<div>行)时,显示样式是折叠起来的。。同样达到效果——没有记录时,显示空白。

...
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]
...

 

5.6.2.2 在仓储中强制查询

直接操作IQueryable<T>对象的问题是,数据存储如何实现的细节已经泄露到应用程序的其他部分,这破坏了MVC模式遵循的“功能分离”原则。

 

在现实与设计模式之间达到一个平衡

设计模式是构建易于理解、易于测试的项目的一套模板化的设计,但实际开发和设计模式之间需要达成一种平衡。不管你采用了哪个设计模式的哪个部分,也不管你忽略了哪个部分,只要是你主观决定的就行。

 

在示例应用程序中,我们既想用仓储模式(旨在隐藏数据存储的细节),又希望EF Core 与 ASP.NET Core MVC能很好地协调工作、让开发更方便(比如我们上面在view model中直接使用IQueryable<T>)。这就产生了实际开发和设计模式之间的矛盾,就需要在一定程度上达成平衡。

 

仓储模式应该把IQueryable<T>封装到仓储实现类中,由我来限制哪些代码(仓储相关的)需要了解EF Core查询的细节,哪些不需要。但是现在还没有完全做到限制,因为应用程序的其余部分仍然需要了解我在清单5-13中定义的主键属性,我还要在后面的章节中使用该属性来标识对象。

 

对我来说,这是实用性(必须唯一标识对象)和原则(把数据存储细节封装到仓储)之间的合理平衡。您可能更倾向于不使用仓储,或者选择更严格地遵循仓储模式(例如,使用不同的主键策略,如第19章所述)。

 

  另一种方式是让仓储实现类来负责IQueryable<T>对象的复杂度(关注它和LINQ怎样去影响SQL语句,对产生的SQL语句肩负起责任),然后向应用程序的其它部分提供内存中的常规集合(如返回List<T>或者直接返回IEnumerable<T>),即实现了IEnumerable<T>接口的集合。如此可以遍历(列举)集合元素,而不用再担心意外结果。在清单5-14中,我更改了repository类,使其不再传递由上下文类的Products属性返回的DbSet<T>对象。而是返回一个产品的数组(如下代码所示,ToArray() )。

 1 //Listing 5-14. Forcing Query Evaluation in the DataRepository.cs File in the Models Folder
 2 using System.Collections.Generic;
 3 using System.Linq;
 4 namespace SportsStore.Models
 5 {
 6     public class DataRepository : IRepository
 7     {
 8         private DataContext context;
 9         public DataRepository(DataContext ctx) => context = ctx;
10         public IEnumerable<Product> Products => context.Products.ToArray();
11         public void AddProduct(Product product)
12         {
13             this.context.Products.Add(product);
14             this.context.SaveChanges();
15         }
16     }
17 }

LINQ的ToArray和ToList方法触发立即查询,生成结果集的数组或List列表。数组和List是实现了IEnumerable<T> 接口的内存中的对象集合(和SQL没有半毛钱关系)。这意味着我必须再次修改Home控制器使用的Index 视图中的vew model视图模型。如清单5-15所示。当然这也意味着我可以重新在视图中安全地执行多种LINQ操作(因为现在给到视图的是内存中的集合——数组或List),而不需要考虑数据是如何获得的。

注意:这种方式的后果是:仓储必须能够向应用程序的其余部分提供所需的数据(人家想要啥你都得能满足),这将导致在仓储类中加入更复杂的查询。我倾向于使用这种方法,因为它更容易看到和管理EF Core要去处理所有查询,但这只是我个人的偏好,你应该选择最适合你的方式。

 

 1 Listing 5-15. Changing the View Model 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="container-fluid mt-3">
 5         <div class="row">
 6             <div class="col font-weight-bold">Name</div>
 7             <div class="col font-weight-bold">Category</div>
 8             <div class="col font-weight-bold text-right">Purchase Price</div>
 9             <div class="col font-weight-bold text-right">Retail Price</div>
10             <div class="col"></div>
11         </div>
12         <form asp-action="AddProduct" method="post">
13             <div class="row">
14                 <div class="col"><input name="Name" class="form-control" /></div>
15                 <div class="col"><input name="Category" class="form-control" /></div>
16                 <div class="col">
17                     <input name="PurchasePrice" class="form-control" />
18                 </div>
19                 <div class="col">
20                     <input name="RetailPrice" class="form-control" />
21                 </div>
22                 <div class="col">
23                     <button type="submit" class="btn btn-primary">Add</button>
24                 </div>
25             </div>
26         </form>
27         <div>
28             @if (Model.Count() == 0) {  //你看我又把If判断加了回来,但视图模型是个数组。这个Count是在内存中计算的
29             <div class="row">
30                 <div class="col text-center p-2">No Data</div>
31             </div>
32             } else {
33               @foreach (Product p in Model) {
34                 <div class="row p-2">
35                   <div class="col">@p.Name</div>
36                   <div class="col">@p.Category</div>
37                   <div class="col text-right">@p.PurchasePrice</div>
38                   <div class="col text-right">@p.RetailPrice</div>
39                   <div class="col"></div>
40                 </div>
41                }
42             }
43         </div>
44     </div>

使用dotnet run重新启动应用程序,浏览器导航到http://localhost:5000;您将看到前面的图中所示的熟悉输出。在本节中,用户体验没有改变,但是如果检查应用程序生成的日志,您将看到对数据库只有一个查询,即使视图模型对象被遍历(列举)了两次(因为遍历的是仓储返回的数组)。

...
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]
...

 

5.7 常见错误和处理办法

一旦你掌握了基本的功能,使用实体框架核心来存储和检索数据是很简单的,但也有一些陷阱需要避免。在下面的部分中,我将描述您最可能遇到的问题,并解释如何解决它们。

5.7.1 创建或访问数据库的问题

当试图创建数据库或从应用程序访问数据库时,可能会发生基础性的错误。大多数情况下,这些问题都是由错误配置引起的,我将在下面的部分中解释。

5.7.1.1 未找到匹配dotnet-ef命令的可执行程序。

The “No executable found matching command dotnet-ef” Error

dotnet ef命令用于创建和管理迁移,但默认情况下不启用它们,而是依赖于添加到应用程序中的包。如果您在尝试运行任何dotnet ef命令时收到“无可执行程序”错误,请打开.csproj文件,确保有一个DotNetCliToolReference引用,用于引入Microsoft.EntityFrameworkCore.Tools.DotNet包,如列表5-1所示的那样。

  如果您已经添加了这个包,那么请确保您是在项目文件夹下执行的dotnet ef命令,该文件夹包含.csproj文件和Startup.cs 文件。如果您尝试在任何其他文件夹中使用dotnet ef,那么.net Core运行时将无法找到您正在使用的命令。

 

5.7.1.2 编译失败

The “Build Failed” Error 

当您运行一个dotnet ef命令时,项目将自动编译。如果代码中有任何问题,将报告“编译失败”错误,尽管没有提供关于问题原因的详细信息。

  如果您想查看是什么导致编译失败,那么在项目文件夹中运行dotnet build命令。等解决问题后要再次运行dotnet ef命令。
注意:这个错误也可能是由于你在一个命令行或PowerShell窗口使用dotnet run启动应用之后,试图在另一个命令行或PowerShell窗口运行某个dotnet ef命令而导致的。编译过程试图覆盖正在运行的应用程序文件,这会导致编译失败。先停止应用程序,再执行您的dotnet ef命令应该就会成功了。
 
5.7.1.3 实体类需要一个主键
The “The entity type requires a primary key to be defined” Error
如果您在尝试创建迁移时看到此错误,那么最有可能的原因是您没有选择主键。对于简单的应用程序,最好的方法如清单5-3所示。对于复杂的应用程序,使用主键的高级特性将在第19章中进行描述。
 
5.7.1.4 数据库中已存在名为XXX的对象
The “There is already an object named <Name> in the database” Exception
当您试图应用一个迁移,该迁移尝试创建已存在的数据库表时,将出现此异常。当您从项目中删除、再重建迁移,然后尝试应用到数据库时,就经常发生这种异常。数据库已经包含迁移创建的表,这将阻止迁移成功。
 
  这个问题最可能出现在开发过程中,最简单的解决方案是在项目文件夹中运行清单5-16中的命令,删除和重建数据库。这些命令将删除数据库及其包含的数据,所以这个方法不应该在生产系统上使用。
Listing 5-16. Resetting a Database
dotnet ef database drop --force
dotnet ef database update

 

5.7.1.5 网络相关或者具体事例错误

The “A Network-Related or Instance-Specific Error Occurred” Exception

这个异常告诉您实体框架核心无法连接数据库服务。这个异常最常见的原因是appsettings.json中的连接字符串写错。如果使用LocalDB进行开发,那么请确保将Server 配置属性设置为(LocalDB)//MSSQLLocalDb,其中有两个/字符,名称的第二部分是MSSQLLocalDb(别写错)。如果您正在使用完整的SQL Server产品——或者使用的是另一个牌子的数据库服务——那么请确保您使用了正确的主机名和TCP端口,确保主机名可以解析出正确的IP地址,并测试您的网络,以确保您能够访问到服务器(比如用数据库前端工具先去连接一下数据库服务试试)
 
5.7.1.6 无法打开请求的数据库
The “Cannot Open Database Requested By The Login” Exception
如果您收到此异常,说明实体框架核心能够连接数据库服务器,但请求访问不存在的数据库。首先要检查的是,您已经在appsettings.json中的连接字符串中指定了正确的数据库名称。对于LocalDB(以及完整的SQL Server产品),就是要正确设置Database 属性,如清单5-6所示。如果您使用的是其他数据库服务器,那么请去查看文档,了解应该如何指定数据库名称。
小贴士:很难确定应该包含哪些连接字符串,特别是在切换数据库服务或Provider程序包时。https://www.connectionstrings.com  网站提供了有用的参考,即大多数的数据库服务和连接选项。
 
 
  如果您输入了正确的数据库名称,那么您可能创建了一个迁移,但还没有应用它,这意味着数据库服务从未被要求创建EFCore要访问的数据库。在项目文件夹中运行dotnet ef database update 命令以应用迁移。
 
 
5.7.2 数据查询错误
查询数据时最大的问题是对数据库服务器的重复请求,如“避免查询陷阱”一节所述。但这不是可能出现的唯一问题,我将在下面的部分中解释:

 
5.7.2.1 属性无法映射 
The “Property Could Not Be Mapped” Exception
该异常是当你在实体类中添加了一个属性,但是没有创建和应用迁移更新数据库。如何使用迁移保证实体类与数据库同步的更多细节见第13章。
 
5.7.2.2 无效对象名
The “Invalid Object Name” Exception
该异常通常意味着EF Core试图从一个数据库中不存在的表里查询数据。这是上一个问题的衍生问题,通常意味着实体类的变更没有同步更新到数据库。迁移如何工作的、如何管理它们详见第13章。
 
5.7.2.3 已存在一个打开的DataReader
The “There is Already an Open DataReader” Exception
该异常是你在前一个查询读取完数据之前试图再开始执行另一个查询时产生。如果你用的SQL Server,你可以在连接字符串中设置并行(MARS)功能,见清单5-6。对于其他数据库,可以使用ToArray或ToList方法强制在开始下一个查询之前,让前一个查询先读取完数据。
 
 
5.7.2.4 单例情况无法使用跨域服务
The “Cannot Consume Scoped Service from Singleton” Exception
Startup 类中的AddDbContext方法使用AddedScoped方法为上下文类设置DI依赖注入。这意味着您必须使用AddTransient或AddScoped方法(如清单5-7所示)来配置任何依赖于上下文类的服务,例如仓储实现类。如果使用AddSingleton方法注册您的服务,就会在ASP.NET Core试图解析依赖关系时抛出异常
 
5.7.2.5 上下文数据过期问题
The Stale Context Data Problem
在一个ASP.NET Core MVC应用中,EF Core希望为每个HTTP请求创建一个新的上下文对象。然而,一个常见的问题是,要保留用同一个上下文对象,并尝试在后续请求中使用它们。
  这带来的问题是,每个上下文对象为了利用缓存技术和侦测实体对象的数据更改,要一直保存着(由上下文创建的)实体对象的踪迹。持有上下文对象并重用它们会产生意想不到的结果,因为数据已经过时或不完整。尽管您可能不喜欢为每个请求创建一个(上下文)对象,但它已经是应用程序其余部分所使用的模式——MVC框架为每个HTTP请求创建一个新的控制器和视图对象——EF Core也同样希望为每个HTTP请求创建一个新的上下文对象。
 
 
5.7.3 数据存储的错误
在大多数情况下,在一个MVC数据模型中,Entity Framework Core要存储类的实例,所需的更改很少。然而,有一些常见的问题,我将在下面的部分中进行描述。
 
5.7.3.1 对象未存储
Objects Are Not Stored
如果应用程序貌似工作正常,但是对象没有存储到数据库,那么要检查的第一件事是,在仓储实现类中有没有忘记调用SaveChanges方法。EF Core只会在调用SaveChanges方法之后去数据库Update数据,如果您忘记了SaveChanges,它会默默地丢弃任何更改。
 
 
5.7.3.2 只有部分属性值未存储
Only Some Property Values Are Not Stored
如果数据库中只存储了与对象相关的部分数据值,那么请确保实体类只使用属性,并且所有属性都有public的set和get访问器。实体框架核心只存储属性(返回的)值,默认情况下忽略任何方法或字段。如果应用程序的限制使您不能在数据模型类(实体类)中仅使用属性,那么请参阅第20章,了解实体框架核心的高级特性,以更改数据模型类的使用方式。
 
5.7.3.3 无法显式为标识列插入值的异常
The “Cannot Insert Explicit Value for Identity Column” Exception
如果您选择了如清单5-3所示的主键,那么Entity Framework Core将配置数据库,以便数据库服务器负责生成能唯一标识对象的值。
  这意味着多个应用程序(或同一个应用程序的多个实例)可以共享同一个数据库,而无需进行协调以避免重复的值。这还意味着,如果您试图使用键值存储一个新对象(即自己指定主键),而该键值不是该键类型的默认值,那么将引发异常。对于Product类,主键类型是long,因此只能在Id值为0时存储新对象(0是long的默认值)。这个异常最常见的原因是视图中包含一个输入元素,该元素用于创建新对象,并允许用户输入一个值,然后MVC模型绑定器使用该值并通过实体框架核心传递给数据库。(用户输入了Id值)
 
小贴士:如果不希望数据库服务器为您生成主键值,请参阅第19章了解高级主键选项。
 
 
5.8 本章小结
  在本章,我添加了对数据库存储和查询的支持。我讲述了代码改为数据持久化存储的过程,演示了如何调整应用程序执行的查询,使其能够有效地与实体框架核心一起工作。我还描述了将实体框架核心引入现有应用程序时可能遇到的最常见问题,并告诉您如何解决每个问题。在下一章中,我将向SportsStore应用程序添加一些功能来修改和删除数据库中的数据。
 
 

posted on 2019-01-04 13:33  困兽斗  阅读(302)  评论(0)    收藏  举报

导航