Проект Java Servlet + jsp. Часть 2. Постановка задачи и работа с базой MySQL. Составные первичные ключи.

Продолжаю цикл статей, посвящённый моему приложению для портфолио с Java Servlet, Hibernate и jsp страницами.

В прошлой части я создал простое Hello World приложение в качестве разминки. В этой статье я начну работать уже непосредственно над своей задачей и с существующей базой данных.

В этой статье буду создавать модели, соответствующие таблицам в базе, DAO слой, добавлять зависимости в pom.xml для работы с Hibernate и базой данных MySQL, и настаивать конфиги подключения.


Опишу для начала, что я хочу сделать.

Задача, может, не самая распространённая в этой форме, но всё же довольно универсальная и на поиграться с приложением самое то. На своём сайте каждую неделю я обновляю личный чарт топ40 на основе данных со своей странички ласт.фм. То есть просто публикую 40 самых прослушиваемых песен каждое воскресенье. Архивы я храню в базе MySQL, но забивать туда данные пока что нужно вручную, и это занимает очень много времени: сначала я составляю список в ворде, потом сохраняю все данные в три таблицы моей базы. На данный момент схема выглядит так:

Приложение должно сделать создание еженедельных чартов и доступ к архивам легче и исключить возможность ошибиться в id или другой колонке. По сути, это будет CRUD-приложение с простой логикой. Что оно должно делать:

  1. Отображать на главной странице последний чарт.
  2. Дать доступ к архиву чартов — выбор чарта по дате.
  3. Показывать историю в чарте для каждого артиста — поиск песен по артисту.
  4. Показывать историю в чарте для каждой песни — показывать все её позиции в чарте за каждую дату, что она была в чарте.
  5. Позволять компилировать новый чарт.

Вся работа происходит с уже существующей базой в MySQL, которую я изначально запроектировал не особо правильно, но моим требованиям она отвечает. В базе три таблицы:

  • chart с номером чарта и его датой, первичный ключ здесь порядковый номер чарта;
  • song с названием песни и её исполнителями, первичный ключ здесь порядковый номер песни в базе;
  • position с составным первичным ключом: номер чарта и номер песни.

Зависимости Hibernate и MySQL в pom.xml

Добавим в pom.xml зависимости для работы с Hibernate.

    <properties>
 
        <...>

        <hibernate.version>5.4.3.Final</hibernate.version>
        <org.springframework.version>5.1.4.RELEASE</org.springframework.version>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <...> 

        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>${hibernate.version}</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-entitymanager</artifactId>
            <version>${hibernate.version}</version>
        </dependency>
        
        <...>
    </dependencies>

Тут же добавлю коннектор к базе данных:

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.32</version>
        </dependency>

Создаём модели

Теперь для каждой таблицы в базе создам соответствующий класс. Все связанные с данными классы помещу в пакет data. Модели будут внутри него в ещё одном пакете model.

Перед каждым заголовком класса пишем @Entity и @Table с указанием имени таблицы. Перед каждым полем пишем имя соответствующей колонки в базе с аннотацией @Column и тем же атрибутом name. Перед первичным ключом ставим аннотацию @Id.

Так как мои ключи в базе задаются вручную, оставляю так. Иначе, нужно было бы написать что-то типа этого (но только если в базе на колонке задан auto increment):

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="id")
    private int id;

Описать таблицы с чартами и песнями будет довольно легко. Просто перечисляю все колонки и задаю геттеры и сеттеры.

@Entity
@Table(name="chart")
public class Chart {

    @Id
    @Column(name="idChart")
    private int id;

    @Column(name="Date")
    private String date;

    public Chart() {
    }

    public Chart(int id, String date) {
        this.id = id;
        this.date = date;
    }

    //тут должны быть геттеры и сеттеры
}
@Entity
@Table(name="song")
public class Song {

    @Id
    @Column(name="idSong")
    private int id;

    @Column(name="Weeks")
    private int weeks;

    @Column(name="Peak")
    private int peak;

    @Column(name="Artists")
    private String artists;

    @Column(name="Name")
    private String name;

    public Song() {
    }

    public Song(int id, int weeks, int peak, String artists, String name) {
        this.id = id;
        this.weeks = weeks;
        this.peak = peak;
        this.artists = artists;
        this.name = name;
    }

  // тут должны быть геттеры и сеттеры
}

С третьей таблицей будет сложнее, потому что в ней есть составной первичный ключ. Я уже писал про такие вещи в контексте Спринга, но на всякий случай повторюсь.

Составной первичный ключ

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

Для начала нужно сделать отдельный класс с полями, которые входят в этот ключ и аннотировать его как @Embeddable

