Всем привет.
Я разрабатываю приложения с использованием 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.
vic_1
Не знаю насчёт Fluent interface, но то что описано в статье называется builder. Возможно кто-то расскажет в чем разница
AlekseyShibayev Автор
Существует 2 очень похожих паттерна, которые в клиентском коде выглядят как вертикальная запись через точку: builder, fluent interface.
С практической точки зрения я бы классифицировал их следующим образом:
Ленивость - действие выполняется в момент вызова промежуточного метода, или действие выполняется только в момент вызова терминальной операции.
По типу возвращаемого значения. Возвращается тот же самый тип, или другой.
Представленный пример можно назвать ленивым спринговым билдером.