MyBatis «на минималках»

в 14:48, , рубрики: junior, mybatis, orm, orm всегда медленный
MyBatis «на минималках» - 1

Привет! Меня зовут Пётр Гусаров, я Java‑программист в CDEK. В этой статье расскажу про не очень распространённый фреймворк MyBatis.

Почему MyBatis? Потому что мы в CDEK используем его в большинстве проектов, и в деле он весьма неплохо себя показал. Немного сложен и непривычен на этапе входа, но все эти минусы перекрываются его гибкостью. «Да есть Hibernate, Jooq, JDBC и еще что‑то», — скажут бывалые. Есть, но в данной статье речь пойдёт о MyBatis.

Статья будет полезна новичкам, которые хотели попробовать данный фреймворк или попробовали, но что‑то не получилось.

Содержание

Что мы сделаем

  • Посмотрим на плюсы и минусы данного фреймворка

  • Развернём MyBatis на основе Spring Boot (так шустрее)

  • Напишем и запустим пару‑тройку запросов

  • Посмотрим, в каких случаях применять MyBatis лучше, чем другие фреймворки.

Почему MyBatis

У меня две новости: хорошая и плохая. С какой начать? Хорошо, начнём с плохой, точнее — с недостатков:

  • все запросы придётся писать на нативном SQL в XML-файлах. Стойте, не уходите! Не все так страшно, как звучит :)

А теперь послушай, птичка: ты будешь писать SQL на XML (кадр из м/ф «Крылья, ноги и хвосты»)

А теперь послушай, птичка: ты будешь писать SQL на XML (кадр из м/ф «Крылья, ноги и хвосты»)

Теперь к достоинствам:

  • полный контроль над запросами к БД;

  • намного легче накладывать логику на legacy‑схемы БД (просто мапим запросы на сущности, остальное делает «птичка»);

  • при развитии и усложнении продукта вы потратите меньше времени на оптимизацию запросов, чем в других фреймворках;

  • скорость обработки данных выше. Но здесь есть нюансы: за формирование запросов отвечает разработчик, и только от него зависит, как быстро будет работать обмен данными между приложением и БД;

  • не нужны знания о дополнительных состояниях вашего объекта, как в Hibernate‑е.

Итак, начнём. Что многие обычно делают, когда начинают изучать новый фреймворк? Открывают официальную документацию подключают его и начинают «тыкать» с разных сторон. В крайнем случае пробуют найти готовый примерчик в сети. Фреймворк считается успешным, если получается поднять его, добавив зависимость и пару‑тройку аннотаций, и всё работает. С MyBatis немного по‑другому — здесь подходит фраза из мультика: «лучше день потерять, потом за 5 минут долететь».

*в рамках данной статьи мы затронем только мапперы

*в рамках данной статьи мы затронем только мапперы

Запустим птичку

Для самых нетерпеливых ссылка на пример лежит здесь.

Создадим проект на основе Spring Boot. Не буду описывать подробности, вы и так знаете, как это делается. Кто не в курсе — вам сюда. Добавьте такие зависимости как mybatis, h2, lombok. Или просто возьмите их из этого pom‑файла:

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.3</version>
        <relativePath/>
    </parent>
    <groupId>ru.gpm.example</groupId>
    <artifactId>mybatis-minimum</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>mybatis-minimum</name>
    <description>Demo MyBatis Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.2</version>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter-test</artifactId>
            <version>3.0.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Можем уже запустить проект. Он запустится, но делать ничего не будет. Это самая лучшая программа: ничего не делает, ничего не ломает. Но нас не так учили: нам нужен движ, печеньки и зарплата :)

Стартуем на малых оборотах

Мы не будем писать «hello world», а напишем более приближенный к реальности проект, потому что именно такой подход, по моему мнению, покажет этот инструмент в деле. Сделаем сервис по управлению складом и остатками товара на нём.

Настроим MyBatis (здесь будет немного XML). Рядом с application.yml положим файл mybatis‑config.xml следующего содержания:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <typeAliases>
        <package name="ru.gpm.example.mybatis.min.domain"/>
    </typeAliases>
