惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
F
Fortinet All Blogs
博客园_首页
The GitHub Blog
The GitHub Blog
V
Visual Studio Blog
D
DataBreaches.Net
aimingoo的专栏
aimingoo的专栏
爱范儿
爱范儿
B
Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
N
Netflix TechBlog - Medium
阮一峰的网络日志
阮一峰的网络日志
P
Proofpoint News Feed
D
Docker
Engineering at Meta
Engineering at Meta
大猫的无限游戏
大猫的无限游戏
The Cloudflare Blog
罗磊的独立博客
云风的 BLOG
云风的 BLOG
Microsoft Azure Blog
Microsoft Azure Blog
T
The Exploit Database - CXSecurity.com
博客园 - 三生石上(FineUI控件)
量子位
The Last Watchdog
The Last Watchdog
MyScale Blog
MyScale Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
The Blog of Author Tim Ferriss
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
小众软件
小众软件
Cloudbric
Cloudbric
博客园 - 司徒正美
H
Help Net Security
人人都是产品经理
人人都是产品经理
Application and Cybersecurity Blog
Application and Cybersecurity Blog
L
LangChain Blog
Latest news
Latest news
M
MIT News - Artificial intelligence
T
Threat Research - Cisco Blogs
博客园 - Franky
S
Security Affairs
W
WeLiveSecurity
F
Full Disclosure
Know Your Adversary
Know Your Adversary
Google DeepMind News
Google DeepMind News
The Hacker News
The Hacker News
Cyberwarzone
Cyberwarzone
美团技术团队
PCI Perspectives
PCI Perspectives
C
Check Point Blog
Spread Privacy
Spread Privacy

博客园 - 东邪独孤

【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】使用自定义的值比较器
东邪独孤 · 2026-05-10 · via 博客园 - 东邪独孤

EF Core 默认实现了许多值比较器,用于在实体状态追踪时检查属性值是否被修改。故大多情况下,咱们不需要操作心。但,凡是总有特殊情况,有些数据虽然值不相等,但所表示的意思是相等的。这种时候就不能依靠默认的比较器了。

老周举一个连外星人都知道的例子。假设有这样的实体类。

public class Company
{
    public Guid CompID { get; set; }
    public required string Name { get; set; }
    public required string Phone { get; set; }
}

这个类表示某些公司信息,除主键外,两个属性分别表示公司名称和固话。银河系居民都了解,固话由区号和电话号码组成,且有两种写法:

(010)88988989
010-88988989

也就是说,用户给 Phone 属性设置这两个值,指的是同一个固话。若以默认的字符串比较器,肯定会认为二者不相等的。所以,这就要咱们动手了。

要实现自定义的比较器,99% 的做法是从 ValueComparer<T> 类派生。不需要重写任何成员,只提供三个方法(由对应的委托类型接收)的实现,然后传给基类的构造函数即可。

需要的三个委托为:

1、Func<T, T, bool>:两个输入参数是 T 的值,返回值是 bool 类型。该委托用于判断两个值是否相等,相等就返回 true,不相等就返回 false。

2、Func<T, int>:返回输入参数 T 的哈希值,整型。

3、Func<T, T>:此委托用于创建“快照”,由 ChangeTracker 负责管理。返回的 T 实例就是创建的快照实例。对于简单类型,咱们不需要实现这个委托。它主要面向需要深度拷贝或存在嵌套数据的值,用于自定义属性值的拷贝。

对于这个固定电话,咱们要实现相等比较和哈希计算,创建快照不需要实现,使用 EF Core 内置的就可以。

于是,定义 MyValueComparer 类。

public class MyValueComparer : ValueComparer<string>
{
    /// <summary>
    /// 匹配规则
    /// </summary>
    private const string REGEX_PATTERN = "^\\(?(\\d{3,4})(?:\\)|-)?(\\d{6,})$";

