Обратная связь

Для меня очень важно получать обратную связь от Bас. Пишите мне, если есть вопросы, интересные темы для обсуждения или по любым другим поводам. Мой  почтовый ящик и страница на сайте Мой Круг. Просьба, обращайтесь ко мне на ты, так удобнее :)

суббота, 12 декабря 2009 г.

Принцип инверсии зависимости

Формулировка:

  • Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракции.
  • Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Сейчас мы создадим и отрефакторим приложение. Будем двигаться по шагам и я покажу применение принципа инверсии зависимостей в действии.

Шаг 1. Сильная связанность

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

   1:  public class Program 
   2:  {
   3:      public static void Main()
   4:      {
   5:          var reporter = new Reporter();
   6:          reporter.SendReports();
   7:      } 
   8:  }  

Главный объект в нашей бизнес-логике – Reporter.

   1:  public class Reporter 
   2:  {
   3:       public void SendReports()
   4:       {
   5:          var reportBuilder = new ReportBuilder();
   6:          IList<Report> reports = reportBuilder.CreateReports();
   7:   
   8:          if (reports.Count == 0)
   9:              throw new NoReportsException();
  10:   
  11:          var reportSender = new EmailReportSender();
  12:          foreach (Report report in reports)
  13:          {
  14:              reportSender.Send(report);
  15:          }
  16:      }
  17:  }

Устроен Reporter очень просто. Он просит ReportBuilder создать список отчетов, а потом один за другим отсылает их с помощью объекта EmailReportSender.

Есть ли в этом коде проблемы? В подавляющем большинстве случаев зависит от того, кто и как этот код будет использовать, как часто он будет меняться и т.д. Но есть проблемы, которые очевидны уже сейчас.

Тестируемость

Как протестировать функцию SendReports? Давайте проверим поведение функции, когда ReportBuilder не создал ни одного отчета. В этом случае она должна создать исключение NoReportsException:

   1:  public class ReporterTests 
   2:  {
   3:       [Fact]
   4:       public void IfNotReportsThenThrowException()
   5:       {
   6:           var reporter = new Reporter();
   7:           reporter.SendReports();
   8:           // ???
   9:       }
  10:  }

Как в этом случае задать поведение объектов, которые использует Reporter? Мы же должны «сказать» ReportBuilder'у вернуть пустой список, и тогда функция SendReports выбросит исключение. Но в текущей реализации Reporter'а сделать мы этого не можем. Получается, мы не можем задать такие входные данные, при которых SendReports выкинет исключение. Значит в данной реализации объект Reporter очень плохо поддается тестированию.

Связанность

Дело в том, что функция SendReports, кроме своей прямой обязанности, слишком много знает и умеет:

  • знает, что именно ReportBuilder будет создавать отчеты
  • знает, что все отчеты надо отсылать через email с помощью EmailReportSender
  • умеет создавать объект ReportBuilder
  • умеет создавать объект EmailReportSender

Здесь нарушается принцип единственности ответственности. Проблема заключается в том, что в данный момент внутри функции SendReports объект ReportBuilder создается оператором new. А если у него появятся обязательные параметры в конструкторе? Нам придется менять код в классе Reporter да и во всех других классах, которые использовали оператор new для ReportBuilder'а.

К тому же, первые пункты нарушают принцип открытости/закрытости. Дело в том, что если мы захотим с помощью нашей утилиты отсылать сообщения через SMS, то придется изменять код класса Reporter. Вместо EmailReportSender мы должны будем написать SmsReportSender. Еще сложнее ситуация, когда одна часть пользователей класса Reporter захочет отправлять сообщения через emal, а вторая через SMS.

Обратите внимание, что наш объект Reporter зависит не от абстракций, а от конкретных объектов ReportBuilder и EmailReportSender. Можно сказать, что он "сцеплен" с этими классами. Это и объясняет его хрупкость при изменениях в системе. Может оказаться, что Reporter жестко зависит от двух классов, эти два класса зависят еще от 4х других. Получится, что вся система – это клубок из стальных ниток, который нельзя ни изменить, ни протестировать. Этот пример наглядно показывает нарушение принципа инверсии зависимостей.

Шаг 2. Применяем принцип инверсии зависимостей

Сейчас несколькими простыми действиями мы решим наши проблемы с Reporter'ом.

