1. Обзор
Принцип инверсии зависимостей (DIP) является частью набора принципов объектно-ориентированного программирования, широко известного как SOLID .
По сути, DIP — это простая, но мощная парадигма программирования, которую мы можем использовать для реализации хорошо структурированных, сильно развязанных и повторно используемых программных компонентов .
В этом руководстве мы рассмотрим различные подходы к реализации DIP — один в Java 8 и один в Java 11 с использованием JPMS (система модулей платформы Java).
2. Внедрение зависимостей и инверсия управления не являются реализациями DIP
Прежде всего, давайте сделаем фундаментальное различие, чтобы правильно понять основы: DIP не является ни внедрением зависимостей (DI), ни инверсией управления (IoC) . Тем не менее, все они отлично работают вместе.
Проще говоря, DI заключается в том, чтобы программные компоненты явно объявляли свои зависимости или соавторов через свои API, вместо того, чтобы приобретать их сами по себе.
Без DI программные компоненты тесно связаны друг с другом. Следовательно, их трудно повторно использовать, заменять, имитировать и тестировать, что приводит к жесткой конструкции.
С DI ответственность за предоставление зависимостей компонентов и связывание графов объектов переносится с компонентов на базовую среду внедрения. С этой точки зрения DI — это просто способ достижения IoC.
С другой стороны, IoC — это шаблон, в котором управление потоком приложения меняется на противоположное . При использовании традиционных методологий программирования наш пользовательский код контролирует поток приложения. И наоборот, с IoC управление передается внешнему фреймворку или контейнеру .
Фреймворк представляет собой расширяемую кодовую базу, которая определяет точки подключения для подключения нашего собственного кода .
В свою очередь, фреймворк вызывает наш код через один или несколько специализированных подклассов, используя реализации интерфейсов и аннотации. Фреймворк Spring — хороший пример последнего подхода.
3. Основы ДИП
Чтобы понять мотивы DIP, давайте начнем с его формального определения, данного Робертом К. Мартином в его книге Agile Software Development: Principles, Patterns, and Practices
:
- Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Итак, ясно, что в основе DIP лежит инвертирование классической зависимости между высокоуровневыми и низкоуровневыми компонентами путем абстрагирования взаимодействия между ними .
В традиционной разработке программного обеспечения высокоуровневые компоненты зависят от низкоуровневых. Таким образом, сложно повторно использовать высокоуровневые компоненты.
3.1. Варианты дизайна и DIP
Давайте рассмотрим простой класс StringProcessor
, который получает значение String
с помощью компонента StringReader
и записывает его в другое место с помощью компонента StringWriter
:
public class StringProcessor {
private final StringReader stringReader;
private final StringWriter stringWriter;
public StringProcessor(StringReader stringReader, StringWriter stringWriter) {
this.stringReader = stringReader;
this.stringWriter = stringWriter;
}
public void printString() {
stringWriter.write(stringReader.getValue());
}
}
Хотя реализация класса StringProcessor
является базовой, здесь мы можем выбрать несколько вариантов дизайна.
Давайте разобьем каждый вариант дизайна на отдельные элементы, чтобы четко понять, как каждый из них может повлиять на общий дизайн:
StringReader
иStringWriter
, низкоуровневые компоненты, представляют собой конкретные классы, помещенные в один и тот же пакет.StringProcessor
высокоуровневый компонент находится в другом пакете.StringProcessor
зависит отStringReader
иStringWriter
. Нет инверсии зависимостей, поэтомуStringProcessor
нельзя повторно использовать в другом контексте.StringReader
иStringWriter
— это интерфейсы, помещенные в один пакет вместе с реализациями .StringProcessor
теперь зависит от абстракций, а низкоуровневые компоненты — нет. Мы еще не добились инверсии зависимостей.StringReader
иStringWriter
— это интерфейсы, помещенные в один пакет вместе сStringProcessor
. ТеперьStringProcessor
явно владеет абстракциями.StringProcessor,
StringReader
иStringWriter
зависят от абстракций. Мы добились инверсии зависимостей сверху вниз, абстрагировав взаимодействие между компонентами.
StringProcessor
теперь можно повторно использовать в другом контексте.StringReader
иStringWriter
— это интерфейсы, помещенные в отдельный пакет отStringProcessor
. Мы добились инверсии зависимостей, а также проще заменитьреализации StringReader
иStringWriter
.StringProcessor
также можно повторно использовать в другом контексте.
Из всех вышеперечисленных сценариев только пункты 3 и 4 являются допустимыми реализациями DIP.
3.2. Определение права собственности на абстракции
Пункт 3 представляет собой прямую реализацию DIP, в которой высокоуровневый компонент и абстракция(и) помещаются в один и тот же пакет. Следовательно, высокоуровневый компонент владеет абстракциями . В этой реализации высокоуровневый компонент отвечает за определение абстрактного протокола, посредством которого он взаимодействует с низкоуровневыми компонентами.
Аналогично, пункт 4 представляет собой более развязанную реализацию DIP. В этом варианте паттерна ни высокоуровневые, ни низкоуровневые компоненты не владеют абстракциями .
Абстракции размещены в отдельном слое, что облегчает переключение низкоуровневых компонентов. При этом все компоненты изолированы друг от друга, что обеспечивает более прочную инкапсуляцию.
3.3. Выбор правильного уровня абстракции
В большинстве случаев выбор абстракций, которые будут использовать высокоуровневые компоненты, должен быть довольно простым, но с одной оговоркой, которую стоит отметить: уровень абстракции.
В приведенном выше примере мы использовали DI для внедрения типа StringReader в класс
StringProcessor
. Это будет эффективно до тех пор, пока уровень абстракции StringReader
близок к домену StringProcessor
.
Напротив, мы бы просто упустили внутренние преимущества DIP, если бы StringReader
был, например, объектом File
, который считывает значение String
из файла. В этом случае уровень абстракции StringReader
будет намного ниже, чем уровень домена StringProcessor
.
Проще говоря, уровень абстракции, который высокоуровневые компоненты будут использовать для взаимодействия с низкоуровневыми, всегда должен быть близок к предметной области первых .
4. Реализации Java 8
Мы уже подробно рассмотрели ключевые концепции DIP, поэтому теперь рассмотрим несколько практических реализаций шаблона в Java 8.
4.1. Прямая реализация DIP
Давайте создадим демонстрационное приложение, которое извлекает клиентов из уровня сохраняемости и обрабатывает их каким-то дополнительным образом.
Основным хранилищем слоя обычно является база данных, но для простоты кода здесь мы будем использовать обычную карту
.
Начнем с определения высокоуровневого компонента :
public class CustomerService {
private final CustomerDao customerDao;
// standard constructor / getter
public Optional<Customer> findById(int id) {
return customerDao.findById(id);
}
public List<Customer> findAll() {
return customerDao.findAll();
}
}
Как мы видим, класс CustomerService
реализует методы findById()
и findAll()
, которые извлекают клиентов из уровня сохраняемости, используя простую реализацию DAO . Конечно, мы могли бы инкапсулировать в классе больше функций, но для простоты давайте оставим все как есть.
В этом случае тип CustomerDao
— это абстракция , которую CustomerService
использует для использования низкоуровневого компонента.
Поскольку это прямая реализация DIP, давайте определим абстракцию как интерфейс в том же пакете CustomerService
:
public interface CustomerDao {
Optional<Customer> findById(int id);
List<Customer> findAll();
}
Поместив абстракцию в тот же пакет высокоуровневого компонента, мы возлагаем на компонент ответственность за владение абстракцией. Эта деталь реализации — то, что действительно инвертирует зависимость между компонентом высокого уровня и компонентом низкого уровня .
Кроме того, уровень абстракции CustomerDao
близок к уровню CustomerService ,
что также необходимо для хорошей реализации DIP.
Теперь давайте создадим низкоуровневый компонент в другом пакете. В данном случае это просто базовая реализация CustomerDao
:
public class SimpleCustomerDao implements CustomerDao {
// standard constructor / getter
@Override
public Optional<Customer> findById(int id) {
return Optional.ofNullable(customers.get(id));
}
@Override
public List<Customer> findAll() {
return new ArrayList<>(customers.values());
}
}
Наконец, давайте создадим модульный тест для проверки функциональности класса CustomerService :
@Before
public void setUpCustomerServiceInstance() {
var customers = new HashMap<Integer, Customer>();
customers.put(1, new Customer("John"));
customers.put(2, new Customer("Susan"));
customerService = new CustomerService(new SimpleCustomerDao(customers));
}
@Test
public void givenCustomerServiceInstance_whenCalledFindById_thenCorrect() {
assertThat(customerService.findById(1)).isInstanceOf(Optional.class);
}
@Test
public void givenCustomerServiceInstance_whenCalledFindAll_thenCorrect() {
assertThat(customerService.findAll()).isInstanceOf(List.class);
}
@Test
public void givenCustomerServiceInstance_whenCalledFindByIdWithNullCustomer_thenCorrect() {
var customers = new HashMap<Integer, Customer>();
customers.put(1, null);
customerService = new CustomerService(new SimpleCustomerDao(customers));
Customer customer = customerService.findById(1).orElseGet(() -> new Customer("Non-existing customer"));
assertThat(customer.getName()).isEqualTo("Non-existing customer");
}
Модульный тест проверяет API CustomerService .
А также показано, как вручную внедрить абстракцию в высокоуровневый компонент. В большинстве случаев для этого мы будем использовать какой-либо контейнер или инфраструктуру внедрения зависимостей.
Кроме того, на следующей диаграмме показана структура нашего демонстрационного приложения с высокоуровневой и низкоуровневой точки зрения пакета:
4.2. Альтернативная реализация DIP
Как мы обсуждали ранее, можно использовать альтернативную реализацию DIP, в которой мы размещаем высокоуровневые компоненты, абстракции и низкоуровневые компоненты в разных пакетах.
По понятным причинам этот вариант более гибкий, обеспечивает лучшую инкапсуляцию компонентов и упрощает замену низкоуровневых компонентов.
Конечно, реализация этого варианта шаблона сводится к тому, чтобы просто поместить CustomerService
, MapCustomerDao
и CustomerDao
в отдельные пакеты.
Поэтому диаграммы достаточно, чтобы показать, как каждый компонент расположен в этой реализации:
5. Модульная реализация Java 11
Довольно легко преобразовать наше демонстрационное приложение в модульное.
Это действительно хороший способ продемонстрировать, как JPMS обеспечивает соблюдение лучших практик программирования, включая строгую инкапсуляцию, абстракцию и повторное использование компонентов через DIP.
Нам не нужно заново реализовывать наши образцы компонентов с нуля. Следовательно, модульность нашего примера приложения — это всего лишь вопрос размещения каждого файла компонента в отдельном модуле вместе с соответствующим дескриптором модуля .
Вот как будет выглядеть модульная структура проекта:
project base directory (could be anything, like dipmodular)
|- com.foreach.dip.services
module-info.java
|- com
|- foreach
|- dip
|- services
CustomerService.java
|- com.foreach.dip.daos
module-info.java
|- com
|- foreach
|- dip
|- daos
CustomerDao.java
|- com.foreach.dip.daoimplementations
module-info.java
|- com
|- foreach
|- dip
|- daoimplementations
SimpleCustomerDao.java
|- com.foreach.dip.entities
module-info.java
|- com
|- foreach
|- dip
|- entities
Customer.java
|- com.foreach.dip.mainapp
module-info.java
|- com
|- foreach
|- dip
|- mainapp
MainApplication.java
5.1. Компонентный модуль высокого уровня
Начнем с размещения класса CustomerService в отдельном модуле.
Мы создадим этот модуль в корневом каталоге com.foreach.dip.services
и добавим дескриптор модуля, module-info.java
:
module com.foreach.dip.services {
requires com.foreach.dip.entities;
requires com.foreach.dip.daos;
uses com.foreach.dip.daos.CustomerDao;
exports com.foreach.dip.services;
}
По понятным причинам мы не будем вдаваться в подробности того, как работает JPMS. Тем не менее, ясно увидеть зависимости модуля, просто взглянув на директивы require .
Наиболее важной деталью, на которую стоит обратить внимание, является директива uses .
В нем указано, что модуль является клиентским модулем , который использует реализацию интерфейса CustomerDao .
Конечно, в этом модуле нам еще нужно разместить высокоуровневый компонент — класс CustomerService .
Итак, в корневом каталоге com.foreach.dip.services
создадим следующую структуру каталогов, похожую на пакет: com/foreach/dip/services.
Наконец, давайте поместим файл CustomerService.java в этот каталог.
5.2. Модуль абстракции
Точно так же нам нужно разместить интерфейс CustomerDao в отдельном модуле.
Поэтому давайте создадим модуль в корневом каталоге com.foreach.dip.daos
и добавим дескриптор модуля:
module com.foreach.dip.daos {
requires com.foreach.dip.entities;
exports com.foreach.dip.daos;
}
Теперь давайте перейдем в каталог com.foreach.dip.daos
и создадим следующую структуру каталогов: com/foreach/dip/daos
. Давайте поместим файл CustomerDao.java в этот каталог.
5.3. Модуль низкоуровневых компонентов
По логике, низкоуровневый компонент SimpleCustomerDao
тоже нужно поместить в отдельный модуль. Как и ожидалось, процесс очень похож на то, что мы только что сделали с другими модулями.
Давайте создадим новый модуль в корневом каталоге com.foreach.dip.daoimplementations
и включим дескриптор модуля:
module com.foreach.dip.daoimplementations {
requires com.foreach.dip.entities;
requires com.foreach.dip.daos;
provides com.foreach.dip.daos.CustomerDao with com.foreach.dip.daoimplementations.SimpleCustomerDao;
exports com.foreach.dip.daoimplementations;
}
В контексте JPMS это модуль поставщика услуг , поскольку он объявляет директивы Provides
и with
.
В этом случае модуль делает службу CustomerDao
доступной для одного или нескольких потребительских модулей посредством реализации SimpleCustomerDao
.
Не будем забывать, что наш потребительский модуль com.foreach.dip.services
использует эту службу с помощью директивы uses .
Это ясно показывает, насколько просто иметь прямую реализацию DIP с помощью JPMS, просто определяя потребителей, поставщиков услуг и абстракции в разных модулях .
Точно так же нам нужно поместить файл SimpleCustomerDao.java
в этот новый модуль. Давайте перейдем в каталог com.foreach.dip.daoimplementations
и создадим новую структуру каталогов, подобную пакету, с этим именем: com/foreach/dip/daoimplementations
.
Наконец, давайте поместим файл SimpleCustomerDao.java
в каталог.
5.4. Модуль сущности
Кроме того, нам нужно создать еще один модуль, в котором мы можем разместить класс Customer.java .
Как и раньше, давайте создадим корневой каталог com.foreach.dip.entities
и включим дескриптор модуля:
module com.foreach.dip.entities {
exports com.foreach.dip.entities;
}
В корневом каталоге пакета создадим каталог com/foreach/dip/entities
и добавим следующий файл Customer.java :
public class Customer {
private final String name;
// standard constructor / getter / toString
}
5.5. Основной модуль приложения
Далее нам нужно создать дополнительный модуль, который позволит нам определить точку входа нашего демонстрационного приложения. Поэтому создадим еще одну корневую директорию com.foreach.dip.mainapp
и разместим в ней дескриптор модуля:
module com.foreach.dip.mainapp {
requires com.foreach.dip.entities;
requires com.foreach.dip.daos;
requires com.foreach.dip.daoimplementations;
requires com.foreach.dip.services;
exports com.foreach.dip.mainapp;
}
Теперь давайте перейдем к корневому каталогу модуля и создадим следующую структуру каталогов: com/foreach/dip/mainapp.
В этот каталог добавим файл MainApplication.java
, который просто реализует метод main() :
public class MainApplication {
public static void main(String args[]) {
var customers = new HashMap<Integer, Customer>();
customers.put(1, new Customer("John"));
customers.put(2, new Customer("Susan"));
CustomerService customerService = new CustomerService(new SimpleCustomerDao(customers));
customerService.findAll().forEach(System.out::println);
}
}
Наконец, давайте скомпилируем и запустим демонстрационное приложение — либо из нашей IDE, либо из командной консоли.
Как и ожидалось, мы должны увидеть список объектов Customer
, выводимых на консоль при запуске приложения:
Customer{name=John}
Customer{name=Susan}
Кроме того, на следующей диаграмме показаны зависимости каждого модуля приложения:
6. Заключение
В этом руководстве мы подробно рассмотрели ключевые концепции DIP, а также показали различные реализации шаблона в Java 8 и Java 11 , причем последняя использует JPMS.
Все примеры реализации DIP для Java 8 и реализации Java 11 доступны на GitHub.