    #region 辅助方法
    /// <summary>
    /// 分析固话号码
    /// </summary>
    /// <param name="no">输入值</param>
    /// <returns>返回区号和电话</returns>
    protected static (string code, string phone) ParsePhoneNo(string no)
    {
        // 分析号码
        var res = Regex.Match(no, REGEX_PATTERN);
        // 实际捕捉两个分组
        if(res.Success)
        {
            return (
                    res.Groups[1].Value,
                    res.Groups[2].Value
                );
        }

        return (string.Empty, string.Empty);
    }

    /// <summary>
    /// 两个固话号码是否相等
    /// </summary>
    protected static bool IsEqual(string? val1, string? val2)
    {
        if (val1 == null && val2 == null)
            return true;
        if (val1 == null && val2 != null)
            return false;
        if (val1 != null && val2 == null)
            return false;

        string code1, phone1;       // 第一个号码
        string code2, phone2;       // 第二个号码
        (code1, phone1) = ParsePhoneNo(val1);
        (code2, phone2) = ParsePhoneNo(val2);
        // 两个号码的区号与电话是否相同
        return (code1 == code2 && phone1 == phone2);
    }

    /// <summary>
    /// 计算哈希值
    /// </summary>
    protected static int GetHash(string val)
    {
        (string code, string ph) = ParsePhoneNo(val);
        return HashCode.Combine(code, ph);
    }
    #endregion

    public MyValueComparer()
           :base((p1, p2) => IsEqual(p1, p2),  p => GetHash(p))
    {
        // .........
    }
}

上面代码中,老周使用正则表达式来提取固话中的区号的号码。先解释一下这个匹配规则。

^\\(?(\\d{3,4})(?:\\)|-)?(\\d{6,})$
  • ^ 表示开头字符;
  • \(? 表示开头的字符可能是左括号,但可能不出现,所以用 ? 匹配;
  • (\d{3,4}) 这是一个分组,匹配时会把它存储起来,\d 是数字,{3,4} 表示数字的出现次数为最少三次最多四次。即区号由三到四个数字组成;
  • (?:\)|-)? 表示可选的右括号或者“-”。(?: ...) 避免被识别为分组,因为我们对右括号和“-”不感兴趣,匹配结果也不要存储这些字符,所以用 ?: 告诉正则处理引擎可以匹配它,但不要存到结果中,我们不需要;“|”表示分支(并列、或),即可以出现右括号或“-”。后面的 ? 表示这个分组可以出现可以不出现。其实这里用“+”也可以,右括号和“-”应该至少出现一次;
  •  (\d{6,})$ “$”表示字符串结尾,号码部分同样也是匹配数字,{6, } 表示至少出现六次。也可以是 {6,8},不过这里老周就没把规则定那么严格。

正则匹配成功后,应从 Groups 集合中找结果,不要去 Captures 中找。Groups 集合存储了两个分组中匹配的数字字符(区号和号码)。

if(res.Success)
{
    return (
            res.Groups[1].Value,
            res.Groups[2].Value
        );
}

Groups 集合中,第一个元素存的是整个字符串,所以要从第二个元素获取分组的文本(索引1起)。

好了,现在咱们实现数据库上下文类,并在配置数据库模型时应用自定义的比较器。

public class MyContext : DbContext
{
    public DbSet<Company> Companies { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("server=.\\Test;database=some_db;Trust Server Certificate=True;Integrated Security=True");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Company>(ent =>
        {
            ent.Property(x => x.CompID).HasColumnName("cmp_id");
            ent.Property(x => x.Name).HasColumnName("cmp_name").HasMaxLength(45);
            ent.Property(x => x.Phone).HasColumnName("cmp_phone").HasMaxLength(15)
                .Metadata.SetValueComparer(new MyValueComparer());
            ent.HasKey(x => x.CompID).HasName("PK_Company");
        });
    }
}

要注意的是,PropertyBuilder 的成员方法/扩展方法并没有让咱们设置比较器的(使用 HasConversion 方法除外,配置值转换器时可以设置比较器。但这种只适合你需要转换类型的情形)。不过没事,咱们可以通过元数据对象来设置。

.Metadata.SetValueComparer(new MyValueComparer())

