创建模型(二)

值转换

值转换器可在从数据库读取或向其中写入属性值时转换属性值。 此转换可以是从同一类型的一个值转换为另一个值(例如加密字符串),也可以是从一种类型的值转换为另一种类型的值(例如数据库中枚举值和字符串的相互转换)。

值转换器的指定涉及 ModelClrType 和 ProviderClrType。 ModelClrType是实体类型中的属性的 .NET 类型。 ProviderClrType是数据库提供程序理解的 .NET 类型。 例如,若要在数据库中将枚举保存为字符串,模型类型(ModelClrType)是枚举的类型,而提供程序类型(ProviderClrType)是 String。

使用两个 Func 表达式树定义转换:一个从 ModelClrType 转换为 ProviderClrType,另一个从 ProviderClrType 转换为 ModelClrType。 使用表达式树的目的是使它们可被编译到数据库访问委托中,以便进行高效转换。 表达式树可能包含对复杂转换的转换方法的简单调用。

配置值转换器

值转换在 中 DbContext.OnModelCreating配置。 例如,假设将一个枚举和实体类型定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Rider
{
public int Id { get; set; }
public EquineBeast Mount { get; set; }
}

public enum EquineBeast
{
Donkey,
Mule,
Horse,
Unicorn
}

可以在 中 OnModelCreating 配置转换,以将枚举值作为字符串(如“Donkey”、“Mule”等)存储在数据库中;只需提供一个从 ModelClrType 转换为 ProviderClrType的函数,为相反的转换提供另一个函数:

1
2
3
4
5
6
7
8
9
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion(
v => v.ToString(),
v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));
}

批量配置值转换器

为使用相关 CLR 类型的每个属性配置相同的值转换器很常见。 可以使用 预约定模型配置 为整个模型执行此操作一次,而不是为每个属性手动执行此操作。 因为它在允许运行模型生成约定之前配置模型的各个方面。 通过重写 ConfigureConventions 派生自 DbContext的类型来应用此类配置。

1
2
3
4
5
6
7
8
9
public class CurrencyConverter : ValueConverter<Currency, decimal>
{
public CurrencyConverter()
: base(
v => v.Amount,
v => new Currency(v))
{
}
}

然后,在上下文类型中重写 ConfigureConventions 并配置转换器,如下所示:

1
2
3
4
5
6
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder
.Properties<Currency>()
.HaveConversion<CurrencyConverter>();
}

此示例演示如何在 类型 string的所有属性上配置一些方面:

1
2
3
4
configurationBuilder
.Properties<string>()
.AreUnicode(false)
.HaveMaxLength(1024);

预定义的转换

EF Core 含有许多预定义转换,不需要手动编写转换函数。 而是根据模型中的属性类型和请求的数据库提供程序类型选取要使用的转换。

例如,枚举到字符串的转换用作上面的示例,但当提供程序类型配置为 string 使用 的泛型类型 HasConversion时,EF Core 实际上会自动执行此操作:

1
2
3
4
5
6
7
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion<string>();
}

可通过显式地指定数据库列类型实现相同的操作。 例如,如果实体类型的定义如下:

1
2
3
4
5
6
7
public class Rider2
{
public int Id { get; set; }

[Column(TypeName = "nvarchar(24)")]
public EquineBeast Mount { get; set; }
}

然后枚举值将保存为数据库中的字符串,而无需在 中 OnModelCreating进行任何进一步配置。

ValueConverter 类

如上所示调用 HasConversion 将创建一个 ValueConverter<TModel,TProvider> 实例并在 属性上设置它。 可改为显式地创建 ValueConverter。 例如:

1
2
3
4
5
6
7
8
9
10
11
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<EquineBeast, string>(
v => v.ToString(),
v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));

modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion(converter);
}

多个属性使用同一个转换时,这非常有用。

内置转换器

如上所述,EF Core 附带了一组预定义 ValueConverter<TModel,TProvider> 的类,这些类位于 命名空间中 Microsoft.EntityFrameworkCore.Storage.ValueConversion 。 在许多情况下,EF 将根据模型中属性的类型和在数据库中请求的类型,选择适当的内置转换器,正如上面的枚举转换示例所示。 例如,对 bool 属性使用 .HasConversion() 会使 EF Core 将布尔值转换为数值零和一:

1
2
3
4
5
6
7
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<User>()
.Property(e => e.IsActive)
.HasConversion<int>();
}

