深度解析.NET映射新秀Facet
前言
在 .NET 开发中,对象映射(Mapping)几乎是每个项目的刚需。无论是将数据库实体(Entity)转换为 DTO,还是在微服务间传递数据,我们都在不断编写 a.Name = b.Name 这种重复代码。
长期以来,AutoMapper 是社区的默认选择。但随着 .NET 8/9 拥抱 Native AOT 以及对冷启动性能的追求,基于运行时反射(Reflection)的映射器逐渐显露弊态:性能瓶颈、调试困难、以及 AOT 不兼容。今天,我们要深度拆解一个全新的“源生成”映射神器:Facet。它通过 C# Source Generators 在编译时就把代码写好,带你进入“零反射”映射时代。
核心概念:什么是源生成映射?
传统的映射器像是一个“黑盒”,在程序运行时通过反射去寻找匹配的字段。而 Facet 利用了 Source Generators 技术,在编译器工作时就为你生成了对应的赋值逻辑。
- 零反射开销:映射速度接近手写代码。
- 调试透明:你可以直接 F11 步入生成的代码,清楚看到每一行赋值,不再有“消失的异常”。
- AOT 友好:天然支持 Native AOT,没有任何运行时动态生成的代码。
实战演示:三步玩转 Facet
第一步:定义 DTO 并开启映射
使用 [Facet] 特性标记 DTO。在实际开发中,我们经常需要排除多个敏感字段(如密码、盐值)。Facet 支持通过数组实现多字段排除。
注意:DTO 类必须标记为
partial,否则源生成器无法将生成的代码“缝合”进去。
using Facet;
[Facet(typeof(UserEntity), Exclude = [
nameof(UserEntity.PasswordHash),
nameof(UserEntity.SecurityStamp),
nameof(UserEntity.InternalNotes)
])]
public partial class UserDto
{
// 源生成器会自动根据 UserEntity 补全除上述三个字段外的所有属性
}
第二步:执行映射
Facet 自动生成了构造函数和流式扩展方法,使用起来非常直观。
var user = new UserEntity { Id = 1, Name = "Gemini", Points = 500 };
// 方式 A:直接使用生成的构造函数
var dto = new UserDto(user);
// 方式 B:使用 ToFacet 扩展方法
var dto2 = user.ToFacet<UserEntity, UserDto>();
第三步:EF Core 数据库投影
这是 Facet 的“杀手锏”:它生成的 Projection 表达式能让 EF Core 在 SQL 层面只查询 DTO 需要的列,极大优化查询性能。
var users = await dbContext.Users
.Select(UserDto.Projection) // 自动生成 Select 语句,仅查询非排除字段
.ToListAsync();
进阶技巧:属性微操与复杂逻辑
1. 声明式属性配置
如果你只是想改个名字或忽略某个字段,可以使用内置的特性,这比写配置类更轻量。
[Facet(typeof(UserEntity))]
public partial class UserDto
{
// 别名映射:将数据库的 usr_auth_id 映射到 AuthorizationId
[FacetProperty(MapFrom = "usr_auth_id")]
public string AuthorizationId { get; set; } = default!;
// 显式忽略:即使源对象有匹配字段也不映射
[FacetIgnore]
public string SessionToken { get; set; } = default!;
}
2. 自定义映射逻辑(如加密/解密)
当涉及复杂逻辑(如:数据库存的是 Base64 密文,DTO 需要明文)时,我们需要实现 IFacetMappingConfiguration 接口。
public class UserSecurityConfig : IFacetMappingConfiguration<UserEntity, UserDto>
{
public void Apply(UserEntity source, UserDto target)
{
// 1. 字段合并
target.FullDisplayName = $"{source.FirstName} {source.LastName}";
// 2. 解密逻辑:将 DB 密文转为明文
if (!string.IsNullOrEmpty(source.EncryptedEmail))
{
var bytes = Convert.FromBase64String(source.EncryptedEmail);
target.Email = System.Text.Encoding.UTF8.GetString(bytes);
}
}
}
// 绑定配置
[Facet(typeof(UserEntity), Configuration = typeof(UserSecurityConfig))]
public partial class UserDto { /* ... */ }
深度思考
1. 性能数据的真相
在 .NET 8 环境下映射 100 万个对象的参考数据:
| 映射工具 | 耗时 (1M Ops) | 内存分配 | 调试友好度 |
|---|---|---|---|
| Manual Mapping (手写) | ~2.5 ms | 0 B | 极高 |
| Facet (源生成) | ~70.5 ms | 极低 | 极高 (可步入) |
| AutoMapper (反射) | ~185.0 ms | 较高 | 差 (黑盒) |
2. 特性 (Attribute) vs 配置类 (Config)
- 特性:适合“元数据”描述,如改名、忽略。它简单、直观,是声明式的。
- 配置类:适合“行为”逻辑,如解密、格式化。它易于单元测试,符合关注点分离原则。
3. 微服务中的 DTO 应该放在哪?
在微服务场景下,DTO 往往散落在多个项目中。
- 不推荐集中管理:强行建立一个
Common.DTO会导致服务间的强耦合,形成“分布式单体”。 - 推荐按需定义:在每个微服务内部定义自己的 Facet DTO。虽然会有少量代码重复,但换取了服务独立演变的能力。Facet 名字的本意正是——每个调用方都有看数据的独特视角(切面)。
总结
Facet 代表了 .NET 开发的新范式:能交给编译器的,绝不留给运行时。它通过源生成技术,既保留了手写代码的透明度和高性能,又获得了框架级的开发效率。