Appearance
值转换 Value Conversions
目录
什么是值转换
概念理解
值转换(Value Conversion) 允许将实体属性的 CLR 类型转换为不同的数据库类型存储,实现领域模型与数据库结构的解耦。
csharp
// 领域模型: 使用强类型
public class Order
{
public OrderId Id { get; set; } // 值对象
public Money TotalAmount { get; set; } // 金额对象
public PhoneNumber Phone { get; set; } // 电话号码
public Address ShippingAddress { get; set; } // 地址对象
}
// 数据库存储: 使用基本类型
// Orders 表:
// - Id: INT
// - TotalAmount: DECIMAL(18,2)
// - Phone: NVARCHAR(20)
// - ShippingAddress_Street: NVARCHAR(200)
// - ShippingAddress_City: NVARCHAR(100)
// 值转换器: 自动双向转换
// OrderId (CLR) ↔ INT (Database)
// Money (CLR) ↔ DECIMAL (Database)
// PhoneNumber (CLR) ↔ STRING (Database)工作原理
mermaid
graph LR
A[实体属性] --> B[值转换器]
B --> C[数据库列]
C --> B
B --> A
style A fill:#e1f5ff
style C fill:#fff4e1
style B fill:#d4edda转换流程:
- 保存时: CLR 类型 → 转换 → 数据库类型 → 写入
- 读取时: 数据库类型 → 转换 → CLR 类型 → 赋值
为什么需要值转换?
问题: 领域模型与数据库不匹配
csharp
// ❌ 没有值转换: 领域模型被数据库污染
public class Order
{
// 应该用值对象,但为了数据库妥协为 int
public int OrderId { get; set; }
// 应该用 decimal,但要手动处理货币
public decimal Amount { get; set; }
public string Currency { get; set; }
// 应该用 PhoneNumber 类型,但只能用 string
public string Phone { get; set; }
// 地址应该是值对象,但只能拆分为多个字段
public string Street { get; set; }
public string City { get; set; }
public string ZipCode { get; set; }
}
// 问题:
// - 类型安全性差
// - 缺少验证逻辑
// - 代码重复
// - 不符合 DDD 原则解决: 使用值转换
csharp
// ✅ 使用值转换: 领域模型纯净
public class Order
{
public OrderId Id { get; set; } // 强类型 ID
public Money TotalAmount { get; set; } // 包含货币
public PhoneNumber Phone { get; set; } // 带验证
public Address ShippingAddress { get; set; } // 值对象
}
// 配置转换
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasConversion(
v => v.Value, // OrderId → int
v => new OrderId(v) // int → OrderId
);
modelBuilder.Entity<Order>()
.Property(o => o.TotalAmount)
.HasConversion(
v => v.Amount, // Money → decimal
v => new Money(v, "USD") // decimal → Money
);
modelBuilder.Entity<Order>()
.Property(o => o.Phone)
.HasConversion(
v => v.Number, // PhoneNumber → string
v => new PhoneNumber(v) // string → PhoneNumber
);
}内置值转换器
EF Core 提供的转换器
EF Core 提供了多种内置转换器:
1. Enum ↔ String
csharp
public enum OrderStatus
{
Pending = 0,
Processing = 1,
Shipped = 2,
Delivered = 3,
Cancelled = 4
}
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
}
// 默认: Enum → Int
// Status 列: 0, 1, 2, 3, 4
// 转换为字符串
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>(); // ← 简单!
}
// Status 列: "Pending", "Processing", "Shipped"
// 优点:
// - 可读性好
// - 重构安全(重命名 Enum 成员不会破坏数据)
// - 调试方便2. DateTime ↔ Utc
csharp
public class Event
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
// 确保始终存储 UTC 时间
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Event>()
.Property(e => e.CreatedAt)
.HasConversion(
v => v.ToUniversalTime(), // DateTime → UTC
v => DateTime.SpecifyKind(v, DateTimeKind.Utc) // UTC → DateTime
);
modelBuilder.Entity<Event>()
.Property(e => e.UpdatedAt)
.HasConversion(
v => v.HasValue ? v.Value.ToUniversalTime() : (DateTime?)null,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : (DateTime?)null
);
}
// 或者使用 .NET 8+ 简化语法
modelBuilder.Entity<Event>()
.Property(e => e.CreatedAt)
.HasConversion<UtcValueConverter>();3. Bool ↔ String
csharp
public class User
{
public bool IsActive { get; set; }
}
// 存储为 "Y"/"N"(兼容旧系统)
modelBuilder.Entity<User>()
.Property(u => u.IsActive)
.HasConversion(
v => v ? "Y" : "N",
v => v == "Y"
);4. String ↔ Trimmed
csharp
public class Product
{
public string Name { get; set; }
public string Description { get; set; }
}
// 自动去除首尾空格
modelBuilder.Entity<Product>()
.Property(p => p.Name)
.HasConversion(
v => v?.Trim(),
v => v?.Trim()
);5. Number Formatting
csharp
public class Product
{
public decimal Price { get; set; }
}
// 格式化为固定小数位
modelBuilder.Entity<Product>()
.Property(p => p.Price)
.HasConversion(
v => Math.Round(v, 2),
v => Math.Round(v, 2)
);完整内置转换器列表
| 转换器 | 用途 | 示例 |
|---|---|---|
ToStringConversion() | Any → String | Enum → String |
ToNumberConversion() | String → Number | "123" → 123 |
ToBoolConversion() | Any → Bool | "Y" → true |
ToDateTimeConversion() | Any → DateTime | Timestamp → DateTime |
UtcValueConverter | DateTime → UTC | Local → Utc |
TrimmingStringConverter | String → Trimmed | " abc " → "abc" |
自定义值转换器
基础自定义转换器
示例 1: 强类型 ID
csharp
// 值对象: 订单 ID
public readonly struct OrderId : IEquatable<OrderId>
{
public int Value { get; }
public OrderId(int value)
{
if (value <= 0)
throw new ArgumentException("Order ID must be positive");
Value = value;
}
public bool Equals(OrderId other) => Value == other.Value;
public override bool Equals(object obj) => obj is OrderId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => $"ORD-{Value}";
public static implicit operator int(OrderId id) => id.Value;
public static implicit operator OrderId(int value) => new OrderId(value);
}
// 实体
public class Order
{
public OrderId Id { get; set; }
public DateTime OrderDate { get; set; }
}
// 转换器
public class OrderIdConverter : ValueConverter<OrderId, int>
{
public OrderIdConverter() : base(
v => v.Value, // OrderId → int
v => new OrderId(v), // int → OrderId
new ConverterMappingHints(valueComparer: new OrderIdComparer())
)
{
}
}
// 比较器
public class OrderIdComparer : ValueComparer<OrderId>
{
public OrderIdComparer() : base(
(v1, v2) => v1.Equals(v2),
v => v.GetHashCode(),
v => v
)
{
}
}
// 配置
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasConversion(new OrderIdConverter());
// .NET 8+ 简化语法
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasConversion<int>(
v => v.Value,
v => new OrderId(v)
);
}示例 2: 金额对象
csharp
// 值对象: 金额
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "CNY")
{
Amount = Math.Round(amount, 2);
Currency = currency;
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
public bool Equals(Money other)
=> Amount == other.Amount && Currency == other.Currency;
public override string ToString() => $"{Currency} {Amount:F2}";
}
// 实体
public class Order
{
public int Id { get; set; }
public Money TotalAmount { get; set; }
}
// 转换器(存储为 JSON)
public class MoneyJsonConverter : ValueConverter<Money, string>
{
public MoneyJsonConverter() : base(
v => JsonSerializer.Serialize(new { v.Amount, v.Currency }, null),
v =>
{
var json = JsonSerializer.Deserialize<Dictionary<string, object>>(v, null);
return new Money(
Convert.ToDecimal(json["Amount"]),
Convert.ToString(json["Currency"])
);
}
)
{
}
}
// 配置
modelBuilder.Entity<Order>()
.Property(o => o.TotalAmount)
.HasConversion(new MoneyJsonConverter())
.HasColumnType("nvarchar(100)");
// 数据库存储: {"Amount":199.99,"Currency":"CNY"}示例 3: 电话号码
csharp
// 值对象: 电话号码
public class PhoneNumber : IEquatable<PhoneNumber>
{
private static readonly Regex PhoneRegex = new(@"^\+?[\d\s\-()]{7,20}$");
public string Number { get; }
public PhoneNumber(string number)
{
if (string.IsNullOrWhiteSpace(number))
throw new ArgumentException("Phone number cannot be empty");
// 标准化格式
Number = new string(number.Where(c => char.IsDigit(c) || c == '+').ToArray());
if (!PhoneRegex.IsMatch(Number))
throw new ArgumentException($"Invalid phone number: {number}");
}
public string Format()
{
// +8613800138000 → +86 138-0013-8000
if (Number.StartsWith("+86") && Number.Length == 14)
{
return $"+86 {Number.Substring(3, 3)}-{Number.Substring(6, 4)}-{Number.Substring(10)}";
}
return Number;
}
public bool Equals(PhoneNumber other) => Number == other?.Number;
public override string ToString() => Format();
}
// 转换器
modelBuilder.Entity<Customer>()
.Property(c => c.Phone)
.HasConversion(
v => v.Number,
v => new PhoneNumber(v)
);复杂对象转换
示例 4: 地址值对象
csharp
// 值对象: 地址
public class Address
{
public string Street { get; }
public string City { get; }
public string Province { get; }
public string ZipCode { get; }
public string Country { get; }
public Address(string street, string city, string province, string zipCode, string country = "CN")
{
Street = street;
City = city;
Province = province;
ZipCode = zipCode;
Country = country;
}
public string FullAddress => $"{Province}{City}{Street} ({ZipCode})";
}
// 方式 1: JSON 序列化(推荐)
modelBuilder.Entity<Order>()
.Property(o => o.ShippingAddress)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<Address>(v, null)
)
.HasColumnType("nvarchar(500)");
// 数据库存储: {"Street":"长安街1号","City":"北京","Province":"北京","ZipCode":"100000","Country":"CN"}
// 方式 2: 拆分为多列(传统方式)
modelBuilder.Entity<Order>()
.OwnsOne(o => o.ShippingAddress, a =>
{
a.Property(addr => addr.Street).HasColumnName("ShippingStreet");
a.Property(addr => addr.City).HasColumnName("ShippingCity");
a.Property(addr => addr.Province).HasColumnName("ShippingProvince");
a.Property(addr => addr.ZipCode).HasColumnName("ShippingZipCode");
a.Property(addr => addr.Country).HasColumnName("ShippingCountry");
});示例 5: 集合转换
csharp
// 标签列表
public class Product
{
public int Id { get; set; }
public List<string> Tags { get; set; }
}
// 存储为逗号分隔的字符串
modelBuilder.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(
v => string.Join(",", v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
)
.HasColumnType("nvarchar(500)");
// 数据库存储: "Electronics,Gadgets,New"
// 或者存储为 JSON(支持复杂对象)
modelBuilder.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<List<string>>(v, null)
)
.HasColumnType("nvarchar(max)");
// 数据库存储: ["Electronics","Gadgets","New"]高级应用场景
场景 1: 加密存储
csharp
// 敏感数据加密
public class User
{
public int Id { get; set; }
public string Email { get; set; }
public EncryptedString IdNumber { get; set; } // 身份证号
}
public class EncryptedString
{
private readonly string _encryptedValue;
public EncryptedString(string plainText)
{
_encryptedValue = Encrypt(plainText);
}
public string Decrypt()
{
return Decrypt(_encryptedValue);
}
private static string Encrypt(string plainText)
{
// AES 加密逻辑
using var aes = Aes.Create();
// ... 加密实现
return Convert.ToBase64String(encryptedBytes);
}
private static string Decrypt(string encryptedText)
{
// AES 解密逻辑
return Encoding.UTF8.GetString(decryptedBytes);
}
}
// 转换器
modelBuilder.Entity<User>()
.Property(u => u.IdNumber)
.HasConversion(
v => v.ToString(), // 调用 ToString() 返回加密字符串
v => new EncryptedString(v)
);场景 2: 审计信息
csharp
// 审计元数据
public class AuditableEntity
{
public int Id { get; set; }
public AuditInfo AuditInfo { get; set; }
}
public class AuditInfo
{
public DateTime CreatedAt { get; }
public string CreatedBy { get; }
public DateTime? UpdatedAt { get; }
public string UpdatedBy { get; }
public AuditInfo(DateTime createdAt, string createdBy)
{
CreatedAt = createdAt;
CreatedBy = createdBy;
}
}
// JSON 存储
modelBuilder.Entity<AuditableEntity>()
.Property(e => e.AuditInfo)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<AuditInfo>(v, null)
);场景 3: 多语言支持
csharp
// 多语言文本
public class Product
{
public int Id { get; set; }
public LocalizedString Name { get; set; }
}
public class LocalizedString
{
public Dictionary<string, string> Translations { get; }
public LocalizedString()
{
Translations = new Dictionary<string, string>();
}
public string this[string language]
{
get => Translations.TryGetValue(language, out var value) ? value : null;
set => Translations[language] = value;
}
}
// JSON 存储
modelBuilder.Entity<Product>()
.Property(p => p.Name)
.HasConversion(
v => JsonSerializer.Serialize(v.Translations, null),
v =>
{
var translations = JsonSerializer.Deserialize<Dictionary<string, string>>(v, null);
var localized = new LocalizedString();
foreach (var kvp in translations)
localized[kvp.Key] = kvp.Value;
return localized;
}
);
// 数据库存储: {"en":"Laptop","zh":"笔记本电脑","ja":"ノートパソコン"}.NET 8/9/10 新特性
.NET 8: 简化的转换器语法
csharp
// .NET 8 之前
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion(
new EnumToStringConverter<OrderStatus>(),
new ValueComparer<OrderStatus>(...)
);
// .NET 8 简化
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>(); // ← 自动推断!
// 自定义转换也更简洁
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasConversion<int>(
v => v.Value,
v => new OrderId(v)
);.NET 9: 泛型改进
csharp
// .NET 9 支持更好的泛型推断
modelBuilder.Entity<Order>()
.Property(o => o.TotalAmount)
.HasConversion<Money, string>(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<Money>(v, null)
);.NET 10: 智能转换(路线图)
预计特性:
- 基于 AI 的自动转换建议
- 更强大的复合类型支持
- 运行时动态转换
- 转换性能优化
最佳实践与陷阱
最佳实践
1. 优先使用值对象
csharp
// ✅ 推荐: 使用值对象 + 转换
public class Order
{
public OrderId Id { get; set; }
public Money Amount { get; set; }
public PhoneNumber Phone { get; set; }
}
// ❌ 避免: 使用原始类型
public class Order
{
public int Id { get; set; }
public decimal Amount { get; set; }
public string Phone { get; set; }
}2. 实现 ValueComparer
csharp
// ✅ 正确: 提供比较器以支持变更跟踪
modelBuilder.Entity<Order>()
.Property(o => o.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<List<string>>(v, null),
new ValueComparer<List<string>>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()
)
);
// ❌ 错误: 没有比较器,变更跟踪失效3. 选择合适的存储格式
csharp
// 简单枚举 → String
.HasConversion<string>()
// 复杂对象 → JSON
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<T>(v, null)
)
// 结构化数据 → 多列
.OwnsOne(x => x.Address)常见陷阱
陷阱 1: 忘记比较器
csharp
// ❌ 错误: 集合属性没有比较器
modelBuilder.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(...); // 变更跟踪不工作!
// ✅ 正确: 添加比较器
modelBuilder.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(..., new ValueComparer<List<string>>(...));陷阱 2: 过度使用 JSON
csharp
// ❌ 错误: 频繁查询的字段存为 JSON
modelBuilder.Entity<Order>()
.Property(o => o.CustomerName) // 经常 WHERE CustomerName LIKE ...
.HasConversion(...); // JSON 中无法高效查询
// ✅ 正确: 查询字段单独列,其他存 JSON
modelBuilder.Entity<Order>()
.Property(o => o.CustomerName); // 单独列
modelBuilder.Entity<Order>()
.Property(o => o.Metadata); // 不常查询的元数据存 JSON陷阱 3: 性能问题
csharp
// ⚠️ 注意: 复杂的 JSON 序列化可能成为瓶颈
// 对于高频访问数据,考虑拆分为多列
// ❌ 慢: 每次查询都序列化/反序列化
.HasConversion(
v => ComplexSerialization(v),
v => ComplexDeserialization(v)
)
// ✅ 快: 简单的列映射
.OwnsOne(x => x.Address, a =>
{
a.Property(addr => addr.Street);
a.Property(addr => addr.City);
})总结
核心要点
- 值转换作用: 解耦领域模型与数据库结构
- 内置转换器: Enum、DateTime、Bool 等常用类型
- 自定义转换: 支持任意复杂类型
- JSON 存储: 适合复杂但不常查询的对象
- 比较器: 必须实现以支持变更跟踪
转换策略选择
| 类型 | 推荐策略 | 示例 |
|---|---|---|
| Enum | String | HasConversion<string>() |
| 值对象(简单) | 自定义转换 | OrderId ↔ int |
| 值对象(复杂) | JSON 序列化 | Address → JSON |
| 集合 | JSON 数组 | List<string> → JSON |
| 敏感数据 | 加密转换 | EncryptedString |
| 结构化数据 | 拆分为多列 | OwnsOne |
性能对比
访问模式 | 推荐方式 | 原因
-----------------|--------------|------------------
频繁查询 | 单独列 | 可直接 WHERE
偶尔查询 | JSON | 节省空间
复杂对象 | JSON | 简化开发
简单值对象 | 自定义转换 | 性能最优