NHibernate的映射体系里有个非常有意思的小众特性叫entity-name,我最初注意到它是在一个报表模块的改造中:同一个数据实体,需要按不同业务口径做两套查询,但又不想拆成两个类来维护。翻了一下午映射文档,最后靠这个特性优雅地解决了问题。它不算新,但对很多刚从 EF 或 Dapper 转过来的开发者来说相当陌生,网上系统性讲明白的资料也不多,所以值得单独写一篇。
这篇内容适合对 NHibernate 映射机制有一定了解、想深入理解 Session 与持久化对象关系,或者正在设计多态结构、历史数据拆分场景的朋友。我会从概念讲起,给出 XML 和代码映射的实操写法,配合多态查询的完整示例,再顺手分享几个实际项目里踩过的坑。
1. entity-name 到底是什么
1.1 一句话理解:把“业务名”和“类名”解耦
常规的 NHibernate 映射中,一个持久化类对应一个映射定义,默认情况下这个映射的标识就是类的全名。你写ICriteria查询时用typeof(User),写 HQL 时用from User,Session 也知道你要操作的是哪个类。
entity-name做的事情很简单:允许你给这个映射另起一个名字,独立于 .NET 类型名。也就是说,User类可以被映射成"UserProfile"、"BackendUser"甚至"UserHistory"这样的名字。持久化对象的真实类型没变,但 NHibernate 的“持久化标识”变了。
对应到映射文件里,就是<class>标签上除了name属性(指 .NET 类型)之外的entity-name属性:
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"> <class name="MyApp.Domain.User, MyApp" entity-name="UserProfile" table="t_user_profile"> <id name="Id" column="id" type="long"> <generator class="native"/> </id> <property name="UserName" column="user_name" type="string"/> </class> </hibernate-mapping>上面这段代码注册了一个叫UserProfile的实体,程序里依然是User类型的对象。你用session.Get("UserProfile", 1L)能查到数据,用session.Get<User>(1L)就查不到,因为后者找的是默认同名的那个映射。
1.2 设计初衷:NHibernate 元数据层的“表名”
我个人的理解是,entity-name是 NHibernate 映射元数据中的“逻辑主键”。Metamodel里维护着一套完整的实体名到持久化类的映射关系,Session 在执行加载、保存、级联、查询时,第一步永远是根据实体名找到对应的EntityPersister,然后才谈得上 SQL 生成、字段装配这些事情。
这个设计让 NHibernate 拥有比很多 ORM 更强的元数据抽象能力:在默认情况下它用类名作为这个逻辑主键,但当你需要扩展时,它允许你显式指定一个更贴合业务含义的名字。这个机制衍生出的典型能力包括:
- 一个类多套映射,对应多张表或多种查询口径
- 继承体系中按“实体名”而非“运行时类型”操作对象
- 动态映射场景中的
entity-name覆盖,做内容模型的基座
1.3 与name、class属性的区别
这个经常被搞混。name在<class>上代表 .NET 类型全名;entity-name是映射别名;class属性在多对一、集合元素上则是指关联目标的类型或实体名。
我画过一张自己用的速查表,帮你理清这几种属性在不同标签下的作用:
| 属性 | 出现位置 | 含义 |
|---|---|---|
name | <class>、<subclass> | .NET 类型全名 |
entity-name | <class>、<subclass>等 | 映射别名,唯一标识一个持久化映射 |
class | <many-to-one>、<one-to-one>、集合标签 | 关联目标类型或实体名(可见entity-name与类名都接受) |
column | 各属性标签 | 表字段名 |
当你设置entity-name后,关联映射里指向它的class属性也要跟着改用实体名,否则程序会报“无法解析关联目标”的错误。这块我在第 4 部分会专门拿坑出来讲。
2. 映射配置实操
2.1 XML 方式:子类继承体系中的实体名
映射继承体系时用实体名,能获得非常干净的查询语义。考虑一个宠物零售项目里的场景:有一个Animal基类,下面有Cat和Dog两个子类,我想在查询时直接按“动物种类”来筛选,而不是依赖运行时类型或额外的Discriminator字段去判断。
映射文件写成这样:
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" default-access="property"> <class name="MyApp.Model.Animal, MyApp" entity-name="Animal" table="animal"> <id name="Id" column="id" type="long"> <generator class="native"/> </id> <discriminator column="kind" type="string" /> <property name="Name" column="name" type="string"/> <subclass name="MyApp.Model.Cat, MyApp" entity-name="Cat" discriminator-value="cat"> <property name="FavouriteToy" column="favorite_toy" type="string"/> </subclass> <subclass name="MyApp.Model.Dog, MyApp" entity-name="Dog" discriminator-value="dog"> <property name="BarkVolume" column="bark_volume" type="int"/> </subclass> </class> </hibernate-mapping>注意这里我同时定义了Animal与Cat、Dog三个实体名。它们的 .NET 类型分别对应基类和子类,但实体名是独立的。查询时 HQL 可以直接写:
from Cat from Animal from Dog d where d.BarkVolume > 5from Cat在 NHibernate 里会自动加上 discriminator 条件,只查 kind='cat' 的记录。这是默认情况下做不到的,默认时 HQL 只能写from Animal,然后用where class=...来判断,麻烦不少。
2.2 同一个类型,多个实体名映射
这是一个比较进阶的用法:一个 .NET 类型对应多套映射,每套映射拥有独立的entity-name、独立的表和独立的字段映射。这个方案在“历史归档”和“多租户”场景中价值极高。
拿订单来说。订单对象本身的逻辑我不想复制一份,但历史订单和有效订单在表中字段略有差异(历史表多一个archived_time字段),访问频率也不同。我配置两份映射:
<class name="MyApp.Model.Order, MyApp" entity-name="Order" table="t_order"> <id name="Id" column="id" type="long"> <generator class="native"/> </id> <property name="Amount" column="amount" type="decimal"/> <property name="Status" column="status" type="int"/> </class> <class name="MyApp.Model.Order, MyApp" entity-name="OrderArchive" table="t_order_archive"> <id name="Id" column="id" type="long"> <generator class="native"/> </id> <property name="Amount" column="amount" type="decimal"/> <property name="Status" column="status" type="int"/> <property name="ArchivedTime" column="archived_time" type="datetime"/> </class>操作的时候:
// 读写当前订单 var order = session.Get("Order", orderId); // 归档时写入历史表 session.Save("OrderArchive", order);同一个Order类型对象,通过不同的实体名操作,就会访问不同的表。这比新建一个OrderArchive类要省事,也比复制属性、写 AutoMapper 映射要干净得多。当然代价是这两套映射的字段变化需要同步维护,所以这个模式要克制地用。
2.3 声明式(Attribute)映射、Fluent 与代码映射
如果你不喜欢 XML,entity-name同样可以通过声明式和代码映射配置。用 NHibernate.Mapping.Attributes:
[Class(NameType = typeof(Animal), EntityName = "Animal", Table = "animal")] public class Animal { [Id(0, Name = "Id", Column = "id")] [Generator(1, Class = "native")] public virtual long Id { get; set; } }Fluent 映射的话,需要用到EntityName方法:
public class AnimalMap : ClassMapping<Animal> { public AnimalMap() { EntityName("Animal"); Table("animal"); Id(x => x.Id, m => m.Generator(Generators.Native)); } }如果你用的还是较早版本的 Fluent NHibernate,对应写法是.EntityName("Animal")或.AsEntityName()之类的辅助方法。代码映射是 NHibernate 3.2 之后推荐的方案,用起来灵活得多,调试也方便。
需要注意一点:混合使用多种映射方式时,不要对同一个类型注册两个同名实体,否则启动时MappingException会直接亮给你看。我习惯的做法是团队内统一走一种映射方式,要么全 XML,要么全代码映射,避免维护混乱。
3. 核心 API 与多态场景应用
3.1 ISession 上的实体名操作
配好了实体名,最终还是要落到操作上。ISession 有不少重载是带实体名参数的,这些方法用公共 API 就能访问,不需要什么黑科技:
| 方法 | 说明 |
|---|---|
session.Get(entityName, id) | 根据实体名和主键加载对象,没有则返回 null |
session.Load(entityName, id) | 返回代理对象,不存在主键时访问才抛异常 |
session.Save(entityName, obj) | 插入或更新实体到指定实体名映射的表 |
session.Update(entityName, obj) | 更新实体 |
session.SaveOrUpdate(entityName, obj) | 智能判断保存或更新 |
session.Delete(entityName, obj) | 删除实体 |
session.CreateQuery("from " + entityName) | 用 HQL 按实体名查询 |
这些方法第二眼看上去没什么,但在代码里切换的时候非常顺手。比如要做批量归档,完全可以用统一的方式遍历列表:
public void ArchiveOrders(ISession session, IList<Order> orders) { foreach (var order in orders) { session.Save("OrderArchive", order); session.Delete("Order", order); } }注意SaveOrUpdate和Update在实体名场景下最容易踩坑:如果你用一个游离状态的Order对象调SaveOrUpdate("OrderArchive", order),而这个对象的主键在Order表里也存在,NHibernate 不会自动判断“应该从哪张表加载”,它会按照OrderArchive的映射去select一遍,查不到记录就按新增处理。这个逻辑在对照表业务下没问题,但如果你期望它根据主键去原表加载,就完全不是那么回事了。
3.2 多态查询的完整示例
回到动物继承的例子,我们来看看在实际的仓储逻辑里如何使用实体名完成多态查询:
public IList<T> GetByEntityName<T>(ISession session, string entityName) { return session.CreateQuery($"from {entityName}").List<T>(); }这个泛型方法看着简单,实际上真的能从“动物”表中捞出一批只属于某个子类的记录,并自动映射为对应子类型对象。我用一个查询示例组合一下:
using (var session = sessionFactory.OpenSession()) { // 只查猫 var cats = session.CreateQuery("from Cat").List<Animal>(); // 只查狗,且按叫声分贝排序 var loudDogs = session.CreateQuery("from Dog d where d.BarkVolume >= 8 order by d.BarkVolume desc") .List<Animal>(); // 查询全部动物 var all = session.CreateQuery("from Animal").List<Animal>(); // 用条件查询 API,按实体名指定 var catsByCriteria = session.CreateCriteria("Cat") .Add(Restrictions.Gt("FavouriteToy", "")) .List<Animal>(); }这里有个细节值得注意:CreateCriteria("Cat")返回的结果类型,如果你用List<Animal>接收,NHibernate 会将结果转成真实运行时类型Cat。如果你用List<Cat>接收,也是合法的,因为运行时类型本来就是Cat。
配合实体名在 LINQ 里的使用可以这样:
var dogs = session.Query<Animal>("Dog") .Where(a => a.Name.StartsWith("旺")) .ToList();session.Query<T>(entityName)这个重载在 NHibernate 5.0 之后的版本里可以直接指定查询的实体名,但需要注意内部生成的 SQL 依然走的是discriminator列过滤,所以映射文件里discriminator-value必须明确配置,不能依赖默认值。
3.3 存储过程与命名查询中的实体名
命名查询(Named Query)同样可以使用实体名。这个场景有点冷门,但我在做报表取数时用过,非常合适:
<query name="GetActiveCats"> <query-param name="minAge" type="int"/> <![CDATA[ from Cat c where c.Age >= :minAge ]]> </query>调用时:
var cats = session.GetNamedQuery("GetActiveCats") .SetInt32("minAge", 2) .List<Animal>();命名查询的好处是把 HQL 从代码中抽取出来,SQL 调优时不用改代码。查询名字本身没有实体名的概念,但 HQL 内部引用的是实体名,这就足够了。
4. 实际项目中的应用模式设计
4.1 模式一:历史数据归档分离
我在某电商平台的后台系统里实际用过这个模式。订单表 t_order 数据量涨得非常快,业务方要求 90 天前的订单从主查询中剥离,但审计时需要完整访问。经典方案是定期把数据搬到 t_order_archive 表,删除原表数据。
问题来了:ArchiveOrder 表和 Order 表结构 95% 相同,新建一个类意味着所有 Service 层要写两套接口,或者用继承。用 entity-name 的“同一类型双映射”方案之后,核心的迁移逻辑简化成:
var expiredOrders = session.CreateQuery("from Order where CreatedTime < :deadline") .SetDateTime("deadline", deadline) .List<Order>(); foreach (var order in expiredOrders) { session.Save("OrderArchive", order); session.Delete("Order", order); }这段代码从业务层看就是在“操作同一类订单对象”,但从持久化层看,它在两张表之间搬数据。关键是传输过程中你不用重建对象,字段也不用逐一复制,映射框架帮你装配。实测在几万条数据级别下,比手工写 DataTable 迁移要稳得多,也更容易维护。
4.2 模式二:同结构但不同“业务身份”的数据
有一类业务会用到这样的模型:短信记录和邮件记录字段几乎一样,但业务上它们是两类完全不同的记录,查询时也不能混在一起。用常规设计,要么建两个几乎相同的类,要么用一个类型加一个类型字段区分。
用实体名方案,一个NotificationRecord类可以同时映射成SmsRecord和EmailRecord两张表:
<class name="MyApp.Model.NotificationRecord, MyApp" entity-name="SmsRecord" table="sms_record"/> <class name="MyApp.Model.NotificationRecord, MyApp" entity-name="EmailRecord" table="email_record"/>仓储层这样设计:
public class NotificationRepository { private readonly ISession _session; public void SaveSms(NotificationRecord record) { _session.Save("SmsRecord", record); } public void SaveEmail(NotificationRecord record) { _session.Save("EmailRecord", record); } public IList<NotificationRecord> GetRecentSms(DateTime since) { return _session.CreateQuery("from SmsRecord s where s.CreatedAt >= :since") .SetDateTime("since", since) .List<NotificationRecord>(); } }这个模式省掉了两个子类文件、两份属性复制代码,也没有引入复杂的抽象继承层次。代价是如果你把两个实体名的对象混在一个集合里再整体保存,可能出现“会话中同一类型、不同实体名的实例同时存在”的问题。
4.3 模式三:多态体系下的公共操作基座
动物模型那种继承体系是另一种典型用法。当你的领域模型天然有“基类 + 多子类”的结构,且想在上层用统一接口处理时,实体名可以作为“类型路由的 Key”。
设想一个消息中心,有文本消息、图片消息、语音消息,都继承自BaseMessage。你可以用:
public T GetMessage<T>(string entityName, long id) where T : BaseMessage { return session.Get(entityName, id) as T; } public IList<BaseMessage> GetMessagesByType(string entityName, long userId) { return session.CreateQuery($"from {entityName} where SenderId = :uid") .SetInt64("uid", userId) .List<BaseMessage>(); }调用端可以按枚举值映射实体名,避免硬编码字符串:
public enum MessageType { Text = 1, Image = 2, Voice = 3 } public static class EntityNameMap { public static string GetEntityName(MessageType type) { switch (type) { case MessageType.Text: return "TextMessage"; case MessageType.Image: return "ImageMessage"; case MessageType.Voice: return "VoiceMessage"; default: throw new ArgumentOutOfRangeException(nameof(type)); } } }这套设计在面对新增消息类型时,只需要增加一个子类和一个映射配置,不需要改动公共操作代码。扩展性比 switch 类型判断扎实很多。
4.4 设计取舍:什么时候不该用 entity-name
聊完了合适的场景,也得说说克制的地方。我见过一些项目把 entity-name 用得很“high”,但维护成本直线上升的案例:
- 一个类型搞了 7、8 个实体名,每个都对应一张表,字段一调整要同步改多个映射文件。
- 用实体名做“软删除”而不是用过滤器和软删除字段,导致所有查询都要手动拼接实体名。
- 跨实体名做级联关联,映射关系没配清楚,运行时冒出“could not resolve property”错误。
如果你的需求仅仅是“多一层过滤条件”或“区分类型字段”,常规的where或者discriminator可能就够了。entity-name本质上是“持久化视角的身份变化”,不是普通查询条件的替代品。如果你对 NHibernate 的映射生命周期理解还不深,我更建议先用常规的继承映射和类型字段把业务跑通,再考虑用实体名优化模型结构。
5. 常见问题与排查技巧实录
5.1 “could not resolve property/association”错误
这个错误在处理关联映射时特别常见。比如你在<many-to-one>里配置class属性指了一个类名,但目标类却注册了实体名,运行时就解析不了。
排查思路其实简单:先去映射文件中确认目标实体是否有显式entity-name,如果有,把关联标签中的class属性改为对应的实体名。假设Pet关联Animal,但Animal的实体名是AnimalEntity,那么:
<many-to-one name="Animal" class="AnimalEntity" column="animal_id" />另一个易错点是在 HQL 里写关联查询时:
from Pet p inner join p.Animal animal如果Animal属性类型是基类,而实际关联子类是Cat,HQL 里并不会自动带出继承多态,你需要显式声明with或指定类型。这个属于 HQL 多态查询的范畴,和 entity-name 本身关系不大,但在实体名场景下更容易被混淆。
5.2 二级缓存中的实体名差异
启用了二级缓存时,缓存区域的默认命名通常基于实体名,而不是 .NET 类型名。如果你把缓存区域名配错了,会导致缓存没有命中,但也不报错,看起来像是“数据偶发过期/加载慢”。
举个我排查过的例子:走了 entity-name 后,某个查询第一次执行 800ms,第二次居然还是 800ms。查监控发现二级缓存完全没有命中,原因是 Xml 中:
<class name="MyApp.Model.Product, MyApp" entity-name="ProductEntity" table="product"> <cache region="productCache" usage="read-write"/> </class>而代码里配置查询缓存区域时却用了typeof(Product).FullName作为 region。修复方式就是让 region 命名基于实体名,或者干脆在启动时用sessionFactory.GetAllClassMetadata()打印所有已注册的实体名和对应类型,一眼就能看出映射是否生效。
foreach (var meta in sessionFactory.GetAllClassMetadata()) { Console.WriteLine($"EntityName: {meta.Key}, Type: {meta.Value.MappedClass}"); }这段代码在开发和排障阶段非常好用,能快速列出当前 SessionFactory 中注册的全部实体名,排查配置错位问题基本靠它。
5.3 与 StatelessSession 的兼容性
StatelessSession是无状态会话,不维护一级缓存,没有级联保存功能。它同样支持实体名操作,但要注意它不会做任何级联,也没有持久化上下文协助管理“同一实体不同实体名”的关系。
例如,你向StatelessSession插入一个关联了Animal的Pet,如果Animal是游离状态且已经存在,StatelessSession不会帮你做Get加载或检查持久化状态,而是直接按你传入的对象主键拼接 SQL。这种情况下极容易出现“插入成功但外键指向不存在记录”的脏数据。
提示:需要批处理大量归档或迁移数据时,
StatelessSession配合实体名确实性能优秀,但务必保证传给它的对象图是“干净”的,或者干脆在循环内用普通ISession并定期Flush、Clear。性能差一点,但数据安全性有保障。
5.4 动态映射中的实体名
如果你是用动态映射(Dynamic Mapping)构建内容模型,entity-name是核心拼图。动态映射通常不预先定义 .NET 类型,而是通过PersistentClass层动态注册。
var model = new HbmMapping(); var classMapping = new HbmClass { name = "MyApp.Content.Article, MyApp", entityname = "Article", table = "article" };操作代码:
var article = session.Get("Article", id);这种用法常见于 CMS 类系统:后台通过界面配置“内容类型”,运行时为每种内容类型注册不同的实体名映射。这是entity-name非常高级的用法,但也是最容易出现元数据混乱的地方,建议在服务启动时做一次实体名注册检查,确保没有重复或遗漏。
5.5 事务边界与实体名切换的坑
最后一个要提醒的坑和事务有关。我在一个批量迁移的功能里踩过:同一个ISession内,先Save("OrderArchive", order)再Delete("Order", order),如果把这两步包在同一个事务里,看起来没问题;但如果事务提交后order对象被误用在另一个实体名的保存操作中,NHibernate 会把它当作“已持久化实体状态”,行为会很奇怪,甚至抛出NonUniqueObjectException。
这个异常的大意是:同一 Session 中,同一个实体实例已经以某个实体名被持久化,再用另一个实体名操作同一个实例就会冲突。解决办法是:在切换实体名操作同一个对象之前,务必session.Evict(order)或使用一个新对象。
注意:如果你需要把对象从实体名 A “转到”实体名 B 的表(比如归档),用新开 Session 或先 Evict 再操作,永远是最稳妥的选择。不要试图用同一个持久化上下文同时持有两个实体名的同一实例。
整理一下我自己的使用经验,entity-name最值得投入的场景其实就三个:继承体系里的多态查询、同一类型多表归档/隔离、动态内容模型的底座。它不是一个“高频特性”,但用对地方后,代码的扩展性和整洁度会明显上一个台阶。如果你刚开始接触,建议先从动物模型那个多态查询的例子入手,跑通之后再考虑双映射和动态场景,千万别一上来就把整个业务模型的重构都押在它上面。