Введение

В процессе разработки часто возникает необходимость в параметризации сложного процесса, которая сопровождается использованием не менее сложных моделей данных.

Инициализация подобных моделей через ломбоковский билдер может привести к некорректной конфигурации объекта, особенно если разработчик недостаточно хорошо знаком со спецификой процесса.

Можно пойти по пути использования конструктора, да это надежно, но бывает неудобно либо из-за большого количества параметров, либо из за их чрезмерной сложности.

Золотая середина между гибкостью и безопасностью – это Stage Builder. Этот подход позволяет:

  • Четко разделить процесс построения объекта на этапы;

  • Гарантировать последовательное заполнение всех обязательных полей;

  • Работать с необязательными параметрами и предусматривать их отсутствие;

  • Упростить поддержку кода, делая процесс конфигурации более интуитивным.

Несколько подходов к реализации подобного билдера представлены в статье "Next-level Java 8 staged builders", здесь же я расскажу как развил эту идею.

1. Optional stage

В оригинальном подходе все этапы считаются обязательными. Однако часто требуется добавить опциональные этапы, которые могут быть пропущены.

Пример реализации:

@Getter
@AllArgsConstructor(access = PRIVATE)
@RequiredArgsConstructor(access = PRIVATE)
public class Model {

    private final String requiredFieldFirst;

    private final String requiredFieldSecond;

    private String optionalField;


    public static RequireFieldFirst<RequireFieldSecond<OptionalStage>> builder() {
        return requiredFieldFirst -> requiredFieldSecond -> new OptionalStage(requiredFieldFirst,
                                                                              requiredFieldSecond);
    }

    public static class StageBuilder {

        @FunctionalInterface
        public interface RequireFieldFirst<T> {

            T requiredFieldFirst(String requiredFieldFirst);

        }

        @FunctionalInterface
        public interface RequireFieldSecond<T> {

            T requiredFieldSecond(String requiredFieldSecond);

        }

        @FunctionalInterface
        public interface OptionalField<T> {

            T optionalField(String optionalField);

        }


        @AllArgsConstructor(access = PRIVATE)
        @RequiredArgsConstructor(access = PRIVATE)
        public static class FinalStage {

            protected final String requiredFieldFirst;

            protected final String requiredFieldSecond;

            private String optionalField;

            public Model build() {
                return new Model(requiredFieldFirst, requiredFieldSecond, optionalField);
            }

        }

        public static class OptionalStage extends FinalStage implements OptionalField<FinalStage> {


            private OptionalStage(String requiredFieldFirst, String requiredFieldSecond) {
                super(requiredFieldFirst, requiredFieldSecond);
            }

            @Override
            public FinalStage optionalField(String optionalField) {
                return new FinalStage(requiredFieldFirst, requiredFieldSecond, optionalField);
            }

        }

    }

}

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

public static RequireFieldFirst<RequireFieldSecond<OptionalBuilder>> builder() {
      return requiredFieldFirst -> requiredFieldSecond -> new OptionalBuilder(requiredFieldFirst,
                                                                                requiredFieldSecond);
}

За счет этого при создании объекта порядок прибит гвоздями.

Доступен только первый этап
Доступен только первый этап
Доступен только второй этап
Доступен только второй этап

Опциональность достигается благодаря классу OptionalStage.

public static class OptionalStage extends FinalStage implements OptionalField<FinalStage> {


    private OptionalStage(String requiredFieldFirst, String requiredFieldSecond) {
        super(requiredFieldFirst, requiredFieldSecond);
    }

    @Override
    public FinalStage optionalField(String optionalField) {
        return new FinalStage(requiredFieldFirst, requiredFieldSecond, optionalField);
    }

}

@AllArgsConstructor(access = PRIVATE)
@RequiredArgsConstructor(access = PRIVATE)
public static class FinalStage {

    protected final String requiredFieldFirst;

    protected final String requiredFieldSecond;

    private String optionalField;

    public Model build() {
        return new Model(requiredFieldFirst, requiredFieldSecond, optionalField);
    }

}

Он наследует FinalStage и имплементирует OptionalField, за счет чего в процессе билда появляется развилка:

Доступен финальный и опциональный этап
Доступен финальный и опциональный этап

Пример использования:

Model build = Model.builder()
        .requiredFieldFirst("first")
        .requiredFieldSecond("second")
        .build();
Model build = Model.builder()
        .requiredFieldFirst("first")
        .requiredFieldSecond("second")
        .optionalField("optional")
        .build();

2. Map builder

