воскресенье, 14 февраля 2016 г.

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

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


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

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

Шаг 1. Сильная связанность
Наша система будет консольным приложением, которое занимается рассылкой отчетов.
?
1
2
3
4
5
6
7
8
public class Program
{
    public static void Main()
    {
        var reporter = new Reporter();
        reporter.SendReports();
    }
}
Главный объект в нашей бизнес-логике – Reporter.
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Reporter
{
     public void SendReports()
     {
        var reportBuilder = new ReportBuilder();
        IList<Report> reports = reportBuilder.CreateReports();
  
        if (reports.Count == 0)
            throw new NoReportsException();
  
        var reportSender = new EmailReportSender();
        foreach (Report report in reports)
        {
            reportSender.Send(report);
        }
    }
}
Устроен Reporter очень просто. Он просит ReportBuilder создать список отчетов, а потом один за другим отсылает их с помощью объекта EmailReportSender.
Есть ли в этом коде проблемы? В подавляющем большинстве случаев зависит от того, кто и как этот код будет использовать, как часто он будет меняться и т.д. Но есть проблемы, которые очевидны уже сейчас.

Тестируемость
Как протестировать функцию SendReports? Давайте проверим поведение функции, когдаReportBuilder не создал ни одного отчета. В этом случае она должна создать исключениеNoReportsException:
?
1
2
3
4
5
6
7
8
9
10
public class ReporterTests
{
     [Fact]
     public void IfNotReportsThenThrowException()
     {
         var reporter = new Reporter();
         reporter.SendReports();
         // ???
     }
}
Как в этом случае задать поведение объектов, которые использует 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
2
3
4
5
6
7
8
9
public interface IReportBuilder
{
    IList<Report> CreateReports();
}
  
public interface IReportSender
{
    void Send(Report report);
}
Теперь вместо того, чтобы создавать объекты в функции SendReports, мы передами их объекту Reporter в конструктор:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Reporter : IReporter
{
     private readonly IReportBuilder reportBuilder;
     private readonly IReportSender reportSender;
  
     public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
     {
         this.reportBuilder = reportBuilder;
         this.reportSender = reportSender;
     }
  
     public void SendReports()
     {
        IList<Report> reports = reportBuilder.CreateReports();
  
        if (reports.Count == 0)
            throw new NoReportsException();
  
        foreach (Report report in reports)
        {
            reportSender.Send(report);
        }
    }
}

Во время создания объекта Reporter в самом начале программы мы будем задавать конкретные IReportBuilder и IReportSender и передавать их в конструктор:
?
1
2
3
4
5
6
7
8
public static void Main()
{
     var builder = new ReportBuilder();
     var sender = new SmsReportSender();
     var reporter = new Reporter(builder, sender);
  
     reporter.SendReports();
}
Посмотрим, какие проблемы мы смогли решить.

Тестируемость
Теперь у нас есть возможность передавать в конструктор Reporter'а объекты, которые реализуют нужные интерфейсы. Давайте подставим mock-объекты и зададим нужное нам поведение:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ReporterTests
{
     [Fact]
     public void IfNotReportsThenThrowException()
     {
        var builder = new Mock<IReportBuilder>();
        builder.Setup(m => m.CreateReports()).Returns(new List<Report>());
  
        var sender = new Mock<IReportSender>();
  
        var reporter = new Reporter(builder.Object, sender.Object);
  
        Assert.Throws<NoReportsException>(() => reporter.SendReports());
    }
}
Тест прошел! Мы отлично справились. Теперь есть возможность задавать поведение объектов, с которыми работает наш Reporter. И в данном случае нам не важно, что где-то естьEmailReportSenderSmsReportSender или еще какой-то *ReportSender. Тесты Reporter'а не зависят от других реализаций, мы используем только интерфейсы. Это делает тесты более устойчивыми к изменениям в системе.

Связанность
Мы реализовали на практике главный принцип инверсии зависимостей. Наш Reporter зависит только от абстракций (интерфейсов).
Как быть, если мы хотим отсылать отчеты не через email, а через SMS? Теперь сделать это проще простого . Надо передать в конструктор Reporter'а не EmailReportSender, аSmsReportSender. Код самого Reporter'а мы изменять уже не будем.
Тем не менее меня такое решение еще не полностью устраивает. Мне не нравится, что где-то в клиентах моей библиотеки будет куча строк типа:
?
1
2
3
4
5
var builder = new ReportBuilder();
var sender = new SmsReportSender();
var reporter = new Reporter(builder, sender);
  
// ... 
Первым решением может стать использование фабрики объектов Reporter. В принципе это рабочее решение, но мы пойдем еще дальше. Я хочу, чтобы конфигурирование объектов моей программы происходило один раз и находилось в одном месте.

Шаг 3. Используем ServiceLocator
Наша цель - задавать соответствие интерфейсов и их реализаций. Сделаем наше приложение конфигурируемым на клиенте!
Нам нужен объект, который будет хранить информацию о том, что интерфейсу IReportSenderсоответствует реализация EmailReportSender. Назовем этот объект ServiceLocator. Связь интерфейса и реализации он будет хранить во внутреннем словаре:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static class ServiceLocator
{
    private static readonly Dictionary<Type, Type> services = new Dictionary<Type, Type>();
  
    public static void RegisterService<T>(Type service)
    {
        services[typeof (T)] = service;
    }
  
    public static T Resolve<T>()
    {
        return (T) Activator.CreateInstance(services[typeof (T)]);
    }
}

Теперь рассмотрим, как мы будем его использовать. Для начала зарегистрируем связи:
?
1
2
3
4
public static void Main()
{
     ServiceLocator.RegisterService<IReportBuilder>(typeof(ReportBuilder))
     ServiceLocator.RegisterService<IReportSender>(typeof(SmsReportSender));

Теперь у класса Reporter создадим конструктор, который пользуется этими настройками:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Reporter : IReporter
{
    private readonly IReportBuilder reportBuilder;
    private readonly IReportSender reportSender;
  
    public Reporter() : this(ServiceLocator.Resolve<IReportBuilder>(), ServiceLocator.Resolve<IReportSender>())
    {
    }
  
    public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
    {
        this.reportBuilder = reportBuilder;
        this.reportSender = reportSender;
    // ...
Примечание: второй конструктор отлично подойдет для модульного тестирования.
После инициализации ServiceLocator'а вызываем в любом месте программы пустой конструктор:
?
1
2
var reporter = new Reporter();
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
2
3
4
5
6
7
8
9
10
11
12
public class Reporter : IReporter
{
    private readonly IReportBuilder reportBuilder;
    private readonly IReportSender reportSender;
  
    public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
    {
        this.reportBuilder = reportBuilder;
        this.reportSender = reportSender;
    }
  
    // ...
Теперь вначале исполнения программы инициализируем контейнер и вызываем отправку отчетов:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Program
{
    public static void Main()
    {
        IKernel kernel = new StandardKernel(new InlineModule(
                            m => m.Bind<IReportBuilder>().To<ReportBuilder>(),
                            m => m.Bind<IReportSender>().To<EmailReportSender>(),
                            m => m.Bind<Reporter>().ToSelf()
                            ));
  
        var reporter = kernel.Get<Reporter>();
  
        reporter.SendReports();
    }

}
При создании экземпляра 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(). А там уже по цепочке через инжекцию (например, в конструктор) создадутся все необходимые объекты.
Вся информация взята с этого блога.

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

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