Для начала вынесем интерфейсы IReportSender из EmailReportSender и IReportBuilder из ReportBuilder.

   1:  public interface IReportBuilder 
   2:  {
   3:      IList<Report> CreateReports();
   4:  }
   5:   
   6:  public interface IReportSender 
   7:  {
   8:      void Send(Report report);
   9:  }

Теперь вместо того, чтобы создавать объекты в функции SendReports, мы передами их объекту Reporter в конструктор:

   1:  public class Reporter : IReporter 
   2:  {
   3:       private readonly IReportBuilder reportBuilder;
   4:       private readonly IReportSender reportSender;
   5:   
   6:       public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
   7:       {
   8:           this.reportBuilder = reportBuilder;
   9:           this.reportSender = reportSender;
  10:       }
  11:   
  12:       public void SendReports()
  13:       {
  14:          IList<Report> reports = reportBuilder.CreateReports();
  15:   
  16:          if (reports.Count == 0)
  17:              throw new NoReportsException();
  18:   
  19:          foreach (Report report in reports)
  20:          {
  21:              reportSender.Send(report);
  22:          }
  23:      }
  24:  }

Во время создания объекта Reporter в самом начале программы мы будем задавать конкретные IReportBuilder и IReportSender и передавать их в конструктор:

   1:  public static void Main()
   2:  {
   3:       var builder = new ReportBuilder();
   4:       var sender = new SmsReportSender();
   5:       var reporter = new Reporter(builder, sender);
   6:   
   7:       reporter.SendReports();
   8:  }

Посмотрим, какие проблемы мы смогли решить.

Тестируемость

Теперь у нас есть возможность передавать в конструктор Reporter'а объекты, которые реализуют нужные интерфейсы. Давайте подставим mock-объекты и зададим нужное нам поведение:

   1:  public class ReporterTests 
   2:  {
   3:       [Fact]
   4:       public void IfNotReportsThenThrowException()
   5:       {
   6:          var builder = new Mock<IReportBuilder>();
   7:          builder.Setup(m => m.CreateReports()).Returns(new List<Report>());
   8:   
   9:          var sender = new Mock<IReportSender>();
  10:   
  11:          var reporter = new Reporter(builder.Object, sender.Object);
  12:   
  13:          Assert.Throws<NoReportsException>(() => reporter.SendReports());
  14:      }
  15:  }

Тест прошел! Мы отлично справились. Теперь есть возможность задавать поведение объектов, с которыми работает наш Reporter. И в данном случае нам не важно, что где-то есть EmailReportSender, SmsReportSender или еще какой-то *ReportSender. Тесты Reporter'а не зависят от других реализаций, мы используем только интерфейсы. Это делает тесты более устойчивыми к изменениям в системе.

Связанность

Мы реализовали на практике главный принцип инверсии зависимостей. Наш Reporter зависит только от абстракций (интерфейсов).

Как быть, если мы хотим отсылать отчеты не через email, а через SMS? Теперь сделать это проще простого . Надо передать в конструктор Reporter'а не EmailReportSender, а SmsReportSender. Код самого Reporter'а мы изменять уже не будем.

Тем не менее меня такое решение еще не полностью устраивает. Мне не нравится, что где-то в клиентах моей библиотеки будет куча строк типа:

   1:  var builder = new ReportBuilder(); 
   2:  var sender = new SmsReportSender(); 
   3:  var reporter = new Reporter(builder, sender); 
   4:   
   5:  // ...  

Первым решением может стать использование фабрики объектов Reporter. В принципе это рабочее решение, но мы пойдем еще дальше. Я хочу, чтобы конфигурирование объектов моей программы происходило один раз и находилось в одном месте.

Шаг 3. Используем ServiceLocator

Наша цель - задавать соответствие интерфейсов и их реализаций. Сделаем наше приложение конфигурируемым на клиенте!

Нам нужен объект, который будет хранить информацию о том, что интерфейсу IReportSender соответствует реализация EmailReportSender. Назовем этот объект ServiceLocator. Связь интерфейса и реализации он будет хранить во внутреннем словаре:

   1:  public static class ServiceLocator 
   2:  { 
   3:      private static readonly Dictionary<Type, Type> services = new Dictionary<Type, Type>();
   4:   
   5:      public static void RegisterService<t>(Type service)
   6:      {
   7:          services[typeof (T)] = service;
   8:      }
   9:   
  10:      public static T Resolve<t>()
  11:      {
  12:          return (T) Activator.CreateInstance(services[typeof (T)]);
  13:      }
  14:  }

