【EF Core】使用自定义的值比较器

发布时间:2026/6/26 5:47:53
【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% 的做法是从 ValueComparerT 类派生。不需要重写任何成员只提供三个方法由对应的委托类型接收的实现然后传给基类的构造函数即可。需要的三个委托为1、FuncT, T, bool两个输入参数是 T 的值返回值是 bool 类型。该委托用于判断两个值是否相等相等就返回 true不相等就返回 false。2、FuncT, int返回输入参数 T 的哈希值整型。3、FuncT, T此委托用于创建“快照”由 ChangeTracker 负责管理。返回的 T 实例就是创建的快照实例。对于简单类型咱们不需要实现这个委托。它主要面向需要深度拷贝或存在嵌套数据的值用于自定义属性值的拷贝。对于这个固定电话咱们要实现相等比较和哈希计算创建快照不需要实现使用 EF Core 内置的就可以。于是定义 MyValueComparer 类。public class MyValueComparer : ValueComparerstring { /// summary /// 匹配规则 /// /summary private const string REGEX_PATTERN ^\\(?(\\d{3,4})(?:\\)|-)?(\\d{6,})$; #region 辅助方法 /// summary /// 分析固话号码 /// /summary /// param nameno输入值/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 是数字{34} 表示数字的出现次数为最少三次最多四次。即区号由三到四个数字组成(?:\)|-)? 表示可选的右括号或者“-”。(?: ...) 避免被识别为分组因为我们对右括号和“-”不感兴趣匹配结果也不要存储这些字符所以用 ?: 告诉正则处理引擎可以匹配它但不要存到结果中我们不需要“|”表示分支并列、或即可以出现右括号或“-”。后面的 ? 表示这个分组可以出现可以不出现。其实这里用“”也可以右括号和“-”应该至少出现一次(\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 DbSetCompany Companies { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(server.\\Test;databasesome_db;Trust Server CertificateTrue;Integrated SecurityTrue); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityCompany(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(newMyValueComparer());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}UnchangedCompID: 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; } publicRGBColorColor { get; set; } }Paper 实体类的 Color 属性是 RGBColor 结构类型而存入数据库时我们只需要一个 uint 值即可因此它需要一个值转换器。public class RGBValueConverter : ValueConverterRGBColor, 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); returnnewRGBColor(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 : ValueComparerRGBColor { #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) {