В данной статье мы рассмотрим проектирование системы по подходу DB-first и то, какие проблемы он помогает не просто решить, а устранить как явление.
DB-first - это принцип первичности БД перед приложением. Не приложение, а БД владеет контрактом взаимодействия с ней.
Мы смотрим на БД как на сервис с таким же API, как REST, только средством коммуникации служит не JSON, а SQL, а контрактом является не OpenAPI/Swagger, а схема БД в виде DDL. Если БД - это сервис, значит приложения, взаимодействующие с ней, являются клиентами. Клиентов может быть множество. Сервис определяет контракт, который он способен обеспечить, а не клиент.
Приступим.
Постановка
Давайте спроектируем прототип рекомендательной системы музыкального сервиса в духе Яндекс-Музыки или Spotify.
Подобного рода системы опираются на сопоставление объектов по численным критериям. Критерии определяются в процессе профилирования/разметки, реализацию которого мы вынесем за скобки нашей задачи. Зафиксируем лишь, что система профилирования размечает композиции в пространстве из 5 координат, каждая из которых равномерно распределена в диапазоне от 0.0 до 1.0. Похожие композиции имеют близкие координаты, а непохожие - далёкие. Что значат сами координаты для нас неважно.
К сведению, такого рода разметку, как правило, осуществляют с помощью нейросетей, тренируя их на больших данных о поведении людей. Такими данными может послужить множество плейлистов, составленных людьми самостоятельно, а доступ к ним можно получить на большинстве сервисов онлайн-музыки.
Нашу задачу сфокусируем на проектировании системы подбора рекомендаций. У нас есть список функциональных требований, и мы уже выбрали в качестве хранилища Postgres.
Функциональные требования
-
Поиск похожих композиций по набору композиций
Набор композиций определяется списком идентификаторов
Поиск должен выдавать набор композиций с координатами близкими к среднему значению координат композиций, предоставленных на входе
-
Добавление/обновление профиля композиции по параметрам:
Идентификатор композиции
Разметка (embedding): координаты в пятимерном пространстве
Удаление профиля композиции по идентификатору
Проектирование БД
Начнём с определения перекоса нагрузки, так как это предопределяет принципиальное устройство БД. Здравый смысл подсказывает, что операций поиска будет осуществляться на порядки больше, чем операций добавления композиций, а потому систему нужно оптимизировать под поиск. Значит и дизайн БД имеет смысл начать именно с этой операции, и уже под неё подстроить схему и остальные операции.
Поиск похожих композиций по набору композиций
Исполнение любого запроса приводит к путешествию по сети. Это задержки и трафик. Поэтому всегда имеет смысл искать возможность выразить требуемую операцию с помощью одного запроса.
Можем ли мы реализовать требование поиска похожих композиций через один запрос? Да, мы можем осуществить выборку по следующему алгоритму: по представленным идентификаторам композиций вывести их среднюю разметку и по диапазону вокруг этой разметки найти другие композиции, отсортировав по увеличению дистанции. Дистанция - это среднее значение разницы по всем измерениям.
Сделаем набросок данного запроса:
with
reference as (
select
avg(f1) as f1,
avg(f2) as f2,
avg(f3) as f3,
avg(f4) as f4,
avg(f5) as f5
from composition
where
id = any($composition_ids)
)
select
match.id,
(
abs(match.f1 - reference.f1) +
abs(match.f2 - reference.f2) +
abs(match.f3 - reference.f3) +
abs(match.f4 - reference.f4) +
abs(match.f5 - reference.f5)
) / 5 as distance
from reference
right join composition as match on
match.id <> all($composition_ids) and
match.f1 >= reference.f1 - $margin and match.f1 <= reference.f1 + $margin and
match.f2 >= reference.f2 - $margin and match.f2 <= reference.f2 + $margin and
match.f3 >= reference.f3 - $margin and match.f3 <= reference.f3 + $margin and
match.f4 >= reference.f4 - $margin and match.f4 <= reference.f4 + $margin and
match.f5 >= reference.f5 - $margin and match.f5 <= reference.f5 + $margin
order by distance
limit 100
К сведению, параметрам запроса мы дали названия по формату:
$имя_параметра
.
В данном запросе мы подразумеваем следующую схему:
create table composition (
id uuid not null primary key,
f1 float4 not null,
f2 float4 not null,
f3 float4 not null,
f4 float4 not null,
f5 float4 not null,
);
Не забываем про индекс:
create index composition_features on composition (
f1, f2, f3, f4, f5
);
Добавление/обновление профиля композиции по параметрам
Легко реализуется с помощью одного запроса с теми же требованиями к БД:
insert into composition (id, f1, f2, f3, f4, f5)
values ($id, $f1, $f2, $f3, $f4, $f5)
on conflict (id) do update
set
f1 = $f1,
f2 = $f2,
f3 = $f3,
f4 = $f4,
f5 = $f5
Удаление профиля композиции по идентификатору
Это совсем просто:
delete from composition
where id = $id
Подключаем инструментарий
Итак, у нас на выходе из стадии проектирования появился артефакт: схема БД и конечный набор запросов к ней. Ничего из этого мы, правда, ещё не проверяли. Так что, если бы мы хотели передать этот артефакт коллегам, скажем, для осуществления задач по интегрированию с данной БД из кода, стоило бы осуществить проверки. И вот настал момент для знакомства с инструментарием dbfirst.
На сайте dbfirst.ru запросим доступ по программе Preview и получим утилиту dbfirst
для командной строки.
Создадим папку для нашего проекта и сохраним артефакты в виде следующей структуры файлов:
migrations/1.sql - Файл миграции, содержащий объявления схемы БД
queries/select-similar-compositions.sql - Запрос на выборку композиций
queries/upsert-composition.sql - Запрос на создание/обновление композиции
queries/delete-composition.sql - Запрос на удаление композиции
Добавим в корень проекта файл project.dbfirst-v1.yaml
со следующим содержанием:
space: spookify
name: recommendations
version: 1.0.0
Пока не будем вдаваться в детали того, что мы там написали.
Теперь, находясь в директории этого проекта, запустим утилиту dbfirst
. И с порога мы получаем сообщение об ошибке:
syntax error at or near ")"
SQL state code: 42601.
Context:
migrations/1
Точно. В файле миграции у нас была лишняя запятая перед скобкой. Исправим:
create table composition (
id uuid not null primary key,
f1 float4 not null,
f2 float4 not null,
f3 float4 not null,
f4 float4 not null,
f5 float4 not null
);
Прогоняем dbfirst
заново и получаем новую ошибку:
relation "compositions" does not exist
Context:
queries/select-similar-compositions
Правда. Мы же в схеме назвали таблицу composition
. Внесём коррективы в запрос:
with
reference as (
select
avg(f1) as f1,
avg(f2) as f2,
avg(f3) as f3,
avg(f4) as f4,
avg(f5) as f5
from composition
where
id = any($composition_ids)
)
select
match.id,
(
abs(match.f1 - reference.f1) +
abs(match.f2 - reference.f2) +
abs(match.f3 - reference.f3) +
abs(match.f4 - reference.f4) +
abs(match.f5 - reference.f5)
) / 5 as distance
from reference
right join composition as match on
match.id <> all($composition_ids) and
match.f1 >= reference.f1 - $margin and match.f1 <= reference.f1 + $margin and
match.f2 >= reference.f2 - $margin and match.f2 <= reference.f2 + $margin and
match.f3 >= reference.f3 - $margin and match.f3 <= reference.f3 + $margin and
match.f4 >= reference.f4 - $margin and match.f4 <= reference.f4 + $margin and
match.f5 >= reference.f5 - $margin and match.f5 <= reference.f5 + $margin
order by distance
limit 100
Прогоняем утилиту и видим, что результат Ok
.
Что это значит? Это значит, что dbfirst
проверила нашу схему и наши запросы не только на синтаксическую корректность, но и на совместимость запросов со схемой. Теперь мы можем передать запросы в разработку интеграции с чистой совестью. Однако это отнюдь не всё, что нам может дать утилита dbfirst
.
Генерация кода
Дополним файл project.dbfirst-v1.yaml
до следующего:
space: spookify
name: recommendations
version: 1.0.0
artifacts:
- java-jdbc-v1
- haskell-hasql-v1
И прогоним dbfirst
ещё раз. Получаем снова Ok
. Прекрасно! Но что это нам дало?
Обратите внимание на появившуюся папку artifacts
. В ней вы найдёте SDK для языков Java и Haskell. Это библиотеки, предоставляющие полную интеграцию с предоставленными нами запросами. Структуры и типы данных выведены из схемы и запросов и осуществляется кодирование параметров и декодирование результатов. Всё это гарантированно совместимо со схемой и не требует тестирования.
Для наглядности вот кусок сгенерированного кода Java:
public ArrayList<SelectSimilarCompositionsResultRow> selectSimilarCompositions(UUID[] compositionIdsParam, double marginParam) throws SQLException {
Array compositionIdsParamArray = selectSimilarCompositionsStatement.getConnection().createArrayOf("uuid", compositionIdsParam);
selectSimilarCompositionsStatement.setArray(1, compositionIdsParamArray);
selectSimilarCompositionsStatement.setArray(2, compositionIdsParamArray);
selectSimilarCompositionsStatement.setDouble(3, marginParam);
selectSimilarCompositionsStatement.setDouble(4, marginParam);
selectSimilarCompositionsStatement.setDouble(5, marginParam);
selectSimilarCompositionsStatement.setDouble(6, marginParam);
selectSimilarCompositionsStatement.setDouble(7, marginParam);
selectSimilarCompositionsStatement.setDouble(8, marginParam);
selectSimilarCompositionsStatement.setDouble(9, marginParam);
selectSimilarCompositionsStatement.setDouble(10, marginParam);
selectSimilarCompositionsStatement.setDouble(11, marginParam);
selectSimilarCompositionsStatement.setDouble(12, marginParam);
selectSimilarCompositionsStatement.execute();
ArrayList<SelectSimilarCompositionsResultRow> list = new ArrayList<>();
try (ResultSet resultSet = selectSimilarCompositionsStatement.getResultSet()) {
while (resultSet.next()) {
UUID idCol = (UUID) resultSet.getObject(1);
double distanceColDouble = resultSet.getDouble(2);
OptionalDouble distanceCol = resultSet.wasNull() ? OptionalDouble.empty() : OptionalDouble.of(distanceColDouble);
list.add(new SelectSimilarCompositionsResultRow(idCol, distanceCol));
}
}
return list;
}
Полный код Java здесь.
Вот Haskell:
selectSimilarCompositions ::
-- | Specification of how to fold the result rows.
Fold Model.SelectSimilarCompositionsResultRow result ->
Statement Model.SelectSimilarCompositionsParams result
selectSimilarCompositions (Fold step init extract) =
Statement sql encoder decoder True
where
sql =
"with\n\
\ reference as (\n\
\ select\n\
\ avg(f1) as f1,\n\
\ avg(f2) as f2,\n\
\ avg(f3) as f3,\n\
\ avg(f4) as f4,\n\
\ avg(f5) as f5\n\
\ from composition\n\
\ where\n\
\ id = any($1)\n\
\ )\n\
\select\n\
\ match.id,\n\
\ (\n\
\ abs(match.f1 - reference.f1) +\n\
\ abs(match.f2 - reference.f2) +\n\
\ abs(match.f3 - reference.f3) +\n\
\ abs(match.f4 - reference.f4) +\n\
\ abs(match.f5 - reference.f5) \n\
\ ) / 5 as distance\n\
\from reference\n\
\right join composition as match on\n\
\ match.id <> all($1) and\n\
\ match.f1 >= reference.f1 - $2 and match.f1 <= reference.f1 + $2 and\n\
\ match.f2 >= reference.f2 - $2 and match.f2 <= reference.f2 + $2 and\n\
\ match.f3 >= reference.f3 - $2 and match.f3 <= reference.f3 + $2 and\n\
\ match.f4 >= reference.f4 - $2 and match.f4 <= reference.f4 + $2 and\n\
\ match.f5 >= reference.f5 - $2 and match.f5 <= reference.f5 + $2\n\
\order by distance\n\
\limit 100"
encoder =
(Model.selectSimilarCompositionsParamsCompositionIds >$< (Encoders.param (Encoders.nonNullable (Encoders.array (Encoders.dimension foldl' (Encoders.element (Encoders.nullable Encoders.uuid))))))) <> (Model.selectSimilarCompositionsParamsMargin >$< (Encoders.param (Encoders.nonNullable Encoders.float8)))
decoder =
fmap extract . Decoders.foldlRows step init $
Model.SelectSimilarCompositionsResultRow
<$> (Decoders.column (Decoders.nonNullable Decoders.uuid))
<*> (Decoders.column (Decoders.nullable Decoders.float8))
Полный код Haskell здесь.
Здесь весь демонстрационный проект и сгенерированные артефакты.
Разработка сервера
На этом этапе остаётся лишь обернуть сгенерированный SDK в REST API. Как это делать, вы, наверняка, и так знаете.
Рекомендуем лишь обратить внимание на подход Spec-first, который таким же образом отбирая первичность у кода, позволяет вам генерировать код как сервера так и клиента из спецификации контракта в OpenAPI с гарантиями соответствия контракту. На эту тему вы найдёте множество отличных постов. Например, вот, здесь же на Хабре.
В итоге, единственный код, который вам останется написать самому - это конвертация между моделями API и БД. Оставим это за рамками данного поста.
Наблюдения
Интересная практика, не так ли? За буквально часовую работу проектирования мы получили качественные артефакты, которые требуются для любого проекта, интегрирующегося с базой данных, и которые руками программистов реализуются днями или неделями, притом, с нестабильным уровнем качества.
В результате мы получили гарантию, что схема и запросы синтаксически корректны и совместимы. Также мы получили интеграционные SDK для двух очень разных языков программирования, не написав ни строчки в них. Ещё мы избавили наших коллег, отвечающих за тестирование, от необходимости проверять интеграцию на банальные вещи, вроде соответствия типов и исполняемости запросов, чем также сократили длительность конвейера сборки, исключив необходимость прогонять подобные интеграционные тесты.
Иными словами, перед нами открылась возможность снять с программистов задачи по написанию интеграционного кода, получая его автоматически на выходе из стадии проектирования, а с тестировщиков - его проверку.
О dbfirst
Вы можете запросить доступ к Preview нашей SaaS-платформы на сайте dbfirst.ru. Также доступна опция внедрения ПО в ваши проекты.
Мы молодой проект и всегда рады обратной связи и предложениям. Для прямой связи пишите в Telegram: @wormholio.
Комментарии (8)
sugia
19.12.2023 08:00Было бы интересно увидеть, как такой подход можно согласовать с применением Hasura, когда с одной стороны хочется иметь возможность не писать запросы тривиальных выборок, а для сложных запросов применять DB-First.
mojojojo Автор
19.12.2023 08:00Что подразумевается под "согласовать"? И почему хочется не писать запросы тривиальных выборок?
gena_k
19.12.2023 08:00Для выбора ближайших точек индекс btree полезен только для точек, но не для векторов.
В postgresql есть либо cube+gist
Либо сторонее расширение pgvector
mojojojo Автор
19.12.2023 08:00Спасибо за замечание! Однако пока кодогенератор не поддерживает этих типов. Но мы внесём это в планы.
gena_k
19.12.2023 08:00Пожалуйста. В случае с btree, чтобы найти ближайшую точку (значение, не вектор), надо сделать 2 подзапроса: максимальное слева и минимальное справа. Когда не потребуется сканировтаь весь индекс.
mrfloony
Окей, а можете дополнительно для Java показать, как это всё будет работать под JPA и JTA?
mojojojo Автор
На будущее учту запрос на такой туториал. Было бы полезно, если бы вы конкретизировали, что именно в нём хотели бы увидеть.
В общих же словах, вы получаете декоратор над соединением JDBC, который реализует интеграцию со всеми запросами. Соединение с JDBC остаётся под вашим управлением, так что все стандартные практики должны быть применимы.
mojojojo Автор
Пока, возможно, вам будет интересен вот этот пост.