Spring Data: нюансы @Transactional

от автора

Rollback по умолчанию

Предположим, что у нас есть сервис, который создает трех пользователей в рамках одной транзакции. Если что-то идет не так, выбрасывается java.lang.Exception.

@Service public class PersonService {   @Autowired   private PersonRepository personRepository;    @Transactional   public void addPeople(String name) throws Exception {     personRepository.saveAndFlush(new Person("Jack", "Brown"));     personRepository.saveAndFlush(new Person("Julia", "Green"));     if (name == null) {       throw new Exception("name cannot be null");     }     personRepository.saveAndFlush(new Person(name, "Purple"));   } }

А вот простой unit-тест.

@SpringBootTest @AutoConfigureTestDatabase class PersonServiceTest {   @Autowired   private PersonService personService;   @Autowired   private PersonRepository personRepository;    @BeforeEach   void beforeEach() {     personRepository.deleteAll();   }    @Test   void shouldRollbackTransactionIfNameIsNull() {     assertThrows(Exception.class, () -> personService.addPeople(null));     assertEquals(0, personRepository.count());   } }

Как думаете, тест завершится успешно, или нет? Логика говорит нам, что Spring должен откатить транзакцию из-за исключения. Следовательно personRepository.count() должен вернуть 0, так ведь? Не совсем.

expected: <0> but was: <2> Expected :0 Actual   :2

Здесь требуются некоторые объяснения. По умолчанию Spring откатывает транзакции только в случае непроверяемого исключения. Проверяемые же считаются «восстанавливаемыми» из-за чего Spring вместо rollback делает commit. Поэтому personRepository.count() возращает 2.

Самый простой исправить это — заменить Exception на непроверяемое исключение. Например, NullPointerException. Либо можно переопределить атрибут rollbackFor у аннотации.

Например, оба этих метода корректно откатывают транзакцию.

@Service public class PersonService {   @Autowired   private PersonRepository personRepository;    @Transactional(rollbackFor = Exception.class)   public void addPeopleWithCheckedException(String name) throws Exception {     addPeople(name, Exception::new);   }    @Transactional   public void addPeopleWithNullPointerException(String name) {     addPeople(name, NullPointerException::new);   }    private <T extends Exception> void addPeople(String name, Supplier<? extends T> exceptionSupplier) throws T {     personRepository.saveAndFlush(new Person("Jack", "Brown"));     personRepository.saveAndFlush(new Person("Julia", "Green"));     if (name == null) {       throw exceptionSupplier.get();     }     personRepository.saveAndFlush(new Person(name, "Purple"));   } }
@SpringBootTest @AutoConfigureTestDatabase class PersonServiceTest {   @Autowired   private PersonService personService;   @Autowired   private PersonRepository personRepository;    @BeforeEach   void beforeEach() {     personRepository.deleteAll();   }    @Test   void testThrowsExceptionAndRollback() {     assertThrows(Exception.class, () -> personService.addPeopleWithCheckedException(null));     assertEquals(0, personRepository.count());   }    @Test   void testThrowsNullPointerExceptionAndRollback() {     assertThrows(NullPointerException.class, () -> personService.addPeopleWithNullPointerException(null));     assertEquals(0, personRepository.count());   }  }

Rollback при «глушении» исключения

Не все исключения должны быть проброшены вверх по стеку вызовов. Иногда вполне допустимо отловить его внутри метода и залогировать информацию об этом.

Предположим, что у нас есть еще один транзакционный сервис, который проверяет, может ли быть создан пользователь с переданным именем. Если нет, выбрасывается IllegalArgumentException.

@Service public class PersonValidateService {   @Autowired   private PersonRepository personRepository;    @Transactional   public void validateName(String name) {     if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) {       throw new IllegalArgumentException("name is forbidden");     }   } }

Давайте добавим валидацию в наш PersonService.

@Service @Slf4j public class PersonService {   @Autowired   private PersonRepository personRepository;   @Autowired   private PersonValidateService personValidateService;    @Transactional   public void addPeople(String name) {     personRepository.saveAndFlush(new Person("Jack", "Brown"));     personRepository.saveAndFlush(new Person("Julia", "Green"));     String resultName = name;     try {       personValidateService.validateName(name);     }     catch (IllegalArgumentException e) {       log.error("name is not allowed. Using default one");       resultName = "DefaultName";     }     personRepository.saveAndFlush(new Person(resultName, "Purple"));   } }

Если валидация не проходит, создаем пользователя с именем по умолчанию.

Окей, теперь нужно протестировать новую функциональность.

@SpringBootTest @AutoConfigureTestDatabase class PersonServiceTest {   @Autowired   private PersonService personService;   @Autowired   private PersonRepository personRepository;    @BeforeEach   void beforeEach() {     personRepository.deleteAll();   }    @Test   void shouldCreatePersonWithDefaultName() {     assertDoesNotThrow(() -> personService.addPeople(null));     Optional<Person> defaultPerson = personRepository.findByFirstName("DefaultName");     assertTrue(defaultPerson.isPresent());   } }

Однако результат оказывается довольно неожиданным.

