Здравствуйте!

Маловажное
  • Конечно же подход, описанный в статье супер стар (>=12 лет). Это не делает его менее полезным

  • Изначально я подумал, что реализую что-то вроде Compile time State Machine. А чтобы не плутать по понятиям как я, необходимо учить Паттерны проектирования, чаще выделять места, где вы их применили и смотреть, как это помогло

  • Help me Step Builder! I copypaste!

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

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

Один из самых распространённых примеров -- обращение к базе данных с помощью ванильного JDBC (Предположим, есть/были причины использовать именно и только его). Синтетический пример:

Код getUser()
private @Nullable User getUser(int id) {
        String sql = "SELECT * FROM users_table WHERE id = ?"
        try (Connection dbConnect = myConnectionPool.getConnection()) {
             try(PreparedStatement sqlPrepare = dbConnect.prepareStatement(sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
                sqlPrepare.setInt(1, id);
                try(ResultSet rs = sqlPrepare.executeQuery()) {
                    if (rs.next()) {
                        return new User(rs.getInt("id"), rs.getInt("money"),
                                rs.getString("name"),
                                rs.getString("surname"),
                                rs.getString("patronymic"),
                                rs.getString("login"));
                        }
                }
            } catch (SQLException e) {
                log.error(String.format("Ошибка выполнения запроса \"%s\"", sql), e);
            }
        } catch (SQLException e) {
            log.error("Не удалось получить соединение к БД", e);
        }
        return null;
    }

А теперь сделайте то же для выбора коллекции пользователей по ФИО, тех, у кого больше 1000 "денег" и ещё 100 подобных методов для разных ситуаций в десятке таблиц.

Накопировано таких методов будет много и все они будут примерно одинаково выглядеть

Здесь мы не беспокоимся о производительности, не кешируем PreparedStatement для переиспользования query plan, разве что умозрительно используем абстрактный ConnectionPool.

Но что если мы хотим переиспользовать метод для поиска по другим полям юзера? Всё просто, мы выносим локальную строку sql в аргументы метода.

Окей, тут же появляется куча вопросов, аналогичных тому, что выше:

  1. Что если мы хотим множество параметров?

  2. А если параметры не только типа int?

  3. А если мы хотим выполнять не только .executeQuery(), но и executeUpdate()?

Вариантов "Что если" можно придумать великое множество и все они сводятся к трём требованиям:

  1. Принять произвольные входные данные

  2. Выдать произвольные данные

  3. Минимизировать рутину

А главное, по причине аж трёх конструкций try-with-resources, привычно разбить метод на несколько не выйдет. Но мы попробуем выкрутиться.

С чего начинаем?

Что если мы не разобьём метод, а наоборот, соберём? Прям как какой-нибудь сложный объект.

Всем нам знаком паттерн Builder, позволяющий гибко создать объект с произвольным количеством свойств, не генерируя кучу "телескопических" конструкторов под каждый сценарий. Билдер позволяет собирать объект "по кускам" в разных частях кода, разных контекстах. Давайте попробуем применить подобное тут?

Предположим, мы реализовали такой Builder в классическом варианте -- класс с набором сеттеров:

  • для Connection, сделаем сразу под Supplier<Connection>, чтобы брать каждый раз новый из пула

  • для SQL-строки

  • для Consumer'а PreparedStatement, содержащего логику добавления параметров

  • как альтернатива, для самих параметров по одному

И, помимо этого добавить в Query метод <T> execute(), принимающий Function<ResultSet, T> для преобразования данных из результата выполнения (хотя что делать если мы хотим выполнить executeUpdate()?)

Когда мы реализуем всё описанное, объединим в конечном методе и получим результат, мы увидим, что у нас больно много свободы -- мы можем делать с таким QueryBuilder'ом что угодно -- проставить sql, параметры, затем выполнить, даже не проставив Connection. Ожидаемо, мы получим NullPointerException, попытавшись достать этот самый Connection из Supplier'а. И хорошо, что мы знаем, как собирать такой запрос, но наверняка мы работаем в команде не одни, да и со временем можно забыть, что там и как было устроено.

Что могло бы получиться
class Query {
  private Query(/*args*/) {}
  public static QueryBuilder builder() {} //делаем новый билдер

  public <T> Optional<T> execute(Function<ResultSet, T>) {
    //То же что в первом примере, но с проставлением значений, собранных билдером
  }

  public static class QueryBuilder {
    private Connection c;
    private String sql;
    private List<Consumer<PreparedStatement>> paramSetters = new ArrayList<>();
    
    public QueryBuilder setConnection(Connection)
    public QueryBuilder setSql(String)
    public QueryBuilder setParam(int/long/String/etc...) //Перегружаем под разные типы параметров
    public Query build()
  }
}

Что можем сделать не так:

Query.builder().build().execute(rs -> rs.next); //NullPointerException

С таким мы разве что поймаем NPE или SQLException. В большей степени зависит от решаемой задачи, можно получить и некорректное поведение, что страшнее.

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

Что меняем?

У нас уже есть всё, что необходимо для проставления требуемого, обвязка убрана в недра класса Query, но не хватает главного: порядка действий. Он легко прослеживается в самом первом блоке кода:

  1. Получить Connection

  2. Прописать SQL-statement для выполнения

  3. Проставить параметры запроса согласно знакам вопроса в выражении

  4. Получить данные, конвертировав в нужный тип

На ум приходит только подход "Объявить в классе четыре внутренних интерфейса, возвращающих друг друга в заданном порядке и реализовать их в билдере, каждый вызов метода будет возвращать его же, но под следующим интерфейсом"

Перед тем как действовать, попробуем найти что-то подобное в сети.

По запросу "Structured builder" Google выдаёт вопросы на stackexhcange и stackoverflow, находим парочку статей, которые советуют по такому паттерну:

  • Ещё один билдер в Java -- статья, которой почти 11 лет -- проблема не нова. Здесь рассказывается о похожем подходе к структурированию

  • Step builder pattern -- ещё более старая статья по шаговому строителю -- теперь это совсем то что надо!

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

Реализовав этот шаблон мы обеспечим порядок действий.

Остался последний каприз: у нас всё есть, а теперь я хочу запомнить и использовать этот запрос из любой точки программы множество раз. Для такого нужна иммутабельность всех четырёх шагов. На каждом шаге мы должны создавать новый объект с заданными параметрами.

Реализовать это поведение можно с помощью внутренних классов с приватными конструкторами и методами, ведущими от одного к другому

Что получаем?

Итоговый код класса Query (260 строк)
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;
import java.util.function.Supplier;

/**
 * Класс для удобного исполнения запроса к БД.
 * Представляет собой шаговый билдер запросов.
 * Начните с метода {@link Query#withConnection} и продолжайте заполнять по мере того, что требуют методы
 * Иммутабелен, кроме участка выставления параметров по одному методом {@link StatementSetter#setParam}
 * Если необходима полная иммутабельность, воспользуйтесь методом {@link StatementSetter#setParams}
 */
@Slf4j
public class Query {

    /**
     * Начните отсюда и продолжайте заполнять по мере того, что требуют методы
     * @param connection {@link Supplier<Connection>} возвращающий {@link Connection}
     * @return {@link SqlSetter}, продолжающий набор информации для запроса в базу
     */
    @NonNull
    public static SqlSetter withConnection(@NonNull Supplier<Connection> connection) {
        return new SqlSetter(connection);
    }
    /**
     * Второй шаг. Проставляет SQL запрос в билдер
     */
    public static final class SqlSetter {
        private final Supplier<Connection> connection;
        private SqlSetter(@NonNull Supplier<Connection> connection) {
            this.connection = connection;
        }

        /**
         * @param sql Строка SQL которая будет выполнена
         * @return Следующий шаг
         */
        @NonNull
        public StatementSetter withSql(@NonNull String sql) {
            return new StatementSetter(connection, sql);
        }
    }

    /**
     * Третий шаг. Нужен для проставления параметров в {@link PreparedStatement}
     */
    public static final class StatementSetter {
        private final Supplier<Connection> connection;
        private final String sql;
        private final List<ParamSetter> paramSetters = new ArrayList<>();

        private StatementSetter(Supplier<Connection> connection, String sql) {
            this.connection = connection;
            this.sql = sql;
        }

        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(int param) {
            paramSetters.add(preparedStatement -> preparedStatement.setInt(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(@NonNull String param) {
            paramSetters.add(preparedStatement -> preparedStatement.setString(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(boolean param) {
        paramSetters.add(preparedStatement -> preparedStatement.setBoolean(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(@NonNull Date param) {
            java.sql.Date date = new java.sql.Date(param.getTime());
            paramSetters.add(preparedStatement -> preparedStatement.setDate(paramSetters.size() + 1, date));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(double param) {
            paramSetters.add(preparedStatement -> preparedStatement.setDouble(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(short param) {
            paramSetters.add(preparedStatement -> preparedStatement.setShort(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(float param) {
            paramSetters.add(preparedStatement -> preparedStatement.setFloat(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Выставляет параметр в {@link PreparedStatement}
         * @param param Параметр который должен быть выставлен. Метод множественно перегружен и принимает самые распространённые типы параметров
         * @return Тот же объект, для выставления следующего параметра или перехода к следующему шагу с помощью метода {@link StatementSetter#done}
         */
        @NonNull
        public StatementSetter setParam(long param) {
            paramSetters.add(preparedStatement -> preparedStatement.setLong(paramSetters.size() + 1, param));
            return this;
        }
        /**
         * Вызвать после выставления всех параметров
         * @return Следующий шаг
         */
        @NonNull
        public QueryExecutor done() {
            return new QueryExecutor(connection, sql, paramSetters);
        }

        /**
         * @param statementConsumer консумер для обработки {@link PreparedStatement}
         * @return Следующий шаг
         */
        @NonNull
        public QueryExecutor setParams(@NonNull ParamSetter statementConsumer) {
            return new QueryExecutor(connection, sql, Collections.singletonList(statementConsumer));
        }

    }

    /**
     * Последний шаг до выполнения запроса к БД. Возвращает результат выполнения
     */
    public static final class QueryExecutor {
        private final Supplier<Connection> connection;
        private final String sql;
        private final List<ParamSetter> paramSetters;private QueryExecutor(@NonNull Supplier<Connection> connection,
                              @NonNull String sql,
                              @NonNull List<ParamSetter> paramSetters) {
            this.connection = connection;
            this.sql = sql;
            this.paramSetters = paramSetters;
        }

        /**
         * Выполняет запрос на изменение и возвращает количество изменённых записей
         * @return -1 в случае ошибки. Иначе количество изменённых записей
         */
        public int executeUpdate() {
            return Optional.ofNullable(executeAndGet(preparedStatement -> {
                try {
                    return preparedStatement.executeUpdate();
                } catch (SQLException e) {
                    log.error(String.format("Ошибка выполнения Update запроса \"%s\"", sql), e);
                    return -1;
                }
            })).orElse(0);
        }

        /**
         * @param executor Функция, возвращающая результат, полученный из {@link ResultSet}
         * @param <T> Тип, возвращаемый функцией
         * @return То, что возвращает функция, обернутая в {@link Optional}
         */
        @NonNull
        public <T> Optional<T> executeQuery(@NonNull DataTransformer<ResultSet, T> executor) {
            return Optional.ofNullable(executeAndGet(statement -> {
                try {
                    return executor.transformData(statement.executeQuery());
                } catch (SQLException e) {
                    log.error(String.format("Ошибка выполнения Query запроса \"%s\"", sql), e);
                    return null;
                }
            }));
        }

        /**
         * Нужен для создания и загрузки данными {@link PreparedStatement}.
         * @param executor Функция, возвращающая результат, полученный из {@link PreparedStatement}
         * @param <T> Тип, возвращаемый функцией
         * @return То, что возвращает функция
         */
        @Nullable
        private <T> T executeAndGet(@NonNull DataTransformer<PreparedStatement, T> executor) {
            try (Connection dbConnect = connection.get()) {
                try(PreparedStatement sqlPrepare = dbConnect.prepareStatement(sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
                    for (int i = 0; i < paramSetters.size(); i++) {
                        try {
                            paramSetters.get(i).setParam(sqlPrepare);
                        } catch (SQLException e) {
                            log.error("Не удалось выставить %d по счёту параметр".formatted(i), e);
                        }
                    }
                    executor.transformData(sqlPrepare);
                } catch (SQLException e) {
                    log.error(String.format("Ошибка выполнения запроса \"%s\"", sql), e);
                }
            } catch (SQLException e) {
                log.error("Не удалось получить соединение с базой данных", e);
            }
            return null;
        }
    }

    /**
     * Принимает PreparedStatement, нужен для выставления параметров лямбдой
     */
    public interface ParamSetter {
        /**
         * Принимает PreparedStatement, нужен для выставления параметров лямбдой
         * @param statement будет передан в лямбду для выставления параметров и настроек
         * @throws SQLException если содержимое его выкинет
         */
        void setParam(@NonNull PreparedStatement statement) throws SQLException;
    }
    /**
     * Принимает источник данных (в данном случае PreparedStatement или ResultSet), нужен для трансформации данных лямбдой
     */
    public interface DataTransformer<S, T> {
        /**
         * @param source Источник данных (в данном случае PreparedStatement или ResultSet)
         * @return Результат преобразования
         * @throws SQLException если вылетит при использовании PreparedStatement или ResultSet
         */
        T transformData(S source) throws SQLException;
    }
}

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

@NonNull
public Optional<User> getById(int id) {
    return Query.withConnection(getConnectionSupplier())
        .withSql("SELECT * FROM users_table WHERE %s = ?".formatted(User.Fields.ID))
        .setParam(id)
        .done()
        .executeQuery(resultSet -> {
            if (resultSet.next()) {
                return new User(
                    resultSet.getInt("id"), 
                    resultSet.getInt("money"),
                    resultSet.getString("name"),
                    resultSet.getString("surname"),
                    resultSet.getString("patronymic"),
                    resultSet.getString("login"));
            } 
            return null;
        });
}

Будет неплохо, если для всех репозиториев одной базы данных закешировать и переиспользовать Query.withConnection(getConnectionSupplier()).

Также можно запомнить .withSql("SELECT * FROM users_table WHERE %s = ?".formatted(User.Fields.ID)) для типичных операций, вытаскивающих что-то из User по его ID (хотя и неоптимально по производительности). Вариантов переиспользования море.

Какие выводы делаем?

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

Теперь давайте посмотрим на то, в чём плох и хорош такой билдер: это видно даже на таком простом примере

Плюсы:

  • Легок в использовании, очевиден

  • Сокращает объём телодвижений программиста, уменьшает порывы к бездумному копированию

  • Не допускает вольнодумства (при грамотном проектировании) и отступлений от основного алгоритма

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

  • Можно описать большое количество ветвлений, любое из которых приведёт (при исполнении контракта методов) к корректному результату

Минусы:

  • Как и любой инструмент, его нужно создать и отточить. Сходу сделать сложное не выйдет

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

В статье я попытался не только раскрыть модифицированный шаблон билдера, но и показать, что его можно использовать для построения как условного объекта, так и целого бизнес-процесса (в рамках здравого смысла). Шаблон позволяет спрятать повторяющиеся из раза в раз куски кода, выдать только методы для обеспечения конкретного функционала.

С удовольствием отвечу на критику и предложения по улучшению того, что я написал. Спасибо!

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


  1. DenSigma
    23.05.2024 13:12
    +2

    Другой разработчик, когда ему понадобится выбрать пользователей по имени, видя в базе кода вашу getById, скопипастит ваш код рядышком, немного подправит и сделает getByName.

    Я правильно понимаю?

    Точно такой-же копипастинг, как и просто использование TypedQuery. Только появляется прокладка-велосипед.

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

    Правильно - реализация репозитория с методами UserRepository.getById, UserRepository.getByName, UserRepository.getByFamilyName и так далее. Хоть с сотней методов. И да, если они написаны копипастом с небольшими изменениями, ничего страшного.


    1. Kanayaya Автор
      23.05.2024 13:12

      Целиком и полностью согласен о вынесении в репозиторий. Нет, конечно не предлагаю выносить этот код в БЛ, это залёт. Что я предложил -- способ вынести рутинное (логирование, обработку ошибок, перформансные улучшения и пр.) в отдельный кусок логики. Просто показал на примере кода, который часто вслепую копируют, потому что блоки try-with-resources обязывают держать, собственно, ресурсы внутри себя.

      Конечно, подход с матрёшкой методов (внутри каждого try) тоже имеет место, но описанный выше подход мне кажется более наглядным и выпячивающим суть каждого запроса, при этом прячущим остальное под ковёр.

      Ну и стандартные плюсы про одну точку изменений, разделение ответственности, однообразность кода и пр.

      Да и в принципе шаговый билдер не только про БД, он про любой кусок сложной последовательно требующей новых данных логики, порядок выполнения которой строг. Всё. А что это будет, запрос, кусок бизнес-логики или рецепт пирога -- неважно.


      1. DenSigma
        23.05.2024 13:12

        Да в принципе нормально, прочитал код с интересом. Просто с базами данных - это такая заезженная тема, что там трудно что-то придумать.

        Формирование объекта-алгоритма с заданием последовательности действий? Действия со счетом например. Сначала то, потом это, задать параметры, выполнить то и так далее. Да, спасибо за идею, надо подумать.


  1. sandersru
    23.05.2024 13:12

    Чем то напомнило реактивные пулы на vert.x + mutiny - https://quarkus.io/guides/reactive-sql-clients#using

    или hibernate reactive на том же mutiny


    1. Kanayaya Автор
      23.05.2024 13:12

      Спасибо, прочту. Возможно, подобные подходы использовались и там, за 12 лет можно найти применение


  1. SimSonic
    23.05.2024 13:12

    Использую этот шаблон, он действительно местами очень классно применим, спасибо, что популяризируете :)