Тема . Обработка ошибок в контроллерах Spring - BelyiZ/JavaCourses GitHub Wiki
- Особенности запуска программы
- Сложные запросы к БД
- Обзор возможностей Spring Cache
- Список литературы/курсов
JDBC API
предоставляет программный доступ к реляционным базам данных из программ, написанных на Java.
Так же рассказывает о том, что JDBC API
является частью платформы Java и входит поэтому в Java SE
и Java EE
.
JDBC API
представлен двумя пакетами: java.sql and javax.sql.
Запросы к базе данных делаются с помощью Spring JDBC
.
Предварительная подготовка Из зависимостей нам нужны data-jdbc — стартер, flyway для управления схемой и драйвер postgres для подключения к базе данных.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.flywaydb:flyway-core'
runtimeOnly 'org.postgresql:postgresql'
}
Далее настраиваем приложение для подключения к базе данных:
# application.yml
spring:
application:
name: template-app
datasource:
url: jdbc:postgresql://localhost:5432/demo_app?currentSchema=app
username: app_user
password: change_me
driver-class-name: org.postgresql.Driver
Маппинг сущностей Для этого примера будем использовать следующую таблицу:
create table book (
id varchar(32) not null,
title varchar(255) not null,
author varchar(255),
isbn varchar(15),
published_date date,
page_count integer,
primary key (id)
);
И соответствующий java-класс (обратите внимание, что @Id импортируется из org.springframework.data.annotation.Id):
// Book.java
public class Book {
@Id
private String id;
private String title;
private String author;
private String isbn;
private Instant publishedDate;
private Integer pageCount;
}
Однако, если мы запустим тест:
// BookRepositoryTest.java
@Test
void canSaveBook() {
var book = Book.builder().author("Steven Erikson").title("Gardens of the Moon").build();
var savedBook = bookRepository.save(book);
assertThat(savedBook.getId()).isNotBlank();
assertThat(savedBook.getAuthor()).isEqualTo(book.getAuthor());
assertThat(savedBook.getTitle()).isEqualTo(book.getTitle());
assertThat(savedBook).isEqualTo(bookRepository.findById(savedBook.getId()).get());
}
То увидим ошибку — ERROR: null value in column "id" violates not-null constraint.
Это происходит, потому что не определен ни способ генерации id ни значение по умолчанию.
Поведение Spring Data JDBC
в части идентификаторов немного отличается от Spring Data JPA
.
В примере нужно определить ApplicationListener
для BeforeSaveEvent
:
// PersistenceConfig.java
@Bean
public ApplicationListener<BeforeSaveEvent> idGenerator() {
return event -> {
var entity = event.getEntity();
if (entity instanceof Book) {
((Book) entity).setId(UUID.randomUUID().toString());
}
};
}
Теперь тест пройдет, потому что поле Id заполняется. Полный список поддерживаемых событий жизненного цикла смотрите в документации.
Методы запросов
Одной из особенностей проектов Spring Data
является возможность определять методы запросов в репозиториях.
Spring Data JDBC
использует здесь несколько иной подход. Для демонстрации определим метод запроса в BookRepository
:
Optional<Book> findByTitle(String title);
И если запустим соответствующий тест:
@Test
void canFindBookByTitle() {
var title = "Gardens of the Moon";
var book = Book.builder().author("Steven Erikson").title(title).build();
var savedBook = bookRepository.save(book);
assertThat(bookRepository.findByTitle(title).get()).isEqualTo(savedBook);
}
Получим ошибку —
java Caused by: java.lang.IllegalStateException: No query specified on findByTitle.
В настоящее время Spring Data JDBC
поддерживает только явные запросы, задаваемые через @Query
.
Напишем sql-запрос для нашего метода:
@Query("select * from Book b where b.title = :title")
Optional<Book> findByTitle(@Param("title") String title);
Тест пройден!
Связи
Для работы со связями Spring Data JDBC
также использует другой подход.
Основное отличие в том, что отсутствует ленивая загрузка.
Поэтому если не нужна связь в сущности, то просто не добавляйте ее туда.
Такой подход основан на одной из концепций предметно-ориентированного проектирования (Domain Driven Design
), согласно которой сущности, которые мы загружаем, являются корнями агрегатов, поэтому проектировать надо так, чтобы корни агрегатов тянули за собой загрузку других классов.
Один-к-одному
Для связей "один-к-одному" и "один-ко-многим" используется аннотация @MappedCollection
.
Сначала посмотрим на "один-к-одному". Класс UserAccount
будет ссылаться на Address
.
Вот соответствующий sql
:
create table address
(
id varchar(36) not null,
city varchar(255),
state varchar(255),
street varchar(255),
zipcode varchar(255),
primary key (id)
);
create table user_account
(
id varchar(36) not null,
name varchar(255) not null,
email varchar(255) not null,
address_id varchar(36),
primary key (id),
constraint fk_user_account_address_id foreign key (address_id) references address (id)
);
Класс UserAccount выглядит примерно так:
// UserAccount.java
public class UserAccount implements GeneratedId {
// ...other fields
@MappedCollection(idColumn = "id")
private Address address;
}
Здесь опущены другие поля, чтобы показать маппинг address. Значение в idColumn — это имя поля идентификатора класса Address. Обратите внимание, что в классе Address нет ссылки на класс UserAccount, поскольку агрегатом является UserAccount. Это продемонстрировано в тесте:
//UserAccountRepositoryTest.java
@Test
void canSaveUserWithAddress() {
var address = stubAddress();
var newUser = stubUser(address);
var savedUser = userAccountRepository.save(newUser);
assertThat(savedUser.getId()).isNotBlank();
assertThat(savedUser.getAddress().getId()).isNotBlank();
var foundUser = userAccountRepository.findById(savedUser.getId()).orElseThrow(IllegalStateException::new);
var foundAddress = addressRepository.findById(foundUser.getAddress().getId()).orElseThrow(IllegalStateException::new);
assertThat(foundUser).isEqualTo(savedUser);
assertThat(foundAddress).isEqualTo(savedUser.getAddress());
}
Один-ко-многим
Вот sql
, который будем использовать для демонстрации связи "один-ко-многим":
create table warehouse
(
id varchar(36) not null,
location varchar(255),
primary key (id)
);
create table inventory_item
(
id varchar(36) not null,
name varchar(255),
count integer,
warehouse varchar(36),
primary key (id),
constraint fk_inventory_item_warehouse_id foreign key (warehouse) references warehouse (id)
);
В этом примере на складе (warehouse
) есть много товаров/объектов (inventoryitems
).
Поэтому в классе Warehouse
мы также будем использовать @MappedCollection
для InventoryItem
:
public class Warehouse {
// ...other fields
@MappedCollection
Set<InventoryItem> inventoryItems = new HashSet<>();
public void addInventoryItem(InventoryItem inventoryItem) {
var itemWithId = inventoryItem.toBuilder().id(UUID.randomUUID().toString()).build();
this.inventoryItems.add(itemWithId);
}
}
public class InventoryItem {
@Id
private String id;
private String name;
private int count;
}
В этом примере мы устанавливаем поле id во вспомогательном методе addInventoryItem
.
Можно также определить ApplicationListener
для класса Warehouse с обработкой BeforeSaveEvent
, в котором установить поле id для всех InventoryItem. Вам не обязательно делать в точности так, как сделано у меня. Посмотрите тесты с демонстрацией некоторых особенностей поведения связи "один-ко-многим". Главное то, что сохранение или удаление экземпляра Warehouse влияет на соответствующие InventoryItem
.
В нашем случае InventoryItem
не должен знать о Warehouse
.
Таким образом, у этого класса есть только те поля, которые описывают его.
В JPA
принято делать двусторонние связи, но это может быть громоздким и провоцировать ошибки, если забудете поддерживать обе стороны связи.
Spring Data JDBC
способствует созданию только необходимых вам связей, поэтому обратная связь "многие-к-одному" здесь не используется.
Многие-к-одному и многие-ко-многим В рамках этого руководства я не буду вдаваться в подробности о связях "многие-к-одному" или "многие ко многим". Я советую избегать связей "многие-ко-многим" и использовать их только в крайнем случае. Хотя иногда они могут быть неизбежны. Оба этих типа связей реализуются в Spring Data JDBC через ссылки на Id связанных сущностей. Поэтому имейте ввиду, что здесь вам предстоит еще немного потрудиться.
Итог:
Spring Data JDBC
стремится быть проще, и поэтому отсутствует ленивая загрузка.
Помимо этого, отсутствует кеширование, отслеживание "грязных" объектов (dirty tracking) и сессии (session).
Если в Spring Data JDBC
загружаете объект, то он загружается полностью (включая связи) и сохраняется тогда, когда сохраняете его в репозиторий.
Для наглядности продемонстрируем
0. Создание проекта
Создадим простой проект, в котором мы сможем брать сущность из базы данных.
Добавим в проект Lombok
, Spring Cache
, Spring Data JPA
и H2
. Хотя, вполне можно обойтись только Spring Cache
.
plugins {
id 'org.springframework.boot' version '2.1.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
id 'java'
}
group = 'ru.xpendence'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
У нас будет только одна сущность, назовём её User.
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@ToString
public class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
}
Добавим репозиторий и сервис:
public interface UserRepository extends JpaRepository<User, Long> {
}
@Slf4j
@Service
public class UserServiceImpl implements UserService {
private final UserRepository repository;
public UserServiceImpl(UserRepository repository) {
this.repository = repository;
}
@Override
public User create(User user) {
return repository.save(user);
}
@Override
public User get(Long id) {
log.info("getting user by id: {}", id);
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found by id " + id));
}
}
Когда заходим в сервисный метод get()
, пишем об этом в лог.
Подключим к проекту Spring Cache
.
@SpringBootApplication
@EnableCaching //подключение Spring Cache
public class CacheApplication {
public static void main(String[] args) {
SpringApplication.run(CacheApplication.class, args);
}
}
Проект готов.
1. Кэширование возвращаемого результата
Функция Spring Cache
- кэшировать возвращаемый результат для определённых входных параметров.
Далее проверим данный факт. Поставим аннотацию @Cacheable
над сервисным методом get()
, чтобы кэшировать возвращаемые данные.
Дадим этой аннотации название "users".
@Override
@Cacheable("users")
public User get(Long id) {
log.info("getting user by id: {}", id);
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found by id " + id));
}
Для того, чтобы проверить, как это работает, напишем простой тест.
@RunWith(SpringRunner.class)
@SpringBootTest
public abstract class AbstractTest {
}
@Slf4j
public class UserServiceTest extends AbstractTest {
@Autowired
private UserService service;
@Test
public void get() {
User user1 = service.create(new User("Vasya", "[email protected]"));
User user2 = service.create(new User("Kolya", "[email protected]"));
getAndPrint(user1.getId());
getAndPrint(user2.getId());
getAndPrint(user1.getId());
getAndPrint(user2.getId());
}
private void getAndPrint(Long id) {
log.info("user found: {}", service.get(id));
}
}
Тест создаёт двоих пользователей и потом по 2 раза вытаскивает их из базы.
Как мы помним, мы поместили аннотацию @Cacheable
, которая будет кэшировать возвращаемые значения.
После получения объекта из метода get()
выводим объект в лог.
Также, выводим в лог информацию о каждом посещении приложением метода get()
.
Запустим тест. Результат, передаваемый в консоль:
getting user by id: 1
user found: User(id=1, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
user found: User(id=2, name=Kolya, email=kolya@mail.ru)
user found: User(id=1, name=Vasya, email=vasya@mail.ru)
user found: User(id=2, name=Kolya, email=kolya@mail.ru)
Первые два раза действительно сходили в метод get()
и реально получили пользователя из базы.
Во всех остальных случаях, реального захода в метод не было, приложение брало закэшированные данные по ключу (в данном случае, это id).
2. Объявление ключа для кэширования
Бывают ситуации, когда в кэшируемый метод приходит несколько параметров.
В таком случае, бывает нужно определить параметр, по которому будет происходить кэширование.
Добавим в пример метод, который будет сохранять в базу сущность, собранную по параметрам, но если сущность с таким именем уже есть, не будем её сохранять.
Для этого определим параметр name
как ключ для кэширования.
Выглядеть это будет так:
@Override
@Cacheable(value = "users", key = "#name")
public User create(String name, String email) {
log.info("creating user with parameters: {}, {}", name, email);
return repository.save(new User(name, email));
}
Напишем соответствующий тест:
@Test
public void create() {
createAndPrint("Ivan", "[email protected]");
createAndPrint("Ivan", "[email protected]");
createAndPrint("Sergey", "[email protected]");
log.info("all entries are below:");
service.getAll().forEach(u -> log.info("{}", u.toString()));
}
private void createAndPrint(String name, String email) {
log.info("created user: {}", service.create(name, email));
}
Попробуем создать троих пользователей, для двоих из которых будет совпадать имя
createAndPrint("Ivan", "[email protected]");
createAndPrint("Ivan", "[email protected]");
и для двоих из которых будет совпадать email
createAndPrint("Ivan", "[email protected]");
createAndPrint("Sergey", "[email protected]");
В методе создания логируем каждый факт обращения к методу, а также, будем логировать все сущности, которые этот метод нам вернул.
Результат будет таким:
creating user with parameters: Ivan, ivan@mail.ru
created user: User(id=1, name=Ivan, email=ivan@mail.ru)
created user: User(id=1, name=Ivan, email=ivan@mail.ru)
creating user with parameters: Sergey, ivan@mail.ru
created user: User(id=2, name=Sergey, email=ivan@mail.ru)
all entries are below:
User(id=1, name=Ivan, email=ivan@mail.ru)
User(id=2, name=Sergey, email=ivan@mail.ru)
Фактически приложение вызывалось метод 3 раза, а заходило в него только два раза. Один раз для метода совпадал ключ, и он просто возвращал закэшированное значение.
3. Принудительное кэширование. @CachePut
Бывают ситуации, когда необходимо кэшировать возвращаемое значение для какой-то сущности, но в то же время, нужно обновить кэш.
Для таких нужд существует аннотация @CachePut
.
Оно пропускает приложение в метод, при этом, обновляя кэш для возвращаемого значения, даже если оно уже закэшировано.
Добавим пару методов, в которых мы будем сохранять юзера.
Один из них мы пометим обычной аннотацией @Cacheable
, второй — @CachePut
.
@Override
@Cacheable(value = "users", key = "#user.name")
public User createOrReturnCached(User user) {
log.info("creating user: {}", user);
return repository.save(user);
}
@Override
@CachePut(value = "users", key = "#user.name")
public User createAndRefreshCache(User user) {
log.info("creating user: {}", user);
return repository.save(user);
}
Первый метод будет просто возвращать закэшированные значения, второй — принудительно обновлять кэш.
Кэширование будет осуществляться по ключу #user.name
.
Напишем соответствующий тест.
@Test
public void createAndRefresh() {
User user1 = service.createOrReturnCached(new User("Vasya", "[email protected]"));
log.info("created user1: {}", user1);
User user2 = service.createOrReturnCached(new User("Vasya", "[email protected]"));
log.info("created user2: {}", user2);
User user3 = service.createAndRefreshCache(new User("Vasya", "[email protected]"));
log.info("created user3: {}", user3);
User user4 = service.createOrReturnCached(new User("Vasya", "[email protected]"));
log.info("created user4: {}", user4);
}
По той логике, которая уже описывалась, при первом сохранении пользователя с именем "Vasya" через метод createOrReturnCached()
далее будем получать кэшированную сущность, при этом, в сам метод приложение заходить не будет.
Если же вызовем метод createAndRefreshCache()
, кэшированная сущность для ключа с именем "Vasya" перезапишется в кэше.
Выполним тест и посмотрим, что будет выведено в консоль.
creating user: User(id=null, name=Vasya, email=vasya@mail.ru)
created user1: User(id=1, name=Vasya, email=vasya@mail.ru)
created user2: User(id=1, name=Vasya, email=vasya@mail.ru)
creating user: User(id=null, name=Vasya, email=kolya@mail.ru)
created user3: User(id=2, name=Vasya, email=kolya@mail.ru)
created user4: User(id=2, name=Vasya, email=kolya@mail.ru)
Мы видим, что "user1" благополучно записался в базу и кэш. При повторной попытке записать юзера с таким же именем мы получаем закэшированный результат выполнения первого обращения ("user2", для которого id такой же, как у user1, что говорит нам о том, что юзер не был записан, и это просто кэш). Далее, мы пишем третьего пользователя через второй метод, который даже при имеющемся закэшированном результате всё равно вызвал метод и записал в кэш новый результат. Это "user3". Как можно заметить, у него уже новый id. После чего, мы вызываем первый метод, который берёт новый кэш, добавленный "user3".
4. Удаление из кэша. @CacheEvict
Иногда возникает необходимость жёстко обновить какие-то данные в кэше. Например, сущность уже удалена из базы, но она по-прежнему доступна из кэша. Для сохранения консистентности данных, необходимо хотя бы не хранить в кэше удалённые данные.
Добавим в сервис ещё пару методов.
@Override
public void delete(Long id) {
log.info("deleting user by id: {}", id);
repository.deleteById(id);
}
@Override
@CacheEvict("users")
public void deleteAndEvict(Long id) {
log.info("deleting user by id: {}", id);
repository.deleteById(id);
}
Первый будет просто удалять пользователя, второй тоже будет его удалять, но мы пометим его аннотацией @CacheEvict. Добавим тест, который будет создавать двух юзеров, после чего, одного будет удалять через простой метод, а второго — через аннотируемый метод. После чего, мы достанем этих юзеров через метод get().
@Test
public void delete() {
User user1 = service.create(new User("Vasya", "[email protected]"));
log.info("{}", service.get(user1.getId()));
User user2 = service.create(new User("Vasya", "[email protected]"));
log.info("{}", service.get(user2.getId()));
service.delete(user1.getId());
service.deleteAndEvict(user2.getId());
log.info("{}", service.get(user1.getId()));
log.info("{}", service.get(user2.getId()));
}
Логично, что раз наш юзер уже закэширован, удаление не помешает нам его как бы получить — ведь он закэширован. Посмотрим логи.
getting user by id: 1
User(id=1, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
User(id=2, name=Vasya, email=vasya@mail.ru)
deleting user by id: 1
deleting user by id: 2
User(id=1, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
javax.persistence.EntityNotFoundException: User not found by id 2
Видим, что приложение благополучно сходило оба раза в метод get()
и Spring
закэшировал эти сущности.
Далее, удалили их через разные методы. Первый удалили обычным путём, и закэшированное значение осталось, поэтому когда попытались получить юзера под id 1, это удалось.
Когда же попытались получить юзера 2, метод вернул нам EntityNotFoundException
— такого юзера в кэше не оказалось.
5. Группировка настроек. @Caching
Иногда один метод требует нескольких настроек кэширования. Для этих целей используется аннотация @Caching
.
Выглядеть это может приблизительно так:
@Caching(
cacheable = {
@Cacheable("users"),
@Cacheable("contacts")
},
put = {
@CachePut("tables"),
@CachePut("chairs"),
@CachePut(value = "meals", key = "#user.email")
},
evict = {
@CacheEvict(value = "services", key = "#user.name")
}
)
void cacheExample(User user) {
}
Это единственный способ группировать аннотации. Если попытаемся нагородить что-то вроде
@CacheEvict("users")
@CacheEvict("meals")
@CacheEvict("contacts")
@CacheEvict("tables")
void cacheExample(User user) {
}
то IDEA сообщит, что так нельзя.
6. Гибкая настройка. CacheManager
Рассмотрим как возможно настроить кэширование в целом.
Для таких задач существует CacheManager
.
Он существует везде, где есть Spring Cache
.
Когда мы добавили аннотацию @EnableCache
, такой кэш менеджер автоматически будет создан Spring
.
Возможно убедиться в этом, если заавтовайрим ApplicationContext и вскроем его на брейкпоинте.
Среди прочих бинов, будет и бин cacheManager
.
Остановим приложение на этапе, когда уже два юзера были созданы и помещены в кэш.
Если вызовем нужный нам бин через Evaluate Expression
, то увидим, что такой бин действительно есть, в нём есть ConcurentMapCache
с ключом "users" и значением ConcurrentHashMap, в которой уже лежат закэшированные юзеры.
В свою очередь, можем создать свой кэш-менеджер, с хабром и программистами, после чего, тонко настроить его на наш вкус.
@Bean("habrCacheManager")
public CacheManager cacheManager() {
return null;
}
Самые популярные кэш-менеджеры:
SimpleCacheManager
— самый простой кэш-менеджер, удобный для изучения и тестирования.
ConcurrentMapCacheManager
— лениво инициализирует возвращаемые экземпляры для каждого запроса. Также рекомендуется для тестирования и изучения работы с кэшем, а также, для каких-то простых действий. Для серьёзной работы с кэшем рекомендуются имплементации ниже.
JCacheCacheManager
, EhCacheCacheManager
, CaffeineCacheManager
— серьёзные кэш-менеджеры «от партнёров», гибко настраиваемые и выполняющие задачи очень широкого спектра действия.
Разберём несколько аспектов настройки кэш-менеджера на примере ConcurrentMapCacheManager
:
@Bean("habrCacheManager")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager();
}
Таким образом кэш-менеджер готов.
7. Настройка кэша. Время жизни, максимальный размер
Для этого потребуется довольно популярная библиотека Google Guava
.
compile group: 'com.google.guava', name: 'guava', version: '28.1-jre'
При создании кэш-менеджера переопределим метод createConcurrentMapCache
, в котором вызовем CacheBuilder
от Guava
.
В процессе нам будет предложено настроить кэш-менеджер при помощи инициализации следующих методов:
-
maximumSize
— максимальный размер значений, которые может содержать кэш. При помощи этого параметра можно найти попытаться найти компромисс между нагрузкой на базу данных и на оперативную память JVM. -
refreshAfterWrite
— время после записи значения в кэш, после которого оно автоматически обновится. -
expireAfterAccess
— время жизни значения после последнего обращения к нему. -
expireAfterWrite
— время жизни значения после записи в кэш. Именно этот параметр мы определим.
Определим в менеджере время жизни записи. Например 1 секунду.
@Bean("habrCacheManager")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager() {
@Override
protected Cache createConcurrentMapCache(String name) {
return new ConcurrentMapCache(
name,
CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.build().asMap(),
false);
}
};
}
Напишем соответствующий такому случаю тест.
@Test
public void checkSettings() throws InterruptedException {
User user1 = service.createOrReturnCached(new User("Vasya", "[email protected]"));
log.info("{}", service.get(user1.getId()));
User user2 = service.createOrReturnCached(new User("Vasya", "[email protected]"));
log.info("{}", service.get(user2.getId()));
Thread.sleep(1000L);
User user3 = service.createOrReturnCached(new User("Vasya", "[email protected]"));
log.info("{}", service.get(user3.getId()));
}
Сохраняем несколько значений в базу данных, причём, если данные закэшированы, ничего не сохраняем. Сначала сохраняем два значения, потом ожидаем 1 секунду, пока кэш не протухнет, после чего, сохраняем ещё одно значение.
creating user: User(id=null, name=Vasya, email=vasya@mail.ru)
getting user by id: 1
User(id=1, name=Vasya, email=vasya@mail.ru)
User(id=1, name=Vasya, email=vasya@mail.ru)
creating user: User(id=null, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
User(id=2, name=Vasya, email=vasya@mail.ru)
Логи показывают, что сначала мы создали юзера, потом попытались ещё одного, но поскольку данные были закэшированы, получили их из кэша (в обоих случаях — при сохранении и при получении из базы). Потом протух кэш, о чём сообщает нам запись о фактическом сохранении и фактическом получении юзера.