Всем привет.

Я разрабатываю приложения с использованием 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.

Комментарии (3)


  1. vic_1
    30.09.2024 17:38

    Не знаю насчёт Fluent interface, но то что описано в статье называется builder. Возможно кто-то расскажет в чем разница


    1. AlekseyShibayev Автор
      30.09.2024 17:38

      Существует 2 очень похожих паттерна, которые в клиентском коде выглядят как вертикальная запись через точку: builder, fluent interface.

      С практической точки зрения я бы классифицировал их следующим образом:

      1. Ленивость - действие выполняется в момент вызова промежуточного метода, или действие выполняется только в момент вызова терминальной операции.

      2. По типу возвращаемого значения. Возвращается тот же самый тип, или другой.

      Представленный пример можно назвать ленивым спринговым билдером.