Сьогодні будемо робити фінальний проєкт по четвертому модулю JRU. Що це буде? Спробуємо працювати з різними технологіями: MySQL, Hibernate, Redis, Docker. Тепер предметніше.

Завдання: у нас є реляційна база даних MySQL зі схемою (країна-місто, мова по країні). І є частий запит міста, яке йде з затримкою. Ми придумали рішення – винести всі дані, на які часто робиться запит, до Redis (in memory storage типу ключ-значення).

І потрібні нам не всі дані, а визначений набір полів. Проєкт буде у форматі туторіалу. Тобто, тут будемо порушувати проблему й одразу її вирішувати.

Отже, почнемо з того, який софт нам буде потрібен:

  1. IDEA Ultimate (у кого закінчився ключ — пишіть у Slack куратору групи)
  2. Workbench (або будь-який інший клієнт для MySQL)
  3. Docker
  4. redis-insight — опціонально

Наш план дій:

  1. Налаштувати докер. Для кожної ОС є особливості, тому радимо пошукати відповідь в мережі на питання типу "how to install docker on windows"). Перевірити, що все працює.
  2. Запустити MySQL сервер як докер-контейнер.
  3. Розгорнути дамп.
  4. Створити проект в IDEA, додати залежності maven.
  5. Зробити шар domain.
  6. Написати метод отримання всіх даних з MySQL.
  7. Написати метод трансформації даних (в Redis писатимемо лише ті дані, на які часто робиться запит).
  8. Запустити Redis сервер як докер-контейнер.
  9. Записати дані в Redis.
  10. Опціонально: встановити redis-insight, подивитися на дані, які зберігаються в Redis.
  11. Написати метод отримання даних з Redis.
  12. Написати метод отримання даних з MySQL.
  13. Порівняти швидкість отримання одних і тих самих даних з MySQL та Redis.

Налаштування Docker

Докер — це відкрита платформа для розробки, доставки та експлуатації застосунків. Ми його використовуватимемо для того, щоб не встановлювати і налаштовувати Redis на локальній машині, а використовувати вже готовий image. Докладніше про докер можна подивитися тут.

Аби переконатися, що докер у тебе встановлений і налаштований, виконай команду: docker -v

Якщо все ОК, ти побачиш версію докера

Розгорнути дамп

Для розгортання дампа потрібно з Workbench створити новий конекшн до БД, де вказати параметри. Я використовував стандартний порт (3306), не змінював ім'я користувача (root за замовчуванням) і вказав пароль для рутового користувача (root).

У Workbench-і робимо Data Import/Restore і обираємо Import from Self Contained File. Як файл вкажи куди ти скачав дамп. Схему заздалегідь створювати не потрібно — її створення входить до дамп-файлу. Після успішного імпорту, у тебе буде схема world із трьома таблицями:

  1. city — це таблиця міст.
  2. country — таблиця країн.
  3. 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, швидше за все, можна "виграти" ще трохи часу.