Как правильно использовать DTO в реальных проектах asp.net core WebAPI?
Доброго времени,
с принципами SOLID в общем, и использовании DI-контейнеров, слоев, разных архитектур все понято. Вопрос более сложный, про то, как это работает в больших крутых проектах, с поправкой, что речь именно о WebAPI на базе asp.net core. Думаю, что многим будет интересно прочитать ответы, кто какие подходы использует.
У меня по совокупности практики и теории сложилась такая картина (Business, Data - это папки, остальное проекты, все это в одном солюшене):
Data
|___Project.Data.Contracts
|___Project.Data.Impl
Business
|___Project.Services.Contracts
|___Project.Services.Impl
WebApi
Смысл в том, что все сервисы и репозитории регистрируются в DI, и могут быть использованы в любом месте.
Общая логика такова:
1. В контроллер прилетает запрос, код метода почти из одной строки: идет вызов метода из нужного сервиса. Еще можем тут же проверять авторизацию.
2. В сервисе вся логика, основной код, и там же дергаются репозитории.
Вот теперь самое главное. На простом примере, пусть сущность User.
Есть UserController в WebApi, в слое сервисов, допустим, только логика, объекта User нет. Он есть в слое DAL, причем в Project.Data.Contracts находится IUser, а в Project.Data.Impl - просто User. Понятно, что вот этот User содержит "database-specific" обвески, например, атрибуты EntityFramework. Мы не можем этот объект выпускать за пределы слоя данных, репозиторий отдает, конечно, его под видом IUser. Но что мы используем на уровне WebApi? Нужна ли в данном случае своего рода ViewModel?
Другими словами: нужен ли еще один класс, реализующий IUser, используемый как DTO в работе самого WebAPI?
Понятно, что он может и не наследовать IUser (просто конвертировать AutoMapper'ом). Мне видится, что он нужен (его будет получать или возвращать контроллер), но вот код дублируется. Получается 3 почти идентичных куска кода: IUser, User (DB), UserViewModel (WebApi).
Есть немного другой путь: не использовать EntityFramework (вполне реальные кейсы), взять, например, Dapper. Там в классе User не будет атрибутов, он будет чистым DTO. И теоретически, мог бы использоваться в WebAPI, но тогда придется сделать прямую ссылку на Project.Data.Impl. Точнее говоря, эта ссылка и так есть (нам же надо в Startup.cs зарегистрировать все репозитории, а DI из коробки asp.net core не поддерживает модули и их сканирование, в отличие от Ninject), но архитектурно это не правильно. Если только не вынести такие объекты в еще один проект.
Другими словами: нужен ли еще один класс, реализующий IUser, используемый как DTO в работе самого WebAPI?
В Вашей ситуации слой сервисов должен возвращать в WebApi DTO, но реализовывать его как IUser не стоит. Разделение системы на слои подразумевает что каждый слой может знать только о нижележащем слое (точнее о его интерфейсах). Если DTO, возвращаемое в WebAPI, будет реализовывать IUser относящийся к DAL произойдет нарушение порядка слоев, что как бы ни разу не тру.
Получается 3 почти идентичных куска кода: IUser, User (DB), UserViewModel (WebApi).
Вы этого никак не избежите, если хотите делить приложение на слои... сейчас у Вас структуры данных - идентичны, но со временем в системе может что-то поменять и они таковыми перестануть быть
Есть немного другой путь: не использовать EntityFramework (вполне реальные кейсы), взять, например, Dapper. Там в классе User не будет атрибутов, он будет чистым DTO. И теоретически, мог бы использоваться в WebAPI, но тогда придется сделать прямую ссылку на Project.Data.Impl.
Если User останеться в DAL - тогда проблема не решиться, у Вас по прежнему будет нарушение порядка слоев ...
Спасибо за ответ!
Хорошо, пусть будет так,
Project.Data.Contracts.IUser
Project.Data.Impl.User
WebApi.UserViewModel
Т.е. WebAPI-контроллер получает/отдает объекты UserViewModel (не уверен, кстати, в названии, ViewModel обычно для передачи в MVC View используются, а тут именно REST API).
Далее, мы из контроллера дергаем слой сервисов, который уже работает исключительно с IUser, поэтому либо придется перед этим прямо в контроллере сделать маппинг (через AutoMapper), либо передать этот UserViewModel, для чего сделать его наследником IUser. Это первое место, которое меня смущает. Маппинг может работать медленно, ну и не хочется в каждый метод контроллера тащить код маппинга. Может быть лучше все-таки унаследоваться от IUser, но это архитектурно не очень: прямая зависимость Presentation слоя от слоя Data (в обход сервисов). Хотя может это и норм.
Есть еще второй момент. В Project.Data.Impl есть database-specific класс User и репозиторий (к нему, или вообще на все объекты). Поскольку репозиторий реализует интерфейс из Project.Data.Contracts, он, естественно, принимает на вход IUser. Т.е. внутри метода репозитория, например, Create, нам приходит IUser, и нам надо перейти от него к User. Опять же, либо маппинг, либо через Reflection пройти по типам проекта и найти IsAssignableFrom. Но вот в моем случае с Dapper все еще печальнее: там не подсунуть Type, там именно generic, т.е. что-то типа db.Query($"SELECT * FROM {nameof(T)}").ToList();
где T - должен быть User. Там есть вторая форма без , работающая через dynamic, но боюсь это плохо скажется на производительности (да и типобезопасность страдает). Есть у меня одна идея, но у нее тоже имеется минус. Сейчас попробую кратко рассказать.
Репозиторий я сделал не generic-классом, а обычным классом с generic-методами. Знаю, так редко делают, но я вижу это удобным. Например, в каком-то сервисе, мне надо записать объекты двух разных типов. Сейчас я просто через constructor-injection получаю IRepository и его методы могу использовать для разных типов (repository.Get(), repository.Get()). А если параметризовать сам репозиторий, тогда придется заводить в сервисе два разных репозитория, для двух типов. А если типов 10? Неудобно. Но, зато, если сделать generic-class, тогда вопрос снимается, можно через DI-контейнер зарегистрировать нужную реализацию.
TimeCoder: Вы не много не правильно поняли.... У Вас есть IUser который принадлежит слою данных, у Вас есть скажем UserDTO который принадлежит слою сервисов. Слой API получает/передает DTO в слой сервисов, слой сервисов в свою очередь производит маппинг IUser и наоборот. Естественно что это создаст некоторый оверхед(хотя думаю не сильно большой), но по другому независимости слоев не добиться...