不知道大伙伴们有没有这样的想法:如果我不定义实体类,那 EF Core 能建模吗?能正常映射数据库吗?能正常增删改查吗?
虽然一般开发场景很少这么干,但有时候,尤其是数据库中的某些视图,就不太想给它定义实体类。好消息,EF Core 还真支持不定义实体类的。可是,你一定会疑惑了,不定义实体类,那还怎么面向对象呢?不急,咱们一个个去探寻真相。
先看看这个自定义的上下文类。
public class MyDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("server=...");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
EntityTypeBuilder ent = modelBuilder.Entity("Student");
// 先把它标记为无主键,避免报错
ent.HasNoKey();
}
}
这里我给数据库模型添加了一个叫 Student 的实体,我可没有定义对应的类。然后我们打印一下模型信息,看看能不能建模。
internal class Program
{
static void Main(string[] args)
{
using var context = new MyDbContext();
// 打印模型信息
Console.WriteLine(context.Model.ToDebugString());
}
}
运行一下,好家伙,你看,还真的能建模了。
Model:
EntityType: Student (Dictionary<string, object>) CLR Type: **Dictionary\<string, object\>** Keyless
注意 CLR Type 后面的信息。这下懂了,当你不给 EF Core 提供实体类时,它会默认使用字典类型,Key 是字符串,Value 是 object 类型。
我们继续验证,既然能建模了,那定义些属性,包括主键。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
EntityTypeBuilder ent = modelBuilder.Entity("Student");
// 添加属性
ent.Property<int>("StuID");
ent.Property<string>("Name").IsRequired(true);
ent.Property<int>("Age").IsRequired();
ent.Property<string>("Major").IsRequired(false);
// 主键
ent.HasKey("StuID");
}
Property 方法要指定属性的类型,因为没有对应的 CLR 属性,不然 EF 不知道这个属性是什么类型。这个其实就像影子属性。
现在再看看模型的信息。
Model:
EntityType: Student (Dictionary<string, object>) CLR Type: Dictionary<string, object>
Properties:
StuID (no field, int) Indexer Required PK AfterSave:Throw ValueGenerated.OnAdd
Age (no field, int) Indexer Required
Major (no field, string) Indexer
Name (no field, string) Indexer Required
Keys:
StuID PK
看来是没问题的。
好,进入下一个验证阶段。我们用这个模型去创建数据库(这里我用的是 SQL Server)。
public class MyDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("server=(localdb)\\MSSQLLocalDB;database=my_school");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
......
}
}
internal class Program
{
static void Main(string[] args)
{
using var context = new MyDbContext();
......
// 创建数据库
context.Database.EnsureCreated();
}
}
运行后,成功创建数据库,包含数据表 Student。
CREATE TABLE [dbo].[Student] (
[StuID] INT IDENTITY (1, 1) NOT NULL,
[Age] INT NOT NULL,
[Major] NVARCHAR (MAX) NULL,
[Name] NVARCHAR (MAX) NOT NULL,
CONSTRAINT [PK_Student] PRIMARY KEY CLUSTERED ([StuID] ASC)
);
默认它使用了 Student 为表名。
咱们的数据库上下文还少了一个 DbSet<> 属性,为了能访问数据,应当定义此属性。不过,这样定义是错误的。
public class MyDbContext : DbContext
{
......
public DbSet<Dictionary<string, object>> Students { get; set; }
}
虽然在运行的时候没有抛出异常,但是,Students 属性在 DbContext 初始化时,DbSet<> 没有被正确设置,即内部的 InternalDbSet 类初始化失败。说白了这个 DbSet 类型的属性你不能正常访问。
什么原因?当我们通过 LINQ 查询时,由于 DbSet 本身就是实现了 IQueryable<> 接口的,因此,EntityQueryable 属性会被 IEnumerable.GetEnumerator() 等方法访问。

