值转换

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

提示

通过从 GitHub 下载示例代码,你可运行并调试到本文档中的所有代码。

概述

值转换器的指定涉及 ModelClrTypeProviderClrType。 模型类型是实体类型中的属性的 .NET 类型。 提供程序类型是数据库提供程序理解的 .NET 类型。 例如,若要在数据库中将枚举保存为字符串,模型类型是枚举的类型,而提供程序类型是 String。 这两种类型可以相同。

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

注意

为值转换配置的属性可能也需要指定 ValueComparerT>。 有关详细信息,请参阅以下示例和值比较器文档。

配置值转换器

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

public class Rider
{
    public int Id { get; set; }
    public EquineBeast Mount { get; set; }
}

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

可在 OnModelCreating 中对转换进行如下配置:在数据库中将枚举值存储为字符串(例如 Donkey、Mule)。你只需要提供两个函数,一个执行从 ProviderClrType 的转换,另一个执行反向的转换:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<Rider>()
        .Property(e => e.Mount)
        .HasConversion(
            v => v.ToString(),
            v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));
}

注意

绝不会向值转换器传递 null 值。 数据库列中的 null 在实体实例中始终为 null,反之亦然。 这使实现转换更容易,并允许在可为 null 和不可为 null 的属性之间共享转换。 有关详细信息,请参阅 GitHub 问题 #13850

预定义的转换

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

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

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<Rider>()
        .Property(e => e.Mount)
        .HasConversion<string>();
}

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

public class Rider2
{
    public int Id { get; set; }

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

然后,枚举值会被保存为数据库中的字符串,而无需在 OnModelCreating 中进行额外的配置。

ValueConverter 类

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

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

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<User>()
        .Property(e => e.IsActive)
        .HasConversion<int>();
}

就功能而言,这种做法与创建一个内置 BoolToZeroOneConverterTProvider> 实例并显式地设置它相同:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var converter = new BoolToZeroOneConverter<int>();

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

下表汇总了从模型/属性类型到数据库提供程序类型的常用预定义转换。 表中的 any_numeric_type 表示 intshortlongbyteuintushortulongsbytechardecimalfloatdouble 之一。

模型/属性类型 提供程序/数据库类型 转换 使用情况
bool any_numeric_type False/true 转换为 0/1 .HasConversion<any_numeric_type>()
any_numeric_type False/true 转换为任意两个数字 使用 BoolToTwoValuesConverterTProvider>
string False/true 转换为 N/Y .HasConversion<string>()
字符串 False/true 转换为任意两个字符串 使用 BoolToStringConverter
any_numeric_type bool 0/1 转换为 false/true .HasConversion<bool>()
any_numeric_type 简单强制转换 .HasConversion<any_numeric_type>()
字符串 数字作为字符串 .HasConversion<string>()
枚举 any_numeric_type 枚举的数值 .HasConversion<any_numeric_type>()
string 枚举值的字符串表示形式 .HasConversion<string>()
字符串 bool 将字符串分析为布尔值 .HasConversion<bool>()
any_numeric_type 将字符串分析为给定的数字类型 .HasConversion<any_numeric_type>()
char 字符串的第一个字符 .HasConversion<char>()
DateTime 将字符串分析为 DateTime .HasConversion<DateTime>()
DateTimeOffset 将字符串分析为 DateTimeOffset .HasConversion<DateTimeOffset>()
TimeSpan 将字符串分析为 TimeSpan .HasConversion<TimeSpan>()
Guid 将字符串分析为 Guid .HasConversion<Guid>()
byte[] 以 UTF8 字节表示的字符串 .HasConversion<byte[]>()
char string 单字符的字符串 .HasConversion<string>()
DateTime long 保留为 DateTime.Kind 形式的编码日期/时间 .HasConversion<long>()
long 计时周期 使用 DateTimeToTicksConverter
字符串 固定区域性日期/时间字符串 .HasConversion<string>()
DateTimeOffset long 带偏移量的编码日期/时间 .HasConversion<long>()
字符串 带偏移量的固定区域性日期/时间字符串 .HasConversion<string>()
TimeSpan long 计时周期 .HasConversion<long>()
字符串 固定区域性时间跨度字符串 .HasConversion<string>()
URI string 字符串形式的 URI .HasConversion<string>()
PhysicalAddress string 字符串形式的地址 .HasConversion<string>()
byte[] 采用大端网络顺序的字节 .HasConversion<byte[]>()
IPAddress string 字符串形式的地址 .HasConversion<string>()
byte[] 采用大端网络顺序的字节 .HasConversion<byte[]>()
Guid string 采用 dddddddd-dddd-dddd-dddd-dddddddddddd 格式的 GUID .HasConversion<string>()
byte[] 采用 .NET 二进制序列化顺序的字节 .HasConversion<byte[]>()

