Ядро ASP.NET с EF Core - сопоставление коллекции DTO
Я пытаюсь использовать (POST/PUT) объект DTO с набором дочерних объектов из JavaScript в ASP.NET Core (Web API) с основным EF-контекстом в качестве источника данных.
Основной класс DTO - это что-то вроде этого (упрощенно, конечно):
public class CustomerDto {
public int Id { get;set }
...
public IList<PersonDto> SomePersons { get; set; }
...
}
Я действительно не знаю, как сопоставить это с классом сущности клиента таким образом, чтобы он не включал много кода, чтобы узнать, какие люди были добавлены/обновлены/удалены и т.д.
Я немного поработал с AutoMapper, но на самом деле это не похоже на игру с EF Core в этом сценарии (сложная структура объекта) и коллекции.
После поиска по некоторым советам, я не нашел хороших ресурсов вокруг того, что будет хорошим подходом. Мои вопросы в основном: должен ли я перепроектировать JS-клиент, чтобы не использовать "сложные" DTO, или это то, что "должно" обрабатываться слоем отображения между моими DTO и моделью Entity или есть ли другое хорошее решение, которое я не понимаете?
Я смог решить это как с помощью AutoMapper, так и путем сопоставления вручную между объектами, но ни одно из решений не кажется правильным и быстро становится довольно сложным с большим количеством шаблонов.
РЕДАКТИРОВАТЬ:
В следующей статье описывается, что я имею в виду в отношении AutoMapper и EF Core. Это не сложный код, но я просто хочу знать, является ли это "лучшим" способом управления этим.
(Код из статьи отредактирован, чтобы соответствовать приведенному выше примеру кода)
http://cpratt.co/using-automapper-mapping-instances/
var updatedPersons = new List<Person>();
foreach (var personDto in customerDto.SomePersons)
{
var existingPerson = customer.SomePersons.SingleOrDefault(m => m.Id == pet.Id);
// No existing person with this id, so add a new one
if (existingPerson == null)
{
updatedPersons.Add(AutoMapper.Mapper.Map<Person>(personDto));
}
// Existing person found, so map to existing instance
else
{
AutoMapper.Mapper.Map(personDto, existingPerson);
updatedPersons.Add(existingPerson);
}
}
// Set SomePersons to updated list (any removed items drop out naturally)
customer.SomePersons = updatedPersons;
Код выше, написанный как общий метод расширения.
public static void MapCollection<TSourceType, TTargetType>(this IMapper mapper, Func<ICollection<TSourceType>> getSourceCollection, Func<TSourceType, TTargetType> getFromTargetCollection, Action<List<TTargetType>> setTargetCollection)
{
var updatedTargetObjects = new List<TTargetType>();
foreach (var sourceObject in getSourceCollection())
{
TTargetType existingTargetObject = getFromTargetCollection(sourceObject);
updatedTargetObjects.Add(existingTargetObject == null
? mapper.Map<TTargetType>(sourceObject)
: mapper.Map(sourceObject, existingTargetObject));
}
setTargetCollection(updatedTargetObjects);
}
.....
_mapper.MapCollection(
() => customerDto.SomePersons,
dto => customer.SomePersons.SingleOrDefault(e => e.Id == dto.Id),
targetCollection => customer.SomePersons = targetCollection as IList<Person>);
Редактировать:
Единственное, что я действительно хочу, - это настроить конфигурацию AutoMapper в одном месте (профиль), не нужно использовать расширение MapCollection() каждый раз, когда я использую mapper (или любое другое решение, которое требует усложнения кода отображения).
Поэтому я создал метод расширения, подобный этому
public static class AutoMapperExtensions
{
public static ICollection<TTargetType> ResolveCollection<TSourceType, TTargetType>(this IMapper mapper,
ICollection<TSourceType> sourceCollection,
ICollection<TTargetType> targetCollection,
Func<ICollection<TTargetType>, TSourceType, TTargetType> getMappingTargetFromTargetCollectionOrNull)
{
var existing = targetCollection.ToList();
targetCollection.Clear();
return ResolveCollection(mapper, sourceCollection, s => getMappingTargetFromTargetCollectionOrNull(existing, s), t => t);
}
private static ICollection<TTargetType> ResolveCollection<TSourceType, TTargetType>(
IMapper mapper,
ICollection<TSourceType> sourceCollection,
Func<TSourceType, TTargetType> getMappingTargetFromTargetCollectionOrNull,
Func<IList<TTargetType>, ICollection<TTargetType>> updateTargetCollection)
{
var updatedTargetObjects = new List<TTargetType>();
foreach (var sourceObject in sourceCollection ?? Enumerable.Empty<TSourceType>())
{
TTargetType existingTargetObject = getMappingTargetFromTargetCollectionOrNull(sourceObject);
updatedTargetObjects.Add(existingTargetObject == null
? mapper.Map<TTargetType>(sourceObject)
: mapper.Map(sourceObject, existingTargetObject));
}
return updateTargetCollection(updatedTargetObjects);
}
}
Тогда, когда я создаю сопоставления, мне это нравится:
CreateMap<CustomerDto, Customer>()
.ForMember(m => m.SomePersons, o =>
{
o.ResolveUsing((source, target, member, ctx) =>
{
return ctx.Mapper.ResolveCollection(
source.SomePersons,
target.SomePersons,
(targetCollection, sourceObject) => targetCollection.SingleOrDefault(t => t.Id == sourceObject.Id));
});
});
Это позволяет мне использовать его таким образом при сопоставлении:
_mapper.Map(customerDto, customer);
И резольвер заботится о картировании.
Ответы
Ответ 1
AutoMapper
- лучшее решение.
Вы можете сделать это очень легко, как это:
Mapper.CreateMap<Customer, CustomerDto>();
Mapper.CreateMap<CustomerDto, Customer>();
Mapper.CreateMap<Person, PersonDto>();
Mapper.CreateMap<PersonDto, Person>();
Примечание: Потому что AutoMapper
автоматически сопоставит List<Person>
с List<PersonDto>
. Так как у них есть same name
, и уже есть отображение с Person
на PersonDto
.
Если вам нужно знать, как внедрить его в ядро ASP.net, вы должны ознакомиться со следующей статьей: Интеграция AutoMapper с ASP.NET Core DI
Автоматическое сопоставление между DTO и сущностями
Отображение с использованием атрибутов и методов расширения
Ответ 2
Я очень долго боролся с той же проблемой. После копания многих статей я придумал свою собственную реализацию, которую я поделился с вами.
Прежде всего, я создал пользовательский IMemberValueResolver
.
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutoMapper
{
public class CollectionValueResolver<TDto, TItemDto, TModel, TItemModel> : IMemberValueResolver<TDto, TModel, IEnumerable<TItemDto>, IEnumerable<TItemModel>>
where TDto : class
where TModel : class
{
private readonly Func<TItemDto, TItemModel, bool> _keyMatch;
private readonly Func<TItemDto, bool> _saveOnlyIf;
public CollectionValueResolver(Func<TItemDto, TItemModel, bool> keyMatch, Func<TItemDto, bool> saveOnlyIf = null)
{
_keyMatch = keyMatch;
_saveOnlyIf = saveOnlyIf;
}
public IEnumerable<TItemModel> Resolve(TDto sourceDto, TModel destinationModel, IEnumerable<TItemDto> sourceDtos, IEnumerable<TItemModel> destinationModels, ResolutionContext context)
{
var mapper = context.Mapper;
var models = new List<TItemModel>();
foreach (var dto in sourceDtos)
{
if (_saveOnlyIf == null || _saveOnlyIf(dto))
{
var existingModel = destinationModels.SingleOrDefault(model => _keyMatch(dto, model));
if (EqualityComparer<TItemModel>.Default.Equals(existingModel, default(TItemModel)))
{
models.Add(mapper.Map<TItemModel>(dto));
}
else
{
mapper.Map(dto, existingModel);
models.Add(existingModel);
}
}
}
return models;
}
}
}
Затем я настраиваю AutoMapper и добавляю свое конкретное сопоставление:
cfg.CreateMap<TDto, TModel>()
.ForMember(dst => dst.DestinationCollection, opts =>
opts.ResolveUsing(new CollectionValueResolver<TDto, TItemDto, TModel, TItemModel>((src, dst) => src.Id == dst.SomeOtherId, src => !string.IsNullOrEmpty(src.ThisValueShouldntBeEmpty)), src => src.SourceCollection));
Эта реализация позволяет мне полностью настроить логику соответствия объектов из-за функции keyMatch
которая передается в конструкторе. Вы также можете передать дополнительную функцию saveOnlyIf
если по какой-то причине вам нужно проверить переданные объекты, если они подходят для сопоставления (в моем случае были некоторые объекты, которые не должны отображаться и добавляться в коллекцию, если они не передавали дополнительный Проверка).
Затем, например, в вашем контроллере, если вы хотите обновить отключенный график, вы должны сделать следующее:
var model = await Service.GetAsync(dto.Id); // obtain existing object from db
Mapper.Map(dto, model);
await Service.UpdateAsync(model);
Это работает для меня. Это зависит от вас, если эта реализация вам подходит лучше, чем автор этого вопроса предложил в своем отредактированном сообщении :)
Ответ 3
Сначала я бы порекомендовал использовать JsonPatchDocument для своего обновления:
[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] JsonPatchDocument<CustomerDTO> patchDocument)
{
var customer = context.EntityWithRelationships.SingleOrDefault(e => e.Id == id);
var dto = mapper.Map<CustomerDTO>(customer);
patchDocument.ApplyTo(dto);
var updated = mapper.Map(dto, customer);
context.Entry(entity).CurrentValues.SetValues(updated);
context.SaveChanges();
return NoContent();
}
И, наконец, вы должны воспользоваться AutoMapper.Collections.EFCore. Вот как я настроил AutoMapper в Startup.cs
с помощью метода расширения, так что я могу вызывать services.AddAutoMapper()
без полного кода конфигурации:
public static IServiceCollection AddAutoMapper(this IServiceCollection services)
{
var config = new MapperConfiguration(cfg =>
{
cfg.AddCollectionMappers();
cfg.UseEntityFrameworkCoreModel<MyContext>(services);
cfg.AddProfile(new YourProfile()); // <- you can do this however you like
});
IMapper mapper = config.CreateMapper();
return services.AddSingleton(mapper);
}
Вот как должен выглядеть YourProfile
:
public YourProfile()
{
CreateMap<Person, PersonDTO>(MemberList.Destination)
.EqualityComparison((p, dto) => p.Id == dto.Id)
.ReverseMap();
CreateMap<Customer, CustomerDTO>(MemberList.Destination)
.ReverseMap();
}
У меня есть похожий объект-граф, и это прекрасно работает для меня.
EDIT
Я использую LazyLoading, если вам не нужно явно загружать навигационные свойства/коллекции.