这在功能上与创建内置 BoolToZeroOneConverter 实例并显式设置它相同:

1
2
3
4
5
6
7
8
9
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new BoolToZeroOneConverter<int>();

modelBuilder
.Entity<User>()
.Property(e => e.IsActive)
.HasConversion(converter);
}

如果默认情况下所有 EquineBeast 列都应为 varchar(20),则可以将此信息作为 提供给值转换器 ConverterMappingHints。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<EquineBeast, string>(
v => v.ToString(),
v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v),
new ConverterMappingHints(size: 20, unicode: false));

modelBuilder
.Entity<Rider>()
.Property(e => e.Mount)
.HasConversion(converter);
}

现在只要使用此转换器,数据库列就不能采用 unicode,且最长为 20 个字符。 但是,这些只是提示,因为它们被映射属性上显式设置的任何方面覆盖。

示例

简单值对象

此示例使用简单类型来包装基元类型。 希望模型中的类型比基元类型更具体(因而更具类型安全性)时,这很有用。 在此示例中,该类型为 Dollars,它包装小数基元:

1
2
3
4
5
6
7
8
9
10
public readonly struct Dollars
{
public Dollars(decimal amount)
=> Amount = amount;

public decimal Amount { get; }

public override string ToString()
=> $"${Amount}";
}

这可用于实体类型中:

1
2
3
4
5
6
public class Order
{
public int Id { get; set; }

public Dollars Price { get; set; }
}

还可在存储到数据库中时被转换为基本 decimal:

1
2
3
4
5
modelBuilder.Entity<Order>()
.Property(e => e.Price)
.HasConversion(
v => v.Amount,
v => new Dollars(v));

值对象的集合

我们可以创建一个值对象集合。 例如,假设有一个 AnnualFinance 类型,它为博客一年的财务状况建模:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public readonly struct AnnualFinance
{
[JsonConstructor]
public AnnualFinance(int year, Money income, Money expenses)
{
Year = year;
Income = income;
Expenses = expenses;
}

public int Year { get; }
public Money Income { get; }
public Money Expenses { get; }
public Money Revenue => new Money(Income.Amount - Expenses.Amount, Income.Currency);
}

此类型构成几个我们先前创建的 Money 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public readonly struct Money
{
[JsonConstructor]
public Money(decimal amount, Currency currency)
{
Amount = amount;
Currency = currency;
}

public override string ToString()
=> (Currency == Currency.UsDollars ? "$" : "£") + Amount;

public decimal Amount { get; }
public Currency Currency { get; }
}

public enum Currency
{
UsDollars,
PoundsSterling
}

然后,我们可以向实体类型添加一个 AnnualFinance 集合:

1
2
3
4
5
6
7
public class Blog
{
public int Id { get; set; }
public string Name { get; set; }

public IList<AnnualFinance> Finances { get; set; }
}

接下来再次使用序列化来进行存储:

1
2
3
4
5
6
7
8
9
modelBuilder.Entity<Blog>()
.Property(e => e.Finances)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<AnnualFinance>>(v, (JsonSerializerOptions)null),
new ValueComparer<IList<AnnualFinance>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => (IList<AnnualFinance>)c.ToList()));

使用不区分大小写的字符串键

一些数据库(包括 SQL Server)默认执行不区分大小写的字符串比较。 另一方面,.NET 默认执行区分大小写的字符串比较。 这意味着,“DotNet”之类的外键值将与 SQL Server 上的主键值“dotnet”匹配,但与 EF Core 中的该值不匹配。 键的值比较器可用于强制 EF Core 执行不区分大小写的字符串比较,就像在数据库中那样。 例如,请考虑使用拥有字符串键的博客/文章模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Blog
{
public string Id { get; set; }
public string Name { get; set; }

public ICollection<Post> Posts { get; set; }
}

public class Post
{
public string Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }

public string BlogId { get; set; }
public Blog Blog { get; set; }
}

如果某些 Post.BlogId 值具有不同的大小写,此模型不会按预期工作。 此问题造成的错误取决于应用程序正在执行的操作,通常都涉及未正确修复的对象图和/或由于 FK 值错误而失败的更新。 值比较器可用于更正这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var comparer = new ValueComparer<string>(
(l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase),
v => v.ToUpper().GetHashCode(),
v => v);

modelBuilder.Entity<Blog>()
.Property(e => e.Id)
.Metadata.SetValueComparer(comparer);

