Разработка механизма извлечения DTO из БД с помощью LINQ

Постановка задачи

В этой статье я опишу механизм создания DTO, реализованный в одном из проектов нашей компании. Проект состоит из серверной части и нескольких клиентов (Silverlight, Outlook, iPad). Сервер представляет собой ряд сервисов, реализованных на WCF. Раз есть сервисы, то надо обмениваться с ними какими-то данными. Вариант, когда клиенты знают о сущностях доменной области и получают их с сервера, отпал сразу по ряду причин:

  1. Не все клиенты реализованы на .NET
  2. Возможные проблемы сериализации сложных графов объектов
  3. Избыточность передаваемых данных

В принципе, все эти недостатки давно известны и для их устранения умные люди придумали паттерн 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. Как это работало:
  1. Клиент вызывает операцию WCF для получения набора данных
  2. Сущности по определенным критериям загружаются из БД и передаются в IDtoMapper
  3. IDtoMapper создает экземпляры DTO и копирует данные из сущностей в DTO.
  4. Клиенту возвращается коллекция 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 параметра вместо одного:
  1. query — LINQ-запрос по типу сущности
  2. paging — информация об извлекаемой странице
  3. 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.

Надеюсь, описанный выше опыт позволит некоторым читателям обойти те грабли, которые мы благополучно собрали во время реализации проекта. А если кто-то знает, как все это можно реализовать проще и гибче, буду рад об этом услышать. Благодарю за внимание!
Поделиться публикацией

Комментарии 12

    0
    Чем выше абстракция — тем ниже производительность. Вы в своем решении судя по всему тупо выкинули Domain layer.
      0
      В последнем решении генерируются наиболее оптимальные SQL-запросы, поэтому производительность БД, наоборот, возрастает. Что касается производительности кода, то она такая же, как и при ручном написании LINQ-запроса, ведь дерево строится один раз в статическом конструкторе. И там, где скорости LINQ хватает, механизм можно смело использовать. По поводу Domain Layer немного не понял, что имеется в виду. Доменная модель присутствует. В статье описывается сценарий, когда надо просто получить данные и отдать их клиенту. Для выполнения бизнес-логики, конечно, из БД загружаются сущности и с ними производятся определённые операции.
      0
      Вы серьезно используете этот код когда есть WCF Data Services и WebAPI?
        0
        Безотносительно содержимого поста — вы пробовали взлететь с WCF и Web API?
          0
          Да, очень успешно. Был SL клиент, на нем модель, сгенерированная по WCF Data Services + немного JS, дергающих те же сервисы.

          А недавно коллегами было сделано iOS приложение, которое работает с WebAPI и похожее, которое работает с OData.
            0
            Если не секрет — сколько DTO, сколько методов у сервисов? какой форматер? сериализацию/десериализацию не профилировали? сколько пользователей держит?

            мы после полугода поедания кактусов переехали на protobuf — существенно быстрее стало. существенно.
          0
          Мне не доводилось использовать WCF Data Services в реальных проектах. Хочу воспользоваться моментом и спросить Вас, как человека, имеющего опыт работы с ними. С помощью них можно реализовать мультитенантность? И как проводятся проверки безопасности? К примеру, проверка прав пользователя и/или извлечение только тех сущностей, на которые у пользователя имеются ACL. Сейчас мы на сервере добавляем критерии в LINQ-запрос, который в конце отдаем в фетчер. Причем в зависимости от роли текущего пользователя мы определяем, надо ли проверять ACL или нет. И по разным типам запросов над одним типом данных такая логика может разнится.
            0
            На WCF Data Services можно вставлять Interceptor's которые в зависимости от контекста могут фильтравать результаты.
              0
              Так и надо делать, а в чем проблема?
                0
                Мы все это делаем на сервере. Как я понял, фишка WCF Data Services в том, что запросы пишутся на стороне клиента. Проверка безопасности должна производиться на сервере. Из предыдущего комментария я понял, что для этого надо использовать Interceptors.
              0
              По поводу описанного кода — да, мы его используем. И он нас вполне устраивает, проект живет и быстро развивается. Вся логика запросов у нас находится на сервере и фетчеры являются одним из этапов (по факту, декларативно сконфигурированная проекция), которые проходит LINQ-запрос перед тем, как быть выполненным в БД. Перед этим он проходит через различные механизмы, навешивающим на него критерии поиска, безопасности и т.п. Плюс в проекте реализована мультитенантность и она хорошо легла в общую архитектуру. Data Services позволяют писать запросы на клиенте и еще возможно различные плюшки, о которых я пока не знаю, но это не значит, что теперь единственным правильным решением является их использование. Возможно, это действительно панацея (что редко бывает, так везде есть свои плюсы и минусы и разные задачи требуют разного подхода). В моих ближайших планах теперь хорошенько изучить WCF Data Services и составить свое мнение о них.
              0
              WCF DataServices работает с контекстом EF. Так что все равно сколько, хоть 100 будет. Форматтеры как обычно: atompub и json, встренные, вроде может можно поменять. Пользователей десятки-сотни были, полет нормальный. На фоне IO базы и передачи данных по сети сериализация\десерилизация не видна.

              В последней версии Odata научили передавать проекции, так что сериализатор теперь никакой роли не играет по сути, важнее правильные запросы делать.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое