Как вы можете написать свое приложение в соответствии с архитектурой портов и адаптеров (она же шестиугольная) и почему вы должны попробовать.
Фото автора Боннибел Б на Unsplash
Unsplash
Давайте предположим, что вы новичок в университете и только что прошли первую стажировку в компании, занимающейся разработкой программного обеспечения. Или, может быть, вы более опытный разработчик, который присоединился к новой компании. Это не имеет значения.
Следующая история написана с точки зрения новичка, который делает свои первые шаги в новом проекте. Следующая история написана с точки зрения новичка, который делает свои первые шаги в новом проекте. и я хотел бы познакомить вас с этим как с подходом к архитектурному шаблону Ports & Adapters .
Поскольку моей основной технологией является Java, все примеры представлены на этом языке.
День 1. Где находятся пакеты контроллеров, служб и репозиториев?
Это мой первый день в новой компании, и я сразу же начинаю работать над новым проектом под названием — “библиотека” . Это кажется довольно простым — это система управления для работы с книгами и пользователями в локальной библиотеке. Его код находится в открытом доступе на GitHub .
Первое задание, данное Джоном, другим разработчиком этого проекта, состояло в том, чтобы ознакомиться с проектом, узнать его структуру и то, как он организован, потому что они используют менее распространенный подход, называемый Порты и адаптеры в сочетании с Domain Driven Design (DDD) .
Моя первая мысль? Где находятся все необходимые слои приложения? Во всех предыдущих проектах было очень тонкое разделение между слоями и их назначением, т.е. контроллеры были разработаны для обработки запросов от пользователей, | репозитории для извлечения и сохранения данных в базе данных и
Но здесь? Таких пакетов нет. Вот почему я спросил Джона, какого черта. И он сказал, что мне будет понятнее, если я выберу один из доменов и окунусь в него. Как объяснял Джон, домен можно описать как небольшую часть приложения, но разделение производится на основе бизнес-контекста. Приложение было разделено на несколько доменов, и каждый из них отвечает за отдельную часть бизнес-логики. Например, пользователь домен отвечает за управление пользователями, инвентаризацию за добавление и удаление книг и заимствование за все, что связано с резервированием, заимствованием и возвратом книг. Это помогает понять, что делает каждая из этих частей с точки зрения бизнеса. Джон посоветовал мне сначала взглянуть на простой — user .
Да! Наконец-то я вижу контроллер, репозиторий и сервис. Они называются немного по-другому, например UserService здесь вызывается here UserFacade и они находятся в странно звучащих упаковках — приложение |/, ядро и инфраструктура . Мне нужно спросить Джона, что это такое и почему они не являются контроллером, сервисом и хранилищем.
Тем временем я также проверил структуру других доменов и кажется, что этот шаблон применяется для каждого домена, как в заимствовании :
Возвращаясь к пользовательскому домену, я начал анализировать код с контроллера (очевидно), и вот что я увидел:
@RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserCommandController { private final AddNewUser addNewUser; @PostMapping("") public ResponseEntityaddNewUser(@RequestBody AddUserCommand addUserCommand){ addNewUser.handle(addUserCommand); return new ResponseEntity<>("New user was added to library", HttpStatus.CREATED); } }
Очень странно. Вместо зависимости UserFacade (service) здесь есть нечто, называемое (и именуемое глаголом!) Добавить нового пользователя , который оказывается всего лишь интерфейсом:
public interface AddNewUser { UserIdentifier handle(AddUserCommand addUserCommand); }
Поэтому я проверил код UserFacade , и похоже, что он реализует этот интерфейс.
@AllArgsConstructor public class UserFacade implements AddNewUser { private final UserDatabase database; @Override public UserIdentifier handle(AddUserCommand addUserCommand) { User user = new User( new EmailAddress(addUserCommand.getEmail()), addUserCommand.getFirstName(), addUserCommand.getLastName() ); return database.save(user); } }
Логика кажется довольно простой, я бы сказал, очевидной. Но опять же странная вещь, База данных пользователей , зависимость этого класса снова является интерфейсом!
public interface UserDatabase { UserIdentifier save(User user); }
Они должны издеваться надо мной! Почему они создают так много интерфейсов и классов? Я бы спросил об этом Джона, но он уже ушел и пошел домой. Поэтому мне нужно разобраться в этом самостоятельно.
@RequiredArgsConstructor public class UserDatabaseAdapter implements UserDatabase { private final UserRepository userRepository; @Override public UserIdentifier save(User user) { User savedUser = userRepository.save(user); return new UserIdentifier(savedUser.getIdentifierAsLong()); } }
@Repository public interface UserRepository extends CrudRepository{}
Реализация Пользовательской базы данных , называемая Адаптером базы данных пользователя , имеет зависимость от Spring CrudRepository , которая выполняет фактическую работу — сохраняет пользователя. Но почему это сделано именно так? Почему они просто не использовали Пользовательское хранилище внутри пользовательского интерфейса ?
Я думаю, что не разберусь в этом сегодня, возможно, завтра Джон прольет на это больше света для меня.
День 2. Приложение, ядро и инфраструктура
Новый день, поэтому, после утреннего кофе и быстрой болтовни на кухне я спросил Джона, может ли он объяснить мне концепцию Портов и адаптеров , поскольку она все еще размыта для меня.
Он согласился и начал объяснять, как это происходит в их предыдущем проекте, где бизнес-логика была действительно сложной, было много странных вариантов использования и исключений. И имея это в виду, они попытались каким-то образом втиснуть эту логику в стандартную многослойную конвенцию, которая закончилась катастрофой. Главным образом потому, что не было единого места, где находилась бы такая логика, она была распространена на все уровни — контроллеры, службы и сохранение данных.
По этой причине они попробовали другой подход — Порты и адаптеры (он же Шестиугольная ) архитектура.
Основная концепция, и здесь он начал рисовать ее на бумаге, состоит в том, чтобы разделить ваше приложение на три основные части:
приложение — определяет, как внешний мир взаимодействует с приложением , это шлюз к ядру приложения. Это может быть с помощью контроллеров Rest, но это может быть также с помощью какой-либо службы сообщений (например, Kafka, RabbitMQ и т.д.), клиента командной строки или другого,
core — здесь находится бизнес-логика вашего приложения. Цель состоит в том, чтобы он был написан простым языком так, чтобы аналитик или даже нетехнический человек мог понять. Внутри него мы используем специфичный для конкретной предметной области язык, который может быть легко понятен деловым людям. И еще одно, он должен быть независимым от любого фреймворка Java (например, Spring , ( EE , Quarks , Micronaut ), потому что это всего лишь строительные леса приложения. А ядро – это сердце приложения, то, что инкапсулирует логику.
инфраструктура — это последняя часть, большинство приложений не только содержат бизнес-логику, но обычно им также необходимо использовать некоторые внешние системы, такие как база данных, очередь, sFTP-сервер, другое приложение и так далее. В этой части мы говорим как будет реализована эта коммуникация (в основном мы говорим только что необходимо ). Например, для сохранения данных в базе данных мы можем использовать несколько подходов, таких как Hibernate , обычный Jdbc , jOOQ или любой другой фреймворк, который нам нравится.
Чем это отличается от “обычного” многоуровневого приложения? Очевидно, что эти “части” – это просто контроллеры, службы и репозитории, но со странными именами. — Я уже спрашивал.
Да, немного, — ответил он, — они могут показаться похожими концепциями, но есть одно ключевое различие. Ядро не знает о прикладных и инфраструктурных слоях. Он не должен ничего знать о внешнем мире.
Подожди, что? Как этого можно было бы достичь? — Я был немного удивлен.
Очень просто. Внутри core мы определяем что-то, что называется Порт он определяет все взаимодействия, которые ядро будет иметь с чем-либо внешним. Эти порты подобны контрактам (или API) и могут быть разделены на две группы входящие (первичные) и исходящие (вторичный). Первые отвечают за то, как вы можете взаимодействовать с бизнес-ядром (какие команды вы можете использовать на нем), а вторые используются ядром для общения с внешним миром.
Для их определения мы используем интерфейсы Java, например, вот определение одного из них, которое определяет метод резервирования книги:
public interface ReserveBook { Long handle(BookReservationCommand bookReservation); }
И примером исходящего порта будут методы базы данных:
public interface BorrowingDatabase { ReservationDetails save(ReservedBook reservedBook); OptionalgetAvailableBook(Long bookId); Optional getActiveUser(Long userId); }
Оба они находятся в пакете портов в исходящих и входящих подпакетах. Как это делается здесь:
Как вы можете видеть порты – это только определения что мы хотели бы сделать. Они не говорят о как их достичь .
Эту проблему решает |/адаптер . Это реализация портов, например, вот реализация порта ReserveBook, внутри BorrowingFacade.java класс, который является бизнес-ядром приложения:
public class BorrowingFacade implements ReserveBook { private final BorrowingDatabase database; private final BorrowingEventPublisher eventPublisher; @Override public Long handle(BookReservationCommand bookReservation) { AvailableBook availableBook = database.getAvailableBook(bookReservation.getBookId()) .orElseThrow(() -> new AvailableBookNotFoundExeption(bookReservation.getBookId())); ActiveUser activeUser = database.getActiveUser(bookReservation.getUserId()) .orElseThrow(() -> new ActiveUserNotFoundException(bookReservation.getUserId())); ReservedBook reservedBook = activeUser.reserve(availableBook); ReservationDetails reservationDetails = database.save(reservedBook); eventPublisher.publish(new BookReservedEvent(reservationDetails)); return reservationDetails.getReservationId().getIdAsLong(); } }
Вы можете легко прочитать, что здесь происходит, каков рабочий процесс бизнес-процесса.
Но описанный выше метод требует наличия адаптеров для двух исходящих портов — database & eventPublisher . Для первого и его первого метода (для получения Доступная книга ) реализация может выглядеть следующим образом:
@RequiredArgsConstructor public class BorrowingDatabaseAdapter implements BorrowingDatabase { private final JdbcTemplate jdbcTemplate; @Override public OptionalgetAvailableBook(Long bookId) { try { return Optional.ofNullable( jdbcTemplate.queryForObject( "SELECT book_id FROM available WHERE book_id = ?", AvailableBook.class, bookId)); } catch (DataAccessException exception) { return Optional.empty(); } } }
Конечно, это может быть не единственным решением. Возможно, в зависимости от конкретного случая, было бы проще реализовать его с помощью Hibernate .
И в этом красота и элегантность решения. Мы можем определить несколько адаптеров для одного порта, потому что бизнес-логике должно быть все равно, как вы получаете/сохраняете данные из/в базу данных, если вы используете Jdbc , Hibernate или другие. Кроме того, бизнес также не должен беспокоиться о том, какой тип базы данных вы используете. Будь то PostgreSQL, MySQL, Oracle, MongoDB или любой другой тип базы данных.
И более того, вы можете внедрять свои собственные адаптеры только для тестирования. Может быть очень полезно иметь очень быструю и простую реализацию базы данных только для модульного тестирования бизнес- ядра .
public class InMemoryBorrowingDatabase implements BorrowingDatabase { ConcurrentHashMapavailableBooks = new ConcurrentHashMap<>(); @Override public Optional getAvailableBook(Long bookId) { if (availableBooks.containsKey(bookId)) { return Optional.of(availableBooks.get(bookId)); } else { return Optional.empty(); } } }
Таким образом, в конце у нас может быть несколько адаптеров для одного порта, которые мы можем переключать, когда захотим.
Ладно, Джон, успокойся! Притормози, мне нужно подумать об этом. Но спасибо за знакомство, — сказал я и до конца дня проверял код и читал об этой скороговорке в Интернете.
День 3. Внедрение бизнес-ядра
Сегодня я получил свое первое настоящее задание! Ура!
Джон сказал, что аналитик нашей команды Ирен свяжется со мной, и вместе мы будем работать над основной бизнес—функциональностью новой функции – отменой просроченного бронирования.
Когда она приходит, мы сразу же приступаем к реализации этой проблемы. Сначала мы определили новый класс интерфейса, который будет отвечать за проверку просроченных бронирований и их повторную доступность.
public interface CancelOverdueReservations { void cancelOverdueReservations(); }
Ничего сложного. Затем мы добавили этот порт в BorrowingFacade.java
класс (который является адаптером для вышеуказанного порта):
public class BorrowingFacade implements CancelOverdueReservations{ @Override public void cancelOverdueReservations() { // here will be an implementation } }
Затем мы начали обсуждать, что нам здесь делать. Ирен сказала мне, что нам нужно найти все книги, которые хранятся как зарезервированные более 3 дней, а затем сделать их автоматически доступными. И в итоге мы получили этот код.
public class BorrowingFacade implements CancelOverdueReservations{ private final BorrowingDatabase database; @Override public void cancelOverdueReservations() { ListoverdueReservationList = database.findReservationsForMoreThan(3L); overdueReservationList.forEach( overdueBook -> database.save( new AvailableBook(overdueBook.getBookIdentificationAsLong()) )); } }
Это действительно просто, и в нем используются два метода из базы данных. Один из них, чтобы сделать книгу доступной, уже объявлен реализованным. Второй — database.findReservationsForMoreThan
еще не был объявлен, поэтому я добавил его в исходящий порт базы данных.
public interface BorrowingDatabase { void save(AvailableBook availableBook); ListfindReservationsForMoreThan(Long days); }
На данный момент нас не волновало, как это будет реализовано (другими словами, какой SQL-запрос нам нужно использовать, чтобы получить их).
И сразу же мы перешли к подготовке нескольких модульных тестов. Мы подготовили два простых теста: один для просроченного бронирования, а второй – когда срок бронирования не истек:
public class BorrowingFacadeTest { private InMemoryBorrowingDatabase database; @BeforeEach public void init(){ database = new InMemoryBorrowingDatabase(); facade = new BorrowingFacade(database); } @Test @DisplayName("Cancel reservation after 3 days") public void givenBookIsReserved_when3daysPass_thenBookIsAvailable(){ //given ReservedBook reservedBook = ReservationTestData.anyReservedBook(100L, 100L); changeReservationTimeFor(reservedBook, Instant.now().minus(4, ChronoUnit.DAYS)); database.reservedBooks.put(100L, reservedBook); //when facade.cancelOverdueReservations(); //then assertEquals(0, database.reservedBooks.size()); } @Test @DisplayName("Do not cancel reservation after 2 days") public void givenBookIsReserved_when2daysPass_thenBookIsStillReserved(){ //given ReservedBook reservedBook = ReservationTestData.anyReservedBook(100L, 100L); changeReservationTimeFor(reservedBook, Instant.now().minus(2, ChronoUnit.DAYS)); database.reservedBooks.put(100L, reservedBook); //when facade.cancelOverdueReservations(); //then assertEquals(1, database.reservedBooks.size()); } private void changeReservationTimeFor(ReservedBook reservedBook, Instant reservationDate) { try { FieldUtils.writeField(reservedBook, "reservedDate", reservationDate, true); } catch (IllegalAccessException e) { e.printStackTrace(); } } }
В приведенном выше классе мы были вынуждены использовать отражение Java, потому что файл зарезервированная дата
– это закрытое поле, которое нельзя изменить после создания объекта ReservedBook
.
Чтобы приведенный выше код работал, нам также нужно было создать класс In Memory, заимствующий базу данных
, который имеет реализацию двух исходящих портов базы данных, необходимых для бизнес-логики.
public class InMemoryBorrowingDatabase implements BorrowingDatabase { ConcurrentHashMapavailableBooks = new ConcurrentHashMap<>(); ConcurrentHashMap reservedBooks = new ConcurrentHashMap<>(); @Override public void save(AvailableBook availableBook) { availableBooks.put(availableBook.getIdAsLong(), availableBook); reservedBooks.remove(availableBook.getIdAsLong()); borrowedBooks.remove(availableBook.getIdAsLong()); } @Override public List findReservationsForMoreThan(Long days) { return reservedBooks.values().stream() .filter(reservedBook -> Instant.now().isAfter( reservedBook.getReservedDateAsInstant().plus(days, ChronoUnit.DAYS))) .map(reservedBook -> new OverdueReservation( 1L, reservedBook.getIdAsLong())) .collect(Collectors.toList()); } }
Из приведенного выше кода мы можем видеть, что реализация “базы данных” для модульных тестов – это просто простая карта, которая заставляет их выполняться очень-очень быстро. Что-то, за что стоит бороться”.
После этого моя сессия с Ирен подошла к концу, так как ей нужно было перейти на другую встречу, но самая важная работа уже была сделана. Мы создали основную бизнес-логику, поэтому завтра я могу сосредоточиться на написании адаптера базы данных для подключения к реальной базе данных.
День 4. Адаптер базы данных и внедрение зависимостей
Я начал новый день с напоминания себе, что мне нужно сделать. Поэтому я еще раз перешел к определению порта базы данных и проверил, что метод find Reservations Для более чем
по-прежнему не реализован.
public interface BorrowingDatabase { void save(AvailableBook availableBook); ListfindReservationsForMoreThan(Long days); }
Поэтому я открыл класс с именем Заимствующий DatabaseAdapter
и добавлена реализация для нового метода. Все методы там были с использованием Spring JdbcTemplate
и я прикидываю, что в моем случае это тоже будет наиболее подходящим. После нескольких минут борьбы с SQL-запросом я наткнулся на решение:
@RequiredArgsConstructor public class BorrowingDatabaseAdapter implements BorrowingDatabase { private final JdbcTemplate jdbcTemplate; @Override public ListfindReservationsForMoreThan(Long days) { List entities = jdbcTemplate.query( "SELECT id AS reservationId, book_id AS bookIdentification FROM reserved WHERE DATEADD(day, ?, reserved_date) > NOW()", new BeanPropertyRowMapper (OverdueReservationEntity.class), days); return entities.stream() .map(entity -> new OverdueReservation(entity.getReservationId(), entity.getBookIdentification())) .collect(Collectors.toList()); } }
А затем я подготовил для него интеграционный тест (поскольку в нем я хочу коснуться базы данных H2), в котором я использовал несколько помощников по тестированию и SQL-скрипты для настройки состояния базы данных перед запуском фактического теста.
@SpringBootTest public class BorrowingDatabaseAdapterITCase { @Autowired private JdbcTemplate jdbcTemplate; private DatabaseHelper databaseHelper; private BorrowingDatabaseAdapter database; @BeforeEach public void init(){ database = new BorrowingDatabaseAdapter(jdbcTemplate); databaseHelper = new DatabaseHelper(jdbcTemplate); } @Test @DisplayName("Find book after 3 days of reservation") @Sql({"/book-and-user.sql"}) @Sql(scripts = "/clean-database.sql", executionPhase = AFTER_TEST_METHOD) public void shouldFindOverdueReservations(){ //given Long overdueBookId = databaseHelper.getHomoDeusBookId(); Long johnDoeUserId = databaseHelper.getJohnDoeUserId(); jdbcTemplate.update( "INSERT INTO public.reserved (book_id, user_id, reserved_date) VALUES (?, ?, ?)", overdueBookId, johnDoeUserId, Instant.now().plus(4, ChronoUnit.DAYS)); //when OverdueReservation overdueReservation = database.findReservationsForMoreThan(3L).get(0); //then assertEquals(overdueBookId, overdueReservation.getBookIdentificationAsLong()); } }
О, точно! Все стало зеленым! Хороший 😏 .
Последнее, что мне нужно сделать, это написать код для application part, который будет запускать весь процесс.
Я решил использовать Spring Scheduler, который каждые 1 минуту будет проверять наличие просроченных книг:
@RequiredArgsConstructor public class OverdueReservationScheduler { @Qualifier("CancelOverdueReservations") private final CancelOverdueReservations overdueReservations; @Scheduled(fixedRate = 60 * 1000) public void checkOverdueReservations(){ overdueReservations.cancelOverdueReservations(); } }
Класс Планировщик просроченных резерваций
очень прост. Каждую минуту он запускает метод отмены просроченных бронирований
на входящем порту Отменить просроченные бронирования
который является API бизнес-ядра .
Но здесь есть еще одна вещь, которую нужно сделать. Отменить просроченные бронирования
объект – это просто интерфейс, а не реализация. Поэтому нам нужно внедрить его через внедрение зависимостей в класс конфигурации.
@Configuration public class BorrowingDomainConfig { @Bean public BorrowingDatabase borrowingDatabase(JdbcTemplate jdbcTemplate) { return new BorrowingDatabaseAdapter(jdbcTemplate); } @Bean @Qualifier("CancelOverdueReservations") public CancelOverdueReservations cancelOverdueReservations(BorrowingDatabase database){ return new BorrowingFacade(database); } }
При этом мы сообщаем Spring context, что реализация этого интерфейса должна быть взята из Заимствующего фасада
класса. Что, в свою очередь, требует наличия реализации интерфейса Заимствующей базы данных
, что выполняется в классе Заимствующей базы данных
.
И это все! После развертывания моих изменений в тестовой среде и выполнения некоторого ручного тестирования кажется, что некоторые изменения сработали! Что за неделя!
Вывод
Я надеюсь, вам понравится эта “история”. Я хотел бы указать на пару вещей, которые продают спорт и адаптеры (по крайней мере, для меня):
по крайней мере, часть кода ( business core ) может быть понятна непрограммистам (бизнес-аналитикам, владельцам продуктов, вашим родителям и т.д.).,
код core отделен от infrastructure , что позволяет очень легко заменять адаптеры без изменения кода бизнес-ядра (я нашел это очень полезным, особенно в мире микросервисов, когда ваше приложение зависит от нескольких других API, которые постоянно меняют свои версии),
ядро не зависит от фреймворка приложения, старый фреймворк можно заменить на Spring Boot, Jakarta EE, Quarks, Micronaut или любой другой фреймворк, популярный на данный момент,
написание модульных тестов для ядра очень простое и быстрое, нам не нужно создавать тестовые настройки для конкретной платформы (например, в Spring нам не нужно добавлять аннотацию
@SpringBootTest
и создавать весь контекст Spring только для тестирования небольшой части приложения), достаточно простой Java.
Как обычно, полный код доступен на GitHub
кшивец/библиотека-шестиугольная
Пример приложения, написанного на шестиугольной архитектуре (Порты и адаптер)
Рекомендации
DDD, Шестиугольный, Луковый, Чистый, CQRS, … Как я собрал все это воедино на herbertograca.com
Доменно-ориентированный дизайн и гексагональная архитектура на vaadin.com
Шестиугольная архитектура, DDD и Spring | Baeldung на baeldung.com
hirannor/spring-boot-шестиугольная архитектура на github.com
gshaw-поворотный/пружинный-шестигранный-пример на github.com
Оригинал: “https://dev.to/wkrzywiec/ports-adapters-architecture-on-example-22ia”