Spring – самый популярный Java-фреймворк. В нем есть множество готовых решений для Интернета, безопасности, кэширования и доступа к данным. Spring Data особенно облегчает жизнь разработчику. Нам не нужно беспокоиться о подключениях к базе данных и управлении транзакциями. Фреймворк выполняет свою работу. Но тот факт, что он скрывает от нас некоторые важные детали, может привести к трудному отслеживанию ошибок и проблем. Итак, давайте глубоко погрузимся в аннотацию @Transactional .
Поведение отката по умолчанию
Предположим, что у нас есть простой метод обслуживания, который создает 3 пользователя во время одной транзакции. Если что-то пойдет не так, он выдает java.lang. Исключение .
@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"));
}
}
А вот простой модульный тест.
@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 выполняет фиксацию вместо отката. Вот почему PersonRepository.count() возвращает 2.
Самый простой способ исправить это – заменить проверенное исключение непроверенным (например, Исключение NullPointerException ). Или же мы можем использовать атрибут аннотации Откат для .
Например, оба эти случая совершенно справедливы.
@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 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());
}
}
Откат при подавлении исключений
Не все исключения должны быть распространены. Иногда допустимо поймать его и записать информацию о нем.
Предположим, что у нас есть другой транзакционный сервис, который проверяет, можно ли создать человека с данным именем. Если это не так, он выдает исключение 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");
}
}
}
Давайте добавим проверку в наш сервис Person .
@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 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 ставит прокси-сервер.
Вот рабочий процесс для определенного метода добавить людей .
По умолчанию @Транзакционное распространение является ОБЯЗАТЕЛЬНЫМ . Это означает, что новая транзакция создается, если она отсутствует. И если он уже присутствует, то поддерживается текущий. Таким образом, весь запрос выполняется в рамках одной транзакции.
В любом случае, есть одно предостережение. Если исключение RuntimeException выбрасывается из транзакционного прокси-сервера, Spring помечает текущую транзакцию только как откат. Именно это и произошло в нашем случае. Служба проверки личности.проверка имени выдает Исключение IllegalArgumentException . Транзакционный прокси-сервер отслеживает его и устанавливает флаг отката. Более поздние исполнения во время транзакции не имеют никакого эффекта, потому что в конце их следует откатить.
Каково же решение? Их есть несколько. Например, мы можем добавить атрибут noRollbackFor в службу Проверки личности .
@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");
}
}
}
Другой подход заключается в изменении распространения транзакции на REQUIRES_NEW . В этом случае Служба проверки личности.проверка имени будет выполнена в отдельной транзакции. Таким образом, родительский не будет откатан.
@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 много общего с Java. Но управление исключениями – это не тот случай.
Котлин устранил идею проверено и непроверенные исключения. По сути, любое исключение в языке является непроверенным , потому что нам не нужно указывать вызывает исключение SomeException в объявлении метода. Плюсы и минусы этого решения должны быть темой для другого рассказа. Но теперь я хочу показать вам проблемы, которые могут возникнуть при использовании данных Spring.
Давайте перепишем самый первый пример статьи с помощью java.lang. Исключение в Котлине.
@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 управлял транзакциями одинаково как в Java, так и в Kotlin. Но в Java мы не можем выполнить метод, который выдает java.lang. Исключение не заботясь об этом. Котлин позволяет это. Это может привести к неожиданным ошибкам, поэтому вам следует уделять особое внимание таким случаям.
Вывод
Это все, что я хотел рассказать вам о Весне @Транзакционный аннотация. Если у вас есть какие-либо вопросы или предложения, пожалуйста, оставьте свои комментарии ниже. Спасибо за чтение!
Оригинал: “https://dev.to/kirekov/spring-data-transactional-caveats-19di”