Как эффективно создать файл CSV - с возможностью потоковой передачи Spring Data JPA со спецификацией JPA (или чем-то еще в этом отношении).
Пример GitHub (если вы предпочитаете просматривать сам код, а не читать эту статью): https://github.com/verzac/demo-spring-data-jpa-stream-and-spec
Почему нам это надо?
Не стесняйтесь пропустить вперед, если вы специально искали эту статью и вам не нужно вдаваться в подробности о том, почему это важно.
Представьте себе следующий сценарий: ваш пользователь хочет загрузить файл CSV, заполненный данными о клиентах, из вашей базы данных на основе динамических критериев (например, их возраст старше 30 лет). Все просто, правда? Любая система должна иметь такую возможность экспорта!
Но подождите, не все так просто. Во-первых, вы не всегда можете загрузить свой набор данных и создать свой CSV в памяти, чтобы ваше приложение не прерывалось самым гротескным образом: из-за нехватки памяти.
Что будем использовать?
Создание CSV - простая часть: все, что вам нужно сделать, это вернуть текст, содержащий все ваши данные. Каждая строка в тексте CSV представляет собой строку (и / или объект в нашем случае), и каждая строка содержит набор данных, разделенных запятыми.
Вот сложная часть: поскольку ваш пользователь хочет фильтровать по определенным критериям динамически, мы должны использовать механизм, который позволяет вам создавать предложения WHERE на лету. Если вы использовали Spring раньше, вы могли заметить, что это не очень просто / элегантно сделать, потому что многие функции, которые Spring настраивает для вас, настраиваются при запуске системы.
Вот почему нам нужна спецификация JPA.
Спецификация JPA позволяет вам динамически создавать предложения WHERE на лету в Spring Data БЕЗ фактической реализации вашего собственного класса репозитория (т.е. иметь дело с EntityManager самостоятельно и создавать запросы вручную, что является скользкой дорогой для большого количества шаблонного кода).
Есть несколько вещей, которые делают спецификацию JPA отличной:
- По умолчанию он поставляется с Spring Data. Существуют сторонние альтернативы спецификации JPA, такие как QueryDSL. Эти сторонние альтернативы могут предоставить лучший API для некоторых разработчиков. Однако спецификация JPA находится поверх существующего API критериев Spring (альтернатива написанию строк SQL самостоятельно), а это означает, что написанная вами логика может быть (в некоторой степени) заменена между спецификацией JPA и API критериев.
- Вы можете смешивать и сопоставлять спецификации, используя примитивную, но работоспособную логическую логику. Поскольку это основано на коде, а не на том, что Spring генерирует автоматически для вас, вы можете смешивать и сочетать на лету по мере необходимости. Например, вы можете объединить две следующие спецификации с помощью оператора AND:
CustomerSpecification.hasName(“Ben”).and(CustomerSpecification.hasJob(“Software Developer”)), что эквивалентноSELECT … WHERE name = ‘Ben’ AND job = ‘Software Developer’.
В этом сообщении в блоге не будет подробно рассказываться о том, как вы можете создавать свои собственные спецификации; уже есть масса ресурсов, которые помогут вам в этом:
- Репозиторий GitHub, приведенный выше, представляет собой образец проекта, в котором реализовано все, о чем мы собираемся здесь поговорить.
- Https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl/
Соединяем все вместе
Итак, как мы можем реализовать это, чтобы создать наш прекрасный CSV-файл?
Загрузите все в память, а затем упорядочите их все в CSV.
Честно говоря, для какой-то системы этого достаточно. Например, если вы знаете, что ваши данные никогда не превысят 100 строк, возможно, вы не захотите перерабатывать свое решение и просто сделайте быстрый однострочный запрос, чтобы реализовать это. Что-то типа…
Однако проблема в том, что это решение не очень масштабируемо и, откровенно говоря, опасно, потому что загрузка всего в память означает, что вы можете загрузить слишком много данных, чтобы поместиться в память вашего сервера приложений. Например, что, если ваши данные растут день ото дня и однажды у вас останется 100 миллионов записей, которые вам нужно будет загрузить в память вашего приложения? Я вам скажу: ваше приложение выйдет из строя.
Что приводит нас к…
Загрузка всего постранично, а затем последовательная запись каждой страницы в вывод в виде CSV
Это второй вариант, и на самом деле самый распространенный. Основная концепция, лежащая в основе этой техники, заключается в том, что вы разделяете свои данные на несколько небольших «страниц» и обрабатываете их постранично. Это предотвращает сбой вашего сервера приложений из-за того, что вы пытались загрузить всю свою базу данных в свое приложение.
Помните, что мы используем PrintWrite для отправки данных по мере их поступления вместо того, чтобы упорядочивать полный список в памяти и затем возвращать их. Это позволяет вам, по сути, сбрасывать содержимое страницы в ваш ответ, прежде чем работать над следующим, эффективно предотвращая нехватку памяти для вашего приложения.
В этом подходе нет ничего плохого. Это стандартный подход, который используется во многих приложениях. Если этот подход вам подходит, не стесняйтесь его использовать.
Однако у этого подхода есть несколько причуд.
Во-первых, вы должны написать свою собственную логику для «итерации» по страницам, что означает больше кода для тестирования и поддержки. Это еще не конец света, но делать это довольно неприятно. Помните, чем меньше кода вы напишете, тем меньше вам придется поддерживать.
Другая, более важная причина заключается в том, что этот подход разбиения на страницы считается менее эффективным, чем следующий подход, который ...
Использование возможностей потоковой передачи Spring Data JPA
Об этом мы и поговорим сегодня.
Вкратце, вызов метода вашего репозитория даст Stream<YourObject>. Ваш поставщик сохраняемости (например, Hibernate через ScrollableResultSet, EclipseLink через ScrollableCursor [1]) решит, как эффективно передавать данные, которые вы хотите, в ваше приложение, хотя будьте уверены, что они могут обычно лучше обрабатывать логику разбиения на страницы. чем если бы вы реализовали их сами.
У этого подхода есть несколько преимуществ:
- Лучшая организация кода, потому что вам не нужно обрабатывать логику итераций вашего набора данных.
- Производительность, так как вам не нужно делать несколько громоздких запросов PageRequest к своей БД.
- Удобно для памяти! [По крайней мере, для PostgreSQL и MySQL] Вы можете ограничить количество элементов, которые вы загружаете в память, до одного элемента за раз, а остальные должны поддерживать сборку мусора!
[1] https://www.baeldung.com/spring-data-java-8
Spring Data уже поддерживает потоки. См. Следующий фрагмент кода, чтобы узнать о различных способах реализации этого:
Но подождите, эти две функции еще не обязательно совместимы друг с другом!
Ходят разговоры о введении результата на основе Stream в JpaSpecificationExecutor (это то, что вы обычно расширяете, если хотите использовать спецификацию JPA), но я не думаю, что за этим стоял какой-то реальный драйв (я потерял номер билета; он похоронен в JIRA Spring Data см. https://jira.spring.io).
По правде говоря, реализовать это (для себя) довольно просто; все, что вам нужно сделать, это использовать getResultStream EntityManager вместо обычного getResultList , который возвращает список (CustomerRepository.findAll использует это под капотом).
Итак, что вы будете делать, когда у вас есть пробел в функциональности Spring Data (помимо самостоятельной реализации всего вашего класса репозитория, просто чтобы использовать новую шикарную функциональность)?
На помощь приходят фрагменты репозитория!
«Фрагменты» - это несколько многоразовые, изготовленные на заказ кусочки головоломки, которые ваш класс репозитория может расширить для добавления пользовательских методов / функций поверх любых существующих общих методов запроса (например, findAll, findById). Вот что говорится в документации Spring Data о фрагментах:
Когда метод запроса требует другого поведения или не может быть реализован путем создания запроса, необходимо предоставить индивидуальную реализацию. Репозитории Spring Data позволяют предоставить пользовательский код репозитория и интегрировать его с универсальной абстракцией CRUD и функциональностью методов запроса.
Тебе это кажется пугающим? Сначала это сработало для меня, но на самом деле это самая приятная вещь, которую я когда-либо обнаруживал, что можно использовать для «полифиллирования» отсутствующей функциональности в Spring Data.
Вот как вы это используете:
Во-первых, вы должны определить интерфейс фрагмента:
А затем реализация для этого интерфейса фрагмента (обратите внимание, что вы можете @Autowire все, что захотите, поскольку Spring создает его как bean-компонент во время выполнения, даже если вы не делаете его компонентом Spring):
Наконец, чтобы использовать настраиваемые методы в вашем репозитории, все, что вам нужно сделать, это расширить интерфейс фрагментов, который мы создали:
Вуаля! Теперь у CustomerRepository будет метод stream (), который принимает ваши спецификации! Вот как это можно использовать:
Имейте в виду, что это не ограничивается спецификацией JPA; используя фрагменты репозитория, вы можете практически заставить свои репозитории вести себя так, как вы хотите, поскольку у вас есть полный контроль над тем, как выполняется метод.
Важные лакомые кусочки
Потоки можно открывать только в транзакции
Это связано с тем, что, когда мы возвращаем данные в потоке, мы должны поддерживать состояние БД достаточно стабильным, чтобы мы могли безопасно просматривать его данные. Вдобавок к этому по умолчанию ваши сеансы БД «закрываются» после того, как вы что-то сделали в Spring Data, но потоки не обязательно работают таким образом. Соединение / сеанс должны быть гарантированно открыты, когда поток еще открыт и еще не закрыт, поскольку данные могут быть получены клиентом.
EntityManager :: отсоединить
Теперь, в зависимости от того, кого вы спросите, это может быть необязательно. Я не очень внимательно изучал, как обрабатываются объекты с потоковой передачей, но я полагаю, что это не повредит (и все источники, на которые я смотрел, рекомендовали это).
Насколько мне известно, объекты не будут автоматически GC’d и удалены из памяти, ЕСЛИ они не были сначала отсоединены (что, как я полагаю, имеет смысл). Помните, что все это происходит в транзакции, поэтому возвращаемые «объекты» привязаны к контексту персистентности, что означает, что любые изменения объектов синхронизируются с вашей БД.
Размер выборки? Это что?
Это устанавливает количество строк, которые драйвер JDBC (т.е. то, что фактически подключается к вашей БД из вашей системы) должен извлекать в определенный момент времени. Разным драйверам БД требуются разные значения для выполнения построчной выборки (что мы и собираемся здесь делать). Насколько я помню, MySQL требует, чтобы HINT_FETCH_SIZE было "" + Integer.MIN_VALUE, а PostgreSQL требует, чтобы значение было установлено в 1.
Вот и все! Теперь вы можете расширить StreamableJpaSpecificationRepository в любом репозитории, которому нужна эта спецификация + возможность потоковой передачи.
Должен ли я использовать спецификацию JPA?
Нет. Репозиторий позволяет вам использовать что угодно для определения ваших «критериев». Если он совместим с Criteria API (или EntityManager, если на то пошло), вы можете использовать этот метод для подключения любых недостающих функций.
Опять же, если вы хотите поиграть с образцом приложения, которое я настроил, ознакомьтесь с моим репозиторием на GitHub, в котором есть сценарий SQL, который заполнит вашу БД 500000 записей фиктивных данных:
Следующие шаги
Есть несколько следующих шагов, которые вы можете сделать для оптимизации этой функциональности:
- Вы можете отделить свои функции потоковой передачи CSV в отдельную службу, которая обрабатывает всю эту логику, чтобы вы могли повторно использовать ее для других компонентов. Я сделал это для предыдущего проекта, и это позволило любым контроллерам воспользоваться этой функцией; все, что нужно было сделать контроллерам, - это передать в службу PrintWriter своего ответа.
- Объедините его с StreamingResponseBody, чтобы переложить тяжелую работу на другой поток, чтобы вы могли освободить поток сервлета HTTP, который не позволяет вам заблокировать ваше приложение. Это полезно для больших наборов данных. В этой статье на Medium я нашел этот изящный трюк:
- Скажите своим пользователям, чтобы они перестали запрашивать эту функцию, чтобы вам не пришлось ее создавать;)
Спасибо за чтение! Надеюсь, вам понравилась эта статья, и не стесняйтесь присылать мне любые отзывы в своих ответах :)