DatabaseClient – это центральный класс в основном пакете R2DBC. Он обрабатывает создание и освобождение ресурсов, что помогает избежать распространенных ошибок, например, если забыть закрыть соединение. Он выполняет базовые задачи основного рабочего процесса R2DBC (такие как создание и выполнение стейтментов), оставляя код приложения, чтобы предоставлять SQL и извлекать результаты. Класс DatabaseClient:

  • Выполняет SQL-запросы

  • Обновляет инструкции и вызовы хранимой процедуры

  • Выполняет обход экземпляров Result

  • Перехватывает исключения R2DBC и преобразовывает их в типизированную, более информативную иерархию исключений, определенную в пакете org.springframework.dao. (см. Согласованная иерархия исключений)

Клиент имеет функциональный, свободный API-интерфейс, использующий реактивные типы для декларативного компонования.

Если вы используете DatabaseClient для своего кода, то необходимо лишь реализовать интерфейсы java.util.function, предоставив им четко определенный контракт. Учитывая Connection, предоставленное классом DatabaseClient, обратный вызов Function создает Publisher. То же самое касается и функций отображения, которые извлекают результат Row.

Можно использовать DatabaseClient внутри реализации DAO путем прямого создания экземпляра со ссылкой на ConnectionFactory, или же можно сконфигурировать его в IoC-контейнере Spring и передать его DAO-объектам в качестве ссылки на бин.

Самый простой способ создания объекта DatabaseClient – это статический фабричный метод, как показано ниже:

Java
DatabaseClient client = DatabaseClient.create(connectionFactory);
Kotlin
val client = DatabaseClient.create(connectionFactory)
ConnectionFactory всегда нужно конфигурировать в IoC-контейнере Spring как бин.

Предыдущий метод создает DatabaseClient с настройками по умолчанию.

Также можно получить экземпляр Builder из DatabaseClient.builder(). Можно настроить клиент, вызывая следующие методы:

  • ….bindMarkers(…): Укажите конкретную фабрику BindMarkersFactory, чтобы сконфигурировать именованный параметр для преобразования маркеров привязки базы данных.

  • ….executeFunction(…): Установите ExecuteFunction, в соответствии с которой будут выполняться объекты Statement.

  • ….namedParameters(false): Отключите расширение именованных параметров. Включено по умолчанию.

Диалекты определяются BindMarkersFactoryResolver из ConnectionFactory, обычно путем проверки ConnectionFactoryMetadata. Вы можете позволить Spring автоматически обнаруживать вашу BindMarkersFactory, зарегистрировав класс, реализующий org.springframework.r2dbc.core.binding.BindMarkersFactoryResolver$BindMarkerFactoryProvider через META-INF/spring.factories. BindMarkersFactoryResolver обнаруживает реализации поставщиков маркеров привязки из classpath, используя SpringFactoriesLoader.

В настоящее время поддерживаются следующие базы данных:

  • H2

  • MariaDB

  • Microsoft SQL Server

  • MySQL

  • Postgres

Все SQL-запросы, выдаваемые этим классом, регистрируются на уровне DEBUG под категорией, соответствующей полному имени класса экземпляра клиента (обычно DefaultDatabaseClient). Кроме того, каждое выполнение регистрирует контрольную точку в реактивной последовательности для облегчения отладки.

В следующих разделах приведены некоторые примеры использования DatabaseClient. Эти примеры не представляют собой исчерпывающий список всех функциональных возможностей, предоставляемых DatabaseClient. См. об этом в сопутствующем javadoc.

Выполнение стейтментов

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

Java
Mono<Void> completion = client.sql("CREATE TABLE person (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), age INTEGER);")
        .then();
Kotlin
client.sql("CREATE TABLE person (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), age INTEGER);")
        .await()

DatabaseClient разработан для удобного, свободного использования. Он раскрывает промежуточные, продолжающие и конечные методы на каждом этапе спецификации выполнения. В предыдущем примере используется then() для возврата завершающегося Publisher, который завершается, как только запрос (или запросы, если SQL-запрос содержит несколько стейтментов) завершится.