modelBuilder.Entity<Post>(
b =>
{
b.Property(e => e.Id).Metadata.SetValueComparer(comparer);
b.Property(e => e.BlogId).Metadata.SetValueComparer(comparer);
});
}

.NET 字符串比较和数据库字符串比较的区别不仅限于大小写敏感性。 此模式适用于简单的 ASCII 键,但对于具有任意一种区域性特定字符的键,可能会失败。

处理定长的数据库字符串

前一个示例不需要值转换器。 但是,对于定长数据库字符串类型(如 char(20) 或 nchar(20)),转换器很有用。 每当向数据库插入值时,都会将定长字符串填充到完整长度。 这意味着键值“dotnet”在从数据库中读回时将为“dotnet…………..”,其中 . 表示空格字符。 这样将不能与未填充的键值正确地进行比较。

值转换器可用于在读取键值时剪裁填充。 可将此与上一个示例中的值比较器结合,以正确比较定长的不区分大小写的 ASCII 键。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<string, string>(
v => v,
v => v.Trim());

var comparer = new ValueComparer<string>(
(l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase),
v => v.ToUpper().GetHashCode(),
v => v);

modelBuilder.Entity<Blog>()
.Property(e => e.Id)
.HasColumnType("char(20)")
.HasConversion(converter, comparer);

modelBuilder.Entity<Post>(
b =>
{
b.Property(e => e.Id).HasColumnType("char(20)").HasConversion(converter, comparer);
b.Property(e => e.BlogId).HasColumnType("char(20)").HasConversion(converter, comparer);
});
}

加密属性值

值转换器可用于在将属性值发送到数据库之前对其加密,再在发送回来时解密。例如,使用字符串反转替代实际加密算法:

1
2
3
modelBuilder.Entity<User>().Property(e => e.Password).HasConversion(
v => new string(v.Reverse().ToArray()),
v => new string(v.Reverse().ToArray()));

目前没有任何方法可以从值转换器内获取对当前 DbContext 或其他会话状态的引用。 这限制了可以使用的加密类型。

限制

目前,值转换系统存在一些已知的限制:

  • 如上所述,不能转换 null。
  • 目前没有办法将一个属性的转换扩展到多个列,反之亦然。
  • 对于通过值转换器映射的大多数键,不支持值生成。
  • 值转换无法引用当前的 DbContext 实例。
  • 使用值转换类型的参数当前不能在原始 SQL API 中使用。

值比较器

EF Core 内置有用于快照截取和比较数据库中使用的大多数标准类型的逻辑,所以用户通常不需要担心这个问题。 但是,当通过值转换器映射属性时,EF Core 需要对任意用户类型执行比较,这可能很复杂。 默认情况下,EF Core 使用类型定义的默认相等比较, (例如 Equals 方法) ;对于快照, 将复制值类型 以生成快照,而对于 引用类型 ,不进行复制,并将同一实例用作快照。

如果内置比较行为不合适,用户可以提供值比较器,其中包含用于快照截取、比较和计算哈希代码的逻辑。 例如,下面将 List 属性的值转换设置为将值转换为数据库中的 JSON 字符串,并定义适当的值比较器:

1
2
3
4
5
6
7
8
9
10
modelBuilder
.Entity<EntityType>()
.Property(e => e.MyListProperty)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<int>>(v, (JsonSerializerOptions)null),
new ValueComparer<List<int>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));

简单的不可变类

考虑一个使用值转换器映射简单的不可变类的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public sealed class ImmutableClass
{
public ImmutableClass(int value)
{
Value = value;
}

public int Value { get; }

private bool Equals(ImmutableClass other)
=> Value == other.Value;

public override bool Equals(object obj)
=> ReferenceEquals(this, obj) || obj is ImmutableClass other && Equals(other);

public override int GetHashCode()
=> Value.GetHashCode();
}

modelBuilder
.Entity<MyEntityType>()
.Property(e => e.MyProperty)
.HasConversion(
v => v.Value,
v => new ImmutableClass(v));

此类型的属性不需要特殊比较或快照,原因如下:

  • 相等性被覆盖,以便不同的实例可以正确比较
  • 类型是不可变的,所以不可能改变快照值
    因此,在这种情况下,EF Core 的默认行为本身就是正常的。

可变类

建议尽可能将不可变类型(类或结构)与值转换器一起使用。 这通常比使用可变类型更有效,语义更清晰。 但是,话虽如此,使用应用程序无法更改的类型的属性是很常见的。 例如,映射包含数字列表的属性:
public List<int> MyListProperty { get; set; }