然后我们再看 EntityQueryable 属性的代码。
private EntityQueryable<TEntity> EntityQueryable
{
get
{
CheckState();
return NonCapturingLazyInitializer.EnsureInitialized(
ref field,
this,
static internalSet => internalSet.CreateEntityQueryable());
}
}
这里触发了CheckState 方法。
private void CheckState()
// ReSharper disable once AssignmentIsFullyDiscarded
=> _ = EntityType;
CheckState 方法又访问了 EntityType 属性。
public override IEntityType EntityType
{
get
{
if (field != null)
{
return field;
}
field = _entityTypeName != null
? _context.Model.FindEntityType(_entityTypeName)
: _context.Model.FindEntityType(typeof(TEntity));
if (field == null)
{
if (_context.Model.IsShared(typeof(TEntity)))
{
throw new InvalidOperationException(CoreStrings.InvalidSetSharedType(typeof(TEntity).ShortDisplayName()));
}
......
return field;
}
}
其他地方不用看,重点是发现中间有一段是判断实体的类型是否为共享类型。IsShared 方法是 Model 类中定义的。
public virtual bool IsShared([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type)
=> FindIsSharedConfigurationSource(type) != null
|| Configuration?.GetConfigurationType(type) == TypeConfigurationType.SharedTypeEntityType;
public virtual ConfigurationSource? FindIsSharedConfigurationSource(Type type)
=> _sharedTypes.TryGetValue(type, out var existingTypes) ? existingTypes.ConfigurationSource : null;
_sharedTypes 是 Model 类的字段,从初始化时,它就把 Dictionary<string, object> 设定为共享类型。
public static readonly Type DefaultPropertyBagType = typeof(Dictionary<string, object>);
private readonly Dictionary<Type, (ConfigurationSource ConfigurationSource, SortedSet<EntityType> Types)> _sharedTypes =
new() { { **DefaultPropertyBagType**, (ConfigurationSource.Explicit, new SortedSet<EntityType>(TypeBaseNameComparer.Instance)) } };
看到没,_sharedTypes 字段从 new 那一刻起,它里面就包含了 Dictionary<string, object> 类型。
共享类型的特点是允许多个实体使用同一个 .NET 类,而字典类型就是默认的共享类型。而使用 DbSet<...> Students { get; set; } 这种格式定义的属性,在 DbContext 类的内部,只用实体类的 Type 作为索引的 Key,没有命名。一旦 Type 是多个实体共享的类型,就破坏了唯一性。这时候必须同时用 Type 和 name 来标识 DbSet 才能做到唯一区分。看看 DbContext 类内部的 DbSet 列表是怎么缓存的。
private Dictionary<(Type Type, string? Name), object>? _sets;
说白了,DbContext 类的内部是用一个字典来缓存 DbSet 的(看,字典类型真是好用,哪儿都能用得上它),其中 Key 是由两个值构成的:实体的类型 + 实体的名字。对于常见的实体类,因为类是唯一的,不共享的,所以,Name 的值可以忽略;而共享类型则不同,多个实体的 Type 是相同的,不用 Name 的话无法区分。所以,对于咱们这个未定义实体类的 Student 实体,只能调用 Set 方法来返回,并且要显式地指定一个名字,名字必须和模型中注册的实体名相同,否则,查询的时候还是找不到映射的。
废话了那么多,正确的属性定义应当是这样的。
public DbSet<Dictionary<string, object>> Students => Set<Dictionary<string, object>>("Student");
咱们把表映射完善一下。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
EntityTypeBuilder ent = modelBuilder.Entity("Student");
// 添加属性
ent.Property<int>("StuID")
.HasColumnName("f_stuid");
ent.Property<string>("Name")
.IsRequired(true)
.HasMaxLength(15)
.HasColumnName("f_name");
ent.Property<int>("Age")
.IsRequired()
.HasColumnName("f_age");
ent.Property<string>("Major")
.IsRequired(false)
.HasMaxLength(30)
.HasColumnName("f_major");
// 主键
ent.HasKey("StuID").HasName("PK_stu_id");
// 表名
ent.ToTable("tb_students");
}
然后创建的数据库是这样的。

逐渐对味了,这一关可以 pass 了。
接下来咱们还要验证一下,增删改查是否可行。
A、先插入五条记录。
// 插入数据
// Name:姓名;Age:年龄;Major:专业
Dictionary<string, object>[] newRecs =
[
new()
{
["Name"] = "刘桂圆",
["Age"] = 19,
["Major"] = "人力资源管理裁员方向"
},
new()
{
["Name"] = "方小同",
["Age"] = 20,
["Major"] = "调酒师"
},
new()
{
["Name"] = "程河洛",
["Age"] = 20,
["Major"] = "兽医"
},
new()
{
["Name"] = "史地分",
["Age"] = 22,
["Major"] = "堪舆学"
},
new()
{
["Name"] = "吴胜隆",
["Age"] = 18,
["Major"] = "行星科学"
}
];
context.Students.AddRange(newRecs);
// 提交更改
int n = context.SaveChanges();
Console.WriteLine("已更新{0}条数据", n);
结果如下:

B、现在看一下数据更新。把 ID=3 的同学的专业改为"土狗工程"。
int n = context.Students
.Where(stu => (int)stu["StuID"] == 3)
.ExecuteUpdate(setter=> setter.SetProperty(s => s["Major"], "土狗工程"));
Console.WriteLine("更新了{0}条记录", n);
这个写法可能有大伙伴没看懂,老周解释一下。
首先,用 ExecuteUpdate 方法的好处是只生成一条 UPDATE 语句,提高了效率。如果你先查询 ID=3 的数据,然后修改其属性,然后再提交更改。那样 EF 会先生成 SELECT 语句,返回数据后,在内存中修改,再生成 UPDATE 语句,这样不太必要。
ExecuteUpdate 方法生成的 UPDATE 语句,它的 Where 子句是和 LINQ 查询匹配的,由于我们要改 id=3 的记录,所以要先调用 Where 方法,让 EF 记住有筛选条件,然后再调用 ExecuteUpdate 方法生成 SET 子句。
ExecuteUpdate 是扩展方法,这里我调用的以下重载:
public static int ExecuteUpdate<TSource>(this IQueryable<TSource> source, Action<UpdateSettersBuilder<TSource>> setPropertyCalls)
参数是一只委托,委托有个输入参数,类型是 UpdateSettersBuilder<TSource>。UpdateSettersBuilder 类的功能是帮助框架生成更新语句的 SET 子句。即调用它的 SetProperty 方法给实体的属性赋值。为了保持高度的灵活性和可扩展性,SetProperty 方法采用表达树的方式传参。此处,我使用的是以下重载:
public UpdateSettersBuilder<TSource> SetProperty<TProperty>(
Expression<Func<TSource, TProperty>> propertyExpression,
TProperty valueExpression
)
根据 C# 语句可以隐式转化为 Expression 的原则,propertyExpression 参数可以简化理解为 Func<TSource, TProperty>,即你要告 EF 我要更新实体的哪个属性,比如,我要更新 Car.Speed 属性,那么就是 c => c.Speed,其中,c 是 Car 实例。不过,这里我们的 Student 实体是没有定义类的,它是个字典,但没关系,我们就告诉框架要更新哪个 Key 的值就好了。也就是 s => s["Major"]。
第二个参数 valueExpression 就是属性的新的值。
所以,综合起来,整个 ExecuteUpdate 方法的调用就是
ExecuteUpdate(
setter => setter.SetProperty(s => s["Major"],
"土狗工程"
));
等等啊,各位,别忙着按【F5】,上面代码还有个问题,如果你直接运行,就会报类型映射失败的错误。问题就出在这个 Lambda 表达式:s => s["Major"]。我们还记得,它的类型是 Dictionary<string, object>,也就是说,s["Major"] 给表达式树处理引擎提供的类型是 object,而 Major 属性其实要的是 string。
怎么解决呢,简单啊,把它强制转换为 string 就完事了。
ExecuteUpdate(
setter => setter.SetProperty(s => (string)s["Major"],
"土狗工程"
));
现在运行代码就不会出错了。生成的 SQL 语句如下:
UPDATE [t]
SET [t].[f_major] = @p
FROM [tb_students] AS [t]
WHERE [t].[f_stuid] = 3
咱们验证一下,看到底改了没有。

嗯,已经改了。
C、删除数据。现在咱们把 ID=3 的数据记录删除。为了提高效率,我们用 ExecuteDelete 方法。
int n = context.Students
.Where(stu => (int)stu["StuID"] == 3)
.ExecuteDelete();
Console.WriteLine("已删除{0}条记录", n);
ExecuteDelete 方法不需要参数,因为生成 DELTE FROM ... 语句一般只要带个 WHERE 子句作为筛选条件就可以了。所以,要先调用 Where 方法做筛选,然后才调用 ExecuteDelete 方法。不然会把整个表的记录删除。
生成的 SQL 语句如下:
DELETE FROM [t]
FROM [tb_students] AS [t]
WHERE [t].[f_stuid] = 3
运行代码后,ID=3 的记录就没了。

严重注意:凡是调用 ExecuteXXX 方法处理数据的,不要再调用 SaveChanges 方法了。ExecuteXXX 方法不需要跟踪实体更改,而是直接生成 SQL 发送到数据库执行的。
D、查询数据。就剩下最后一个操作了。咱们把所有记录查询出来,并输出到控制台。
var stuArr = context.Students.ToArray();
// 打印
var q = from s in stuArr
select new
{
StuID = (int)s["StuID"],
Name = (string)s["Name"],
Age = (int)s["Age"],
Major = (string)s["Major"]
};
foreach(var x in q)
{
Console.WriteLine($"{x.Name}({x.StuID})\t{x.Age}岁,{x.Major}专业");
}
context.Students.ToArray 由于调用了 ToArray 扩展方法,所以查询会执行,从而触发翻译为 SQL 语句,并发送到数据库,返回查询结果。生成 SQL 如下:
SELECT [t].[f_stuid], [t].[f_age], [t].[f_major], [t].[f_name]
FROM [tb_students] AS [t]
控制台输出结果如下:
刘桂圆(1) 19岁,人力资源管理裁员方向专业
方小同(2) 20岁,调酒师专业
史地分(4) 22岁,堪舆学专业
吴胜隆(5) 18岁,行星科学专业
在查询的时候,你也可以直接用 SQL 语句,例如查询 ID=1 的记录。
int qid = 1;
var stuArr = context
.Students
.FromSql($"select * from tb_students where f_stuid = {qid}")
.ToArray();
建议使用这种格式化的方式来组织 SQL 语句,而不用 Raw SQL 来拼接,因为用格式化字符串构建 SQL 语句默认使用参数化查询,可以避免特殊字符注入攻击,保证安全。
经过咱们一系列验证,表明不定义类的实体也可以正常完成增删改查操作的。只是使用 Dictionary<string, object> 类型在书写查询的时候,Key 的名称容易写错。尤其是项目你做了一半,让后别人接盘,这种情况很容易把 Key 写错,导致各种错误。
因此,不定义实体类的方案可以使用,但不建议全用,可以仅在部分表或视图的映射中使用。
好了,今天咱们就水到这里了。