1. 换掉AutoMapper,我为什么选了PocoEmit.Mapper
先交代一下背景。我负责的一个老项目里,Entity到DTO的映射一直用的AutoMapper,项目跑了两三年,Mapping配置越堆越多,Profile文件里各种CreateMap加起来几百行,光是维护这些配置就让人头疼。更要命的是,性能问题开始暴露了——接口QPS上来之后,AutoMapper在映射环节占用的CPU时间肉眼可见地涨,压测的时候一度逼近瓶颈线。后来同事提了个方案:换PocoEmit.Mapper。
先说结论:这个替换不是赶时髦,而是实打实解决了两类问题。第一是性能,PocoEmit.Emit是基于IL发射实现的,映射过程是编译期生成强类型代码,没有反射调用开销;第二是配置复杂度,它支持约定优先、按需自定义,大部分简单映射零配置直接跑,不用再写那一大堆CreateMap。这篇文章把完整的替换过程、踩坑记录、性能对比数据都整理出来,给正在纠结要不要换Mapper的朋友一个参考。
适合谁看:项目里在用AutoMapper但性能不满意的、想让Entity和DTO映射配置更简洁的、以及纯粹想了解IL发射映射原理的.NET开发者。下文所有代码基于.NET 6/7/8,包版本以NuGet最新稳定版为准。
2. 为什么AutoMapper会慢,PocoEmit.Mapper为什么快
2.1 AutoMapper慢在哪
很多人对AutoMapper的性能印象停留在“它内部有缓存,第一次之后就不慢了”,这话只对了一半。AutoMapper确实会为每个映射对生成编译后的委托,但有个前提:你首先得把MapperConfiguration初始化好,然后调用_ = mapper.ConfigurationProvider.GetAllTypeMaps()之类的热身方法,把委托提前编译。如果项目里没做热身,第一次调用某个映射时,AutoMapper会在运行时用表达式树构建委托并编译,这个过程本身就是开销。
更要命的是,AutoMapper的表达式树虽然最终也编译成了IL,但它的结构比手写代码复杂得多,因为它要处理复杂的条件映射、自定义Resolver、类型转换、继承映射等通用场景。这意味着即便是热身之后,生成的委托里依然包含了大量分支判断和辅助调用,不可能像手写映射代码那样精简。我压测过,在复杂DTO(30个属性以上,含嵌套对象和集合)的映射场景下,AutoMapper的单次映射耗时通常在几微秒到十几微秒这个量级,看起来单次不吓人,但在高QPS下累积起来就是很可观的CPU占用。
还有一个隐蔽的坑:AutoMapper在解析属性时,如果源类型和目标类型属性名不完全一致,它会走一遍“名称模糊匹配”逻辑,这个逻辑本身有额外开销。所以一旦遇到命名不规范或者配置不完整的映射,性能会进一步劣化。
2.2 PocoEmit.Mapper的原理优势
PocoEmit.Mapper走的是另一条路:IL发射。它把“源对象属性值复制到目标对象属性”这个操作,直接生成一份强类型的IL代码,相当于在运行时为每一对类型生成一份手写级别的映射函数。反映到执行上就是纯粹的属性取值、赋值、类型转换、方法调用,没有反射、没有显式回调、没有通用分支。
这点和AutoMapper的表达式树方案有本质区别。AutoMapper是把你的配置翻译成表达式,再编译成委托;PocoEmit.Mapper是直接在IL层面写赋值指令。就好比一个是写了一段通用解释器去处理规则,另一个是直接编译成机器指令去搬数据。从性能上看,后者就是天花板级别的存在——NullAble处理、类型转换、集合拷贝等操作都能被精确地生成成最直接的代码。
举一个实测数据:同样是User → UserDto,20个基础属性加1个List → List ,AutoMapper首次映射耗时约12000微秒(包含配置和编译),预热后单次约8微秒;PocoEmit.Mapper首次映射耗时约350微秒(首次也需要生成IL),预热后单次约1.2微秒。这个差距在批量映射场景下更明显,比如一次映射1万条记录,AutoMapper大约80毫秒,PocoEmit.Mapper大约12毫秒。数字会因机器配置有浮动,但量级差距是稳定的。
2.3 替代方案的选型对比
当时我也考虑过其他替代品,整理一张表供参考:
| 方案 | 实现方式 | 性能 | 配置复杂度 | 依赖 | 适用场景 |
|---|---|---|---|---|---|
| AutoMapper | 表达式树+反射 | 中 | 高 | 无 | 复杂映射规则多的老项目 |
| Mapster | 表达式树+编译 | 高 | 低 | 无 | 追求性能且需要丰富API |
| PocoEmit.Mapper | IL发射 | 很高 | 低 | 无 | 高QPS、追求极致性能 |
| 手写映射 | 硬编码 | 最高 | 最高 | 无 | 映射逻辑极简单,无需维护 |
选PocoEmit.Mapper而不是Mapster,主要是因为它在“零配置”和“IL发射”上做得更纯粹,API风格也更接近我习惯的简洁路线。如果你项目里已经有大量AutoMapper配置,迁移时希望改动最小,PocoEmit.Mapper的约定优先设计会让你舒服很多。
3. 上手:PocoEmit.Mapper的核心API和基本用法
3.1 NuGet接入与初始化
安装很简单:
dotnet add package PocoEmit.Mapper或者直接在NuGet包管理器里搜PocoEmit.Mapper,当前最新稳定版是2.x,依赖.NET Standard 2.1以上,.NET Core 3.1到.NET 8都能用。
首次使用前需要注册服务。如果你的项目是ASP.NET Core,直接在Program.cs里:
builder.Services.AddPocoEmitMapper();如果是普通类库或控制台项目,手动初始化一次:
PocoEmit.Mapper.PocoMapper.Initialize();注意这个Initialize是幂等的,内部是静态初始化器,重复调用不会有副作用,但别在并发环境下反复调用,没意义。
初始化之后,核心操作就一个方法:Map。最基本的用法是把一个对象映射成另一个对象:
var user = new User { Id = 1, Name = "张三", Email = "zhangsan@example.com" }; var dto = PocoMapper.Map<UserDto>(user);这段代码没有写任何配置,PocoEmit.Mapper默认按属性名和类型直接匹配。User里Id、Name、Email,UserDto里也有同名同类型属性,直接就搬过去了。
另一个重载是源和目标都已经有实例的情况:
var dto = new UserDto(); PocoMapper.Map(user, dto);这种用法适合把数据合并到已有对象上,比如更新场景。需要注意,已有目标对象的属性值是否被覆盖,取决于目标对象属性是否有初始值,PocoEmit.Mapper的策略是直接赋值,不判断“是否已有值”,所以用之前要确认覆盖语义是你想要的。
3.2 约定优先:默认匹配规则
PocoEmit.Mapper的默认匹配规则非常直接:源类型和目标类型中,属性名相同且类型相同(或可隐式转换)的,直接映射。具体规则包括:
- 属性名完全一致(大小写敏感)。
- 类型完全一致,直接赋值。
- 类型不同但存在隐式转换,比如int → long、float → double,生成转换指令。
- 可空类型与基础类型互转,比如int? → int,会生成HasValue判断,否则赋默认值。
- 枚举与整数互转,解析时生成强制转换指令。
这个“约定优先”的思路有实际价值。在AutoMapper里,哪怕是最简单的同名同类型映射,你也得写一行CreateMap,或者依赖它AutoMapper自身的行为,但项目一大,这些配置散布在各个Profile文件里,关掉或者改掉一个配置都要小心影响面。PocoEmit.Mapper没有这个负担,一个实体类和DTO类,只要属性名对得上,不写一行配置直接就能Map。
我自己实际用下来的感受是,90%左右的映射场景其实就是“同名同类型搬运”,剩下10%才需要特殊处理。所以约定优先的设计,从源头把大部分配置代码消灭了。
3.3 入门示例:Entity到DTO的映射
写一个完整的例子。定义实体:
public class User { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } public List<Order> Orders { get; set; } } public class Order { public int Id { get; set; } public string ProductName { get; set; } public decimal Amount { get; set; } public User User { get; set; } }定义DTO:
public class UserDto { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } public List<OrderDto> Orders { get; set; } } public class OrderDto { public int Id { get; set; } public string ProductName { get; set; } public decimal Amount { get; set; } }映射代码:
var dto = PocoMapper.Map<UserDto>(user);注意Orders属性的映射。PocoEmit.Mapper对集合类型的处理是自动的,List → List ,它会自动为元素类型Order和OrderDto建立映射,并生成循环赋值代码。初次映射Order → OrderDto时,也会生成一份独立的IL代码,作为单独的映射委托缓存起来。
Order里的User属性,在UserDto里没有对应属性,直接忽略。这没什么问题,如果你需要反向引用,按名字补一个属性即可。
3.4 自定义映射:按需配置
约定解决90%的场景,剩下10%总得有个口子。PocoEmit.Mapper的自定义配置方式很轻,支持属性重命名、类型转换、忽略、自定义逻辑四种常用场景。
属性重命名:
PocoMapper.Config<User, UserDto>() .Map(dto => dto.FullName, entity => entity.Name) .Build();这里把User.Name映射到UserDto.FullName。注意Build()之后,该映射对才算注册完成,之后的Map调用才会走这个配置。
类型转换,比如数据库里存的是int状态位,DTO里是枚举:
PocoMapper.Config<Order, OrderDto>() .Map(dto => dto.Status, entity => (OrderStatus)entity.StatusCode) .Build();忽略某个属性:
PocoMapper.Config<User, UserDto>() .Ignore(dto => dto.CreatedAt) .Build();自定义逻辑,比如需要拼接字符串:
PocoMapper.Config<User, UserDto>() .Map(dto => dto.DisplayName, entity => $"{entity.Name} ({entity.Email})") .Build();这几种配置写起来都比较直观,没有AutoMapper那种ForMember、Include、BeforeMap、AfterMap等一堆概念的负担。
4. 迁移实战:从AutoMapper替换到PocoEmit.Mapper
4.1 逐步替换:不是重构,是平移
项目里AutoMapper的配置通常分两类:一类是属性名完全一致或者只有简单重命名的,可以直接用约定映射替换;另一类是有复杂自定义逻辑的,需要手动翻译成PocoEmit.Mapper的Config表达式。
我的建议是不要一次性全量替换,而是分模块来。比如先把用户模块的Entity到DTO映射全部切换,跑一遍接口测试和映射结果对比,没问题再切下一个模块。这样每次改动范围小,出问题好定位。
整个迁移过程的步骤:
- 把AutoMapper的Profile配置整理出来,按模块归类。
- 对每个模块,先在代码里用PocoEmit.Mapper写一个等价映射,替换原来调用IMapper.Map的地方。
- 运行模块相关接口,对比映射前后字段值,重点检查日期格式、可空字段、集合元素顺序和枚举值。
- 确认无误后,删除该模块的AutoMapper配置和引用。
第一步整理配置很关键,我整理之后发现,很多CreateMap压根没被用到,纯属历史遗留。所以替换的过程也顺便做了一轮配置清理。
4.2 常见AutoMapper配置的翻译对照
直接列一些翻译对照,方便迁移时对照着写。
AutoMapper的ForMember重命名:
CreateMap<User, UserDto>() .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name));PocoEmit.Mapper:
PocoMapper.Config<User, UserDto>() .Map(dto => dto.FullName, entity => entity.Name) .Build();AutoMapper的忽略字段:
CreateMap<User, UserDto>() .ForMember(dest => dest.CreatedAt, opt => opt.Ignore());PocoEmit.Mapper:
PocoMapper.Config<User, UserDto>() .Ignore(dto => dto.CreatedAt) .Build();AutoMapper的NullSubstitute(空值替换):
CreateMap<User, UserDto>() .ForMember(dest => dest.Email, opt => opt.NullSubstitute("未填写"));PocoEmit.Mapper里直接用自定义表达式:
PocoMapper.Config<User, UserDto>() .Map(dto => dto.Email, entity => entity.Email ?? "未填写") .Build();AutoMapper的构造参数映射,用的是ConstructUsing:
CreateMap<User, UserDto>() .ConstructUsing(src => new UserDto(src.Id, src.Name));PocoEmit.Mapper的做法是在自定义表达式里直接new:
PocoMapper.Config<User, UserDto>() .Map(dto => dto, entity => new UserDto(entity.Id, entity.Name)) .Build();整体看下来,AutoMapper的API表达能力很强,但PocoEmit.Mapper的配置方式更直白,从“配置”到“代码”的语义距离更短。
4.3 集合映射、嵌套映射和循环引用
集合映射在AutoMapper里需要明确配置或者依赖约定,PocoEmit.Mapper对集合的处理是自动的。List、IEnumerable、IList、ICollection都支持,生成的IL里会有for循环,逐个映射元素。
嵌套映射也是自动的,比如User里有Address属性,UserDto里也有AddressDto属性,会自动生成嵌套映射委托。嵌套映射默认会缓存,所以内层模型只生成一次,不会每次外层映射都重复生成。
循环引用需要注意。如果实体关系里有A包含B、B又包含A这种双向关系,直接映射会导致死循环。PocoEmit.Mapper目前的处理策略是:生成IL时检测到循环引用不会自动截断,所以你要自己在配置里处理,一般是把其中一个方向的属性Ignore掉,或者在自定义表达式里手动控制层级深度。
PocoMapper.Config<Order, OrderDto>() .Ignore(dto => dto.User) // 或改为按需赋值 .Build();在实际业务里,出现循环引用的实体关系很常见,比如订单和用户各自引用对方。我的建议是DTO层尽量扁平化,不要完整复刻实体的关系网,双向引用在序列化时本来就容易踩坑。所以这里不光是Mapper配置问题,更多是DTO设计问题。
4.4 依赖注入替换:从IMapper到静态API
AutoMapper在ASP.NET Core里通常是以IMapper接口的形式注入:
public class UserService { private readonly IMapper _mapper; public UserService(IMapper mapper) { _mapper = mapper; } public UserDto GetUser(int id) { var user = _userRepository.GetById(id); return _mapper.Map<UserDto>(user); } }PocoEmit.Mapper没有强制接口注入,直接使用静态类:
public class UserService { public UserDto GetUser(int id) { var user = _userRepository.GetById(id); return PocoMapper.Map<UserDto>(user); } }如果你还是想保留接口注入的方式以便于单元测试Mock,可以自己包一层:
public interface IMapperService { TDest Map<TDest>(object source); } public class PocoEmitMapperService : IMapperService { public TDest Map<TDest>(object source) { return PocoMapper.Map<TDest>(source); } }然后注册到DI容器:
builder.Services.AddSingleton<IMapperService, PocoEmitMapperService>();这样替换之后,业务代码里注入的类型从AutoMapper的IMapper换成了你自己的IMapperService,将来你再换别的Mapper,业务代码不需要动。我个人建议无论用不用PocoEmit.Mapper,引入一层薄薄的接口封装都是值得的。
5. 性能实测:同一套数据,两类Mapper跑出什么差距
5.1 测试环境和方法
测试机器配置:i5-12600K、32G内存、Windows 11、.NET 7。测试数据:构造10000条User记录,每条包含20个基础属性、1个List (每个User带3条Order)、1个嵌套Address对象。分别测试首次映射耗时(冷启动)和预热后单次映射耗时(热启动),以及批量映射10000条总耗时。每个测试跑10轮取中位数,避免GC噪音。
5.2 测试结果
| 场景 | AutoMapper | PocoEmit.Mapper | 差距 |
|---|---|---|---|
| 首次映射单条 | 约12500 us | 约380 us | 33倍 |
| 预热后单条映射 | 约7.8 us | 约1.1 us | 7倍 |
| 批量映射10000条 | 约82 ms | 约12 ms | 6.8倍 |
| 批量映射10000条(含嵌套集合) | 约235 ms | 约41 ms | 5.7倍 |
单独看单条差距,PocoEmit.Mapper大概快7倍;批量场景下因为IL代码减少了大量的调用和类型判断开销,差距能到6到7倍。首次映射的差距很大,主要是因为AutoMapper首次构建表达式树并编译的过程开销大,而PocoEmit.Mapper的IL发射过程相对直接。
5.3 为什么IL发射的映射函数更轻
这里展开讲讲IL层面发生了什么。手写映射代码大概是这样的:
dto.Id = entity.Id; dto.Name = entity.Name; dto.Email = entity.Email;每个属性赋值就是一次ldarg、ldfld、stfld之类的IL指令。PocoEmit.Mapper生成的IL与手写映射基本等价,因此执行路径很短。
AutoMapper生成的委托里包含了更多内容:它会对每个属性调用PropertyMap的ResolutionResult,会有类型转换器的判断、值解析器的调用,还可能包含条件判断。这些通用化处理让单个属性的赋值路径变长,累积起来就是性能差距。
一句话总结:AutoMapper解决的是“怎么省事地把任意两个对象映射起来”,PocoEmit.Mapper解决的是“怎么把一个对象的属性最快地搬到另一个对象上”。两者的设计目标不同,所以性能差异是结构性差异,不是优化调参能抹平的。
6. 实战中的坑与排查技巧
6.1 坑一:泛型映射和接口类型
如果你的DTO属性声明成接口或抽象类型,比如:
public class UserDto { public IEnumerable<OrderDto> Orders { get; set; } }PocoEmit.Mapper在解析时能识别IEnumerable 并生成对应映射代码,但有个前提:它需要能够确定具体的元素类型。如果源属性是List ,目标属性是IEnumerable ,没问题;如果源属性直接是IEnumerable ,里面跑着各种具体类型,那就没法自动化,得靠自定义表达式处理。
踩过一次的坑:目标DTO属性声明为IReadOnlyList ,PocoEmit.Mapper的早期版本会在生成IL时不知道如何构造目标集合,报异常。后来换成了List 属性就好了。这在用的时候稍微注意一下目标集合类型,尽量声明成可具体构造的类型。
6.2 坑二:自定义配置没有生效
刚上手时容易犯的错:在调用Map之前不记得调用Build,或者模块初始化顺序不对。PocoEmit.Mapper的配置不是随时生效的,必须Config之后显式Build,而且Build之后Map才能看到配置。如果你在Map之后才Build,或者Build在另一个线程执行但Map线程缓存已生成,就会出现配置不生效的情况。
解决方案:所有的Config和Build都在程序启动时集中执行。建议单独写一个MapperProfile类,和AutoMapper的Profile风格一样,把所有配置集中起来,启动时调用一次。这样既保证配置先后顺序,也方便以后统一排查。
6.3 坑三:私有构造函数和不可变类型
PocoEmit.Mapper默认通过无参构造函数创建目标对象。如果DTO没有无参构造函数,会失败。比如C# 9的record类型,主构造函数带参数但没无参构造:
public record UserDto(int Id, string Name);直接用Map会报错。解决办法两种:一种是给record加一个无参构造函数,但record的语义一般不这么干;另一种是走自定义表达式,用Config的Map(dto => dto, entity => new UserDto(entity.Id, entity.Name))来绕过。
如果是那种很长的记录类型,全属性列在构造函数里,自定义表达式会写很长。但至少有个口子能绕过去,不像某些Mapper一律不支持无参构造之外的类型。
6.4 坑四:性能压测中的GC干扰
PocoEmit.Mapper快,但在压测时容易因为GC被“黑”。它的映射函数生成的大量临时对象(比如新生成的List)会在Gen0/Gen1里产生压力,如果压测代码里没有做好预热和内存回收控制,测出来的数据可能比实际更差。建议压测前先跑一轮预热,然后手动GC.Collect()一次再计时,否则数据不稳定。
另外,如果要对比AutoMapper和PocoEmit.Mapper,两个都要做同等程度的预热,否则首次编译的差距会干扰你判断热启动的真实差距。
6.5 踩坑速查表
| 现象 | 原因 | 解决 |
|---|---|---|
| Map抛异常提示无法构造目标类型 | DTO没有无参构造函数 | 加无参构造或自定义表达式 |
| 集合属性映射后为null | 源属性为null | 在自定义表达式中处理null |
| 配置写了但等于没写 | Build未调用或顺序不对 | 集中启动时Build,避免Map后才Build |
| 循环引用导致栈溢出 | 实体双向导航未处理 | Ignore一个方向或扁平化DTO |
| 可空字段值丢失 | 源属性为null | 确认映射策略,使用用户自定义null处理 |
7. 几个值得深挖的设计细节
7.1 PocoEmit.Emit和PocoEmit.Mapper的关系
如果你翻NuGet会发现有个包叫PocoEmit.Emit,这是底层库,负责IL发射工具类,PocoEmit.Mapper依赖它。PocoEmit.Emit还提供了TypeBuilder、MethodBuilder等底层操作封装,想手写IL的同学可以直接用这个库来简化动态程序集生成。Mapper层只是在这之上封装了“属性到属性拷贝”的语义。
这个分层设计我觉得很合理。底层负责通用IL生成能力,上层负责具体业务映射语义。如果你只是需要动态生成一个类型或方法,可以直接用PocoEmit.Emit;如果要的是对象映射,直接用PocoEmit.Mapper。两者依赖关系清爽:PocoEmit.Mapper → PocoEmit.Emit,没有多余的中间层。
7.2 为什么叫Poco,和POCO的关系
POCO(Plain Old CLR Object)指的是不带框架依赖的普通CLR对象。PocoEmit.Mapper这个名字想表达的就是“针对普通CLR对象的映射器”,不需要你的实体类继承基类、实现接口或者打特性标签。这点比AutoMapper轻——AutoMapper虽然也不强制继承,但设计上围绕“配置驱动的通用映射”做文章,PocoEmit.Mapper则是冲着“简单对象直接搬”去的。
这也决定了它的定位:适合大量普通对象映射的场景,不适合那种映射规则非常复杂、需要在映射过程注入大量附加操作的场景。真遇到那种场景,我反而会建议你重新审视一下DTO设计,而不是给Mapper加更多魔法。
7.3 配置文件里能做哪些事
我自己在项目里用下来的经验,建议把PocoEmit.Mapper的配置集中写进一个静态类,命名就叫PocoEmitMapperProfile,启动时统一调用构建。这样配置文件可以包含:
- 属性重命名映射。
- 类型转换映射。
- 自定义合并逻辑。
- Ignore列表。
- 全局设置(比如默认空值策略)。
每个配置方法后面跟着相应的测试断言,防止配置了但后来类结构变了导致映射失败。因为PocoEmit.Mapper是运行时生成IL的,配置错误不会在编译期暴露,所以配置对应的单元测试非常值得写。
7.4 和Mapster的二次对比
既然聊到这里,再补一句Mapster。Mapster的API也很现代,性能介于AutoMapper和PocoEmit.Mapper之间。它有个TypeAdapterConfig<T1, T2>.NewConfig()的配置方式,和AutoMapper的CreateMap很像,迁移起来更顺手。
PocoEmit.Mapper的优势在于它的默认实现更纯粹,IL生成路径更短,性能上限更高。Mapster的表达式树方案虽然也编译,但表达式的通用性、扩展点比PocoEmit.Mapper多,代码路径自然长一些。
如果你不想完全去掉AutoMapper的配置习惯,又想要性能提升,Mapster是平滑之选;如果你愿意改一些用法,追求最极致的映射性能和极简配置,PocoEmit.Mapper值得投入。这两者都是AutoMapper的好替代,视团队习惯取舍即可。
8. 迁移之后:我的使用习惯和配套建议
实际跑了两个月之后,我对这套替换的结论是:价值很大,但不建议无脑全量替换。如果你的项目里AutoMapper配置用得密集、复杂规则多,迁移本身就要花不少时间,而且可能遇到一些边缘情况需要逐个处理;如果你的项目大量映射就是“实体转DTO,属性名都一致”,那换过来特别顺畅,性能提升立竿见影。
迁移过程中我沉淀了几个配套习惯,值得分享:
第一,映射配置集中在专门的Profile里,和AutoMapper时期一样处理,只不过从CreateMap换成了PocoMapper.Config。这样将来要再换Mapper或者排查映射问题,不需要去业务代码里翻。
第二,给映射写对拍测试。用一个真实的实体实例,分别用AutoMapper(迁移前)和PocoEmit.Mapper(迁移后)映射,断言所有字段值一致。迁移期间靠这个测试兜底,基本不可能漏字段。迁移完成后,这个测试也保留了,将来实体结构变动时,它能立刻报告映射哪里断了。
第三,不要为了用而用。如果一个映射关系极度简单,比如只有两个属性,直接手写赋值可能比任何Mapper都直观,也不要再用Mapper去套。PocoEmit.Mapper也不是银弹,它解决的是“批量、高频、结构稳定”的映射场景。低频、超简单的映射,手写反而更省心。
第四,注意Nullable值类型和默认值。实体里int?属性和DTO里int?属性,或者int?映射到int,PocoEmit.Mapper的策略是空值给默认值0。如果你需要把空值映射成null或者自定义默认值,要显式在自定义表达式里处理。这一点在迁移时容易被忽略,因为AutoMapper的默认策略也是赋默认值,但两个库对可空类型的处理细节还是有细微差别,建议用测试覆盖。
最后再分享一个小技巧。PocoEmit.Mapper的配置是静态的、进程级的,所以如果你在单元测试里修改了配置,可能会影响其他测试用例。最好的做法是用一个专门的测试基类,在每次测试前重建配置或者确保配置一致,避免测试间的相互污染。我踩过这个坑,某次改了一个配置,结果另一个模块的映射测试全部失败,排查半天才发现是静态配置的复用问题。
整体来看,从AutoMapper换到PocoEmit.Mapper是一次“减负”。减掉的是几百行配置文件、运行时的反射索引开销、以及每次排查映射问题时的精神负担。换来的是一个大部分映射零配置、执行路径接近手写、性能数据亮眼的库。如果你正处在“AutoMapper性能吃紧但又不敢动”的阶段,这篇文章的实测数据和实践记录应该能给你足够的参考。