execute(…) принимает либо строку SQL-запроса, либо запрос Supplier<String>, чтобы отложить фактическое создание запроса до его выполнения.

Построение запросов (SELECT)

SQL-запросы могут возвращать значения через объекты Row или количество затронутых строк. DatabaseClient может вернуть количество обновленных строк или сами строки, в зависимости от выданного запроса.

Следующий запрос получает столбцы id и name из таблицы:

Java
Mono<Map<String, Object>> first = client.sql("SELECT id, name FROM person")
        .fetch().first();
Kotlin
val first = client.sql("SELECT id, name FROM person")
        .fetch().awaitSingle()

В следующем запросе используется переменная связывания:

Java
Mono<Map<String, Object>> first = client.sql("SELECT id, name FROM person WHERE first_name = :fn")
        .bind("fn", "Joe")
        .fetch().first();
Kotlin
val first = client.sql("SELECT id, name FROM person WHERE WHERE first_name = :fn")
        .bind("fn", "Joe")
        .fetch().awaitSingle()

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

Вызов first() возвращает первую строку из результата и отбрасывает оставшиеся строки. Данные можно потреблять с помощью следующих операторов:

  • first() возвращает первую строку всего результата. Его разновидность сопрограммы на Kotlin называется awaitSingle() для возвращаемых значений, не допускающих пустое значение, и awaitSingleOrNull(), если значение необязательное.

  • one() возвращает ровно один результат и завершается ошибкой, если результат содержит больше строк. Используются сопрограммы Kotlin, а именно awaitOne() для получения ровно одного значения или awaitOneOrNull(), если значение может быть null.

  • all() возвращает все строки результата. При использовании сопрограмм на Kotlin используйте flow().

  • rowsUpdated() возвращает количество затронутых строк (счетчик INSERT/UPDATE/DELETE). Его вариант сопрограммы на Kotlin называется awaitRowsUpdated().

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

Можно управлять отображением результатов, предоставив Function<Row, T>, которая вызывается для каждой Row, чтобы она могла возвращать произвольные значения (единичные значения, коллекции, Map и объекты).

В следующем примере показано извлечение стобца name и порождение его значения:

Java
Flux<String> names = client.sql("SELECT name FROM person")
        .map(row -> row.get("name", String.class))
        .all();
Kotlin
val names = client.sql("SELECT name FROM person")
        .map{ row: Row -> row.get("name", String.class) }
        .flow()
А что насчет null?

Результаты реляционной базы данных могут содержать null значения. Спецификация Reactive Streams запрещает передачу null значений. Это требование подразумевает надлежащую обработку null в функции экстрактора. Хотя и можно получать null значения из Row, не нужно порождать null значение. Нужно обернуть все null значения в объект (например, Optional для единичных значений), чтобы убедиться, что null значение никогда не будет возвращено непосредственно функцией экстрактора.

Обновление (INSERT, UPDATE, и DELETE) с помощью DatabaseClient

Единственное отличие модифицирующих инструкций заключается в том, что эти инструкции обычно не возвращают табличные данные, поэтому для получения результатов используется функция rowsUpdated().

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

Java
Mono<Integer> affectedRows = client.sql("UPDATE person SET first_name = :fn")
        .bind("fn", "Joe")
        .fetch().rowsUpdated();
Kotlin
val affectedRows = client.sql("UPDATE person SET first_name = :fn")
        .bind("fn", "Joe")
        .fetch().awaitRowsUpdated()

Привязка значений к запросам

Типичное приложение требует параметризованных SQL-инструкций для выборки или обновления строк в соответствии с определенными входными данными. Обычно это инструкции SELECT, ограниченные выражением WHERE, или инструкции INSERT и UPDATE, которые принимают входные параметры. Параметризованные инструкции несут риск внедрения SQL, если параметры не приведены в надлежащий вид. DatabaseClient использует API-интерфейс bind из R2DBC для устранения риска внедрения SQL для параметров запроса. Можно указать параметризованную SQL-инструкцию с помощью оператора execute(…) и привязать параметры к фактической Statement. Затем драйвер R2DBC запускает стейтмент с помощью скомпилированного стейтмента и подстановки параметров.

