Middle-starter-pack по spring data
кто вообще такой автор статьи
Меня зовут Старакожев Денис,
на момент написания статьи являюсь действующим ИТ лидом кроссфункциональной команды в финтехе (в числе которой 7 back-end разработчиков),
имею 5 лет стажа очень активного коммерческого написания кода и проектирования решений (java back-end),
За плечами есть опыт как работы с древним легаси, так и проекты «с нуля».
(выстрадать успел много чего интересного: бизнес-логика, интеграции, многопоточность, алгоритмы, тестирование и тд)
Для кого эта статья?
В рамках статьи рассмотрю несколько неочевидных моментов, с которыми рано или поздно столкнется любой пользователь spring-data-jpa.
Статья не является исчерпывающим руководством и ориентирована на тех, кто хоть раз ставил аннотацию Transactional в spring приложении и искренне удивлялся когда ожидание не сходилось с результатом.
Так же считаю что любой разработчик уровня middle и выше должен этим вопросом владеть.
Магии не существует
Как показывает практика, многие разработчики относятся к аннотациям в коде как к магическим заклинаниям, при этом даже не задумываясь о том, почему эти «заклинания» вообще работают.
Не могу утверждать на 100% что не существует какой-то Нарнии, но со всей ответственностью заявляю, что в разработке магии точно нет, есть только код, который кто-то написал и который работает всегда ровно так, как он написан.
Код всегда работает так как мы его написали (или не мы), если мы считаем что код работает не так, значит мы что-то не знаем/не учитываем.
оговорочка
Есть, конечно, факторы заставляющие работать код не так как задумано, например космические лучи, но такой вероятностью можно пренебречь.
Так же стоит упомянуть артефакты многопоточности, но это уже совсем другая история (может и про это статью напишу позже).
К делу
Далее посмотрим на несколько примеров.
pom
org.springframework.boot
spring-boot-starter-parent
2.3.5.RELEASE
org.example
transactional-demo
1.0-SNAPSHOT
11
11
UTF-8
org.springframework.boot
spring-boot-starter-data-jpa
org.postgresql
postgresql
true
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
main
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
базовые компоненты
package org.example.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.UUID;
@Data
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "example")
public class ExampleEntity {
@Id
@GeneratedValue
private UUID id;
private String value;
}
package org.example.data;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface ExampleEntityRepository extends CrudRepository {
Optional findByValue(String value);
}
package org.example.data;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ExampleService {
private final ExampleEntityRepository exampleEntityRepository;
public void save(String value) {
exampleEntityRepository.save(ExampleEntity.builder()
.value(value)
.build());
}
public ExampleEntity get(UUID id) {
return exampleEntityRepository.findById(id)
.orElseThrow();
}
}
Кейс 1: классика
Пример, который практически каждый получал на собеседовании.
Что будет с транзакцией при вызове метода saveAll () ?
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
@RequiredArgsConstructor
public class FirstCase {
private final ExampleService exampleService;
public void saveAll() {
saveFirst();
saveSecond();
}
@Transactional
public void saveFirst() {
exampleService.save("first");
}
@Transactional
public void saveSecond() {
exampleService.save("second");
}
}
Любой здравомыслящий человек, который осведомлен о том, как работают аспекты в спринге, догадается, что транзакции не будет
потому что
Транзакции, как и все остальные аспекты, работают за счет декорирования.
В декораторе (классический паттерн по расширению функционала компонента, код которого мы не можем или не хотим изменять).
Концептуальная реализация выглядит примерно так:
package org.example.decorator;
import org.springframework.stereotype.Service;
public class DecoratorExample {
/**
* Наш сервис, который мы придумали
*/
@Service
public static class SomeService {
public Object doSome() {
return new Object();
}
}
/**
* Декоратор, который в случае с аспектами "из коробки" генерируется в рантайме
*/
public static class SomeServiceDecorator extends SomeService {
private final SomeService originService;
public SomeServiceDecorator(SomeService originService) {
this.originService = originService;
}
@Override
public Object doSome() {
before();
try {
Object returnValue = originService.doSome(); // вызов оригинального метода
after(returnValue);
return returnValue;
} catch (Exception e) {
return handle(e);
}
}
private void before() {
// какая-то логика до вызова метода, например по открытию транзакции
}
private void after(Object returnValue) {
// какая-то логика после вызова метода, например по коммиту транзакции
}
private Object handle(Exception e) {
// какая-то логика обработки исключний, например по откату транзакции
throw new RuntimeException(e);
}
}
}
Соответственно для методов, которые помечены «магическими» аннотациями такая логика будет добавлена, а для тех, которые не помечены, останется простое делегирование
package org.example.decorator;
import org.springframework.stereotype.Service;
public class DelegateDecoratorExample {
/**
* Наш сервис который мы придумали
*/
@Service
public static class SomeService {
public Object doSome() {
return new Object();
}
}
/**
* Декоратор, который в случае с аспектами "из коробки" генерируется в рантайме
*/
public static class SomeDelegateServiceDecorator extends SomeService {
private final SomeService originService;
public SomeDelegateServiceDecorator(SomeService originService) {
this.originService = originService;
}
@Override
public Object doSome() {
return originService.doSome(); // вызов оригинального метода
}
}
}
При формировании контекста, ExampleService будет обернут в сгенерированный на ходу декоратор, а так как декоратор наследует (либо реализует те же интерфейсы что и оригинальный класс, никто из потребителей об этом не догадается, если явно не полезет проверять (подробный разбор от гуру).
Если вернуться к вопросу, то получим примерно такую схему вызовов методов
Кейс 2: JPA и persistenceContext
Как показывает практика, большой процент разработчиков, работая с JPA в лице hibernate, прикрытого spring-data, даже не догадываются о существовании persistenceContext (либо в лучшем случае слышали, что такое есть на уровне явления).
Детальный разбор его особенностей будет слишком громоздким для этой статьи (для этих целей лучше прочитать книгу, например «Java Persistence API и Hibernate»).
Пробегусь по поверхности:
persistenceContext — это очень сильно прокаченный кэш, в котором хранится все то, что полученное из БД в рамках транзакции, а так же накапливаются изменения инициированные в рамках этой самой транзакции.
Контекст концептуально имеет 2 набора данных
1. массивы объектов, представляющие собой то, что сейчас видно в БД
2. entity — которые созданы из массивов объектов из п1 (либо новые — руками в коде), именно с ними мы и взаимодействуем в нашей логике.
Следующее ключевое явление это flush контекста, в рамках которого вычисляется дифф между содержимым БД (п1) и результатами нашей работы (п2), и только этот дифф отправляется в БД в виде запросов.
Вопрос когда?
1. обязательно во время коммита транзакции.
2. в любой момент когда хибер сочтет нужным (например перед каким то специфичным селектом в БД).
Таким образом вызов метода save у репозитория совершенно не гарантирует, что запрос будет отправлен сразу, и даже, что запрос будет отправлен после завершения метода помеченного как Transactional, потому что вполне вероятно транзакция открыта на другом уровне по стактрейсу, и коммит будет происходить там же. (пример ниже)
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class FlushCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
private final ExampleService exampleService;
@Transactional
public void firstDoSame() {
secondService.secondDoSome(); // после выполнения это строки нет абсолютно никакой гарантии что мы увидем изменения в БД
exampleService.save("firstValue");
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
@Transactional
public void secondDoSome() {
exampleService.save("secondValue");
}
}
}
Но вот что метод save у репозитория гарантирует нам, это то, что он нам вернет энтити связанную с контекстом.
Часто можно встретить такой код
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleEntity;
import org.example.data.ExampleEntityRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class PersistEntityCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
@Transactional
public void doSome() {
ExampleEntity entity = secondService.getOrCreateEntity("value");
entity.setValue("newValue");
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleEntityRepository exampleEntityRepository;
@Transactional
public ExampleEntity getOrCreateEntity(String value) { // классический подход реализации upsert
return exampleEntityRepository.findByValue(value)
.orElseGet(() -> ExampleEntity.builder()
.value(value)
.build());
}
}
}
Тут мы видим, что человек писавший FirstService, осведомлен о необязательности вызова метода save (), так как достаточно просто модифицировать entity и изменения отправятся в БД за счет autoFlush при коммите транзакции.
Вот только это не произойдет в случае сохранения новой entity, потому что persistenceContext про нее ничего не знает (а узнает он как раз при вызове save).
На мой взгляд есть косяк в реализации SecondService, так как реализация не однозначная,
но в то же время от вызова метода save в FirstService тоже не убудет.
Вопрос про то как проектировать систему и разделять ответственность между компонентами таким образом, чтобы было очевидно с чем мы работаем (с полноценной entity или просто с POJO) вопрос не однозначный, но нужно всегда заручаться гарантиями, что ожидаемое состояние равно действительности.
Кейс 3: атрибут readOnly
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class ReadOnlyCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
@Transactional(readOnly = true)
public void doSome() {
secondService.secondDoSome();
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
@Transactional
public void secondDoSome() {
exampleService.save("someValue");
}
}
}
Транзакция открывается при вызове первого метода на стэке согласно настройкам, указанных в приложенной к нему аннотации, а большинство атрибутов на «вложенных» методах не имеют значения.
Таким образом, в данной картине при вызове метода FirstService.doSome () отправки в БД данных в рамках строки 30 не произойдет (в рамках реализации SecondService.secondDoSome ()).
Все это вполне логично и очевидно, но иногда встречаю попытку обезопасить свой код в таком виде
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class ReadOnlyCase2 {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
@Transactional
public void doSome() {
secondService.secondDoSome();
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
private final OftenChangeSomeService oftenChangeSomeService;
/**
* метод, в котором важно, чтобы в БД не было запросов на запись
*/
@Transactional(readOnly = true)
public void secondDoSome() {
oftenChangeSomeService.logic();
exampleService.save("someValue");
}
}
/**
* Сервис, который разрабатывается другой коммандой и может измениться в любой момент
*/
@Service
public static class OftenChangeSomeService {
public Object logic() {
// нет гарантии что тут не появистся логики на запись
return new Object();
}
}
}
В этом примере видим, что readOnly в строке 32 (над методом SecondService.secondDoSome ()) нам не дает 100% гарантию, если SecondService является чей-то зависимостью.
В целом использование таких атрибутов снижает прозрачность кода, и может вылезти боком, так что для их использования должны быть очень веские причины.
+ обязательно нужно это тестировать, ибо для разных реализаций TransactionManager может поведение отличаться (или вовсе не поддерживаться)
Кейс 4: обработка исключений
Часто вижу примерно такой код, который в принципе, запускается и достаточно долго работает нормально
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class ExceptionCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
private final ExampleService exampleService;
@Transactional
public void firstDoSame() {
exampleService.save("firstValue");
try {
secondService.secondDoSame(); // если не сохранится, то с нас не убудет
} catch (Exception e) {
// какая-то логика с полгощением исключения
}
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
@Transactional
public void secondDoSame() {
exampleService.save("secondValue");
}
}
}
Но потом оказывается, что данных в БД не хватает, а в логах подобные «неведомые ошибки»
А соль в том, что когда исключение «пролетает» через метод помеченный как Transactional, в аспекте меняется статус транзакции, и ее уже нельзя закоммитить.
Есть конечно атрибут noRollbackFor, но это не панацея, потому что из зависимостей могут вылетать совершенно неожиданные исключения.
Способ, на самом деле рабочий, но нужно подходить со знанием дела, а так же учитывать что этим кодом будете пользоваться не только вы.
Так же, наличие noRollbackFor свидетельствует о размытии зон ответственности компонентов либо о попытка использовать try/catch там где больше уместен if/else.
Кейс 5: Transactional и Controller
Так же часто вижу попытки сделать подобный код
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleEntity;
import org.example.data.ExampleEntityRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
public class ExampleController {
@RestController
@RequiredArgsConstructor
public static class ExampleRestController {
private final ExampleEntityRepository exampleEntityRepository;
@GetMapping
public Iterable getAll() {
return exampleEntityRepository.findAll();
}
}
@Controller
@RequiredArgsConstructor
public static class ExampleMvcController {
private final ExampleEntityRepository exampleEntityRepository;
@GetMapping
public String getAll(Model model) {
model.addAttribute(exampleEntityRepository.findAll());
return "page.html";
}
}
}
Не буду распинаться о том, на сколь плохая идея вытаскивать entity на уровень контроллеров, упомяну только почему можно словить LazyInitializationException.
Жизненный цикл persistenceContext напрямую связан с транзакцией.
Транзакция открылась — контекст создался, причем на каждую транзакцию свой контекст (привет многопоточность).
Транзакция закрылась — контекст помер вместе с сессией хибера.
И так как конвертация возвращаемого значения (подстановки значений из модели) происходит за границами транзакций (в недрах DispatcherServlet), к этому моменту все наши entity превращаются в тыкву обычные POJO, а попытки подгрузки ленивых полей приводят к исключениям.
Небольшой БЛИЦ
1. JPA не поддерживает propagation NESTED (просто представим какой геморой будет организовать safePoint для persistenceContext учитывая что ссылки на entity из него могут быть где угодно)
2. нужно иметь очень сильные причины для использования propagation REQUIRES_NEW, а так же глубокое понимание матчасти и стальные гениталии (привет многопоточность, изоляция транзакций и консистентность данных)
3. подружить lombok и hibernate так же отдельная история (привет StackOverflow и LazyInit)
4. при вызове метода save () у репозитория есть гарантия получения актуальной entity, но нет гарантии, что получим тот ж самый объект, который передали параметром
Заключение
Магии не существует, все это особенности реализации (а часто даже часть спецификации).
Нужно изучать инструменты, которыми мы пользуемся и никому нельзя верить на слово, все нужно проверять