1
2
3
4
5
6
7
8
9
10
modelBuilder
.Entity<EntityType>()
.Property(e => e.MyListProperty)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<int>>(v, (JsonSerializerOptions)null),
new ValueComparer<List<int>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));

构造 ValueComparer 函数接受三个表达式:

  • 用于检查相等性的表达式
  • 用于生成哈希代码的表达式
  • 用于截取值的快照的表达式
    在这种情况下,比较是通过检查数字序列是否相同来完成的。

同样,哈希代码也是基于相同的序列构建的。 (请注意,这是可变值的哈希代码,因此 可能会导致问题。如果可以,则不可变。)

快照是通过使用 ToList 克隆列表来创建的。 同样,仅当要转变列表时,才需要这样做。 如果可以,请改为不可变。

高级表映射

表拆分

EF Core 允许将两个或多个实体映射到一个表。 这称为“表拆分”或“表共享”。

若要使用表拆分,需将实体类型映射到同一个表,将主键映射到相同的列,并且在同一个表中的一种实体类型的主键和另一种实体类型的主键之间至少配置一种关系。

表拆分的一个常见场景是仅使用表中的部分列,以提高性能或实现封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Order
{
public int Id { get; set; }
public OrderStatus? Status { get; set; }
public DetailedOrder DetailedOrder { get; set; }
}

public class DetailedOrder
{
public int Id { get; set; }
public OrderStatus? Status { get; set; }
public string BillingAddress { get; set; }
public string ShippingAddress { get; set; }
public byte[] Version { get; set; }
}

modelBuilder.Entity<DetailedOrder>(
dob =>
{
dob.ToTable("Orders");
dob.Property(o => o.Status).HasColumnName("Status");
});

modelBuilder.Entity<Order>(
ob =>
{
ob.ToTable("Orders");
ob.Property(o => o.Status).HasColumnName("Status");
ob.HasOne(o => o.DetailedOrder).WithOne()
.HasForeignKey<DetailedOrder>(o => o.Id);
ob.Navigation(o => o.DetailedOrder).IsRequired();
});

实体拆分

EF Core 允许将实体映射到两个或多个表中的行。 这称为 实体拆分。

例如,假设有一个数据库,其中包含三个保存客户数据的表:

  • Customers客户信息的表
  • PhoneNumbers客户的电话号码表
  • Addresses客户地址表
    下面是SQL Server中这些表的定义:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
    );

    CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
    );

    CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
    );
    这些表中的每个表通常映射到自己的实体类型,并具有类型之间的关系。 但是,如果所有三个表始终一起使用,则将它们全部映射到单个实体类型会更方便。 例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class Customer
    {
    public Customer(string name, string street, string city, string? postCode, string country)
    {
    Name = name;
    Street = street;
    City = city;
    PostCode = postCode;
    Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
    }
    这是在 EF7 中通过为实体类型中的每个拆分调用 SplitToTable 来实现的。 例如,以下代码将 Customer 实体类型拆分为上面所示的 Customers、 PhoneNumbers和 Addresses 表:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
    entityBuilder
    .ToTable("Customers")
    .SplitToTable(
    "PhoneNumbers",
    tableBuilder =>
    {
    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
    tableBuilder.Property(customer => customer.PhoneNumber);
    })
    .SplitToTable(
    "Addresses",
    tableBuilder =>
    {
    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
    tableBuilder.Property(customer => customer.Street);
    tableBuilder.Property(customer => customer.City);
    tableBuilder.Property(customer => customer.PostCode);
    tableBuilder.Property(customer => customer.Country);
    });
    });

    限制

  • 实体拆分不能用于继承结构中的实体类型。
  • 对于主表中的任何行,每个拆分表中都必须有一行。

TPT继承映射

例如,假设有一个简单的继承层次结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class Animal
{
public int Id { get; set; }
public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
public string? FavoriteToy { get; set; }
}

