Системное программирование, Dependency Inversion Principle, паттерны проектирования

Системное программирование, Dependency Inversion Principle, паттерны проектирования

07.02.2019

Системное программирование

Просто поговорили.

Dependency Inversion Principle

Пример

Рассмотрим на примере веб-приложения. Языком у нас будет C#, фреймворком — ASP.NET Core MVC. Предположим, мы разрабатываем интернет-магазин, и у нас планируется несколько точек входа, которые обычно бывают в интернет-магазинах:

GET /orders
GET /orders/100
POST /orders
POST /orders/100/edit

Запрос GET /orders покажет страницу с заказами пользователя, а GET /orders/100 — детали заказа с номером 100. Если вы работали с Ruby on Rails или Django, такая схема вам знакома.

MVC

В ASP.NET MVC для обработки таких запросов используются классы-контроллеры. Для четырёх запросов, которые мы перечислил, интерфейс класса будет выглядеть так:

public class OrderController
{
    [HttpGet("/orders")]
    public ActionResult GetOrders(int? page = null) { }

    [HttpGet("/orders/{orderId}")]
    public ActionResult GetOrder(int orderId) { }

    [HttpPost("/orders")]
    public ActionResult CreateOrder(OrderModel order) { }

    [HttpPost("/orders/{orderId}/edit")]
    public ActionResult EditOrder(int orderId, OrderModel order) { }
}

Здесь работает соглашение: если ресурс называется orders, то контроллер будет называться OrderController. У контроллера есть общедоступные методы, и каждый из них соответсвует запросу из списка выше. Четыре запроса — четыре метода.

Разберём, как можно реализовать метод GetOrder классически, без инверсии зависимостей:

[HttpGet(/orders/{orderId})]
public ActionResult GetOrder(int orderId)
{
    string connectionString = AppSettings.ConnectionStrings[SHOP].ConnectionString;
    using (SqlConnection connection = new SqlConnection(connectionString))
    {
        connection.Open();

        using (SqlCommand command = connection.CreateCommand())
        {
            command.CommandText = SELECT * FROM Orders WHERE id = @id;
            command.Parameters.Add("@id", orderId);

            SqlDataReader dataReader = command.ExecuteReader();
            if (dataReader.Read())
            {
                Order order = new Order(dataReader);
                return View(order, order);
            }
            else
                return View(order-not-found);
        }
    }
}

Даже если вы не работали с .NET, наверняка вам знакома схема работы с базой данных. Сначала вы создаёте подключение к базе, затем выполняете команду SQL. Команды INSERT, UPDATE, DELETE просто выполняются, а команда SELECT возвращает вам данные. Для чтения данных вы используете специальный объект, который в .NET называется data reader.

Для подключения нам нужны параметры, которые хранятся в специальном формате в строке подключения, которую мы читаем из настроек приложения AppSettings.

Я намеренно сделал простую реализацию без применения ORM наподобие Entity Framework или Dapper, чтобы не затенять основную тему множеством деталей.

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

Зависимости классической реализации

Проблемы классической реализации

  1. Мы можем легко заменить уровень представления, но заменить уровень инфраструктуры трудно. Надо ли менять? Уровень представления — да. Даже в простом интернет-магазине есть скрипты, которые надо запускать по таймеру — для чистки базы, закрытия «подвисших» заказов. Эти скрипты по сути те же самые приложения, в которых уровень презентации это не веб-приложение, а консоль. Уровень инфраструктуры меняется реже, но тем не менее регулярно. Важно обеспечить себе возможность изменений.
  2. Из интерфейса класса OrderController мы не видим зависимостей. Чтобы их увидеть, надо заглянуть в код.
  3. Код в OrderController получается объёмным, он содержит много деталей.
  4. Класс Order не только выполняет свою основную функцию, но и умеет себя загружать из SqlDataReader, что нарушает принцип единственной ответственности.

Решение: промежуточный интерфейс

Вместо прямого обращения к базе данных мы формируем промежуточный интерфейс хранилища (repository). Вместо того, чтобы описывать его снизу — что умеет реляционная база — описываем сверху — что бизнес-логике надо от хранилища?

