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 и узнаем, что же там происходит.