
Спасаемся от Spring: есть ли альтернативы репозиторным фреймворкам.
Дата
06.03.2025
Место проведения
Санкт-Петербург
Формат
Онлайн
Тема доклада
Монополия Spring на доступ к данным: почему это не очень хорошо, и что с этим делать
В части доступа к базе данных, Java-сообщество однозначно делится на два лагеря: одни любят Spring Data JPA за его простоту и низкий порог вхождения, другие предпочитают Spring JDBC за его точность и возможность детальной настройки. Что с того, что оба фреймворка принадлежат одной компании-разработчику? Плевать: самоотверженному хейту и бесконечным холиварам быть!
Какую сторону выбрать? И Spring Data JPA, и Spring Data JDBC, при их очевидных плюсах, имеют недостатки, делающие разработку на них не очень подходящей для прода. Эти решения являются двумя крайностями, а нам нужна золотая середина.
Вы спросите: какие альтернативы? И я отвечу: давайте посмотрим на проблему шире. Вы джавист? Вам повезло — есть хорошая альтернатива. Котлинист? Ещё лучше — есть отличная альтернатива!
Что мы сделаем?
Чтобы понимать, какой фреймворк считать хорошим, а какой — плохим, мы определим критерии, которые будем считать критичными для репозиторного фреймворка. И проверим фреймворки на соответствие этим идеалам. В конце у нас получится сводная таблица и «ценность» фреймворков согласно бальной системе.
public static void checkNotNull(Object object, String message)
public static void checkNotNull(Object object, String message)
private fun trySuspend(): Boolean
Первое. Разработчик должен иметь контроль над запросами в базу данных.
Запросы в базу данных должны полностью соответствовать ожиданиям разработчика, без неожиданного поведения и побочных эффектов. Разработчик должен иметь возможность собрать именно такой запрос, который ему нужен, и затюнить его в любую сторону.
Контроль над запросами: почему это важно?
Всё дело — в трудностях перевода.
Во время работы нашего приложения, создаются объекты, вызываются функции, сборщик мусора чистит память. За десятки лет виртуальная машина научилась выполнять свои алгоритмы без ошибок. В объектно-ориентированной парадигме, где идентификатором объекта является ссылка на него в другом объекте или стековом фрейме. Единицей указания к действию является алгоритм.
Реляционная база данных реализует реляционный подход, где объектами являются записи базы данных, идентификаторами таких записей являются первичные ключи, ссылками на них являются ключи внешние, и все архитектурные сущности подчинены наложенным на них ограничениям (constraint). Единицей указания к действию является декларация.
Перевод команды с объектно-ориентированного языка на язык реляционный можно сравнить с переводом языка млекопитающего на язык насекомого. Объектно-ориентированный язык запрограммирован настолько отлично от языка реляционного, что сложности перевода при этом неизбежны.
Именно поэтому, окончательный контроль над таким переводом должен происходить на более высоком абстрактном уровне. Этот контроль должен осуществлять человек.
Разработчик должен иметь возможность анализировать запросы приложения в базу данных и изменять их по своему вкусу.
Второе. Репозиторный фреймворк не должен препятствовать поддержке и развитию кодовой базы.
Доработки кода, такие, как расширение функциональности, изменение функциональности и рефакторинг, должны осуществляться максимально удобным для разработчика способом. Это касается и репозиторного фреймворка. Его реализация не должна останавливать разработчика от изменения кода в любой момент.
Удобство в поддержке: почему это так важно?
Главным критерием работоспособности кода, на мой взгляд, является возможность его изменения и рефакторинга в любой момент времени. Мы, разработчики, ошибаемся. И стоимость ошибки, в том числе, зависит от стоимости её исправления. Как только мы видим ошибку в коде, мы должны её исправить. Или запланировать её исправление на самое ближайшее время (для обеспечения этого правила мы, например, добавляем в TODO номер задачи, в рамках которой ошибка должна быть исправлена).
Мы, люди, существа слабые. И мы избегаем неприятных для нас вещей. Мы найдём миллион причин, почему именно сегодня этот баг не должен быть исправлен. И он не будет исправлен — если исправление бага будет сопряжено с трудностями.
Ну а дальше вы знаете: обрастание кода костылями (костыль ведь всегда дешевле, чем нормальный рефакторинг, ведь правда, правда?), переход проекта в жёлтую фазу, потом в коричневую, последующее превращение в Большой Ком Грязи и бегство с проекта ключевых разработчиков.
Пришёл я в одну команду как-то. Дождался планирования.
Аналитик берёт слово: «Давайте в этом спринте реализуем эту фичу». Разработчик: «Это слишком дорого. Мы не успеем в спринт». Аналитик: «Хорошо, давайте тогда вот эту?» Разработчик: «Это тоже слишком дорого».
…
Они так и не договорились на спринт. Любые, даже самые маленькие фичи, оказывались слишком «дорогими». Конечно, этому способствовало общее состояние кодовой базы. И, как потом выяснилось, в значительной части этому поспособствовал выбор репозиторного фреймворка.
Мы же не хотим этого, правда? А раз не хотим, нужно обращать на это внимание прямо на этапе выбора репозиторного фреймворка.
Третье. Соблюдение чистой архитектуры.
В части соблюдения чистой архитектуры, требование одно, но очень важное: вся работа с фреймворком должна инкапсулироваться в репозиторном слое. В части доступа к данным, слой DAL (Data Access Layer) должен зависеть от предметной области приложения, но не наоборот. Предметная область приложения и обслуживающий её сервисный слой не должны ничего знать про внешние интерфейсы, к которым они подключаются.
Соблюдение чистой архитектуры: почему это важно?
Слово Роберту Мартину:
Цель архитектуры программного обеспечения — уменьшить человеческие трудозатраты на создание и сопровождение системы.
Иными словами, чистая архитектура — это деньги, сэкономленные заказчиком. Но при чём здесь Data Access Layer и инкапсуляция репозиторного слоя в его пределах?
А вот при чём.
Наше приложение состоит из предметной области, как было уже указано выше, и бизнес логики, которая её обслуживает. На этом наше приложение вроде как и заканчивается, но для успешной работы ему нужно с чем-то взаимодействовать. Кто-то должен подключаться к нему, чтобы получить результат её работы. К каким-то сервисам должно подключаться наше приложение. Базы данных, сервисы логирования, почтовые клиенты, файловые системы, брокеры сообщений, периферийные устройства, брокеры и прочее. Все эти источники данных имеют свои предметные области и свою логику исполнения.
Чтобы предметные области не перемешивались и не влияли друг на друга, их нужно разделять через абстракцию. В противном случае, предметные области начнут в той или иной степени влиять на вашу предметную область. А ваша предметная область начнёт влиять на предметные области подключаемых к ней сервисов. За счёт высокой связности, системы станут жёсткими — ваш сервис должен будет учитывать любое изменение в системе, которая пустила в нём корни своей предметной областью (подробнее можно прочитать в моей статье Domain-Driven Design: чистая архитектура снизу доверху).
Очень часто команды начинают проектирование сервиса c проектирования базы данных. Это ошибочный подход, поскольку база данных является для сервиса внешним источником данных, со своей предметной областью. Таким образом, предметная область базы данных начинает влиять на предметную область приложения, поскольку ваш сервис уже при проектировании учитывает архитектуру базы данных и завязывается на неё.
Да, такой риск может быть оправдан, особенно если база данных может обслуживать только ваш сервис, но концептуально это всё равно отдельный сервис со своей предметной областью, и в перспективе к ней как к самостоятельному сервису могут подключаться другие сервисы.
Проектирование сервиса нужно начинать со своей предметной области, и более подробно это описано в статье Приложение от проекта до релиза: этапы реализации.
Что ж. Повторим три основных критерия:
- Контроль над запросами.
- Удобство сопровождения кода.
- Соблюдение чистой архитектуры.
Теперь, когда мы определились с критериями, давайте разберём фреймворки Spring на соответствие. И начнём со Spring Data JPA.
Spring Data JPA. Пара слов о фреймворке.
Я сомневаюсь, что кто-то в Java-разработке не знает, что такое Spring Data JPA. Согласно опросу в сообществе Spring АйО, 74% опрошенных используют Spring Data JPA в текущем проекте.
Spring Data JPA стремится к максимальной автоматизации запросов, предоставляя разработчику интуитивно понятное API для работы с базой данных. При этом, фреймворк старается сделать базу данных как можно более абстрактной для разработчика, скрывая информацию о ней. Философия Spring Data JPA декларирует:
«Работайте с объектами, а всеми вопросами хранения данных буду заниматься я».
Все типовые запросы уже реализованы на уровне интерфейсов, которые нужно просто имплементировать. Их работа отшлифована десятилетиями отладки. У разработчика может создаться впечатление, будто базы данных действительно нет.
Типичный репозиторий выглядит так:
interface RestaurantRepository : JpaRepository<Restaurant, UUID>
и прямо «из коробки» содержит в себе все впечатляющее количество функций управления данными. Над реализацией фреймворка была проделана колоссальная работа.
Но давайте посмотрим, насколько Spring Data JPA соответствует нашим требованиям к репозиторным фреймворкам.
Spring Data JPA. Насколько соответствует нашим требованиям?
Почему мы начинаем со Spring Data JPA? Потому что он безусловно лидирует в качестве репозиторного фреймворка в текущих проектах на JVM. Согласно опросу сообщества Spring АйО, проведённому в сентябре прошлого года, 74% опрошенных пользуются в текущих проектах Spring Data JPA — против 76%, которые набрали в сумме все остальные фреймворки. Это ставит Spring Data JPA на безусловное первое место, и именно поэтому начнём мы с него.
Но прежде чем начать, познакомимся с базой данных.
База данных будет самая простая из возможных. Это будет ресторанный агрегатор. Здесь будут пользователи, которые связаны ролями связью Many-To-Many при помощи Join-таблицы. Также, будут рестораны, в ресторанах будут блюда, а заказывать блюда будут те же пользователи. Вот и вся база.
Структуру базы и тестовые данные можно посмотреть здесь:
Что ж, вернёмся к Spring Data JPA.
Spring Data JPA. Удобство сопровождения кода.
Что мы сделали? Мы получили некоторое количество ресторанов. Получили все блюда. Получили все заказы по блюдам. Получили пользователей, оставили только админов. Вернули отфильтрованные рестораны.
В проекте этот код лежит здесь.
Если выполнить этот код и включить логирование запросов, мы увидим целых 34 селекта в базу данных:
Первый, и самый главный, на мой взгляд, плюс: практически нулевой порог вхождения. Spring Data JPA полностью отстраняет разработчика от базы данных, декларируя:
«Забудьте про базу данных, работайте с объектами. Хранение объектов я беру на себя».
И предоставленная функциональность, действительно, позволяет полностью забыть про базу данных, работая с объектами. Вы можете посадить за код любого джуна, который уже кое-как разбирается в Java, но понятия не имеет о базе данных, и он сможет писать рабочий код. И самое неприятное, что Spring Data JPA позаботится о том, чтобы этот код был полностью рабочим — здесь у фреймворка всё отлажено десятилетиями.
Да, в основном это будут запросы findAll и findById, но при умелом жонглировании этими двумя функциями можно получить из базы данных практически любые данные. А на трёх записях в локальной базе эти запросы выдержат любую нагрузку.
Когда я работал в заказной разработке, у меня был такой случай. Я встретил своего коллегу, другого мидла, как и я, и поинтересовался, как у него дела. Он ответил что-то вроде: «Я устал ревьюить код джунов. Здесь сплошные стримы!» На дворе стоял 2019 год, Java 8 наконец-то вошла в силу, и лямды были очень модным решением. Я удивился: «Чем тебе стримы-то не нравятся?» — «Да нет, стримы-то мне нравятся! Но ведь они делают findAll на всю таблицу безо всякой фильтрации и потом уже фильтруют нужные им данные через стримы!»
Давайте разберём этот случай на имеющейся базе данных. Мы напишем запрос, который будет получать какую-то выборку, и далее фильтровать её в соответствии с параметрами фильтрации.
Проект с примерами по Spring Data JPA.
Например, нам нужно получить список ресторанов, в которых хотя бы раз был пользователь с ролью ADMIN. Такой запрос в его самой простой форме (и будьте уверены, что джун соблазнится написать именно такой) будет выглядеть так:
Что мы сделали? Мы получили некоторое количество ресторанов. Получили все блюда. Получили все заказы по блюдам. Получили пользователей, оставили только админов. Вернули отфильтрованные рестораны.
В проекте этот код лежит здесь.
Если выполнить этот код и включить логирование запросов, мы увидим целых 34 селекта в базу данных:
Почему это произошло?
Как мы знаем, фильтрация — это вызов каждого элемента списка в цикле и его проверка на соответствие предикату. Хорошей практикой связывания объекта и таблицы является «ленивая» инициализация, именно поэтому у нас все поля — ленивые. Мы же не хотим обязательных лишних запросов.
И вот, мы получили ресторан и решили проверить его на соответствие условию (ступала ли в него нога админа). Мы запрашиваем все блюда для этого ресторана. Это запрос. Для каждого блюда мы запрашиваем заказы, которые были для него сделаны. Ещё один запрос. Для каждого заказа мы запрашиваем пользователя, который его сделал. Ещё запрос. Для пользователя мы запрашиваем его роли. Ещё запрос.
Всего получилось 34 селекта на базу общим числом в 41 запись (по всем таблицам). Философия Spring Data JPA призывала нас забыть о существовании базы данных и работать с объектами. Мы забыли про базу данных и работаем с объектами. В этом время, при простой фильтрации, фреймворк безостановочно молотит запросы в базу данных.
В той же компании на меня упал дефект по поддержке кода, написанного джуном (порядки в компании были такие, чтобы отправлять код джунов сразу в прод, если он проходит ПСИ на базе в 10 записей, про НТ речи не шло, потому что дорого).
Дефект заключался в том, что один несложный запрос выполнялся 40 секунд, а второй съедал 50 мегабайт оперативки. Каждый. Я заглянул в код, и в обоих случаях увидел вышеописанную картину: содержимое всей таблицы выгребается в оперативку, и потом начинается фильтрация.
Особо одарённые коллеги использовали этот запрос для получения только одного элемента (они не знали про существование findById).
И даже если вы будете очень аккуратны с запросами, где-нибудь вы всё равно пропустите запрос, который будет инициализацией ленивых полей съедать половину ресурсов ЦОД — Spring Data JPA обеспечивает для этого все условия.
Впрочем, если отбросить проблемы чрезмерного потребления ресурсов, с основной обсуждаемой здесь задачей — удобство сопровождения кода — Spring Data JPA блестяще справляется.
Что дальше?
Spring Data JPA. Контроль над запросами
И здесь всё намного хуже. Вы не можете в полной мере, используя магистральное решение Spring Data JPA (опустим пока аннотацию Query, до неё мы ещё доберёмся), контролировать запросы, которые вы пишете. Выше мы уже обсуждали ситуацию, когда запросы выполняются не тогда, когда этого ожидаете вы, а когда их считает нужным выполнить фреймворк. И вам будет очень сложно обуздать лишние запросы, поскольку сама философия Spring Data JPA абстрагирует вас от базы данных и требует, чтобы вы туда не лезли.
С другой стороны, никто не заставлял вас играть в эту игру. Вы выбрали её самостоятельно, так что, примите её правила. Довольно странным решением будет сначала выбрать фреймворк, а потом всеми путями пытаться обойти его философию.
Да, это проблема N + 1. И в Spring Data JPA она встаёт во весь рост. С одной стороны, фреймворк решает, где и какие запросы выполнять. Но с другой стороны, это всего лишь фреймворк. Которому вы доверили ключи от производительности.
Разберём пример.
Переходим в место в проекте со следующим примером.
У нас есть тест, который проверяет обновление имени блюда:
Всё очевиднее некуда. Мы берём блюдо по идентификатору. Оно в базе данных есть. После этого мы меняем значение поля name (я использовал 32-символьное случайное окончание, чтобы быть уверенным, что новое имя будет уникальным). После этого мы обновляем сущность при помощи функции save (функции update в Spring Data JPA нет). В итоге, мы удостоверяемся, что поле обновилось.
Я ожидаю, что выполнится 3 запроса:
- Получение объекта из базы данных по идентификатору.
- Обновление поля объекта в базе данных.
- Повторное получение объекта из базы данных для того, чтобы удостовериться, что поле name обновилось.
Я ожидаю вызова именно этих запросов на том основании, что я как разработчик последовательно вызвал репозиторные функции с очевидными названиями: findById, save и ещё раз findById.
Что ж, запустим тест и посмотрим, какие запросы выполнятся.
Логи нас в значительной степени удивят:
Выполнился только первый select. Мы получили объект из базы данных, и ничего не произошло. Ни обновления объекта, ни повторного получения объекта с целью сравнения полей.
Почему?
Хорошо, здесь всё просто. Мы поставили над тестом аннотацию Transactional. А мы знаем, что при использовании этой аннотации коммит не применяется. Хорошо, давайте вызовем другую функцию: saveAndFlush. Эта функция гарантирует коммит в базу данных.
Запустим тест повторно. Логи на этот раз будут такими:
Теперь мы видим один select и один update. Но второй select, который удостоверит нас в том, что имя успешно записалось в базу данных, по-прежнему отсутствует.
Почему?
Потому что фреймворк закешировал объект при сохранении. И сравнил первоначальный объект не с заново полученным из базы данных, а с закешированным экземпляром. И посчитал, что второй запрос выполнять не обязательно.
Хорошо, давайте всё-таки попытаемся добиться вызова именно тех запросов, которые мы описали в коде. У нас в коде по-прежнему три вызова репозиторных функций, и, возможно, с третьего раза нам удастся вызвать именно те функции, которые мы вызываем из кода.
Для достижения требуемого результата мы пойдём на крайнюю меру — уберём аннотацию Transactional.
Запустим наш тест. Смотрим логи:
К счастью, у нас появился наконец-то третий select. Но их теперь у нас три — перед update у нас появился ещё один select, да ещё и с join-ом. Но откуда?
Дело в том, что, как уже было сказано ранее, Spring Data JPA не имеет функции update. И, получив объект для сохранения, он сначала определяет, новый ли это объект или нет.
Если поле id у объекта отсутствует, он считается новым, и для него вызывается метод persist. Если же поле есть, вызывается метод merge. При этом, из базы извлекается уже имеющийся в ней объект с таким id, в котором обновляются поля и он записывается в базу данных. Таким образом, при обновлении сущности происходит лишний запрос.
Я попытался вызвать ожидаемые запросы тремя разными способами, но мне так это и не удалось. Возможно, удастся вам. Но задача, как мы видим, не тривиальная, и архитектура фреймворка не предполагает тривиальных решений.
Просто примите это. Выбирая Spring Data JPA, вы отказываетесь от полного контроля запросов в базу данных. Для высоконагруженных запросов это может стать критичным препятствием.
Но как же Query?
Да, Query отчасти спасает ситуацию. Мы можем написать SQL-запрос «поверх» вызываемой функции, что даёт некоторую возможность контроля запросов со стороны разработчика. Но у такого подхода есть и минусы.
Подход является по сути обходным путём для основного решения. Это говорит о том, что разработчики фреймворка признают архитектурные проблемы, которые заложены в их решении, и стараются дать спасительную таблетку в виде альтернативы, причём, прямо в целевом решении.
Поскольку JPA-репозитории используют декларативный подход, объявляются через интерфейсы и не имеют реализации, такие запросы могут быть написаны в очень жёстких рамках, регулируемых параметрами аннотации. Вы не можете добавить дополнительную логику обработки запроса, поскольку функциональность Query ограничена параметрами.
Используемая реализация через инжект запроса в виде строкового литерала имеет свои недостатки, но об этом ниже.
Если подвести итог, то в части контроля над запросами Spring Data JPA очень сильно проигрывает. Зафиксируем это и пойдём дальше.
Spring Data JPA. Соблюдение чистой архитектуры
Здесь тоже не всё так хорошо. Ранее мы уже выяснили, почему репозиторий должен быть инкапсулирован в репозиторном слое. Такой подход гарантирует независимость предметной области приложения от влияния внешних источников данных, коим является и база данных в том числе. Допуская влияние базы данных на предметную область приложения, мы подписываемся учитывать дальнейшие изменения в базе данных в предметной области приложения и подстраивать её под эти изменения. Это гарантирует ещё и веерные изменения в бизнес-логике.
Приложение получит дополнительную неподвижность на ровном месте.
Типичная бизнес-сущность, скрещенная с репозиторной и инфицированная репозиторными аннотациями, выглядит так:
Мы знаем практически обо всех атрибутах таблицы: название таблицы, идентификатор, стратегия генерации, имена колонок, связи с другими таблицами и прочее. Эти знания способны серьёзно испортить нам жизнь.
В дополнение к этому, такая связь накладывает на нас некоторые архитектурные обязательства, которые нам в проекте, как бы, и не особо были нужны:
- Репозиторная сущности должна быть открытой для наследования.
- Репозиторная сущность должна иметь публичный конструктор.
- Репозиторная сущность должна быть изменяемой.
и многое другое, что вполне может быть лишним в архитектуре ваших бизнес-сущностей, но вы должны имплементировать в угоду JPA. Каждый из этих пунктов имеет свои недостатки и при определённых обстоятельствах может стать архитектурным кошмаром.
Решения?
Они есть. Самым очевидным решением было бы создание отдельной репозиторной сущности с последующим мапингом её полей на поля доменной сущности.
Но и у этого подхода есть проблемы. Функциональность Spring Data JPA реализована через интерфейсы, которые возвращают репозиторную сущность. И если сервис будет вызывать интерфейс, имплементирующий JpaRepository напрямую, он будет получать репозиторную сущность, чего так же нельзя допускать: согласно луковичной архитектуре, сервис работает только с предметной областью своего приложения и не должен ничего знать о предметных областях внешних источников данных.
Как быть? Решение напрашивается в виде абстракции между сервисом и репозиторием, и здесь команды обычно сдаются. Разделить модели на бизнес-сущности и репозиторные сущности — ещё ок, но когда речь заходит о ещё одном абстрактном слое, единственная задача которого заключается в мапинге репозиторной сущности в бизнес-сущность и обратно, команды приходят к выводу, что прямой мапинг репозиторной сущности на бизнес-сущности через аннотации уже не выглядит таким безобразным.
Таким образом, в части соблюдения чистой архитектуры Spring Data JPA имеет определённые проблемы.
Spring Data JPA. Итого
Таким образом, Spring Data JPA полностью соответствует только одному требованию из трёх — благодаря продуманной архитектуре и отточенной функциональности, фреймворк очень хорошо подходит для быстрого старта небольшого проекта на этапе MVP, что делает его крайне удобным для поддержки. Что касается управления запросами в базу данных, разработчик не может управлять такими запросами в полной мере. Чистую архитектуру фреймворк нарушает, подминая бизнес-сущность под свои нужды.
Таким образом, Spring Data JPA максимально отстраняет разработчика от управления запросами в базу данных, предлагая взамен философию «никакой базы данных нет».
Окей, мы разобрали Spring Data JPA. Да, он очень плохо подходит для проектов, в которых важен тюнинг запросов. Но есть же Spring JDBC, который реализует противоположный подход, отдавая в руки разработчика практически все инструменты управления запросами. Давайте обратим внимание на этот фреймворк и разберём его на соответствие нашим требованиям.
Другие статьи