В простейшем случае каждой операции контроллера, которая что-то делает с заказами в хранилище, нужен своей метод в репозитории:

public interface IOrderRepository
{
    Order CreateOrder(DateTime createdAt, User customer);

    void UpdateOrder(Order order);

    Order[] GetOrders(int offset, int count, out int totalCount);

    Order GetOrder(int orderId);
}

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

Создадим класс SqlOrderRepository, который реализует интерфейс IOrderRepository для подключения к базе MS SQL:

public class SqlOrderRepository : IOrderRepository
{
    private readonly Func<SqlConnection> _createSqlConnection;

    public SqlOrderRepository(Func<SqlConnection> createSqlConnection)
    {
        _createSqlConnection = createSqlConnection;
    }

    . . .

    public Order GetOrder(int orderId)
    {
        using (SqlConnection connection = _createSqlConnection())
        using (SqlCommand command = connection.CreateCommand())
        {
            command.CommandText = SELECT * FROM Orders WHERE id = @id;
            command.Parameters.Add("@id", orderId);

            SqlDataReader dataReader = command.ExecuteReader();
            if (dataReader.Read())
            {
                var id = dataReader.GetInt32(id);
                var createdAt = dataReader.GetDateTime(createdAt);
                var userId = dataReader.GetInt32(userId);

                return new Order(id, createdAt, userId);
            }
            
            throw new InvalidOperationException(Cant find order.);
        }
    }
}

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

Мы разорвали зависимость Oder от SqlDataReader, теперь мы сами читаем данные из базы, а в конструктор Order передаём примитивные значения.

Мы упростили обработку отсутствия заказа, вызывая исключение.

Код контроллера стал ещё проще:

public class OrderController
{
    private readonly IOrderRepository _orderRepository;

    public OrderController(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    [HttpGet(/orders/{orderId})]
    public ActionResult GetOrder(int orderId)
    {
        var order = _orderRepository.GetOrder(orderId);
        
        return View(order, order);
    }
}

Взглянем, как зависимости выглядят сейчас.

Зависимости реализации с инверсией

Между уровнями Presentation и Domain зависимость осталась прежней — сверху-вниз, а между уровнями Domain и Infrastructure она изменилась на обратную — теперь Infrastructure зависит от Domain.

Завершающий штрих

Мы всё сделали, но как это счастье будет работать практически? Сейчас в конструкторе класса OrderController ожидается параметр типа IOrderRepository, куда мы должны передать экземпляр SqlOrderRepository. Кто его создаст и кто передаст?

Фреймворки, которые поддерживают инверсию зависимостей, предоставляют специальное место — шов  — куда можно подключить свой локатор служб:

DependencyResolver.SetResolver(new PureDI());

В простейшем случае создать нужный набор классов мы можем вручную. Такое решение называется внедрением зависимостей для бедных или pure DI. Посмотрим, как может быть реализован класс PureDI:

public class PureDI : IDependencyResolver
{
    private SqlConnection CreateSqlConnection()
    {
        string connectionString = AppSettings.ConnectionStrings[SHOP].ConnectionString;
        return new SqlConnection(connectionString);
    }

    public object GetService(Type serviceType)
    {
        if (serviceType == typeof(OrderController))
        {
            var orderRepository = new SqlOrderRepository(CreateSqlConnection);
            
            return new OrderController(orderRepository);
        }

        throw new InvalidOperationException($"Unregistered service type {serviceType.FullName}.");
    }

