Рубрики
Без рубрики

Весенние данные — Транзакционные предостережения

Spring – самый популярный Java-фреймворк. В нем есть множество готовых решений для Интернета, безопасности,… Помечено как java, программирование, база данных.

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 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”