… его четверо отлаживают.


Вдохновлённый докладом Владимира Плизги (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)


  1. aegor
    13.02.2019 15:52

    весь проект в прямом смысле слова покраснел:

    А всё потому, что вы не использовали дополнительную прокладку в виде service над репозиториями. Нсли бы она была, красноты стало бы существенно меньше.


    1. tsypanov Автор
      13.02.2019 16:00

      Дело не в прослойке, а в ошибках компиляции.


      @Service
      @RequiredArgsConstructor
      public class Service
        private final SomeRepository someRepository;
      
        @Transactional
        public void foo(long id) {
          SomeEntity something = someRepository.findOne(someId);
          if (something == null) {
            something = someRepository.save(new SomeEntity());
          }
          useSomething(something);
      }

      На СБ1 (для которого Спринг Дата 1.*) это работает, на СБ2 (для которого Спринг Дата 2.*) нужно:


      • вызывать другой метод
      • сменить тип переменной something на Optional<SomeEntity>
      • заменить проверку пустой ссылки на Optional::isPresent или цепочку Optional::orElse/Optional::orElseGet

      И так в каждом сервисе.


  1. Toparvion
    14.02.2019 03:19

    tsypanov, спасибо за статью — интересные кейсы и приятное изложение.
    Вот уж правда обширная тема! Особенно про LDAP — хоть в музей относи)


    1. tsypanov Автор
      14.02.2019 15:46

      Особенно про LDAP — хоть в музей относи

      Да уж, впору начинать их коллекционировать и на собеседованиях просить соискателей рассказать о своих случаях.