使用 TPT 继承映射策略,这些类型将映射到三个表。 但是,每个表中的主键列可能具有不同的名称。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE [Animals] (
[Id] int NOT NULL IDENTITY,
[Breed] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
[CatId] int NOT NULL,
[EducationalLevel] nvarchar(max) NULL,
CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
[DogId] int NOT NULL,
[FavoriteToy] nvarchar(max) NULL,
CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 允许使用嵌套表生成器配置此映射:

1
2
3
4
5
6
7
8
9
10
11
modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
.ToTable(
"Cats",
tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
.ToTable(
"Dogs",
tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

从属实体类型

EF Core 使你能够对只能出现在其他实体类型的导航属性上的实体类型进行建模。 它们称为“从属实体类型”。 包含从属实体类型的实体是其所有者。

从属实体本质上是所有者的一部分,没有它就不能存在,它们在概念上类似于聚合。 这意味着,根据定义,从属实体位于与所有者关系的从属关系中。

将类型配置为Owned

在大多数提供程序中,实体类型永远不会按约定配置为已拥有,必须显式使用 OnModelCreating 中的 OwnsOne 方法或使用 OwnedAttribute 为类型做注释以将类型配置为已拥有。

在此示例中,StreetAddress 是一个无标识类型。 它用作 Order 类型的属性来指定特定订单的发货地址。我们可以使用 OwnedAttribute 将其标记为从属实体:

1
2
3
4
5
6
7
8
9
10
11
12
[Owned]
public class StreetAddress
{
public string Street { get; set; }
public string City { get; set; }
}

public class Order
{
public int Id { get; set; }
public StreetAddress ShippingAddress { get; set; }
}

还可以使用 OnModelCreating 中的 OwnsOne 方法来指定 ShippingAddress 属性是 Order 实体类型的从属实体,并根据需要配置其他方面。

1
modelBuilder.Entity<Order>().OwnsOne(p => p.ShippingAddress);

如果 ShippingAddress 属性在 Order 类型中是专用的,则可以使用 OwnsOne 方法的字符串版本:

1
modelBuilder.Entity<Order>().OwnsOne(typeof(StreetAddress), "ShippingAddress");

隐式键

使用 OwnsOne 配置的从属类型或通过引用导航发现的从属类型始终与所有者具有一对一的关系,因此它们不需要自己的键值,因为外键值是唯一的。 在上面的示例中,StreetAddress 类型不需要定义键属性。

为了了解 EF Core 如何跟踪这些对象,了解主键是作为从属类型的属性创建的很有用。 从属类型的实例的键值将与所有者实例的键值相同。

无键实体类型

除了常规实体类型外,EF Core 模型还可以包含无键实体类型,可用于对不包含键值的数据执行数据库查询。

定义无键实体类型

1
2
3
4
5
6
[Keyless]
public class BlogPostsCount
{
public string BlogName { get; set; }
public int PostCount { get; set; }
}

无键实体类型特征

无键实体类型支持与常规实体类型相同的多个映射功能,例如继承映射和导航属性。 在关系存储上,它们可以通过 Fluent API 方法或数据注释来配置目标数据库对象和列。

但是,它们不同于常规实体类型,因为它们:

  • 不能定义键。
  • 永远不会对 DbContext 中的更改进行跟踪,因此不会对数据库进行插入、更新或删除这些操作。
  • 绝不会被约定发现。
  • 仅支持导航映射功能的子集,具体如下:
    • 它们永远不能充当关系的主体端。
    • 它们可能没有指向从属实体的导航
    • 它们只能包含指向常规实体的引用导航属性。
    • 实体不能包含无键实体类型的导航属性。
  • 需要配置 [Keyless] 数据注释或 .HasNoKey() 方法调用。
  • 可以映射到定义查询。 定义查询是在模型中声明的查询,它充当无键实体类型的数据源。
  • 可以有层次结构,但必须映射为 TPH。
  • 不能使用表拆分或实体拆分。

使用方案

无键实体类型的一些主要使用场景包括:

  • 用作 SQL 查询的返回类型。
  • 映射到不包含主键的数据库视图。
  • 映射到未定义主键的表。
  • 映射到模型中定义的查询。

模型批量配置

OnModelCreating 中的批量配置

从 ModelBuilder 返回的每个生成器对象都会公开ModelMetadata 属性,该属性提供对构成模型对象的低级别访问。 具体而言,有一些方法允许循环访问模型中的特定对象,并对其应用通用配置。

在以下示例中, 模型包含自定义值类型 Currency:

1
2
3
4
5
6
7
8
9
10
public readonly struct Currency
{
public Currency(decimal amount)
=> Amount = amount;

public decimal Amount { get; }

public override string ToString()
=> $"${Amount}";
}

默认情况下不会发现此类型的属性,因为当前 EF 提供程序不知道如何将它映射到数据库类型。 此代码片段 OnModelCreating 添加 类型 Currency 的所有属性,并将值转换器配置为受支持的类型 - decimal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var propertyInfo in entityType.ClrType.GetProperties())
{
if (propertyInfo.PropertyType == typeof(Currency))
{
entityType.AddProperty(propertyInfo)
.SetValueConverter(typeof(CurrencyConverter));
}
}
}
public class CurrencyConverter : ValueConverter<Currency, decimal>
{
public CurrencyConverter()
: base(
v => v.Amount,
v => new Currency(v))
{
}
}

元数据 API 的缺点

与 Fluent API 不同,对模型的每个修改都需要显式完成。 例如,如果某些 Currency 属性按约定配置为导航,则需要先删除引用 CLR 属性的导航,然后再为其添加实体类型属性。 #9117 将对此进行改进。
约定在每次更改后运行。 如果删除由约定发现的导航,该约定将再次运行,并可以重新添加它。 若要防止这种情况发生,需要延迟约定,直到通过调用 DelayConventions() 并稍后释放返回的对象添加属性后,或使用 将 CLR 属性标记为已忽略。AddIgnored
此迭代发生后,可能会添加实体类型,并且配置不会应用于这些实体类型。 通常可以通过将此代码放在 末尾 OnModelCreating来防止这种情况,但如果具有两组相互依赖的配置,则可能不会有一个允许一致应用它们的顺序。

预约定配置

EF Core 允许为给定 CLR 类型指定一次映射配置;然后,该配置在发现时应用于模型中该类型的所有属性。 这称为“预约定模型配置”,因为它在允许运行模型生成约定之前配置模型的各个方面。 通过重写 ConfigureConventions 派生自 DbContext的类型来应用此类配置。

此示例演示如何将 类型 Currency 的所有属性配置为具有值转换器:

1
2
3
4
5
6
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder
.Properties<Currency>()
.HaveConversion<CurrencyConverter>();
}

