Постановка задачи
В этой статье я опишу механизм создания DTO, реализованный в одном из проектов нашей компании. Проект состоит из серверной части и нескольких клиентов (Silverlight, Outlook, iPad). Сервер представляет собой ряд сервисов, реализованных на WCF. Раз есть сервисы, то надо обмениваться с ними какими-то данными. Вариант, когда клиенты знают о сущностях доменной области и получают их с сервера, отпал сразу по ряду причин:
- Не все клиенты реализованы на .NET
- Возможные проблемы сериализации сложных графов объектов
- Избыточность передаваемых данных
В принципе, все эти недостатки давно известны и для их устранения умные люди придумали паттерн Data Transfer Object (DTO). То есть, классы сущностей доменной области известны только серверу, клиенты же оперируют классами DTO и экземплярами этих же классов обмениваются с сервисами. В теории все прекрасно, на практике же среди прочих возникают вопросы создания DTO и записи в них данных из сущностей. В небольших проектах с этой работой отлично справится оператор "=". Но, когда размер проекта начинает расти и повышаются требования к производительности и сопровождаемости кода, возникает необходимость в более гибком решении. Ниже я опишу эволюцию механизма, который мы используем для создания и заполнения DTO.
Доменная модель примера
Для более наглядной иллюстрации определим тестовую доменную модель. Предположим, что наше приложение ведет учет метеоритов. Имеем следующие основные типы:
/// <summary>
/// Базовый класс всех сущностей.
/// </summary>
public class Entity
{
/// <summary>
/// Идентификатор.
/// </summary>
public Guid Id { get; set; }
}
/// <summary>
/// Базовый класс метеорита.
/// </summary>
public abstract class Meteor: Entity
{
/// <summary>
/// Название.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Вес.
/// </summary>
public double Weight { get; set; }
/// <summary>
/// Из какого материала состоит метеорит.
/// </summary>
public Material Material { get; set; }
/// <summary>
/// Расстояние до Земли.
/// </summary>
public double DistanceToEarth { get; set; }
/// <summary>
/// Уровень опасности метеорита.
/// </summary>
public RiskLevel RiskLevel { get; set; }
}
/// <summary>
/// Космический метеорит.
/// </summary>
public class SpaceMeteor: Meteor
{
/// <summary>
/// Дата/время обнаружения.
/// </summary>
public DateTime DetectedAt { get; set; }
/// <summary>
/// Обнаруживший человек.
/// </summary>
public Person DetectingPerson { get; set; }
/// <summary>
/// Галактика, из которой прилетел.
/// </summary>
public Galaxy PlaceOfOrigin { get; set; }
}
/// <summary>
/// Метеорит неприродного происхождения.
/// </summary>
public class ArtificialMeteor: Meteor
{
/// <summary>
/// Страна-изготовитель.
/// </summary>
public Country Country { get; set; }
/// <summary>
/// Завод-изготовитель.
/// </summary>
public SecretFactory Maker { get; set; }
/// <summary>
/// Заводской номер.
/// </summary>
public string SerialNumber { get; set; }
/// <summary>
/// Контролер ОТК.
/// </summary>
public Person QualityEngineer { get; set; }
}
Для соответствующих доменных классов создаем классы DTO. Следует отметить, что в нашем проекте мы используем полностью плоские DTO.
/// <summary>
/// Базовый класс всех DTO.
/// </summary>
public class BaseDto
{
public Guid Id { get; set; }
}
/// <summary>
/// Базовый класс DTO метеорита.
/// </summary>
public abstract class MeteorDto: BaseDto
{
public string Name { get; set; }
public double Weight { get; set; }
public string MaterialName { get; set; }
public Guid? MaterialId { get; set; }
public double DistanceToEarth { get; set; }
public string RiskLevelName { get; set; }
public Guid RiskLevelId { get; set; }
}
/// <summary>
/// DTO космического метеорита.
/// </summary>
public class SpaceMeteorDto: MeteorDto
{
public DateTime DetectedAt { get; set; }
public string DetectingPersonName { get; set; }
public Guid DetectingPersonId { get; set; }
public string PlaceOfOriginName { get; set; }
public Guid? PlaceOfOriginId { get; set; }
}
/// <summary>
/// DTO метеорита неприродного происхождения.
/// </summary>
public class ArtificialMeteorDto: MeteorDto
{
public string CountryName { get; set; }
public Guid CountryId { get; set; }
public string MakerName { get; set; }
public string MakerAddress { get; set; }
public string MakerDirectorName { get; set; }
public Guid MakerId { get; set; }
public string SerialNumber { get; set; }
public string QualityEngineerName { get; set; }
public Guid QualityEngineerId { get; set; }
}
Механизм №1 — сущность из БД, затем DTO из сущности
Первый подход к проблеме вылился в создании интерфейса
interface IDtoMapper<TEntity, TDto>
{
IEnumerable<TDto> Map(IEnumerable<TEntity> entities);
}
Для каждой пары сущность-DTO создавался класс, реализующий интерфейс, закрытый по соответствующим типам. Маппинг был реализован через Automapper, который позволил избавиться от рутинного присваивания свойств и явно описывать только те случаи, когда именование свойств не попадало под соглашение, используемое Automapper. Как это работало:
- Клиент вызывает операцию WCF для получения набора данных
- Сущности по определенным критериям загружаются из БД и передаются в IDtoMapper
- IDtoMapper создает экземпляры DTO и копирует данные из сущностей в DTO.
- Клиенту возвращается коллекция DTO.
Этот механизм работал и нас вполне устраивал до тех пор, пока с ростом нагрузки не появились проблемы с производительностью. Замеры показали, что одним из виновников был старина IDtoMapper. (Не будем его сильно ругать, так как он в лучших традициях Agile помог нам быстро выпустить продукт, лучше понять его в процессе работы и затем переписывать отдельные части с учетом полученного опыта.) Проблема заключалась в том, что из БД извлекалось больше данных, чем было необходимо для заполнения DTO. Ассоциации и агрегации между объектами вели к большому числу join'ов, что негативно сказывалось на скорости работы системы.
Мы рассматривали 2 варианта решения возникшей проблемы: поколдовать со стратегиями извлечения данных (lazy или eager loading и т.п.), либо напрямую извлекать DTO из базы данных. Был выбран второй путь, как наиболее простой, производительный и гибкий. Тут следует отметить, что в качестве ORM мы используем NHibernate, запросы к БД производятся посредством LINQ. Все нижеописанное будет также работать в Entity Framework.
Механизм №2 — DTO из БД
Был создан следующий интерфейс:
interface IDtoFetcher<TEntity, TDto>
{
IEnumerable<TDto> Fetch(IQueryable<TEntity> query, Paging paging, FetchAim fetchAim);
}
Теперь метод принимает 3 параметра вместо одного:
- query — LINQ-запрос по типу сущности
- paging — информация об извлекаемой странице
- fetchAim — цель извлечения
Так как механизм маппинга DTO полностью переписывался, было решено проводить оптимизацию сразу по нескольким направлениям. Одним из них явилось понятие цели извлечения. Для разных форм на клиенте у DTO должны быть установлены различные свойства. Например, для выбора из выпадающего списка необходимо иметь только наименование и идентификатор. Для отображения в реестре должны быть установлены текстовые свойства. Для карточки необходим другой набор данных. Поэтому метод Fetch принимает параметр, по которому определяется цель извлечения DTO и, соответственно, из БД загружаются только необходимые поля.
/// <summary>
/// Цель извлечения DTO.
/// </summary>
public enum FetchAim
{
/// <summary>
/// Значение по умолчанию
/// </summary>
None,
/// <summary>
/// Карточка
/// </summary>
Card,
/// <summary>
/// Список
/// </summary>
List,
/// <summary>
/// Индекс
/// </summary>
Index
}
Абстракцию определили, настала очередь ее реализации. Для выборочной выборки полей в SQL используется проекция (projection), например:
SELECT (id, name) FROM meteors
В LINQ проекции реализуются с помощью метода Select(). SQL-запрос, приведенный выше, будет сгенерирован при выполнении следующего LINQ-запроса:
IQueryable<Meteor> meteorQuery = _meteorRepository.Query();
IEnumerable<MeteorDto> meteors = meteorQuery
.Select(m =>
new MeteorDto
{
Id = m.Id,
Name = m.Name
})
.ToList();
Узнав об этой способности LINQ, мы принялись усердно создавать конкретные реализации IDtoFetcher:
class SpaceMeteorDtoFetcher: IDtoFetcher<SpaceMeteor, SpaceMeteorDto>
{
public IEnumerable<SpaceMeteorDto> Fetch(IQueryable<SpaceMeteor> query, Page page, FetchAim fetchAim)
{
if (fetchAim == FetchAim.Index)
{
return query
.Select(m =>
new SpaceMeteorDto
{
Id = m.Id,
Name = m.Name
})
.Page(page)
.ToList();
}
else if (fetchAim == FetchAim.List)
{
// ...
}
// ...
}
}
Но после второго класса произошел внезапный приступ лени (и осознание того, что данный подход приведет к масштабному дублированию кода и значительным трудностям при дальнейшем сопровождении системы и добавлении новых сущностей). К примеру, при маппинге наследников базового класса во всех них придется повторять строки с инициализацией общих свойств. Также дублирование будет происходить при маппинге одной сущности для различных целей извлечения. И тут в голове непроизвольно возникли простые русские слова: expression trees…
Так как LINQ-запрос представляет собой дерево выражений, которое потом парсится и генерируется SQL-запрос, было решено создать декларативный механизм, позволяющий описывать маппинг свойств сущностей на свойства DTO и строящий по этой информации необходимый LINQ-запрос.
Реализация
Исходный код проекта с реализацией (.NET 4.0, NHibernate 3.3.2, Visual Studio 2012) находится здесь.
Для понимания того, зачем пришлось вступать в неравную борьбу с деревьями выражений, приведу пример того, как теперь осуществляется конфигурирование фетчера для конкретного класса.
/// <summary>
/// Фетчер DTO космических метеоритов.
/// </summary>
public class SpaceMeteorDtoFetcher: BaseMeteorDtoFetcher<SpaceMeteor, SpaceMeteorDto>
{
static SpaceMeteorDtoFetcher()
{
CreateMapForIndex();
CreateMapForList();
CreateMapForCard();
}
private static void CreateMapForIndex()
{
var map = CreateFetchMap(FetchAim.Index);
// Определен в базовом классе фетчера метеорита
MapBaseForIndex(map);
}
private static void CreateMapForList()
{
var map = CreateFetchMap(FetchAim.List);
// Определен в базовом классе фетчера метеорита
MapBaseForList(map);
MapSpecificForList(map);
}
/// <summary>
/// Мапит специфические свойства космического метеорита для списка.
/// </summary>
/// <param name="map">Объект маппинга для списка</param>
private static void MapSpecificForList(IFetchMap<SpaceMeteor, SpaceMeteorDto> map)
{
map.Map(d => d.DetectedAt, e => e.DetectedAt)
.Map(d => d.DetectingPersonName, e => e.DetectingPerson.FullName)
.Map(d => d.PlaceOfOriginName, e => e.PlaceOfOrigin.Name);
}
private static void CreateMapForCard()
{
var map = CreateFetchMap(FetchAim.Card);
MapBaseForCard(map);
MapSpecificForCard(map);
}
/// <summary>
/// Мапит специфические свойства космического метеорита для карточки.
/// </summary>
/// <param name="map">Объект маппинга для карточки</param>
private static void MapSpecificForCard(IFetchMap<SpaceMeteor, SpaceMeteorDto> map)
{
map.Map(d => d.DetectedAt, e => e.DetectedAt)
.Map(d => d.DetectingPersonId, e => e.DetectingPerson.Id)
.Map(d => d.PlaceOfOriginId, e => e.PlaceOfOrigin.Id);
}
public SpaceMeteorDtoFetcher(IRepository repository) : base(repository)
{
}
}
Для конфигурирования фетчера используется абстракция маппинга
public interface IFetchMap<TSource, TTarget>
where TSource : Entity
where TTarget : BaseDto
{
/// <summary>
/// Используется для задания соответствия между свойством сущности и свойством DTO.
/// </summary>
IFetchMap<TSource, TTarget> Map<TProperty>(
Expression<Func<TTarget, TProperty>> targetProperty,
Expression<Func<TSource, TProperty>> sourceProperty);
/// <summary>
/// Используется для задания дополнительной логики маппинга DTO, когда нельзя обойтись простым маппингом свойств.
/// </summary>
IFetchMap<TSource, TTarget> CustomMap(Action<IQueryable<TSource>, IEnumerable<TTarget>> fetchOperation);
}
Как происходит конфигурация: создаем объект маппинга для конкретной цели извлечения, вызываем методы Map для указания того, какие свойства необходимо загружать из БД. Метод CustomMap используется как точка расширения — в делегате, передаваемом туда, мы можем прописать логику ручной загрузки данных из БД и записи их в извлеченные DTO.
Базовый класс фетчера DTO метеоритов BaseMeteorDtoFetcher предоставляет методы для маппинга свойств базового класса — таким образом мы избегаем дублирования и ускоряем создание фетчеров для новых типов метеоритов. Сам BaseMeteorDtoFetcher, в свою очередь, наследуется от BaseDtoFetcher, который хранит коллекцию созданных объектов типа IFetchMap и использует их для извлечения DTO.
Добавилась новая абстракция и, по сложившейся традиции, нам нужна ее реализация. (На самом деле, в жизни все было наоборот — сперва появился конкретный класс, а затем из него был извлечен интерфейс.) Реализация представлена классом FetchMap. Именно в нем находится вся логика работы с деревьями выражений. Статья получается довольно большой, поэтому здесь я не буду пошагово разбирать реализацию FetchMap. Ее можно посмотреть в прилагаемом проекте. Для понимания требуется иметь некоторое представление о деревьях выражений.
Заключение
Таким образом, на сегодняшний момент мы имеем механизм, позволяющий оптимальным извлекать DTO из БД и обладающий декларативным синтаксисом настройки маппингов, позволяющим упростить их создание. Он устраивает нас по скорости работы и простоте расширения для новых сущностей и DTO.
Надеюсь, описанный выше опыт позволит некоторым читателям обойти те грабли, которые мы благополучно собрали во время реализации проекта. А если кто-то знает, как все это можно реализовать проще и гибче, буду рад об этом услышать. Благодарю за внимание!
Автор: mitro