Теперь рассмотрим, как мы будем его использовать. Для начала зарегистрируем связи:

   1:  public static void Main() 
   2:  {
   3:       ServiceLocator.RegisterService<IReportBuilder>(typeof(ReportBuilder))
   4:       ServiceLocator.RegisterService<IReportSender>(typeof(SmsReportSender));

Теперь у класса Reporter создадим конструктор, который пользуется этими настройками:

   1:  public class Reporter : IReporter 
   2:  {
   3:      private readonly IReportBuilder reportBuilder;
   4:      private readonly IReportSender reportSender;
   5:   
   6:      public Reporter() : this(ServiceLocator.Resolve<IReportBuilder>(), ServiceLocator.Resolve<IReportSender>())
   7:      {
   8:      }
   9:   
  10:      public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
  11:      {
  12:          this.reportBuilder = reportBuilder;
  13:          this.reportSender = reportSender;
  14:      // ...

Примечание: второй конструктор отлично подойдет для модульного тестирования.

После инициализации ServiceLocator'а вызываем в любом месте программы пустой конструктор:

   1:  var reporter = new Reporter(); 
   2:  reporter.SendReports();  

С таким подходом мы можем задать соответствие интерфейсов и их реализаций один раз и использовать его. Чтобы во всем приложении вместо SmsReportSender использовать EmailReportSender, надо в начале выполнения программы (сайта, сервиса и т.д.) изменить:

   1:  ServiceLocator.RegisterService<IReportSender>(typeof(SmsReportSender));

на другую реализацию IReportSender'а:

   1:  ServiceLocator.RegisterService<IReportSender>(typeof(EmailReportSender));

В чем же отличие? Дело в том, что раньше классы сами знали от каких объектов они зависят и могли напрямую использовать друг друга:

Теперь объекты знают только про интерфейсы классов, с которыми взаимодействуют, а реализации просят у сервиса:

Используем готовый IoC (inversion of control) контейнер

Конечно, решение получилось достаточно гибким, но сама реализация класса ServiceLocator еще требует доработки. Например, что делать, если реализацию интерфейса IReportBuilder напротяжении жизни приложения нужно создавать только один раз, а потом при обращении к функции Resolve возвращать созданную реализацию? Нашему ServiceLocator'у не хватает настроек поведения. К счастью, изобретать свой велосипед не надо. На данный момент существуют довольно много готовых IoC контейнеров. Вот самые популярные:

Вкратце, покажу преимущества от их использования на примере Ninject.

Для начала отчистим класс Reporter от лишнего конструктора, оставим только конструктор с параметрами:

   1:  public class Reporter : IReporter
   2:  {
   3:      private readonly IReportBuilder reportBuilder;
   4:      private readonly IReportSender reportSender;
   5:   
   6:      public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
   7:      {
   8:          this.reportBuilder = reportBuilder;
   9:          this.reportSender = reportSender;
  10:      }
  11:   
  12:      // ...

Теперь вначале исполнения программы инициализируем контейнер и вызываем отправку отчетов:

   1:  public class Program
   2:  {
   3:      public static void Main()
   4:      {
   5:          IKernel kernel = new StandardKernel(new InlineModule(
   6:                              m => m.Bind<IReportBuilder>().To<ReportBuilder>(),
   7:                              m => m.Bind<IReportSender>().To<EmailReportSender>(),
   8:                              m => m.Bind<Reporter>().ToSelf()
   9:                              ));
  10:   
  11:          var reporter = kernel.Get<Reporter>();
  12:   
  13:          reporter.SendReports();
  14:      }
  15:  }

При создании экземпляра Reporter Ninject с помощью метода Get сам подставит в конструктор объекта реализации IReportBuilder и IReportSender. Это инжекция в конструктор. Есть и другие способы инжектирования зависимостей. Я советую использовать готовые IoC контейнеры в своих проектах.

Инвертированная архитектура

Итак, рассмотрев детали мы можем подняться выше. Взглянем на архитектуру приложения. Давайте рассмотрим классическую трехзвенную архитектуру:

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

1. Жесткость

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

2. Хрупкость

Когда вы вносите изменения в одну часть системы, то в неожиданном месте ломается другая.

3. Неподвижность

Очень сложно повторно использовать код в другом приложении, потому что модули сильно связаны между собой. Внизу дана ссылка на статью Джеймса Ковака, который сделал интересную заметку:

"Выберите любой класс на бизнес-уровне, допустим InvoiceService, и просто скопируйте его код в новый проект. Попробуйте его скомпилировать. Скорее всего выяснится, что не хватает каких-то зависимостей: Invoice, InvoiceValidator и т. д. Скопируйте и эти классы в проект и повторите попытку. Скорее всего и на этот раз каких-то классов система недосчитается. И когда, в конечном итоге, вам все же удастся скомпилировать приложение, вы обнаружите, что в новом проекте находится добрая доля исходного кода."

К чему мы пришли:

В данном случае каждый слой отдельно представлен абстрактными классами/интерфейсами. Сам слой наследуется от этого абстрактного слоя (например, Business Layer реализует интерфейсы, которые объявлены в Business Layer Abstract). Все классы верхнего уровня используют нижележащий уровень через его абстрактный слой. Таким образом ни один слой не зависит от деталей другого. Напротив, они зависят только от абстракций.

Тут есть вопрос по реализации. Как класс из UI Layer узнает во время исполнения программы, какую реализацию IReportSender'а надо использовать? Ведь у него нет доступа к слою Business Layer. Ответ уже был дан выше – мы запишем все зависимости в IoC конейнер. Потом вызываем container.Get(). А там уже по цепочке через инжекцию (например, в конструктор) создадутся все необходимые объекты.

Исходный код

Этот пост входит в серию Принципы проектирования классов (S.O.L.I.D.):


Ссылки

Роберт Мартин: The Dependency Inversion Principle

dnrTV: James Kovacs' roll-your-own IoC container

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

Inversion of Control Containers and the Dependency Injection pattern

Использование инверсии управления (IoC) в сигнатурах методов

Инверсия зависимостей при проектировании Объектно-Ориентированных систем

NDC 2009, Ayende Rahien - Inversion of Control & Dependency Injection Breaking out from the Dependency Hell

Wikipedia: Dependency injection

18 коммент.:

algel комментирует...

Отличный пост!
Только исправь во второй строке кода после фразы: "Чтобы во всем приложении вместо SmsReportSender использовать EmailReportSender, надо в начале выполнения программы (сайта, сервиса и т.д.) изменить..." c
(typeof(SmsReportSender));
на
(typeof(EmailReportSender));

Александр Бындю комментирует...

@algel
Спасибо, поправил!

Sergey Litvinov комментирует...

Соглашусь. Пост отличный, хорошо все расписано.

betspaam комментирует...

А можно вопрос ;) незнаю даже как сформулировать ;)

Вот везде в DDD советуют выносить получение entity в отдельный репозиторий, ну тоесть писать

чтото вроде:
Product product = productRepository.getbyid(id);

а не вот так:
Product product = new Product(id)
product.load();

Но такое движение как-то мне не нравится. Это конечно хорошо, что DB код вынесен в репозиторий,
а в самой entity осталась только бизнесс логика. Но допустим у нас есть класс Order, который этот Product и использовал,
и вот теперь ему придется знать не только про товар, но еще и про то, что надо использовать репозиторий этих товаров.
Была одна зависимость - стало их две.

Есть идея дублировать методы в таком духе, а репозитории вообще делать internal

class Product
{
static Product getbyid(int id)
{
//productRepository заинжектили
return productRepository.getbyid(id);
}
static Product getByBarcode(string barcode)
{

return productRepository.getbybarcode(barcode);
}
static Boolean save()
{
return productRepository.save(this);
}

}

Ну и использовать соотвественно вот так

Product product = Product.getbyid(id);
product.price=1;
product.save();

Как думаешь это плохой подход? какие могут проблемы потом с таким кодом?

Александр Бындю комментирует...

@betspaam

Привет!
Я уже писал, что думаю по поводу ActiveRecord

http://blog.byndyu.ru/2009/10/blog-post.html

Ты писал
> Но допустим у нас есть класс Order, который этот Product и использовал
> Была одна зависимость - стало их две

Приведи пример в коде, иначе не понятно о чем ты говоришь. Можешь мне на мыло написать.

Sergey комментирует...

Александр, у меня вопрос. Можно считать, что ServiceLocator является неотъемлемой частью паттерна инверсии зависимости, или это все же полезное дополнение, усиливающее эффект паттерна? Если мы попробуем отказаться от ServiceLocator и вернуться к соотв. фрагменту кода:

1: var builder = new ReportBuilder();
2: var sender = new SmsReportSender();
3: var reporter = new Reporter(builder, sender);

а затем представить, что создание объекта Reporter происходит не в ф-ии Main, а в методе какого-то класса X нашей бизнес-логики, тогда будет ли какая-то польза от такой инверсии? Да, мы ослабим зависимости класса Reporter, заменив конкретные типы контрактами интерфейсов, но эти зависимоти теперь попадают в метод класса X. Выходит, мы не избавляемся от проблемы, а попросту перекладываем ее на другой объект, и без локатора зависимости так и буду блуждать в пределах модуля?

Александр Бындю комментирует...

@Sergey
Отличный вопрос!

Начну по порядку.

> неотъемлемой частью паттерна инверсии зависимости...
Поправлю, что это не шаблон проектирования, а принцип. Шаблоны заточены под решение более конкретных задач. Их особенностью является то, что при использовании шаблонов мы не нарушаем ни один принцип проектирования.

> Можно считать, что ServiceLocator является неотъемлемой частью паттерна инверсии зависимости...
ServiceLocator это шаблон проектирования. Т.е. это один из способов добиться нужного результата. Это такой же инструмент, как и IoC контейнеры.

> создание объекта Reporter происходит не в ф-ии Main, а в методе какого-то класса X нашей бизнес-логики, тогда будет ли какая-то польза от такой инверсии?
Определенно будет. Если ваши объекты будут зависеть от интерфейсов, то архитектура вашего приложения от этого только выиграет.

> Выходит, мы не избавляемся от проблемы, а попросту перекладываем ее на другой объект...
Смотря на какой объект переложить. Например, стороки:

1: var builder = new ReportBuilder();
2: var sender = new SmsReportSender();
3: var reporter = new Reporter(builder, sender);

можно спрятать в фабрике класса Reporter и тем самым скрыть зависимость от реализаций в одном месте.

Sergey комментирует...

Тогда мы локатор заменяем фабрикой. Т.е. в любом мы должны сочетать IoC либо с локатором либо с фарикой, чтобы сделать все части модуля не связанными с конкретными типами? А что по поводу IoC в чистом виде?

По семантики слов "паттерн/шаблон" и "принцип": я правильно понимаю, что IoC вы считаете "принципом", а локатор и IoC контейнеры -- "паттернами"? В словаре у слова pattern очень широкий набор значений (образец, модель, пример, шаблон, принцип, система, структура, характер, стиль и т.д.), поэтому тут есть шанс основательно запутаться.

Sergey комментирует...

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

Александр Бындю комментирует...

@Sergey
Для начала советую посмотреть/почитать ссылки, которые даны в конце статьи.

> IoC без сочетания с фабриками/локаторами позволит ослабить связи внутри модуля только до определенной степени...
Я еще раз повторюсь. Принцип проектирования - это стратегия. Шаблоны - это тактика. Какую тактику вы примените для того, чтобы добиться желаемой архитектуры зависит от вас.
Я рекомендую использовать IoC-контейнеры, как наиболее простой и эффективный способ.

reviews комментирует...

Код выделяется вместе с номерами строк.
Плохо.

reviews комментирует...

И что используется для тестирования? атрибуты намекают на то, что это не MsTests.

Александр Бындю комментирует...

@reviews

"И что используется для тестирования?"

xUnit

plain_fact комментирует...

Считается, что глобальное состояние, от которого все зависят — это плохо. Но ведь рассуждения, приводящие к такому выводу, можно применить и к IoC-контейнеру? В частности, ваш ServiceLocator — чем не глобальная переменная?

Александр Бындю комментирует...

@plain_fact
Все правильно. ServiceLocator - это глобальная статическая переменная, поэтому я его и заменил за IoC-контейнер.

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

Maxim комментирует...

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

Спасибо!! Отличный пост. Все идеально расписано. Буду изучать дальше :)

Александр Бындю комментирует...

@Maxim
Рад, что полезно :)

Теперь мы можем решать проблемы с кодом вместе, присоединяйтесь http://blog.byndyu.ru/2010/05/blog-post.html

Denis Bazhenov комментирует...

Интересная статья, спасибо. Я тоже изложил свое видение IoC: http://dotsid.blogspot.com/2009/12/ioc.html

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