Привязка параметров поддерживает две стратегии привязки:

  • По индексу, используя нулевые индексы параметров.

  • По имени, используя имя-плейсхолдер.

В следующем примере показана привязка параметров для запроса:

db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)")
    .bind("id", "joe")
    .bind("name", "Joe")
    .bind("age", 34);
Нативные маркеры привязки R2DBC

R2DBC использует нативные маркеры привязки базы данных, которые зависят от фактического производителя базы данных. Как пример, в Postgres используются индексированные маркеры, такие как $1, $2, $n. Другим примером является SQL Server, который использует именованные маркеры связывания с префиксом @.

Это отличается от JDBC, который требует ? в качестве маркеров привязки. В JDBC фактические драйверы преобразовывают маркеры привязки ? в нативные маркеры базы данных в процессе выполнения инструкции.

Поддержка R2DBC в Spring Framework позволяет использовать нативные маркеры привязки или именованные маркеры привязки с синтаксисом :name.

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

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

Рассмотрим следующий запрос:

SELECT id, name, state FROM table WHERE (name, age) IN (('John', 35), ('Ann', 50))

Предыдущий запрос можно параметризовать и выполнить следующим образом:

Java
List<Object[]> tuples = new ArrayList<>();
tuples.add(new Object[] {"John", 35});
tuples.add(new Object[] {"Ann",  50});
client.sql("SELECT id, name, state FROM table WHERE (name, age) IN (:tuples)")
    .bind("tuples", tuples);
Kotlin
val tuples: MutableList<Array<Any>> = ArrayList()
tuples.add(arrayOf("John", 35))
tuples.add(arrayOf("Ann", 50))
client.sql("SELECT id, name, state FROM table WHERE (name, age) IN (:tuples)")
    .bind("tuples", tuples)
Возможность использования раскрывающихся списков зависит от производителя.

В следующем примере показан более простой вариант с использованием предикатов IN:

Java
client.sql("SELECT id, name, state FROM table WHERE age IN (:ages)")
    .bind("ages", Arrays.asList(35, 50));
Kotlin
val tuples: MutableList<Array<Any>> = ArrayList()
tuples.add(arrayOf("John", 35))
tuples.add(arrayOf("Ann", 50))
client.sql("SELECT id, name, state FROM table WHERE age IN (:ages)")
    .bind("tuples", arrayOf(35, 50))
Сам R2DBC не поддерживает значения типа Collection. Тем не менее, расширение данного List в приведенном выше примере работает для именованных параметров в средствах поддержки Spring для R2DBC, например, для использования в выражениях IN, как показано выше. Однако для вставки или обновления столбцов с типом массива (например, в Postgres) требуется тип массива, который поддерживается базовым драйвером R2DBC: обычно это массив Java, например, String[] для обновления столбца text[]. Не передавайте Collection<String> или т.п. в качестве параметра массива.

Фильтры стейтментов

Иногда требуется тонко настроить опции для самой Statement, прежде чем она будет запущена. Зарегистрируйте фильтр Statement (StatementFilterFunction) через DatabaseClient для перехвата и модификации инструкций в процессе их выполнения, как показано в следующем примере:

Java
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter((s, next) -> next.execute(s.returnGeneratedValues("id")))
    .bind("name", …)
    .bind("state", …);
Kotlin
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
            .filter { s: Statement, next: ExecuteFunction -> next.execute(s.returnGeneratedValues("id")) }
            .bind("name", …)
            .bind("state", …)

DatabaseClient открывает также упрощенную перегрузку filter(…), принимающую Function<Statement, Statement>:

Java
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter(statement -> s.returnGeneratedValues("id"));
client.sql("SELECT id, name, state FROM table")
    .filter(statement -> s.fetchSize(25));
Kotlin
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter { statement -> s.returnGeneratedValues("id") }
client.sql("SELECT id, name, state FROM table")
    .filter { statement -> s.fetchSize(25) }

