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
и узнаем, что же там происходит.
Kwisatz
Вы извините, не это говнокод какойто. Сервис пользователей который возвращает NullPointerException? Серьезно? Я бы не хотел иметь с таким API ничего общего.
Дальше, имхо, начинается совсем жесть. Ладно валидатор который ексепшен кидает я еще могу понять, но «Если валидация не проходит, создаем пользователя с именем по умолчанию.» совсем никак. Да, я понимаю, что это примеры, но у новичков такие подходы вполне себе отложатся.
ЗЫ как человек давно не писавший на ява, и не юзающий спринг, немного недоумеваю зачем так стрелять себе в ногу. У меня обычно есть обработчик ексепшенов самого верхнего уровня который по типам смотрит что случилось и в большинстве случаев откатывает транзакцию, если это не нужно то транзакция коммитится/откатывается вручную, все предельно очевидно ясно и красиво.
o_nix
придирайся к искусственным примерам, введенным для наглядности
@
ругай джаву
@
говори как у тебя всё хорошо
Kwisatz
А я написал на этот счет что и почему.
Джаву я люблю
Так и рождаются best practice
kacetal
Проблема в том что когда новичок увидит такой пример, для него это будет рабочее решение. И не факт что он сможет понять что так делать нельзя.
tonad
Для этого есть код ревью. ИМХО перегружать примеры, для понимания новичка будет еще хуже.