Сьогодні будемо робити фінальний проєкт по четвертому модулю JRU. Що це буде? Спробуємо працювати з різними технологіями: MySQL, Hibernate, Redis, Docker. Тепер предметніше.
Завдання: у нас є реляційна база даних MySQL зі схемою (країна-місто, мова по країні). І є частий запит міста, яке йде з затримкою. Ми придумали рішення – винести всі дані, на які часто робиться запит, до Redis (in memory storage типу ключ-значення).
І потрібні нам не всі дані, а визначений набір полів. Проєкт буде у форматі туторіалу. Тобто, тут будемо порушувати проблему й одразу її вирішувати.
Отже, почнемо з того, який софт нам буде потрібен:
- IDEA Ultimate (у кого закінчився ключ — пишіть у Slack куратору групи)
- Workbench (або будь-який інший клієнт для MySQL)
- Docker
- redis-insight — опціонально
Наш план дій:
- Налаштувати докер. Для кожної ОС є особливості, тому радимо пошукати відповідь в мережі на питання типу "how to install docker on windows"). Перевірити, що все працює.
- Запустити MySQL сервер як докер-контейнер.
- Розгорнути дамп.
- Створити проект в IDEA, додати залежності maven.
- Зробити шар domain.
- Написати метод отримання всіх даних з MySQL.
- Написати метод трансформації даних (в Redis писатимемо лише ті дані, на які часто робиться запит).
- Запустити Redis сервер як докер-контейнер.
- Записати дані в Redis.
- Опціонально: встановити redis-insight, подивитися на дані, які зберігаються в Redis.
- Написати метод отримання даних з Redis.
- Написати метод отримання даних з MySQL.
- Порівняти швидкість отримання одних і тих самих даних з MySQL та Redis.
Налаштування Docker
Докер — це відкрита платформа для розробки, доставки та експлуатації застосунків. Ми його використовуватимемо для того, щоб не встановлювати і налаштовувати Redis на локальній машині, а використовувати вже готовий image. Докладніше про докер можна подивитися тут.
Аби переконатися, що докер у тебе встановлений і налаштований, виконай команду: docker -v
Якщо все ОК, ти побачиш версію докера
Розгорнути дамп
Для розгортання дампа потрібно з Workbench створити новий конекшн до БД, де вказати параметри. Я використовував стандартний порт (3306), не змінював ім'я користувача (root за замовчуванням) і вказав пароль для рутового користувача (root).
У Workbench-і робимо Data Import/Restore
і обираємо Import from Self Contained File
.
Як файл вкажи куди ти скачав дамп.
Схему заздалегідь створювати не потрібно — її створення входить до дамп-файлу. Після успішного імпорту, у тебе буде схема
world із трьома таблицями:
- city — це таблиця міст.
- country — таблиця країн.
- country_language — таблиця, де вказується, який відсоток населення в країні говорить певною мовою.
Оскільки під час запуску контейнера ми використовували вольюм, після зупинки та навіть видалення контейнера mysql і повторного виконання
команди запуску (docker run --name mysql -d -p 3306:3306 -e
) повторно дамп розгортати не потрібно – він вже розгорнутий у вольюмі.
Створити проєкт в IDEA, додати залежності maven
Як в IDEA створити проєкт ти вже давно знаєш – це найлегший пункт у сьогоднішньому проєкті.
Додаємо до pom-файлу залежності:
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core-jakarta</artifactId>
<version>5.6.14.Final</version>
</dependency>
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.0</version>
</dependency>
</dependencies>
Перші три залежності тобі вже давно знайомі.
lettuce-core
— це один із доступних Java клієнтів для роботи з Redis.
jackson-databind
— залежність для використання ObjectMapper (для перетворення даних для їх зберігання в Redis (ключ-значення типу String)).
Так само до папки ресурсів (src/main/resources) додай spy.properties для перегляду запитів з параметрами, які виконує Hibernate. Вміст файлу:
driverlist=com.mysql.cj.jdbc.Driver
dateformat=yyyy-MM-dd hh:mm:ss a
appender=com.p6spy.engine.spy.appender.StdoutLogger
logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat
Зробити шар domain
Створи пакет com.javarush.domain
Мені зручно під час мапінгу таблиць на ентіті користуватися структурою таблиці в IDEA, тому додамо підключення БД до IDEA.
Ентіті я пропоную створювати в такому порядку:
- Country
- City
- CountryLanguage
Бажано щоб мапінг ти виконав самостійно.
Код класу Country:
package com.javarush.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.Set;
@Entity
@Table(schema = "world", name = "country")
public class Country {
@Id
@Column(name = "id")
private Integer id;
private String code;
@Column(name = "code_2")
private String alternativeCode;
private String name;
@Column(name = "continent")
@Enumerated(EnumType.ORDINAL)
private Continent continent;
private String region;
@Column(name = "surface_area")
private BigDecimal surfaceArea;
@Column(name = "indep_year")
private Short independenceYear;
private Integer population;
@Column(name = "life_expectancy")
private BigDecimal lifeExpectancy;
@Column(name = "gnp")
private BigDecimal GNP;
@Column(name = "gnpo_id")
private BigDecimal GNPOId;
@Column(name = "local_name")
private String localName;
@Column(name = "government_form")
private String governmentForm;
@Column(name = "head_of_state")
private String headOfState;
@OneToOne
@JoinColumn(name = "capital")
private City city;
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "country_id")
private Set<CountryLanguage> languages;
//Getters and Setters omitted
}
У коді є 3 цікаві моменти.
Перший — це enum Continent, який у БД зберігається як original значення. У структурі таблиці country в коментарі до поля continent можна подивитися, яке числове значення відповідає якому континенту.
package com.javarush.domain;
public enum Continent {
ASIA,
EUROPE,
NORTH_AMERICA,
AFRICA,
OCEANIA,
ANTARCTICA,
SOUTH_AMERICA
}
Другий момент — це сет сутностей CountryLanguage
. Тут зв'язок @OneToMany
, якого не було у другому проєкті цього модуля. За замовчуванням Hibernate не витягуватиме значення цього сету під час запиту ентіті країни. Але оскільки нам потрібно віднімати всі значення з реляційної бази даних для кешування, тут додано параметр FetchType.EAGER
.
Третє — поле city. Зв'язок @OneToOne
— тут начебто все звично і зрозуміло. Але якщо поглянути на структуру foreign key у БД, побачимо, що у країни (country) є зв'язок на столицю (city), а у міста (city) — на країну (country). А це — циклічна залежність.
Поки що з цим нічого робити не будемо, але, коли дійдемо до пункту «Написати метод отримання всіх даних з MySQL», подивимося, які запити виконує Hibernate, на їх кількість, і згадаємо про цей пункт.
package com.javarush.domain;
import jakarta.persistence.*;
@Entity
@Table(schema = "world", name = "city")
public class City {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@ManyToOne
@JoinColumn(name = "country_id")
private Country country;
private String district;
private Integer population;
//Getters and Setters omitted
}
Код класу CountryLanguage:
package com.javarush.domain;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.math.BigDecimal;
@Entity
@Table(schema = "world", name = "country_language")
public class CountryLanguage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Integer id;
@ManyToOne
@JoinColumn(name = "country_id")
private Country country;
private String language;
@Column(name = "is_official", columnDefinition = "BIT")
@Type(type = "org.hibernate.type.NumericBooleanType")
private Boolean isOfficial;
private BigDecimal percentage;
//Getters and Setters omitted
}
Написати метод отримання всіх даних з MySQL
У класі Main оголосимо поля:
private final SessionFactory sessionFactory;
private final RedisClient redisClient;
private final ObjectMapper mapper;
private final CityDAO cityDAO;
private final CountryDAO countryDAO;
та ініціализуємо їх у конструкторі класу Main:
public Main() {
sessionFactory = prepareRelationalDb();
cityDAO = new CityDAO(sessionFactory);
countryDAO = new CountryDAO(sessionFactory);
redisClient = prepareRedisClient();
mapper = new ObjectMapper();
}
Як бачиш, не вистачає методів та класів — давай хх напишемо.
Оголоси пакет com.javarush.dao та додай до нього 2 класи:
package com.javarush.dao;
import com.javarush.domain.Country;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import java.util.List;
public class CountryDAO {
private final SessionFactory sessionFactory;
public CountryDAO(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public List<Country> getAll() {
Query<Country> query = sessionFactory.getCurrentSession().createQuery("select c from Country c", Country.class);
return query.list();
}
}
package com.javarush.dao;
import com.javarush.domain.City;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import java.util.List;
public class CityDAO {
private final SessionFactory sessionFactory;
public CityDAO(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public List<City> getItems(int offset, int limit) {
Query<City> query = sessionFactory.getCurrentSession().createQuery("select c from City c", City.class);
query.setFirstResult(offset);
query.setMaxResults(limit);
return query.list();
}
public int getTotalCount() {
Query<Long> query = sessionFactory.getCurrentSession().createQuery("select count(c) from City c", Long.class);
return Math.toIntExact(query.uniqueResult());
}
}
Тепер можна в Main заімпортити ці 2 класи. Ще не вистачає двох методів:
private SessionFactory prepareRelationalDb() {
final SessionFactory sessionFactory;
Properties properties = new Properties();
properties.put(Environment.DIALECT, "org.hibernate.dialect.MySQL8Dialect");
properties.put(Environment.DRIVER, "com.p6spy.engine.spy.P6SpyDriver");
properties.put(Environment.URL, "jdbc:p6spy:mysql://localhost:3306/world");
properties.put(Environment.USER, "root");
properties.put(Environment.PASS, "root");
properties.put(Environment.CURRENT_SESSION_CONTEXT_CLASS, "thread");
properties.put(Environment.HBM2DDL_AUTO, "validate");
properties.put(Environment.STATEMENT_BATCH_SIZE, "100");
sessionFactory = new Configuration()
.addAnnotatedClass(City.class)
.addAnnotatedClass(Country.class)
.addAnnotatedClass(CountryLanguage.class)
.addProperties(properties)
.buildSessionFactory();
return sessionFactory;
}
До редіса ми ще не дійшли, тому імплементація ініціалізації клієнта редіса поки залишається заглушкою:
private void shutdown() {
if (nonNull(sessionFactory)) {
sessionFactory.close();
}
if (nonNull(redisClient)) {
redisClient.shutdown();
}
}
Нарешті, можна написати метод, в якому ми витягнемо всі міста:
private List<City> fetchData(Main main) {
try (Session session = main.sessionFactory.getCurrentSession()) {
List<City> allCities = new ArrayList<>();
session.beginTransaction();
int totalCount = main.cityDAO.getTotalCount();
int step = 500;
for (int i = 0; i < totalCount; i += step) {
allCities.addAll(main.cityDAO.getItems(i, step));
}
session.getTransaction().commit();
return allCities;
}
}
Особливість реалізації така, що міста ми отримуємо по 500 штук. Це потрібно, бо існують обмеження на обсяг даних, що передаються. Так, в нашому випадку вони нас не стосуються, оскільки в нашій БД загалом 4079 міст. Але в продакшн застосунках, коли потрібно отримати багато даних, таким прийомом користуються часто.
І реалізація методу main:
public static void main(String[] args) {
Main main = new Main();
List<City> allCities = main.fetchData(main);
main.shutdown();
}
Тепер можна вперше запустити нашу програму в дебазі і подивитися, як вона працює (або не працює – так, це часто трапляється).
Міста дістаються. До кожного міста дістається країна, якщо її ще не вичитали з БД раніше, для іншого міста. Давай умовно порахуємо, скільки запитів Hibernate надішле до БД:
- 1 запит потрібен, щоб дізнатися загальну кількість міст (потрібно для циклу ітерування по 500 міст, щоб знати коли зупинитися).
- 4079 / 500 = 9 запитів (список міст).
- Для кожного з міст дістається країна, якщо її не зчитали раніше. Оскільки у БД 239 країн, це нам дасть 239 запитів.
Разом 249 запитів
. І це ще ми вказали, що разом із країною потрібно одразу отримувати сет мов, інакше була б взагалі складна ситуація. Але це все одно багато, тому давай трохи підтюнимо поведінку. Почнемо з роздумів: що робити, куди тікати? А якщо серйозно — чому так багато запитів. Якщо поглянути на лог запитів, бачимо, що кожна країна запитується окремо, тому перше просте рішення — давай зробимо запит на всі країни разом, тому що ми наперед знаємо, що в цій транзакції вони нам будуть потрібні.
У методі fetchData() одразу після початку транзакції додай рядок:
List<Country> countries = main.countryDAO.getAll();
Рахуємо запити:
- 1 — отримати всі країни
- 239 — запит щодо кожної країни її столиці
- 1 — запит кількості міст
- 9 — запит списків міст
Разом виходить 250
. Ідея гарна, але не спрацювало. Проблема в тому, що в країні зв'язок із столицею (city) @OneToOne
. А такий зв'язок за замовчуванням завантажується одразу (FetchType.EAGER
). Давай поставимо FetchType.LAZY
, оскільки потім у цій же транзакції ми все одно завантажимо всі міста.
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "capital")
private City city;
Окремо на столиці припинили йти запити, але кількість запитів не змінилася. Тепер по кожній країні робиться окремий запит на список CountryLanguage. Тобто прогрес є, і ми рухаємось у правильному напрямку. Якщо пам'ятаєш, у лекціях пропонувалося рішення «join fetch», щоб завдяки додаванню додаткового джоїну до запиту робити запит на сутність із залежними даними за один раз. У CountryDAO перепиши HQL запит у методі getAll()
на:
"select c from Country c join fetch c.languages"
Запуск. Дивимося лог, рахуємо запити:
- 1 — усі країни з мовами
- 1 — кількість міст
- 9 — списки міст.
Разом 11
— у нас усе вийшло)) Якщо ти не лише прочитав весь цей текст, а й спробував запускати після
кожного кроку тюнінгу застосунки, у тебе була можливість навіть візуально відзначити прискорення роботи всього додатку в кілька разів.
Написати метод трансформації даних
Створимо пакет com.javarush.redis
, до якого додамо 2 класи: CityCountry (дані щодо міста та країни, в якій це місто розташоване) та Language (дані з мови). Сюди винесено всі поля, які «за завданням» запитуються часто в «запиті, що гальмує».
package com.javarush.redis;
import com.javarush.domain.Continent;
import java.math.BigDecimal;
import java.util.Set;
public class CityCountry {
private Integer id;
private String name;
private String district;
private Integer population;
private String countryCode;
private String alternativeCountryCode;
private String countryName;
private Continent continent;
private String countryRegion;
private BigDecimal countrySurfaceArea;
private Integer countryPopulation;
private Set<Language> languages;
//Getters and Setters omitted
}
package com.javarush.redis;
import java.math.BigDecimal;
public class Language {
private String language;
private Boolean isOfficial;
private BigDecimal percentage;
//Getters and Setters omitted
}
У методі main після отримання всіх міст додай рядок
List<CityCountry>> preparedData = main.transformData(allCities);
Та реалізуй цей метод:
private List<CityCountry> transformData(List<City> cities) {
return cities.stream().map(city -> {
CityCountry res = new CityCountry();
res.setId(city.getId());
res.setName(city.getName());
res.setPopulation(city.getPopulation());
res.setDistrict(city.getDistrict());
Country country = city.getCountry();
res.setAlternativeCountryCode(country.getAlternativeCode());
res.setContinent(country.getContinent());
res.setCountryCode(country.getCode());
res.setCountryName(country.getName());
res.setCountryPopulation(country.getPopulation());
res.setCountryRegion(country.getRegion());
res.setCountrySurfaceArea(country.getSurfaceArea());
Set<CountryLanguage> countryLanguages = country.getLanguages();
Set<Language> languages = countryLanguages.stream().map(cl -> {
Language language = new Language();
language.setLanguage(cl.getLanguage());
language.setOfficial(cl.getOfficial());
language.setPercentage(cl.getPercentage());
return language;
}).collect(Collectors.toSet());
res.setLanguages(languages);
return res;
}).collect(Collectors.toList());
}
Думаю, цей метод пояснень не потребує: просто створюємо сутність CityCountry та заповнюємо даними з City, Country, CountryLanguage.
Запустити Redis сервер як докер-контейнер
Тут є 2 варіанти. Якщо ти робитимеш опціональний крок "встановити redis-insight, подивитися на дані, які зберігаються в Redis", тоді команда для тебе:
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
Якщо вирішив пропустити цей крок, тоді достатньо:
docker run -d --name redis -p 6379:6379 redis:latest
Різниця в тому, що в першому варіанті прокидається порт 8001 на локальну машину, до якого можна підключитися зовнішнім клієнтом, щоб подивитися, що зберігається всередині. Імена я звик давати значущі, тому redis-stack
або redis
.
Після запуску можна переглянути список запущених контейнерів. Для цього виконай команду:
docker container ls
І побачиш щось приблизно таке:
Якщо потрібно знайти якусь команду, можна або в терміналі подивитися хелп (docker help) або загуглити «how to …» (наприклад, docker how to remove running container).
Також у конструкторі Main ми викликали ініціалізацію редіс-клієнта, але сам метод не реалізували. Додай реалізацію:
private RedisClient prepareRedisClient() {
RedisClient redisClient = RedisClient.create(RedisURI.create("localhost", 6379));
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
System.out.println("\nConnected to Redis\n");
}
return redisClient;
}
sout доданий у навчальних цілях, щоб у лозі запуску бачити, що всі ОК, і конекшн через редіс-клієнт пройшов без помилок.
Записати дані в Redis
До методу main додай виклик
main.pushToRedis(preparedData);
З такою реалізацією методу:
private void pushToRedis(List<CityCountry> data) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisStringCommands<String, String> sync = connection.sync();
for (CityCountry cityCountry : data) {
try {
sync.set(String.valueOf(cityCountry.getId()), mapper.writeValueAsString(cityCountry));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
Тут відкривається синхронне з'єднання з редіс-клієнтом, і послідовно кожен об'єкт типу CityCountry пишеться до редісу. Оскільки редіс — це сховище формату ключ-значення типу String, ключ (id міста) перетворюється на рядок. А значення — теж на рядок, але з використанням ObjectMapper у JSON форматі.
Залишилося запустити і перевірити, що немає помилок у лозі. Все спрацювало.
Встановити redis-insight, подивитися на дані, які зберігаються в Redis (опціонально)
Завантажуємо за посиланням redis-insight та встановлюємо його. Після запуску у мене одразу показує наш інстанс редісу в докер-контейнері:
Якщо зайти, побачимо список всіх ключів:
І можна зайти на будь-який ключ, щоб подивитися, які дані за ним зберігаються:
Написати метод отримання даних з Redis
Для перевірки використовуємо такий тест: отримаємо 10 записів CityCountry. Кожен — окремим запитом, але в одному конекшні.
Дані з редіс можна отримати через нашого редіс-клієнта. Для цього напишемо метод, що приймає список ID, які потрібно отримати.
private void testRedisData(List<Integer> ids) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisStringCommands<String, String> sync = connection.sync();
for (Integer id : ids) {
String value = sync.get(String.valueOf(id));
try {
mapper.readValue(value, CityCountry.class);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
Реалізація, думаю, інтуїтивно зрозуміла: відкриваємо синхронне з'єднання, і для кожного id отримуємо JSON String, який перетворюємо на потрібний нам об'єкт типу CityCountry.
Написати метод отримання даних з MySQL
У класі CityDAO додай метод getById(Integer id)
, в якому отримаємо місто разом з країною:
public City getById(Integer id) {
Query<City> query = sessionFactory.getCurrentSession().createQuery("select c from City c join fetch c.country where c.id = :ID", City.class);
query.setParameter("ID", id);
return query.getSingleResult();
}
За аналогією з попереднім пунктом, додамо до класу Main аналогічний метод для MySQL:
private void testMysqlData(List<Integer> ids) {
try (Session session = sessionFactory.getCurrentSession()) {
session.beginTransaction();
for (Integer id : ids) {
City city = cityDAO.getById(id);
Set<CountryLanguage> languages = city.getCountry().getLanguages();
}
session.getTransaction().commit();
}
}
З особливостей, щоб точно отримати повний об'єкт (без проксі-заглушок), явно зробимо запит у країни на список мов.
Порівняти швидкість отримання одних і тих же даних з MySQL та Redis
Тут одразу наведу код методу main, і результат, який виходить на моєму локальному комп'ютері.
public static void main(String[] args) {
Main main = new Main();
List<City> allCities = main.fetchData(main);
List<CityCountry> preparedData = main.transformData(allCities);
main.pushToRedis(preparedData);
//закриємо поточну сесію, щоб точно зробити запит до БД, а не витянути дані з кеша
main.sessionFactory.getCurrentSession().close();
//обираємо 10 випадкових id міст
//оскільки ми не робили обробку невалідних ситуацій, використовуй id, які існують БД
List<Integer> ids = List.of(3, 2545, 123, 4, 189, 89, 3458, 1189, 10, 102);
long startRedis = System.currentTimeMillis();
main.testRedisData(ids);
long stopRedis = System.currentTimeMillis();
long startMysql = System.currentTimeMillis();
main.testMysqlData(ids);
long stopMysql = System.currentTimeMillis();
System.out.printf("%s:\t%d ms\n", "Redis", (stopRedis - startRedis));
System.out.printf("%s:\t%d ms\n", "MySQL", (stopMysql - startMysql));
main.shutdown();
}
Під час тестування є особливість — дані з MySQL лише читаються, так що його можна не перезапускати між запусками нашої програми. А до Redis пишуться.
Хоча у разі спроби додати дублікат за таким же ключем дані просто оновляться, я б рекомендував між запусками програми в терміналі виконувати команди зупинки контейнера docker stop redis-stack
та видалення контейнера docker rm redis-stack
. Після цього заново піднімати контейнер з редісом docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
, і лише після цього виконувати нашу програму.
Ось мої результати тестування:
У підсумку ми домоглися підвищення продуктивності відповіді на «гальмуючий частий» запит у півтора рази. До того ж, з урахуванням, що в тестуванні ми використовували не найшвидшу десеріалізацію через ObjectMapper. Якщо її замінити на GSON, швидше за все, можна "виграти" ще трохи часу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