Реализации StatementFilterFunction позволяют фильтровать Statement, а также фильтровать объекты Result.

Оптимальные методы работы с DatabaseClient

После конфигурирования экземпляры класса DatabaseClient являются потокобезопасными. Это важно, поскольку означает, что можно сконфигурировать один экземпляр DatabaseClient, а затем безопасно внедрять эту общую ссылку в несколько DAO (или репозиториев). DatabaseClient сохраняет состояние, поскольку хранит ссылку на ConnectionFactory, но это состояние не является диалоговым состоянием.

Общепринятым методом работы при использовании класса DatabaseClient является конфигурирование ConnectionFactory в конфигурационном файле Spring, а затем внедрение зависимостей с этим общем бином ConnectionFactory в классы DAO. DatabaseClient создается в сеттере для ConnectionFactory. Это приводит к появлению DAO примерно следующего вида:

Java
public class R2dbcCorporateEventDao implements CorporateEventDao {
    private DatabaseClient databaseClient;
    public void setConnectionFactory(ConnectionFactory connectionFactory) {
        this.databaseClient = DatabaseClient.create(connectionFactory);
    }
    // Реализации методов для CorporateEventDao с поддержкой R2DBC следуют...
}
Kotlin
class R2dbcCorporateEventDao(connectionFactory: ConnectionFactory) : CorporateEventDao {
    private val databaseClient = DatabaseClient.create(connectionFactory)
    // Реализации методов для CorporateEventDao с поддержкой R2DBC следуют...
}

Альтернативой явной конфигурации является использование сканирования компонентов и поддержка аннотаций для внедрения зависимостей. В этом случае можно пометить класс с помощью аннотации @Component (что делает его кандидатом на сканирование компонентов) и аннотировать метод установки ConnectionFactory с помощью @Autowired. В следующем примере показано, как это сделать:

Java
@Component 
public class R2dbcCorporateEventDao implements CorporateEventDao {
    private DatabaseClient databaseClient;
    @Autowired 
    public void setConnectionFactory(ConnectionFactory connectionFactory) {
        this.databaseClient = DatabaseClient.create(connectionFactory); 
    }
    // Реализации методов для CorporateEventDao с поддержкой R2DBC следуют...
}
  1. Аннотируем класс с помощью @Component.
  2. Аннотируем сеттер ConnectionFactory с помощью @Autowired.
  3. Создаем новый DatabaseClient с помощью ConnectionFactory.
Kotlin
@Component 
class R2dbcCorporateEventDao(connectionFactory: ConnectionFactory) : CorporateEventDao { 
    private val databaseClient = DatabaseClient(connectionFactory) 
    // Реализации методов для CorporateEventDao с поддержкой R2DBC следуют...
}
  1. Аннотируем класс с помощью @Component.
  2. Constructor injection of the ConnectionFactory.
  3. Создаем новый DatabaseClient с помощью ConnectionFactory.

Независимо от того, какой из описанных выше стилей инициализации шаблона вы решите использовать (или не использовать), редко возникает необходимость создавать новый экземпляр класса DatabaseClient всякий раз, когда нужно выполнить SQL. После конфигурирования экземпляр DatabaseClient является потокобезопасным. Если ваше приложение обращается к нескольким базам данных, то может понадобиться несколько экземпляров DatabaseClient, что требует наличия нескольких ConnectionFactory и, соответственно, нескольких по-разному сконфигурированных экземпляров DatabaseClient.

Получение автоматически сгенерированных ключей

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

Java
Mono<Integer> generatedId = client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter(statement -> s.returnGeneratedValues("id"))
        .map(row -> row.get("id", Integer.class))
        .first();
// generatedId порождает сгенерированный ключ после завершения инструкции INSERT
Kotlin
val generatedId = client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter { statement -> s.returnGeneratedValues("id") }
        .map { row -> row.get("id", Integer.class) }
        .awaitOne()
// generatedId порождает сгенерированный ключ после завершения инструкции INSERT