Spring patterns. Fluent interface

от автора

Всем привет.

Я разрабатываю приложения с использованием Java, Spring Boot, Hibernate.

В этой статье я хочу поделиться опытом создания Fluent Interface, но не классического шаблона из GOF, а с использованием Spring.

Классическим примером шаблона Fluent Interface в Java является Stream API. Я покажу, как можно написать нечто подобное, используя Spring.

Пример клиентского кода:

 Participant participant = testEntityFactory.participantBy(RankType.WRITER)             .withName("customName")             .withPost("post_1")             .withPost("post_2")             .withPost("post_3")             .createOne();

Предисловие

Все мы знаем, как легко можно реализовать Chain of Responsibility pattern при помощи Spring:

@Autowired private List<AnyInterface> implementations;

Прогонять через цепочки различные сущности, удобно расширять цепочку при помощи новых имплементаций. А если я хочу использовать не всю цепочку, а только часть, и динамически её менять в клиентском коде? Сейчас покажу.

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

1. Подготовка графа сущностей

Для примера необходимо подготовить граф @Entity. Нам понадобятся 3 @Entity: Participant, Rank, Post. Примера кода не будет, обычные @Entity. У Participant один Rank и много Post. Rank может быть 3х видов:

public enum RankType {      READER, WRITER, MODERATOR  }

Значения обычно прописывают в бд скриптами. Для примера это не имеет значения, поэтому ddl скрипты будет генерировать Hibernate, а значения для Rank – RankInitializer, при поднятии Spring контекста:

  @PostConstruct     void init() {         List<Rank> ranks = Arrays.stream(RankType.values())             .map(rankType -> new Rank().setRankType(rankType))             .toList();         rankRepository.saveAll(ranks);     }

2. Классическое создание тестовых сущностей

Для написания тестов я буду использовать SpringBootTest и TestContainers. Создадим класс SpringBootTestApplication, от которого будут наследоваться Spring Boot тесты.

Представим, что нам надо написать тест, для которого надо подготовить сущности. Классическое решение выглядит следующим образом:

class ManualImperativeCreatingExampleTest extends SpringBootTestApplication {      @Test     void test() {         Participant participant = createParticipantManualAndImperative("name", "post", RankType.WRITER);          Assertions.assertEquals(RankType.WRITER, participant.getRank().getRankType());     }      private Participant createParticipantManualAndImperative(String participantName, String postTitle, RankType rankType) {         Rank rank = rankRepository.findByRankType(rankType); // вытащить Rank из бд          Participant participant = new Participant() // создать новый Participant             .setRank(rank)             .setName(participantName);         participantRepository.save(participant); // заперсистить          Post post = new Post() // создать новый Post             .setTitle(postTitle);         postRepository.save(post); // заперсистить          // переженить Participant и Post         participant.getPosts().add(post);         participantRepository.save(participant);          post.setParticipant(participant);         postRepository.save(post);          return participant;     }  }

Скорее всего, изначально за это будет отвечать приватный метод. Потом метод поднимут выше — в класс, от которого наследуются все Spring Boot тесты. Сделают метод protected. Потом рядом появится еще миллион методов, с другими сигнатурами. Потом окажется, что было бы неплохо обернуть создание сущности в транзакцию. Благо это можно легко сделать при помощи TransactionTemplate. В итоге — часть методов с транзакцией, часть без. Класс подобный SpringBootTestApplication превращается в антипаттерн статик утиль – класс на много строк, в котором есть всё, при этом, чтобы что-то найти, не будучи автором, надо потратить много времени, а методы с похожей сигнатурой либо именем метода пытаются пояснить чем они отличаются друг от друга, либо выше написан java-doc, который часто не соответствует текущей реализации метода… Хватит это терпеть 😉

3. Знакомство с TestEntityFactory

Я предпочитаю разносить классы по их ответственности. Поэтому в примере будут отдельные классы для фасада (TestEntityFactory), сборщика колбэков (ParticipantPrototype), колбэка (Callback), бина с реализацией не терминальных операций (ParticipantPrototypeService), бина с реализацией терминальных операций (ParticipantPrototypeFinisher).

