… его четверо отлаживают.
Вдохновлённый докладом Владимира Плизги (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И так в каждом сервисе.