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