它将覆盖所有约定和数据注释。 例如,使用上述配置时,所有字符串外键属性都将创建为具有 1024 的非 unicode MaxLength ,即使这与主体键不匹配也是如此。

忽略类型

约定前配置还允许忽略类型,并阻止约定将其作为实体类型或实体类型的属性发现:

1
2
configurationBuilder
.IgnoreAny(typeof(IList<>));

默认类型映射

通常,只要为此类型的属性指定了值转换器,EF 就可以使用提供程序不支持的类型常量转换查询。 但是,在不涉及此类型的任何属性的查询中,EF 无法找到正确的值转换器。 在这种情况下,可以调用 DefaultTypeMapping 以添加或替代提供程序类型映射:

1
2
3
configurationBuilder
.DefaultTypeMapping<Currency>()
.HasConversion<CurrencyConverter>();

约定

EF Core 模型生成约定是包含逻辑的类,这些逻辑基于生成模型时对模型所做的更改触发。 这会在进行显式配置、应用映射属性和其他约定时使模型保持最新状态。 为了参与此目的,每个约定实现一个或多个接口,用于确定何时触发相应的方法。 例如,每当向模型添加新实体类型时,将触发实现 IEntityTypeAddedConvention 的约定。 同样,每当向模型添加键或外键时,都会触发实现 和 IKeyAddedConvention 的IForeignKeyAddedConvention约定。

模型生成约定是控制模型配置的一种强大方法,但可能很复杂,很难正确。

添加新约定

约束鉴别器属性的长度

让我们通过首次尝试实现鉴别器长度约定,使这更具体一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
public void ProcessEntityTypeBaseTypeChanged(
IConventionEntityTypeBuilder entityTypeBuilder,
IConventionEntityType? newBaseType,
IConventionEntityType? oldBaseType,
IConventionContext<IConventionEntityType> context)
{
var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
if (discriminatorProperty != null
&& discriminatorProperty.ClrType == typeof(string))
{
discriminatorProperty.Builder.HasMaxLength(24);
}
}
}

此约定实现 IEntityTypeBaseTypeChangedConvention,这意味着每当实体类型的映射继承层次结构发生更改时,都会触发它。 然后,该约定查找并配置层次结构的字符串鉴别器属性。

然后,通过在 中ConfigureConventions调用 Add 来使用此约定:

1
2
3
4
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(_ => new DiscriminatorLengthConvention1());
}

但是,如果我们现在显式配置不同的鉴别器属性,会发生什么情况呢? 例如:

