NHibernate 如何将同一实体映射到不同表
结论
不建议将同一套 MoneyTransferMean 继承层次分别映射为 payment 和 refund 两套实体。
entity-name 可以为映射指定不同的逻辑名称,但在当前继承映射中,SubclassMap<CashMoneyTransferMean> 对应的仍是同一个 CLR 类型。重复声明后,NHibernate 无法判断具体子类应归入哪一个根映射,因此会报错:
Duplicate class/entity mapping MoneyTransferMean+CashMoneyTransferMean
问题在于,Cash、EFT 等表示有限且固定的方式类型,payment 和 refund 则表示终端启用了哪些类型。这两者不是同一种实体。
改动较小且更稳妥的做法是:
- 保留
MoneyTransferMean及其行为,但不直接持久化它。 - 数据库只保存稳定的类型代码,例如
Cash、EFT。 - 为 payment 和 refund 新增配置实体,分别映射到不同的表。
- 读取配置后,根据类型代码取得对应的
MoneyTransferMean实例。
这样既不需要在 MoneyTransferMean 中增加公开的 IsRefund 属性,也不需要重复映射同一套继承层次。
当前模型为什么难以直接映射
当前代码混合了三个概念:
MoneyTransferMean的业务行为。Cash、EFT等固定值。- 某个 POS terminal 启用了哪些支付或退款方式。
真正需要持久化的是第三项,也就是终端的配置状态。
例如,下面两个集合都可以包含 Cash:
Payment means = { Cash, EFT, Voucher }
Refund means = { Cash, Voucher }
这并不意味着存在两个不同的 Cash 实体。实际存在的是两条配置记录:
Payment -> Cash
Refund -> Cash
如果将 CashMoneyTransferMean 本身映射为实体,NHibernate 就需要为它分配实体身份、跟踪生命周期,还要判断它属于 payment hierarchy 还是 refund hierarchy。这与静态单例式的领域模型并不相符。
直接把下面这样的静态对象交给 NHibernate 管理,也容易引发问题:
public static readonly MoneyTransferMean Cash =
new CashMoneyTransferMean();
NHibernate 实体通常属于某个 ISession 的持久化上下文,而静态实例由整个进程共享。同一个静态实例一旦交给不同的 Session 管理,其实体标识、状态跟踪和缓存语义都会变得含糊。
推荐方案:持久化配置项,而不是行为对象
先为每种资金转移方式定义稳定的持久化代码,可以使用 enum,也可以使用字符串代码。
public enum MoneyTransferMeanCode
{
Cash,
EFT,
LoyaltyCard,
Voucher
}
MoneyTransferMean 继续承载业务行为,并提供根据代码取得实例的方法:
public abstract class MoneyTransferMean
{
public static readonly MoneyTransferMean Cash =
new CashMoneyTransferMean();
public static readonly MoneyTransferMean EFT =
new EFTMoneyTransferMean();
public static readonly MoneyTransferMean LoyaltyCard =
new LoyaltyCardMoneyTransferMean();
public static readonly MoneyTransferMean Voucher =
new VoucherMoneyTransferMean();
public abstract MoneyTransferMeanCode Code { get; }
public static MoneyTransferMean FromCode(MoneyTransferMeanCode code)
{
switch (code)
{
case MoneyTransferMeanCode.Cash:
return Cash;
case MoneyTransferMeanCode.EFT:
return EFT;
case MoneyTransferMeanCode.LoyaltyCard:
return LoyaltyCard;
case MoneyTransferMeanCode.Voucher:
return Voucher;
default:
throw new ArgumentOutOfRangeException(nameof(code), code, null);
}
}
public sealed class CashMoneyTransferMean : MoneyTransferMean
{
public override MoneyTransferMeanCode Code =>
MoneyTransferMeanCode.Cash;
}
public sealed class EFTMoneyTransferMean : MoneyTransferMean
{
public override MoneyTransferMeanCode Code =>
MoneyTransferMeanCode.EFT;
}
public sealed class LoyaltyCardMoneyTransferMean : MoneyTransferMean
{
public override MoneyTransferMeanCode Code =>
MoneyTransferMeanCode.LoyaltyCard;
}
public sealed class VoucherMoneyTransferMean : MoneyTransferMean
{
public override MoneyTransferMeanCode Code =>
MoneyTransferMeanCode.Voucher;
}
}
这里的 enum 只用于持久化标识,业务行为仍保留在类中,因此不会变成只有 enum、没有行为的贫血设计。
分别建立 payment 和 refund 配置实体
如果确实需要两个独立的表,可以增加两个很小的持久化实体。
public class PaymentMeanConfiguration
{
public virtual long Id { get; protected set; }
private MoneyTransferMeanCode _code;
protected PaymentMeanConfiguration()
{
}
public PaymentMeanConfiguration(MoneyTransferMean mean)
{
if (mean == null)
throw new ArgumentNullException(nameof(mean));
_code = mean.Code;
}
public virtual MoneyTransferMean Mean =>
MoneyTransferMean.FromCode(_code);
}
public class RefundMeanConfiguration
{
public virtual long Id { get; protected set; }
private MoneyTransferMeanCode _code;
protected RefundMeanConfiguration()
{
}
public RefundMeanConfiguration(MoneyTransferMean mean)
{
if (mean == null)
throw new ArgumentNullException(nameof(mean));
_code = mean.Code;
}
public virtual MoneyTransferMean Mean =>
MoneyTransferMean.FromCode(_code);
}
对应的表结构可以很简单:
CREATE TABLE PaymentMeans
(
Id BIGINT NOT NULL PRIMARY KEY,
Type VARCHAR(30) NOT NULL,
CONSTRAINT UQ_PaymentMeans_Type UNIQUE (Type)
);
CREATE TABLE RefundMeans
(
Id BIGINT NOT NULL PRIMARY KEY,
Type VARCHAR(30) NOT NULL,
CONSTRAINT UQ_RefundMeans_Type UNIQUE (Type)
);
如果配置属于特定的 POS terminal,表中还应包含 TerminalId,唯一约束也应改为:
UNIQUE (TerminalId, Type)
否则,各终端无法分别保存自己的配置。
FluentNHibernate 映射示例
FluentNHibernate 可以通过成员访问机制映射私有字段,无需为持久化增加公开 setter。
public sealed class PaymentMeanConfigurationMap
: ClassMap<PaymentMeanConfiguration>
{
public PaymentMeanConfigurationMap()
{
Table("PaymentMeans");
Id(x => x.Id)
.GeneratedBy.Identity();
Map(Reveal.Member<PaymentMeanConfiguration>("_code"))
.Column("Type")
.CustomType<MoneyTransferMeanCode>()
.Not.Nullable()
.Unique();
}
}
public sealed class RefundMeanConfigurationMap
: ClassMap<RefundMeanConfiguration>
{
public RefundMeanConfigurationMap()
{
Table("RefundMeans");
Id(x => x.Id)
.GeneratedBy.Identity();
Map(Reveal.Member<RefundMeanConfiguration>("_code"))
.Column("Type")
.CustomType<MoneyTransferMeanCode>()
.Not.Nullable()
.Unique();
}
}
私有字段的具体映射方式可能因 FluentNHibernate 版本和访问策略而异。如果当前版本不接受 Reveal.Member<T>(),也可以改为映射受保护属性:
protected virtual MoneyTransferMeanCode Code
{
get => _code;
set => _code = value;
}
映射代码相应改为:
Map(Reveal.Member<PaymentMeanConfiguration>("Code"))
.Column("Type")
.CustomType<MoneyTransferMeanCode>();
这样依然不会污染实体的 public API。
保存和读取集合
替换支付方式集合时,应在同一个事务中完成删除和插入:
using (var transaction = session.BeginTransaction())
{
session.CreateQuery(
"delete from PaymentMeanConfiguration")
.ExecuteUpdate();
foreach (var mean in newPaymentMeans)
{
session.Save(new PaymentMeanConfiguration(mean));
}
transaction.Commit();
}
读取时再将配置转换为领域对象:
var paymentMeans = session
.Query<PaymentMeanConfiguration>()
.ToList()
.Select(x => x.Mean)
.ToList();
退款方式的处理相同,只需使用 RefundMeanConfiguration。
实际系统中,如果配置属于某个 terminal,删除和查询操作必须带上终端条件,不能直接处理整张表:
session.CreateQuery(@"
delete from PaymentMeanConfiguration
where Terminal.Id = :terminalId")
.SetParameter("terminalId", terminalId)
.ExecuteUpdate();
也可以使用一张配置表
如果没有必须拆成两个物理表的要求,也可以只建立一个配置实体,用私有的用途字段区分 payment 和 refund:
internal enum MoneyTransferUsage
{
Payment,
Refund
}
表结构可以写成:
CREATE TABLE EnabledMoneyTransferMeans
(
Id BIGINT NOT NULL PRIMARY KEY,
TerminalId BIGINT NOT NULL,
Usage VARCHAR(20) NOT NULL,
Type VARCHAR(30) NOT NULL,
CONSTRAINT UQ_EnabledMeans
UNIQUE (TerminalId, Usage, Type)
);
Usage 可以一直作为实体的私有字段,不必在 MoneyTransferMean 上增加 IsRefund。这种设计使用的表更少,查询时按 Usage 过滤。使用两个独立表,则能在数据库结构上提供更明确的隔离。
注意事项
不要将 enum 的整数序号直接作为长期持久化值。以后调整 enum 成员的顺序,可能会改变已有数据的含义。更安全的做法是保存字符串:
Cash
EFT
LoyaltyCard
Voucher
也可以为每个值定义不可变的显式编号:
public enum MoneyTransferMeanCode
{
Cash = 1,
EFT = 2,
LoyaltyCard = 3,
Voucher = 4
}
数据库还应为同一 terminal、同一用途下的 Type 设置唯一约束,防止重复插入同一种方式。
这个问题不适合通过复制 NHibernate inheritance mapping 来解决。让 MoneyTransferMean 保持为带有行为的固定值对象,再单独持久化”某终端启用了哪种方式”这一配置关系,既可以保留现有的领域 API,也能得到清晰、稳定且便于查询的数据库结构。
备注:内容仅供参考。