请注意,这些转换假定值的格式适用于转换。 例如,如果字符串值无法分析为数字,从字符串到数字的转换将失败。

内置转换器的完整列表如下:

请注意,所有内置的转换器都是无状态的,因此多个属性可安全地共享一个实例。

列分面和映射提示

一些数据库类型拥有修改数据存储方式的分面。 其中包括:

  • 小数和日期/时间列的精度和进位制
  • 二进制和字符串列的大小/长度
  • 字符串列的 Unicode

可以为使用值转换器的属性按正常方式配置这些分面,它们将应用于转换后的数据库类型。 例如,从枚举转换为字符串时,我们可以指定数据库列应为非 Unicode 的列,并且最多可存储 20 个字符:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<Rider>()
        .Property(e => e.Mount)
        .HasConversion<string>()
        .HasMaxLength(20)
        .IsUnicode(false);
}

或者,在显式地创建转换器时:

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)
        .HasMaxLength(20)
        .IsUnicode(false);
}

这样做会在对 SQL Server 使用 EF Core 迁移时得到 varchar(20) 列:

CREATE TABLE [Rider] (
    [Id] int NOT NULL IDENTITY,
    [Mount] varchar(20) NOT NULL,
    CONSTRAINT [PK_Rider] PRIMARY KEY ([Id]));

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

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,它包装小数基元:

public readonly struct Dollars
{
    public Dollars(decimal amount) 
        => Amount = amount;
    
    public decimal Amount { get; }

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

这可用于实体类型中:

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

    public Dollars Price { get; set; }
}

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

modelBuilder.Entity<Order>()
    .Property(e => e.Price)
    .HasConversion(
        v => v.Amount,
        v => new Dollars(v));

注意

此值对象作为只读结构实现。 也就是说,EF Core 可以在不出问题的情况下截取快照和比较值。 有关详细信息,请参阅值比较器

复合值对象

在上一个示例中,值对象类型仅包含一个属性。 更常见的是:值对象类型组成共同构成一个域概念的多个属性。 例如,一个通用的 Money 类型包含金额和货币:

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
}

可以像以前一样在实体类型中使用此值对象:

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

    public Money Price { get; set; }
}

值转换器目前只能执行值与一个数据库列之间的转换。 此限制意味着对象的所有属性值都必须被编码为一个列值。 对此,通常的处理方法是:在对象进入数据库中时序列化该对象,再在它退出数据库时反序列化。例如,使用 System.Text.Json

modelBuilder.Entity<Order>()
    .Property(e => e.Price)
    .HasConversion(
        v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
        v => JsonSerializer.Deserialize<Money>(v, (JsonSerializerOptions)null));

注意

在 EF Core 6.0 中,我们计划允许将一个对象映射到多个列,从而不必使用序列化。 GitHub 问题 #13947 对此进行跟踪。

注意

和前面的示例一样,该值是作为只读结构实现的。 也就是说,EF Core 可以在不出问题的情况下截取快照和比较值。 有关详细信息,请参阅值比较器

基元的集合

序列化还可用于存储基元值的集合。 例如:

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Contents { get; set; }

    public ICollection<string> Tags { get; set; }
}

再次使用 System.Text.Json

modelBuilder.Entity<Post>()
    .Property(e => e.Tags)
    .HasConversion(
        v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
        v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions)null),
        new ValueComparer<ICollection<string>>(
            (c1, c2) => c1.SequenceEqual(c2),
            c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
            c => (ICollection<string>)c.ToList()));

ICollection<string> 表示可变引用类型。 也就是说,需要使用 ValueComparerT>,这样 EF Core 才能正确地跟踪和监测更改。 有关详细信息,请参阅值比较器

值对象的集合

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

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 类型:

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 集合:

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

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

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

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()));

注意

此转换依然需要使用 ValueComparerT>。 有关详细信息,请参阅值比较器

值对象用作键

有时可以将基元键属性包装在值对象中,以便在分配值的过程中添加一层额外的类型安全性。 例如,我们可以为博客和文章各实现一个键类型:

public readonly struct BlogKey
{
    public BlogKey(int id) => Id = id;
    public int Id { get; }
}

public readonly struct PostKey
{
    public PostKey(int id) => Id = id;
    public int Id { get; }
}

然后,可以在域模型中使用它们:

public class Blog
{
    public BlogKey Id { get; set; }
    public string Name { get; set; }

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

public class Post
{
    public PostKey Id { get; set; }

    public string Title { get; set; }
    public string Content { get; set; }

