前面的系列文章:
我们一直没提怎么与数据库进行交互,不与数据库交互,就没法存储用户信息,没有真正的登录、注册功能,没法真正的做一个实际可用、可部署的、有意义的交互式网站。
好消息是时代发展了,框架进步了,现在写网站已经不需要程序员精通SQL了,不需要知道怎么写复杂的查询语句了。
坏消息是,你需要学习一个胶水层工具:这个工具就是Entity Framework Core,简称EFC,它能自动化的帮忙你把服务端内存中的数据,与数据库中的存储映射起来。
如果我们不借助外力,仅依赖数据库交互相关的SDK的话,要从数据库中读取一个用户信息,我们大致会写出以下的代码:
// ...
string sqlConnStr = "xxx";
using (var conn = new SqlConnection(sqlConnStr))
{
var command = new SqlCommand("SELECT * FROM Users WHERE Id = @Id", conn);
command.Parameters.AddWithValue("@Id", userId);
await conn.OpenAsync();
using (var reader = await.command.ExecuteReaderAsync())
{
if (await reader.ReadAsync())
{
return new User
{
Id = reader.GetInt32(reader.GetOrdinal("Id"));
Name = reader.GetString(reader.GetOrdinal("Name"));
Gender = reader.GetString(reader.GetOrdinal("Gender"));
Age = reader.GetInt32(reader.GetOrdinal("Age"));
};
}
else
{
return null;
}
}
}
// ...
这样做的背后逻辑是:
User
对象这样太麻烦了,不光是上面的查询过程麻烦,数据库中库表的设计,也是完全独立于程序开发的。
而EFC工具,就解决了这样一个痛点,同样的功能,在使用了EFC之后,代码长下面这样:
public class UserDbContext : DbContext
{
public DbSet<User> Users {get;set;}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string sqlConnStr = "xxx";
optionsBuilder.UseSqlServer(sqlConnStr);
}
}
// ...
using (var userDbCtx = new UserDbContext())
{
User user = await userDbCtx.Users.FindAsync(userId);
return user;
}
// ...
这样做的背后逻辑是:
DbContext
中的“对象”就行了,至于这些“类”是怎么对应到数据库中的“表”上的,以及对“对象”的查询、修改是怎么翻译成SQL查询语句的,程序员不需要再去关心了。除了上面说的这些功能,EFC还可以将同一份代码,映射到多个不同的数据库产品上去,你可以很方便的从MySQL迁移到SQL Server或者甚至SQLite,当然我说的是部署运营前的迁移,运营后带数据的迁移则要根据实际情况仔细调研。
我们写一个示例程序,来展示如何用EFC,从C#代码里对Sqlite数据库中的数据进行增删改查,以及如何修改库表结构。
这个例子没有实际意义,只是用来展示EFC的功能和基本概念的。之所以选择Sqlite做数据库,主要是因为Sqlite够简单,在实验的时候不需要在本机安装什么东西。不像MySQL或者SqlServer,为了示例,你是真的得去在你电脑上安装一个MySQL或SqlServer并把进程跑起来的。
最后我们再把程序的存储层从Sqlite改成SQL Server,向大家展示如何换存储层。
通过以下命令来创建项目:
> dotnet new console --use-program-main -o HelloEFC
> cd HelloEFC
HelloEFC> dotnet add package Microsoft.EntityFrameworkCore.Sqlite
HelloEFC> dotnet add package Microsoft.EntityFrameworkCore.Design
HelloEFC> dotnet new tool-manifest
HelloEFC> dotnet tool install dotnet-ef
以下命令结束后,HelloEFC
目录中的内容会如下所示:
HelloEFC
|
|-- .config
| \--> dotnet-tools.json
|
|--> HelloEFC.csproj
\--> Program.cs
可能创建后HelloEFC
下面会生成一个叫obj
的目录,显而易见,是工具链创建的临时目录,不算项目文件,是可以删除的。
创建.NET Core项目我们很熟悉了,但上面的创建过程里有些命令依然没见过,这里做一下解说:
dotnet new console --use-program-main -o HelloEFC
。这个很简单,就是创建一个命令行应用程序,让Program.cs
使用传统写法。
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
与dotnet add package Microsoft.EntityFrameworkCore.Design
就值得专门说一说了。
这两行虽然看起来很简单:从命令行为项目添加两个NuGet包的依赖。但实际上需要说明一下:
EFC的功能有两方面:
对程序员来说,它需要提供一些接口让程序员去增删改查数据。
这些数据虽然本质上是数据库中的数据,但表现在程序员眼中其实就是代码中写着的,由变量引用着的对象。程序员接触的就是一些API、接口、方法而已,把这些接口、API、方法与自己定义的“类”结合在一起,就行了。
换句话说,EFC需要以“类库”的形式向程序员去提供编程接口。而考虑到EFC支持数目众多的不同类型、不同厂商的数据库产品,分层设计就是必然的。
EFC首先将所有数据库的读写,都抽象成统一的接口,然后基于这抽象出来的统一接口,来给程序员提供编程接口。这个抽象层,就是Microsoft.EntityFrameworkCore
这个包
其次,对于每种具体的数据库产品,都需要实现抽象层的统一接口,这个实现层,就是Microsoft.EntityFrameworkCore.Sqlite
:当然,这是在Sqlite上的实现。如果数据是存储在MySQL上的,使用的就是另一个包,叫MySQL.EntityFrameworkCore
。实现层的包还有另外一个名字,叫“Database Provider”。
有些Provider是微软官方实现的,比如SQL Server,Sqlite这俩玩意,而更多的Provider包,其实是数据库厂商自己实现的,比如MySQL和Oracle。EFC目前可用的Pvovider有好几十种,可以访问这里来获得完整列表。
所以按理说,我们的项目应该同时引用M.EFC
和M.EFC.Sqlite
两个包,但实际上由于Provider包自己就引用着抽象层的M.EFC
,所以我们不需要显式的在项目中引用M.EFC
:反正它会被M.EFC.Sqlite
间接引用进来。
对项目的构建、部署而言,EFC要提供很多工具
就比如“扫描项目代码从而生成创建库表的脚本”这个活,以及“执行脚本去创建、更新库表定义”这个活,这些活和项目本身是完全没关系的,但这些活都需要EFC提供工具去做。EFC为了提供这些小工具,设计出了两个东西,其中的第一个东西,就是Microsoft.EntityFrameworkCore.Design
包。第二个东西,是一个命令行工具,叫dotnet-ef
。
Microsoft.EntityFrameworkCore.Design
这个包主要是写了一些工具类,这些工具类能做一些非常基础的事情,你不需要知道这些事情具体是什么,只需要知道两点:
dotnet-ef
这个命令行工具看的,dotnet-ef
这个命令行工具在运行时,诶,有些子命令是真的会用到这个包里的类、方法的。如果我们去看上面创建的项目文件HelloEFC.csproj
的话,会发现对Microsoft.EntityFrameworkCore.Design
的引用和普通的包引用,长得不一样,如下:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
</ItemGroup>
</Project>
这个引入进来的包,由<PrivateAssets>all</PrivateAssets>
标记着,意思就是:项目打包的时候它不参与打包,它纯粹就是一个只有在开发阶段才有意义的东西。有点类似于前端项目中的npm install xxx --save-dev
一样。
再来看dotnet new tool-manifest
和dotnet tool install dotnet-ef
,有了上面的解释,这里就稍微容易理解一点点了:这两行其实就是为当前项目,安装了一个命令行工具,叫dotnet-ef
,执行了这两行之后,我们就可以在项目目录执行dotnet ef
命令了,如下所示:
这dotnet tool install dotnet-ef
可以理解,那个dotnet new tool-manifest
是什么鬼?它逻辑是这样的:
dotnet tool install xxx
安装的工具是有版本号的,而不同的项目,可以安装同一工具,的不同版本.config/dotnet-tools.json
,而dotnet new tool-manifest
只是用来创建这个配置文件的一个命令而已dotnet tool install xxx --global
把这个工具安装在全局上,这样项目目录中就不需要.config/dotnet-tools.json
文件了再回过头来说dotnet-ef
是什么:简单来说,它就是上面我们提到的那个工具,可以做的事情包括且不限于 :
等,现在光介绍是有一点点不好体会,不好理解的,没事,跟着我一步一步体会一下,你就明白了
DbSet
,DbContext
我们给项目中添加两个文件,一个是User.cs
,一个是Department.cs
,分别用来描述“用户”和“公司部门”。这两个类,就是我们在程序层面对“数据”的抽象,在后续的步骤中,我们会借助EFC来向大家一步步的展示,EFC是如何把程序中的User
对象和Department
对象存储在数据库中的,又是如何从数据库中读取数据并转换成对象的。
namespace HelloEFC;
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
namespace HelloEFC;
public class Department
{
public int Id { get; set; }
public string Name { get; set; }
public string Location { get; set; }
}
这个类目前只是一个普通的C#类,EFC并不会多瞧它一眼。我们想达成的目的,是EFC根据我们写的这个类的定义,能为我们在数据库中创建出类似于以下的两张表:
Id | Name | |
---|---|---|
.. | .. | .. |
Id | Name | Location |
---|---|---|
.. | .. | .. |
为了告诉EFC框架:“请按照这两个类的样子去设计数据库”,我们要做两件事:
DbContext
的子类,来全权管理与数据库的所有交互事宜DbContext
类中,告诉EFC框架:需要用哪些类的样子去创建表说起来是两件事,但这两件事都写在BoringCompanyDBContext.cs
文件中,内容如下:
using Microsoft.EntityFrameworkCore;
namespace HelloEFC;
public class BoringCompanyDBContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Department> Departments { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=BoringCompanyDB.db");
}
}
上面这个类的定义,做了三件事:
BoringCompanyDBContext
类,就是在定义程序员使用EFC框架的入口每当程序员需要访问数据库中的数据,无论是做读取操作,还是写入操作的时候,第一件事,就是要实例化这个类,像下面这样:
{
using dbCtx = new BoringCompanyDBContext();
// ...
// ...
// ...
}
在代码中,程序员是通过dbCtx
中的方法、属性来读取或写入数据的。
在概念上,你可以简单的认为,dbCtx
的对象代表的就是一个“数据库事务”,这个细节我们后面再详解
DbSet<T>
类型的属性,就是在定义类与表的映射关系我们把“映射为数据库表”的类,叫Entity类,这种类的对象,自然也映射着表中的一行数据,我们把对象称为Entity。
就好比,User
类,是一个Entity类,因为它映射着的是数据库中的一张叫User
的表,这张表可能长下面这样:
Id | Name | |
---|---|---|
.. | .. | .. |
9527 | Alice | [email protected] |
.. | .. | .. |
而表中的一行数据,即一行记录,在程序内存中,则会被一个User
实例所映射着,比如上面表中的Alice,在程序中就是一个User
对象。
而数据库中的表会有多行记录,所以映射在程序中,就会变成User
实例的集合,这个集合类型,不是IEnumerable
或IList
,而是上面代码中的DbSet<T>
。
在BoringCompanyDBContext
类内部定义的Users
和Departments
属性:
DbSet<T>
属性,就代表着一张数据库中的表:EFC框架要以T
类型的样子,在数据库中建立一张表,并存储数据OnConfiguring
方法,就是在告诉框架如何连接到数据库上OnConfiguring
方法是一个会被EFC框架调用的方法,它里面写的逻辑主要是围绕参数DbContextOptionsBuilder
展开的,简单来说,就是在“做配置”。
而配置中,最重要的配置信息就是:如何连接到真正的数据库上。
我们的示例代码使用的是Sqlite作为Provider,这个数据库与MySQL和SQL Server不一样,它的数据文件就存储在本地文件系统中,所谓的“连接字符串”,也只是在描述“数据库文件的路径”而已。
现在,我们定义好了BoringCompanyDBContext
,如果我们现在直接在Program.cs
中写如下代码,运行是会报错的:
namespace HelloEFC;
class Program
{
static void Main(string[] args)
{
using BoringCompanyDBContext boringCompanyDBCtx = new BoringCompanyDBContext();
foreach(var user in boringCompanyDBCtx.Users)
{
Console.WriteLine(user.Name);
}
foreach(var dp in boringCompanyDBCtx.Departments)
{
Console.WriteLine(dp.Name);
}
}
}
报错信息如下:
报错很直白,也很容易理解:
这里,就要介绍两个概念:一个叫Schema,一个叫Migration
Schema是一个通用概念,它一般描述的是“数据的格式”。用在数据库领域的话,当我们说“某个数据库的Schema”的时候,说的其实是整个数据库的表结构、定义。当我们说“某张表的Schema”的时候,说的就是这张表的定义,这张表中有哪些字段、哪些索引之类的。
而Migration,是一个EFC框架内部的概念:EFC框架在用户定义好Context类后,使用工具可以生成“一个Migration”,比如我们现在,在项目目录中,用命令行运行如下命令:
HelloEFC> dotnet ef migrations add InitializeBoringCompany
我们就是通过这个命令,新建了一个叫InitializeBoringCompany的Migration。
项目目录下就会多出一个Migrations
目录来,里面会有三个文件
20240828062832_InitializeBoringCompany.cs
和20240828062832_InitializeBoringCompany.Designer.cs
BoringCompanyDBContextModelSnapshot.cs
如下所示:
文件名里的2024....
是时间戳,InitializeBoringCompany
是Migration的名字。
所以说,什么是Migration呢?从文件的角度来说,一个名为“狗”的Migration,其实就是三个文件:
{时间戳}_狗.cs
和 {时间戳}_狗.Designer.cs
{DBContext类名}ModelSnapshot.cs
不过我要是这么给你解释Migration的含义,你恐怕是不高兴的:是,是仨文件,然后呢?这仨文件有什么用?你光说这是仨文件顶个屁用啊!我得知道它们是干啥的呀!
诶,不要着急,我们先不急着解释,先跑一下下面这个命令:
HelloEFC> dotnet ef database update InitializeBoringCompany
程序的输出如下图所示:
这又是干了个啥呢?你这时再看项目目录下面,会发现它创建了一个数据库文件BoringCompanyDB.db
,这就是我们在BoringCompanyDBContext.OnConfigure
方法中写的Sqlite数据库文件路径:刚才执行的命令其实是给我们创建库表去了。
我们去Sqlite的官方下载页面,把下图中的文件下载下来,然后把压缩包中的sqlite3.exe
解压到项目目录中(这是sqlite官方的数据库连接客户端工具),连接上BoringCompanyDB.db
一探究竟。
使用到的命令如下:
命令 | 功能 |
---|---|
.tables |
列出当前库中所有的表 |
.schema Departments .schema Users .schema __EFMigrationsHistory |
列出指定表的Schema,即列出创建该表的命令 |
select * from User select * from Departments select * from __EFMigrationsHistory |
列出指定表中的所有数据 |
运行结果如下所示:
相信到此为止你大致也猜到了所谓的Migration是什么意思,我这里简单的表述一下:
dotnet ef migrations add xxx
命令,在扫描了Context类后,根据类的定义,生成的一个名为xxx
的“东西”InitializeBoringCompany
dotnet ef database update xxx
命令,这个命令在执行时,将读取名为xxx
的东西,然后去更新数据库中的库表结构现在我们再回过头来看,Migration那三个文件的具体内容,具体内容我就不在这里贴出来了,你们自己去看,我这里说两句总结
三个文件定义了两个类,其中{时间戳}_狗.cs
和 {时间戳}_狗.Designer.cs
这两个文件定义的都是狗
这个类
{时间戳}_狗.cs
文件中的Up
和Down
方法是通过C#描述了表应当怎么创建的逻辑,以及撤销操作的逻辑{时间戳}_狗.Designer.cs
和{DBContext类名}ModelSnapshot.cs
的内容基本是一致的,是用C#语言将Context类中的信息再复述了一遍
在我们执行dotnet ef database update InitializeBoringCompany
命令的时候,这个命令内部就是通过上面三个Migration文件中的内容知道怎么去创建表的。
现在我们给User
类新加一个属性,如下:
namespace HelloEFC;
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
+ public string Gender { get; set; }
}
然后我们再创建一个新的Migration,并更新数据库,观察一下会发生什么:
HelloEFC> dotnet ef migrations add AddGenderToUser
HelloEFC> dotnet ef database update AddGenderToUser
具体细节我这里就不展示了,我这里直接说结论:
新增的Migration,新增了{时间戳}_狗.cs
和 {时间戳}_狗.Designer.cs
这两个文件,更新了{DBContext类名}ModelSnapshot.cs
文件
在{时间戳}_狗.cs
文件的Up
和Down
方法中,仅描述了如何为已经存在的Users
表添加,以及删除Gender
字段。即记录的是差异信息
新增的Gender
字段虽然在Entity类中的定义与Name
和Email
一样,但EFC为其添加了默认值为空字符串的定义,这是为了兼容老数据的设计,程序员不应当过分关心这个特性
比如在更改之前,Users
表中已经有了两行数据了,那么在此次变更之后,如果Gender
没有默认值的话,之前的老数据扩充的这个字段应该取什么值,就是个问题。
这就要求所有后续添加上来的字段,都必须有个默认值。
但这个行为程序员不必过分关注,对于我们来说,我们不需要关注在数据库层面每个字段是怎么约束的,我们能看到的只是C#对象,是Context类中的Users和Departments字段
把Program.cs
改造成下面这样,就可以向里面写数据了:
namespace HelloEFC;
class Program
{
static void Main(string[] args)
{
using BoringCompanyDBContext boringCompanyDBCtx = new BoringCompanyDBContext();
if(boringCompanyDBCtx.Users.Count() == 0)
{
boringCompanyDBCtx.Users.Add(new User { Name = "Alice", Email = "[email protected]", Gender = "Female"});
boringCompanyDBCtx.Users.Add(new User { Name = "Bob", Email = "[email protected]", Gender = "Male"});
boringCompanyDBCtx.Users.Add(new User { Name = "Charlie", Email = "[email protected]", Gender = "Male"});
boringCompanyDBCtx.Users.Add(new User { Name = "David", Email = "[email protected]", Gender = "Male"});
boringCompanyDBCtx.Users.Add(new User { Name = "Emma", Email = "[email protected]", Gender = "Female"});
}
if(boringCompanyDBCtx.Departments.Count() == 0)
{
boringCompanyDBCtx.Departments.Add(new Department { Name = "Marketing", Location = "New York"});
boringCompanyDBCtx.Departments.Add(new Department { Name = "Engineering", Location = "Seattle"});
}
boringCompanyDBCtx.SaveChanges();
}
}
但注意运行这个程序最好在命令行使用dotnet run
来运行,如果你直接在Visual Studio中通过“调试”或“运行”按钮运行这个程序的话,程序会抛出异常,并说:“Users表不存在”,如下图所示:
这背后的原因在于,我们在BoringCompanyDBContext.cs
中,是通过如下方式指定数据库的:
optionsBuilder.UseSqlite("Data Source=BoringCompanyDB.db");
SQLite SDK在解析这个连接字符串的时候,会认为BoringCompanyDB.db
是一个相对路径地址,在运行时,会以System.Environment.CurrentDirectory
中的值来解析这个相对地址路径。在Visual Studio中,这个System.Environment.CurrentDirectory
的路径默认是项目的编译产出路径,即...HelloEFC\bin\Debug\net8.0
目录,而如果是在项目根目录以命令行dotnet run
运行程序,这个System.Environment.CurrentDirectory
的值就会被设置为项目根目录,即...HelloEFC
目录本身。
这其实是命令行环境下,一个叫工作目录(working directory)的概念,这个小知识无论是在Linux还是在Window平台上都是一致的:
如果你是在使用Visual Studio,那么要解决这个问题其实有好几个办法,这里说两个简单的:
右键项目,属性,Debug,打开调试配置UI,在里面手动指定working directory,
在代码中不要使用相对路径,直接写成绝对路径
而如果要读数据的话,只需要在实例化BoringCompanyDBContext
后直接访问他内部的Users
和Departments
字段就好了
namespace HelloEFC;
class Program
{
static void Main(string[] args)
{
using BoringCompanyDBContext boringCompanyDBCtx = new BoringCompanyDBContext();
foreach(var user in boringCompanyDBCtx.Users)
{
Console.WriteLine($"User#{user.Id}: {user.Name}");
}
foreach(var dp in boringCompanyDBCtx.Departments)
{
Console.WriteLine($"Department#{dp.Id}: {dp.Name}");
}
boringCompanyDBCtx.SaveChanges();
}
}
运行结果如下:
删除数据就更简单了
namespace HelloEFC;
class Program
{
static void Main(string[] args)
{
using BoringCompanyDBContext boringCompanyDBCtx = new BoringCompanyDBContext();
User? alice = boringCompanyDBCtx.Users.Where(u => u.Name == "Alice").FirstOrDefault();
if(alice is not null)
{
boringCompanyDBCtx.Users.Remove(alice);
}
boringCompanyDBCtx.SaveChanges();
}
}
改数据也是简单到不行,直接改对象就行了
namespace HelloEFC;
class Program
{
static void Main(string[] args)
{
using BoringCompanyDBContext boringCompanyDBCtx = new BoringCompanyDBContext();
User? emma = boringCompanyDBCtx.Users.Where(u => u.Name == "Emma").FirstOrDefault();
if(emma is not null)
{
emma.Email = "[email protected]";
}
boringCompanyDBCtx.SaveChanges();
}
}
总结一下:
ctx.Users
里添加对象ctx.Remove
或ctx.RemoveRange
方法,传入要被删除的对象Where
来设定查询条件ctx.SaveChanges()
或await ctx.SaveChangesAsync()
EFC在增删改查这方面,体验确实做的优秀,这真的是世界上最好的ORM框架,真的在体验这块,没有之一。
上面的示例虽然简单,但其实已经足够应付小项目开发了。但除了会干活之外,我们还是有必要了解一些背后的知识和原理的。
从Context对象的使用者这个角度来看,EFC的体验非常好,给了人一种:“操作Context中的数据,就是在直接操作数据库中的数据”的错觉。实际上EFC框架并不会将你对ctx.Users
的改动立即同步到数据库去,而是默默的记录下这个改动,最终在调用ctx.SaveChanges()
的时候,才一股脑的把所有改动都同步到数据库中去。
这也是为什么我们上面在描述Context时,说:你可以简单的认为,dbCtx
的对象代表的就是一个“数据库事务”。
其实这个说法也是错误的,稍微准确一点的说法是,Context对象中每调用一次SaveChanges
,就是对事务的一次提交。
如果真的要较真的话,这个稍微准确一点的说法也不是100%正确的,但这些细节不重要,我们只需要这样去理解就可以了,至于具体实现细节上,EFC是怎么做的,不必太过关心。
总结起来就是:框架会记录下程序员对Context中的数据的操作,并在最终SaveChanges
的时候向数据库同步这些更改。那么问题来了:框架是怎么记录这些变更的?
答案是:Context中有一个字段,叫ChangeTracker
,它里面就写着所有对数据的修改、访问记录。
下面我们重新来创建一个项目,来做一个实验来向大家说明底层的原理,实验准备条件包括以下几条:
如何创建项目,添加必要的依赖包,以及如何"add migration"和"update database",这里就不重复说了
代码如下
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System;
using System.Linq;
using System.Text;
namespace HelloEFCTracker;
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class ApplicationDBContext : DbContext
{
public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=HelloEFCTracker.db");
}
}
public static class Program
{
public static void Main(string[] arsg)
{
PrepareTestData();
using var ctx = new ApplicationDBContext();
Console.WriteLine($"001: after initialize\n{GetChangesString(ctx)}");
var apple = ctx.Products.Where(p => p.Name == "Apple").FirstOrDefault()!;
Console.WriteLine($"002: after get \'Apple\' object\n{GetChangesString(ctx)}");
var banana = ctx.Products.Where(p => p.Name == "Banana").FirstOrDefault()!;
Console.WriteLine($"003: after get \'Banana\' object\n{GetChangesString(ctx)}");
apple.Price = 10.99M;
Console.WriteLine($"004: after change \'Apple\''s Price\n{GetChangesString(ctx)}");
banana.Price = 20.99M;
Console.WriteLine($"005: after change \'Banana\''s Price\n{GetChangesString(ctx)}");
ctx.Products.Add(new Product { Name = "Orange", Price = 14.99M });
Console.WriteLine($"006: after add \'Orange\' into ctx\n{GetChangesString(ctx)}");
ctx.SaveChanges();
Console.WriteLine($"007: after SaveChanges()\n{GetChangesString(ctx)}");
}
public static void PrepareTestData()
{
using ApplicationDBContext ctx = new ApplicationDBContext();
ctx.Products.RemoveRange(ctx.Products);
ctx.Products.Add(new Product { Name = "Apple", Price = 4.43M });
ctx.Products.Add(new Product { Name = "Banana", Price = 5.57M });
ctx.SaveChanges();
}
public static string GetChangesString(DbContext ctx)
{
StringBuilder sb = new StringBuilder();
foreach(EntityEntry entityEntry in ctx.ChangeTracker.Entries())
{
sb.AppendLine($" Entity: {entityEntry.ToString()}, State: {entityEntry.State.ToString()}");
}
return sb.ToString();
}
}
运行结果如下:
001: after initialize
002: after get 'Apple' object
Entity: Product {Id: 1} Unchanged, State: Unchanged
003: after get 'Banana' object
Entity: Product {Id: 1} Unchanged, State: Unchanged
Entity: Product {Id: 2} Unchanged, State: Unchanged
004: after change 'Apple''s Price
Entity: Product {Id: 1} Modified, State: Modified
Entity: Product {Id: 2} Unchanged, State: Unchanged
005: after change 'Banana''s Price
Entity: Product {Id: 1} Modified, State: Modified
Entity: Product {Id: 2} Modified, State: Modified
006: after add 'Orange' into ctx
Entity: Product {Id: -2147482645} Added, State: Added
Entity: Product {Id: 1} Modified, State: Modified
Entity: Product {Id: 2} Modified, State: Modified
007: after SaveChanges()
Entity: Product {Id: 1} Unchanged, State: Unchanged
Entity: Product {Id: 3} Unchanged, State: Unchanged
Entity: Product {Id: 2} Unchanged, State: Unchanged
重复运行程序会导致输出结果中的Id从1 2
变成更大的数,这背后的原因想必不用我多说。
这个例子很生动的向我们说明了以下几个知识点:
ctx
来增删改查数据库中的数据时,其实在SaveChanges
调用之前,所有的改变都仅发生在内存中,并没有写到数据库中去ctx.ChangeTracker
来追踪所有的变更,并在最终SaveChanges
的时候一把把这些变更都写到数据库中去不必过分纠结ChangeTracker
内部是怎么实现的,作为开发者,只需要大致了解这个原理就足够了,牢记以下两条铁律:
using
开始,以SaveChanges
结束using
和SaveChanges
之间当成一个数据库事务去用事后我们通过命令行查看表中的内容,如下:
sqlite> select * from Products;
1|Apple|10.99
2|Banana|20.99
3|Orange|14.99
sqlite>
到目前为止,你掌握的知识包括:
Entity
,什么是DbSet
,什么是DbContext
migration
,以及如何通过dotnet ef
命令行工具add migration
以及update database
ctx
对象对数据进行增删改查的时候,背后运行的原理是什么这些知识点基本就是EFC框架最重要、最常用的70%的知识点,现在配合着搜索引擎,你已经基本能应付日常开发了。接下来的章节,会一个个的讲一些独立的知识点。
在开发环境中,使用dotnet ef migrations add xxx
以及dotnet ef database update
来对测试环境的数据库进行变更升级,没什么问题。
但要把库表的变更部署到生产环境时,使用dotnet ef
工具就不是很合理了。这时候你就需要为migration
生成SQL脚本了。
生成sql脚本也有两种模式:从0开始,从指定migration开始。
比如你的项目现在有两个migration,如下:
*****> dotnet ef migrations list
Build started...
Build succeeded.
20241223053240_init
20241223053639_change id from string to int
这时呢,如果你的项目即将第一次部署上生产环境,你就需要生成一个,从0开始的,一把配置好库表的SQL脚本,可以执行如下命令来生成:
*****> dotnet ef migrations script
但请注意,这样生成的SQL脚本,并不是一步到位直接把最终的表创建出来,它实际还是一步一步的按你代码目录下的migrations去创建。下面就是个例子:
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
-- 第一次migration只用了一个事务就搞定了
BEGIN TRANSACTION;
-- 第一次migration时,创建的Products表是使用字符串类型做主键的
CREATE TABLE "Products" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Products" PRIMARY KEY,
"Name" TEXT NOT NULL,
"Price" TEXT NOT NULL
);
-- 表建完顺手向__EFMigrationsHistory中写记录
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20241223053240_init', '8.0.11');
COMMIT;
-- 第二次migration的实现,包括三个事务
-- 第二次migration之第一次事务:创建临时表
BEGIN TRANSACTION;
-- 创建一个临时表,使用数值类型做主键
CREATE TABLE "ef_temp_Products" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Products" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"Price" TEXT NOT NULL
);
-- 把Products表中的数据拷贝至临时表中
INSERT INTO "ef_temp_Products" ("Id", "Name", "Price")
SELECT "Id", "Name", "Price"
FROM "Products";
-- 提交事务
COMMIT;
-- 第二次migration之第二次事务:狸猫换太子
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
-- 扔掉原表
DROP TABLE "Products";
-- 临时表更名,顶替掉原表
ALTER TABLE "ef_temp_Products" RENAME TO "Products";
-- 提交事务
COMMIT;
-- 第二次migration之第三次事务:向__EFMigrationsHistory中写记录
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20241223053639_change id from string to int', '8.0.11');
COMMIT;
而如果你在第一次migration,即init之后,就已经上线部署了,现在版本更新,你所需要的只是将线上生产环境的数据库进行升级,那么你就需要在生成SQL脚本的时候指明,你线上生产环境目前的migration名字是什么,如下:
*****> dotnet ef migrations script init
这个命令,生成的SQL脚本,只能应用在migration版本为init
的数据库上,对应的,生成的内容也只包含如何从init
升级到change id from string to int
,如下:
-- 事务1:创建临时表,拷贝旧数据
BEGIN TRANSACTION;
CREATE TABLE "ef_temp_Products" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Products" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"Price" TEXT NOT NULL
);
INSERT INTO "ef_temp_Products" ("Id", "Name", "Price")
SELECT "Id", "Name", "Price"
FROM "Products";
COMMIT;
-- 事务2:狸猫换太子
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Products";
ALTER TABLE "ef_temp_Products" RENAME TO "Products";
COMMIT;
-- 事务3:添加更新记录至__EFMigrationsHistory
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20241223053639_change id from string to int', '8.0.11');
COMMIT;
Include
与ThenInclude
单个Entity,即单表的数据,使用简单的LINQ就可以完成增删改查,如下所示:
功能 | 示例代码 | 备注 |
---|---|---|
添加数据 | ctx.Products.Add(new Product{Name = "Apple", Price = 4.99M}); |
|
查找多个数据 | IQueryable<Product> cheapProducts = ctx.Products.Where(p => p.Price < 4.99M>); |
IQueryable<T> 是一个可迭代集合,你就把它当IEnumerable<T> 用 |
查找某个指定数据 | Product? product = ctx.Products.Where(p => p.Id == 1).FirstOrDefault(); |
注意返回的可能是null |
更改某个指定数据中的某个字段 | Product? product = ctx.Products.Where(p => p.Id == 1).FirstOrDefault(); if(product is not null) { product.Price = 3.99M; } |
|
删除一个或多个数据 | ctx.Products.Remove(ctx.Products.Where(p => p.Price < 1.99>)); |
而如果要增删改查的是有关联的多个Entity,就有意思了,这里有个知识点需要知道:"eager loading",我不知道这个术语在中文应该怎么翻译,它的意思就是“迫不及待的载入数据”的意思
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
// 产品
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
// 库存
public class Inventory
{
public int Id { get; set; }
public Product Product { get; set; }
public long Amount { get; set; }
}
public class ApplicationDBContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Inventory> Inventories { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
optionsBuilder.UseSqlite("Data Source=HelloEFCTracker.db");
}
}
public static class Program
{
public static void Main(string[] args)
{
AddSeedData();
using var ctx = new ApplicationDBContext();
Inventory? appleInventory = ctx.Inventories.Where(i => i.Amount == 3000).FirstOrDefault(); // 四号位置
Console.WriteLine(appleInventory!.Amount);
}
public static void AddSeedData()
{
using var ctx = new ApplicationDBContext();
if(ctx.Inventories.Count() != 0) // 一号位置
{
return;
}
var apple = new Product { Name = "Apple", Price = 2.99M };
var inventory = new Inventory { Product = apple, Amount = 3000 };
ctx.Products.Add(apple); // 二号位置
ctx.Inventories.Add(inventory); // 三号位置
ctx.SaveChanges();
}
}
上面的代码中出现了一行陌生的代码,即是在OnConfiguring
里出现的optionsBuilder.LogTo
,这句调用的作用是让EFC框架在执行SQL的时候,把对应的日志写在控制台上。当然最重要的是,我们要观察实际执行的SQL长什么样子。
除此之外,记住以上代码中注释里标记的四个位置,等会要考。
以上的代码编译、运行后,初次运行的时候控制台输出比较丰富,控制台输出的日志如下:
info: 12/23/2024 17:29:28.990 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (7ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Inventories" AS "i"
info: 12/23/2024 17:29:29.108 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (2ms) [Parameters=[@p0='?' (Size = 5), @p1='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Products" ("Name", "Price")
VALUES (@p0, @p1)
RETURNING "Id";
info: 12/23/2024 17:29:29.119 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p2='?' (DbType = Int64), @p3='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Inventories" ("Amount", "ProductId")
VALUES (@p2, @p3)
RETURNING "Id";
info: 12/23/2024 17:29:29.182 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "i"."Id", "i"."Amount", "i"."ProductId"
FROM "Inventories" AS "i"
WHERE "i"."Amount" = 3000
LIMIT 1
3000
可以看到程序总共执行了四句SQL语句,分别按顺序对应着上述代码中的四个位置。
重新再运行一次程序,输出就会变成下面这样:只包含两个SQL的执行:
info: 12/23/2024 17:33:14.527 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (7ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Inventories" AS "i"
info: 12/23/2024 17:33:14.594 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "i"."Id", "i"."Amount", "i"."ProductId"
FROM "Inventories" AS "i"
WHERE "i"."Amount" = 3000
LIMIT 1
3000
这也很好理解,因为第二次运行时,数据库中已经有了数据,就不会再执行AddSeedData
中的数据插入逻辑了。等于只执行了一号位置
和四号位置
。
我们重点观察四号位置
对应的SQL语句。接下来,我们对代码进行一点点小改动,如下:
public static void Main(string[] args)
{
AddSeedData();
using var ctx = new ApplicationDBContext();
Inventory? appleInventory = ctx.Inventories.Where(i => i.Amount == 3000).FirstOrDefault();
- Console.WriteLine(appleInventory!.Amount);
+ Console.WriteLine(appleInventory!.Product.Name);
}
执行程序,程序就会抛出一个异常,如下:
info: 12/23/2024 17:35:20.588 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (6ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Inventories" AS "i"
info: 12/23/2024 17:35:20.653 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "i"."Id", "i"."Amount", "i"."ProductId"
FROM "Inventories" AS "i"
WHERE "i"."Amount" = 3000
LIMIT 1
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.Main(String[] args) in .....\Program.cs:line 41
这个异常很好理解:因为ctx.Inventories.Where(...).FirstOrDefault();
其实只是在查数据库中Inventories
对应的那张表,虽然在C#代码中,Inventory
类中引用着Product
类,但EFC在翻译代码逻辑的时候,并不觉得你会使用到这个外键引用的Products
表中的记录,所以自然的,返回的Inventory
对象其实并不包含Product
属性。
而如果我们把代码改成下面这样,EFC会在查询时正确的构造JOIN
语句,但依然,不能为我们构造Inventory.Product
属性,异常依然存在:
public static void Main(string[] args)
{
AddSeedData();
using var ctx = new ApplicationDBContext();
- Inventory? appleInventory = ctx.Inventories.Where(i => i.Amount == 3000).FirstOrDefault();
+ Inventory? appleInventory = ctx.Inventories.Where(i => i.Product.Price < 3.0M).FirstOrDefault();
Console.WriteLine(appleInventory!.Product.Name);
}
对应的SQL长下面这样:
SELECT "i"."Id", "i"."Amount", "i"."ProductId"
FROM "Inventories" AS "i"
INNER JOIN "Products" AS "p" ON "i"."ProductId" = "p"."Id"
WHERE ef_compare("p"."Price", '3.0') < 0
LIMIT 1
那么如何才能让EFC在查询时,把外键引用的外表字段代表的对象也帮我们查询、构造出来呢?这就介绍到下面这个函数了:Include
,使用方法如下:
public static void Main(string[] args)
{
AddSeedData();
using var ctx = new ApplicationDBContext();
- Inventory? appleInventory = ctx.Inventories.Where(i => i.Product.Price < 3.0M).FirstOrDefault();
+ Inventory? appleInventory = ctx.Inventories
+ .Where(i => i.Product.Price < 3.0M)
+ .Include<Inventory, Product>(i => i.Product)
+ .FirstOrDefault();
Console.WriteLine(appleInventory!.Product.Name);
}
Include
的两个类型参数多数情况下可以忽略不写,编译器会自动推断出正确的类型。第一个类型参数是“当前查询出来的Entity数据类型”,第二个参数是“需要附加的Entity数据类型”,括号内的lambda表达式参数是“说明两表之间的引用关系”,或者叫“如何从查询类型引用到附加类型的”。
如此操作后,程序异常就会消失,对应的SQL长下面这样,
SELECT "i"."Id", "i"."Amount", "i"."ProductId", "p"."Id", "p"."Name", "p"."Price"
FROM "Inventories" AS "i"
INNER JOIN "Products" AS "p" ON "i"."ProductId" = "p"."Id"
WHERE ef_compare("p"."Price", '3.0') < 0
LIMIT 1
而如果表间的引用不止一层,就要上ThenInclude
方法了,如下所示:
+public class OriginPlace
+{
+ public int Id { get; set; }
+ public string Name { get; set; }
+}
// 产品
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
+ public IEnumerable<OriginPlace> Origins { get; set; }
}
// 库存
public class Inventory
{
public int Id { get; set; }
public Product Product { get; set; }
public long Amount { get; set; }
}
public class ApplicationDBContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Inventory> Inventories { get; set; }
public DbSet<OriginPlace> OriginPlaces { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
optionsBuilder.UseSqlite("Data Source=HelloEFCTracker.db");
}
}
public static class Program
{
public static void Main(string[] args)
{
AddSeedData();
using var ctx = new ApplicationDBContext();
Inventory? inventory = ctx.Inventories
.Where(i => i.Amount == 3000)
.Include<Inventory, Product>(i => i.Product)
+ .ThenInclude<Inventory, Product, IEnumerable<OriginPlace>>(p => p.Origins)
.FirstOrDefault();
Console.WriteLine(inventory.Product.Origins.FirstOrDefault()!.Name);
}
public static void AddSeedData()
{
using var ctx = new ApplicationDBContext();
if(ctx.Inventories.Count() != 0)
{
return;
}
var china = new OriginPlace { Name = "China" };
var japan = new OriginPlace { Name = "Japan" };
var apple = new Product { Name = "Apple", Price = 2.99M, Origins = new List<OriginPlace> { china, japan} };
var inventory = new Inventory { Product = apple, Amount = 3000 };
ctx.OriginPlaces.Add(china);
ctx.OriginPlaces.Add(japan);
ctx.Products.Add(apple);
ctx.Inventories.Add(inventory);
ctx.SaveChanges();
}
}
上面代码生成的SQL如下:
SELECT "t"."Id", "t"."Amount", "t"."ProductId", "t"."Id0", "t"."Name", "t"."Price", "o"."Id", "o"."Name", "o"."ProductId"
FROM (
SELECT "i"."Id", "i"."Amount", "i"."ProductId", "p"."Id" AS "Id0", "p"."Name", "p"."Price"
FROM "Inventories" AS "i"
INNER JOIN "Products" AS "p" ON "i"."ProductId" = "p"."Id"
WHERE "i"."Amount" = 3000
LIMIT 1
) AS "t"
LEFT JOIN "OriginPlaces" AS "o" ON "t"."Id0" = "o"."ProductId"
ORDER BY "t"."Id", "t"."Id0"
我们以如下的Entity类定义及Context定义,来说明一些EFC框架在建表时的默认行为。多数情况下,EFC的默认行为都是符合直觉的,但你还是有必要去了解一下这些行为。
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
public byte[] Photo { get; set; }
public decimal Height { get; set; }
public float Weight { get; set; }
public int GradeId { get; set; }
public Grade Grade { get; set; }
}
public class Grade
{
public int Id { get; set; }
public string GradeName { get; set; }
public string Section { get; set; }
public IList<Student> Students { get; set; }
}
public class SchoolContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite(@"Data Source=HelloConventions.db");
}
public DbSet<Student> Students { get; set; }
}
以上的代码有以下几个特点:
Student
和Grade
,但在SchoolContext
中只出现了一个DbSet<Student> Students
的定义DbSet<T>
,EFC还会为必要的类建表虽然SchoolContext
中仅有DbSet<Student> Students{get;set;}
的定义,但由于Student
的类定义中引用了Grade
类,EFC也会为Grade
建一张表。
在表名方面
DbSet<T>
定义的Entity,表名与属性名一致,比如存储学生信息的表,名为Students
,而不是Student
。Grade
,而不是Grades
sqlite> .tables
Grade Students __EFMigrationsHistory
但这样做的一个问题是,我们无法通过类似ctx.Grade
的方式来对Grade
表中的数据来做增删改查。这时,我们就必须使用以下的方式,从ctx
对象直接调用方法完成增删改查了
public static void Main(string[] args)
{
using var ctx = new SchoolContext();
// 查询依然需要间接进行
var grades = ctx.Students
.Include<Student, Grade>(stu => stu.Grade)
.Select(stu => stu.Grade)
.Distinct();
// 拿到对象后正常的编辑对象然后SaveChanges就可以修改数据
foreach(var g in grades)
{
g.GradeName = "Super " + g.GradeName;
}
// 插入新数据可以直接使用ctx.Add
ctx.Add<Grade>(new Grade { GradeName = "Seniro", Section = "....." });
// 删除数据可以直接使用ctx.RemoveRange或ctx.Remove
// 注意ctx.RemoveRange不是模板函数,没有类型参数
// ctx.RemoveRange(...);
// ctx.Remove<Grade>(...);
ctx.SaveChanges();
}
以上代码中,Student.Grade
与Grade.Students
互相引用,但从逻辑上来说,这两个引用其实描述的是同一件事:“学生”与“年级”之间,是多对一的关系。
即一个“年级”里能有零个或一个或多个学生,但一个“学生”只能对应一个“年级”
所以在建表定义各列时,EFC会选择最合适的方式去描述这种关系,即:
Grade
表中定义Students
列Students
表中定义对Grade
表的外键,来描述这个一对多的关系如下,通过.schema
命令查看sqlite中的表定义,非常清晰:代码中重复了两遍描述的这段关系,在表中,只用了一个Students.GradeId
字段来描述,外带一个名为FK_Students_Grade_GradeId
的外键约束:
sqlite> .schema Grade
CREATE TABLE IF NOT EXISTS "Grade" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Grade" PRIMARY KEY AUTOINCREMENT,
"GradeName" TEXT NOT NULL,
"Section" TEXT NOT NULL
);
sqlite> .schema Students
CREATE TABLE IF NOT EXISTS "Students" (
"StudentId" INTEGER NOT NULL CONSTRAINT "PK_Students" PRIMARY KEY AUTOINCREMENT,
"FirstName" TEXT NOT NULL,
"LastName" TEXT NOT NULL,
"DateOfBirth" TEXT NOT NULL,
"Photo" BLOB NOT NULL,
"Height" TEXT NOT NULL,
"Weight" REAL NOT NULL,
"GradeId" INTEGER NOT NULL,
CONSTRAINT "FK_Students_Grade_GradeId" FOREIGN KEY ("GradeId") REFERENCES "Grade" ("Id") ON DELETE CASCADE
);
上面我们已经通过.schema
命令展示了两表的定义,通过定义你能看出来以下几点:
int
被翻译成INTEGER NOT NULL
类型
string
被翻译成TEXT NOT NULL
类型
DateOfBirth
在C#中是DateTime
类型,但在sqlite中被翻译成了TEXT NOT NULL
类型。
这是sqlite的个人行为,因为sqlite数据库本身并不支持时间类型。
在诸如MySql或SqlServer等数据库中,会被翻译成合适的时间类型。比如在SqlServer上,会被翻译成datetime2(7), not null
类型
bytes[]
被翻译成BLOB NOT NULL
类型
decimal
被翻译成TEXT NOT NULL
类型
这依然是sqlite的个人行为
float
被翻译成REAL NOT NULL
类型
作为开发者,正常情况下,我们不需要,也不应该去关注类型映射,因为使用的数据库不同,或者换个说法,使用的Data Provider
包不同,映射行为也是不同的。比如上面提到的DateTime
类型和decimal
类型,这两个类型在sqlite上没有原生支持、对应的内置类型,所以框架为了保守起见,会把它们都翻译成TEXT NOT NULL
,再由Data Provider
包进行转译。
但如果你使用的是SqlServer而不是sqlite的话,你就会发现,DateTime
的映射类型是datetime2(7), not null
,而decimal
的映射类型是decimal(18,2), not null
。
话再说回来,作为开发者,正常情况下,你不应该去关心类型映射。。
上面代码所有的Entity中的属性,都被翻译成了xxx NOT NULL
的列定义,即使string
类型与byte[]
在C#中是引用类型,可以持有null
值。
而如果想让EFC建出来的表中的某个列为可空类型,就需要指定对应的Entity属性为xxx ?
可空类型。比如string ? xxx {get; set; }
就会被翻译成一个TEXT
类型,而不是TEXT NOT NULL
。
而显然,虽然在代码中string
类型的变量、属性、字段确实可以持有null
值,但如果你写出以下的代码的话,EFC就会扔给你一个异常:
public static void Main(string[] args)
{
using var ctx = new SchoolContext();
ctx.Students.Add(new Student
{
FirstName = "Tony",
LastName = null,
DateOfBirth = new DateTime(2004, 2, 6),
Photo = Encoding.ASCII.GetBytes("fake photo"),
Height = 174,
Weight = 63,
Grade = new Grade
{
GradeName = "Junior",
Section = "..."
}
});
ctx.SaveChanges();
}
因为在数据库中Students.LastName
是TEXT NOT NULL
类型,扔出的异常长下面这样:
Unhandled exception. Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'NOT NULL constraint failed: Students.LastName'.
at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
at Microsoft.Data.Sqlite.SqliteDataReader.NextResult()
at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader(CommandBehavior behavior)
...
把这个问题的高度拔高一点,相当于在使用EFC编程的过程中,你应当在项目文件中添加如下内容:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
+ <Nullable>enable</Nullable>
</PropertyGroup>
<!-- ... -->
</Project>
并认真对待编译时的每一个有关可空类型的警告信息,以及编写代码时,认真对待IDE的每一处有关可空类型的警告信息。
主键,即"primary key",是关系型数据库表中的一个特殊字段,它是表中每行数据的身份证号,在表中,主键是不会重复的。
EFC会把Entity类中名为Id
或<Entity>Id
的属性作为主键,比如在Student
类中,有字段public int StudentId { get; set; }
,在Grade
类中,有public int Id { get; set; }
。
EFC在扫描Entity类定义时,扫描各个属性名时,是忽略大小写的,这意味着在Student
类中,无论那个字段是叫id
,还是ID
,甚至是STUdEnTID
,都会被EFC拿来当主键。
在我们截至目前所写的所有代码中,所有Entity类的主键属性类型都是int
,并且我们写过的,向数据库添加数据的代码中,都没有对主键属性显式赋过值。比如下面的代码:
// ...
ctx.Add<Grade>(new Grade { GradeName = "Seniro", Section = "....." });
// ...
ctx.SaveChanges();
但实际这行代码是能成功执行的。EFC对于一些特定类型的主键字段,会在创建库表的时候,告诉数据库:如果在数据插入时用户没有为主键提供值,请自动生成一个。
这些“特定类型”有四个:short
, int
, long
以及Guid
。
这里也有一些trick的地方,我们来编写以下的代码来观察一下这种行为在三种不同的数据库上的表现,首先是sqlite,代码如下:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
public class ShortAsPK
{
public short Id { get; set; }
public string Something { get; set; }
}
public class IntAsPK
{
public int Id { get; set; }
public string Something { get; set; }
}
public class LongAsPK
{
public long Id { get; set; }
public string Something { get; set; }
}
public class GuidAsPK
{
public Guid Id { get; set; }
public string Something { get; set; }
}
public class AppContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
optionsBuilder.UseSqlite(@"Data Source=HelloPrimaryKey.db");
}
public DbSet<ShortAsPK> ShortAsPKTable { get; set; }
public DbSet<IntAsPK> IntAsPKTable { get; set; }
public DbSet<LongAsPK> LongAsPKTable { get; set; }
public DbSet<GuidAsPK> GuidAsPKTable { get; set; }
}
public static class Program
{
public static void Main(string[] args)
{
using var ctx = new AppContext();
ctx.ShortAsPKTable.Add(new ShortAsPK { Something = "..." });
ctx.IntAsPKTable.Add(new IntAsPK { Something = "..." });
ctx.LongAsPKTable.Add(new LongAsPK { Something = "..." });
ctx.GuidAsPKTable.Add(new GuidAsPK { Something = "..." });
ctx.SaveChanges();
}
}
首先当然是创建migration:dotnet ef migrations add "Init"
,然后通过dotnet ef migrations script
查看SQL脚本,会发现如下内容:
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
BEGIN TRANSACTION;
CREATE TABLE "GuidAsPKTable" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GuidAsPKTable" PRIMARY KEY,
"Something" TEXT NOT NULL
);
CREATE TABLE "IntAsPKTable" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_IntAsPKTable" PRIMARY KEY AUTOINCREMENT,
"Something" TEXT NOT NULL
);
CREATE TABLE "LongAsPKTable" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_LongAsPKTable" PRIMARY KEY AUTOINCREMENT,
"Something" TEXT NOT NULL
);
CREATE TABLE "ShortAsPKTable" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ShortAsPKTable" PRIMARY KEY AUTOINCREMENT,
"Something" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20241224050022_Init', '8.0.11');
COMMIT;
可以看到:
short
, long
还是int
,在sqlite中都被映射成了INTEGER
AUTOINCREMENT
。这意味着即使是手工执行SQL语句向表中添加行,也不需要显式指定Id
列的值Guid
类型做主键的表,除了主键约束外,没有任何其它额外的料,意味着如果手工执行SQL语句向表中添加行,就需要显式指定Id
列的值在dotnet ef database update
后,执行dotnet run
运行程序,会得到如下日志:
info: 12/24/2024 13:06:19.029 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (3ms) [Parameters=[@p0='?' (DbType = Guid), @p1='?' (Size = 3)], CommandType='Text', CommandTimeout='30']
INSERT INTO "GuidAsPKTable" ("Id", "Something")
VALUES (@p0, @p1);
info: 12/24/2024 13:06:19.037 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 3)], CommandType='Text', CommandTimeout='30']
INSERT INTO "IntAsPKTable" ("Something")
VALUES (@p0)
RETURNING "Id";
info: 12/24/2024 13:06:19.046 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 3)], CommandType='Text', CommandTimeout='30']
INSERT INTO "LongAsPKTable" ("Something")
VALUES (@p0)
RETURNING "Id";
info: 12/24/2024 13:06:19.047 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 3)], CommandType='Text', CommandTimeout='30']
INSERT INTO "ShortAsPKTable" ("Something")
VALUES (@p0)
RETURNING "Id";
可以看到,在向[Int/Long/Short]AsPKTable
三张表中添加数据时,框架执行的SQL语句确实没有为Id
字段赋值。即这三张表中的Id
字段是数据库自己通过AUTOINCREMENT
自己生成的。与我们的代码、EFC框架代码都没关系。
而在向GuidAsPKTable
中添加数据时,框架执行的SQL语句则显式的指定了Id
字段的值。即这张表中的Id
字段是EFC框架自己生成的,然后写入表中的。与我们的代码没关系,与数据库本身也没关系,而是由EFC框架代码负责的。
接下来我们对上面的实验代码做一点小改动,首先是在项目csproj
文件中添加SQLServer的Data Provider包
...
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
</ItemGroup>
...
然后在AppContext
中把连接字符串指向SqlServer,如果你安装了Visual Studio的话,Visual Studio已经附带了一个本地的SqlServer实例,供本地测试使用:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
- optionsBuilder.UseSqlite(@"Data Source=HelloPrimaryKey.db");
+ optionsBuilder.UseSqlServer("Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=HelloPrimaryKey;");
}
把项目下的Migrations
目录中的内容删掉,顺便把之前的HelloPrimaryKey.db
这个sqlite文件也删了,再重新执行一次dotnet ef migrations add "Init"
,再执行dotnet ef migrations script
,观察在SqlServer下的初始化脚本,如下所示:
IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
CREATE TABLE [__EFMigrationsHistory] (
[MigrationId] nvarchar(150) NOT NULL,
[ProductVersion] nvarchar(32) NOT NULL,
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
);
END;
GO
BEGIN TRANSACTION;
GO
CREATE TABLE [GuidAsPKTable] (
[Id] uniqueidentifier NOT NULL,
[Something] nvarchar(max) NOT NULL,
CONSTRAINT [PK_GuidAsPKTable] PRIMARY KEY ([Id])
);
GO
CREATE TABLE [IntAsPKTable] (
[Id] int NOT NULL IDENTITY,
[Something] nvarchar(max) NOT NULL,
CONSTRAINT [PK_IntAsPKTable] PRIMARY KEY ([Id])
);
GO
CREATE TABLE [LongAsPKTable] (
[Id] bigint NOT NULL IDENTITY,
[Something] nvarchar(max) NOT NULL,
CONSTRAINT [PK_LongAsPKTable] PRIMARY KEY ([Id])
);
GO
CREATE TABLE [ShortAsPKTable] (
[Id] smallint NOT NULL IDENTITY,
[Something] nvarchar(max) NOT NULL,
CONSTRAINT [PK_ShortAsPKTable] PRIMARY KEY ([Id])
);
GO
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20241224052935_Init', N'8.0.11');
GO
COMMIT;
GO
SQLServer中,整数类型被标记为IDENTITY
的话,就会自动生成且自增,并且保证唯一。不过和sqlite类似,在GuidAsPKTable
中,SqlServer也没有机制去自动生成Id
字段。初始化脚本中的uniqueidentifier
只是一个保证字段不出现重复值的东西。
在dotnet ef database update
之后,再dotnet run
运行程序,观察输出
info: 12/24/2024 13:34:41.407 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (32ms) [Parameters=[@p0='?' (DbType = Guid), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000), @p4='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
INSERT INTO [GuidAsPKTable] ([Id], [Something])
VALUES (@p0, @p1);
INSERT INTO [IntAsPKTable] ([Something])
OUTPUT INSERTED.[Id]
VALUES (@p2);
INSERT INTO [LongAsPKTable] ([Something])
OUTPUT INSERTED.[Id]
VALUES (@p3);
INSERT INTO [ShortAsPKTable] ([Something])
OUTPUT INSERTED.[Id]
VALUES (@p4);
SqlServer在EFC中的实现比Sqlite智能了一些,会把插入操作合并成一个Query去执行,但依然,是EFC框架代码生成的GuidAsPKTable.Id
字段,不是数据库本身。
我们再切到MySQL/mariaDB看一眼,如何安装MySQL/mariaDB,以及如何改代码,这里就不再赘述了,这里直接贴实验结果:
初始化脚本如下:
CREATE TABLE IF NOT EXISTS `__EFMigrationsHistory` (
`MigrationId` varchar(150) NOT NULL,
`ProductVersion` varchar(32) NOT NULL,
PRIMARY KEY (`MigrationId`)
);
START TRANSACTION;
CREATE TABLE `GuidAsPKTable` (
`Id` char(36) NOT NULL,
`Something` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
CREATE TABLE `IntAsPKTable` (
`Id` int NOT NULL AUTO_INCREMENT,
`Something` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
CREATE TABLE `LongAsPKTable` (
`Id` bigint NOT NULL AUTO_INCREMENT,
`Something` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
CREATE TABLE `ShortAsPKTable` (
`Id` smallint NOT NULL AUTO_INCREMENT,
`Something` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
INSERT INTO `__EFMigrationsHistory` (`MigrationId`, `ProductVersion`)
VALUES ('20241224054311_Init', '8.0.11');
COMMIT;
程序运行日志如下:
info: 12/24/2024 13:45:19.790 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (6ms) [Parameters=[@p0='?' (DbType = Guid), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000), @p4='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
INSERT INTO `GuidAsPKTable` (`Id`, `Something`)
VALUES (@p0, @p1);
INSERT INTO `IntAsPKTable` (`Something`)
VALUES (@p2);
SELECT `Id`
FROM `IntAsPKTable`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();
INSERT INTO `LongAsPKTable` (`Something`)
VALUES (@p3);
SELECT `Id`
FROM `LongAsPKTable`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();
INSERT INTO `ShortAsPKTable` (`Something`)
VALUES (@p4);
SELECT `Id`
FROM `ShortAsPKTable`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();
MySQL和前两者一样,都是EFC框架在负责生成Guid。在执行层面,SqlServer最优化,将所有插入语句合并成了一条INSERT
,MySQL次之,合并了,但没完全合并,最次的是sqlite,压根没优化。
一点小小的总结 :
short
, int
, long
, Guid
做主键,不需要管主键的值,值会自动生成我们再回过头看我们那个Student
和Grade
的示例代码,我把代码再贴一遍
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
public byte[] Photo { get; set; }
public decimal Height { get; set; }
public float Weight { get; set; }
public int GradeId { get; set; }
public Grade Grade { get; set; }
}
public class Grade
{
public int Id { get; set; }
public string GradeName { get; set; }
public string Section { get; set; }
public IList<Student> Students { get; set; }
}
这里有个细节:Student
类中定义了一个GradeId
属性。
知识点是:这个属性可以省略不定义,即使你在代码中不定义这个属性,生成的Students
表中,依然会有一个列,名为GradeId
。在数据库层面,就是使用这个GradeId
字段当外键,来描述两张表之间的多对一的关系的。
而一个无聊的知识点是:在数据库中的Students
表中,这个外键字段,或者叫列,的名字是什么,下面是一张表,描述了EFC框架的默认行为:
Student 类中如果有 |
Student 类中如果还有 |
Grade 类中如果有 |
那么在数据库中,Students 表中的外键字段的名字是 |
---|---|---|---|
public Grade Grade {get;set;} |
public int GradeId{get;set;} |
public int GradeId{get;set;} |
GradeId |
public Grade Grade {get;set;} |
没有GradeId 的定义 |
public int GradeId{get;set;} |
GradeId |
public Grade Grade {get;set;} |
没有GradeId 的定义 |
public int Id{get;set;} |
GradeId |
public Grade CurrentGrade {get;set;} |
public int CurrentGradeId{get;set;} |
public int GradeId{get;set;} |
CurrentGradeId |
public Grade CurrentGrade {get;set;} |
没有GradeId 的定义 |
public int GradeId{get;set;} |
CurrentGradeGradeId |
public Grade CurrentGrade {get;set;} |
没有GradeId 的定义 |
public int Id{get;set;} |
CurrentGradeId |
public Grade CurrentGrade {get;set;} |
public int GradeId{get;set;} |
public int Id{get;set;} |
GradeId |
代码中的Entity之间若要有关联,无非就是三种:
一对多的关系。
这种最常见,上面的Student
和Grade
就是典型的一对多的关系,或者叫多对一的关系:多个Student
对应一个Grade
一对一的关系。
这种也不罕见,比如“学生信息”与“登录账号”之间,就是典型的一对一的关系。典型的,在学校的学生信息管理系统中,一个登录账户严格对应一个学生的信息。
扩展一点,多数有登录功能的应用,都是把“账号信息”和“用户身份信息”分割成两张表,甚至多张表。“账号信息”只存储与认证授权功能相关的一些关键字段,比如用户名、密码HASH之类的,更多的信息存储在另外一张表中。
多对多的关系
多对多的关系也比较常见,这个与前两者都不一样,它是用三张表来描述两个Entity之间的关系。
典型的,就比如asp .net core identity框架中的User
和Role
之间的关系:一个User
可以有多个Role
,一个Role
显然可以有多个User
。
比如在电子税务局系统中,张三可以既是公司法人,还兼任公司财务负责人。而整个系统中,公司法人可以有成千上万个,公司财务负责人也可以有成千上万个。
这种情况下,一般是先分别存储User
和Role
,另外再使用一张额外的表,来记录UserId
与RoleId
之间的映射关系。
我们现在来给大家说一下,在使用EFC框架时,如何定义Entity来实现上述三种关系,以及对应的底层的数据库表会长什么样子:
这个非常简单,就是两个Entity类互相持有对方的一个引用,即可描述这种关系,比如下面这种:
public class AuthUser
{
public int Id { get; set; }
public string UserName { get; set; }
public string PasswordHash { get; set; }
public UserProfile UserProfile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public string Gender { get; set; }
public DateTime Birthday { get; set; }
public AuthUser AuthUser { get; set; }
}
但按上面这种定义来写代码的话,EFC框架在dotnet ef migrations add xxx
的时候就会给你扔出个异常,如下所示:
Unable to create a 'DbContext' of type ''. The exception 'The dependent side could not be determined for the one-to-one relationship between 'AuthUser.UserProfile' and 'UserProfile.AuthUser'. To identify the dependent side of the relationship, configure the foreign key property. If these navigations should not be part of the same relationship, configure them independently via separate method chains in 'OnModelCreating'. See https://go.microsoft.com/fwlink/?LinkId=724062 for more details.' was thrown while attempting to create an instance. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728
什么意思呢?意思是,EFC看懂了你这是想搞一对一的关系表,但EFC不知道应该把外键放在哪张表里。换句话说,EFC只会为一对一的两张表,生成一个单向的外键。
所以我们这时就需要手动在其中一张表中,添加一个字段,来提示EFC,请把外键放在这里,如下:
public class AuthUser
{
public int Id { get; set; }
public string UserName { get; set; }
public string PasswordHash { get; set; }
public UserProfile UserProfile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public string Gender { get; set; }
public DateTime Birthday { get; set; }
+ public int AuthUserId { get; set; }
public AuthUser AuthUser { get; set; }
}
这样生成的两张表中,只有在UserProfiles
表中存在一个指向AuthUsers
表的外键。如下(MySQL的初始化SQL脚本)
CREATE TABLE `AuthUsers` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserName` longtext NOT NULL,
`PasswordHash` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
CREATE TABLE `UserProfiles` (
`Id` int NOT NULL AUTO_INCREMENT,
`Gender` longtext NOT NULL,
`Birthday` datetime(6) NOT NULL,
`AuthUserId` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_UserProfiles_AuthUsers_AuthUserId` FOREIGN KEY (`AuthUserId`) REFERENCES `AuthUsers` (`Id`) ON DELETE CASCADE
);
建立一以多的关系有三种写法,如下:
第一种:多引一。在“多”的类定义中,显式引用“一”
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
+ public Grade Grade { get; set; }
}
public class Grade
{
public int Id { get; set; }
public string Name { get; set; }
}
第二种:一引多。在“一”的类定义中,以IEnumerable<T>
或ICollection<T>
,或其它容器集合类型,引用“多”
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Grade
{
public int Id { get; set; }
public string Name { get; set; }
+ public IEnumerable<Student> Students { get; set; }
}
第三种:互相引。
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
+ public Grade Grade { get; set; }
}
public class Grade
{
public int Id { get; set; }
public string Name { get; set; }
+ public IEnumerable<Student> Students { get; set; }
}
这三种写法写出来的两张表,几乎是完全等价的,EFC建表时,只会在“多”的那一方,添加一个外键字段,即在Students
表中,添加一个GradeId
外键。
只有一处细微的差别:
多引一,以及互相引的写法,会在Students.GradeId
这个外键上创建一个级联删除,如下所示:
CREATE TABLE `Students` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NOT NULL,
`GradeId` int NOT NULL,
PRIMARY KEY (`Id`),
+ CONSTRAINT `FK_Students_Grades_GradeId` FOREIGN KEY (`GradeId`) REFERENCES `Grades` (`Id`) ON DELETE CASCADE
);
但一引多的写法,不会创建这个级联删除,如下:
CREATE TABLE `Students` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NOT NULL,
`GradeId` int NULL,
PRIMARY KEY (`Id`),
+ CONSTRAINT `FK_Students_Grades_GradeId` FOREIGN KEY (`GradeId`) REFERENCES `Grades` (`Id`)
);
这三种写法具体选用哪种,看实际情况灵活选择,我个人建议使用“互相引”的写法。
多对多的关系,在EntityFramework框架早期的时候,是很蛋疼的,但现在,你可以直接写出下面这样的代码:
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public IEnumerable<Role> Roles { get; set; }
}
public class Role
{
public int Id { get; set; }
public string Name { get; set; }
public IEnumerable<User> Users { get; set; }
}
然后你会发现,EFC框架默默的为我们生成了第三张表,建表的SQL脚本如下(mysql脚本)
CREATE TABLE `Roles` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
CREATE TABLE `Users` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NOT NULL,
PRIMARY KEY (`Id`)
);
CREATE TABLE `RoleUser` (
`RolesId` int NOT NULL,
`UsersId` int NOT NULL,
PRIMARY KEY (`RolesId`, `UsersId`),
CONSTRAINT `FK_RoleUser_Roles_RolesId` FOREIGN KEY (`RolesId`) REFERENCES `Roles` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_RoleUser_Users_UsersId` FOREIGN KEY (`UsersId`) REFERENCES `Users` (`Id`) ON DELETE CASCADE
);
CREATE INDEX `IX_RoleUser_UsersId` ON `RoleUser` (`UsersId`);
通常情况下,上面这种基本的写法已经足够应付日常开发。但请注意网上有很多过时的教程。
这些过时的教程可能写EFC在多对多方面支持甚少的时期,它们会告诉你,需要手动创建一个UserRole
的类,然后在Context
中的OnModelCreating
的添加一两行额外的代码,来配置一个联合主键等等。
不要理会那些教程,现在,请直接按上面的写法写就可以了。
目前我们掌握的知识已经足够差不多应付95%的开发工作了,但回头来看,EFC框架确实很方便,但总让人有一种“对数据库掌控不足”的感觉。
这个感觉是对的:因为我们没有像10年前甚至20年前的程序员一样,分析、思考如何建表,表中每个字段应当用什么类型,应当为哪些字段建立索引等等。所有的这些索引、约束、外键之类的杂事,我们都扔给EFC处理了。
多数情况下,这就基本足够了。
但你可能觉得将string翻译成sqlite中的TEXT
,SQLServer中的nvarchar(max)
或mysql/mariaDB中的longtext
太过浪费,或者除了主键之外,你想对其它某几个重要字段建立索引以提高查询性能。
你的想法没有错,但你的想法很不合时宜:你太过于小瞧数据库产品,哪怕是号称性能最差的sqlite,你也太过于高看你自己写的那坨烂代码了。
你扪心自问:你的生意真的做的足够大,大到单机部署的性能瓶颈还没来,数据库查询上的IO瓶颈先一步来了?
好了,平复一下心情,骂就先骂到这里,但作为知识点,还是有必要介绍一下,如何在EFC中进数据库进行进一步的细节配置。
或者更具体一点:如何对数据库中的表的定义,进行进一步配置。
以为一个额外字段添加索引为例,EFC有两种配置方式,一种,是在DBContext
类中,覆盖一个名为OnModelCreating
的方法,在其中使用代码描述配置,这种配置方式因其API的设计模式而得名,叫fluent API,如下:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.HasIndex(b => b.Url);
}
另一种,是在Entity类的脑门,使用属性,或者叫注解,去配置,如下:
[Index(nameof(Url))]
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
}
这两种方式各有优劣,属性,或者叫注解看起来更洋气一点,也更自然一点。不过注解的功能比较有限,fluent API的历史比较长一点
自定义表名
[Table("blogs")]
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
}
给表添加注释
[Comment("Blogs managed on the website")]
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
}
在建表时忽略指定字段
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
[NotMapped]
public DateTime LoadedFromDatabase { get; set; }
}
自定义字段名、为字段添加注释
public class Blog
{
[Column("blog_id")]
public int BlogId { get; set; }
[Comment("The URL of the blog")]
public string Url { get; set; }
}
为字段排顺序
public class EntityBase
{
[Column(Order = 0)]
public int Id { get; set; }
}
public class PersonBase : EntityBase
{
[Column(Order = 1)]
public string FirstName { get; set; }
[Column(Order = 2)]
public string LastName { get; set; }
}
public class Employee : PersonBase
{
public string Department { get; set; }
public decimal AnnualSalary { get; set; }
}
进一步控制字段类型
// 直接指定类型
public class Blog
{
public int BlogId { get; set; }
[Column(TypeName = "varchar(200)")]
public string Url { get; set; }
[Column(TypeName = "decimal(5, 2)")]
public decimal Rating { get; set; }
}
// 暗示字符串类型的长度
public class Blog
{
public int BlogId { get; set; }
[MaxLength(500)]
public string Url { get; set; }
}
// 暗示类型的精度
public class Blog
{
public int BlogId { get; set; }
[Precision(14, 2)]
public decimal Score { get; set; }
[Precision(3)]
public DateTime LastUpdated { get; set; }
}
// 使用Unicode
public class Book
{
public int Id { get; set; }
public string Title { get; set; }
[Unicode(true)]
public string Isbn { get; set; }
}
指定主键
// 指定单一主键
internal class Car
{
[Key]
public string LicensePlate { get; set; }
public string Make { get; set; }
public string Model { get; set; }
}
// 指定联合主键
[PrimaryKey(nameof(State), nameof(LicensePlate))]
internal class Car
{
public string State { get; set; }
public string LicensePlate { get; set; }
public string Make { get; set; }
public string Model { get; set; }
}
字段的默认值与自动生成值
// 默认值无法使用注解设置
// 计算值也无法使用注解设置
// 插入时自动生成值(如果值没有提供的话)
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public DateTime Inserted { get; set; }
}
// 插入或变更时自动更新值
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
public DateTime LastUpdated { get; set; }
}
添加索引
// 普通索引
[Index(nameof(Url))]
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
}
// 联合索引
[Index(nameof(FirstName), nameof(LastName))]
public class Person
{
public int PersonId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
// 唯一索引
[Index(nameof(Url), IsUnique = true)]
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
}
自定义表名
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.ToTable("blogs");
}
给表添加注释
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().ToTable(
tableBuilder => tableBuilder.HasComment("Blogs managed on the website"));
}
在建表时忽略指定字段
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Ignore(b => b.LoadedFromDatabase);
}
自定义字段名,为字段添加注释
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.BlogId)
.HasColumnName("blog_id");
modelBuilder.Entity<Blog>()
.Property(b => b.Url)
.HasComment("The URL of the blog");
}
为字段排顺序
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Employee>(x =>
{
x.Property(b => b.Id)
.HasColumnOrder(0);
x.Property(b => b.FirstName)
.HasColumnOrder(1);
x.Property(b => b.LastName)
.HasColumnOrder(2);
});
}
进一步控制字段类型
// 直接指定类型
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>(
eb =>
{
eb.Property(b => b.Url).HasColumnType("varchar(200)");
eb.Property(b => b.Rating).HasColumnType("decimal(5, 2)");
});
}
// 暗示字符串类型的长度
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.Url)
.HasMaxLength(500);
}
// 暗示类型精度
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.Score)
.HasPrecision(14, 2);
modelBuilder.Entity<Blog>()
.Property(b => b.LastUpdated)
.HasPrecision(3);
}
// 使用Unicode
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>()
.Property(b => b.Isbn)
.IsUnicode(true);
}
指定主键
// 指定单一主键
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Car>()
.HasKey(c => c.LicensePlate);
}
// 指定联合主键
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Car>()
.HasKey(c => new { c.State, c.LicensePlate });
}
字段的默认值与自动生成值
// 默认值
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.Rating)
.HasDefaultValue(3);
}
// 使用sql表达式的默认值,一定程度上相当于自动值
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.Created)
.HasDefaultValueSql("getdate()");
}
// 计算虚值:值其实并不存在在数据库中,而是每次查询时实时计算
modelBuilder.Entity<Person>()
.Property(p => p.DisplayName)
.HasComputedColumnSql("[LastName] + ', ' + [FirstName]");
// 计算实值:值存储在数据库中,每次插入或更新时值自动更新
modelBuilder.Entity<Person>()
.Property(p => p.NameLength)
.HasComputedColumnSql("LEN([LastName]) + LEN([FirstName])", stored: true);
// 插入时自动生成值
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.Inserted)
.ValueGeneratedOnAdd();
}
// 插入或更新时自动更新值
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.LastUpdated)
.ValueGeneratedOnAddOrUpdate();
}
添加索引
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 普通索引
modelBuilder.Entity<Blog>()
.HasIndex(b => b.Url);
// 联合索引
modelBuilder.Entity<Person>()
.HasIndex(p => new { p.FirstName, p.LastName });
// 唯一索引
modelBuilder.Entity<Blog>()
.HasIndex(b => b.Url)
.IsUnique();
}
DbContext
的配置与初始化上面所有的示例我们都是在写一个简单的控制台应用程序,我们目前对DbContext
的了解仅限于以下几点:
OnConfiguring
中配置要使用的Data Provider,连接字符串,以及配置日志怎么输出OnModelCreating
中可以用fluent API对建表的过程进行进一步精细控制using
来创建DbContext
对象除此之外我们对DbContext
知之甚少,这里补充一些必要的知识
DbContext
对象的生命周期应该是什么样的虽然从体验上来说,DbContext
给人的感觉像是在持有一个活动的数据库连接,仿佛每次创建一个DbContext
对象,都会让程序新建一个与数据库的连接。但实际上并不一定是这样,或者说,作为EFC框架的使用者,我们不应该假定DbContext
与数据库连接之间有任何映射关系。
你的程序新建了几个DbContext
,与EFC框架在背后与数据库新建了几个连接,这两者之间虽然实际上确实有映射关系,但作为程序员来说,我们不应当去假定这种关系。
请你放宽心一点,不要觉得每次用到DbContext
的时候去新建一个会导致资源的浪费,退一万步讲,即便每个DbContext
都严格映射着一个数据库连接,EFC框架没有对网络连接做任何池化优化,就你写的那个烂代码,也不会碰到什么严重的性能问题。
重要的不是去思考DbContext
里是怎么连接到数据库的,而是去保证:每个DbContext
都会被析构,即Dispose
掉。
对待DbContext
,更正确的理解是:将它理解为“事务”的客户端去使用,并将它的生命周期尽量缩短。
在非Web应用中
将你的业务逻辑在数据层面的操作拆分成一个个的小单元,每个小单元里对数据的操作都应当是“事务”的,即要么一起成功,要么一起失败。
然后把每个操作单元,封装成方法,或函数,在函数中,使用using var ctx = new xxxDbContext()
来创建DbContext
实例,以确保在这个有限的小单元中,DbContext
可以被稳妥的Dispose
掉。
并且在最终函数退出之前,调用ctx.SaveChanges()
或await ctx.SaveChangesAsync()
。
在Web应用中
有两种选择:
Scoped : 大多数情况下你应该选这种
将DbContext
注册成DI池中的Scoped对象,让DI池去控制它的创建与析构。在使用方只需要接收注入就可以了,不需要使用using
来控制其析构过程。
在每次应对Http请求时,将每个单个的Http请求视为一个大的“事务”。
Transient : 除非你对DI机制与DbContext
的细节很了解,或者有其它非常必要的理由,否则不要用这种。
将DbContext
注册成DI池中的Transient对象,依然不需要使用using
去控制析构过程。
在每次应对Http请求时,每个用到DbContext
的功能模块都能拿到自己独立的DbContext
。这些DbContext
会在Http请求结束,scoped DI container析构的时候一起统一析构。
DbContext
ASP .NET Core程序中,一般是以以下的方式向DI池中注册DbContext
的:
var connectionString =
builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string"
+ "'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
这里有几个要点:
AddDbContext
扩展方法,注册的ApplicationDbContext
的生命周期是scoped的
AddDbContext
方法的参数
DbContextOptions<ApplicationDbContext>
的对象加料。这个参数的实际类型是Action<DbContextOptionsBuilder>
DbContextOptions<ApplicationDbContext>
对象会在创建ApplicationDbContext
的时候当成参数传递给构造函数这就要求ApplicationDbContext
必须有一个构造函数,其接受一个类型为DbContextOptions<ApplicationDbContext>
的参数。
就像下面这样:
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
这样,我们就可以把要配置的信息全写在那个lambda表达式中,间接的来控制ApplicationDbContext
的初始化过程,即配置它。
要使用ApplicationDbContext
的时候,就正常用依赖注入就可以了,如下:
public class MyController
{
private readonly ApplicationDbContext _context;
public MyController(ApplicationDbContext context)
{
_context = context;
}
}
DbContext
在没有依赖注入的程序中,就像我们这篇文章里所有的控制台示例程序一样,在使用时,我们大多是使用new
来就地创建一个DbContext
实例的。
而要把配置信息传递给初始化过程的话,一方面,我们可以使用带DbContextOptions<ApplicationDbContext> options
参数的构造函数,就像asp .net core一样,在构造函数内部调用base(options)
。
但这样的缺陷是我们需要手动构造一个options
对象,但请注意,请不要直接使用new
来构造这个对象,所有的EFC公开文档与示例代码都没人这样做过,正确的做法是通过一个构造器类DbContextOptionsBuilder<T>
,如下去构造:
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
public static class Program
{
public static void Main(string[] args)
{
var contextOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Test;ConnectRetryCount=0")
.Options;
using var context = new ApplicationDbContext(contextOptions);
}
}
另外一种途径,是去覆盖DbContext
中一个叫OnConfiguring
的虚方法,这个方法在DbContext
的初始化过程中被EFC框架调用,方法的入参就是一个options
对象的构造器,即DbContextOptionsBuilder
对象。
示例如下:
public class ApplicationDbContext : DbContext
{
private readonly string _connectionString;
public ApplicationDbContext(string connectionString)
{
_connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(_connectionString);
}
}
public static class Program
{
public static void Main(string[] args)
{
using var context = new ApplicationDbContext(@"Server=(localdb)\mssqllocaldb;Database=Test;ConnectRetryCount=0");
}
}
DbContext
实例在一些有依赖注入环境,但DI池对对象的生命周期的定义与业务逻辑不是很匹配的时候。典型的,比如在一个非常复杂的asp .net core程序中,对于每个Http请求,服务器后台都需要完成很多数据库操作,甚至于在一个Controller
里,你想用到多个不同的DbContext
实例。
这时你可能需要更精细的去控制DbContext
的生命周期,这时,当然,你可以将DbContext
在DI中注册为transient类型,也甚至可以使用new
来随心所欲的初始化,但一个更好的方式,是去使用工厂类,如下:
// 在DI池中注册工厂
services.AddDbContextFactory<ApplicationDbContext>(
options => options.UseSqlServer(
@"Server=(localdb)\mssqllocaldb;Database=Test;ConnectRetryCount=0"));
// ...
// DbContext类必须有参数为DbContextOptions<T>的构造函数,如下:
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
// 在Controller中注入工厂类实例
public class MyController
{
private readonly IDbContextFactory<ApplicationDbContext> _contextFactory;
public MyController(IDbContextFactory<ApplicationDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
[HttpPost]
...
{
using (var ctx = _contextFactory.CreateDbContext())
{
// ...
// 执行第一单元的业务
}
using (var ctx = _contextFactory.CreateDbContext())
{
// ...
// 执行第二单元的业务
}
// ...
}
}
DbContext
还有哪些可配置的东西?你回顾一下DbContext
的几种初始化方式,就会发现,所有的初始化方式,虽然写法不同,生命周期不同,但一个共同的点是:
DbContextOptions<T>
对象DbContextOptions<T>
对象的,总是间接的在用DbContextOptionsBuilder
,或DbContextOptionsBuilder<T>
来创建配置对象所以,这个小节的问题其实等价于:DbContextOptionsBuilder
,包括泛型与非泛型版本,除了配置Data Provider和连接字符串外,有哪些方法是框架使用者需要熟悉的,分别都有什么功能?
大部分可配置的东西都是与EFC的日志相关:
方法名 | 功能 |
---|---|
LogTo |
配置简单日志输出 |
UseLoggerFactory |
配置日志工厂类,相当于进阶的日志配置 |
EnableSensitiveDataLogging |
在日志中展示数据。如果不调用这个方法,你只能在日志中看到执行的SQL,但看不到参数的值 |
EnableDetailedErrors |
在日志中展示更细节的错误信息 |
ConfigureWarnings |
进一步配置当Warning发生时,EFC框架应当如何处理,是忽略警告继续执行还是抛出异常给上层,以及是否要写进日志等 |
另外也有几个可配置的东西,但这些属于比较高阶的EFC玩法,这篇文章就不介绍了,有兴趣的同学自行去了解:
方法名 | 功能 |
---|---|
AddInterceptors |
拦截器特性 |
UseLazyLoadingProxies |
懒加载特性,并不是EFC核心功能,而是作为DLC实现在包Microsoft.EntityFrameworkCore.Proxies 中 |
UseChangeTrackingProxies |
追踪代理,并不是EFC核心功能,而是作为DLC实现在包Microsoft.EntityFrameworkCore.Proxies 中 |
DbContext
的池化我们上一章节也说了,作为EFC框架的使用者,我们不应当假定DbContext
对象与底层的数据库连接有任何映射关系。也就是说,在同时创建了一千个DbContext
后,你不应当假定程序进程与数据库之间保持了一千个活动的网络连接。
事实上,在.NET Core程序中,数据库连接管理是由各个数据库产品的Driver包负责的,而EFC框架作者其实也不关心底层到底有多少活动的连接、这些连接是否会被复用、是否被池化管理等。
这就是软件设计的分层。
虽然说在认识到这一点后,我们就基本可以放心大胆的按照需求去使用DbContext
实例了,但对于我们写业务逻辑代码的程序员来说,DbContext
就是数据库句柄,虽然我们不需要、也不应该了解更多的底层细节,但可以确定的是,DbContext
对象是一种需要被释放的资源对象。
而资源对象本身的构造、析构过程是要消耗性能的,太过频繁的创建、析构肯定是不好的。要是能把这些资源对象循环利用起来,尽量减少构造、析构动作,肯定对性能是有益的。
这,就是DbContext
的“池化”:设计思路是在程序一启动的时候,就创建一堆DbContext
对象,用的时候从池子里直接取,用完了放回池子里,不析构,等待下一次复用。
“池化”默认是不开启的,要开启“池化”也很简单,
在有依赖注入功能的程序中,使用AddDbContextPool
替换掉AddDbContext
方法,如下
var connectionString =
builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string"
+ "'DefaultConnection' not found.");
-builder.Services.AddDbContext<ApplicationDbContext>(options =>
- options.UseSqlServer(connectionString));
+builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
+ options.UseSqlServer(connectionString));
在没有依赖注入的程序中,使用一个特殊的工厂类
var contextOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Test;ConnectRetryCount=0")
.Options;
+
+var factory = new PooledDbContextFactory<ApplicationDbContext>(options);
-using var context = new ApplicationDbContext(contextOptions);
+using var context = factory.CreateDbContext();
默认情况下,池子的容量是1024,当池子里的对象不够用的时候,逻辑就会降级到普通的非池化逻辑,即在池外创建DbContext
实例并返回给使用方,池外创建的DbContext
在析构时是直接析构的,不会进池缓存。
EFC框架历史相当长,特性与功能都比较丰富,我们在这里仅仅是介绍了一些常用到的特性,足够你应付日常开发。
对于全栈开发者而言,没有必要把精力过多的放在细枝末节的一些特性上,程序能跑起来,功能正确,是最重要的。
EFC就相当于一把多功能手电钻,它功能很多也很强大,但对于一个木匠而言,最重要的并不是精通这把手电钻的各种功能与用法。最重要的是先把你要做的“家具”攒出来。
在做“家具”的过程中,你可能用了不太合适的螺丝、铰链、连接件,可能某个卯榫做的比较松散质量不达标,可能由于不熟悉手电钻而把某个螺丝拧花了,缺乏经验没有按最佳实践去刷漆导致漆面不光滑,但从整个应用的开发过程而言,这些都不是重点,重点是你能把“家具”做出来,能用上。
全栈开发者跟木匠很像,我们甚至比现实世界的木匠更幸运:因为所有的缺陷其实都可以通过修修补补慢慢迭代而改善,而现实世界的木匠如果一个关键的卯榫打废了,可能整个项目就要推倒重来了。