Шаблон проектирования адаптер

При написании материалов я так или иначе касаюсь темы шаблонов проектирования, однако я обратил своё внимание на то, что нередко путаюсь в их названиях. Поэтому я решил перечитать книгу "банды четырёх", а заодно написать цикл материалов по шаблонам проектирования с примерами кода на языке программирования Java.

Статья на сайте

Открывает цикл материалов статья о шаблоне проектирования "адаптер". Адаптер, так же известный как "обёртка" (wrapper), применяется в тех случаях, когда некоторый существующий класс необходимо адаптировать под использование с другим целевым интерфейсом без внесения изменений в адаптируемый класс, дабы не нарушать принцип открытости/закрытости.

В качестве примера адаптера из реального мира могу привести зарядное устройство смартфона. В электрических розетках в наших домах напряжение равняется примерно 220 вольтам, в то время как для зарядки смартфонов требуется 5, 9, 10 или 40 вольт, да и разъём у смартфонов может быть разный: Micro USB Type B, USB C или Lightning. Адаптируемым объектом в данном случае является розетка, а целевым - смартфон.

Реализовать шаблон проектирования можно двумя способами: через наследование (адаптер класса) и через композицию (адаптер объекта). Давайте разберём оба способа подробнее.

Адаптер класса

При реализации через наследование класс-адаптер расширяет адаптируемый класс и реализует целевой интерфейс, через который используется адаптируемый класс. Допустим, в моём проекте существует интерфейс FindTaskByIdSpi для поиска объекта TaskData по идентификатору в источнике данных:

@FunctionalInterface
interface FindTaskByIdSpi {

    Optional<TaskData> findTaskById(UUID id);
}

В то же время в библиотеке Spring Framework JDBC есть абстрактный класс MappingSqlQuery, при помощи которого можно описывать SQL-запросы, возвращающие данные (SELECT-запросы). Однако MappingSqlQuery не реализует интерфейс FindTaskByIdSpi, и я не могу использовать его без изменения кода. Для решения этой задачи я могу применить шаблон проектирования "адаптер" и создать новый класс - FindTaskByIdMappingSqlQuery, который будет расширять класс MappingSqlQuery и реализовывать интерфейс FindTaskByIdSpi, и который я смогу использовать без изменений в коде.

Адаптер класса
Адаптер класса
class FindTaskByIdMappingSqlQuery extends MappingSqlQuery<TaskData>
        implements FindTaskByIdSpi {

    // Метод целевого интерфейса
    public Optional<TaskData> findTaskById(UUID id) {
        return Optional.ofNullable(
                // обращение к методу адаптируемого класса
                this.findObjectByNamedParam(Map.of("id", id)));
    }
}

Для реализации шаблона проектирования "адаптер" через наследование требуется, чтобы адаптируемый класс, в данном случае - MappingSqlQuery, соответствовал двум требованиям: не был финальным и имел доступную область видимости.

Тестирование

При тестировании адаптера класса не нужно стараться покрыть всю логику, достаточно протестировать только ту логику, что реализуется в самом классе-адаптере, в то время как логика адаптируемого класса должна быть покрыта собственными тестами. Ниже приведён пример такого тестирования с использованием JUnit 5 и Mockito:

@ExtendWith(MockitoExtension.class)
class FindTaskByIdSpiMappingSqlQueryTest {

    @Spy
    FindTaskByIdSpiMappingSqlQuery adapter;

    @Test
    void findTaskById_CallsFindObjectByNamedParam_ReturnsOptional() {
        // given
        var id = UUID.randomUUID();
        var task = new TaskData(id);

        doReturn(task).when(this.adapter)
                .findObjectByNamedParam(Map.of("id", id));

        // when
        var optional = this.adapter.findTaskById(id);

        // then
        assertEquals(Optional.of(task), optional);
    }
}

Как видно из этого примера фактическая логика метода findObjectByNamedParam из MappingSqlQuery не используется, так как она уже протестирована разработчиками Spring Framework. Вместо этого поведение метода findObjectByNamedParam в тесте имитируется.

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

Адаптер объекта

Как уже было сказано выше, адаптер класса применим в случаях, когда есть возможность расширить адаптируемый класс. В противном случае на помощь приходит реализация шаблона проектирования "адаптер" через композицию, которая так же называется "Адаптер объекта". В этом случае экземпляр адаптируемого класса или даже интерфейса должен помещаться в свойства класса-адаптера, а это позволяет адаптировать сразу несколько объектов.

Давайте представим, что описанный ранее интерфейс FindTaskByIdSpi я решил реализовать при помощи NamedParameterJdbcOperations:

Адаптер объекта
Адаптер объекта

В этом случае класс-адаптер FindTaskByIdAdapter реализует целевой интерфейс FindTaskByIdSpi, а так же содержит адаптируемый объект NamedParameterJdbcOperations, как это показано в следующем примере кода:

public class FindTaskByIdSpiAdapter implements FindTaskByIdSpi, RowMapper<TaskData> {

    private final NamedParameterJdbcOperations jdbcOperations;

    public FindTaskByIdSpiAdapter(NamedParameterJdbcOperations jdbcOperations) {
        this.jdbcOperations = jdbcOperations;
    }

    @Override
    public Optional<TaskData> findTaskById(UUID id) {
        return this.jdbcOperations.query("select * from t_task where id = :id",
                Map.of("id", id), this).stream().findFirst();
    }

    @Override
    public TaskData mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new TaskData(rs.getObject("id", UUID.class));
    }
}

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

Тестирование

Логика тестирования адаптера объекта та же - тестировать нужно только логику самого класса-адаптера, как это продемонстрировано ниже:

@ExtendWith(MockitoExtension.class)
class FindTaskByIdSpiAdapterTest {

    @Mock
    NamedParameterJdbcOperations jdbcOperations;

    @InjectMocks
    FindTaskByIdSpiAdapter adapter;

    @Test
    void findTaskById_CallsQuery_ReturnsOptional() {
        // given
        var id = UUID.randomUUID();
        var task = new TaskData(id);

        doReturn(List.of(task)).when(this.jdbcOperations)
                .query("select * from t_task where id = :id",
                        Map.of("id", id), this.adapter);

        // when
        var optional = this.adapter.findTaskById(id);

        // then
        assertEquals(Optional.of(task), optional);
    }
}

Адаптер и другие

Я частенько путаю "адаптер" с двумя другими шаблонами проектирования: декоратором и заместителем (proxy). Но на самом деле все три шаблона используются для решения разных задач и по-разному работают с интерфейсом исходного класса.

  • Адаптер предоставляет новый интерфейс для взаимодействия с классом или объектом.
  • Декоратор предоставляет либо исходный интерфейс объекта, либо расширенный.
  • Заместитель предоставляет исходный интерфейс объекта.

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

58 views·2 shares