    . . .
}

Мы выделяем в нашем проекте место, куда сходятся все зависимости. Оно называется корнем композиции (composition root). Когда нужен экземпляр OrderController, мы создаём репозиторий SqlOrderRepository и передаём ему написанную нами фабрику подключений к MS SQL. Создав цепочку объектов мы возвращаем самый верхний, то есть тот самый контроллер заказов.

Конечно, такой код можно писать только в небольших проектах. В больших мы будем использовать библиотеки DI, которые традиционно называются IoC-контейнерами.

Путаница с названиями

Существует некоторая путаница с названями IoC, Dependency Inversion и Dependency Injection. Сейчас мы разберёмся.

Начнём со второй части. Инверсия зависимостей — это принцип (один из принципов SOLID), а внедрение зависимостей — это способ. В объектно-ориентированных программах есть три основных способа внедрения: через конструктор, через свойство и через метод, по английски они называются constructor injection, property injection и method injection. Поскольку концепция свойств (properties) существует не во всех языках, другое название для property injection — setter injection, внедрение через сеттер.

В процедурных программах были свои способы инверсии. Например, в языке C библиотечная функция qsort не зависела от вашего кода, но умела сортировать ваши данные, какими бы они ни были. Она определяла сигнатуру функции, которую вызывала для сравнения — int compare(const void *, const void *), а вам нужно было написать функцию с такой же сигнатурой.

Инверсия может быть реализована через события — events — в языках, где они есть. Классическим для ООП способом инверсии является паттерн шаблонный метод. Базовый класс реализует общий алгоритм, но позволяет наследникам переопределить его части.

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

В современных текстах принято писать DIP, когда мы имеем в виду Dependency Inversion Principle и DI, когда мы имеем в виду Dependency Injection.

Теперь об IoC. В старых консольных приложениях инициатором взаимодействия была сама программа. Она просила вас ввести A, затем B, и затем выводила сумму A + B. Потом появились оконные программы, в которых инициатива оказалась на стороне пользователя.

Он мог ввести число в поле A или в поле B, изменить его или стереть. Когда пользователь хотел сумму, то нажимал кнопку A + B, и только тогда программа складывала числа.

Такой подход к разработке назвали инверсией управления или Inverse of Control. Чтобы всё работало, вызывающая сторона — фреймворк — определяла набор интерфейсов, которые вы в своей программе обязаны были реализовать и внедрить.

Долгое время термин IoC применялся и к подходу целиком, и конкретно к внедрению зависимостей, пока Мартин Фаулер не написал статью, где предложил конкретный термин Dependency Injection вместо общего Inverse of Control.

Однако термин IoC всё ещё часто встречается. В частиности библиотеки для Dependency Injection называют не DI-библиотеки, а IoC-контейнеры.

Пример IoC-контейнера Autofac

В отличие от внедрениея зависимостей для бедных, библиотеки предоставляют удобные средства регистрации зависимостей:

protected void Application_Start()
{
    var builder = new ContainerBuilder();

    builder.RegisterType<SqlConnection>()
           .WithParameter("connectionString", AppSettings.ConnectionStrings[SHOP].ConnectionString)
           .InstancePerRequest();

    builder.RegisterType<SqlOrderRepository>()
           .As<IOrderRepository>()
           .SingleInstance();

    builder.RegisterType<OrderController>()
           .InstancePerRequest();

    var container = builder.Build();
    DependencyResolver.SetResolver(new AutofacDependencyResolver(container));
}

При старте приложения мы описываем соответсвия реализаций и интерфейсов. Метод Build строит граф зависимостей и создаёт корневой контейнер объектов, который мы подключаем как IDependencyResolver. Это, кстати, паттерн Адаптер.

В корневом контейнере размещаются объекты, которые существуют в единственном экземпляре, например, репозитории. Autofac умеет одновременно создавать много независимых контейнеров, в частности, в веб-приложении он будет создавать отдельный контейнер на каждый HTTP-запрос. Важно, что SqlConnection, созданный в одном из них, будет независим от SqlConnection в других контейнерах.

Паттерны проектирования

Под паттернами сейчас разные люди понимают разные вещи. Чтобы внести ясность, мы обсудили тему паттернов.

Основополагающая книга по паттернам это GoF. По русски называется «Приёмы объекто-ориентированного проектирования» с подзаголовком «Паттерны проектирования».

GoF это сокращение от Gang of Four, то есть банда четырёх. Речь об авторах книги, которых как раз четверо. Книга сурова и фундаментальна. Десятки технических писателей переложили оригинал для разных языков программирования, и для не самых далёких читателей.

Другие — как например Фаулер сотоварищи — продолжили работу, и описали паттерны, применяемые при разработке корпоративных приложений.

Обе книги обязательны к прочтению. Знающие люди рекомендуют также Head First. Паттерны проектирования, говорят, что она проще, чем GoF. Если с темой ещё не знакомы, попробуйте начать с неё.

Для чего нужны паттерны

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

Паттерны проектирования упрощают повторное использование удачных проектных архитектурных решений.

При этом

Опытные проектировщики, очевидно, знают какие-то тонкости, ускользающие от новичков.

Можно сказать, паттерны ускоряют становление программиста. Однако, если вам 27–30 лет, у вас уже десять лет опыта. Большинство паттернов вам знакомы, просто вы не знаете, что это паттерны, и что они так называются.

Но как только вы узнаете, вы получите сразу три бонуса:

  1. Вы начнёте проектировать крупными блоками, а, значит, быстрее и проще.
  2. Обсуждая архитектурные вопросы с коллегами, вы будете тратить меньше времени.
  3. Вы станете проводить меньше времени в Google, потому что будете писать autofac composite pattern и получите результат в первой же строке.

Это плюсы. Но плюсам сопутствуют минусы, и основной минус в том, что под паттернами разные люди понимают разные вещи.

Что не является паттернами

Авторы оригинальной книги пишут, что

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

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

Это нижняя граница. А какова верхняя?

Но это и не сложные, предметно-ориентированные решения для целого приложения или подсистемы.

Можно ли инверсию зависимости назвать паттерном или это всё-таки принцип? Всё-таки принцип. А многозвенную архитектуру? В книге Фаулера её нет в списке паттернов, но на просторах интернета найти можно. Микросервисы? Есть подробное структурированное описание микросервисной архитектуры, как типового решения.

Я предложу такое правило: паттерном можно называть такое решение, которое:

  1. недоступно в готовом виде и требует адаптации;
  2. подробно описано в виде паттерна в популярной книге или на популярном сетевом ресурсе.

Посмотрите, как описаны паттерны в GoF или книге Фаулера. Описания достаточно конкретны, чтобы программист мог реализовать решение. Описание «разные классы можно размещать в разных слоях» я бы не назвал достаточно конкретным.

Требование популярности книги/ресурса вытекает из того факта, что паттерны образуют словарь для общения. Если паттерн неизвестен широкому кругу программистов, его использование в речи общения не ускоряет.

Не фантазируйте

Чтобы общаться с другими программистами и понимать друг друга, важно вкладывать в паттерны один и тот же смысл. Предположим, вы общаетесь с программистом Васей, который имеет интуитивное представление о паттерне Unit of Work. Интуитивное представление заключается у большинства программистов в том, что UoW — это объектная абстрация над транзакцией БД.

Мы знаем, что транзакции во многих СУБД могут быть вложенными. Поэтому Вася считает, что разные единицы работы могут зависеть друг от друга, то есть тоже могут быть вложенными.

Взглянем, однако, на описание паттерна из статьи Фаулера:

Единица работы

Мы видим, что реализация паттерна должна иметь аналоги шести методов: registerNew, registerDirty, registerClean, registerDelete, commit и rollback, и больше ничего. Единица работы не может быть вложенной.

Паттерны функционального программирования

Если они и существуют, никто их каталога не составил.

Большинство решений, которые можно называть функциональными паттернами являются библиотечными, и поэтому не могут считаться паттернами. Мы могли бы говорить, скажем, о паттерне неподвижная точка (fixed point), который позволяет вычислять число π, или квадратный корень, или искать корни уравнений. Но этот метод реализован в стандартной библиотеке большинства функциональных языков. Паттерн хвостовая рекурсия похож на паттерн оператор for. Согласитесь, смешно.

С некоторой натяжкой можно говорить о паттерне отображение-свёртка (map-reduce), но, если разбираться, это вполне конкретное техническое решение для создания распределённых систем, оно может быть реализовано на разных языках, не обязательно функциональных, и к функциональным языкам имеет опосредованное отношение.