访问 PropertyBuilder.Metadata 得到的是属性元数据。要是访问 EntityTypeBuilder 的 Metadata 呢,那就是实体类型元数据。

下面,创建数据库上下文实例,先动态创建数据库,然后我们修改第一条记录的 Phone 属性为【与原电话号码相同但格式不同的电话】。

using (var c = new MyContext())
{
    var created = c.Database.EnsureCreated();

    if(created)
    {
        c.Companies.AddRange([
            new Company
            {
                Name = "三鬼贸易有限公司",
                Phone = "020-55128130"
            },
            new Company
            {
                Name = "一口焖信息服务有限公司",
                Phone = "0765-20881919"
            }
            ]);
        c.SaveChanges();
    }
}

using(var c = new MyContext())
{
    var obj = c.Companies.FirstOrDefault();
    if(obj != null)
    {
        obj.Phone = "(020)55128130";
        c.ChangeTracker.DetectChanges();    // 检测是否更改
        // 打印跟踪信息
        Console.WriteLine(c.ChangeTracker.ToDebugString(ChangeTrackerDebugStringOptions.IncludeProperties));
    }
}

在插入数据时,咱们设置的值是 020-55128130,之后我们修改为 (020)55128130,咱们认为这是同一个号码。由于这里老周没有调用 SaveChanges 方法,即不会保存到数据库。所以,需要调用一次 ChangeTracker.DetectChanges 方法,强制 context 做一轮更改扫描。最后向控制台打印跟踪信息。

属性修改后,跟踪信息如下:

Company {CompID: 286554a7-e1f4-4ab1-e791-08deae71e616} Unchanged
    CompID: '286554a7-e1f4-4ab1-e791-08deae71e616' PK
    Name: '三鬼贸易有限公司'
    Phone: '(020)55128130' Originally '020-55128130'

抠亮眼睛看清楚呢,属性值确实是变了的,但由于咱们自定义的比较器在作怪,所以,实体的状态依然被标记为 Unchanged。

 -----------------------------------------------------------------------------------------------

接下来,咱们看看值转器和值比较器一起用的情况。

// 表示颜色的结构
public struct RGBColor
{
    public byte Red { get; set; }
    public byte Green { get; set; }
    public byte Blue { get; set; }

    // 构造函数
    public RGBColor(byte r, byte g, byte b)
    {
        Red = r;
        Green = g;
        Blue = b;
    }
}

// 纸张 - 实体类
public class Paper
{
    public int ID { get; set; }
    public int WidthCM { get; set; }
    public int HeightCM { get; set; }
    public RGBColor Color { get; set; }
}

Paper 实体类的 Color 属性是 RGBColor 结构类型,而存入数据库时,我们只需要一个 uint 值即可,因此,它需要一个值转换器。

public class RGBValueConverter : ValueConverter<RGBColor, uint>
{
    #region 封装方法
    private static RGBColor IntToColor(uint color)
    {
        byte red = Convert.ToByte((color >> 16) & 0xff);
        byte green = Convert.ToByte((color >> 8) & 0xff);
        byte blue = Convert.ToByte(color & 0xff);
        return new RGBColor(red, green, blue);
    }

    private static uint ColorToInt(ref RGBColor color)
    {
        return Convert.ToUInt32((color.Red << 16) | (color.Green << 8) | color.Blue);
    }
    #endregion

    // 构造函数
    public RGBValueConverter()
        : base(rgb => ColorToInt(ref rgb), cv => IntToColor(cv))
    { }
}

由于 uint 是 32 位整数,咱们用它的低 24 位就可以表示 RGB 值。在查询数据时,数据库提供程序先以 uint 类型读出值,然后转为 RGBColor 结构实例,再赋给 Paper.Color 属性;反过来,存入数据时,将 RGBColor 的三个属性合成一个 uint 值,再用此值写入数据库。

由于 Paper 实体类的 Color 属性用的 RGBColor 类型,所以,比较器应面向 RGBColor 结构。