    public BlogKey? BlogId { get; set; }
    public Blog Blog { get; set; }
}

请注意,不能为 Blog.Id 错误地分配 PostKey,也不能为 Post.Id 错误地分配 BlogKey。 同样,必须为 Post.BlogId 外键属性分配 BlogKey

注意

展示此模式并不意味着我们建议这样做。 请仔细考虑这种抽象级别对你的开发体验是有助益还是妨碍。 也请考虑使用导航和生成的键,而不是直接处理键值。

然后,可以使用值转换器映射这些键属性:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var blogKeyConverter = new ValueConverter<BlogKey, int>(
        v => v.Id,
        v => new BlogKey(v));

    modelBuilder.Entity<Blog>().Property(e => e.Id).HasConversion(blogKeyConverter);

    modelBuilder.Entity<Post>(
        b =>
            {
                b.Property(e => e.Id).HasConversion(v => v.Id, v => new PostKey(v));
                b.Property(e => e.BlogId).HasConversion(blogKeyConverter);
            });
}

注意

当前,具有转换的键属性不能使用生成的键值。 投票赞成 GitHub 问题 #11597,以消除此限制。

为 timestamp 和 rowversion 使用 ulong

SQL Server 支持使用 8 字节二进制timestamp列的自动乐观并发。 始终使用 8 字节数组从数据库读取和向其中写入这些列。 但是,字节数组是可变的引用类型,这使它们有点难以处理。 值转换器允许改为将 rowversion 映射到 ulong 属性,该属性的适用性和易用性比字节数组高得多。 例如,假设 Blog 实体具有一个 ulong 并发令牌:

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ulong Version { get; set; }
}

可以使用值转换器将其映射到 SQL Server 的 rowversion 列:

modelBuilder.Entity<Blog>()
    .Property(e => e.Version)
    .IsRowVersion()
    .HasConversion<byte[]>();

在读取日期时指定 DateTime.Kind

SQL Server 在将 DateTime 存储为 时放弃 DateTime.Kind 标志。 也就是说,从数据库返回的 DateTime 值始终具有 DateTimeKind

可通过两种方法使用值转换器来处理这种情况。 其一,EF Core 有一个值转换器,用于创建保留 Kind 标志的 8 字节不透明值。 例如:

modelBuilder.Entity<Post>()
    .Property(e => e.PostedOn)
    .HasConversion<long>();

这样就可以在数据库中混合使用具有不同 Kind 标志的 DateTime 值。

此方法的问题在于数据库不再拥有可识别的 datetimedatetime2 列。 所以,更常见的做法是始终存储 UTC 时间(较少见的是始终存储本地时间),然后忽略 Kind 标志或使用值转换器将它设置为恰当的值。 例如,下面的转换器确保从数据库读取的 DateTime 值具有 DateTimeUTC

modelBuilder.Entity<Post>()
    .Property(e => e.LastUpdated)
    .HasConversion(
        v => v,
        v => new DateTime(v.Ticks, DateTimeKind.Utc));

如果在实体实例中混合设置了本地值和 UTC 值,可将转换器用于在插入之前进行适当的转换。 例如:

modelBuilder.Entity<Post>()
    .Property(e => e.LastUpdated)
    .HasConversion(
        v => v.ToUniversalTime(),
        v => new DateTime(v.Ticks, DateTimeKind.Utc));

注意

请仔细考虑统一使所有数据库访问代码 始终使用 UTC 时间,只在向用户呈现数据时处理本地时间。

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

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

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 值错误而失败的更新。 值比较器可用于更正这种情况:

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 键。 例如:

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);
            });
}

加密属性值

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

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

注意

目前没有任何方法可以从值转换器内获取对当前 DbContext 或其他会话状态的引用。 这限制了可以使用的加密类型。 投票赞成 GitHub 问题 #11597,以消除此限制。

警告

如果通过自行加密保护来敏感数据,确保了解所有可能的影响。 请考虑改为使用预先生成的加密机制,例如 SQL Server 上的 Always Encrypted

限制

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

  • 当前无法在一个位置指定给定类型的每个属性都必须使用相同的值转换器。 如果这是你需要的,请为 👍 投赞成票 (👍)。
  • 如上所述,不能转换 null。 如果这是你需要的,请为 👍 投赞成票 (👍)。
  • 目前没有办法将一个属性的转换扩展到多个列,反之亦然。 如果这是你需要的,请为 👍 投赞成票 (👍)。
  • 对于通过值转换器映射的大多数键,不支持值生成。 如果这是你需要的,请为 👍 投赞成票 (👍)。
  • 值转换无法引用当前的 DbContext 实例。 如果这是你需要的,请为 👍 投赞成票 (👍)。

我们正在考虑在未来的版本中消除这些限制。