自动映射器版本=13.0.1
Microsoft.EntityFrameworkCore 版本=7.0.20
Microsoft.EntityFrameworkCore.InMemory 版本=7.0.20
预期行为 我想创建一个配置文件,并将内存中的一个 DTO 的工作与数据库中的投影结合起来。考虑到翻译数据库查询的具体要求。我遇到了一些问题,您可以在重现步骤中查看更多详细信息。有关于当前和预期行为的详细评论,以及可能的解决方案。
Test1() - 基本配置文件设置和我遇到的问题的识别。此配置文件在 ProjectTo 中运行良好,但在内存中与 Map 一起运行不佳,因为 Expression 中没有 Null 检查
Test2() - 在内存 Map 中运行良好,但在 ProjectTo 中不起作用。原因很清楚 - 使用 Func,它不是表达式,无法翻译成 SQL
Test3() - 一种解决方案是始终使用 AsQueryable 和 Projection,即使在内存中也是如此,以便闭包起作用。但是,在我看来,这也是一种黑客行为,而不是解决方案,并且它有自己的问题
Test4() - 我想到的唯一可能的解决方案是创建一个从 Dto 继承的对象并为其创建一个单独的配置文件并仅将其用于投影,并仅在内存中使用主配置文件。这有效。但这也远非理想,您必须始终记住,一个 Dto 应该仅在内存中使用,而另一个仅在投影中使用。还执行从 FooDtoForProjection 到 FooDto 的转换。在更改数据库模型时也支持这两种配置文件。
解决此类问题的最佳方案是什么? 根据我的想法,我想为同一对源和目标创建 2 个贴图,以便一个贴图用于在内存中工作,另一个贴图用于投影。但 Automapper 不允许这样做。或者,如果可以制作 2 个独立的 MapFrom。例如,opt.MapFromForMemory 和 opt.MapFromForProjection。是的,确实,Automapper 涵盖了 99% 的必要工作,但我遇到了它没有涵盖的那 1%,到目前为止我还没有看到一个好的、漂亮的解决方案。帮我出出主意吧!
PS我知道可以在Expression中添加检查,但是如果有很多语言和字段需要翻译,这会显着增加生成的SQL查询的大小和复杂性
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
try
{
await Helper.Populate();
await Helper.Test1();
await Helper.Test2();
await Helper.Test3();
await Helper.Test4();
}
catch (Exception ex)
{
}
public static class Helper
{
public static FooContext CreateContext()
{
DbContextOptionsBuilder<FooContext> builder =
new DbContextOptionsBuilder<FooContext>()
.UseInMemoryDatabase("foo_in_memory")
.ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning));
FooContext dbContext = new FooContext(builder.Options);
return dbContext;
}
public static async Task Populate()
{
FooContext dbContext = CreateContext();
await dbContext.Database.EnsureCreatedAsync();
Foo foo = new Foo();
foo.Name = "Foo";
foo.Translate = new List<FooTranslate>
{
new()
{
Culture = "en",
Name = "FooEn"
},
new()
{
Culture = "fr",
Name = "FooFr"
},
};
dbContext.Add(foo);
await dbContext.SaveChangesAsync();
await dbContext.DisposeAsync();
}
// Works on the DB side, but does not work in memory
public static async Task Test1()
{
MapperConfiguration mapperConfiguration =
new MapperConfiguration(expression =>
{
expression.AddProfile(new FooProfile());
});
IMapper mapper = mapperConfiguration.CreateMapper();
// 1. Projection
// This works because the expression is transformed to SQL and all Null checks
// are performed on the DB side and the correct value is returned
FooContext dbContext_1 = CreateContext();
IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
IQueryable<FooDto> queryProjection_1 =
mapper.ProjectTo<FooDto>(query_1, new { culture = "fr" });
FooDto dto_1 = await queryProjection_1.FirstAsync();
await dbContext_1.DisposeAsync();
// dto_1.Name = "FooFr" - Working
// 2. Map with Include
// The closure does not work. culture in the profile = null.
// When executed in memory (not on the DB side),
// expression throws a NullReferenceException and
// Automapper simply swallows it and the Name field remains Null.
// Why does the closure not work with Map?
FooContext dbContext_2 = CreateContext();
IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
Foo foo_2 = await query_2.FirstAsync();
FooDto dto_2 = mapper.Map<FooDto>(foo_2,
options => options.Items["culture"] = "fr");
await dbContext_2.DisposeAsync();
//dto_2.Name = null - NOT working
// 3. Map with Include and BeforeMap
// The situation in point 2 can be solved by adding BeforeMap to the profile,
// but this looks more like a crutch
MapperConfiguration mapperConfigurationWithBeforeMap =
new MapperConfiguration(expression =>
{
expression.AddProfile(new FooProfileWithBeforeMap());
});
IMapper mapperWithBeforeMap = mapperConfigurationWithBeforeMap.CreateMapper();
FooContext dbContext_3 = CreateContext();
IQueryable<Foo> query_3 = dbContext_3.FooDbSet.Include(q => q.Translate);
Foo foo_3 = await query_3.FirstAsync();
FooDto dto_3 = mapperWithBeforeMap.Map<FooDto>(foo_3,
options => options.Items["culture"] = "fr");
await dbContext_3.DisposeAsync();
//dto_3.Name = "FooFr" - NOT working
// 4. Map WITHOUT Include and BeforeMap
// But even the crutch from point 3 does
// not save from the situation when the required translation
// is not available (it is simply not translated into the required language
// or was not requested from the Include DB).
// The default name from foo.Name is not returned because
// expression in memory throws a NullReferenceException after FirstOrDefault()
FooContext dbContext_4 = CreateContext();
IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
Foo foo_4 = await query_4.FirstAsync();
FooDto dto_4 = mapperWithBeforeMap.Map<FooDto>(foo_4,
options => options.Items["culture"] = "fr");
await dbContext_4.DisposeAsync();
//dto_4.Name = null - NOT working
// 5. Map With Include and BeforeMap not exists translate
string notExistsCulture = "ua";
FooContext dbContext_5 = CreateContext();
IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
Foo foo_5 = await query_5.FirstAsync();
FooDto dto_5 = mapperWithBeforeMap.Map<FooDto>(foo_5,
options => options.Items["culture"] = notExistsCulture);
await dbContext_5.DisposeAsync();
//dto_5.Name = null - NOT working
}
// Works in memory (with hacks - BeforeMap and set culture), but does not work on DB side
public static async Task Test2()
{
MapperConfiguration mapperConfiguration =
new MapperConfiguration(expression =>
{
expression.AddProfile(new FooProfileWithUseFunc());
});
IMapper mapper = mapperConfiguration.CreateMapper();
// 1. Projection
// Does not work for the obvious reason of using Func rather
// than Expression when building a profile map
FooContext dbContext_1 = CreateContext();
IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
IQueryable<FooDto> queryProjection_1 =
mapper.ProjectTo<FooDto>(query_1, new { culture = "fr" });
FooDto dto_1 = await queryProjection_1.FirstAsync();
await dbContext_1.DisposeAsync();
// dto_1.Name = "Foo" - expected "FooFr" - NOT working
// 2. Map with Include
// The closure does not work. culture in the profile = null.
// Why does the closure not work with Map?
FooContext dbContext_2 = CreateContext();
IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
Foo foo_2 = await query_2.FirstAsync();
FooDto dto_2 = mapper.Map<FooDto>(foo_2,
options => options.Items["culture"] = "fr");
await dbContext_2.DisposeAsync();
//dto_2.Name = "Foo" - expected "FooFr" - NOT working
// 3. Map with Include and BeforeMap
// The situation in point 2 can be solved by adding BeforeMap to the profile,
// but this looks more like a crutch
MapperConfiguration mapperConfigurationWithBeforeMap =
new MapperConfiguration(expression =>
{
expression.AddProfile(new FooProfileUseFuncWithBeforeMap());
});
IMapper mapperWithBeforeMap = mapperConfigurationWithBeforeMap.CreateMapper();
FooContext dbContext_3 = CreateContext();
IQueryable<Foo> query_3 = dbContext_3.FooDbSet.Include(q => q.Translate);
Foo foo_3 = await query_3.FirstAsync();
FooDto dto_3 = mapperWithBeforeMap.Map<FooDto>(foo_3,
options => options.Items["culture"] = "fr");
await dbContext_3.DisposeAsync();
//dto_3.Name = "FooFr" - working (hack)
// 4. Map WITHOUT Include and BeforeMap
FooContext dbContext_4 = CreateContext();
IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
Foo foo_4 = await query_4.FirstAsync();
FooDto dto_4 = mapperWithBeforeMap.Map<FooDto>(foo_4,
options => options.Items["culture"] = "fr");
await dbContext_4.DisposeAsync();
//dto_4.Name = "Foo" - working
// 5. Map With Include and BeforeMap not exists translate
string notExistsCulture = "ua";
FooContext dbContext_5 = CreateContext();
IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
Foo foo_5 = await query_5.FirstAsync();
FooDto dto_5 = mapperWithBeforeMap.Map<FooDto>(foo_5,
options => options.Items["culture"] = notExistsCulture);
await dbContext_5.DisposeAsync();
//dto_5.Name = "Foo" - working
}
// One solution is to use AsQueryable and Projection always,
// even in memory, so that the closure works.
// But, in my opinion, this is also a hack,
// not a solution and it has its own problems
public static async Task Test3()
{
MapperConfiguration mapperConfiguration =
new MapperConfiguration(
expression =>
{
expression.AddProfile(new FooProfile());
});
IMapper mapper = mapperConfiguration.CreateMapper();
// 1. Projection
FooContext dbContext_1 = CreateContext();
IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
IQueryable<FooDto> queryProjection_1 =
mapper.ProjectTo<FooDto>(query_1, new { culture = "fr" });
FooDto dto_1 = await queryProjection_1.FirstAsync();
await dbContext_1.DisposeAsync();
// dto_1.Name = "FooFr" - working
// 2. Map with Include
FooContext dbContext_2 = CreateContext();
IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
List<Foo> list_2 = await query_2.ToListAsync();
IQueryable<FooDto> queryProjection_2 =
mapper.ProjectTo<FooDto>(list_2.AsQueryable(), new { culture = "fr" });
FooDto dto_2 = queryProjection_2.First();
await dbContext_2.DisposeAsync();
//dto_2.Name = "FooFr" - working
// 3. Map with Include and BeforeMap
// this test is not needed here
// 4. Map WITHOUT Include
FooContext dbContext_4 = CreateContext();
IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
List<Foo> list_4 = await query_4.ToListAsync();
IQueryable<FooDto> queryProjection_4 =
mapper.ProjectTo<FooDto>(list_4.AsQueryable(), new { culture = "fr" });
try
{
// in this case, AsQueryable is still executed in memory, not on the DB side,
// so WITHOUT checking for Null in expression we get a NullReferenceException
FooDto dto_4 = queryProjection_4.First();
}
catch (Exception ex)
{
}
await dbContext_4.DisposeAsync();
//Exception - NOT working
// 5. Map With Include and BeforeMap not exists translate
string notExistsCulture = "ua";
FooContext dbContext_5 = CreateContext();
IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
List<Foo> list_5 = await query_5.ToListAsync();
IQueryable<FooDto> queryProjection_5 =
mapper.ProjectTo<FooDto>(list_5.AsQueryable(), new { culture = notExistsCulture });
try
{
// in this case, AsQueryable is still executed in memory, not on the DB side,
// so WITHOUT checking for Null in expression we get a NullReferenceException
FooDto dto_5 = queryProjection_5.First();
}
catch (Exception ex)
{
}
await dbContext_5.DisposeAsync();
//Exception - NOT working
}
// The only possible solution that came to my mind is to create an object inherited
// from Dto and create a separate profile for it and use it
// ONLY for Projection, and use the main profile only in memory.
// This works.
// But it is also far from ideal,
// you must always remember that one Dto should be used
// only in memory, and the other only in Projection.
// Also perform transformations from FooDtoForProjection => FooDto.
// Also support both profiles when making changes to the DB model
public static async Task Test4()
{
MapperConfiguration mapperConfiguration = new MapperConfiguration(expression =>
{
expression.AddProfile(new FooProfileForMemory());
expression.AddProfile(new FooProfileForProjection());
});
IMapper mapper = mapperConfiguration.CreateMapper();
// 1. Projection
FooContext dbContext_1 = CreateContext();
IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
IQueryable<FooDtoForProjection> queryProjection_1 =
mapper.ProjectTo<FooDtoForProjection>(query_1, new { culture = "fr" });
FooDto dto_1 = await queryProjection_1.FirstAsync();
await dbContext_1.DisposeAsync();
// dto_1.Name = "FooFr" - Working
// 2. Map with Include
FooContext dbContext_2 = CreateContext();
IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
Foo foo_2 = await query_2.FirstAsync();
FooDto dto_2 =
mapper.Map<FooDto>(foo_2, options => options.Items["culture"] = "fr");
await dbContext_2.DisposeAsync();
// dto_2.Name = "FooFr" - Working
// 3. Map with Include and BeforeMap
// this test is not needed here
// 4. Map WITHOUT Include
FooContext dbContext_4 = CreateContext();
IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
Foo foo_4 = await query_4.FirstAsync();
FooDto dto_4 =
mapper.Map<FooDto>(foo_4, options => options.Items["culture"] = "fr");
await dbContext_4.DisposeAsync();
//dto_4.Name = "Foo" - working
// 5. Map With Include not exists translate
string notExistsCulture = "ua";
FooContext dbContext_5 = CreateContext();
IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
Foo foo_5 = await query_5.FirstAsync();
FooDto dto_5 =
mapper.Map<FooDto>(foo_5, options => options.Items["culture"] = notExistsCulture);
await dbContext_5.DisposeAsync();
//dto_5.Name = "Foo" - working
}
}
public class FooProfile : Profile
{
public FooProfile()
{
string? culture = null;
CreateMap<Foo, FooDto>()
.ForMember(
desc => desc.Name,
opt => opt.MapFrom(src =>
src.Translate.Any(q => q.Culture == culture && q.Name != null)
? src.Translate.First(q => q.Culture == culture).Name
: src.Name)
);
}
}
public class FooProfileWithBeforeMap : Profile
{
public FooProfileWithBeforeMap()
{
string? culture = null;
CreateMap<Foo, FooDto>()
.ForMember(
desc => desc.Name,
opt => opt.MapFrom(src =>
src.Translate.FirstOrDefault(q => q.Culture == culture).Name
?? src.Name))
.BeforeMap((foo, dto, context) =>
{
culture = context.Items["culture"]?.ToString();
});
}
}
public class FooProfileWithUseFunc : Profile
{
public FooProfileWithUseFunc()
{
string? culture = null;
CreateMap<Foo, FooDto>()
.ForMember(
desc => desc.Name,
opt => opt.MapFrom((src, dest) =>
{
return src.Translate.FirstOrDefault(q => q.Culture == culture)?.Name
?? src.Name;
}));
}
}
public class FooProfileUseFuncWithBeforeMap : Profile
{
public FooProfileUseFuncWithBeforeMap()
{
string? culture = null;
CreateMap<Foo, FooDto>()
.ForMember(
desc => desc.Name,
opt => opt.MapFrom((src, dest) =>
{
return src.Translate.FirstOrDefault(q => q.Culture == culture)?.Name
?? src.Name;
}))
.BeforeMap((foo, dto, context) =>
{
culture = context.Items["culture"]?.ToString();
});
}
}
public class Foo
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<FooTranslate> Translate { get; set; } = new();
}
public class FooTranslate
{
public int Id { get; set; }
public int FooId { get; set; }
public string? Culture { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public Foo? Foo { get; set; }
}
public class FooDto
{
public string Name { get; set; } = null!;
}
public class FooDtoForProjection : FooDto
{
}
public class FooProfileForMemory : Profile
{
public FooProfileForMemory()
{
CreateMap<Foo, FooDto>()
.ForMember(
desc => desc.Name,
opt => opt.MapFrom((src, dest, _, context) =>
{
return src.Translate
.FirstOrDefault(q =>
q.Culture == context.Items["culture"]?.ToString())?.Name
?? src.Name;
}));
}
}
public class FooProfileForProjection : Profile
{
public FooProfileForProjection()
{
string? culture = null;
CreateMap<Foo, FooDtoForProjection>()
.ForMember(
desc => desc.Name,
opt => opt.MapFrom(src =>
src.Translate.FirstOrDefault(q => q.Culture == culture).Name
?? src.Name));
}
}
public class FooContext : DbContext
{
public FooContext(DbContextOptions options) : base(options)
{
}
public DbSet<Foo> FooDbSet { get; set; } = null!;
public DbSet<FooTranslate> FooTranslateDbSet { get; set; } = null!;
}
我会对我使用的方法表示敬意,供您考虑。在我的 ViewModels/DTO 中,我将自动映射器配置封装在静态工厂方法中。它使映射保持井井有条,因为我讨厌筛选巨大的映射配置文件。
例如在 PositionSummaryViewModel 中,表示 Position 实体的摘要:
public static MapperConfigurationExpression BuildConfigExpression(MapperConfigurationExpression? configExpression = null)
{
if (configExpression == null)
configExpression = new MapperConfigurationExpression();
configExpression.CreateMap<Position, PositionSummaryViewModel>();
// append any ForMember specific details.
return configExpression;
}
public static MapperConfiguration BuildConfig(MapperConfigurationExpression? configExpression = null)
{
var config = new MapperConfiguration(BuildConfigExpression(configExpression));
return config;
}
“BuildConfigExpression”可用,以便视图模型可以链接调用来构建单个配置表达式,以反映它们包含的任何相关视图模型。准备
ProjectTo<T>()
的调用者只需要在顶层视图模型/DTO上调用“BuildConfig”。
然后,例如,当我有一个类似 EmployeeDetailViewModel 的东西,其中包含一系列职位(SummaryVM)时,那么它的构建配置将链接该职位(以及它也可能填充的任何其他视图模型):
public static MapperConfigurationExpression BuildConfigExpression(MapperConfigurationExpression? configExpression = null)
{
if (configExpression == null)
configExpression = new MapperConfigurationExpression();
configExpression = PositionSummaryViewModel.BuildConfigExpression(configExpression);
configExpression.CreateMap<Employee, EmployeeDetailViewModel>()
// append any ForMember specific details.
.ForMember(x => x.Positions, opt => opt.MapFrom(src => src.Positions
.OrderByDescending(p => p.DateStarted));
return configExpression;
}
public static MapperConfiguration BuildConfig(MapperConfigurationExpression? configExpression = null)
{
var config = new MapperConfiguration(BuildConfigExpression(configExpression));
return config;
}
当代码想要构建 EmployeeDetailViewModel 的投影时,它会从 BuildConfig() 调用中获取 Automapper 配置,其中每个视图模型负责链接它包含的任何其他视图模型。 (BuildConfigExpression 将追加或创建)
因此,获取员工详细视图模型的查询如下所示:
var employeeVM = await _context.Employees
.Where(x => x.EmployeeId == employeeId)
.ProjectTo<EmployeeDetailConfig>(EmployeeDetailViewModel.BuildConfig())
.SingleAsync();
return View(employeeVM);
我只使用映射进行投影,而不是
Map()
。当涉及到从内存中实体<->视图模型中继更改时,我更喜欢使用显式设置器而不是Map<src>()
或Map<src,dest>()
。这些映射表达式仍然有效,并且可以包括返回到相关实体的映射,前提是相关数据已预先加载。不过,我使用显式设置器,因为它在检查代码以查找值何时更改时提供了更多可见性。 Map()
隐藏修改点,否则这些修改点会通过“查找引用”显示出来。在大多数情况下,更新/插入之类的事情是通过操作请求视图模型完成的,而不是通过其他方式发送的投影。