 Далее подробнее о каждом из этих классов.

  1. TestEntityFactory – фасад, который предоставляет доступ к модулю, является Spring Singleton.

@Service @RequiredArgsConstructor public class TestEntityFactory {      private final ParticipantPrototypeService participantPrototypeService;      public ParticipantPrototype participantBy(RankType rankType) {         return new ParticipantPrototype()             .setRankType(rankType)             .setParticipantPrototypeService(participantPrototypeService);     }  }

В клиентском коде доступ к API модуля будет через TestEntityFactory. Пример:

@Autowired protected TestEntityFactory testEntityFactory;
  1. Callback – функциональный интерфейс, в имплементациях которого надо рассказать, как мы хотим модифицировать сущность.

@FunctionalInterface public interface Callback {      void modify(Participant participant);  }

Можно использовать Consumer, но метод apply() в этом контексте не так красноречив, как modify(). Так же можно использовать UnaryOperator<T> (это Function<T, T>) и строить цепочки через andThen(). Но мне больше нравится строить цепочку при помощи List<Callback>.

  1. ParticipantPrototype – POJO, который будет собирать List<Callback> и предоставлять доступ к терминальным и не терминальным методам модуля.

@Getter @Setter @Accessors(chain = true) public class ParticipantPrototype {      private RankType rankType;     private ParticipantPrototypeService participantPrototypeService;     private List<Callback> chain = new ArrayList<>();     private int amount;      /**      * terminal operations      */     public Participant createOne() {         this.amount = 1;         return participantPrototypeService.create(this).get(0);     }      public List<Participant> createMany(int amount) {         this.amount = amount;         return participantPrototypeService.create(this);     }      /**      * intermediate operations      */     public ParticipantPrototype with(Callback callback) {         chain.add(callback);         return this;     }      public ParticipantPrototype withName(String participantName) {         chain.add(participant -> participant.setName(participantName));         return this;     }      public ParticipantPrototype withPost(String customTitle) {         chain.add(participant -> participantPrototypeService.addPost(participant, customTitle));         return this;     }  }

Можно использовать spring scope prototype, однако, здесь нужен только 1 сервис, и проще его передать при создании класса. Тем самым разделив ответственность по сбору информации для создания сущности и реализацию методов терминальных и не терминальных операций.

  1. ParticipantPrototypeFinisher – Spring Singleton, отвечающий за терминальные операции.  

@Service @RequiredArgsConstructor public class ParticipantPrototypeFinisher {      private final ParticipantRepository participantRepository;     private final RankRepository rankRepository;      @Transactional     public List<Participant> create(ParticipantPrototype prototype) {         List<Participant> result = new ArrayList<>();         for (int i = 0; i < prototype.getAmount(); i++) {             result.add(createOne(prototype));         }         return result;     }      private Participant createOne(ParticipantPrototype prototype) {         /**          * step 1 - create minimum possible @Entity and persist          */         RankType rankType = prototype.getRankType();         Rank rank = rankRepository.findByRankType(rankType);          Participant participant = new Participant()             .setName("defaultName")             .setRank(rank);         participantRepository.save(participant);          /**          * step 2 - add default values          */           /**          * step 3 - modify @Entity by chain          */         List<Callback> chain = prototype.getChain();         chain.forEach(callback -> callback.modify(participant));          return participant;     }  }

 5. ParticipantPrototypeService – Spring Singleton, содержит методы с реализацией не терминальных операций. Добавил пример по созданию Post. Так же через этот класс происходит делегирование терминальных операций в ParticipantPrototypeFinisher.

@Service @RequiredArgsConstructor public class ParticipantPrototypeService {      private final ParticipantPrototypeFinisher participantPrototypeFinisher;     private final ParticipantRepository participantRepository;     private final PostRepository postRepository;      public List<Participant> create(ParticipantPrototype prototype) {         return participantPrototypeFinisher.create(prototype);     }      public void addPost(Participant participant, String postTitle) {         Post post = new Post()             .setTitle(postTitle)             .setParticipant(participant);         postRepository.save(post);          participant.getPosts().add(post);         participantRepository.save(participant); // добавил для наглядности, мы в транзакции, сохранит при commit и без .save()     }  }

 

4.       Как работает модуль.

Когда мы в клиентском коде написали нечто подобное:

 Participant participant = testEntityFactory.participantBy(RankType.WRITER)             .withName("customName")             .withPost("post_1")             .withPost("post_2")             .withPost("post_3")             .createOne();

Под капотом происходит следующее:

  1. В первой строке мы создаем прототип. Сигнатура создания должна содержать набор параметров, который необходим для создания “минимально допустимой сущности”. Т.е. такой сущности, которую можно “заперсистить”, не получив исключение, что поле Х не должно быть null или какие-либо ещё валидации на уровне DB/ORM. В моём примере у participant обязан быть name и rank. Я решил, что rank я обязан задавать, а name — можно обойтись дефолтным значением.

  2. С 2 по 5 строку, рассказываем, как мы хотим модифицировать “минимально допустимую сущность”. По дефолту есть метод with(), куда можно передать лямбду:

   public ParticipantPrototype with(Callback callback) {         chain.add(callback);         return this;     }

Если у вас имеются повторяемые лямбды, инкапсулируйте их в метод.

Если модификация простая, реализуйте её в методе:

  public ParticipantPrototype withName(String participantName) {         chain.add(participant -> participant.setName(participantName));         return this;     }

Если методу нужны другие спринговые бины, например Repository – передайте работу в ParticipantPrototypeService — он является спринговым бином, и в него можно инжектить через @Autowired.

 public ParticipantPrototype withPost(String customTitle) {         chain.add(participant -> participantPrototypeService.addPost(participant, customTitle));         return this;     }

Callback добавится в цепочку (List<Callback>), и будет вызван в терминальной операции.

  1. Метод createOne — терминальная операция. FluentInterface ленивый, это значит, что обработка не терминальных операций начнется только тогда, когда начнёт работу терминальная операция. Таким образом мы получаем дополнительный контроль над созданием сущности и можем окружить всё создание в транзакцию.

@Transactional public List<Participant> create(ParticipantPrototype prototype) {

Более того, если нам надо создать несколько сущностей, мы можем попросить фабрику это сделать, при помощи метода createMany(int amount).

    /**      * terminal operations      */     public Participant createOne() {         this.amount = 1;         return participantPrototypeService.create(this).get(0);     }      public List<Participant> createMany(int amount) {         this.amount = amount;         return participantPrototypeService.create(this);     }

Далее ParticipantPrototypeFinisher, в транзакции, создает «минимально допустимую сущность», «персистит» её, заполняет дефолтными значениями, модифицирует при помощи заданной в клиентском коде цепочки не терминальных операций и отдаёт наверх.

Завершение

На примере реализации модуля для создания тестовых сущностей мы рассмотрели создание spring fluent interface pattern.

Данный подход позволяет писать красивый, декларативный, легко расширяемый код, который также позволяет:

  1. В уже написанных тестах не отвлекаться на шум создания тестовых сущностей и видеть структуру только тех полей, которые важны для теста, а остальные поля, которые для теста не важны — будут иметь дефолтные значения.

  2. В новых тестах, переиспользовать модуль и расширять, добавляя новые методы.

  3. В случае изменения каких-то полей в сущностях – делать правку в одном месте – в “кишках” модуля. А не править сигнатуры по всему проекту, что пришлось бы делать, если используется классический статик утиль подход.

  4. Инкапсулировать код, ответственный за создание тестовых сущностей, из класса, общего для всех SpringBootTest классов, в отдельный модуль.

  5. Если ленивость не нужна — можно убрать список лямбд и выполнять работу сразу, тогда получится spring builder pattern.

Код можно посмотреть тут

Примеры в классе FluentInterfaceExampleTest.


ссылка на оригинал статьи https://habr.com/ru/articles/846864/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *