… его четверо отлаживают.
Вдохновлённый докладом Владимира Плизги (Spring Boot 2: чего не пишут в release notes) я решил рассказать о своём опыте работы со Спринг Бут, его особенностях и подводных камнях, встретившихся на моём пути.
Spring Data JPA
Доклад Владимира посвящён миграции на Спринг Бут 2 и сложностям, которые при этом возникают. В первой части он описывает ошибки компиляции, поэтому я тоже начну с них.
Думаю, многие знакомы с каркасом Spring Data и его производными для различных хранилищ данных. Я работал только с одной из них — Spring Data JPA. Основной интерфейс, используемый в этом фреймворке — JpaRepository
, претерпел значительных изменений в версиях 2.*.
Рассмотрим наиболее используемые методы:
//было
interface JpaRepository<T, ID> {
T findOne(ID id);
List<T> findAll(Iterable<ID> ids);
<S extends T> Iterable<S> save(Iterable<S> entities);
}
//стало
interface JpaRepository<T, ID> {
Optional<T> findById(ID id);
List<T> findAllById(Iterable<ID> ids);
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
}
На самом деле изменений больше, и после переезда на версию 2.* все они превратятся в ошибки компиляции.
Когда на одном из наших проектов встал вопрос о миграции на Спринг Бут 2 и были сделаны первые шаги (замена версии в pom.xml) весь проект в прямом смысле слова покраснел: десятки репозиториев и сотни обращений к их методам привели к тому, что миграция "в лоб" (использование новых методов) зацепила бы каждый второй файл. Представьте простейший код:
SomeEntity something = someRepository.findOne(someId);
if (something == null) {
something = someRepository.save(new SomeEntity());
}
useSomething(something);
Чтобы проект просто собрался необходимо:
- вызывать другой метод
- сменить тип переменной
something
наOptional<SomeEntity>
- заменить проверку пустой ссылки на
Optional::isPresent
или цепочкуOptional::orElse
/Optional::orElseGet
- поправить тесты
К счастью, есть способ проще, благо Спринг Дата предоставляет пользователям ранее невиданные возможности по подгонке репозиториев под свои нужды. Всё что нам нужно — определить (если они ещё не определены) интерфейс и класс, которые лягут в основу всех репозиториев нашего проекта. Делается это вот так:
//основа для всех репозиториев
@NoRepositoryBean
public interface BaseJpaRepository<T, ID extends Serializable> extends JpaRepository<T, ID> {
// определяем метод findOne, подобный существовавшему в версиях 1.*
@Deprecated
T findOne(ID id);
// то же для findAll
@Deprecated // подсветка в коде как напоминание о необходимости выкашивания
List<T> findAll(Iterable<ID> ids);
}
От этого интерфейса будем наследовать все наши репозитории. Теперь реализация:
public class BaseJpaRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> implements BaseJpaRepository<T, ID> {
private final JpaEntityInformation<T, ?> entityInfo;
private final EntityManager entityManager;
public BaseJpaRepositoryImpl(JpaEntityInformation<T, ?> entityInfo, EntityManager entityManager) {
super(entityInfo, entityManager);
this.entityInfo = entityInfo;
this.entityManager = entityManager;
}
@Override
public T findOne(ID id) {
return findById(id).orElse(null); //обеспечиваем совместимость поведения с 1.*
}
@Override
public List<T> findAll(Iterable<ID> ids) {
return findAllById(ids); // просто передаём в новый метод
}
}
Остальное — по образу и подобию. Все ошибки компиляции исчезают, код работает как прежде, а изменено всего 2 класса, а не 200. Теперь можно по ходу разработки неспешно заменять устаревшее АПИ на новое, благо "Идея" заботливо закрасит жёлтым все вызовы отмеченных @Deprecated методов.
Последний мазок — сообщаем Спрингу, что отныне репозитории должны строиться поверх нашего класса:
@Configuration
@EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class)
public class SomeConfig{
}
В тихом омуте бины водятся
В основе Спринг Бута лежит два понятия — стартер и автоконфигурация. В жизни следствием этого является важное свойство — библиотека, попавшая в classpath приложения, просматривается СБ-ом и если в ней найден класс, включающий некую настройку, то она включится без вашего ведома и явного указания. Покажем это на простейшем примере.
Многие используют библиотеку gson для превращения объектов в JSON-строки и наоборот. Часто можно увидеть такой код:
@Component
public class Serializer {
public <T> String serialize(T obj) {
return new Gson().toJson(obj);
}
}
При частых обращениях этот код дополнительно нагрузит сборщик мусора, чего нам не хочется. Особо умные создают один объект и используют его:
@Configuration
public class SomeConfig {
@Bean
public Gson gson() {
return new Gson();
}
}
@Component
@RequiredArgsConstructor
public class Serializer {
private final Gson gson;
public String serialize(T obj) {
return gson.toJson(obj);
}
}
… даже не догадываясь, что Спринг Бут может сделать всё сам. По умолчанию СБ поставляется вместе с зависимостью org.springframework.boot:spring-boot-autoconfigure
, включающей множество классов с именем *AutoConfiguration
, например, такой:
@Configuration
@ConditionalOnClass(Gson.class)
@EnableConfigurationProperties(GsonProperties.class)
public class GsonAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GsonBuilder gsonBuilder(List<GsonBuilderCustomizer> customizers) {
GsonBuilder builder = new GsonBuilder();
customizers.forEach((c) -> c.customize(builder));
return builder;
}
@Bean
@ConditionalOnMissingBean
public Gson gson(GsonBuilder gsonBuilder) {
return gsonBuilder.create();
}
}
Настройка незатейлива как рельс: при наличии в приложении класса Gson
и отсутствии вручную определённого бина этого типа будет создана реализация по умолчанию. В этом — огромная мощь, позволяющая одной зависимостью и парой аннотаций поднять полноценную конфигурацию, на описание которой раньше уходили простыни XML-а и многочисленные рукописные бины.
Но в этом и большое коварство СБ. Коварство заключается в скрытности, ведь много работы происходит под капотом по заранее написанным сценариям. Это хорошо для типового приложения, но выход с проторённой дорожки может вызвать проблемы — конвой стреляет без предупреждения. Множество бинов создаётся без нашего ведома, пример с Гсоном хорошо это показывает. Будьте осторожны с класспасом и вашими зависимостями, ибо...
Всё, что попадёт в ваш classpath, может быть использовано против вас
Случай из жизни. Есть два приложения: в одном используется LdapTemplate
, а в другом он когда-то использовался. Оба приложения зависят от проекта core
, куда вынесены некоторые общие классы, а также в pom.xml были заботливо сложены общие для обоих приложений библиотеки.
Спустя некоторое время из второго проекта все использования LdapTemplate
были за ненадобностью выпилены. Но библиотека org.springramework:spring-ldap
осталась в core
. Потом грянул СБ и org.springramework.ldap:ldap-core
превратился в org.springramework.boot:spring-boot-starter-data-ldap
.
Из-за этого в логах появились интересные сообщения:
Multiple Spring Data modules found, entering strict repository configuration mode!
Spring Data LDAP - Could not safely identify store assignment for repository candidate interface ....
Зависимость от core
привела к тому, что org.springramework.boot:spring-boot-starter-data-ldap
затесался в проект, в котором ЛДАП вообще не используется. Что значит затесался? Попал в classpath :). Далее со всеми остановками:
- Spring Boot замечает наличие
org.springramework.boot:spring-boot-starter-data-ldap
в classpath-е - каждый репозиторий теперь проверяется на предмет того, годится ли он для использования со Spring Data JPA или Spring Data LDAP
- реорганизация зависимостей и выпиливание ненужного
org.springramework.boot:spring-boot-starter-data-ldap
уменьшает время запуска приложения в среднем на 20 (!) секунд из общих 40-50
Внимательный и критически настроенный читатель наверняка спросит: а зачем вообще понадобилось менять org.springramework.ldap:ldap-core
на org.springramework.boot:spring-boot-starter-data-ldap
, если Spring Data LDAP не используется, а используется только один класс org.springframework.ldap.LdapTempate
?
Ответ: это была ошибка. Дело в том, что до версии 2.1.0.M1 автонастройка для ЛДАП-а выглядела примерно вот так
@Configuration
@ConditionalOnClass({ContextSource.class})
@EnableConfigurationProperties({LdapProperties.class})
public class LdapAutoConfiguration {
private final LdapProperties properties;
private final Environment environment;
public LdapAutoConfiguration(LdapProperties properties, Environment environment) {
this.properties = properties;
this.environment = environment;
}
@Bean
@ConditionalOnMissingBean
public ContextSource ldapContextSource() {
LdapContextSource source = new LdapContextSource();
source.setUserDn(this.properties.getUsername());
source.setPassword(this.properties.getPassword());
source.setBase(this.properties.getBase());
source.setUrls(this.properties.determineUrls(this.environment));
source.setBaseEnvironmentProperties(Collections.unmodifiableMap(this.properties.getBaseEnvironment()));
return source;
}
}
Где же LdapTemplate
? А его нет :). Точнее он есть, но лежит в другом месте:
@Configuration
@ConditionalOnClass({LdapContext.class, LdapRepository.class})
@AutoConfigureAfter({LdapAutoConfiguration.class})
public class LdapDataAutoConfiguration {
@Bean
@ConditionalOnMissingBean({LdapOperations.class})
public LdapTemplate ldapTemplate(ContextSource contextSource) {
return new LdapTemplate(contextSource);
}
}
Таким образом, было сделано предположение, что получить LdapTemplate
в своём приложении можно удовлетворением условия @ConditionalOnClass({LdapContext.class, LdapRepository.class})
, что возможно при добавлении в classpath зависимости spring-boot-starter-data-ldap
.
Другая возможность: определить этот бин руками, чего не хочется (ведь зачем нам тогда СБ). До этого додумались уже после замены org.springramework.boot:spring-boot-starter-data-ldap
на org.springramework.ldap:ldap-core
.
Проблема решена здесь: https://github.com/spring-projects/spring-boot/pull/13136. Основное изменение: объявление бина LdapTemplate
переехало в LdapAutoConfiguration
. Теперь можно использовать ЛДАП без привязки к spring-boot-starter-data-ldap
и ручного определения бина LdapTemplate
.
Подкапотная возня
Если вы используете Hibernate, то наверняка знакомы с антипаттерном open-in-view. На его описании останавливаться не будем, по ссылке он описан достаточно подробно, да и в других источниках его вредное воздействие описано весьма подробно.
За включение/выключение этого режима отвечает специальный флаг:
spring:
jpa:
open-in-view: true
В СБ версии 1.* по умолчанию он был включен, при этом пользователю об этом ничего не сообщалось. В версиях 2.* он по прежнему включен, но теперь в журнал пишется предупреждение:
WARN spring.jpa.open-in-view is enabled by default.
Therefore, database queries may be performed during view rendering.
Explicitly configure spring.jpa.open-in-view to disable this warning.
Изначально существовал запрос на отключение этого режима, былинный срач по теме содержит несколько десятков (!) развёрнутых комментариев в т.ч. от Оливера Гёрке (разработчик Спринг Даты), Влада Михалче (разработчик Хибернейта), Фила Веба и Ведран Павича (разработчики Спринга) с мнениями "за" и "против".
Сошлись на том, что поведение меняться не будет, но будет выводится предупреждение (что и наблюдается). Также существует довольно распространённый совет отключать этот режим:
spring:
jpa:
open-in-view: false
Вот собственно и всё, пишите о ваших граблях и интересных особенностях СБ — это поистине неисчерпаемая тема.
Комментарии (4)
Toparvion
14.02.2019 03:19tsypanov, спасибо за статью — интересные кейсы и приятное изложение.
Вот уж правда обширная тема! Особенно про LDAP — хоть в музей относи)tsypanov Автор
14.02.2019 15:46Особенно про LDAP — хоть в музей относи
Да уж, впору начинать их коллекционировать и на собеседованиях просить соискателей рассказать о своих случаях.
aegor
А всё потому, что вы не использовали дополнительную прокладку в виде service над репозиториями. Нсли бы она была, красноты стало бы существенно меньше.
tsypanov Автор
Дело не в прослойке, а в ошибках компиляции.
На СБ1 (для которого Спринг Дата 1.*) это работает, на СБ2 (для которого Спринг Дата 2.*) нужно:
Optional<SomeEntity>
Optional::isPresent
или цепочкуOptional::orElse/Optional::orElseGet
И так в каждом сервисе.