среда, 18 июля 2012 г.

Совместное использование Domain Model и DTO

Признаться, при разработке мне очень импонирует концепция DDD (предметно-ориентированное проектирование). Не всегда, правда, мне удается в полную меру использовать потенциал и принципы DDD, но некоторыми фичами пользуюсь достаточно активно. Самая распространенная из них - Domain Model - сущности, соответствующие объектам предметной области. К сожалению, иногда вижу, что у разработчиков отсутствует некая философия или определенная концепция при работе с доменными объектами. В частности, присутствует неразбериха при передачи данных в разные части системы. Именно на границе домена и внешнего мира, на мой взгляд, и начинается активное взаимодействие Domain Model и DTO.

В том виде, в котором описан DTO у Фаулера, DTO призван минимизировать количество сетевых вызовов при работе с удаленным интерфейсом за счет того, что в за один запрос в объекте передаются все необходимые данные. Но, как это обычно бывает с шаблонами,  такой подход можно распространить и на другие случаи.

В общем виде схему взаимодействия между доменом и внешними системами можно представить в следующем виде:

 

"Внешними пользователями" в разном масштабе могут различными: внешним миром по отношению к домену можно считать и смежные слои архитектуры (например, слой представления (Views) является внешним по отношению к домену), и вообще другие сервисы.

Итак, если вкратце, то Domain Model - это модель предметной области, объединяющая данные и поведение, а DTO - объект передачи данных. Если Domain Model содержит в себе бизнес-логику, то DTO - только данные, причем такие объекты могут не иметь соответствия в предметной области.

Разделение Domain Model и DTO