Чтобы показать преимущества билдера при создании мап, примера уровня hello world может не хватить. Это небольшой кусок параметризации стратегии по работе с criteria api. В следующей статье опишу целиком. Получилось на основании JpaSpecificationExecutor изобрести абстрактную стратегию построения списков с поиском, сортировкой, фильтрацией и пагинацией.

Пример реализации:

@Getter
@RequiredArgsConstructor(access = PRIVATE)
public class SearchCriteriaConfig {

    private final Map<String, Function<From<?, ?>, List<Path<?>>>> searchConfigMap;

    public static TableName<SearchColumn<FinalStage>> builder() {
        return tableName -> columnName -> new FinalStage(tableName, new HashMap<>(
                Map.of(tableName, new ArrayList<>(List.of(columnName)))));
    }

    public static class SearchRuleStageBuilder {

        @FunctionalInterface
        public interface TableName<T> {

            T tableName(String tableName);

        }

        @FunctionalInterface
        public interface SearchColumn<T> {

            T searchInColumn(String searchColumn);

        }

        @AllArgsConstructor(access = PRIVATE)
        public static class FinalStage implements SearchColumn<FinalStage>, TableName<SearchColumn<FinalStage>> {

            private String tableName;

            private Map<String, List<String>> tableColumnMap;

            public SearchCriteriaConfig build() {
                Map<String, Function<From<?, ?>, List<Path<?>>>> tableConfigMap =
                        tableColumnMap.entrySet()
                                      .stream()
                                      .collect(toMap(Map.Entry::getKey,
                                                     entry -> from -> entry.getValue()
                                                                           .stream()
                                                                           .map(from::get)
                                                                           .collect(toList())));
                return new SearchCriteriaConfig(tableConfigMap);
            }

            @Override
            public FinalStage searchInColumn(String searchColumn) {
                List<String> columnNames = tableColumnMap.get(tableName);
                columnNames.add(searchColumn);
                return new FinalStage(tableName, tableColumnMap);
            }

            @Override
            public SearchColumn<FinalStage> tableName(String tableName) {
                return searchColumn -> {
                    this.tableName = tableName;
                    if (tableColumnMap.containsKey(tableName)) {
                        List<String> searchParams = tableColumnMap.get(tableName);
                        searchParams.add(searchColumn);
                    } else {
                        tableColumnMap.put(tableName, new ArrayList<>(List.of(searchColumn)));
                    }
                    return new FinalStage(tableName, tableColumnMap);
                };
            }

        }

    }

}

Немного контекста. В данном классе содержится мапа, которая описывает в каких колонках в таблице необходимо будет произвести поиск переданного с клиента значения.

Map<String, Function<From<?, ?>, List<Path<?>>>> searchConfigMap;
//Наименование таблицы против функции извлечения путей до колонок из таблицы

Вся магия происходит тут:

FinalStage implements SearchColumn<FinalStage>, TableName<SearchColumn<FinalStage>>

За счет такой имплементации, после завершения порядка этапов, описанного в статичном методе, появляется развилка: добавить еще одну колонку, добавить новую таблицу или произвести билд.

Статический метод с описанием порядка:

public static TableName<SearchColumn<FinalStage>> builder() {
    return tableName -> columnName -> new FinalStage(tableName, new HashMap<>(
            Map.of(tableName, new ArrayList<>(List.of(columnName)))));
}

Развилка:

Развилка
Развилка

И вишенка на торте метод build, где мы можем инкапсулировать всю логику.

public SearchCriteriaConfig build() {
    Map<String, Function<From<?, ?>, List<Path<?>>>> tableConfigMap =
            tableColumnMap.entrySet()
                          .stream()
                          .collect(toMap(Map.Entry::getKey,
                                         entry -> from -> entry.getValue()
                                                               .stream()
                                                               .map(from::get)
                                                               .collect(toList())));
    return new SearchCriteriaConfig(tableConfigMap);
}

На выходе получаем удобный интерфейс для создания сложного объекта.

SearchCriteriaConfig config = SearchCriteriaConfig.builder()
        .tableName("client")
        .searchInColumn("name")
        .tableName("client_contact")
        .searchInColumn("email")
        .build();

Заключение

Staged builder — мощный инструмент, который помогает обеспечить строгую последовательность при построении объектов и предоставляет интуитивный интерфейс.

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


  1. Borya-Globus
    04.12.2024 08:17

    Правильно ли я понял, что при использовании данного подхода минимизируется вероятность что-то забыть проинициализировать и гарантируется ошибка компиляции при расширении существующих dto с билдерами?

    Было бы очень хорошо, если бы была ссылка на проект с примерами кода в гитхабе.


    1. petr-ananev Автор
      04.12.2024 08:17

      Все верно. Репозиторий с примерами прикреплю чуть позже