@Embeddable
public class Position_PK implements Serializable {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "Chart")
    private Chart chart;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "idSong")
    private Song song;

    public Position_PK() {
    }

    public Position_PK(Chart chart, Song song) {
        this.chart = chart;
        this.song = song;
    }

  // тут должны быть геттеры и сеттеры
}

Класс обязательно должен быть Serializable.

Далее создаём модель Position, где вместо ключевых полей указываем созданный только что объект Position_PK с аннотацией @EmbeddedId. Остальные поля описываем по классике.

@Entity
@Table(name="position")
public class Position {

    @EmbeddedId
    private Position_PK pk;

    @Column(name="Position")
    private int position;

    @Column(name="LastWeek")
    private Integer lastWeek;

    public Position() {
    }

    public Position(Position_PK pk, int position, Integer lastWeek) {
        this.pk = pk;
        this.position = position;
        this.lastWeek = lastWeek;
    }

  // тут должны быть геттеры и сеттеры
}

CRUD операции — слой DAO (Data Access Object)

Теперь пришло время в отдельном пакете для каждой модели создать по классу, который бы работал с этими данными.

Следующий класс предполагает сохранение, получение всех песен из таблицы, получение одной песни по её ключу, обновление и удаление.

public class SongDAOImpl {

    public static void save(Song result) {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        session.save(result);
        session.getTransaction().commit();
        session.close();
    }

    public static List<Song> getAll() {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        Query query = session.createQuery("FROM Song " +
                "ORDER BY id ");
        List<Song> list = query.list();
        session.getTransaction().commit();
        session.close();
        return list;
    }

    public static Song getById(int id) {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        Query query = session.createQuery("FROM Song " +
                "WHERE id = :id");
        query.setInteger("id", id);
        Song result = (Song) query.uniqueResult();
        session.getTransaction().commit();
        session.close();
        return result;
    }

    public static void update(Song result) {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        session.update(result);
        session.getTransaction().commit();
        session.close();
    }

    public static void delete(int id){
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        Query query=session.createQuery("FROM Song " +
                "WHERE id = :id");
        query.setInteger("id",id);
        Song result = (Song) query.uniqueResult();
        session.delete(result);
        session.getTransaction().commit();
        session.close();
    }

}

В следующих статьях по надобности буду добавлять другие методы с более сложными манипуляциями с данными. «Более сложными», конечно, громко сказано, но буду соединять несколько таблиц в одном запросе и фильтровать выдачу. Пример фильтрации уже есть выше с помощью привычного WHERE. Нужно понимать, что в кавычках мы пишем не обычный sql, а hql — язык Hibernate. Он похож на sql, но несколько проще.

Посмотрим на примере Position, как соединять таблицы с помощью Join — конструкция намного короче, чем была бы в sql.

public class PositionDAOImpl {

    public static void save(Position result) {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        session.save(result);
        session.getTransaction().commit();
        session.close();
    }

    public static List<Position> getAll() {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        Query query = session.createQuery("FROM Position p " +
                "LEFT JOIN FETCH p.pk.chart  " +
                "LEFT JOIN FETCH p.pk.song  ");
        List<Position> list = query.list();
        session.getTransaction().commit();
        session.close();
        return list;
    }

    public static void update(Position result) {
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        session.update(result);
        session.getTransaction().commit();
        session.close();
    }

    public static void delete(int id){
        Session session = HibernateUtil.getSessionFactory().openSession();
        session.beginTransaction();
        Query query=session.createQuery("FROM Position " +
                "WHERE id = :id");
        query.setInteger("id",id);
        Position result = (Position) query.uniqueResult();
        session.delete(result);
        session.getTransaction().commit();
        session.close();
    }
}

Здесь в методе getAll() я соединяю таблицу с двумя другими, чтобы через объект Position иметь доступ к вложенным в него объектам Chart и Song.

Конфигурация Hibernate

В папке resources создам xml файл с конфигурацией. Обзову его sqlserverMain.cfg.xml

<!DOCTYPE hibernate-configuration SYSTEM
        "http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd">

<hibernate-configuration>
    <session-factory>
        <property name="connection.driver_class">com.mysql.cj.jdbc.Driver</property>
        <property name="connection.url">jdbc:mysql://localhost:3306/top40_app?useSSL=false</property>
        <property name="connection.username">root</property>
        <property name="connection.password">root</property>
        <property name="connection.pool_size">10000</property>
        <property name="hibernate.dbcp.maxActive">-1</property>
        <property name="hibernate.dbcp.maxIdle">10</property>
        <property name="dialect">org.hibernate.dialect.MySQL8Dialect</property>
        <mapping class="com.chart.TopChart.data.model.Song" />
        <mapping class="com.chart.TopChart.data.model.Chart" />
        <mapping class="com.chart.TopChart.data.model.Position" />

    </session-factory>