Для чего может потребоваться такое разделение? Этому есть несколько причин и объяснений.

  1. Минимизация объема передаваемых данных.  В этом случае происходит огрубление интерфейса модели за счет того, что передаются только необходимые данные.

    Рассмотрим небольшой пример:

    public class User
    {
        public Guid Id { get; set; }
        public string Login { get; set; }
        public string Email { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public DateTime RegistationDate { get; set; }
        public DateTime LastActiveDate { get; set; }
        public Role Role { get; set; }
        public bool IsActive { get; set; }
        public string Skype { get; set; }
        public string Icq { get; set; }
    }
    public class UserModel
    {
        public string Login { get; set; }
        public string Email { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }
    

    Сущность User содержит довольно много свойств. Будем считать, что они используются внутри домена, но для внешнего использования необходимы только некоторые данные. Чтобы не передавать "лишнего" создаем объект UserModel, который содержит только нужную информацию.

  2. Нежелательное множественное обращение к ресурсу. Особенно это актуально при сервис-ориентированном подходе (SOA) или при использовании веб-сервисов. Если не позаботиться об уменьшении сетевых вызовов между сервисами, то при высоких нагрузках ненароком можно санкционировать DDoS-атаку в своих же сервисах.

    Рассмотрим гипотетический случай, когда на каком-нибудь сервисе мы можем получить не только информацию о пользователе, но и некоторую статистику его активности:

    public class User
    {
        public string Login { get; set; }
        public string Email { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public DateTime RegistationDate { get; set; }
        public DateTime LastActiveDate { get; set; }
    }
    public class UserStatistics
    {
        public User User { get; set; }
        public int CommentCount { get; set; }
        public int TopicCount { get; set; }
        public int Karma { get; set; }
    }
    

    Вместо двух сетевых вызовов, можно одним запросом получить такую модель:

    public class UserStatisticsModel
    {
        public string Login { get; set; }
        public string Email { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public int CommentCount { get; set; }
        public int TopicCount { get; set; }
        public int Karma { get; set; }
    }
    
  3. Инкапсуляция бизнес-логики. На мой взгляд, это одна из самых важных причин. Можно создать модель таким образом, что данные, которые в ней содержатся, будут готовы к употреблению без дополнительных телодвижений.

    Рассмотрим пример. Возьмем бизнес-модель счета, который содержит позиции.

    public class Account
    {
        public Guid Id { get; set; }
        public string Number { get; set; }
        public DateTime Date { get; set; }
        public IEnumerable<Position> Positions { get; set; }
    
        public decimal GetAmount()
        {
            return Positions.Sum(x => x.GetPositionAmount());
        }
    
        public decimal GetValue()
        {
            return Positions.Sum(x => x.GetPositionVat());
        }
    }
    
    public class Position
    {
        public string Title { get; set; }
        public int Count { get; set; }
        public decimal Price { get; set; }
        public decimal Vat { get; set; }
    
        public decimal GetPositionAmount()
        {
            return Count*Price;
        }
    
        public decimal GetPositionVat()
        {
            return Count*Vat;
        }
    }
    

    Из кода видно, что для вычисления суммы счета и значения НДС есть специальные методы, которые инкапсулируют в себе определенную бизнес-логику. Значения суммы и НДС по каждой позиции также вычисляются с помощью соответствующих методов.

    Теперь представим, что какому-то сервису потребовалось отобразить счет на странице пользователя или, скажем, отправить на печать. В этих случаях можно использовать следующие модели:

    public class AccountModel
    {
        public string Number { get; set; }
        public DateTime Date { get; set; }
        public decimal Amount { get; set; }
        public decimal VatValue { get; set; }
        public IEnumerable<PositionModel> Positions { get; set; }
    }
    
    public class PositionModel
    {
        public string Title { get; set; }
        public int Count { get; set; }
        public decimal Price { get; set; }
        public decimal Vat { get; set; }
        public decimal PositionAmount { get; set; }
        public decimal PositionVat { get; set; }
    }
    

    Чего мы добились? При таком подходе мы оставляем бизнес-логику вблизи доменной модели и не размазываем ее по разным частям системы - все потенциальные клиенты AccountModel потребляют лишь "разжеванные" данные, которые, например, могут просто подставить в шаблон страницы или отправить на печать. В случае изменения бизнес-правил расчета суммы счета или НДС, это никак не отразится на пользователях нашей модели.

Кстати, удобно придерживаться какого-то единого правила именования DTO-объектов. Я лично предпочитаю использовать суффикс Model. Иногда вижу вариант с суффиксом Dto. Единство наименования таких моделей позволяет быстро понять, что перед тобой находится в коде - Domain Model или DTO.

Построение DTO

Еще одна удобная практика, которой я придерживаюсь, - использование ModelBuilder'ов для построения модели. Типичный интерфейс такого "построителя" модели выглядит так:

public interface IAnyModelBuilder
{
    AnyModel Build(int anyParameter1, ...);
}

С помощью IoC можно внедрить в реализацию AnyModelBuilder'а все необходимые зависимости, например, репозитории, сервисы и т. д. Думаю, что выгоды такого решения очевидны: возможность повторного использования, сокрытие деталей реализации и прочее.

Вот как мог бы выглядеть интерфейс и реализация билдера для AccountModel:

public interface IAccountModelBuilder
{
    AccountModel Build(Guid accountId);
}

public class AccountModelBuilder : IAccountModelBuilder
{
    private readonly IAccountRepository _accountRepository;

    public AccountModelBuilder(IAccountRepository accountRepository)
    {
        _accountRepository = accountRepository;
    }

    public AccountModel Build(Guid accountId)
    {
        Account account = _accountRepository.Get(accountId);
        return new AccountModel
                    {
                        Number = account.Number,
                        Date = account.Date,
                        Amount = account.GetAmount(),
                        VatValue = account.GetVatValue(),
                        Positions = account
                            .Positions
                            .Select(x => new PositionModel
                                            {
                                                Title = x.Title,
                                                Price = x.Price,
                                                Count = x.Count,
                                                Vat = x.Vat,
                                                PositionAmount = x.GetPositionAmount(),
                                                PositionVat = x.GetPositionVat()
                                            })
                            .ToArray()
                    };
    }
}
Редактирование данных с помощью DTO

Пойдем дальше. Наряду с получением данных, может возникнуть задача создания или обновления информации. В таких случаях я также предпочитаю использовать DTO-объекты. Для удобства можно присвоить им другой суффикс. Зачастую интерфейс таких объектов можно огрубить: отбросить всяческие служебные данные и данные, которые легко получить прямо из домена. Например, для регистрации пользователя, доменная модель которого представлена выше, можно использовать такую модель:

public class UserCreateModel
{
    public string Login { get; set; }
    public string Email { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Skype { get; set; }
    public string Icq { get; set; }
}

Подобно билдеру, для сохранения данных можно создать свой обработчик:

public interface IAnyCreateModelHandler
{
    void Execute(AnyCreateModel model);
}

Опять же для нашего случая обработчик мог бы выглядеть примерно так:

public interface IUserCreateModelHandler
{
    void Execute(UserCreateModel model);
}

public class UserCreateModelHandler : IUserCreateModelHandler
{
    private readonly IUserRepository _userRepository;

    public UserCreateModelHanlder(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public void Execute(UserCreateModel model)
    {
        var user = new User
                        {
                            Id = Guid.NewGuid(),
                            Email = model.Email,
                            FirstName = model.FirstName,
                            // ...
                            RegistationDate = DateTime.Now
                        };
        _userRepository.Save(user);
    }
}
Достоинства

Как уже отмечалось, очевидный плюс совместного использования Domain Model и DTO является сокращение числа вызовов к домену и уменьшение объема передаваемых данных.

Также, можно заметить, что при описанном подходе домен отделен от других слоев и сервисов системы. Доменные объекты используются только в точках порождения и изменения данных. В итоге, все общение с доменом централизовано, что облегчает поддержку такой системы.

Такое решение позволяет поддерживать независимость разных частей системы. Особенно это актуально для распределенных систем.

Предложенные решения в виде Builder'ов и Handler'ов на практике оказываются очень простыми, понятными, юзабельными и реюзабельными и легкотестируемыми.

Недостатки

Наряду с многими достоинствами подхода имеется недостаток, который сразу же бросается в глаза: многократное увеличение количества классов в системе. Поэтому чтобы не запутаться, лучше заранее продумать, как их можно структурировать.

Комментариев нет:

Отправить комментарий