public class RGBValueComparer : ValueComparer<RGBColor>
{
    #region 封装的方法
    // 相等比较
    private static bool ColorEqual(RGBColor c1, RGBColor c2)
    {
        return c1.Red == c2.Red && c1.Green == c2.Green && c1.Blue == c2.Blue;
    }

    // 获取哈希值
    private static int ColorHash(RGBColor c)
    {
        HashCode hc = new();
        hc.Add(c.Red);
        hc.Add(c.Green);
        hc.Add(c.Blue);
        return hc.ToHashCode();
    }

    // 创建快照
    private static RGBColor CreateSnapshot(RGBColor c)
    {
        return new RGBColor(c.Red, c.Green, c.Blue);
    }
    #endregion

    // 构造函数
    public RGBValueComparer()
        :base((c1, c2) => ColorEqual(c1, c2), c => ColorHash(c), c => CreateSnapshot(c))
    { }
}

在用于创建快照的 CreateSnapshot 方法中,上述代码创了个 RGBColor 实例,并将原 RGBColor 对象的三个属性值作为参数传给构造函数。这就完成了深度拷贝。当然,因为 RGBColor 是值类型,其实它的实例本身就会把成员复制的。这里老周只演示功能,如果换成是类(引用),那就可以拷贝成员了,而不是仅传递引用。

在 ColorEqual 方法中,上述代码直接用比较两个 RGBColor 的实例的属性值。只有三个属性值都相等才行。

下面,咱们创建数据库上下文类,并做好配置。

public class MyContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // 配置连接字符串
        SqlConnectionStringBuilder cnnsbd = new();
        cnnsbd.DataSource = "(localdb)\\MSSQLLocalDB";
        cnnsbd.InitialCatalog = "test_5";
        cnnsbd.IntegratedSecurity = true;
        optionsBuilder.UseSqlServer(cnnsbd.ConnectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Paper>(ent =>
        {
            ent.ToTable("tb_papers");
            ent.Property(a => a.ID).HasColumnName("p_id");
            ent.Property(a => a.WidthCM).HasColumnName("wid_cm");
            ent.Property(a => a.HeightCM).HasColumnName("hei_cm");
            ent.Property(a => a.Color)
                 .HasColumnName("p_color")
                 // 应用转换器
                 .HasConversion(new RGBValueConverter(), new RGBValueComparer());
            ent.HasKey(s => s.ID).HasName("PK_Papers");
        });
    }

    // 数据集合
    public DbSet<Paper> Papers { get; set; }
}

在配置 Color 属性时,通过 HasConversion 方法可以指定值转换器和值比较器。

创建数据库上下文实例,插入一条测试数据。

using(var ctx = new MyContext())
{
    // 创建数据库
    bool r = ctx.Database.EnsureCreated();
    if(r)
    {
        // 插入测试数据
        ctx.Papers.Add(new() { HeightCM = 219, WidthCM = 100, Color = new RGBColor(0x2f, 0x05, 0x66) });
        ctx.SaveChanges();
    }
}

把刚存入数据库的记录查询出来,然后咱们替换 Color 属性的值,再扫描一遍实体状态。

using(var context = new MyContext())
{
    Paper? p = context.Papers.FirstOrDefault();
    // 修改颜色
    if(p is not null)
    {
        RGBColor c = p.Color;
        c.Blue = 0x4e;
        p.Color = c;
        // 强制扫描更改
        context.ChangeTracker.DetectChanges();
        // 打印跟踪信息
        Console.WriteLine(context.ChangeTracker.DebugView.LongView);
    }
}

和上一个示例一样,由于老周没有调用 SaveChanges 方法,所以不会保存到数据库,也不会扫描实体是否更改。需要手动调用一次 DetectChanges 方法强制进行一次扫描,最后打印实体跟踪信息。

输出结果如下:

Paper {ID: 1} Modified
    ID: 1 PK
    Color: 'QixenLie.RGBColor' Modified Originally 'QixenLie.RGBColor'
    HeightCM: 219
    WidthCM: 100

注意看,实体被标记为 Modified,表明通过值比较器已发现 Color 属性被更改。

好了,今天的水文就水到这里了。