</hibernate-configuration>

Здесь указываю адрес базы и её название (top40_app), а также данные для авторизации (connection.username и connection.password). У меня оба этих свойства по-простому root (для локальной базы и личного проекта такого уровня безопасности мне хватит).

Также я указал нужный диалект для MySQL 8, но можно использовать и SQLServerDialect по умолчанию.

Далее указываю список всех Entity. Если в таблицу добавляете новую таблицу и создаёте новую модель, не забудьте и здесь добавить ещё одну строчку.

И теперь в пакете util создам класс HibernateUtil:

public class HibernateUtil {
    private static SessionFactory sessionFactory = buildSessionFactory();

    private static SessionFactory buildSessionFactory() {
        try {
            return new Configuration().configure("sqlserverMain.cfg.xml").buildSessionFactory();
        } catch (Exception e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public static SessionFactory getSessionFactory() {
            return sessionFactory;
        }

}

Можно конечно всю конфигу засунуть в этот класс, но мне проще вынести её в отдельный файл и указывать путь к файлу в качестве аргумента.

Вывод данных из базы

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

Создам сервлет Main:

@WebServlet(name = "main", value = "/")
public class MainServlet extends HttpServlet {

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        List<Song> songs = SongDAOImpl.getAll();
        List<Position> positions = PositionDAOImpl.getAll();

        request.setAttribute("songs", songs);
        request.setAttribute("positions", positions);
        request.getRequestDispatcher("main.jsp").forward(request, response);
        response.flushBuffer();
    }

    public void destroy() {
    }
}

Здесь я вытаскиваю из базы все песни и все позиции — то есть все данные из двух таблиц и эти списки отправляю на страницу main.jsp.

Перед тем, как взглянуть на main.jsp, нужно добавить в pom.xml ещё одну зависимость:

        <dependency>
            <groupId>jstl</groupId>
            <artifactId>jstl</artifactId>
            <version>1.2</version>
        </dependency>

Jakarta Standard Tag Library (ранее известная как JavaServer Pages Standard Tag Library) или просто JSTL позволяет нам перебирать данные в цикле на main.jsp:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

<!DOCTYPE html>
<html>
<head>
    <title>JSP - Hello World</title>
</head>
<body>
<h1><%= "TOP 40!" %>
</h1>
<br/>
<table>
<c:forEach items="${songs}" var="song">
    <tr>
        <td>${song.name}</td>
        <td>${song.artists}</td>
        <td>${song.peak}</td>
        <td>${song.weeks}</td>
    </tr>
</c:forEach>
</table>

<hr>
<hr>
<hr>

<table>
    <c:forEach items="${positions}" var="position">
        <tr>
            <td>${position.pk.chart.id}</td>
            <td>${position.pk.chart.date}</td>
            <td>${position.position}</td>
            <td>${position.lastWeek}</td>
            <td>${position.pk.song.name}</td>
            <td>${position.pk.song.artists}</td>
        </tr>
    </c:forEach>
</table>

</body>
</html>

В цикле forEach я перебираю все объекты из списков songs и positions.

В случае с песнями для каждой song можно вытащить все поля этого объекта, обращаясь к ним через точку (${song.name}).

С позициями обращения будут длиннее, но так как в DAO я заджойнил таблицы, никаких проблем это тоже не должно вызвать. Так как chart и song являются частью составного первичного ключа, нам нужно сначала обратиться к ключу, а потом достать из него нужный объект. Получается ${position.pk.chart.date} для поля date объекта Chart (который в ключе обозван chart) и ${position.pk.song.artists} для поля artists объекта Song (в ключе он song).


На этом всё. Получилось немного длинно, но зато в этой статье я сделал кучу полезных вещей: создал модели и DAO слой, настроил Hibernate и подключение к базе данных, а также подготовил всё для работы с данными и даже написал первый сервлет, который пока выглядит не особо симпатично (просто в таблице без стилей показывает содержимое двух таблиц)

В следующих статьях из цикла превращу главную страницу в более адекватную и буду выводить там лишь последний чарт, добавлю архив (поиск чарта по дате или номеру, поиск песен по артисту и отображение истории каждой песни) и возможность собирать и сохранять новый чарт (поработаю с doPost и формами и может быть ajax — ещё не решил, как это всё будет выглядеть). Подготовка проекта закончена.