ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NHibernate自定义映射类型IUserType深度实战与避坑指南

NHibernate自定义映射类型IUserType深度实战与避坑指南 ## 1. 什么时候才真正需要自定义映射类型 先说结论NHibernate 自带的那些映射规则能覆盖八成以上的常规需求。你定义了一个 int 属性它就映射成数据库里的 INT你定义了一个 DateTime它就对应 DATETIME 或 TIMESTAMP。真正需要你动手写自定义映射类型Custom Mapping Type的场景通常绕不开下面几类问题。 第一类是**属性类型和数据库列类型天然不对等**。比如你用了 DDD 的“值对象”模式Money 这个类里有 Amount 和 Currency 两个字段但你不希望为它单独建一张表而是想让它以 DECIMAL 加 VARCHAR 两列的形式直接嵌在订单表里。再比如你有一个 PhoneNumber 值对象里面封装了区号、号码和分机号的校验逻辑数据库里只存一个字符串。这种“对象是多个字段的封装表里也是多列但实体属性只有一个”的错位NHibernate 默认不知道该怎么处理必须靠自定义类型把对象和列之间来回翻译。 第二类是**枚举或标记对象的存储格式不理想**。最常见的就是把枚举存成字符串而不是数字。默认情况下 NHibernate 能用 EnumStringType 或 EnumType 处理一部分但当你希望“数据库里可以读懂的英文名”和“代码里语义化很强的枚举名”不完全一致时还是要自己写。 第三类是**需要自定义序列化策略**。比如 ImageMeta 这类复杂对象你想在某个字段里直接存 JSON 文本但又希望读出来时是强类型对象或者某个属性在持久化时要加密、要压缩这些横切的转换逻辑放在实体里会很脏放在自定义类型里则非常合适。 第四类是**遗留数据库改造**。老数据库里的字段类型和格式往往是历史遗留的比如日期以 VARCHAR(14) 存储格式是 yyyyMMddHHmmss而新代码里的属性是 DateTime。这种“旧格式到新类型”的翻译正是自定义映射类型的用武之地。 所以我判断该不该动手写自定义类型的标准就一句话**看属性和列之间是不是存在“转换成本”**。如果只是名字不同、类型一样那是 FluentNHibernate 映射配置的事不需要自定义类型只要出现“类型结构不一致、格式不一致、存储方式不一致”这三类情况就得进入 IUserType 的领域了。 ## 2. IUserType 接口拆解每个方法到底在干什么 要写自定义映射类型先得把 NHibernate.UserTypes.IUserType 这个接口吃透。刚接触它的时候容易犯一个毛病把十个成员全都补齐却不知道每个方法是给谁调的、什么时候调、调错了有什么后果。我根据自己的经验按“运行时”“持久化”“缓存”“元数据”四个维度重新给你拆一遍。 ### 2.1 类型的“身份证”SqlTypes 与 ReturnedType csharp public SqlType[] SqlTypes new[] { SqlTypeFactory.String }; public Type ReturnedType typeof(PhoneNumber);SqlTypes告诉 NHibernate 这个属性要映射成哪些数据库列、每列用什么类型。它返回的是数组意味着一个属性可以对应多列。ReturnedType则是实体属性在 .NET 里的真实类型。这两个成员决定了 Nhibernate 在生成 Schema、绑定参数、读取结果集时用什么样的类型去打交道。如果你让SqlTypes返回SqlTypeFactory.String却把ReturnedType写成DateTime那执行查询时肯定崩因为类型元数据对不上。这里有个经验在多列场景下SqlTypes数组的顺序必须和你后面NullSafeGet里读dr.GetString(0)、NullSafeGet里设参数的下标顺序保持一致。顺序错了数据就串列了。2.2 核心转换逻辑NullSafeGet 与 NullSafeSet这两个方法是整个自定义类型的灵魂名字也和 NHibernate 的“可空值安全性”绑定在一起。public object NullSafeGet(IDataReader rs, string[] names, object owner) { var phoneStr (string)NHibernateUtil.String.NullSafeGet(rs, names[0]); return string.IsNullOrEmpty(phoneStr) ? null : PhoneNumber.Parse(phoneStr); } public void NullSafeSet(IDbCommand cmd, object value, int index) { var phone value as PhoneNumber; var phoneStr phone?.ToString(); NHibernateUtil.String.NullSafeSet(cmd, phoneStr, index); }读的时候Get从IDataReader里取原始数据库值加工成ReturnedType的实例写的时候Set把实体属性值反向翻译回数据库参数。务必调用 NHibernate 自带类型如NHibernateUtil.String的NullSafeGet/NullSafeSet来处理空值自己用rs.IsDBNull判断会漏掉很多边界情况尤其是参数为空时容易怠惰地写死DBNull.Value但IDataReader的GetString(0)在DBNull时直接抛异常。2.3 对象的拷贝与拆分DeepCopy / Assemble / Disassemble很多教程对这三个方法一笔带过但在实际项目中它们决定了你的事务能不能安全回滚、二级缓存能不能正常工作。DeepCopy在 NHibernate 需要克隆属性值时调用。为什么需要克隆因为如果实体对象在会话里被修改了NHibernate 要拿“修改后的值”和“快照里的旧值”做 dirty-check。如果自定义类型返回的是同一个可变对象引用快照和当前值永远相等脏检查就失效了update 永远发不出去。public object DeepCopy(object value) { var phone value as PhoneNumber; return phone?.Clone(); }Assemble和Disassemble是为二级缓存准备的。对象从数据库里取出、放入缓存时需要被“拆解”成可序列化的形式通常就是拆回原始字段从缓存取出时再“组装”回对象。如果自定义类型是不可变的如字符串、值对象直接返回原对象即可。public object Disassemble(object value) value; public object Assemble(object cached, object owner) cached;重要提示如果你的值对象是可变类型比如包含ListT这里不能直接返回原对象否则缓存里存了同一个引用多个会话会互相污染。2.4 生命周期与比较IsMutable / Replace / Equals / GetHashCodeIsMutable表示对象的可变性。不可变对象在缓存和脏检查时有大量优化空间所以如果你能保证值对象设计成不可变的就返回false。Replace只在不可变对象上有实际意义NHibernate 做快照比较时如果对象不可变它直接拿新对象替换旧对象。public object Replace(object original, object target, object owner) original;Equals和GetHashCode比较关键。NHibernate 判断两个属性值是否相同不是拿object.Equals直接比而是调用你这个类型里的Equals。如果你写的是引用相等那每次从数据库读出来的新对象和实体里的对象永远是“不相等”会导致每次会话都发一次无意义的 update。所以必须按业务字段重写Equals/GetHashCode。3. 完整实战从值对象到自定义映射类型的落地实现理论拆完我带你完整地实现一个真实案例。这个案例是我在电商项目里做过的——一个Money值对象包含金额decimal和币种Currency枚举。数据库里不额外建表直接嵌在订单表的MoneyAmount、MoneyCurrency两列里。3.1 定义值对象public class Money { public decimal Amount { get; } public Currency Currency { get; } public Money(decimal amount, Currency currency) { if (amount 0) throw new ArgumentException(金额不能为负数, nameof(amount)); Amount amount; Currency currency; } public static Money Parse(string amountStr, string currencyStr) { var amount decimal.Parse(amountStr, CultureInfo.InvariantCulture); var currency Enum.ParseCurrency(currencyStr); return new Money(amount, currency); } public override bool Equals(object obj) { return obj is Money other Amount other.Amount Currency other.Currency; } public override int GetHashCode() { unchecked { return (Amount.GetHashCode() * 397) ^ Currency.GetHashCode(); } } } public enum Currency { CNY, USD, EUR }注意这里我特意把Money设计成不可变的属性只有 get、构造函数在构造时校验。这不是所有教程都强调的点但对自定义映射类型非常重要因为不可变对象在IsMutable、DeepCopy、缓存处理等环节都能简化到极致。3.2 实现 IUserType这是整个自定义映射类型的核心文件。你会发现实现本身不复杂难的是把接口里每个细节都想明白。public class MoneyType : IUserType { public bool IsMutable false; public Type ReturnedType typeof(Money); public SqlType[] SqlTypes new[] { SqlTypeFactory.Decimal, SqlTypeFactory.String }; public object NullSafeGet(IDataReader rs, string[] names, object owner) { if (names.Length ! 2) return null; var amount (decimal)NHibernateUtil.Decimal.NullSafeGet(rs, names[0]); var currencyStr (string)NHibernateUtil.String.NullSafeGet(rs, names[1]); if (currencyStr null) return null; return new Money(amount, Enum.ParseCurrency(currencyStr)); } public void NullSafeSet(IDbCommand cmd, object value, int index) { var money value as Money; if (money null) { NHibernateUtil.Decimal.NullSafeSet(cmd, null, index); NHibernateUtil.String.NullSafeSet(cmd, null, index 1); return; } NHibernateUtil.Decimal.NullSafeSet(cmd, money.Amount, index); NHibernateUtil.String.NullSafeSet(cmd, money.Currency.ToString(), index 1); } public object DeepCopy(object value) { // Money 不可变直接返回原对象即可 return value; } public object Replace(object original, object target, object owner) { return original; } public object Assemble(object cached, object owner) { return cached; } public object Disassemble(object value) { return value; } public override bool Equals(object x, object y) { if (ReferenceEquals(x, y)) return true; if (x null || y null) return false; return x.Equals(y); } public override int GetHashCode(object x) { return x?.GetHashCode() ?? 0; } }这版实现里有几个细节值得琢磨第一NullSafeSet的下标处理。我用了index和index 1。NHibernate 传进来的index是当前属性在 SQL 参数列表中的起始位置多列情况需要依此后移。如果你写成固定0多个自定义类型属性并存时参数就会互相覆盖这是一个坑。第二空值分支要“对称”。读的时候我判断currencyStr null就返回null写的时候money null就把两列全部设为null。不对称的话一个全空的记录读出来就可能解析失败或产生脏数据。第三字符串枚举转换。这里选用Enum.ParseCurrency并且把数据库里的值按ToString()写入。这样当你在 SQL 客户端看表时MoneyCurrency列显示的是CNY、USD而不是0、1。3.3 在实体中应用自定义类型public class Order { public virtual int Id { get; set; } // 这是自定义类型的核心应用点 public virtual Money TotalAmount { get; set; } public virtual DateTime CreatedAt { get; set; } }如果使用 FluentNHibernate映射配置这样写public class OrderMap : ClassMapOrder { public OrderMap() { Table(Orders); Id(x x.Id).GeneratedBy.Identity(); // Key 点自定义类型映射到两列 Map(x x.TotalAmount) .CustomTypeMoneyType() .Columns.Clear() .Columns.Add(MoneyAmount, MoneyCurrency); } }CustomTypeMoneyType()是关键入口它告诉 NHibernate这个属性不要用默认的类型推断而是交给MoneyType来处理。后面的Columns.Add把对象属性拆到两列上顺序跟SqlTypes里的定义一致。如果你想用 XML 映射则是这样property nameTotalAmount typeYourNamespace.MoneyType, YourAssembly column nameMoneyAmount / column nameMoneyCurrency / /property4. 注册、配置与查询检索的完整链路写完了自定义类型和实体映射新的问题来了NHibernate 怎么知道你的MoneyType是个合法的类型它在查询、缓存、脏检查这些环节是怎么走通的4.1 注册方式显式声明好过隐式扫描使用 FluentNHibernate 场景下最简单的方式是在映射里显式声明CustomTypeMoneyType()。这种方式的优点是非常直接编译器帮你检查类型存在性缺点是一个属性写一次类多了会比较啰嗦。另一种方式是通过配置注册全局别名。在Configuration里把自定义类型挂到一个字符串名字下var config new Configuration(); config.SetProperty(NHibernate.Cfg.Environment.Dialect, typeof(MsSql2012Dialect).AssemblyQualifiedName); // 注册类型别名 config.SetTypeDef(MoneyType, typeof(MoneyType).AssemblyQualifiedName, null);注册之后XML 映射里可以直接写typeMoneyType而不用再写完整的程序集限定名。这种方式对维护旧项目的 XML 映射文件很友好尤其是当系统里已经有大量映射文件、不想逐个改CustomType的时候。4.2 自定义类型与查询HQL、LINQ、条件查询的边界很多人刚写完自定义类型就会兴冲冲地在 HQL 里写session.QueryOrder() .Where(o o.TotalAmount.Amount 1000) .ToList();这里我要泼一盆冷水这个查询基本不会成功除非你给MoneyType配置了足够明确的“列投影”指示。NHibernate 处理属性内部的子字段访问时会尝试把Amount翻译成 SQL 列表达式。但它的翻译器并不了解你的值对象内部结构所以更稳妥的写法是把值对象当作一个整体比较或者在查询时使用 HQL 明确指定构成列// 正确但有限只能做整体比较 session.CreateQuery(from Order o where o.TotalAmount :money) .SetParameter(money, new Money(1000, Currency.CNY)) .ListOrder(); // 按具体列查直接绕开自定义类型使用原始 SQL 表达式 session.CreateSQLQuery( select * from Orders where MoneyAmount :amount and MoneyCurrency :currency) .AddEntityOrder() .SetDecimal(amount, 1000) .SetString(currency, USD) .ListOrder();这种“值对象内部字段不适合直接参与查询”的局限其实不是 NHibernate 独有的Hibernate 也一样。我的建议是在查询边界上不要试图用值对象的子字段做精细过滤能用独立列过滤就用独立列能整体比较就整体比较。你设计值对象是为了业务封装不是为了查询优化。4.3 事务、脏检查与快照不可变类型带来的红利你选择IsMutable false之后NHibernate 的 dirty-check 机制会有明显区别。常规的可变对象在会话快照里存的是对象的引用一旦实体属性值被修改快照和实体对象指向同一个引用快照检测变得困难所以 NHibernate 必须在快照里额外做DeepCopy不可变对象没有这种烦恼快照里存的就是原引用因为改不了所以不会产生“快照被改”的假象。这让update语句的生成更精准——只有真正出现“旧值和新值不同”时才触发 update而不像某些实现那样因为对象引用比较失败而反反复复发更新语句白白增加数据库压力。5. 踩过的坑自定义映射类型里那些“看起来没问题但最终爆炸”的地方这部分内容不是从文档里抄来的而是我真实调试过项目、查过源码甚至被生产环境数据坑过之后总结出来的经验。读一遍能帮你省下至少两三个通宵。5.1 缓存模式冲突假设不可变结果可变这是最常见的坑。你把自定义类型标成IsMutable false但如果属性值对象内部含有List、Dictionary这种可变成员或者你在DeepCopy里返回了原引用那一旦二级缓存开启多个 session 之间就有机会共享同一个可变对象。某个 session 改了它其它 session 读到的数据就“凭空变化”了而且这种变化不会触发任何脏检查也不会发update完全是内存里的野指针式污染。解决办法只有一个诚实地标注可变性。只要值对象内部有任何可变成员就标true并在DeepCopy里真正地深拷贝。不要为了贪图那一点性能优化而谎报不可变。5.2 Equals 和 GetHashCode 实现不当无数无意义的 update我早期写自定义类型时直接用了默认的对象相等比较return x y用的是引用比较。结果每次session.Flush()时NHibernate 都会认为所有实体的属性值都“变了”因为从数据库读出来的对象和实体里持有的对象不是同一个引用。于是每条记录都被 update 了一遍即使是完全没改动过的数据日志里充满了UPDATE ... SET ...。这种问题很难定位因为业务逻辑看起来完全正常只有通过 SQL 日志才能发现大量重复UPDATE。修复的办法就是按值对象的核心字段重写Equals和GetHashCode让“数值相等”替代“引用相等”。5.3 多列类型与NullSafeSet下标错位一个属性映射多列时NullSafeSet的下标计算很容易错。回顾一下我前面的例子NHibernateUtil.Decimal.NullSafeSet(cmd, money.Amount, index); NHibernateUtil.String.NullSafeSet(cmd, money.Currency.ToString(), index 1);这里假设SqlTypes数组长度为 2所以一次NullSafeSet占用index和index 1。如果你同时有两个这样的自定义类型属性第二个属性的index起始值就是整个参数列表的下一个位置如果下标不动你写进去的值就会复盖第一个属性的参数。5.4 二级缓存里的Assemble/Disassemble实现不当如果你不实现这两个方法或直接返回int之类的基础对象缓存读写会变得很奇怪。最典型的表现是第一次查询没问题第二次查询直接返回缓存的“残缺对象”属性是null或变成非预期类型。根因在于Disassemble时你把值对象拆分了Assemble时没能拼回去。标准做法是如果值对象不可变这两个方法直接返回原对象如果可变那么Disassemble返回可序列化的基础数据比如把Money拆成Tupledecimal, stringAssemble再拼回来。5.5 方言差异不同数据库类型名不同SqlTypeFactory.Decimal和SqlTypeFactory.String是跨方言的抽象NHibernate 会根据方言翻译成具体数据库类型。但你如果为了省事直接写SqlTypeFactory.GetString(100)或SqlTypeFactory.GetDecimal(18, 2)遇到 SQLite、Oracle、MySQL 这些不同方言长度或精度处理差异就可能让 SchemaExport 生成的 DDL 不一致。我的建议是能用抽象工厂方法就用抽象工厂方法需要指定长度/精度时再额外用WithLength之类的配套方法尽量减少在自定义类型里写死数据库特性。6. 进阶玩法把自定义映射类型用到更复杂的场景掌握了MoneyType这种基础例子之后你会发现IUserType的扩展空间其实很大。这里再分享几种我实际用过的进阶场景供你举一反三。6.1 JSON 序列化值对象单列存储复杂结构如果你的值对象是一个ImageMeta包含Url、Width、Height、Alt多个字段但数据库里不想建多列只想在MetaJson一个NVARCHAR(MAX)列存 JSON那自定义类型就是天然的解决方案。public class ImageMetaType : IUserType { public SqlType[] SqlTypes new[] { SqlTypeFactory.GetString(int.MaxValue) }; public Type ReturnedType typeof(ImageMeta); public object NullSafeGet(IDataReader rs, string[] names, object owner) { var json (string)NHibernateUtil.String.NullSafeGet(rs, names[0]); return string.IsNullOrEmpty(json) ? null : JsonConvert.DeserializeObjectImageMeta(json); } public void NullSafeSet(IDbCommand cmd, object value, int index) { var meta value as ImageMeta; var json meta null ? null : JsonConvert.SerializeObject(meta); NHibernateUtil.String.NullSafeSet(cmd, json, index); } }这种玩法的利弊都很明显。好处是表结构清爽加字段不用改表坏处是这一列无法在 SQL 里做索引、排序、过滤查询只能整列匹配或走后端过滤。我一般只在“展示型元数据”上使用比如图片尺寸、链接信息核心业务字段绝不塞 JSON。6.2 加密列让加密逻辑不出现在业务代码里假设你有SSN社会安全号码或手机号这类需要加密存储的字段。最简单的做法是在 Service 层手动加密解密但容易漏业务代码里到处都是加解密的杂音。把它封装进自定义类型则要干净得多public class EncryptedStringType : IUserType { private static readonly byte[] Key Convert.FromBase64String(...); private static readonly byte[] IV Convert.FromBase64String(...); public SqlType[] SqlTypes new[] { SqlTypeFactory.GetString(512) }; public Type ReturnedType typeof(string); public object NullSafeGet(IDataReader rs, string[] names, object owner) { var encrypted (string)NHibernateUtil.String.NullSafeGet(rs, names[0]); return string.IsNullOrEmpty(encrypted) ? null : Decrypt(encrypted); } public void NullSafeSet(IDbCommand cmd, object value, int index) { var plain value as string; var encrypted string.IsNullOrEmpty(plain) ? null : Encrypt(plain); NHibernateUtil.String.NullSafeSet(cmd, encrypted, index); } // 参考代码省略 Encrypt / Decrypt 的具体实现 }这个玩法最大的价值在于所有读写入口都经过同一个加密转换逻辑不会出现一条数据在某个入口被加密、在另一个入口却是明文的情况。就算以后更换加密算法你也只需要改一个类。当然坑也存在尤其是查询条件里不能直接用这个字段做where比较因为数据库里存的是密文。6.3 时间日期特殊格式把遗留格式翻译成 DateTime我处理过一个老系统数据库里CREATE_TIME列存的是VARCHAR(17)值形如20240117143052123表示2024-01-17 14:30:52.123。应用层属性是DateTime。直接在数据库那侧能看懂但代码这边就是一个乱字符串。用自定义类型最省事public class LegacyDateTimeType : IUserType { public SqlType[] SqlTypes new[] { SqlTypeFactory.GetString(17) }; public Type ReturnedType typeof(DateTime); public object NullSafeGet(IDataReader rs, string[] names, object owner) { var raw (string)NHibernateUtil.String.NullSafeGet(rs, names[0]); if (string.IsNullOrEmpty(raw)) return null; return DateTime.ParseExact(raw, yyyyMMddHHmmssfff, CultureInfo.InvariantCulture); } public void NullSafeSet(IDbCommand cmd, object value, int index) { var dt value as DateTime?; var raw dt?.ToString(yyyyMMddHHmmssfff, CultureInfo.InvariantCulture); NHibernateUtil.String.NullSafeSet(cmd, raw, index); } }这样业务层面对这个字段的感知就是正常的DateTime类型与其它新字段没有差别只有数据访问层知道它在数据库里是那种奇怪格式。以后如果数据库结构升级、字段格式改成真正的DATETIME只需要改这个自定义类型业务层一行都不用动。7. 性能与设计取舍不是所有属性都值得自定义类型最后聊一个更实际的话题。自定义类型写起来并不复杂但工程上最怕的是“为了炫技而抽象”把本来简单直接的问题复杂化。我给自己定的取舍标准是能用内置类型解决的绝不上自定义类型。单字段映射、类型一致直接Map(x x.Name)。枚举本身可以直接用Map(x x.Status).CustomTypeEnumStringType()不需要自己写。多个字段的组合值对象如果只在查询结果里展示、不参与过滤、不参与脏检查其实可以考虑 DTO AutoMapper而不是实体属性。如果值对象在多个实体里反复出现比如Money在订单、退款单、付款单里都有这种“全局值对象”才是值得写自定义类型的场景因为一次实现多处复用。另外性能方面要注意一点NullSafeGet在每次查询加载实体时都会被调用如果你的转换逻辑里有高开销操作比如加密、正则、JSON 反序列化它的执行频率会比你想的高得多。我的经验是加密字段性能开销大适合低频读取的数据不适合放在每次列表页都全量加载的模型里。JSON 序列化的值对象要小心惰性加载如果实体是延迟加载的访问该属性时才会触发一次读取但如果每次列表也会 select 这个列开销依然存在。自定义类型里的常量加密密钥、正则表达式要定义成静态只读避免每次调用都重新初始化。还有一个小技巧在查询时如果不需要某个自定义类型字段可以用Projections.Property或 DTO 投影绕开实体的完整加载这样NullSafeGet连触发机会都没有性能会有可感知的提升。8. 最后几句实在话自定义映射类型这个东西本质上是给 NHibernate 的“类型系统”插了一个自定义的翻译器。它的接口不算复杂但每个方法的调用时机、返回值的生命周期都跟 NHibernate 的缓存、脏检查、会话管理深度绑定。我见过太多项目写了自定义类型却只实现了NullSafeGet和NullSafeSet其它方法全部返回null然后某天开启二级缓存后数据莫名丢失最后花了一整周排查才发现是Assemble没写对。所以我的建议是第一次写自定义类型务必要把接口所有成员都实现完整哪怕只是简单地转交原对象。先把正确性跑通再去做可变性的优化和缓存特性的利用。尤其是不可变值的对象IsMutable falseDeepCopy直接返回原对象 Disassemble/Assemble直接返回原对象这四个组合在大多数场合都能安全运转。如果你是在维护老项目XML 映射文件里可能已经写了不少type某某Type这时候看到IUserType的实现可以拿我上面的检查清单逐条对一遍。毕竟自定义类型的坑都藏在细节里等生产环境爆出来再修代价就不是写几行代码的事了。
返回列表