1
2
3
4
modelBuilder.Entity<Post>()
.HasDiscriminator<string>("PostTypeDiscriminator")
.HasValue<Post>("Post")
.HasValue<FeaturedPost>("Featured");

这是因为我们在约定中配置的鉴别器属性后来在添加自定义鉴别器时被删除。 我们可以尝试通过在约定上实现另一个接口来修复此问题,以响应鉴别器更改,但找出要实现的接口并不容易。

幸运的是,有一种更简单的方法。 很多时候,只要最终模型正确,模型在生成时的外观并不重要。 此外,要应用的配置通常不需要触发其他约定来做出反应。 因此,我们的约定可以实现 IModelFinalizingConvention。 模型最终确定约定 在所有其他模型生成完成后运行,因此可以访问模型的接近最终状态。 这与响应每个模型更改并确保模型在方法执行的任何时间点处于最新状态的OnModelCreating交互式约定相反。 模型最终确定约定通常会循环访问整个模型,以根据需要配置模型元素。 因此,在这种情况下,我们会在模型中找到每个鉴别器并对其进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
.Where(entityType => entityType.BaseType == null))
{
var discriminatorProperty = entityType.FindDiscriminatorProperty();
if (discriminatorProperty != null
&& discriminatorProperty.ClrType == typeof(string))
{
discriminatorProperty.Builder.HasMaxLength(24);
}
}
}
}

使用此新约定生成模型后,我们发现现在已正确配置了鉴别器长度,即使已对其进行自定义:

我们可以更进一步,将最大长度配置为最长的鉴别器值的长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
.Where(entityType => entityType.BaseType == null))
{
var discriminatorProperty = entityType.FindDiscriminatorProperty();
if (discriminatorProperty != null
&& discriminatorProperty.ClrType == typeof(string))
{
var maxDiscriminatorValueLength =
entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
}
}
}
}

现在,鉴别器列的最大长度为 8,即“特别推荐”的长度,这是使用的最长的鉴别器值。

替换现有约定

有时,与其完全删除现有约定,不如将其替换为执行基本相同操作但行为已更改的约定。 这很有用,因为现有约定已实现需要适当触发的接口。

何时将每种方法用于批量配置

在以下情况下使用 元数据 API :

  • 需要在特定时间应用配置,而不是对模型中的后续更改做出反应。
  • 模型生成速度非常重要。 元数据 API 的安全检查较少,因此可能比其他方法稍快一些,但使用 已编译的模型 将产生更好的启动时间。

在以下情况下使用 约定前模型配置 :

  • 适用性条件很简单,因为它仅取决于类型。
  • 需要在模型中添加给定类型的属性并重写数据注释和约定时应用配置

在以下情况下使用 Finalizing 约定 :

  • 适用性条件很复杂。
  • 配置不应替代数据批注指定的内容。

在以下情况下使用 交互式约定 :

  • 多个约定相互依赖。 完成约定按添加顺序运行,因此无法对以后完成约定所做的更改做出反应。
  • 逻辑在多个上下文之间共享。 交互式约定比其他方法更安全。

IModelCacheKeyFactory

EF 使用 IModelCacheKeyFactory 为模型生成缓存键;默认情况下,EF 假定对于任何给定的上下文类型,模型都相同,因此该服务的默认实现将返回仅包含上下文类型的键。 若要从同一上下文类型构建不同的模型,需要将服务替换为正确的实现;生成的键将使用 IModelCacheKeyFactoryEquals 方法与其他的模型键进行比较,同时考虑影响模型的所有变量。

以下实现在生成模型缓存键时考虑 UseIntProperty:

1
2
3
4
5
6
7
public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
=> context is DynamicContext dynamicContext
? (context.GetType(), dynamicContext.UseIntProperty, designTime)
: (object)context.GetType();
}

还必须实现 Create 方法的重载,该方法也处理设计时模型缓存。 如以下示例所示:

1
2
3
4
5
6
7
8
9
10
public class DynamicModelCacheKeyFactoryDesignTimeSupport : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
=> context is DynamicContext dynamicContext
? (context.GetType(), dynamicContext.UseIntProperty, designTime)
: (object)context.GetType();

public object Create(DbContext context)
=> Create(context, false);
}

最后,在上下文的 OnConfiguring 中注册新的 IModelCacheKeyFactory:

1
2
3
4
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseInMemoryDatabase("DynamicContext")
.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>();