</configuration>

Здесь мы показали «птичке», где у нас будут лежать объекты, отображающие данные из БД.

Создадим файл schema.sql и положим его в корень ресурсов приложения. Это будет скелет нашей высоконагруженной БД — как мы любим.

schema.sql
set mode MySQL;

CREATE TABLE IF NOT EXISTS product (
    id integer NOT NULL auto_increment,
    name varchar,
    sku varchar,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS  warehouse (
    id integer NOT NULL auto_increment,
    name varchar,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS  stock (
    product_id integer,
    warehouse_id integer,
    qty integer,
    PRIMARY KEY (product_id, warehouse_id)
)

mode MySql нужен для поддержки базой H2 некоторых удобных функций при обновлении данных upsert.

Там же создадим папку с названием mappers, в нее мы будем класть наши файлы с запросами к базе. Теперь скажем Spring'у, где у нас этот XML‑файл лежит и откуда брать запросы к БД. Для этого в application.yml напишем следующее:

mybatis:
  config-location: classpath:mybatis-config.xml
  mapper-locations: classpath*:mappers/*.xml
spring.sql.init.mode: always
  • config‑location — показывает, где настройки MyBatis;

  • mapper‑locations — где «птичка» будет брать запросы;

  • spring.sql.init.mode — указывает Spring'у, когда инициировать скрипт schema.sql (в нашем случае — каждый раз при запуске).

Закончили с настройками, приступим к написанию кода. Создадим 3 класса в пакете domain: товар, склад и остатки.

@Data
@Accessors(chain = true)
public class Product {
    private Integer id;
    private String name;
    private String sku;
}

@Data
@Accessors(chain = true)
public class Stock {
    private Product product;
    private Warehouse warehouse;
    private int count;
}

@Data
@Accessors(chain = true)
public class Warehouse {
    private Integer id;
    private String name;
}

Далее напишем сами запросы, репозитории и сервисы в привычном нам стиле. Начнём с репозиториев. Точнее, здесь они называются «мапперы».

По‑хорошему, лучше использовать репозитории с DI мапперов в них, так как это создает правильный слой данных и даст возможность управлять форматом данных в репозитории. Но у нас в рамках примера таких сложностей не ожидается.

@Mapper
public interface ProductRepository {

    void save(Product product);

    List<Product> findAll();
}

@Mapper
public interface WarehouseRepository {

    void save(Warehouse warehouse);

    Warehouse findOne(int id);
}

@Mapper
public interface StockRepository {

    List<Stock> findStockByWarehouse(Warehouse warehouse);

    void save(Stock stock);
}

Почти всё готово, осталось написать запросы SQL. Да‑да, запросы на старом добром SQL ).

mappers/product-mapper.xml
<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE mapper PUBLIC '-//mybatis.org//DTD Mapper 3.0//EN'
        'http://mybatis.org/dtd/mybatis-3-mapper.dtd'>
<mapper namespace="ru.gpm.example.mybatis.min.repository.ProductRepository">

    <insert id="save" keyProperty="id" useGeneratedKeys="true">
        INSERT INTO product (name, sku)
        VALUES (#{name}, #{sku});
    </insert>

    <select id="findAll" resultMap="ProductMap">
        SELECT id, name, sku
        FROM product;
    </select>

    <resultMap id="ProductMap" type="Product" autoMapping="true"/>
</mapper>

mappers/warehouse-mapper.xml
<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE mapper PUBLIC '-//mybatis.org//DTD Mapper 3.0//EN'
        'http://mybatis.org/dtd/mybatis-3-mapper.dtd'>
<mapper namespace="ru.gpm.example.mybatis.min.repository.WarehouseRepository">

    <insert id="save" useGeneratedKeys="true" keyProperty="id">
        <if test="id == null">
            INSERT INTO warehouse (name) values (#{name});
        </if>
        <if test="id != null">
            UPDATE warehouse SET name=#{name} where id=#{id};
        </if>
    </insert>

    <select id="findOne" resultMap="WarehouseMap">
        SELECT id, name FROM warehouse WHERE id = #{id};
    </select>

    <resultMap id="WarehouseMap" type="Warehouse" autoMapping="true"/>
</mapper>

Обратите внимание: id блоков в XML‑схеме полностью идентичны наименованию методов в интерфейсах репозиториев. Так запросы в XML автоматически линкуются с Java‑интерфейсами репозиториев. Есть и другие варианты, но усложняться не будем.

Здесь мы задействовали такие элементы как insert select и resultMap. Немного остановимся на них:

  • insert — выполняет вставку в БД.
    - id = "..." — это id statement'а для соответствия репозиторию, который будет мапиться на этот запрос;
    - useGeneratedKeys="true" говорит о том, что запрос генерирует значение ключа;
    - keyProperty="id" сообщает «птичке», какое свойство у класса‑модели отвечает за ID и устанавливает его в значение в объекте после сохранения.

  • select — выполняет чтение данных из базы.
    - resultMap = "StockMap" используется в блоке <select> и сообщает «птичке», какой маппер использовать для выгрузки данных в объект.

  • resultMap — собственно, сам маппер. Здесь описываются правила маппинга результата запроса (описанного в блоке <select>) на доменный объект. Вариантов множество. В рамках данной статьи все рассматривать не будем. Остановимся на основных.
    - autoMapping="true" объявляет «птичке»: «Cделай все сама». Но это работает, когда у класса и алиаса в ответе однотипные именования полей, иначе придется описывать правила маппинга.

На следующем маппере (mappers/stock‑mapper.xml) остановимся подробнее.

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE mapper PUBLIC '-//mybatis.org//DTD Mapper 3.0//EN'
        'http://mybatis.org/dtd/mybatis-3-mapper.dtd'>
<!--suppress ALL -->
<mapper namespace="ru.gpm.example.mybatis.min.repository.StockRepository">

    <insert id="save">
        INSERT INTO stock (product_id, warehouse_id, qty)
        VALUES (#{product.id}, #{warehouse.id}, #{count}) ON DUPLICATE KEY
        UPDATE qty = #{count}
    </insert>

    <sql id="stock-select-request">
        SELECT
            p.id AS p_id,
            p.name AS p_name,
            p.sku AS p_sku,
            w.id AS w_id,
            w.name AS w_name,
            s.qty
        FROM stock s
        LEFT JOIN product p ON s.product_id = p.id
        LEFT JOIN warehouse w ON s.warehouse_id = w.id
    </sql>

    <select id="findStockByWarehouse" resultMap="StockMap" parameterType="Warehouse">
        <include refid="stock-select-request"/>
        WHERE w.id = #{id}
    </select>

    <select id="findStockByWarehouseAndProduct" resultMap="StockMap">
        <include refid="stock-select-request"/>
        WHERE w.id = #{warehouseId} AND p.id = #{productId}
    </select>

    <resultMap id="StockMap" type="Stock">
        <result property="count" column="qty"/>
        <association property="product" columnPrefix="p_" javaType="Product">
            <result property="id" column="id"/>
            <result property="name" column="name"/>
            <result property="sku" column="sku"/>
        </association>
        <association property="warehouse" columnPrefix="w_" javaType="Warehouse">
            <result property="id" column="id"/>
            <result property="name" column="name"/>
        </association>
    </resultMap>
</mapper>

Рассмотрим новые элементы:

  • sql — шаблонная конструкция запроса, которая может неоднократно использоваться.
    - id = "..." — это id шаблона.

  • include refid = "..." — собственно, сама вставка шаблона. Она применяется здесь в двух запросах с разными условиями where.

  • parameterType="Warehouse" сообщает «птичке», какой класс объекта передается в параметрах запроса.

  • resultMap — более развернутый маппер. Здесь видно, как алиасы ответа накладываются на вложенные объекты.
    - result property = "count" column = "qty" — настраивает отношение свойств класса и наименования полей ответа;
    - association — настраивает отношение вложенных объектов в класс. Таким образом, мы реализуем отношение one‑to‑one. Где property указывает свойство класса вложенного объекта, а columnPrefix — это своеобразный фильтр алиасов в ответе для данного объекта.

 Ну, вот. Теперь мы снова олдскульные ребята и девчата! (кадр из м/ф «Крылья, ноги и хвосты»)

Ну, вот. Теперь мы снова олдскульные ребята и девчата! (кадр из м/ф «Крылья, ноги и хвосты»)

И самое сердце нашего приложения — сервисы. Чтобы не грузить логикой этот пример, напишем один сервис для получения остатков товаров на складах. Сохранение сделаем в тестах напрямую через репозитории.

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository repository;

    public Stock save(Stock stock) {
        repository.save(stock);
        return stock;
    }

    public List<Stock> getAllByWarehouse(Warehouse warehouse) {
        return repository.findStockByWarehouse(warehouse);
    }

    public Stock getBy(Warehouse warehouse, Product product) {
        return repository.findStockByWarehouseAndProduct(warehouse.getId(), product.getId());
    }
}

Вот и всё — можем запускать наш проект и ловить баги наслаждаться жизнью. Не будем дергать это всё великолепие REST‑контроллерами, а просто напишем тест, который покажет, как это всё работает.

Посмотрим, как это всё работает

кадр из м/ф «Крылья, ноги и хвосты»

кадр из м/ф «Крылья, ноги и хвосты»
@Slf4j
@SpringBootTest
class MyBatisApplicationTest {

    @Autowired
    private StockService stockService;
    @Autowired
    private ProductRepository productRepository;
    @Autowired
    private WarehouseRepository warehouseRepository;

    @Test
    void init() {
        // Добавим товары в БД
        productRepository.save(new Product().setName("name-1").setSku("sku-1"));
        productRepository.save(new Product().setName("name-2").setSku("sku-2"));
        final List<Product> all = productRepository.findAll();
        Assertions.assertEquals(2, all.size());

        // Добавим склад.
        final Warehouse warehouse = new Warehouse().setName("склад-1");
        warehouseRepository.save(warehouse);
        Assertions.assertNotNull(warehouseRepository.findOne(warehouse.getId()));

        // Сохраним остатки по товарам на складе
        final Stock stock1 = new Stock().setProduct(all.get(0)).setWarehouse(warehouse).setCount(10);
        final Stock stock2 = new Stock().setProduct(all.get(1)).setWarehouse(warehouse).setCount(50);
        stockService.save(stock1);
        stockService.save(stock2);

        // Получим текущие остатки на складе
        List<Stock> allByWarehouse = stockService.getAllByWarehouse(warehouse);
        Assertions.assertEquals(2, allByWarehouse.size());
        log.info("{}", allByWarehouse);

        // Поменяем остаток товара
        stockService.save(stock1.setCount(20));
        allByWarehouse = stockService.getAllByWarehouse(warehouse);
        Assertions.assertEquals(2, allByWarehouse.size());
        final Stock stockEdit =  stockService.getBy(warehouse, stock1.getProduct());
        Assertions.assertNotNull(stockEdit);
        Assertions.assertEquals(20, stockEdit.getCount());
        log.info("{}", allByWarehouse);
    }
}

Занавес

Думаю, достаточно для первого знакомства с «птичкой». Подозреваю, многие скажут, что неудобно всё это ручками писать, если есть фреймворки, делающие всё за тебя. Отчасти вы будете правы: они очень удобны, чтобы быстренько написать стандартное приложение. Но когда приходится подключать эти «суперавтоматы» к legacy‑базам или требуется оптимизация запросов в связи с возросшим размером данных, то MyBatis реально выручает.
Возможно, кому‑то данная статья поможет в выборе технологии, а у кого‑то я просто украл время.

Всегда готов к конструктивной критике, так как она поднимает знания и опыт критикуемого :)

Ссылки: GitHub примера, Мультфильм: «Крылья, ноги и хвосты» (скрины и цитаты).

Автор: Петр

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js