Unexpected exception thrown: org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

Странно. Мы отловили исключение. Почему же Spring откатил транзакцию? Прежде всего нужно разобраться с тем, как Spring работает с транзакциями.

Под капотом Spring применяет паттерн аспектно-ориентированного программирования. Опуская сложные детали, идея заключается в том, что bean оборачивается в прокси, который генерируются в процессе старта приложения. Внутри этого прокси выполняется требуемая логика. В нашем случае, управление транзакциями. Когда какой-нибудь bean указывает транзакционный сервис в качестве DI зависимости, Spring на самом деле внедряет прокси.

Ниже представлен workflow вызова вышенаписанного метода addPeople.

Параметр propagation у @Transactional по умолчанию имеет значение REQUIRED. Это значит, что новая транзакция создается, если она отсутствует. Иначе выполнение продолжается в текущей. Так что в нашем случае весь запрос выполняется в рамках единственной транзакции.

Однако здесь есть нюанс. Если RuntimeException был выброшен из-за границ transactional proxy, то Spring отмечает текущую транзакцию как rollback-only. Здесь у нас именно такой случай. PersonValidateService.validateName выбросил IllegalArgumentException. Transactional proxy выставил флаг rollback. Дальнейшие операции уже не имеют значения, так как в конечном итоге транзакция не закоммитится.

Каково решение проблемы? Вообще их несколько. Например, мы можем добавить атрибут noRollbackFor в PersonValidateService.

@Service public class PersonValidateService {   @Autowired   private PersonRepository personRepository;    @Transactional(noRollbackFor = IllegalArgumentException.class)   public void validateName(String name) {     if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) {       throw new IllegalArgumentException("name is forbidden");     }   } }

Есть вариант поменять propagation на REQUIRES_NEW. В этом случае PersonValidateService.validateName будет выполнен в отдельной транзакции. Так что родительская не будет отменена.

@Service public class PersonValidateService {   @Autowired   private PersonRepository personRepository;    @Transactional(propagation = Propagation.REQUIRES_NEW)   public void validateName(String name) {     if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) {       throw new IllegalArgumentException("name is forbidden");     }   } }

Возможные проблемы с Kotlin

У Kotlin много схожестей с Java. Но управление исключениями не является одной из них.

Kotlin убрал понятия проверяемых и непроверяемых исключений. Строго говоря, все исключения в Kotlin непроверяемые, потому что нам не требуется указывать конструкции throws SomeException в сигнатурах методов, а также оборачивать их вызовы в try-catch. Обсуждение плюсов и минусов такого решения — тема для отдельной статьи. Но сейчас я хочу продемонстрировать вам проблемы, которые могут из-за этого возникнуть при использовании Spring Data.

Давайте перепишем самый первый пример с java.lang.Exception на Kotlin.

@Service class PersonService(     @Autowired     private val personRepository: PersonRepository ) {     @Transactional     fun addPeople(name: String?) {         personRepository.saveAndFlush(Person("Jack", "Brown"))         personRepository.saveAndFlush(Person("Julia", "Green"))         if (name == null) {             throw Exception("name cannot be null")         }         personRepository.saveAndFlush(Person(name, "Purple"))     } }
@SpringBootTest @AutoConfigureTestDatabase internal class PersonServiceTest {     @Autowired     lateinit var personRepository: PersonRepository      @Autowired     lateinit var personService: PersonService      @BeforeEach     fun beforeEach() {         personRepository.deleteAll()     }      @Test     fun `should rollback transaction if name is null`() {         assertThrows(Exception::class.java) { personService.addPeople(null) }         assertEquals(0, personRepository.count())     } }

Тест падает как в Java.

expected: <0> but was: <2> Expected :0 Actual   :2

Здесь нет ничего удивительного. Spring управляет транзакциями в Kotlin ровно так же, как и в Java. Но в Java мы не можем вызвать метод, который выбрасывает java.lang.Exception, не оборачивая инструкцию в try-catch или не пробрасывая исключение дальше. Kotlin же позволяет. Это может привести к непредвиденным ошибкам и трудно уловимым багам. Так что к таким вещам следует относиться вдвойне внимательнее.

Строго говоря, в Java есть хак, который позволяет выбросить checked исключения, не указывая throws в сигнатуре.

Заключение

Это все, что я хотел рассказать о @Transactionalв Spring. Если у вас есть какие-то вопросы или пожелания, пожалуйста, оставляйте комментарии. Спасибо за чтение!

Spring — самый популярный фреймворк в мире Java. Разработчикам из «коробки» доступны инструменты для API, ролевой модели, кэширования и доступа к данным. Spring Data в особенности делает жизнь программиста гораздо легче. Нам больше не нужно беспокоиться о соединениях к базе данных и управлении транзакциями. Фреймворк сделает все за нас. Однако тот факт, что многие детали остаются для нас скрытыми, может привести к трудно отлавливаемым багам и ошибкам. Так что давайте глубже погрузимся в аннотацию @Transaсtional и узнаем, что же там происходит.

ссылка на оригинал статьи https://habr.com/ru/post/567368/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *