Всем привет.
Я разрабатываю приложения с использованием 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).
Далее подробнее о каждом из этих классов.
-
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;
-
Callback – функциональный интерфейс, в имплементациях которого надо рассказать, как мы хотим модифицировать сущность.
@FunctionalInterface public interface Callback { void modify(Participant participant); }
Можно использовать Consumer, но метод apply() в этом контексте не так красноречив, как modify(). Так же можно использовать UnaryOperator<T> (это Function<T, T>) и строить цепочки через andThen(). Но мне больше нравится строить цепочку при помощи List<Callback>.
-
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 сервис, и проще его передать при создании класса. Тем самым разделив ответственность по сбору информации для создания сущности и реализацию методов терминальных и не терминальных операций.
-
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();
Под капотом происходит следующее:
-
В первой строке мы создаем прототип. Сигнатура создания должна содержать набор параметров, который необходим для создания “минимально допустимой сущности”. Т.е. такой сущности, которую можно “заперсистить”, не получив исключение, что поле Х не должно быть null или какие-либо ещё валидации на уровне DB/ORM. В моём примере у participant обязан быть name и rank. Я решил, что rank я обязан задавать, а name — можно обойтись дефолтным значением.
-
С 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>), и будет вызван в терминальной операции.
-
Метод 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.
Данный подход позволяет писать красивый, декларативный, легко расширяемый код, который также позволяет:
-
В уже написанных тестах не отвлекаться на шум создания тестовых сущностей и видеть структуру только тех полей, которые важны для теста, а остальные поля, которые для теста не важны — будут иметь дефолтные значения.
-
В новых тестах, переиспользовать модуль и расширять, добавляя новые методы.
-
В случае изменения каких-то полей в сущностях – делать правку в одном месте – в “кишках” модуля. А не править сигнатуры по всему проекту, что пришлось бы делать, если используется классический статик утиль подход.
-
Инкапсулировать код, ответственный за создание тестовых сущностей, из класса, общего для всех SpringBootTest классов, в отдельный модуль.
-
Если ленивость не нужна — можно убрать список лямбд и выполнять работу сразу, тогда получится spring builder pattern.
Примеры в классе FluentInterfaceExampleTest.
ссылка на оригинал статьи https://habr.com/ru/articles/846864/
Добавить комментарий