8. Приклад із практики
8.1. Introduction
Ми пропонуємо написати веб-додаток для запису на прийом до медичного кабінету. Ця проблема розглядалася в документі «Посібник AngularJS / Spring 4» у розділах URL та [http://tahe.developpez.com/angularjs-spring4/]. Архітектура цього додатка була такою:
![]() |
- у [1] веб-сервер надає статичні сторінки браузеру. Ці сторінки містять додаток AngularJS, побудований за моделлю MVC (Модель – Вигляд – Контролер). Модель тут є одночасно моделлю представлень та моделлю домену, представленою тут шаром [Services];
- користувач взаємодіятиме з поданими йому у браузері поданнями. Його дії іноді вимагатимуть звернення до сервера Spring 4 [2]. Сервер обробить запит і поверне відповідь jSON (JavaScript — нотація об’єктів) [3]. Ця відповідь буде використана для оновлення подання, що відображається користувачеві.
Ми пропонуємо взяти цю програму та реалізувати її від початку до кінця за допомогою Spring MVC. Архітектура тоді матиме такий вигляд:
![]() |
Браузер підключатиметься до додатка [Web 1], реалізованого за допомогою Spring MVC, який отримуватиме дані з веб-сервісу [Web 2], також реалізованого за допомогою Spring MVC.
8.2. Функціональні можливості додатка
Читачеві пропонується ознайомитися з функціональними можливостями додатка, протестувавши його. Ми завантажуємо в STS проекти Maven із папки [etude-de-cas]:
![]() | ![]() |
Спочатку ми створимо базу даних MySQL 5 [dbrdvmedecins] за допомогою інструменту [Wamp Server] (див. розділ 9.5):
![]() |
- у [1] вибираємо інструмент [phpMyAdmin] з WampServer;
- у [2] вибирається опція [Importer];
![]() |
- у [3] вибираємо файл [database/dbrdvmedecins.sql];
- у [4] — запускаємо його;
- у [5] створюється база даних.
Далі нам потрібно запустити сервер, підключений до бази даних. Це проект [rdvmedecins-webjson-server]
![]() |
Сервер буде доступний за адресами URL та [http://localhost:8080]. Це можна змінити у файлі [application.properties] цього проєкту:
![]() |
server.port=8080
Параметри доступу до бази даних зберігаються у класі [DomainAndPersistenceConfig] проекту [rdvmedecins-metier-dao]:
![]() |
// джерело даних MySQL
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
return dataSource;
}
Якщо ви отримуєте доступ до SGBD MySQL з іншими ідентифікаторами, то саме тут все відбувається.
Потім, так само як і попередній сервер, запускається сервер [rdvmedecins-springthymeleaf-server]:
![]() | ![]() |
Цей сервер за замовчуванням доступний за адресою URL [http://localhost:8081]. Знову ж таки, це можна налаштувати у файлі [application.properties] проекту:
server.port=8081
Крім того, цей сервер повинен знати URL сервера, підключеного до бази даних. Ця конфігурація міститься у класі [AppConfig], наведеному вище:
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// кореневий веб-сервіс / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// тайм-аут у мілісекундах
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
Якщо перший сервер було запущено на порту, відмінному від 8080, необхідно змінити рядок 5.
Потім за допомогою браузера потрібно звернутися до URL [http://localhost:8081/boot.html]:
![]() |
- на [1] — сторінку входу в додаток;
- в [2] та [3] — ім’я користувача та пароль того, хто хоче скористатися додатком. Є два користувачі: admin/admin (логін/пароль) з роллю (ADMIN) та user/user з роллю (USER). Лише роль ADMIN має право користуватися додатком. Роль USER наведено лише для того, щоб показати, що відповідає сервер у цьому випадку використання;
- у [4] — кнопка, що дозволяє підключитися до сервера;
- у [5] — мова додатка. Їх дві: французька (за замовчуванням) та англійська;
- у [6] — URL сервера [rdvmedecins-springthymeleaf-server];
![]() |
- у [1] здійснюється вхід;
![]() |
- після входу можна вибрати лікаря, до якого хочете записатися на прийом [2], та день прийому [3]. Як тільки вказано лікаря та день, автоматично відображається розклад:
![]() |
- отримавши розклад лікаря, можна забронювати час прийому [5];
![]() |
- у [6] вибирають пацієнта для прийому та підтверджують цей вибір у [7];
![]() |
Після підтвердження прийому система автоматично повертає до календаря, де новий прийом тепер занесено. Цей прийом можна буде згодом видалити за допомогою [8].
Основні функції описано. Вони прості. На завершення розглянемо налаштування мови:
1

- у [1] можна перейти з французької на англійську;
![]() |
- у [2] інтерфейс перейшов на англійську мову, включаючи календар;
8.3. База даних
![]() |
База даних, яка надалі називатиметься [dbrdvmedecins], є базою даних MySQL5 із такими таблицями:
![]() |
Записи про зустрічі зберігаються у таких таблицях:
- [medecins]: містить список лікарів клініки;
- [clients]: містить список пацієнтів клініки;
- [creneaux]: містить часові слоти кожного з лікарів;
- [rv]: містить список прийомів лікарів.
Таблиці [roles], [users] та [users_roles] пов’язані з автентифікацією. Наразі ми не будемо їх розглядати. Зв’язки між таблицями, що керують прийомами, такі:
![]() |
- один часовий проміжок належить одному лікарю — один лікар має 0 або кілька часових проміжків;
- запис на прийом об’єднує клієнта та лікаря через часовий проміжок останнього;
- клієнт має 0 або кілька зустрічей;
- з часовим проміжком пов’язано 0 або кілька зустрічей (у різні дні).
8.3.1. Таблиця [MEDECINS]
Вона містить інформацію про лікарів, які обслуговуються додатком [RdvMedecins].
![]() | ![]() |
- ID: номер, що ідентифікує лікаря — первинний ключ таблиці
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 щоразу, коли до рядка вносяться зміни.
- NOM: прізвище лікаря
- PRENOM: його ім’я
- TITRE: його/її титул (пані, пані, пан)
8.3.2. Таблиця [CLIENTS]
Пацієнти різних лікарів заносяться до таблиці [CLIENTS]:
![]() | ![]() |
- ID: номер, що ідентифікує клієнта — первинний ключ таблиці
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 кожного разу, коли до рядка вносяться зміни.
- NOM: ім’я клієнта
- PRENOM: його ім’я
- TITRE: його/її титул (пані, пані, пан)
8.3.3. Таблиця [CRENEAUX]
У ній перелічено часові проміжки, у яких можливі RV:
![]() |
![]() |
- ID: номер, що ідентифікує часовий проміжок — первинний ключ таблиці (рядок 8)
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 щоразу, коли до рядка вносяться зміни.
- ID_MEDECIN: номер, що ідентифікує лікаря, якому належить цей часовий проміжок — зовнішній ключ у стовпці MEDECINS (ID).
- HDEBUT: час початку часового проміжку
- MDEBUT: хвилини початку часового проміжку
- HFIN: година закінчення часового проміжку
- MFIN: хвилини закінчення інтервалу
Другий рядок таблиці [CRENEAUX] (див. [1] вище) вказує, наприклад, що слот № 2 починається о 8:20 і закінчується о 8:40 та належить лікарю № 1 (пані Марі PELISSIER).
8.3.4. Таблиця [RV]
У ній наведено перелік RV, призначених кожному лікарю:
![]() |
- ID: номер, що однозначно ідентифікує RV — первинний ключ
- JOUR: день RV
- ID_CRENEAU: часовий проміжок запису RV — зовнішній ключ до поля [ID] таблиці [CRENEAUX] — визначає як часовий проміжок, так і відповідного лікаря.
- ID_CLIENT: номер клієнта, для якого зроблено бронювання — зовнішній ключ у полі [ID] таблиці [CLIENTS]
Ця таблиця має обмеження на унікальність значень з’єднаних стовпців (JOUR, ID_CRENEAU):
Якщо рядок таблиці [RV] має значення (JOUR1, ID_CRENEAU1) для стовпців (JOUR, ID_CRENEAU), це значення не може зустрічатися більше ніде. Інакше це означало б, що одночасно було зареєстровано два записи RV для одного й того самого лікаря. З точки зору програмування на Java драйвер JDBC бази даних запускає SQLException, коли трапляється такий випадок.
Рядок id, що дорівнює 3 (див. [1] вище), означає, що 23.08.2006 було заброньовано RV для слоту № 20 та клієнта № 4. З таблиці [CRENEAUX] випливає, що слот № 20 відповідає часовому проміжку 16:20–16:40 і належить лікарю № 1 (пані Марі PELISSIER). З таблиці [CLIENTS] випливає, що клієнт № 4 — це пані Бріжит BISTROU.
8.3.5. Створення бази даних
Для створення бази даних [dbrdvmedecins] у прикладі цього документа [1-3] наведено скрипт [dbrdvmedecins.sql]:
![]() |
Ми використовуємо інструмент [PhpMyAdmin] з WampServer:
![]() |
- у [1] вибираємо інструмент [phpMyAdmin] з WampServer;
- у [2] вибираємо опцію [Importer];
![]() |
- у [3] вибираємо файл [database/dbrdvmedecins.sql];
- у [4] — запускаємо його;
- у [5] створюється база даних.
8.4. Веб-сервіс / jSON
![]() |
У наведеній вище архітектурі ми переходимо до створення веб-сервісу / jSON, побудованого на базі фреймворку Spring MVC. Ми будемо писати його у кілька етапів:
- спочатку шари [métier] та [DAO] (Data Access Object). Тут ми використовуватимемо Spring Data;
- потім — веб-сервіс jSON без аутентифікації. Тут ми будемо використовувати Spring MVC;
- потім додамо модуль автентифікації за допомогою Spring Security.
Нижче наведено копію документа [http://tahe.developpez.com/angularjs-spring4/] із деякими змінами.
8.4.1. Вступ до Spring Data
Ми реалізуємо рівень [DAO] проекту за допомогою Spring Data — гілки екосистеми Spring.
![]() |
На сайті Spring є чимало підручників для початківців із Spring [http://spring.io/guides]. Ми скористаємося одним із них, щоб ознайомитися зі Spring Data. Для цього ми використовуємо Spring Tool Suite (STS).
![]() |
- у [1] ми імпортуємо один із підручників із [spring.io/guides];
![]() |
- у [2] вибираємо підручник [Accessing Data Jpa], який демонструє, як отримати доступ до бази даних за допомогою Spring Data;
- у [3] вибираємо проект, налаштований за допомогою Maven;
- у [4] підручник можна отримати у двох формах: [initial] — це порожня версія, яку заповнюють, слідуючи підручнику, або [complete] — це остаточна версія підручника. Ми обираємо останню;
- у [5] можна переглянути посібник у браузері;
- у [6] — кінцевий проект.
8.4.1.1. Конфігурація проекту в Maven
Залежності Maven для проекту налаштовуються у файлі [pom.xml]:
<groupId>org.springframework</groupId>
<artifactId>gs-accessing-data-jpa</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
</dependencies>
<properties>
<!-- використовувати UTF-8 для всього -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>hello.Application</start-class>
</properties>
- рядки 5–9: визначають батьківський проект Maven. Саме він визначає основну частину залежностей проекту. Їх може бути достатньо, і в цьому випадку додаткові залежності не додаються, або ж їх може не вистачати, і в цьому випадку додаються відсутні залежності;
- рядки 12–15: визначають залежність від [spring-boot-starter-data-jpa]. Цей артефакт містить класи Spring Data;
- рядки 16–19: визначають залежність від SGBD та H2, що дозволяє створювати та керувати базами даних у пам’яті.
Розглянемо класи, що надаються цими залежностями:
![]() | ![]() | ![]() |
Їх дуже багато:
- деякі належать до екосистеми Spring (ті, що починаються з spring);
- інші належать до екосистеми Hibernate (hibernate, jboss), реалізацію якої ми тут використовуємо (JPA);
- ще інші — це бібліотеки тестів (junit, hamcrest);
- ще інші — бібліотеки логів (log4j, logback, slf4j);
Ми збережемо їх усі. Для додатка, що працює у виробничому середовищі, слід залишити лише ті, що є необхідними.
У рядку 26 файлу [pom.xml] міститься такий рядок:
<start-class>hello.Application</start-class>
Цей рядок пов’язаний із такими рядками:
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
У рядках 6–9 плагін [spring-boot-maven-plugin] дозволяє згенерувати виконуваний JAR-файл додатка. Рядок 26 файлу [pom.xml] позначає виконуваний клас цього JAR-файлу.
8.4.1.2. Рівень [JPA]
Доступ до бази даних здійснюється через шар [JPA], Java Persistence API:
![]() |
![]() |
Додаток є простим і обслуговує клієнтів [Customer]. Клас [Customer] входить до складу шару [JPA] і має такий вигляд:
package hello;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String firstName;
private String lastName;
protected Customer() {
}
public Customer(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
@Override
public String toString() {
return String.format("Customer[id=%d, firstName='%s', lastName='%s']", id, firstName, lastName);
}
}
Клієнт має ідентифікатор [id], ім’я [firstName] та прізвище [lastName]. Кожен екземпляр [Customer] представляє рядок таблиці бази даних.
- рядок 8: анотація JPA, яка визначає, що збереження екземплярів [Customer] (Create, Read, Update, Delete) буде здійснюватися за допомогою реалізації JPA. Згідно з залежностями Maven, бачимо, що використовується реалізація JPA / Hibernate;
- рядки 11–12: анотації JPA, які пов’язують поле [id] із первинним ключем таблиці [Customer]. Рядок 12 вказує, що реалізація JPA використовуватиме метод генерації первинного ключа, властивий використовуваному SGBD, у даному випадку H2;
Інших анотацій JPA немає. У такому разі будуть використані значення за замовчуванням:
- таблиця [Customer] матиме назву класу, тобто [Customer];
- стовпці цієї таблиці матимуть назви полів класу: [id, firstName, lastName], враховуючи, що у назві стовпця таблиці не враховується регістр;
Слід зауважити, що використовувана реалізація JPA ніде не називається.
8.4.1.3. Рівень [DAO]
![]() |
![]() |
Клас [CustomerRepository] реалізує рівень [DAO]. Його код такий:
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
Отже, це інтерфейс, а не клас (рядок 7). Він успадковує інтерфейс [CrudRepository], інтерфейс Spring Data (рядок 5). Цей інтерфейс має два параметри: перший — це тип керованих елементів, у даному випадку тип [Customer], другий — тип первинного ключа керованих елементів, у даному випадку тип [Long]. Інтерфейс [CrudRepository] має такий вигляд:
package org.springframework.data.repository;
import java.io.Serializable;
@NoRepositoryBean
public interface CrudRepository<T, ID extends Serializable> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> save(Iterable<S> entities);
T findOne(ID id);
boolean exists(ID id);
Iterable<T> findAll();
Iterable<T> findAll(Iterable<ID> ids);
long count();
void delete(ID id);
void delete(T entity);
void delete(Iterable<? extends T> entities);
void deleteAll();
}
Цей інтерфейс визначає операції CRUD (Create – Read – Update – Delete), які можна виконувати над типом JPA T:
- рядок 8: метод save дозволяє зберегти суть T у базі даних. Він зберігає суть із первинним ключем, який їй присвоїв SGBD. Він також дозволяє оновити суть T, ідентифіковану за її первинним ключем id. Вибір тієї чи іншої дії залежить від значення первинного ключа id: якщо його значення дорівнює null, виконується операція збереження, в іншому випадку — операція оновлення;
- рядок 10: те саме, але для списку сутностей;
- рядок 12: метод findOne дозволяє знайти суть T, ідентифіковану за її первинним ключем id;
- рядок 22: метод delete дозволяє видалити суть T, ідентифіковану за її первинним ключем id;
- рядки 24–28: варіанти методу [delete];
- рядок 16: метод [findAll] дозволяє знайти всі збережені сутності T;
- рядок 18: те саме, але обмежено сутностями, для яких передано список ідентифікаторів;
Повернемося до інтерфейсу [CustomerRepository]:
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
- рядок 9 дозволяє знайти [Customer] за його назвою [lastName];
І це все щодо шару [DAO]. Класу реалізації попереднього інтерфейсу немає. Він генерується під час виконання за допомогою [Spring Data]. Методи інтерфейсу [CrudRepository] реалізуються автоматично. Щодо методів, доданих до інтерфейсу [CustomerRepository], то тут все залежить від конкретного випадку. Повернемося до визначення [Customer]:
private long id;
private String firstName;
private String lastName;
Метод у рядку 9 реалізується автоматично класом [Spring Data], оскільки він посилається на поле [lastName] (рядок 3) класу [Customer]. Коли Spring Data зустрічає метод [findBySomething] в інтерфейсі, що підлягає реалізації, він реалізує його за допомогою такого запиту JPQL (Java Persistence Query Language):
Отже, тип T повинен мати поле з іменем [something]. Таким чином, метод
буде реалізовано за допомогою коду, схожого на такий:
return [em].createQuery("select c from Customer c where c.lastName=:value").setParameter("value",lastName).getResultList()
де [em] позначає контекст збереження JPA. Це можливо лише за умови, що клас [Customer] має поле з іменем [lastName], що й є у даному випадку.
Отже, у простих випадках Spring Data дозволяє нам реалізувати рівень [DAO] за допомогою простого інтерфейсу.
8.4.1.4. Рівень [console]
![]() |
![]() |
Клас [Application] має такий вигляд:
package hello;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
// зберегти кількох клієнтів
repository.save(new Customer("Jack", "Bauer"));
repository.save(new Customer("Chloe", "O'Brian"));
repository.save(new Customer("Kim", "Bauer"));
repository.save(new Customer("David", "Palmer"));
repository.save(new Customer("Michelle", "Dessler"));
// завантажити всіх клієнтів
Iterable<Customer> customers = repository.findAll();
System.out.println("Customers found with findAll():");
System.out.println("-------------------------------");
for (Customer customer : customers) {
System.out.println(customer);
}
System.out.println();
// отримати даних про окремого клієнта за ID
Customer customer = repository.findOne(1L);
System.out.println("Customer found with findOne(1L):");
System.out.println("--------------------------------");
System.out.println(customer);
System.out.println();
// отримати клієнтів за прізвищем
List<Customer> bauers = repository.findByLastName("Bauer");
System.out.println("Customer found with findByLastName('Bauer'):");
System.out.println("--------------------------------------------");
for (Customer bauer : bauers) {
System.out.println(bauer);
}
context.close();
}
}
- рядок 10: вказує, що клас призначений для налаштування Spring. Останні версії Spring можна налаштовувати на Java, а не на XML. Обидва методи можна використовувати одночасно. У коді класу з анотацією [Configuration] зазвичай містяться біни Spring, тобто визначення класів, які потрібно інстанціювати. Тут жоден бін не визначений. Слід нагадати, що під час роботи з SGBD необхідно визначити різні біни Spring:
- [EntityManagerFactory], що визначає реалізацію JPA, яку слід використовувати,
- [DataSource], що визначає джерело даних, яке слід використовувати,
- [TransactionManager], що визначає менеджер транзакцій, який слід використовувати;
Тут жоден із цих бінів не визначений.
- у рядку 11: анотація [EnableAutoConfiguration] — це анотація з проєкту [Spring Boot] (рядки 5–6). Ця анотація вимагає від Spring Boot через клас [SpringApplication] (рядок 16) налаштувати додаток відповідно до бібліотек, знайдених у його Classpath. Оскільки бібліотеки Hibernate знаходяться у Classpath, бін [entityManagerFactory] буде реалізовано з використанням Hibernate. Оскільки бібліотека SGBD H2 знаходиться в Classpath, бін [dataSource] буде реалізовано з використанням H2. У біні [dataSource] також потрібно вказати ім’я користувача та його пароль. У цьому випадку Spring Boot використовуватиме адміністратора за замовчуванням з H2, який не має пароля. Оскільки бібліотека [spring-tx] знаходиться у Classpath, буде використовуватися менеджер транзакцій Spring.
Крім того, папка, у якій знаходиться клас [Application], буде просканована на наявність бінів, які Spring розпізнає неявно або які явно визначені за допомогою анотацій Spring. Таким чином, будуть перевірені класи [Customer] та [CustomerRepository]. Оскільки перший клас має анотацію [@Entity], він буде каталогізований як суть, що має управлятися Hibernate. Оскільки другий клас розширює інтерфейс [CrudRepository], він буде зареєстрований як бін Spring.
Розглянемо рядки 16–17 коду:
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
- рядок 16: виконується статичний метод [run] класу [SpringApplication] проекту Spring Boot. Його параметром є клас, що має анотацію [Configuration] або [EnableAutoConfiguration]. Після цього відбудеться все, що було описано раніше. Результатом є контекст додатка Spring, тобто набір бінів, що керуються Spring;
- рядок 17: ми запитуємо у цього контексту Spring бін, що реалізує інтерфейс [CustomerRepository]. Тут ми отримуємо клас, згенерований Spring Data для реалізації цього інтерфейсу.
Наступні операції лише використовують методи біна, що реалізує інтерфейс [CustomerRepository]. Зверніть увагу, що в рядку 50 контекст закрито. Результати виведення в консоль такі:
- рядки 1–8: логотип проєкту Spring Boot;
- рядок 9: виконується клас [hello.Application];
- рядок 10: [AnnotationConfigApplicationContext] — це клас, що реалізує інтерфейс [ApplicationContext] від Spring. Це контейнер бінів;
- рядок 11: бін [entityManagerFactory] реалізовано за допомогою класу [LocalContainerEntityManagerFactory], класу Spring;
- рядок 15: з’являється [Hibernate]. Саме ця реалізація JPA була обрана;
- рядок 19: діалектом Hibernate є варіант SQL, який слід використовувати разом із SGBD. Тут діалект [H2Dialect] вказує, що Hibernate працюватиме з SGBD та H2;
- рядки 21–22: база даних створена. Створено таблицю [CUSTOMER]. Це означає, що Hibernate було налаштовано на генерацію таблиць на основі визначень JPA, у даному випадку — визначення JPA класу [Customer];
- рядки 27–31: вставлені п’ять клієнтів;
- рядки 33–635: результат роботи методу [findOne] інтерфейсу;
- рядки 37–40: результати методу [findByLastName];
- рядки 41 і наступні: журнали закриття контексту Spring.
8.4.1.5. Ручна конфігурація проєкту Spring Data
Ми дублюємо попередній проєкт у проєкті [gs-accessing-data-jpa-2]:
![]() |
У цьому новому проєкті ми не будемо покладатися на автоматичну конфігурацію, виконану Spring Boot. Ми зробимо це вручну. Це може бути корисно, якщо стандартні налаштування нас не влаштовують.
Спочатку ми вкажемо необхідні залежності у файлі [pom.xml]:
...
<dependencies>
<!-- Spring Core -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Транзакції Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring ORM -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring Data -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.7.1.RELEASE</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>1.1.10.RELEASE</version>
</dependency>
<!-- Hibernate -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>4.3.4.Final</version>
</dependency>
<!-- База даних H2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.178</version>
</dependency>
<!-- Commons DBCP -->
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>commons-pool</groupId>
<artifactId>commons-pool</artifactId>
<version>1.6</version>
</dependency>
</dependencies>
...
</project>
- рядки 2–18: базові бібліотеки Spring;
- рядки 19–29: бібліотеки Spring для управління транзакціями з базою даних;
- рядки 30–35: бібліотека Spring для роботи з ORM (об’єктно-реляційний мапер);
- рядки 36–41: Spring Data, що використовується для доступу до бази даних;
- рядки 42–47: Spring Boot для запуску додатка;
- рядки 54–59: SGBD H2;
- рядки 60–70: бази даних часто використовуються з пулами відкритих з’єднань, що дозволяє уникнути багаторазового відкриття та закриття з’єднань. Тут використовується реалізація [commons-dbcp];
Також у файлі [pom.xml] змінюється ім’я класу, що виконується:
<properties>
...
<start-class>demo.console.Main</start-class>
</properties>
У новому проєкті суть [Customer] та інтерфейс [CustomerRepository] залишаються без змін. Ми змінимо клас [Application], який буде розділено на два класи:
- [Config], який буде класом конфігурації:
- [Main], який стане класом виконання;
![]() |
Виконувальний клас [Main] є таким самим, як і раніше, але без анотацій конфігурації:
package demo.console;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
...
context.close();
}
}
- рядок 12: клас [Main] більше не містить анотацій конфігурації;
- рядок 16: додаток запускається за допомогою Spring Boot. Параметр [Config.class] — це новий клас конфігурації проєкту;
Клас [Config], який налаштовує проект, має такий вигляд:
package demo.config;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
//@ComponentScan(basePackages = { "demo" })
//@EntityScan(basePackages = { "demo.entities" })
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = { "demo.repositories" })
@Configuration
public class Config {
// джерело даних H2
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:./demo");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
// провайдер JPA
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(true);
hibernateJpaVendorAdapter.setDatabase(Database.H2);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan("demo.entities");
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// Менеджер транзакцій
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- рядок 22: анотація [@Configuration] перетворює клас [Config] на клас конфігурації Spring;
- рядок 21: анотація [@EnableJpaRepositories] дозволяє вказати папки, в яких знаходяться інтерфейси Spring Data [CrudRepository]. Ці інтерфейси стануть компонентами Spring і будуть доступні в його контексті;
- рядок 20: анотація [@EnableTransactionManagement] вказує, що методи інтерфейсів [CrudRepository] повинні виконуватися в межах транзакції;
- рядок 19: анотація [@EntityScan] дозволяє вказати папки, в яких слід шукати сутності JPA. Тут вона винесена в коментарі, оскільки ця інформація була явно вказана у рядку 50. Ця анотація має бути присутньою, якщо використовується режим [@EnableAutoConfiguration] і сутності JPA не знаходяться в тому самому каталозі, що й клас конфігурації;
- рядок 18: анотація [@ComponentScan] дозволяє вказати папки, в яких слід шукати компоненти Spring. Компоненти Spring — це класи, позначені анотаціями Spring, такими як @Service, @Component, @Controller тощо. Тут немає інших компонентів, крім тих, що визначені в класі [Config], тому анотацію було виведено в коментарі;
- рядки 25–33: визначають джерело даних — базу даних H2. Саме анотація @Bean у рядку 25 робить об’єкт, створений цим методом, компонентом, що керується Spring. Назва методу тут може бути будь-якою. Однак він повинен називатися [dataSource], якщо EntityManagerFactory у рядку 47 відсутній і визначається шляхом автоконфігурації;
- рядок 29: база даних матиме назву [demo] і буде згенерована в папці проєкту;
- рядки 36–43: визначають використовувану реалізацію JPA, у даному випадку реалізацію Hibernate. Назва методу тут може бути будь-якою;
- рядок 39: відсутність журналів SQL;
- рядок 30: база даних буде створена, якщо її ще немає;
- рядки 46–54: визначають EntityManagerFactory, який буде керувати збереженням даних JPA. Метод обов’язково має називатися [entityManagerFactory];
- рядок 47: метод отримує два параметри, що мають типи двох раніше визначених бінів. Вони будуть створені, а потім введені Spring як параметри методу;
- рядок 49: визначає реалізацію JPA, що використовується;
- рядок 50: вказує папки, де знаходяться сутності JPA;
- рядок 51: визначає джерело даних, що підлягають обробці;
- рядки 57–62: менеджер транзакцій. Метод обов’язково має називатися [transactionManager]. Він отримує як параметр bean із рядків 46–54;
- рядок 60: менеджер транзакцій пов’язаний з EntityManagerFactory;
Вищезазначені методи можна визначати в будь-якому порядку.
Виконання проекту дає ті самі результати. У папці проекту з’являється новий файл — файл бази даних H2:
![]() |
Нарешті, можна обійтися без Spring Boot. Створюємо другий виконуваний клас [Main2]:
![]() |
Клас [Main2] має такий код:
package demo.console;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main2 {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
....
context.close();
}
}
- рядок 15: клас конфігурації [Config] тепер використовується класом Spring [AnnotationConfigApplicationContext]. У рядку 5 видно, що тепер немає залежностей від Spring Boot.
Виконання дає ті самі результати, що й раніше.
8.4.1.6. Створення виконуваного архіву
Щоб створити виконуваний архів проекту, можна виконати такі дії:
![]() |
- у [1]: створюємо конфігурацію виконання;
- у [2]: типу [Java Application]
- у [3]: вказується проект, який потрібно виконати (використовуйте кнопку Browse);
- у [4]: вказується клас, який потрібно виконати;
- у [5]: назва конфігурації виконання — може бути будь-якою;
![]() |
- у [6]: проект експортується;
- у [7]: у вигляді виконуваного архіву JAR;
- в [8]: вказує шлях та ім'я виконуваного файлу, що створюється;
- у [9]: ім'я конфігурації виконання, створеної у [5];
Після цього відкриваємо консоль у папці, що містить архів з виконуваним файлом:
Архів запускається наступним чином:
.....\dist>java -jar gs-accessing-data-jpa-2.jar
У консолі з’являються такі результати:
8.4.1.7. Створення нового проєкту Spring Data
Щоб створити шаблон проекту Spring Data, можна виконати такі дії:
![]() |
- у [1] створюємо новий проєкт;
- у [2]: типу [Spring Starter Project];
- згенерований проєкт буде проєктом Maven. У [3] вказуємо назву групи проєкту;
- у [4]: вказуємо назву артефакту (у даному випадку — jar-файл), який буде створено під час компіляції проекту;
- у [5] вказується пакет виконуваного класу, який буде створено в проєкті;
- у [6]: ім’я проекту в Eclipse — може бути будь-яким (не обов’язково збігатися з [4]);
- у полі [7]: вказується, що буде створено проєкт із шаром [JPA]. Необхідні для такого проєкту залежності будуть включені до файлу [pom.xml];
![]() |
- у [8]: створений проект;
Файл [pom.xml] містить залежності, необхідні для проекту JPA:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath/> <!-- пошук батьківського елемента в репозиторії -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
- рядки 9–12: залежності, необхідні для JPA — включатимуть [Spring Data];
- рядки 13–17: залежності, необхідні для тестів JUnit, інтегрованих із Spring;
Виконуваний клас [Application] нічого не робить, але попередньо налаштований:
package istia.st;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Тестовий клас [ApplicationTests] нічого не робить, але попередньо налаштований:
package istia.st;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class ApplicationTests {
@Test
public void contextLoads() {
}
}
- рядок 9: анотація [@SpringApplicationConfiguration] дозволяє використовувати файл конфігурації [Application]. Таким чином, тестовий клас отримає доступ до всіх бінів, які будуть визначені цим файлом;
- рядок 8: анотація [@RunWith] забезпечує інтеграцію Spring із JUnit: клас можна буде виконати як тест JUnit. [@RunWith] є анотацією JUnit (рядок 4), тоді як клас [SpringJUnit4ClassRunner] є класом Spring (рядок 6);
Тепер, коли ми маємо каркас додатка JPA, можемо доповнити його, щоб написати проект шару персистентності сервера нашого додатка для управління зустрічами.
8.4.2. Проєкт сервера в Eclipse
![]() |
![]() |
Основні елементи проєкту такі:
- [pom.xml]: файл конфігурації Maven для проекту;
- [rdvmedecins.entities]: сутності JPA;
- [rdvmedecins.repositories]: інтерфейси Spring Data для доступу до сутностей JPA;
- [rdvmedecins.metier]: рівень [métier];
- [rdvmedecins.domain]: сутності, з якими працює рівень [métier];
- [rdvmdecins.config]: класи конфігурації шару персистентності;
- [rdvmedecins.boot]: простий консольний додаток;
8.4.3. Конфігурація Maven
![]() | ![]() | ![]() |
Файл [pom.xml] цього проєкту має такий вигляд:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- тестування Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- пілотний проект JDBC / MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Tomcat JDBC -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</dependency>
<!-- маппер jSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Googe Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
</dependencies>
<properties>
<!-- використовуйте UTF-8 для всього -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>rdvmedecins.boot.Boot</start-class>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>org.jboss.repository.releases</id>
<name>JBoss Maven Release Repository</name>
<url>https://repository.jboss.org/nexus/content/repositories/releases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>
- рядки 8–12: проект базується на батьківському проекті [spring-boot-starter-parent]. Для залежностей, які вже присутні в батьківському проекті, версію не вказують. Буде використана версія, визначена в батьківському проекті. Інші залежності заявляють у звичайному порядку;
- рядки 15–18: для Spring Data;
- рядки 20–24: для тестів JUnit;
- рядки 26–29: для бібліотеки Spring Security, у якій на рівні [DAO] використовується один із класів шифрування паролів;
- рядки 31–34: драйвер JDBC для SGBD та MySQL5;
- рядки 36–39: пул з’єднань Tomcat JDBC. Пул з'єднань об'єднує відкриті з'єднання з базою даних. Коли код хоче відкрити з'єднання, воно запитується у пулі. Коли код закриває з'єднання, воно не закривається, а повертається до пулу. Все це відбувається прозоро на рівні коду. Це підвищує продуктивність, оскільки багаторазове відкриття/закриття з'єднання вимагає часу. У цьому випадку пул з’єднань встановлює певну кількість з’єднань із базою даних одразу після його інстанціювання. Далі не відбувається ні відкриття, ні закриття з’єднань, за винятком випадків, коли кількість з’єднань, що зберігаються в пулі, виявляється недостатньою. У такому випадку пул автоматично створює нові з’єднання;
- рядки 41–44: бібліотека Jackson для обробки jSON;
- рядки 46–50: бібліотека Google для управління колекціями;
8.4.4. Об’єкти JPA
![]() |
Елементи JPA — це об’єкти, які інкапсулюють рядки таблиць бази даних.
![]() |
Клас [AbstractEntity] є батьківським класом сутностей [Personne, Creneau, Rv]. Його визначення таке:
package rdvmedecins.entities;
import java.io.Serializable;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import javax.persistence.Version;
@MappedSuperclass
public class AbstractEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@Version
protected Long version;
@Override
public int hashCode() {
int hash = 0;
hash += (id != null ? id.hashCode() : 0);
return hash;
}
// ініціалізація
public AbstractEntity build(Long id, Long version) {
this.id = id;
this.version = version;
return this;
}
@Override
public boolean equals(Object entity) {
String class1 = this.getClass().getName();
String class2 = entity.getClass().getName();
if (!class2.equals(class1) || entity==null) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id.longValue() == other.id.longValue();
}
// гетери та сеттери
..
}
- рядок 11: анотація [@MappedSuperclass] вказує, що анотований клас є батьківським для сутностей JPA та [@Entity];
- рядки 15–17: визначають первинний ключ [id] для кожного об’єкта. Саме анотація [@Id] робить поле [id] первинним ключем. Анотація [@GeneratedValue(strategy = GenerationType.IDENTITY)] вказує, що значення цього первинного ключа генерується за допомогою SGBD і що встановлено режим генерації [IDENTITY]. Для SGBD MySQL це означає, що первинні ключі будуть генеруватися за допомогою SGBD з атрибутом [AUTO_INCREMENT]
- рядки 18–19: визначають версію кожного об’єкта. Реалізація JPA збільшуватиме цей номер версії щоразу, коли об’єкт буде змінено. Цей номер слугує для запобігання одночасному оновленню сутності двома різними користувачами: два користувачі U1 та U2 читають сутність E з номером версії, що дорівнює V1. U1 змінює E та зберігає цю зміну в базі даних: номер версії тоді змінюється на V1+1. U2, у свою чергу, змінює E та зберігає цю зміну в базі даних: він отримає виняток, оскільки має версію (V1), відмінну від тієї, що міститься в базі даних (V1+1);
- рядки 29–33: метод [build] дозволяє ініціалізувати обидва поля [AbstractEntity]. Цей метод робить посилання на екземпляр [AbstractEntity] таким чином ініціалізованим;
- рядки 36–44: метод [equals] класу перевизначено: дві сутності вважатимуться рівними, якщо вони мають однакову назву класу та однаковий ідентифікатор id;
- рядки 21–26: при перевизначенні методу [equals] класу необхідно також перевизначити його метод [hashCode] (рядки 21–26). Правило полягає в тому, що два об’єкти, які вважаються рівними за методом [equals], повинні мати однаковий [hashCode]. У даному випадку [hashCode] об’єкта дорівнює його первинному ключу [id]. [hashCode] класу використовується, зокрема, в управлінні словниками, значеннями яких є екземпляри цього класу;
Ентітет [Personne] є батьківським класом для ентітетів [Medecin] та [Client]:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.MappedSuperclass;
@MappedSuperclass
public class Personne extends AbstractEntity {
private static final long serialVersionUID = 1L;
// атрибути особи
@Column(length = 5)
private String titre;
@Column(length = 20)
private String nom;
@Column(length = 20)
private String prenom;
// конструктор за замовчуванням
public Personne() {
}
// конструктор з параметрами
public Personne(String titre, String nom, String prenom) {
this.titre = titre;
this.nom = nom;
this.prenom = prenom;
}
// toString
public String toString() {
return String.format("Personne[%s, %s, %s, %s, %s]", id, version, titre, nom, prenom);
}
// методи getter та setter
...
}
- рядок 6: анотація [@MappedSuperclass] вказує, що анотований клас є батьківським для сутностей JPA та [@Entity];
- рядки 10–15: особа має титул (Melle), ім’я (Jacqueline) та прізвище (Tatou). Інформація про стовпці таблиці не наводиться. Тому за замовчуванням вони матимуть ті самі назви, що й поля;
Ентітет [Medecin] має такий вигляд:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "medecins")
public class Medecin extends Personne {
private static final long serialVersionUID = 1L;
// конструктор за замовчуванням
public Medecin() {
}
// конструктор з параметрами
public Medecin(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
public String toString() {
return String.format("Medecin[%s]", super.toString());
}
}
- рядок 6: клас є сутністю JPA;
- рядок 7: пов’язана з таблицею [MEDECINS] у базі даних;
- рядок 8: суть [Medecin] походить від суті [Personne];
Медичний працівник може бути ініціалізований наступним чином:
Якщо, крім того, потрібно присвоїти йому ідентифікатор та версію, можна записати:
де метод [build] є тим, що визначено в [AbstractEntity].
Суть [Client] така:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "clients")
public class Client extends Personne {
private static final long serialVersionUID = 1L;
// конструктор за замовчуванням
public Client() {
}
// конструктор з параметрами
public Client(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
// ідентичність
public String toString() {
return String.format("Client[%s]", super.toString());
}
}
- рядок 6: клас є сутністю JPA;
- рядок 7: пов’язана з таблицею [CLIENTS] у базі даних;
- рядок 8: суть [Client] походить від суті [Personne];
Елемент [Creneau] має такий вигляд:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// характеристики слота з RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// віконко пов'язане з лікарем
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// зовнішній ключ
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
// конструктор за замовчуванням
public Creneau() {
}
// виробник із параметрами
public Creneau(Medecin medecin, int hdebut, int mdebut, int hfin, int mfin) {
this.medecin = medecin;
this.hdebut = hdebut;
this.mdebut = mdebut;
this.hfin = hfin;
this.mfin = mfin;
}
// toString
public String toString() {
return String.format("Créneau[%d, %d, %d, %d:%d, %d:%d]", id, version, idMedecin, hdebut, mdebut, hfin, mfin);
}
// зовнішній ключ
public long getIdMedecin() {
return idMedecin;
}
// сетери — геттери
...
}
- рядок 10: клас є сутністю JPA;
- рядок 11: пов’язаний із таблицею [CRENEAUX] у базі даних;
- рядок 12: суть [Creneau] походить від суті [AbstractEntity] і, отже, успадковує ідентифікатор [id] та версію [version];
- рядок 16: час початку часового інтервалу (14);
- рядок 17: хвилини початку часового проміжку (20);
- рядок 18: година закінчення часового проміжку (14);
- рядок 19: хвилини закінчення часового проміжку (40);
- рядки 22–24: лікар, якому належить часовий проміжок. Таблиця [CRENEAUX] має зовнішній ключ до таблиці [MEDECINS]. Цей зв’язок реалізовано у рядках 22–24;
- рядок 22: анотація [@ManyToOne] вказує на відношення «багато (інтервали) до одного (лікар)». Атрибут [fetch=FetchType.LAZY] вказує, що коли з контексту персистентності запитується суть [Creneau] і її потрібно шукати в базі даних, то суть [Medecin] не повертається разом з нею. Перевага цього режиму полягає в тому, що суть [Medecin] шукається лише за запитом розробника. Таким чином економиться пам’ять і підвищується продуктивність;
- рядок 23: вказує назву стовпця зовнішнього ключа в таблиці [CRENEAUX];
- рядки 27–28: зовнішній ключ у таблиці [MEDECINS];
- рядок 27: стовпець [ID_MEDECIN] вже використовувався у рядку 23. Це означає, що його можна змінювати двома різними способами, чого не допускає стандарт JPA. Тому додаємо атрибути [insertable = false, updatable = false], завдяки чому стовпець можна лише читати;
Елемент [Rv] має такий вигляд:
package rdvmedecins.entities;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// характеристики Rv
@Temporal(TemporalType.DATE)
private Date jour;
// RV пов'язаний із клієнтом
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// RV пов'язаний із часовим проміжком
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// зовнішні ключі
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
// виробник за замовчуванням
public Rv() {
}
// з параметрами
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// зовнішні ключі
public long getIdCreneau() {
return idCreneau;
}
public long getIdClient() {
return idClient;
}
// методи getter та setter
...
}
- рядок 14: клас є сутністю JPA;
- рядок 15: пов’язаний із таблицею [RV] у базі даних;
- рядок 16: суть [Rv] походить від суті [AbstractEntity] і, отже, успадковує ідентифікатор [id] та версію [version];
- рядок 21: дата зустрічі;
- рядок 20: тип [Date] у Java містить як дату, так і час. Тут вказується, що використовується лише дата;
- рядки 24–26: клієнт, для якого було призначено цю зустріч. Таблиця [RV] має зовнішній ключ до таблиці [CLIENTS]. Цей зв’язок відображено в рядках 24–26;
- рядки 29–31: часовий проміжок зустрічі. Таблиця [RV] має зовнішній ключ до таблиці [CRENEAUX]. Ця залежність відображена в рядках 29–31;
- рядки 34–35: зовнішній ключ [idClient];
- рядки 36–37: зовнішній ключ [idCreneau];
8.4.5. Рівень [DAO]
![]() |
Ми реалізуємо шар [DAO] за допомогою Spring Data:
![]() |
Рівень [DAO] реалізовано за допомогою чотирьох інтерфейсів Spring Data:
- [ClientRepository]: надає доступ до сутностей JPA та [Client];
- [CreneauRepository]: надає доступ до сутностей JPA та [Creneau];
- [MedecinRepository]: надає доступ до об’єктів JPA та [Medecin];
- [RvRepository]: надає доступ до об’єктів JPA та [Rv];
Інтерфейс [MedecinRepository] має такий вигляд:
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Medecin;
public interface MedecinRepository extends CrudRepository<Medecin, Long> {
}
- рядок 7: інтерфейс [MedecinRepository] лише успадковує методи інтерфейсу [CrudRepository], не додаючи інших;
Інтерфейс [ClientRepository] має такий вигляд:
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Client;
public interface ClientRepository extends CrudRepository<Client, Long> {
}
- рядок 7: інтерфейс [ClientRepository] лише успадковує методи інтерфейсу [CrudRepository], не додаючи інших;
Інтерфейс [CreneauRepository] має такий вигляд:
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Creneau;
public interface CreneauRepository extends CrudRepository<Creneau, Long> {
// список прийомних годин лікаря
@Query("select c from Creneau c where c.medecin.id=?1")
Iterable<Creneau> getAllCreneaux(long idMedecin);
}
- рядок 8: інтерфейс [CreneauRepository] успадковує методи інтерфейсу [CrudRepository];
- рядки 10–11: метод [getAllCreneaux] дозволяє отримати часові слоти лікаря;
- рядок 11: параметром є ідентифікатор лікаря. Результатом є список часових проміжків у вигляді об’єкта [Iterable<Creneau>];
- рядок 10: анотація [@Query] дозволяє вказати запит JPQL (Java Persistence Query Language), який реалізує цей метод. Параметр [?1] буде замінено на параметр [idMedecin] цього методу;
Інтерфейс [RvRepository] має такий вигляд:
package rdvmedecins.repositories;
import java.util.Date;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Rv;
public interface RvRepository extends CrudRepository<Rv, Long> {
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
Iterable<Rv> getRvMedecinJour(long idMedecin, Date jour);
}
- рядок 10: інтерфейс [RvRepository] успадковує методи інтерфейсу [CrudRepository];
- рядки 12–13: метод [getRvMedecinJour] дозволяє отримати призначені зустрічі лікаря на певний день;
- рядок 13: параметрами є ідентифікатор лікаря та день. Результатом є список прийомів у вигляді об’єкта [Iterable<Rv>];
- рядок 12: анотація [@Query] дозволяє вказати запит JPQL, який реалізує цей метод. Параметр [?1] буде замінено на параметр [idMedecin] методу, а параметр [?2] — на параметр [jour] методу. Не можна обмежитися лише таким запитом JPQL:
оскільки поля класу Rv типів [Client] та [Creneau] отримуються в режимі [FetchType.LAZY], що означає, що їх потрібно явно запитувати, щоб отримати. Це здійснюється у запиті JPQL із синтаксисом [left join fetch entité], який вимагає виконання з’єднання з таблицею, на яку вказує зовнішній ключ, для отримання сутності, на яку вказує цей ключ;
8.4.6. Рівень [métier]
![]() |
![]() |
- [IMetier] є інтерфейсом шару [métier], а [Metier] — його реалізацією;
- [AgendaMedecinJour] та [CreneauMedecinJour] — це два бізнес-об’єкти;
8.4.6.1. Сутності
Ентітет [CreneauMedecinJour] пов’язує часовий проміжок та можливу зустріч, призначену в цьому проміжку:
package rdvmedecins.domain;
import java.io.Serializable;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// поля
private Creneau creneau;
private Rv rv;
// конструктори
public CreneauMedecinJour() {
}
public CreneauMedecinJour(Creneau creneau, Rv rv) {
this.creneau=creneau;
this.rv=rv;
}
// toString
@Override
public String toString() {
return String.format("[%s %s]", creneau, rv);
}
// методи getter та setter
...
}
- рядок 12: часовий проміжок;
- рядок 13: можливий прийом — null, якщо немає;
Елемент [AgendaMedecinJour] — це розклад лікаря на певний день, тобто перелік його зустрічей:
package rdvmedecins.domain;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
import rdvmedecins.entities.Medecin;
public class AgendaMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// поля
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
// конструктори
public AgendaMedecinJour() {
}
public AgendaMedecinJour(Medecin medecin, Date jour, CreneauMedecinJour[] creneauxMedecinJour) {
this.medecin = medecin;
this.jour = jour;
this.creneauxMedecinJour = creneauxMedecinJour;
}
public String toString() {
StringBuffer str = new StringBuffer("");
for (CreneauMedecinJour cr : creneauxMedecinJour) {
str.append(" ");
str.append(cr.toString());
}
return String.format("Agenda[%s,%s,%s]", medecin, new SimpleDateFormat("dd/MM/yyyy").format(jour), str.toString());
}
// геттери та сеттери
...
}
- рядок 13: лікар;
- рядок 14: день у розкладі;
- лінія 15: години прийому з попереднім записом або без нього;
8.4.6.2. Послуга
Інтерфейс шару [métier] має такий вигляд:
package rdvmedecins.metier;
import java.util.Date;
import java.util.List;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
public interface IMetier {
// список клієнтів
public List<Client> getAllClients();
// список лікарів
public List<Medecin> getAllMedecins();
// список часових проміжків лікаря
public List<Creneau> getAllCreneaux(long idMedecin);
// список прийомів лікаря на певний день
public List<Rv> getRvMedecinJour(long idMedecin, Date jour);
// пошук клієнта за його ідентифікаційним номером
public Client getClientById(long id);
// пошук клієнта за його ідентифікатором
public Medecin getMedecinById(long id);
// знайти прийом за його ідентифікатором
public Rv getRvById(long id);
// знайти часовий проміжок за його ідентифікатором
public Creneau getCreneauById(long id);
// додати RV
public Rv ajouterRv(Date jour, Creneau créneau, Client client);
// видалити RV
public void supprimerRv(Rv rv);
// професія
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour);
}
Коментарі пояснюють роль кожного з методів.
Реалізацією інтерфейсу [IMetier] є наступний клас [Metier]:
package rdvmedecins.metier;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.domain.CreneauMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.repositories.ClientRepository;
import rdvmedecins.repositories.CreneauRepository;
import rdvmedecins.repositories.MedecinRepository;
import rdvmedecins.repositories.RvRepository;
import com.google.common.collect.Lists;
@Service("métier")
public class Metier implements IMetier {
// репозиторії
@Autowired
private MedecinRepository medecinRepository;
@Autowired
private ClientRepository clientRepository;
@Autowired
private CreneauRepository creneauRepository;
@Autowired
private RvRepository rvRepository;
// реалізація інтерфейсу
@Override
public List<Client> getAllClients() {
return Lists.newArrayList(clientRepository.findAll());
}
@Override
public List<Medecin> getAllMedecins() {
return Lists.newArrayList(medecinRepository.findAll());
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return Lists.newArrayList(creneauRepository.getAllCreneaux(idMedecin));
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return Lists.newArrayList(rvRepository.getRvMedecinJour(idMedecin, jour));
}
@Override
public Client getClientById(long id) {
return clientRepository.findOne(id);
}
@Override
public Medecin getMedecinById(long id) {
return medecinRepository.findOne(id);
}
@Override
public Rv getRvById(long id) {
return rvRepository.findOne(id);
}
@Override
public Creneau getCreneauById(long id) {
return creneauRepository.findOne(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau créneau, Client client) {
return rvRepository.save(new Rv(jour, client, créneau));
}
@Override
public void supprimerRv(Rv rv) {
rvRepository.delete(rv.getId());
}
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
...
}
}
- рядок 24: анотація [@Service] — це анотація Spring, яка перетворює анотований клас на компонент, що керується Spring. Компоненту можна надати ім’я або ні. Цей компонент має ім’я [métier];
- рядок 25: клас [Metier] реалізує інтерфейс [IMetier];
- рядок 28: анотація [@Autowired] є анотацією Spring. Значення поля, позначеного цією анотацією, буде ініціалізовано (введено) Spring із посиланням на компонент Spring зазначеного типу або імені. У цьому випадку анотація [@Autowired] не вказує імені. Отже, буде здійснено ін’єкцію за типом;
- рядок 29: поле [medecinRepository] буде ініціалізовано посиланням на компонент Spring типу [MedecinRepository]. Це буде посилання на клас, згенерований Spring Data для реалізації інтерфейсу [MedecinRepository], який ми вже розглядали;
- рядки 30–35: цей процес повторюється для трьох інших розглянутих інтерфейсів;
- рядки 39–41: реалізація методу [getAllClients];
- рядок 40: ми використовуємо метод [findAll] з інтерфейсу [ClientRepository]. Цей метод повертає тип [Iterable<Client>], який ми перетворюємо на [List<Client>] за допомогою статичного методу [Lists.newArrayList]. Клас [Lists] визначено в бібліотеці Google Guava. У [pom.xml] було імпортовано цю залежність:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
- рядки 38–86: методи інтерфейсу [IMetier] реалізовано за допомогою класів шару [DAO];
Лише метод у рядку 88 є специфічним для шару [métier]. Він був розміщений тут, оскільки виконує бізнес-обробку, яка не є простим доступом до даних. Без цього методу не було б сенсу створювати шар [métier]. Метод [getAgendaMedecinJour] має такий вигляд:
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
// перелік часових слотів лікаря
List<Creneau> creneauxHoraires = getAllCreneaux(idMedecin);
// список записів до цього ж лікаря на цей же день
List<Rv> reservations = getRvMedecinJour(idMedecin, jour);
// створюється словник на основі записаних прийомів
Map<Long, Rv> hReservations = new Hashtable<Long, Rv>();
for (Rv resa : reservations) {
hReservations.put(resa.getCreneau().getId(), resa);
}
// створюється календар на запитуваний день
AgendaMedecinJour agenda = new AgendaMedecinJour();
// лікар
agenda.setMedecin(getMedecinById(idMedecin));
// день
agenda.setJour(jour);
// інтервали бронювання
CreneauMedecinJour[] creneauxMedecinJour = new CreneauMedecinJour[creneauxHoraires.size()];
agenda.setCreneauxMedecinJour(creneauxMedecinJour);
// заповнення слотів для бронювання
for (int i = 0; i < creneauxHoraires.size(); i++) {
// рядок i розкладу
creneauxMedecinJour[i] = new CreneauMedecinJour();
// часовий проміжок
Creneau créneau = creneauxHoraires.get(i);
long idCreneau = créneau.getId();
creneauxMedecinJour[i].setCreneau(créneau);
// чи вільний цей часовий проміжок чи заброньований?
if (hReservations.containsKey(idCreneau)) {
// інтервал зайнятий — фіксуємо бронювання
Rv resa = hReservations.get(idCreneau);
creneauxMedecinJour[i].setRv(resa);
}
}
// повертаємо результат
return agenda;
}
Читачеві пропонується ознайомитися з коментарями. Алгоритм такий:
- витягуються всі часові слоти вказаного лікаря;
- отримуємо всі його прийоми на вказаний день;
- маючи ці дві інформації, можна визначити, чи є часовий проміжок вільним чи зайнятим;
8.4.7. Конфігурація проекту Spring
![]() |
Клас [DomainAndPersistenceConfig] налаштовує весь проект:
package rdvmedecins.config;
import javax.persistence.EntityManagerFactory;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@ComponentScan(basePackages = { "rdvmedecins" })
public class DomainAndPersistenceConfig {
// пакети об’єктів JPA
public final static String[] ENTITIES_PACKAGES = { "rdvmedecins.entities", "rdvmedecins.security" };
// джерело даних MySQL
@Bean
public DataSource dataSource() {
// джерело даних TomcatJdbc
DataSource dataSource = new DataSource();
// конфігурація JDBC
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
// спочатку відкриті з'єднання
dataSource.setInitialSize(5);
// результат
return dataSource;
}
// провайдер JPA — це Hibernate
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(false);
hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(ENTITIES_PACKAGES);
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// Менеджер транзакцій
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- рядок 17: цей клас є класом конфігурації Spring;
- рядок 18: пакети, в яких знаходяться інтерфейси [CrudRepository] з Spring Data. Вони будуть додані до контексту Spring;
- рядок 19: додає до контексту Spring усі класи з пакета [rdvmedecins] та його підкласів, що мають анотацію Spring. У пакеті [rdvmdecins.metier] клас [Metier] з анотацією [@Service] буде знайдено та додано до контексту Spring;
- рядки 26–39: налаштовують пул з’єднань Tomcat JDBC (рядок 5);
- рядок 36: пул з’єднань матиме за замовчуванням 5 відкритих з’єднань. Цей рядок наведено для прикладу. У нашому випадку достатньо буде 1 з’єднання. У разі, якщо шар [DAO] використовуватиметься кількома потоками, цей рядок буде необхідним. Так буде пізніше, коли шар [DAO] слугуватиме основою для веб-додатка, який за своєю природою підтримує одночасне обслуговування кількох користувачів;
- рядки 42–49: реалізація JPA, що використовується, є реалізацією Hibernate;
- рядок 45: немає журналів SQL;
- рядок 46: таблиці не регенеруються;
- рядок 47: використовується SGBD, а саме MySQL;
- рядки 53–61: визначають EntityManagerFactory для шару JPA. На основі цього об’єкта отримуємо об’єкт [EntityManager], який дозволяє виконувати операції JPA;
- рядок 57: вказується пакет або пакети, в яких знаходяться сутності JPA;
- рядок 58: вказує джерело даних, яке потрібно підключити до шару JPA;
- рядки 64–69: менеджер транзакцій, пов’язаний із попереднім EntityManagerFactory. За замовчуванням методи інтерфейсів [CrudRepository] Spring Data виконуються в межах транзакції. Транзакція запускається перед входом у метод і завершується (комітом або ролбеком) після виходу з нього;
8.4.8. Тестування рівня [métier]
Клас [rdvmedecins.tests.Metier] є тестовим класом Spring / JUnit 4:
package rdvmedecins.tests;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Metier {
@Autowired
private IMetier métier;
@Test
public void test1(){
// перегляд клієнтів
List<Client> clients = métier.getAllClients();
display("Liste des clients :", clients);
// перегляд лікарів
List<Medecin> medecins = métier.getAllMedecins();
display("Liste des médecins :", medecins);
// перегляд часових проміжків лікаря
Medecin médecin = medecins.get(0);
List<Creneau> creneaux = métier.getAllCreneaux(médecin.getId());
display(String.format("Liste des créneaux du médecin %s", médecin), creneaux);
// перелік прийомів лікаря на певний день
Date jour = new Date();
display(String.format("Liste des rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// додати RV
Rv rv = null;
Creneau créneau = creneaux.get(2);
Client client = clients.get(0);
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
rv = métier.ajouterRv(jour, créneau, client);
// перевірка
Rv rv2 = métier.getRvById(rv.getId());
Assert.assertEquals(rv, rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// додати RV у той самий час того самого дня
// повинно викликати виняток
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
Boolean erreur = false;
try {
rv = métier.ajouterRv(jour, créneau, client);
System.out.println("Rv ajouté");
} catch (Exception ex) {
Throwable th = ex;
while (th != null) {
System.out.println(ex.getMessage());
th = th.getCause();
}
// фіксуємо помилку
erreur = true;
}
// перевіряємо, чи сталася помилка
Assert.assertTrue(erreur);
// список RV
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// відображення календаря
AgendaMedecinJour agenda = métier.getAgendaMedecinJour(médecin.getId(), jour);
System.out.println(agenda);
Assert.assertEquals(rv, agenda.getCreneauxMedecinJour()[2].getRv());
// видалити RV
System.out.println("Suppression du Rv ajouté");
métier.supprimerRv(rv);
// перевірка
rv2 = métier.getRvById(rv.getId());
Assert.assertNull(rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
}
// допоміжна функція — відображення елементів колекції
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- рядок 22: анотація [@SpringApplicationConfiguration] дозволяє використовувати файл конфігурації [DomainAndPersistenceConfig], розглянутий раніше. Таким чином, тестовий клас отримує доступ до всіх бінів, визначених у цьому файлі;
- рядок 23: анотація [@RunWith] забезпечує інтеграцію Spring із JUnit: клас можна буде виконати як тест JUnit. [@RunWith] є анотацією JUnit (рядок 9), тоді як клас [SpringJUnit4ClassRunner] є класом Spring (рядок 12);
- рядки 26–27: ін'єкція в тестовий клас посилання на рівень [métier];
- багато тестів є лише простими візуальними тестами:
- рядки 32–33: список клієнтів;
- рядки 35–36: список лікарів;
- рядки 39–40: список вільних часів лікаря;
- рядок 43: список прийомів лікаря;
- рядок 50: додавання нового прийому. Метод [ajouterRv] повертає прийом із додатковою інформацією — його первинним ключем id;
- рядок 53: цей первинний ключ використовується для пошуку зустрічі в базі даних;
- рядок 54: перевіряється, чи шуканий запис про зустріч і знайдений запис про зустріч є однаковими. Нагадаємо, що метод [equals] сутності [Rv] було перевизначено: дві зустрічі вважаються однаковими, якщо вони мають однаковий ідентифікатор. У даному випадку це показує, що додана зустріч дійсно була внесена до бази даних;
- рядки 61–73: робиться спроба додати ту саму зустріч вдруге. Це має бути відхилено модулем SGBD, оскільки існує обмеження щодо унікальності:
CREATE TABLE IF NOT EXISTS `rv` (
`ID` bigint(20) NOT NULL AUTO_INCREMENT,
`JOUR` date NOT NULL,
`ID_CLIENT` bigint(20) NOT NULL,
`ID_CRENEAU` bigint(20) NOT NULL,
`VERSION` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`ID`),
UNIQUE KEY `UNQ1_RV` (`JOUR`,`ID_CRENEAU`),
KEY `FK_RV_ID_CRENEAU` (`ID_CRENEAU`),
KEY `FK_RV_ID_CLIENT` (`ID_CLIENT`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_swedish_ci AUTO_INCREMENT=60 ;
Рядок 8 вище вказує, що комбінація [JOUR, ID_CRENEAU] має бути унікальною, що не дозволяє розмістити дві зустрічі в один і той самий день у тому самому часовому проміжку.
- рядок 73: перевіряється, чи дійсно стався виняток;
- рядок 77: запитується календар лікаря, для якого щойно було додано зустріч;
- рядок 79: перевіряється, чи доданий прийом дійсно присутній у його розкладі;
- рядок 82: видаляємо доданий прийом;
- рядок 84: шукаємо у базі видалений прийом;
- рядок 85: перевіряємо, чи отримано покажчик null, що свідчить про те, що шуканий прийом не існує;
Виконання тесту завершилося успішно:
![]() |
8.4.9. Консоль програми
![]() |
Консольна програма є простою. Вона демонструє, як отримати чужий ключ:
package rdvmedecins.boot;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
public class Boot {
// завантаження
public static void main(String[] args) {
// підготовка конфігурації
SpringApplication app = new SpringApplication(DomainAndPersistenceConfig.class);
app.setLogStartupInfo(false);
// запуск
ConfigurableApplicationContext context = app.run(args);
// бізнес-процес
IMetier métier = context.getBean(IMetier.class);
try {
// додати RV
Date jour = new Date();
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau 1 pour le client 1", new SimpleDateFormat("dd/MM/yyyy").format(jour)));
Client client = (Client) new Client().build(1L, 1L);
Creneau créneau = (Creneau) new Creneau().build(1L, 1L);
Rv rv = métier.ajouterRv(jour, créneau, client);
System.out.println(String.format("Rv ajouté = %s", rv));
// перевірка
créneau = métier.getCreneauById(1L);
long idMedecin = créneau.getIdMedecin();
display("Liste des rendez-vous", métier.getRvMedecinJour(idMedecin, jour));
} catch (Exception ex) {
System.out.println("Exception : " + ex.getCause());
}
// закриття контексту Spring
context.close();
}
// допоміжний метод — відображає елементи колекції
private static <T> void display(String message, Iterable<T> elements) {
System.out.println(message);
for (T element : elements) {
System.out.println(element);
}
}
}
Програма додає зустріч, а потім перевіряє, чи вона була додана.
- рядок 19: клас [SpringApplication] використовуватиме клас конфігурації [DomainAndPersistenceConfig];
- рядок 20: видалення журналів запуску додатка;
- рядок 22: виконується клас [SpringApplication]. Він повертає контекст Spring, тобто список зареєстрованих бінів;
- рядок 24: отримується посилання на бін, що реалізує інтерфейс [IMetier]. Отже, це посилання на рівень [métier];
- рядки 27–31: додавання нового прийому на сьогодні для клієнта № 1 у слоті № 1. Клієнт і слот були створені з нуля, щоб продемонструвати, що використовуються лише ідентифікатори. Тут було ініціалізовано версію, але можна було б вказати будь-яке значення. У цьому місці вона не використовується;
- рядок 34: потрібно дізнатися, який лікар має слот № 1. Для цього необхідно звернутися до бази даних, щоб знайти слот № 1. Оскільки ми перебуваємо в режимі [FetchType.LAZY], лікар не повертається разом із слотом. Однак ми подбали про те, щоб передбачити поле [idMedecin] в сутності [Creneau] для отримання первинного ключа лікаря;
- рядок 35: отримуємо первинний ключ лікаря;
- рядок 36: виводиться список прийомів лікаря;
Результати у консолі такі:
8.4.10. Управління журналами
Журнали консолі налаштовуються за допомогою двох файлів: [application.properties] та [logback.xml] [1]:
![]() |
Файл [application.properties] використовується фреймворком Spring Boot. У ньому можна задати безліч параметрів, щоб змінити значення за замовчуванням, які використовує Spring Boot (http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html). Ось його вміст:
logging.level.org.hibernate=OFF
spring.main.show-banner=false
- рядок 1: контролює рівень логів Hibernate — тут логів немає
- рядок 2: керує відображенням банера Spring Boot — тут банер відсутній
Файл [logback.xml] є файлом конфігурації фреймворку логів [logback] [2]:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- кодерам за замовчуванням присвоюється тип ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- контроль рівня журналів -->
<root level="info"> <!-- вимкнено, інформація, налагодження, попередження -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- загальний рівень логів регулюється рядком 9 — тут логи рівня [info];
Це дає такий результат:
Якщо змінити рівень логів Hibernate на [info] (не змінюючи нічого іншого):
logging.level.org.hibernate=INFO
spring.main.show-banner=false
отримаємо такий результат:
Якщо встановити рівень логів на [debug] (не змінюючи нічого іншого):
logging.level.org.hibernate=DEBUG
spring.main.show-banner=false
отримуємо такий результат:
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Eagerly caching bean 'clientRepository' to allow for resolving potential circular references
10:35:13.522 [main] DEBUG o.s.b.f.annotation.InjectionMetadata - Processing injected element of bean 'clientRepository': PersistenceElement for public void org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.setEntityManager(javax.persistence.EntityManager)
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'entityManagerFactory'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'jpaMappingContext'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name 'clientRepository'
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$ThreadBoundTargetSource@723ed581
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is SingletonTargetSource for target object [org.springframework.data.jpa.repository.support.SimpleJpaRepository@796065aa]
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean 'clientRepository'
10:35:13.522 [main] DEBUG o.s.b.f.a.AutowiredAnnotationBeanPostProcessor - Autowiring by type from bean name 'métier' to bean named 'clientRepository'
...
8.4.11. Рівень [web / jSON]
![]() |
![]() |
Ми створимо шар [web / jSON] у кілька етапів:
- етап 1: робочий веб-шар без аутентифікації;
- етап 2: налаштування аутентифікації за допомогою Spring Security;
- етап 3: налаштування CORS та [Cross-Origin Resource Sharing (CORS) is a mechanism that allows many resources (e.g. fonts, JavaScript, etc.) on a web page to be requested from another domain outside the domain the resource originated from. (Wikipedia)]. Клієнтом нашого веб-сервісу буде веб-клієнт на базі Angular, який не обов’язково належатиме до того самого домену, що й наш веб-сервіс. За замовчуванням він не матиме доступу до нього, якщо веб-сервіс не надасть йому дозвіл. Ми розглянемо, як це зробити;
8.4.11.1. Налаштування Maven
Файл [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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.mvc</groupId>
<artifactId>rdvmedecins-webjson-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- веб-шар Spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- тестовий шар -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- шар DAO -->
<dependency>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
...
</project>
- рядки 12–15: батьківський проект Maven;
- рядки 19–22: залежності для проекту Spring MVC;
- рядки 24–28: залежності для тестів JUnit / Spring;
- рядки 30–34: залежності від проекту шарів [métier, DAO, JPA];
8.4.11.2. Інтерфейс веб-сервісу
![]() |
- у [1], наведеному вище, браузер може запитувати лише обмежену кількість URL із чітко визначеним синтаксисом;
- у [4] він отримує відповідь jSON;
Усі відповіді нашого веб-сервісу матимуть однаковий формат, що відповідає перетворенню jSON об’єкта типу [Response], а саме:
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- властивості
// статус операції
private int status;
// можливі повідомлення про помилки
private List<String> messages;
// тіло відповіді
private T body;
// конструктори
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// методи getter та setter
...
}
- рядок 7: код помилки у відповіді 0: OK, у інших випадках: KO;
- рядок 11: список повідомлень про помилки, якщо такі є;
- рядок 13: тіло відповіді;
Тепер наводимо знімки екрана, що ілюструють інтерфейс веб-сервісу / jSON:
Список усіх пацієнтів медичного кабінету [/getAllClients]
![]() |
Список усіх лікарів медичного кабінету [/getAllMedecins]
![]() |
Список часових слотів лікаря [/getAllCreneaux/{idMedecin}]
![]() |
Список прийомів лікаря [/getRvMedecinJour/{idMedecin}/{aaaa-mm-jj}
![]() |
Календар лікаря [/getAgendaMedecinJour/{idMedecin}/{aaaa-mm-jj}]
![]() |
Для додавання/видалення зустрічі ми використовуємо розширення Chrome [Advanced Rest Client], оскільки ці операції виконуються за допомогою POST.
Додати зустріч [/ajouterRv]
![]() |
- у [0] — URL веб-сервісу;
- у [1] використовується метод POST;
- у [2] — текст jSON інформації, переданої веб-сервісу у форматі {день, idClient, idCreneau};
- у [3] клієнт повідомляє веб-сервісу, що надсилає йому інформацію у форматі jSON;
Відповідь тоді виглядає так:
![]() |
- у форматі [4]: клієнт надсилає заголовок, що означає, що дані, які він надсилає, мають формат jSON;
- у [5]: веб-сервіс відповідає, що він також надсилає jSON;
- у [6]: відповідь jSON від веб-сервісу. Поле [body] містить форму jSON доданої зустрічі;
Наявність нової зустрічі можна перевірити:
![]() |
Зверніть увагу на ідентифікатор зустрічі [50]. Ми видалимо цю зустріч.
Видалити зустріч [/supprimerRv]
![]() |
- у [1] використовується метод URL веб-сервісу;
- у [2] використовується метод POST;
- у [3] — текст jSON інформації, що передається веб-сервісу у формі {idRv};
- у [4] клієнт повідомляє веб-сервісу, що надсилає йому інформацію jSON;
Відповідь тоді виглядає так:
![]() |
- у [5]: поле [status] має значення 0, що свідчить про успішне виконання операції;
Видалення зустрічі можна перевірити:
![]() |
Як показано вище, зустріч пацієнта [Mme GERMAIN] більше не відображається.
Веб-сервіс також дозволяє отримувати об’єкти за їхнім ідентифікатором:
![]() |
![]() |
![]() |
![]() |
Усі ці URL обробляються контролером [RdvMedecinsController], який ми незабаром представимо.
8.4.11.3. Налаштування веб-сервісу
![]() |
Клас конфігурації [AppConfig] має такий вигляд:
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- рядок 12: клас [AppConfig] налаштовує весь додаток;
- рядок 9: клас [AppConfig] є класом конфігурації Spring;
- рядок 10: задано, щоб компоненти Spring шукалися в пакеті [rdvmedecins.web] та його підпакетах. Таким чином будуть виявлені компоненти:
- [@RestController RdvMedecinsController] — у пакеті [rdvmedecins.web.controllers];
- [@Component ApplicationModel] у пакеті [rdvmedecins.web.models];
- рядок 11: імпортується клас [DomainAndPersistenceConfig], який налаштовує проект [rdvmedecins-metier-dao] для доступу до бінів цього проекту;
- рядок 11: клас [SecurityConfig] налаштовує безпеку веб-додатку. Поки що ми його проігноруємо;
- рядок 11: клас [WebConfig] налаштовує рівень [web / jSON];
Клас [WebConfig] має такий вигляд:
package rdvmedecins.web.config;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@EnableWebMvc
public class WebConfig {
// конфігурація dispatcherservlet для заголовків CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8080);
}
// маппери jSON
@Bean
public ObjectMapper jsonMapper() {
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- рядки 20–25: визначають бін [dispatcherServlet]. Клас [DispatcherServlet] є сервлетом фреймворку Spring MVC. Він виконує роль [FrontController]: перехоплює запити, що надходять на сайт Spring MVC, і передає їх на обробку одному з контролерів (Controller) сайту;
- рядок 22: створення екземпляра класу;
- рядок 23: цей рядок поки що можна проігнорувати;
- рядки 27–30: сервлет [dispatcherServlet] обробляє всі запити URL;
- рядки 27–30: активують вбудований сервер Tomcat у залежностях проєкту. Він працюватиме на порту 8080;
- рядки 38–67: чотири маппери jSON, налаштовані з різними фільтрами jSON;
- рядки 38–41: маппер jSON без фільтрів;
- рядки 43–49: маппер jSON [jsonMapperShortCreneau] серіалізує/десеріалізує об’єкт [Creneau], ігноруючи поле [Creneau.medecin];
- рядки 51–59: маппер jSON [jsonMapperLongRv] серіалізує/десеріалізує об’єкт [Rv], ігноруючи поле [Rv.creneau.medecin];
- рядки 61–67: маппер jSON [jsonMapperShortRv] серіалізує / десеріалізує об’єкт [Rv], ігноруючи поля [Rv.creneau] та [Rv.client];
8.4.11.4. Клас [ApplicationModel]
![]() |
Клас [ApplicationModel] знадобиться нам для двох цілей:
- як кеш для зберігання списків лікарів та пацієнтів (клієнтів);
- як єдиний інтерфейс для контролерів;
package rdvmedecins.web.models;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
import rdvmedecins.web.helpers.Static;
@Component
public class ApplicationModel implements IMetier {
// шар [métier]
@Autowired
private IMetier métier;
// дані з шару [métier]
private List<Medecin> médecins;
private List<Client> clients;
private List<String> messages;
// дані конфігурації
private boolean CORSneeded = false;
private boolean secured = false;
@PostConstruct
public void init() {
// отримуємо дані про лікарів та клієнтів
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
// геттер
public List<String> getMessages() {
return messages;
}
// ------------------------- інтерфейс шару [métier]
@Override
public List<Client> getAllClients() {
return clients;
}
@Override
public List<Medecin> getAllMedecins() {
return médecins;
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return métier.getAllCreneaux(idMedecin);
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return métier.getRvMedecinJour(idMedecin, jour);
}
@Override
public Client getClientById(long id) {
return métier.getClientById(id);
}
@Override
public Medecin getMedecinById(long id) {
return métier.getMedecinById(id);
}
@Override
public Rv getRvById(long id) {
return métier.getRvById(id);
}
@Override
public Creneau getCreneauById(long id) {
return métier.getCreneauById(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau creneau, Client client) {
return métier.ajouterRv(jour, creneau, client);
}
@Override
public void supprimerRv(long idRv) {
métier.supprimerRv(idRv);
}
@Override
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
return métier.getAgendaMedecinJour(idMedecin, jour);
}
// геттери та сеттери
public boolean isCORSneeded() {
return CORSneeded;
}
public boolean isSecured() {
return secured;
}
}
- рядок 19: анотація [@Component] робить клас [ApplicationModel] компонентом Spring. Як і всі компоненти Spring, розглянуті до цього моменту (за винятком @Controller), буде створено лише один екземпляр цього типу (синглтон);
- рядок 20: клас [ApplicationModel] реалізує інтерфейс [IMetier];
- рядки 23–24: Spring вводить посилання на рівень [métier];
- рядок 34: анотація [@PostConstruct] забезпечує виконання методу [init] одразу після створення екземпляра класу [ApplicationModel];
- рядки 38–39: отримуємо списки лікарів та клієнтів із шару [métier];
- рядок 41: якщо виникає виняток, повідомлення зі стека винятків зберігаються у полі рядка 17;
Архітектура веб-шару розвивається наступним чином:
![]() |
- у [2b] методи контролера (або контролерів) взаємодіють із синглтоном [ApplicationModel];
Ця стратегія забезпечує гнучкість у керуванні кешем. Наразі часові слоти лікарів не кешуються. Щоб їх кешувати, достатньо змінити клас [ApplicationModel]. Це жодним чином не вплине на контролер, який продовжуватиме використовувати метод [List<Creneau> getAllCreneaux(long idMedecin)], як і раніше. Зміни торкнуться саме реалізації цього методу в класі [ApplicationModel].
8.4.11.5. Клас Static
Клас [Static] об’єднує набір статичних допоміжних методів, які не мають «бізнес-» або «веб-» спрямованості:
![]() |
Його код такий:
package rdvmedecins.web.helpers;
import java.util.ArrayList;
import java.util.List;
public class Static {
public Static() {
}
// список повідомлень про помилки винятку
public static List<String> getErreursForException(Exception exception) {
// отримуємо список повідомлень про помилки винятку
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
erreurs.add(cause.getMessage());
cause = cause.getCause();
}
return erreurs;
}
}
- рядок 12: метод [Static.getErreursForException], який було використано (рядок 8 нижче) у методі [init] класу [ApplicationModel]:
@PostConstruct
public void init() {
// отримання списку лікарів та клієнтів
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
Цей метод створює об’єкт [List<String>] із повідомленнями про помилки [exception.getMessage()] винятку [exception] та тих, що містяться в ньому, [exception.getCause()].
8.4.11.6. Скелет контролера [RdvMedecinsController]
![]() |
Тепер детально розглянемо обробку URL веб-сервісом. У цій обробці задіяні три основні класи:
- контролер [RdvMedecinsController];
- клас допоміжних методів [Static];
- клас кешу [ApplicationModel];
![]() |
Контролер [RdvMedecinsController] має такий вигляд:
package rdvmedecins.web.controllers;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.web.helpers.Static;
import rdvmedecins.web.models.ApplicationModel;
import rdvmedecins.web.models.PostAjouterRv;
import rdvmedecins.web.models.PostSupprimerRv;
import rdvmedecins.web.models.Response;
@Controller
public class RdvMedecinsController {
@Autowired
private ApplicationModel application;
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
// список повідомлень
private List<String> messages;
// маппери jSON
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
@PostConstruct
public void init() {
// повідомлення про помилки програми
messages = application.getMessages();
}
// список лікарів
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {...}
// список клієнтів
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {...}
// перелік прийомних годин лікаря
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {...}
// перелік прийомів лікаря
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) String origin) throws JsonProcessingException {...}
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {...}
}
- рядок 35: анотація [@Controller] перетворює клас [RdvMedecinsController] на контролер Spring, C з MVC;
- рядки 38–39: об’єкт типу [ApplicationModel] буде введений сюди Spring. Ми вже його представили;
- рядки 41–42: об’єкт типу [RdvMedecinsCorsController] буде введений сюди Spring. Ми представимо цей об’єкт лише пізніше;
- рядки 48–58: маппери jSON, визначені в класі конфігурації [WebConfig];
- рядок 60: анотація [@PostConstruct] позначає метод, який має бути виконаний одразу після інстанціювання класу. Коли цей метод виконується, об’єкти, вставлені Spring, вже доступні;
- рядок 63: отримуємо можливі повідомлення про помилки з об’єкта [ApplicationModel]. Цей об’єкт був інстанційований під час запуску додатка і намагався зберегти в кеші лікарів та клієнтів. Якщо це не вдалося, то ми отримуємо [messages!=null]. Це дозволить методам контролера визначити, чи правильно ініціалізувався додаток;
- рядки 67–118: методи URL, що надаються сервісом [web / jSON]. Усі методи повертають рядок jSON з об’єкта типу [Response<T>] наступного вигляду:
![]() |
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- властивості
// статус операції
private int status;
// можливі повідомлення про помилки
private List<String> messages;
// тіло відповіді
private T body;
// конструктори
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// методи getter та setter
...
}
- рядок 9: код помилки: 0 означає відсутність помилки;
- рядок 11: якщо [status!=0], то [messages] є списком повідомлень про помилки;
- рядок 13: об’єкт T, вкладений у відповідь. У разі помилки значення T дорівнює null;
Цей об’єкт серіалізується у jSON перед відправленням до браузера клієнта;
- рядок 67: оприлюднений URL — це [/getAllMedecins]. Клієнт повинен використовувати метод [GET] для надсилання свого запиту (method = RequestMethod.GET). Якби цей URL був запрошений за допомогою POST, він би був відхилений, і Spring MVC надіслав би веб-клієнту код помилки HTTP. Метод сам повертає відповідь клієнту (рядок 68). Це буде рядок символів (рядок 67). Заголовок HTTP [Content-type : application/json; charset=UTF-8] буде надіслано клієнту, щоб повідомити його про те, що він отримає рядок jSON (рядок 67);
- рядок 77: параметр URL задається значенням {idMedecin}. Цей параметр отримується за допомогою анотації [@PathVariable] у рядку 79;
- рядок 79: параметр [long idMedecin] отримує своє значення від параметра {idMedecin} з URL [@PathVariable("idMedecin")]. Параметр у URL та параметр методу можуть мати різні імена. Слід зауважити, що [@PathVariable("idMedecin")] має тип String (весь URL є String), тоді як параметр [long idMedecin] має тип [long]. Зміна типу відбувається автоматично. Якщо ця зміна типу не вдається, повертається код помилки HTTP;
- рядок 105: анотація [@RequestBody] позначає тіло запиту. У запиті типу GET тіло майже ніколи не вказується (хоча його можна вказати). У запиті типу POST тіло зазвичай вказується (хоча його можна не вказувати). Для запитів URL та [ajouterRv] веб-клієнт надсилає у своєму запиті POST такий рядок:
Синтаксис [@RequestBody PostAjouterRv post] (рядок 105) у поєднанні з тим, що метод очікує jSON [consumes = "application/json; charset=UTF-8"] у рядку 103, призведе до того, що рядок jSON, надісланий веб-клієнтом, буде десеріалізовано в об’єкт типу [PostAjouterRv]. Він має такий вигляд:
package rdvmedecins.web.models;
public class PostAjouterRv {
// дані запиту
private String jour;
private long idClient;
private long idCreneau;
// методи getter та setter
...
}
Тут також необхідні зміни типу відбудуться автоматично;
- у рядках 107–109 міститься подібний механізм для URL та [/supprimerRv]. Надісланий рядок jSON має такий вигляд:
а тип [PostSupprimerRv] має такий вигляд:
package rdvmedecins.web.models;
public class PostSupprimerRv {
// дані запиту
private long idRv;
// методи getter та setter
...
}
8.4.11.7. URL [/getAllMedecins]
URL [/getAllMedecins] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
// список лікарів
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {
// відповідь
Response<List<Medecin>> response;
// стан додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// список лікарів
try {
response = new Response<>(0, null, application.getAllMedecins());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
}
// відповідь
return jsonMapper.writeValueAsString(response);
}
- рядки 9–10: перевіряється, чи правильно ініціалізувався додаток (messages==null). Якщо ні, повертається відповідь із status=-1 та body=messages;
- рядок 13: інакше запитується список лікарів у класі [ApplicationModel];
- рядок 19: надсилається рядок jSON з відповіді разом із маппером jSON [jsonMapper], оскільки клас [Medecin]не має фільтра jSON. Відповідь може бути без помилок (рядок 14) або з помилкою (рядок 16). Метод [application.getAllMedecins()] не генерує винятку, оскільки він просто повертає список, який знаходиться в кеші. Проте ми збережемо цю обробку винятків на випадок, якщо лікарі більше не будуть зберігатися в кеші;
Ми ще не розглянули випадок, коли додаток ініціалізується з помилкою. Зупинімо SGBD MySQL5, запустимо веб-сервіс, а потім надішлімо запит на URL [/getAllMedecins]:

Ми дійсно отримуємо помилку. У звичайних умовах ми бачимо таке:
![]() |
8.4.11.8. URL [/getAllClients]
URL [/getAllClients] обробляється наступним методом контролера [RdvMedecinsController]:
// список клієнтів
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {
// відповідь
Response<List<Client>> response;
// статус заявки
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// список клієнтів
try {
response = new Response<>(0, null, application.getAllClients());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
// відповідь
return jsonMapper.writeValueAsString(response);
}
Вона аналогічна вже розглянутому методу [getAllMedecins]. Отримано такі результати:
![]() |
8.4.11.9. URL [/getAllCreneaux/{idMedecin}]
URL [/getAllCreneaux/{idMedecin}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
// список вільних часів лікаря
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {
// відповідь
Response<List<Creneau>> response;
// статус додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// отримання даних про лікаря
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
Medecin médecin = responseMedecin.getBody();
// прийомні години лікаря
try {
response = new Response<>(0, null, application.getAllCreneaux(médecin.getId()));
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// відповідь
return jsonMapperShortCreneau.writeValueAsString(response);
}
- рядок 12: лікар, ідентифікований параметром [id], запитується у локальному методі:
private Response<Medecin> getMedecin(long id) {
// отримано дані про лікаря
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// лікар вже існує?
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// гаразд
return new Response<Medecin>(0, null, médecin);
}
З цього методу повертаємося з status у [0,1,2]. Повернемося до коду методу [getAllCreneaux]:
- рядки 13–14: якщо status!=0, формується відповідь з помилкою;
- рядок 16: отримуємо дані про лікаря;
- рядок 19: отримуємо часові слоти цього лікаря;
- рядок 25: як відповідь надсилається об’єкт [List<Creneau>]. Нагадаємо визначення класу [Creneau]:
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// характеристики слота RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// вільний час пов'язаний з лікарем
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// зовнішній ключ
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
...
}
- рядок 13: лікар шукається в режимі [FetchType.LAZY];
Нагадаємо запит JPQL, який реалізує метод [getAllCreneaux] у шарі [DAO]:
@Query("select c from Creneau c where c.medecin.id=?1")
Номер запиту [c.medecin.id] примусово виконує з'єднання таблиць [CRENEAUX] та [MEDECINS]. Таким чином, запит повертає всі часові слоти лікаря, у кожному з яких вказано цього лікаря. Під час серіалізації цих часових проміжків у таблицю jSON у кожному з них з’являється рядок jSON, що відповідає лікарю. Це зайве. Щоб контролювати серіалізацію, нам потрібно дві речі:
- доступ до об’єкта, що серіалізується;
- налаштувати об’єкт, що серіалізується;
Пункт 1 перевіряється шляхом введення відповідного конвертера jSON для об’єкта в контролер:
@Autowired
private ObjectMapper jsonMapperShortCreneau;
Пункт 2 досягається шляхом додавання анотації до класу [Creneau], визначеного в проєкті [rdvmedecins-metier-dao]:
![]() |
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
...
- рядок 3: анотація з бібліотеки jSON Jackson. Вона створює фільтр під назвою [creneauFilter]. За допомогою цього фільтра ми зможемо програмно визначити, які поля мають бути серіалізовані, а які ні;
Серіалізація об’єкта [Creneau] відбувається в наступному рядку методу [getAllCreneaux]:
// відповідь
return jsonMapperShortCreneau.writeValueAsString(response);
Маппер jSON [jsonMapperShortCreneau] було визначено у класі [WebConfig] наступним чином:
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
- рядок 5: фільтр із назвою [creneauFilter] пов’язаний із фільтром [creneauFilter] із рядка 4. Цей фільтр серіалізує об’єкт [Creneau] без його поля [medecin];
Результатом, що повертається методом [getAllCreneaux], є рядок jSON типу [Response<List<Creneau>].
Отримано такі результати:
![]() |
або такі, якщо часовий проміжок не існує:
![]() |
З цього прикладу ми винесемо таке правило:
- методи веб-сервера / jSON повертають об’єкт типу [Response<T>], який серіалізується в jSON;
- якщо тип T має один або кілька фільтрів jSON, для його серіалізації буде використано маппер із цими самими фільтрами;
8.4.11.10. URL [/getRvMedecinJour/{idMedecin}/{jour}]
URL [/getRvMedecinJour/{idMedecin}/{jour}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
// список прийомів лікаря
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// відповідь
Response<List<Rv>> response=null;
boolean erreur = false;
// стан додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// перевіряється дата
Date jourAgenda = null;
if (!erreur) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<List<Rv>>(3, messages, null);
erreur = true;
}
}
Response<Medecin> responseMedecin = null;
if (!erreur) {
// отримано дані про лікаря
responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
Medecin médecin = responseMedecin.getBody();
// список його прийомів
try {
response = new Response<>(0, null, application.getRvMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// відповідь
return jsonMapperLongRv.writeValueAsString(response);
}
- необхідно перетворити рядок jSON на тип [Response<List<Rv>>]. Клас [Rv] має поле [Rv.creneau]. Якщо це поле серіалізується, ми натрапимо на фільтр jSON [creneauFilter];
- рядок 47: об’єкт типу [Response<List<Rv>>] із рядка 7 серіалізується в jSON;
Розглянемо випадок, коли список зустрічей було отримано у рядку 42. Клас [Rv] у проєкті [rdvmedecins-metier-dao] визначено таким чином:
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// характеристики прийому
@Temporal(TemporalType.DATE)
private Date jour;
// прийом пов'язаний з пацієнтом
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// прийом пов'язаний із часовим проміжком
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// зовнішні ключі
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
...
}
- рядок 11: клієнт шукається за допомогою режиму [FetchType.LAZY];
- рядок 18: пошук часового проміжку здійснюється за режимом [FetchType.LAZY];
Нагадаємо запит JPQL, який шукає зустрічі:
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
З’єднання виконуються явно для отримання полів [client] та [creneau]. Крім того, завдяки з’єднанню [cr.medecin.id=?1] ми також отримаємо інформацію про лікаря. Отже, ім’я лікаря з’явиться у рядку jSON кожного прийому. Однак ця дубльована інформація є зайвою. Ми розглянули, як вирішити цю проблему за допомогою фільтра jSON для об’єкта [Creneau]. Через режими [FetchType.LAZY] полів [client] та [creneau] класу [Rv], незабаром ми зіткнемося з необхідністю встановити фільтр jSON для класу [RV] у проєкті [rdvmedecins-metier-dao]:
@Entity
@Table(name = "rv")
@JsonFilter("rvFilter")
public class Rv extends AbstractEntity {
...
Ми перевіримо серіалізацію об’єкта [Rv] за допомогою фільтра [rvFilter]. Очевидно, що тут фільтрувати не потрібно, оскільки нам потрібні всі поля об’єкта типу [Rv]. Проте, оскільки ми вказали, що клас має фільтр jSON, ми мусимо визначити його для кожної серіалізації об’єкта типу [Rv], інакше отримаємо виняток. Для цього ми використовуємо наступний мапер jSON, визначений у класі [rdvMedecinsController]:
@Autowired
private ObjectMapper jsonMapperLongRv;
Цей мапер визначено таким чином у класі конфігурації [WebConfig]:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
- рядок 4: вказуємо, що всі поля об’єкта [Rv] мають бути серіалізовані;
- рядок 5: вказуємо, що в об’єкті [Creneau] не слід серіалізувати поле [medecin];
- рядок 6: додаємо два фільтри [rvFilter] та [creneauFilter] до фільтрів jSON об’єкта [jsonMapperLongRv];
Отримано такі результати:
![]() |
або ось ці з одним днем без зустрічей:
![]() |
або ось ці з неправильною датою:
![]() |
або ось ці — з неправильним лікарем:
![]() |
8.4.11.11. URL [/getAgendaMedecinJour/{idMedecin}/{jour}]
URL [/getAgendaMedecinJour/{idMedecin}/{jour}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// відповідь
Response<AgendaMedecinJour> response = null;
boolean erreur = false;
// статус заявки
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// перевіряється дата
Date jourAgenda = null;
if (!erreur) {
// перевіряється дата
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
erreur = true;
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(3, messages, null);
}
}
// пошук лікаря
Medecin médecin = null;
if (!erreur) {
// вибирається лікар
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
médecin = responseMedecin.getBody();
}
}
// отримано його розклад
if (!erreur) {
try {
response = new Response<>(0, null, application.getAgendaMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// відповідь
return jsonMapperLongRv.writeValueAsString(response);
}
- рядки 6, 49: рядок jSON типу [AgendaMedecinJour] інкапсулюється в об’єкт [Response];
Тип [AgendaMedecinJour] має такий вигляд:
public class AgendaMedecinJour implements Serializable {
// поля
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
Тип [CreneauMedecinJour] має такий вигляд:
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// поля
private Creneau creneau;
private Rv rv;
Поля [creneau] та [rv] мають фільтри jSON, які потрібно налаштувати. Саме це робить рядок 49 методу [getAgendaMedecinJour], який використовує вже згаданий мапер jSON [jsonMapperLongRv]:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
Отримано такі результати:
![]() |
Зверху видно, що 28.01.2015 р. у лікаря PELISSIER призначено прийом пані Бріжит BISTROU о 8:20;
або ось ці, якщо вказано неправильну дату:
![]() |
або ось ці, якщо номер лікаря недійсний:
![]() |
8.4.11.12. URL [/getMedecinById/{id}]
URL [/getMedecinById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) throws JsonProcessingException {
// відповідь
Response<Medecin> response;
// стан додатка
if (messages != null) {
response = new Response<Medecin>(-1, messages, null);
} else {
response = getMedecin(id);
}
// відповідь
return jsonMapper.writeValueAsString(response);
}
- рядки 5, 13: метод перетворює рядок jSON на тип [Medecin]. Цей тип не має анотації фільтра jSON. Також у рядку 14 використовується мапер jSON без фільтрів;
У рядку 10 метод [getMedecin] виглядає наступним чином:
private Response<Medecin> getMedecin(long id) {
// пошук лікаря
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// лікар вже є?
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// гаразд
return new Response<Medecin>(0, null, médecin);
}
Отримані результати такі:
![]() |
або ось такі, якщо номер лікаря вказано неправильно:
![]() |
8.4.11.13. URL [/getClientById/{id}]
URL [/getClientById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {
// відповідь
Response<Client> response;
// стан додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
response = getClient(id);
}
// відповідь
return jsonMapper.writeValueAsString(response);
}
- рядки 5, 13: метод перетворює рядок jSON на тип [Client]. Цей тип не має анотацій фільтрів jSON. Тому в рядку 13 використовується мапер jSON без фільтрів;
У рядку 11 метод [getClient] має такий вигляд:
private Response<Client> getClient(long id) {
// отримано дані про клієнта
Client client = null;
try {
client = application.getClientById(id);
} catch (RuntimeException e1) {
return new Response<Client>(1, Static.getErreursForException(e1), null);
}
// клієнт існує?
if (client == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le client d'id [%s] n'existe pas", id));
return new Response<Client>(2, messages, null);
}
// ok
return new Response<Client>(0, null, client);
}
Отримані результати такі:
![]() |
або ось такі, якщо номер клієнта неправильний:
![]() |
8.4.11.14. URL [/getCreneauById/{id}]
URL [/getCreneauById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {
// відповідь
Response<Creneau> response;
// стан додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// повертаємо слот
response = getCreneau(id);
}
// відповідь
return jsonMapperShortCreneau.writeValueAsString(response);
}
- рядки 5, 14: метод повертає рядок jSON типу [Response<Creneau>];
У рядку 8 метод [getCreneau] виглядає так:
private Response<Creneau> getCreneau(long id) {
// отримано слот
Creneau créneau = null;
try {
créneau = application.getCreneauById(id);
} catch (RuntimeException e1) {
return new Response<Creneau>(1, Static.getErreursForException(e1), null);
}
// слот існує?
if (créneau == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le créneau d'id [%s] n'existe pas", id));
return new Response<Creneau>(2, messages, null);
}
// гаразд
return new Response<Creneau>(0, null, créneau);
}
Нагадаємо код об’єкта [Creneau]:
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// характеристики слота з RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// вільний час пов'язаний з лікарем
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// зовнішній ключ
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
- рядки 14–16: оскільки поле [medecin] перебуває в режимі [fetch = FetchType.LAZY], воно не повертається під час пошуку слоту за його [id]. Тому його необхідно виключити з серіалізації. Без цього виключення виникає виняток. Це пов’язано з тим, що об’єкт серіалізації [mapper] викликатиме метод [getMedecin] для отримання поля [medecin]. Однак при реалізації JPA / Hibernate режим [fetch = FetchType.LAZY] поля [medecin] повернув об’єкт [Creneau], метод [getMedecin] якого запрограмовано для пошуку лікаря в контексті JPA. Це називається об’єктом [proxy]. Але давайте згадаємо архітектуру веб-додатку:
![]() |
Контролер знаходиться у блоці [Contrôleurs / Actions]. Коли ми перебуваємо в цьому блоці, поняття контексту JPA більше не існує. Останній створюється під час операцій на рівні [DAO]. Він не існує після цього. Тому, коли контролер намагається отримати доступ до контексту JPA, виникає виняток, що вказує на те, що цей контекст закрито. Щоб уникнути цього винятку, потрібно запобігти серіалізації поля [medecin] класу [Rv]. Саме це робить мапер jSON [jsonMapperShortCreneau]:
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
Отримано такі результати:
![]() |
або такі, якщо номер слота неправильний:
![]() |
8.4.11.15. URL [/getRvById/{id}]
URL [/getRvById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {
// відповідь
Response<Rv> response;
// стан запиту
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// отримано rv
response = getRv(id);
}
// відповідь
return jsonMapperShortRv.writeValueAsString(response);
}
- рядки 5, 14: метод повертає рядок jSON типу [Response<Rv>];
У рядку 11 метод [getRv] виглядає так:
private Response<Rv> getRv(long id) {
// отримано Rv
Rv rv = null;
try {
rv = application.getRvById(id);
} catch (RuntimeException e1) {
return new Response<Rv>(1, Static.getErreursForException(e1), null);
}
// Rv існує?
if (rv == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le rendez-vous d'id [%s] n'existe pas", id));
return new Response<Rv>(2, messages, null);
}
// ok
return new Response<Rv>(0, null, rv);
}
Клас [Rv] має два поля з анотацією [fetch = FetchType.LAZY]: поля [creneau] та [client]. Отже, ці поля не передаються під час пошуку [Rv] за його первинним ключем. Тому, з тих самих причин, що й раніше, їх потрібно виключити з серіалізації. Саме це робить наступний мапер [jsonMapperShortRv], визначений у класі [WebConfig]:
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
Отримано такі результати:
![]() |
або ось такі, якщо номер зустрічі неправильний:
![]() |
8.4.11.16. URL [/ajouterRv]
URL [/ajouterRv] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {
// відповідь
Response<Rv> response = null;
boolean erreur = false;
// стан додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// отримано відправлені значення
String jour;
long idCreneau = -1;
long idClient = -1;
Date jourAgenda = null;
if (!erreur) {
// отримуємо відправлені значення
jour = post.getJour();
idCreneau = post.getIdCreneau();
idClient = post.getIdClient();
// перевіряємо дату
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(6, messages, null);
erreur = true;
}
}
// отримуємо часовий проміжок
Response<Creneau> responseCréneau = null;
if (!erreur) {
// отримуємо час
responseCréneau = getCreneau(idCreneau);
if (responseCréneau.getStatus() != 0) {
erreur = true;
response = new Response<>(responseCréneau.getStatus(), responseCréneau.getMessages(), null);
}
}
// отримуємо клієнта
Response<Client> responseClient = null;
Creneau créneau = null;
if (!erreur) {
créneau = (Creneau) responseCréneau.getBody();
// отримуємо клієнта
responseClient = getClient(idClient);
if (responseClient.getStatus() != 0) {
erreur = true;
response = new Response<>(responseClient.getStatus() + 2, responseClient.getMessages(), null);
}
}
if (!erreur) {
Client client = responseClient.getBody();
// додаємо зустріч
try {
response = new Response<>(0, null, application.ajouterRv(jourAgenda, créneau, client));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(5, Static.getErreursForException(e1), null);
}
}
// відповідь
return jsonMapperLongRv.writeValueAsString(response);
}
- рядки 5, 67: метод повинен повернути рядок jSON типу [Response<Rv>];
- рядок 3: анотація [@RequestBody PostAjouterRv post] отримує тіло POST і поміщає його в параметр [PostAjouterRv post]. Цей текст належить до jSON [consumes = "application/json; charset=UTF-8"], який буде автоматично десеріалізовано у наступний тип [PostAjouterRv]:
public class PostAjouterRv {
// дані запиту
private String jour;
private long idClient;
private long idCreneau;
...
- далі йде код, який вже зустрічався в тій чи іншій формі;
- рядок 67: налаштування фільтрів jSON, [creneauFilter] та [rvFilter]. Метод перетворює рядок jSON на тип [Response<Rv>], де Rv було отримано в рядку 61. Об’єкт [Rv] інкапсулює об’єкт [Creneau], а також об’єкт [Client]. Об’єкт [Creneau] має залежність [FetchType.LAZY] від об’єкта [Medecin] і був отриманий у рядках 36–44. Його було знайдено в контексті JPA за його первинним ключем і отримано без його залежності [FetchType.LAZY]. У підсумку,
- об’єкт [Rv] має усі свої залежності. Їх можна серіалізувати;
- об’єкт [Creneau] не має своєї залежності [medecin]. Отже, її не слід серіалізувати;
Маппер jSON [jsonMapperLongRv], визначений у класі [WebConfig], відповідає цим обмеженням:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
Отримані результати виглядають так із клієнтом [Advanced Rest Client]:
![]() |
- у [1] — URL з POST;
- [2] замінює POST;
- у [3] — значення, що було опубліковано;
- у [4a] це опубліковане значення походить з jSON;
![]() |
- у [4b] клієнт вказує, що надсилає jSON;
- у [5] сервер вказує, що він повертає jSON;
![]() |
- у [6] — відповідь сервера jSON, яка представляє додану зустріч. У ній вказано ідентифікатор [id] доданої зустрічі;
Отримуємо наступне з неіснуючим номером часового проміжку:
![]() |
8.4.11.17. URL [/supprimerRv]
URL [/supprimerRv] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {
// відповідь
Response<Void> response = null;
boolean erreur = false;
// заголовки CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// стан додатка
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// отримуємо відправлені значення
long idRv = post.getIdRv();
// отримано rv
if (!erreur) {
Response<Rv> responseRv = getRv(idRv);
if (responseRv.getStatus() != 0) {
response = new Response<>(responseRv.getStatus(), responseRv.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
// видалення rv
try {
application.supprimerRv(idRv);
response = new Response<Void>(0, null, null);
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// відповідь
return jsonMapper.writeValueAsString(response);
}
- рядок 5: тип [Void] є класом, що відповідає примітивному типу [void];
- рядки 5, 34: метод повертає рядок jSON типу [Response<Void>], який не має фільтрів jSON. Тому в рядку 34 використовується мапер jSON без фільтрів;
- рядок 3: метод має як параметр тіло POST, тобто відправлене значення. Воно отримується у форматі jSON [consumes = "application/json; charset=UTF-8"] і автоматично десеріалізується у наступний тип [PostSupprimerRv]:
public class PostSupprimerRv {
// дані запиту
private long idRv;
- рядок 28: коли видалення відбулося успішно, надсилається відповідь із [status=0];
Отримано такі результати:
![]() |
![]() |
- у [5] поле [status=0] вказує, що видалення відбулося успішно;
У разі введення неіснуючого номера зустрічі отримуємо наступне:
![]() |
Ми завершили роботу з контролером. Тепер розглянемо, як запустити проект.
8.4.11.18. Виконуваний клас веб-сервісу
![]() |
Клас [Boot] [1] має такий вигляд:
package rdvmedecins.web.boot;
import org.springframework.boot.SpringApplication;
import rdvmedecins.web.config.AppConfig;
public class Boot {
public static void main(String[] args) {
SpringApplication.run(AppConfig.class, args);
}
}
У рядку 10 виконується статичний метод [SpringApplication.run], першим параметром якого є клас [AppConfig], що відповідає конфігурації проекту. Цей метод виконає автоконфігурацію проєкту, запустить вбудований у залежності сервер Tomcat та розгорне на ньому контролер [RdvMedecinsController].
Журнали контролюються такими файлами [2]:
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- кодерам за замовчуванням присвоюється тип ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- контроль рівня журналів -->
<root level="info"> <!-- вимкнено, інформація, налагодження, попередження -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- рядок 9: загальний рівень журналів встановлено на [info];
[application.properties]
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=OFF
spring.main.show-banner=false
Рядки 1–2 дозволяють встановити конкретний рівень логів для певних елементів додатка:
- рядок 1: потрібні журнали рівня [web];
- рядок 2: не потрібні журнали рівня [JPA];
- рядок 3: без банера Spring Boot;
Під час виконання з’являються такі журнали:
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.groovy]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Found resource [logback.xml] at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs multiple times on the classpath.
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-metier-dao/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,342 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - debug attribute not set
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - Naming appender as [STDOUT]
11:06:04,357 |-INFO in ch.qos.logback.core.joran.action.NestedComplexPropertyIA - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.RootLoggerAction - Setting level of ROOT logger to INFO
11:06:04,404 |-INFO in ch.qos.logback.core.joran.action.AppenderRefAction - Attaching appender named [STDOUT] to Logger[ROOT]
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - End of configuration.
11:06:04,420 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@56f4468b - Registering current configuration as safe fallback point
11:06:04.732 [main] INFO rdvmedecins.web.boot.Boot - Starting Boot on Gportpers3 with PID 420 (D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server\target\classes started by usrlocal in D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server)
11:06:04.775 [main] INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:05.538 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
11:06:05.688 [main] INFO o.a.catalina.core.StandardService - Starting service Tomcat
11:06:05.689 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet Engine: Apache Tomcat/8.0.26
11:06:05.833 [localhost-startStop-1] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
11:06:05.833 [localhost-startStop-1] INFO o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 1061 ms
11:06:06.231 [localhost-startStop-1] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
11:06:09.234 [localhost-startStop-1] INFO o.s.s.web.DefaultSecurityFilterChain - Creating filter chain: org.springframework.security.web.util.matcher.AnyRequestMatcher@1, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@12d14fa, org.springframework.security.web.context.SecurityContextPersistenceFilter@29823fb6, org.springframework.security.web.header.HeaderWriterFilter@662d93b2, org.springframework.security.web.authentication.logout.LogoutFilter@2d81ee0, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@52aa47ad, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@60bd7a74, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5a374232, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7ddb4452, org.springframework.security.web.session.SessionManagementFilter@2cd9855f, org.springframework.security.web.access.ExceptionTranslationFilter@2263f0a2, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@192ce7f6]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/authenticate],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.authenticate(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAllCreneaux(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getMedecinById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Medecin> rdvmedecins.web.controllers.RdvMedecinsController.getMedecinById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getClientById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Client> rdvmedecins.web.controllers.RdvMedecinsController.getClientById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/supprimerRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.web.models.PostSupprimerRv,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllClients],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Client>> rdvmedecins.web.controllers.RdvMedecinsController.getAllClients(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/ajouterRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.web.models.PostAjouterRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getCreneauById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getCreneauById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllMedecins],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Medecin>> rdvmedecins.web.controllers.RdvMedecinsController.getAllMedecins(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
...
11:06:09.677 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerAdapter - Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:09.770 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
11:06:09.786 [main] INFO o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
11:06:09.802 [main] INFO o.a.tomcat.util.net.NioSelectorPool - Using a shared selector for servlet write/read
11:06:09.817 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
11:06:09.817 [main] INFO rdvmedecins.web.boot.Boot - Started Boot in 5.319 seconds (JVM running for 6.053)
- рядок 18: сервер Tomcat активний;
- рядок 21: відбувається ініціалізація середовища Spring;
- рядки 27–38: виявлено URL, що надаються веб-сервісом;
- рядок 44: сервер Tomcat готовий і очікує запитів на порту 8080;
Якщо змінити файл [application.properties] наступним чином:
logging.level.org.springframework.web: OFF
logging.level.org.hibernate:OFF
spring.main.show-banner=false
отримаємо такі журнали:
Якщо, крім того, змінити файл [logback.xml] наступним чином:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- кодерам за замовчуванням присвоюється тип ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- контроль рівня журналів -->
<root level="off"> <!-- вимкнено, інформація, налагодження, попередження -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
отримуємо такі записи журналу:
Отже, бачимо, що ми маємо певний контроль над логами, які з’являються в консолі. Рівень [info] часто є оптимальним рівнем логів.
Тепер ми маємо робочий веб-сервіс, до якого можна звертатися за допомогою веб-клієнта. Далі ми перейдемо до забезпечення безпеки цього сервісу: ми хочемо, щоб лише певні особи могли керувати записами на прийом до лікарів. Для цього ми використаємо фреймворк Spring Security, який є частиною екосистеми Spring.
8.4.12. Вступ до Spring Security
Ми знову імпортуємо посібник Spring, виконавши кроки 1–3, наведені нижче:
![]() |
![]() |
Проєкт складається з таких елементів:
- у папці [templates] містяться сторінки HTML проекту;
- [Application] — це виконуваний клас проекту;
- [MvcConfig] — це клас конфігурації Spring MVC;
- [WebSecurityConfig] — це клас конфігурації Spring Security;
8.4.12.1. Конфігурація Maven
Проєкт [3] є проєктом Maven. Розглянемо його файл [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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- тег::безпека[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- рядки 10–14: проект є проектом Spring Boot;
- рядки 17–20: залежність від фреймворку [Thymeleaf];
- рядки 22–25: залежність від фреймворку Spring Security;
8.4.12.2. Види Thymeleaf
![]() |
Вигляд [home.html] має такий вигляд:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- рядок 12: атрибут [th:href="@{/hello}"] згенерує атрибут [href] тегу <a>. Значення [@{/hello}] згенерує шлях [<context>/hello], де [context] — це контекст веб-додатка;
Згенерований код HTML має такий вигляд:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
Вигляд [hello.html] такий:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- рядок 9: Атрибут [th:inline="text"] згенерує текст тегу <h1>. Цей текст містить вираз $, який має бути обчислений. Елемент [[${#httpServletRequest.remoteUser}]] є значенням атрибута [RemoteUser] поточного запиту HTTP. Це ім’я користувача, що ввійшов у систему;
- рядок 10: форма HTML. Атрибут [th:action="@{/logout}"] згенерує атрибут [action] тегу [form]. Значення [@{/logout}] згенерує шлях [<context>/logout], де [context] — це контекст веб-додатка;
Згенерований код HTML має такий вигляд:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- рядок 8: переклад «Hello [[${#httpServletRequest.remoteUser}]]!»;
- рядок 9: переклад @{/logout};
- рядок 11: приховане поле з назвою (атрибут name) _csrf;
Останній вигляд [login.html] такий:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- рядок 9: атрибут [th:if="${param.error}"] забезпечує, що тег <div> буде згенеровано лише в тому випадку, якщо URL, який відображає сторінку входу, містить параметр [error] (http://context/login?error);
- рядок 10: атрибут [th:if="${param.logout}"] зумовлює, що тег <div> буде згенеровано лише в тому випадку, якщо URL, що відображає сторінку входу, містить параметр [logout] (http://context/login?logout);
- рядки 11–23: форма HTML;
- рядок 11: форма буде відправлена на URL [<context>/login], де <context> — це контекст веб-додатку;
- рядок 13: поле введення з назвою [username];
- рядок 17: поле введення з назвою [password];
Згенерований код HTML має такий вигляд:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
Зверніть увагу, що в рядку 28 Thymeleaf додав приховане поле з назвою [_csrf].
8.4.12.3. Конфігурація Spring MVC
![]() |
Клас [MvcConfig] налаштовує фреймворк Spring MVC:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- рядок 7: анотація [@Configuration] перетворює клас [MvcConfig] на клас конфігурації;
- рядок 8: клас [MvcConfig] успадковує клас [WebMvcConfigurerAdapter], щоб перевизначити деякі його методи;
- рядок 10: перевизначення методу батьківського класу;
- рядки 11–16: метод [addViewControllers] дозволяє пов’язувати URL з поданнями HTML. У ньому встановлюються такі зв’язки:
URL | погляд |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
Суфікс [html] та папка [templates] є значеннями за замовчуванням, які використовує Thymeleaf. Їх можна змінити у налаштуваннях. Папка [templates] має знаходитися в кореневому каталозі Classpath проекту:
![]() |
Вище за [1] папки [java] та [resources] є папками-джерелами (source folders). Це означає, що їхній вміст буде розміщений у кореневому каталозі Classpath проекту. Отже, у [2] папки [hello] та [templates] будуть у кореневому каталозі Classpath.
8.4.12.4. Налаштування Spring Security
![]() |
Клас [WebSecurityConfig] налаштовує фреймворк Spring Security:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- рядок 9: анотація [@Configuration] перетворює клас [WebSecurityConfig] на клас конфігурації;
- рядок 10: анотація [@EnableWebSecurity] перетворює клас [WebSecurityConfig] на клас конфігурації Spring Security;
- рядок 11: клас [WebSecurity] успадковує клас [WebSecurityConfigurerAdapter], щоб перевизначити деякі його методи;
- рядок 12: перевизначення методу батьківського класу;
- рядки 13–16: метод [configure(HttpSecurity http)] перевизначено для визначення прав доступу до різних URL у додатку;
- рядок 14: метод [http.authorizeRequests()] дозволяє пов’язувати URL з правами доступу. У ньому встановлено такі зв’язки:
URL | правило | код |
доступ без автентифікації | | |
доступ лише після автентифікації |
- рядок 15: визначає метод аутентифікації. Аутентифікація здійснюється через форму URL [/login], доступну для всіх [http.formLogin().loginPage("/login").permitAll()]. Вихід із системи (logout) також доступний для всіх;
- рядки 19–21: перевизначають метод [configure(AuthenticationManagerBuilder auth)], який керує користувачами;
- рядок 20: автентифікація здійснюється за допомогою «жорстко» визначених користувачів [auth.inMemoryAuthentication()]. Користувач тут визначається за допомогою логіна [user], пароля [password] та ролі [USER]. Користувачам з однаковою роллю можна надати однакові права;
8.4.12.5. Виконуваний клас
![]() |
Клас [Application] має такий вигляд:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- рядок 8: анотація [@EnableAutoConfiguration] вимагає від Spring Boot (рядок 3) виконати конфігурацію, яку розробник не зробив явно;
- рядок 9: перетворює клас [Application] на клас конфігурації Spring;
- рядок 10: вимагає сканування папки класу [Application] з метою пошуку компонентів Spring. Таким чином будуть виявлені обидва класи [MvcConfig] та [WebSecurityConfig], оскільки вони мають анотацію [@Configuration];
- рядок 13: метод [main] класу, що виконується;
- рядок 14: статичний метод [SpringApplication.run] виконується з класом конфігурації [Application] як параметром. Ми вже стикалися з цим процесом і знаємо, що вбудований у залежності Maven проекту сервер Tomcat буде запущений, а проект розгорнуто на ньому. Ми бачили, що чотири об’єкти URL управлялися об’єктом [/, /home, /login, /hello] і що деякі з них були захищені правами доступу.
8.4.12.6. Тестування додатка
Почнемо з запиту на URL [/], який є одним із чотирьох прийнятих URL. Він пов’язаний із поданням [/templates/home.html]:
![]() |
Запитана URL [/] є загальнодоступною. Саме тому ми її отримали. Посилання [here] таке:
URL [/hello] буде запитано, коли ми натиснемо на посилання. Воно захищене:
URL | правило | код |
доступ без авторизації | | |
доступ лише для авторизованих користувачів |
Щоб отримати доступ, необхідно пройти автентифікацію. Spring Security перенаправить браузер клієнта на сторінку автентифікації. Згідно з наведеною конфігурацією, це сторінка URL [/login]. Ця сторінка доступна для всіх:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
Отже, ми отримуємо [1]:
![]() |
Вихідний код отриманої сторінки такий:
- у рядку 7 з’являється приховане поле, якого немає на вихідній сторінці [login.html]. Його додав Thymeleaf. Цей код, який називається CSRF (Cross Site Request Forgery), призначений для усунення вразливості безпеки. Цей токен потрібно надіслати до Spring Security разом з даними автентифікації, щоб автентифікація була прийнята;
Нагадаємо, що Spring Security розпізнає лише комбінацію «user/password». Якщо ввести в [2] будь-що інше, ми отримаємо ту саму сторінку з повідомленням про помилку в [3]. Spring Security перенаправив браузер на URL [http://localhost:8080/login?error]. Наявність параметра [error] спричинила відображення тегу:
<div th:if="${param.error}">Invalid username and password.</div>
Тепер введемо очікувані значення user/password [4]:
![]() |
- у [4] ми авторизуємося;
- у [5] Spring Security перенаправляє нас на URL [/hello], оскільки саме URL ми запитували, коли нас перенаправили на сторінку входу. Ідентифікаційні дані користувача були відображені в наступному рядку [hello.html]:
На сторінці [5] відображається така форма:
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
При натисканні на кнопку [Sign Out] буде виконано POST на сторінці URL [/logout]. Цей файл, як і URL та [/login], доступний для всіх:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
У нашому об’єднанні URL / views ми нічого не визначили для URL та [/logout]. Що відбудеться? Спробуємо:
![]() |
- у [6] ми натискаємо кнопку [Sign Out];
- з [7] бачимо, що нас перенаправили на URL [http://localhost:8080/login?logout]. Це Spring Security ініціював це перенаправлення. Наявність параметра [logout] у URL призвела до відображення наступного рядка у вигляді:
<div th:if="${param.logout}">You have been logged out.</div>
8.4.12.7. Conclusion
У попередньому прикладі ми могли б спочатку написати веб-додаток, а потім забезпечити його безпеку. Spring Security не є інтрузивним. Можна налаштувати безпеку вже написаного веб-додатка. Крім того, ми виявили наступні моменти:
- можна визначити сторінку автентифікації;
- автентифікація повинна супроводжуватися токеном CSRF, виданим Spring Security;
- якщо автентифікація не вдалася, користувача перенаправляють на сторінку автентифікації, причому в токені URL додається параметр error;
- якщо автентифікація проходить успішно, користувач перенаправляється на сторінку, яку він запитував під час автентифікації. Якщо безпосередньо зайти на сторінку автентифікації, оминувши проміжну сторінку, Spring Security перенаправляє нас на URL [/] (цей випадок не розглядався);
- вихід із системи здійснюється шляхом запиту на сторінку URL [/logout] із параметром POST. Spring Security перенаправляє нас на сторінку автентифікації з параметром logout у URL;
Усі ці висновки ґрунтуються на стандартній поведінці Spring Security. Цю поведінку можна змінити шляхом конфігурації, перевизначивши певні методи класу [WebSecurityConfigurerAdapter].
Попередній підручник не дуже допоможе нам далі. Адже ми будемо використовувати:
- базу даних для зберігання користувачів, їхніх паролів та ролей;
- аутентифікацію за заголовком HTTP;
Посібників з того, що ми хочемо зробити тут, досить мало. Рішення, яке буде запропоновано, — це комбінація коду, знайденого тут і там.
8.4.13. Налаштування безпеки веб-сервісу для запису на прийом
8.4.13.1. База даних
База даних [rdvmedecins] розширюється, щоб враховувати користувачів, їхні паролі та ролі. З’являються три нові таблиці:

Таблиця [USERS]: користувачі
- ID: первинний ключ;
- VERSION: стовпець версійності рядка;
- IDENTITY: описовий ідентифікатор користувача;
- LOGIN: логін користувача;
- PASSWORD: його пароль;
У таблиці USERS паролі не зберігаються у відкритому вигляді:
![]() |
Алгоритм, який шифрує паролі, — це алгоритм BCRYPT.
Таблиця [ROLES]: ролі
- ID: первинний ключ;
- VERSION: стовпець версійності рядка;
- NAME: ім’я ролі. За замовчуванням Spring Security очікує імен у форматі ROLE_XX, наприклад ROLE_ADMIN або ROLE_GUEST;
![]() |
Таблиця [USERS_ROLES]: таблиця з'єднання USERS / ROLES
Користувач може мати кілька ролей, а одна роль може об’єднувати кількох користувачів. Існує багато-до-багатьох відношення, яке реалізовано таблицею [USERS_ROLES].
- ID: первинний ключ;
- VERSION: стовпець версійності рядка;
- USER_ID: ідентифікатор користувача;
- ROLE_ID: ідентифікатор ролі;
![]() |
Оскільки ми вносимо зміни до бази даних, необхідно змінити всі шари проекту [métier, DAO, JPA]:
![]() |
8.4.13.2. Новий проект STS на основі [métier, DAO, JPA]
Проєкт [rdvmedecins-metier-dao] змінюється наступним чином:
![]() |
- у [1]: новий проект;
- у [2]: зміни, внесені з урахуванням вимог безпеки, були об’єднані в єдиний пакет [rdvmedecins.security]. Ці нові елементи належать до шарів [JPA] та [DAO], але для простоти їх було об’єднано в один пакет.
8.4.13.3. Нові об’єкти [JPA]
![]() |
Шар JPA визначає три нові об’єкти:
![]() |
Клас [User] є відображенням таблиці [USERS]:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
private static final long serialVersionUID = 1L;
// характеристики
private String identity;
private String login;
private String password;
// конструктор
public User() {
}
public User(String identity, String login, String password) {
this.identity = identity;
this.login = login;
this.password = password;
}
// ідентифікатор
@Override
public String toString() {
return String.format("User[%s,%s,%s]", identity, login, password);
}
// методи getter та setter
....
}
- рядок 9: клас розширює клас [AbstractEntity], який вже використовується для інших об’єктів;
- рядки 13–15: імена стовпців не вказуються, оскільки вони мають ті самі назви, що й пов’язані з ними поля;
Клас [Role] є відображенням таблиці [ROLES]:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
private static final long serialVersionUID = 1L;
// властивості
private String name;
// конструктори
public Role() {
}
public Role(String name) {
this.name = name;
}
// ідентичність
@Override
public String toString() {
return String.format("Role[%s]", name);
}
// методи getter та setter
...
}
Клас [UserRole] є відображенням таблиці [USERS_ROLES]:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
private static final long serialVersionUID = 1L;
// UserRole посилається на User
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// UserRole посилається на роль
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// методи getter та setter
...
}
- рядки 15–17: реалізують зовнішній ключ таблиці [USERS_ROLES] до таблиці [USERS];
- рядки 19–21: реалізують зовнішній ключ таблиці [USERS_ROLES] до таблиці [ROLES];
8.4.13.4. Зміни в шарі [DAO]
![]() |
Рівень [DAO] доповнюється трьома новими [Repository]:
![]() |
Інтерфейс [UserRepository] керує доступом до об’єктів [User]:
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// список ролей користувача, ідентифікованого за його ідентифікатором
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// список ролей користувача, ідентифікованого за логіном та паролем
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// пошук користувача за логіном
User findUserByLogin(String login);
}
- рядок 9: інтерфейс [UserRepository] розширює інтерфейс [CrudRepository] з Spring Data (рядок 4);
- рядки 12–13: метод [getRoles(User user)] дозволяє отримати всі ролі користувача, ідентифікованого за його [id]
- рядки 16–17: те саме, але для користувача, ідентифікованого за логіном та паролем;
- рядок 20: для пошуку користувача за його логіном;
Інтерфейс [RoleRepository] керує доступом до об’єктів [Role]:
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface RoleRepository extends CrudRepository<Role, Long> {
// пошук ролі за її назвою
Role findRoleByName(String name);
}
- рядок 5: інтерфейс [RoleRepository] розширює інтерфейс [CrudRepository];
- рядок 8: можна шукати роль за її назвою;
Інтерфейс [userRoleRepository] керує доступом до об’єктів [UserRole]:
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- рядок 5: інтерфейс [UserRoleRepository] лише розширює інтерфейс [CrudRepository], не додаючи до нього нових методів;
8.4.13.5. Класи управління користувачами та ролями
![]() |
Spring Security вимагає створення класу, що реалізує наступний інтерфейс [UsersDetail]:
![]() |
Цей інтерфейс реалізовано класом [AppUserDetails]:
package rdvmedecins.security;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// властивості
private User user;
private UserRepository userRepository;
// конструктори
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------інтерфейс
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// методи getter та setter
...
}
- рядок 10: клас [AppUserDetails] реалізує інтерфейс [UserDetails];
- рядки 15–16: клас інкапсулює користувача (рядок 15) та репозиторій, що дозволяє отримати детальну інформацію про цього користувача (рядок 16);
- рядки 22–25: конструктор, який створює екземпляр класу з користувачем та його репозиторієм;
- рядки 28–35: реалізація методу [getAuthorities] інтерфейсу [UserDetails]. Він повинен створити колекцію елементів типу [GrantedAuthority] або його похідних. Тут ми використовуємо похідний тип [SimpleGrantedAuthority] (рядок 32), який інкапсулює ім’я однієї з ролей користувача з рядка 15;
- рядки 31–33: виконується обхід списку ролей користувача з рядка 15 для формування списку елементів типу [SimpleGrantedAuthority];
- рядки 38–40: реалізують метод [getPassword] інтерфейсу [UserDetails]. Повертається пароль користувача з рядка 15;
- рядки 38–40: реалізують метод [getUserName] інтерфейсу [UserDetails]. Повертається логін користувача з рядка 15;
- рядки 47–50: обліковий запис користувача ніколи не втрачає чинності;
- рядки 52–55: обліковий запис користувача ніколи не блокується;
- рядки 57–60: ідентифікатори користувача ніколи не втрачають чинності;
- рядки 62–65: обліковий запис користувача завжди активний;
Spring Security також вимагає наявності класу, що реалізує інтерфейс [AppUserDetailsService]:
![]() |
Цей інтерфейс реалізовано наступним класом [AppUserDetailsService]:
package rdvmedecins.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// пошук користувача за логіном
User user = userRepository.findUserByLogin(login);
// знайдено?
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// повертаємо дані користувача
return new AppUserDetails(user, userRepository);
}
}
- рядок 9: клас буде компонентом Spring, отже, доступним у своєму контексті;
- рядки 12–13: компонент [UserRepository] буде введений саме тут;
- рядки 16–25: реалізація методу [loadUserByUsername] інтерфейсу [UserDetailsService] (рядок 10). Параметром є логін користувача;
- рядок 18: користувача шукають за його логіном;
- рядки 20–22: якщо його не знайдено, генерується виняток;
- рядок 24: створюється та відображається об’єкт [AppUserDetails]. Він дійсно має тип [UserDetails] (рядок 16);
8.4.13.6. Тестування шару [DAO]
![]() |
Спочатку ми створюємо виконуваний клас [CreateUser], здатний створити користувача з роллю:
package rdvmedecins.security;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.security.Role;
import rdvmedecins.security.RoleRepository;
import rdvmedecins.security.User;
import rdvmedecins.security.UserRepository;
import rdvmedecins.security.UserRole;
import rdvmedecins.security.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// синтаксис: логін пароль roleName
// потрібні три параметри
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// отримуємо параметри
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// контекст Spring
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DomainAndPersistenceConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// чи існує ця роль?
Role role = roleRepository.findRoleByName(roleName);
// якщо її немає, створюємо її
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// Чи існує вже користувач?
User user = userRepository.findUserByLogin(login);
// якщо його немає, то створюємо
if (user == null) {
// пароль хешується за допомогою bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// зберігаємо користувача
user = userRepository.save(new User(login, login, crypt));
// створюємо зв’язок із роллю
userRoleRepository.save(new UserRole(user, role));
} else {
// користувач вже існує — чи має він запитувану роль?
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// якщо не знайдено, створюється зв’язок із роллю
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// закриття контексту Spring
context.close();
}
}
- рядок 17: клас очікує три аргументи, що визначають користувача: його логін, пароль та роль;
- рядки 25–27: ці три параметри отримуються;
- рядок 29: контекст Spring будується на основі класу конфігурації [DomainAndPersistenceConfig]. Цей клас уже існував у початковому проєкті. Він має бути змінений наступним чином:
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins" })
@EntityScan(basePackages = { "rdvmedecins.entities", "rdvmedecins.security" })
@EnableTransactionManagement
public class DomainAndPersistenceConfig {
....
}
- рядок 1: потрібно вказати, що тепер у пакеті [rdvmedecins.security] є компоненти [Repository];
- рядок 4: потрібно вказати, що тепер у пакеті [rdvmedecins.security] є сутності JPA;
Повернемося до коду створення користувача:
- рядки 30–32: отримуємо посилання на три [Repository], які можуть нам знадобитися для створення користувача;
- рядок 34: перевіряємо, чи роль уже існує;
- рядки 36–38: якщо ні, то створюємо її в базі даних. Вона матиме ім’я типу [ROLE_XX];
- рядок 40: перевіряємо, чи вже існує логін;
- рядки 42–49: якщо логіна ще немає, створюємо його в базі даних;
- рядок 44: шифруємо пароль. Тут використовуємо клас [BCrypt] з Spring Security (рядок 4). Тому нам потрібні архіви цього фреймворку. Файл [pom.xml] містить нову залежність:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- рядок 46: користувач зберігається в базі даних;
- рядок 48: а також зв’язок, що пов’язує його з його роллю;
- рядки 51–57: якщо логін уже існує — перевіряється, чи серед його ролей вже є та, яку ми хочемо йому присвоїти;
- рядки 59–61: якщо шукану роль не знайдено, створюється запис у таблиці [USERS_ROLES] для зв’язку користувача з його роллю;
- не передбачено захисту від можливих винятків. Це допоміжний клас для швидкого створення користувача з роллю.
При виконанні класу з аргументами [x x guest] у базі даних отримуємо такі результати:
Таблиця [USERS]
![]() |
Таблиця [ROLES]
![]() |
Таблиця [USERS_ROLES]
![]() |
Тепер розглянемо другий клас [UsersTest], який є тестом JUnit:
![]() |
package rdvmedecins.security;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import com.google.common.collect.Lists;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
@Test
public void findAllUsersWithTheirRoles() {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user);
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// отримуємо користувача [admin]
User user = userRepository.findUserByLogin("admin");
// перевіряємо, чи його пароль — [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// перевіряється роль admin / admin
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// отримуємо користувача [admin]
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// перевіряємо, чи його пароль — [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// перевіряється роль admin / admin
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// допоміжний метод — відображає елементи колекції
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- рядки 27–34: візуальна перевірка. Виводяться всі користувачі з їхніми ролями;
- рядки 36–46: перевіряємо, чи користувач [admin] має пароль [admin] та роль [ROLE_ADMIN], використовуючи репозиторій [UserRepository];
- рядок 41: [admin] — це пароль у відкритому вигляді. У базі даних він зашифровано за алгоритмом BCrypt. Метод [BCrypt.checkpw] дозволяє перевірити, чи відкритий пароль після шифрування дійсно дорівнює тому, що міститься в базі даних;
- рядки 48–59: перевіряється, чи користувач [admin] має пароль [admin] та роль [ROLE_ADMIN] за допомогою служби [appUserDetailsService];
Виконання тестів завершується успішно з такими записами в журналах:
8.4.13.7. Проміжний висновок
Додавання необхідних класів до Spring Security вдалося здійснити з мінімальними змінами в оригінальному проєкті. Нагадаємо їх:
- додавання залежності від Spring Security у файлі [pom.xml];
- створення трьох додаткових таблиць у базі даних;
- створення сутностей JPA та компонентів Spring у пакеті [rdvmedecins.security];
Цей дуже сприятливий випадок зумовлений тим, що три таблиці, додані до бази даних, є незалежними від існуючих таблиць. Їх навіть можна було б розмістити в окремій базі даних. Це стало можливим завдяки тому, що було вирішено, що користувач існує незалежно від лікарів та клієнтів. Якби останні були потенційними користувачами, довелося б створити зв’язки між таблицею [USERS] та таблицями [MEDECINS] і [CLIENTS]. Це мало б значний вплив на існуючий проєкт.
8.4.13.8. Проєкт STS на рівні [web]
![]() |
Проєкт [rdvmedecins-webjson] розвивається наступним чином від [1]:
![]() |
Основні зміни слід внести в пакет [rdvmedecins.web.config], де потрібно налаштувати Spring Security. Є й інші, незначні зміни в класах [AppConfig] та [ApplicationModel]. Ми вже зустрічали клас конфігурації Spring Security:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
Ми будемо дотримуватися того самого підходу:
- рядок 11: визначимо клас, який успадковує клас [WebSecurityConfigurerAdapter];
- рядок 13: визначити метод [configure(HttpSecurity http)], який визначає права доступу до різних URL веб-сервісу;
- рядок 19: визначимо метод [configure(AuthenticationManagerBuilder auth)], який визначає користувачів та їхні ролі;
Конфігурацію Spring Security забезпечує клас [SecurityConfig]:
package rdvmedecins.web.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import rdvmedecins.security.AppUserDetailsService;
import rdvmedecins.web.models.ApplicationModel;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
@Autowired
private ApplicationModel application;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// аутентифікація здійснюється за допомогою біна [appUserDetailsService]
// пароль шифрується за допомогою алгоритму хешування BCrypt
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// захищений додаток?
if (application.isSecured()) {
// пароль передається у заголовку Authorization: Basic xxxx
http.httpBasic();
// метод HTTP OPTIONS має бути дозволений для всіх
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// лише роль ADMIN може використовувати додаток
http.authorizeRequests() //
.antMatchers("/", "/**") // усі URL
.hasRole("ADMIN");
// сесія відсутня
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- рядок 15: клас [SecurityConfig] є класом конфігурації Spring;
- рядок 16: для забезпечення безпеки проєкту;
- рядки 19–20: вставляється клас [AppUserDetails], який надає доступ користувачам додатка;
- рядки 21–22: вводиться клас [ApplicationModel], який слугує кешем для веб-додатка. Тут вирішено використовувати її також для налаштування веб-додатка в одному місці. Саме вона визначає булеве значення [isSecured] у рядку 36. Це булеве значення забезпечує (true) або не забезпечує (false) безпеку веб-додатка;
- рядки 25–29: метод [configure(HttpSecurity http)] визначає користувачів та їхні ролі. Він отримує як параметр тип [AuthenticationManagerBuilder]. Цей параметр доповнюється двома даними (рядок 28):
- посилання на службу [appUserDetailsService] із рядка 20, яка надає доступ до зареєстрованих користувачів. Слід зауважити, що інформація про те, що вони зареєстровані в базі даних, тут не з’являється. Отже, вони можуть зберігатися в кеші, надаватися веб-службою тощо;
- тип шифрування, що використовується для пароля. Нагадаємо, що ми використовували алгоритм BCrypt;
- рядки 38–47: метод [configure(HttpSecurity http)] визначає права доступу до URL веб-сервісу;
- рядок 34: у вступному проекті ми бачили, що за замовчуванням Spring Security керує токеном CSRF (Cross Site Request Forgery), який користувач, що бажає пройти автентифікацію, повинен надіслати серверу. Тут цей механізм вимкнено. У поєднанні з булевим значенням (isSecured=false) це дозволяє використовувати веб-додаток без заходів безпеки;
- рядок 38: увімкнено режим автентифікації за допомогою заголовка HTTP. Клієнт повинен надіслати такий заголовок HTTP:
де code — це кодування рядка login:password за алгоритмом Base64. Наприклад, Base64-кодування рядка admin:admin — це YWRtaW46YWRtaW4=. Отже, користувач із логіном [admin] та паролем [admin] надішле такий заголовок HTTP для автентифікації:
- рядки 40–42: вказують, що всі URL веб-сервісу доступні для користувачів, які мають роль [ROLE_ADMIN]. Це означає, що користувач, який не має цієї ролі, не може отримати доступ до веб-сервісу;
- рядок 47: пароль користувача може бути збережений у сесії або ні. Якщо він збережений, користувачеві потрібно пройти автентифікацію лише вперше. Наступного разу його облікові дані не запитуватимуться. У цьому випадку обрано режим без сесії. Кожен запит повинен супроводжуватися обліковими даними безпеки;
Клас [AppConfig], який налаштовує весь додаток, змінюється наступним чином:
![]() |
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- Зміна відбувається у рядку 11: додається клас конфігурації [SecurityConfig];
Нарешті, клас [ApplicationModel] доповнюється булевим значенням:
@Component
public class ApplicationModel implements IMetier {
...
// дані конфігурації
private boolean secured = false;
public boolean isSecured() {
return secured;
}
- у рядку 6: встановлюємо значення логічного параметра [secured] на [true / false] залежно від того, чи потрібно активувати захист.
8.4.13.9. Тестування веб-сервісу
Ми протестуємо веб-сервіс за допомогою клієнта Chrome [Advanced Rest Client]. Нам потрібно буде вказати заголовок автентифікації HTTP:
де [code] — це код Base64 для рядка [login:password]. Для генерації цього коду можна скористатися такою програмою:
![]() |
package rdvmedecins.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// очікуються два аргументи: логін та пароль
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// отримано обидва аргументи
String chaîne = String.format("%s:%s", args[0], args[1]);
// — кодується рядок
byte[] data = Base64.encode(chaîne.getBytes());
// виводиться його кодування Base64
System.out.println(new String(data));
}
}
Якщо запустити цю програму з двома аргументами [admin admin]:
![]() |
отримаємо такий результат:
Тепер, коли ми знаємо, як згенерувати заголовок автентифікації HTTP, запускаємо захищений веб-сервіс:
@Component
public class ApplicationModel implements IMetier {
...
private boolean secured = true;
Потім за допомогою клієнта Chrome [Advanced Rest Client] ми запитуємо список усіх лікарів:
![]() |
- у запиті [1] ми запитуємо URL лікарів;
- у [2] — із методом GET;
- у [3] ми надаємо заголовок HTTP для автентифікації. Код [YWRtaW46YWRtaW4=] — це кодування Base64 рядка [admin:admin];
- у [4] ми надсилаємо команду HTTP;
Відповідь сервера така:
![]() |
- у [1] — заголовок автентифікації HTTP;
- на [2] сервер повертає відповідь jSON;
- у [3] — список заголовків HTTP, пов’язаних із захистом веб-додатку;
Ми дійсно отримуємо список лікарів:
![]() |
Тепер спробуємо надіслати запит HTTP із неправильним заголовком автентифікації. У відповідь отримаємо таке:
![]() |
- у [1] та [3]: заголовок автентифікації HTTP;
- на [2]: відповідь веб-сервісу;
Тепер спробуємо користувача user / user. Він існує, але не має доступу до веб-сервісу. Якщо запустити програму кодування Base64 з двома аргументами [user user]:
![]() |
отримаємо такий результат:
![]() |
- у вигляді [1] та [3]: заголовок автентифікації HTTP;
- на [2]: відповідь веб-сервісу. Вона відрізняється від попередньої, яка мала вигляд [401 Unauthorized]. Цього разу користувач успішно пройшов автентифікацію, але не має достатніх прав для доступу до URL;
Захищений веб-сервіс тепер працює. Ми доповнимо його, щоб він дозволяв міждоменні запити. Ця потреба з’явилася в документі [Tutoriel AngularJS / Spring 4], і хоча тут такої потреби немає, ми все одно її задовольнимо.
8.4.14. Налаштування міждоменних запитів
Розглянемо проблему міждоменних запитів. У документі [Tutoriel AngularJS / Spring 4] розглядається розробка клієнт-серверного додатка, де клієнтом є додаток AngularJS:
![]() |
- сторінки HTML / CSS / JS додатка Angular надходять із сервера [1];
- на сторінці [2] служба [dao] надсилає запит на інший сервер — [2]. Однак це заборонено браузером, який виконує додаток Angular, оскільки це є уразливістю безпеки. Додаток може звертатися лише до того сервера, з якого він походить, тобто до сервера [1];
Насправді не зовсім правильно стверджувати, що браузер забороняє додатку Angular звертатися до сервера [2]. Насправді додаток звертається до нього, щоб запитати, чи дозволяє він клієнту, який не походить з його домену, звертатися до нього. Цю техніку обміну називають CORS (Cross-Origin Resource Sharing). Сервер [2] дає свою згоду, надсилаючи відповідні заголовки HTTP.
Щоб продемонструвати проблеми, з якими можна зіткнутися, ми створимо клієнтсько-серверний додаток, у якому:
- сервером буде наш веб-сервер / jSON;
- клієнтом буде проста сторінка HTML, оснащена кодом JavaScript, який надсилатиме запити до веб-сервера / jSON;
8.4.14.1. Проект клієнта
![]() |
Проєкт є проєктом Maven із таким файлом [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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-cors</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-cors</name>
<description>Client for webjson server</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- пошук батьківського елемента в репозиторії -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.Client</start-class>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- повертає MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
- рядки 14–19: це проект Spring Boot;
- рядки 29–32: використовується залежність [spring-boot-starter-web], яка містить сервер Tomcat та Spring MVC;
Сторінка HTML виглядає так:
![]() |
Вона генерується за допомогою такого коду:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Spring MVC</title>
<script type="text/javascript" src="/js/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="/js/client.js"></script>
</head>
<body>
<h2>Client du service web / jSON</h2>
<form id="formulaire">
<!-- метод HTTP -->
Méthode HTTP :
<!-- -->
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<!-- -->
<input type="radio" id="post" name="method" value="post" />POST
<!-- URL -->
<br /> <br />URL cible : <input type="text" id="url" size="30"><br />
<!-- значення, що вноситься -->
<br /> Chaîne jSON à poster : <input type="text" id="posted" size="50" />
<!-- кнопка підтвердження -->
<br /> <br /> <input type="submit" value="Valider" onclick="javascript:requestServer(); return false;"></input>
</form>
<hr />
<h2>Réponse du serveur</h2>
<div id="response"></div>
</body>
</html>
- рядок 6: імпортується бібліотека jQuery;
- рядок 7: імпортується код, який ми будемо писати;
Код [client.js] має такий вигляд:
// загальні дані
var url;
var posted;
var response;
var method;
function requestServer() {
// отримуємо інформацію з форми
var urlValue = url.val();
var postedValue = posted.val();
method = document.forms[0].elements['method'].value;
// виконується ручний виклик Ajax
if (method === "get") {
doGet(urlValue);
} else {
doPost(urlValue, postedValue);
}
}
function doGet(url) {
// виконується ручний виклик Ajax
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url,
type : 'GET',
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// текстовий результат
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// системна помилка
response.text(jqXHR.responseText);
}
})
}
function doPost(url, posted) {
// виконується ручний виклик Ajax
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url,
type : 'POST',
contentType : 'application/json',
data : posted,
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// текстовий результат
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// системна помилка
response.text(jqXHR.responseText);
}
})
}
// під час завантаження документа
$(document).ready(function() {
// отримано посилання на компоненти сторінки
url = $("#url");
posted = $("#posted");
response = $("#response");
});
Ми залишаємо читачеві можливість самостійно розібратися в цьому коді. Все це вже зустрічалося в той чи інший момент. Однак деякі рядки заслуговують на пояснення:
- рядок 11:
- [document] позначає документ, завантажений браузером, так звану DOM (Document Object Model),
- [document.forms[0]] позначає першу форму документа; документ може містити кілька форм. У цьому випадку є лише одна,
- [document.forms[0].elements['method']] позначає елемент форми, який має атрибут [name='method']. Їх є два:
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<input type="radio" id="post" name="method" value="post" />POST
- рядок 11:
- [document.forms[0].elements['method'].value] — це значення, яке буде відправлено для компонента, що має атрибут [name='method']. Відомо, що відправлене значення — це значення атрибута [value] радіо-кнопки, яка позначена. Отже, у цьому випадку це буде один із рядків ['get', 'post'];
- рядки 23–25: ми звертаємося до сервера, який вимагає заголовок HTTP [Authorization: Basic code]. Ми створюємо цей заголовок для користувача [admin / admin], який є єдиним, хто може надсилати запити до сервера;
- рядок 26: користувач введе URL типу [/getAllMedecins, /supprimerRv, ...]. Отже, потрібно доповнити ці URL;
- рядок 28: сервер повертає jSON, що є текстовим форматом. Вказується тип [text/plain] як тип результату, щоб відобразити його таким, яким він був отриманий;
- рядок 33: відображення текстової відповіді сервера;
- рядок 39: виведення можливого повідомлення про помилку у текстовому форматі;
- рядок 52: для вказівки, що клієнт надсилає jSON;
У створеному клієнтсько-серверному додатку:
- клієнт — це веб-додаток, доступний за адресою URL [http://localhost:8081]. Це той додаток, який ми зараз створюємо;
- сервер — це веб-додаток, доступний за адресами URL та [http://localhost:8080]. Це наш веб-сервер / jSON;
Оскільки клієнт не підключається через той самий порт, що й сервер, виникає проблема міждоменних запитів. [http://localhost:8080] та [http://localhost:8081] — це два різні домени.
Додаток Spring Boot — це консольний додаток, що запускається за допомогою такого виконуваного класу [Client]:
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
@EnableWebMvc
public class Client extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(Client.class, args);
}
// статичні сторінки
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations(new String[] { "classpath:/static/" });
}
// конфігурація dispatcherServlet
@Bean
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
// вбудований сервер Tomcat
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8081);
}
}
- рядок 14: клас [Client] є класом конфігурації Spring;
- рядок 15: налаштовується додаток Spring MVC. Ця анотація забезпечує низку автоматичних налаштувань;
- рядок 16: щоб перевизначити деякі значення за замовчуванням фреймворку Spring MVC, потрібно розширити клас [WebMvcConfigurerAdapter];
- рядки 23–26: метод [addResourceHandlers] дозволяє вказати папки, в яких знаходяться статичні ресурси (html, css, js тощо) додатка. Тут вказується папка [static], розташована в Classpath проекту:
![]() |
- рядки 29–37: налаштування біна [dispatcherServlet], який вказує на сервлет Spring MVC;
- рядки 40–43: вбудований сервер Tomcat працюватиме на порту 8081;
8.4.14.2. URL [/getAllMedecins]
Ми запускаємо:
- веб-сервер / json на порту 8080;
- клієнт цього сервера на порту 8081;
потім запитуємо URL [http://localhost:8081/client.html] [1]:
![]() |
- у [2], виконуємо GET на URL [http://localhost:8080/getAllMedecins];
Ми не отримуємо відповіді від сервера. При перегляді консолі розробника (Ctrl-Shift-I) виявляється помилка:
![]() |
- у [1], ми перебуваємо на вкладці [Network];
- У запиті [2] видно, що зроблений запит HTTP — це не [GET], а [OPTIONS]. У разі міждоменного запиту браузер перевіряє у сервера, чи виконано певну кількість умов, надсилаючи йому запит HTTP [OPTIONS]. У даному випадку запити позначені маркерами [5-6];
- у запиті [5] браузер запитує, чи можна дістатися до адресата URL за допомогою GET. Заголовок запиту [Access-Control-Request-Method] вимагає відповіді із заголовком HTTP [Access-Control-Allow-Methods], що вказує на те, що запитуваний метод прийнятий;
- у [5] браузер надсилає заголовок HTTP [Origin: http://localhost:8081]. Цей заголовок вимагає відповіді у вигляді заголовка HTTP [Access-Control-Allow-Origin], що вказує на те, що вказане джерело прийнято;
- у запиті [6] браузер запитує, чи прийнятні заголовки HTTP, [accept] та [authorization]. Заголовок запиту [Access-Control-Request-Headers] очікує відповіді із заголовками HTTP та [Access-Control-Allow-Headers], що вказують на те, що запитувані заголовки прийняті;
- у [3] виникає помилка. При натисканні на іконку з’являється помилка [4];
- у [4] повідомлення вказує, що сервер не надіслав заголовок HTTP [Access-Control-Allow-Origin], який вказує, чи прийнято джерело запиту;
- у [7] можна побачити, що сервер дійсно не надіслав цей заголовок. Як наслідок, браузер відмовився виконати запит HTTP GET, який було подано спочатку;
Нам потрібно внести зміни у веб-сервер / jSON. Спочатку ми вносимо зміну в [ApplicationModel], який є одним з елементів конфігурації веб-сервісу:
![]() |
@Component
public class ApplicationModel implements IMetier {
...
// дані конфігурації
private boolean corsAllowed = true;
private boolean secured = true;
...
public boolean isCorsAllowed() {
return corsAllowed;
}
- рядок 6: створюємо логічну змінну, яка вказує, чи приймаються клієнти, що не належать до домену сервера;
- рядки 10–12: метод доступу до цієї інформації;
Потім створюємо новий контролер Spring MVC:
![]() |
Клас [RdvMedecinsCorsController] має такий вигляд:
package rdvmedecins.web.controllers;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import rdvmedecins.web.models.ApplicationModel;
@Controller
public class RdvMedecinsCorsController {
@Autowired
private ApplicationModel application;
// надсилання параметрів клієнту
public void sendOptions(String origin, HttpServletResponse response) {
// CORS дозволено?
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// встановлюємо заголовок CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// дозволено певні заголовки
response.addHeader("Access-Control-Allow-Headers", "accept, authorization");
// дозволяється GET
response.addHeader("Access-Control-Allow-Methods", "GET");
}
// список лікарів
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.OPTIONS)
public void getAllMedecins(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
}
- рядки 12–13: клас [RdvMedecinsCorsController] є контролером Spring;
- рядки 33–36: визначають дію, що обробляє URL [/getAllMedecins], коли вона викликається командою HTTP [OPTIONS];
- рядок 34: метод [getAllMedecins] приймає як параметри:
- об'єкт [@RequestHeader(value = "Origin", required = false)], який отримає заголовок HTTP [Origin] запиту. Цей заголовок був надісланий відправником запиту:
Вказується, що заголовок HTTP [Origin] є необов’язковим [required = false]. У цьому випадку, якщо заголовок відсутній, параметр [String origin] матиме значення null. Оскільки [required = true] є значенням за замовчуванням, у разі відсутності заголовка генерується виняток. Ми прагнули уникнути такого випадку;
- рядок 34:
- об’єкт [HttpServletResponse response], який буде надіслано клієнту, що зробив запит;
Ці два параметри вводяться Spring;
- рядок 35: обробку запиту делегуємо методу, описаному в рядках 19–30;
- рядки 15–16: вводиться об’єкт [ApplicationModel];
- рядки 21–23: якщо додаток налаштовано на прийняття міждоменних запитів, і якщо відправник надіслав заголовок HTTP [Origin], і якщо це джерело починається з [http://localhost], то міждоменний запит буде прийнято, інакше — відхилено;
- рядок 25: якщо клієнт перебуває в домені [http://localhost:port], надсилається заголовок HTTP:
що означає, що сервер приймає домен клієнта;
- рядок 25: ми вказали два особливі заголовки HTTP у запиті HTTP [OPTIONS]:
На заголовок HTTP [Access-Control-Request-X] сервер відповідає заголовком HTTP [Access-Control-Allow-X], у якому вказує, що дозволено. У рядках 23–26 просто повторюється запит клієнта, щоб показати, що він прийнятий;
Тепер ми готові до нових тестів. Запускаємо нову версію веб-сервісу і виявляємо, що проблема залишається невирішеною. Нічого не змінилося. Якщо у рядку 35 вище розмістити вивід у консоль, він ніколи не відобразиться, що свідчить про те, що метод [getAllMedecins] із рядка 34 ніколи не викликається.
Після деяких досліджень ми з’ясували, що Spring MVC самостійно обробляє команди HTTP та [OPTIONS], застосовуючи обробку за замовчуванням. Тому відповідь завжди надає Spring, а не метод [getAllMedecins] у рядку 34. Цю поведінку Spring MVC за замовчуванням можна змінити. Ми модифікуємо існуючий клас [WebConfig]:
![]() |
package rdvmedecins.web.config;
...
import org.springframework.web.servlet.DispatcherServlet;
@Configuration
public class WebConfig {
// конфігурація dispatcherservlet для заголовків CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
// мапування jSON
...
- рядки 10–11: bean [dispatcherServlet] слугує для визначення сервлета, який обробляє запити клієнтів. Тут він має тип [DispatcherServlet] — це сервлет фреймворку Spring MVC;
- рядок 12: створюється екземпляр типу [DispatcherServlet];
- рядок 13: задається, щоб сервлет передавав до додатка команди HTTP та [OPTIONS];
- рядок 14: сервлет налаштовується таким чином;
Повторюємо тести з цією новою конфігурацією. Отримуємо такий результат:
![]() |
- у [1] ми бачимо, що є два запити HTTP до URL та [http://localhost:8080/getAllMedecins];
- у [2] — запит [OPTIONS];
- у [3] — три заголовки HTTP, які ми щойно налаштували у відповіді сервера;
Тепер розглянемо другий запит:
![]() |
- у [1] — це розглянутий запит;
- у [2] — це запит GET. Завдяки першому запиту [OPTIONS] браузер отримав інформацію, яку запитував. Тепер він виконує запит [GET], який запитувався спочатку;
- у запиті [3] — відповідь сервера;
- у [4] сервер надсилає jSON;
- у [5] сталася помилка;
- у [6] — повідомлення про помилку;
Тут складніше пояснити, що сталося. Відповідь сервера [3] є нормальною [HTTP/1.1 200 OK]. Отже, ми мали б отримати запитуваний документ. Можливо, сервер дійсно надіслав документ, але браузер не дозволяє ним скористатися, оскільки вимагає, щоб у відповіді на запит GET також містилися заголовки HTTP та [Access-Control-Allow-Origin:http://localhost:8081].
Змінюємо контролер [RdvMedecinsController] наступним чином:
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
...
// список лікарів
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins(HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// відповідь
Response<List<Medecin>> response;
// заголовки CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// стан додатка
...
- рядки 1–2: вводиться контролер [RdvMedecinsCorsController];
- рядки 7–8: у параметри методу [getAllMedecins] вводиться об’єкт HttpServletResponse, який інкапсулює відповідь, що буде надіслана клієнту, та заголовок HTTP [Origin];
- рядок 12: викликається метод [sendOptions] контролера [RdvMedecinsCorsController], той самий, що був викликаний для обробки запиту HTTP [OPTIONS]. Отже, він надішле ті самі заголовки HTTP, що й для цього запиту;
Після цієї зміни результати такі:
![]() |
Ми дійсно отримали список лікарів.
8.4.14.3. Інші запити URL та [GET]
Тепер покажемо інші запити URL, які були зроблені через GET. У контролерах код дій, що їх обробляють, відповідає шаблону дій, які раніше обробляли URL та [/getAllMedecins]. Читач може перевірити код у прикладах, що додаються до цього документа. Ось приклад:
у [RdvMedecinsCorsController]
// список прийомів лікаря
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.OPTIONS)
public void getRvMedecinJour(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
у [RdvMedecinsController]
// список прийомів лікаря
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour,
HttpServletResponse httpServletResponse, @RequestHeader(value = "Origin", required = false) String origin)
throws JsonProcessingException {
// відповідь
Response<List<Rv>> response = null;
boolean erreur = false;
// заголовки CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// стан додатка
...
А ось скріншоти виконання:
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
8.4.14.4. URL [POST]
Розглянемо такий випадок:
![]() |
- здійснюється перехід від POST [1] до URL [2];
- у [3] — значення, що відправляється. Це рядок jSON;
- загалом ми прагнемо видалити зустріч із значенням [id] 100;
Наразі ми не змінюємо жодного коду. Отриманий результат такий:
![]() |
- у [1], як і для запитів [GET], браузер надсилає запит [OPTIONS];
- у випадку [2] браузер запитує дозвіл на доступ для запиту [POST]. Раніше це був [GET];
- у [3] він запитує дозвіл на відправку заголовків HTTP та [accept, authorization, content-type]. Раніше були лише перші два заголовки;
Ми змінюємо метод [RdvMedecinsCorsController.sendOptions] наступним чином:
public void sendOptions(String origin, HttpServletResponse response) {
// Чи дозволено використання CORS?
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// встановлюємо заголовок CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// дозволено певні заголовки
response.addHeader("Access-Control-Allow-Headers", "accept, authorization, content-type");
// дозволено GET
response.addHeader("Access-Control-Allow-Methods", "GET, POST");
}
- рядок 9: додано заголовки HTTP та [Content-Type] (регістр не має значення);
- рядок 11: додано метод HTTP [POST];
Таким чином, методи [POST] обробляються так само, як і запити [GET]. Ось приклад для URL [/supprimerRv]:
у [RdvMedecinsController]
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post, HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// відповідь
Response<Void> response = null;
boolean erreur = false;
// заголовки CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// стан додатка
if (messages != null) {
...
у [RdvMedecinsCorsController]
@RequestMapping(value = "/supprimerRv", method = RequestMethod.OPTIONS)
public void supprimerRv(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
Отримано такий результат:
![]() |
Для URL [/ajouterRv] отримуємо такий результат:
![]() |
8.4.14.5. Conclusion
Наш додаток тепер підтримує міждоменні запити. Їх можна дозволити або заборонити шляхом налаштування у класі [ApplicationModel]:
// дані конфігурації
private boolean corsAllowed = false;
8.5. Програмований клієнт веб-сервісу / jSON
Повернемося до загальної архітектури додатка, який ми хочемо написати:
![]() |
Верхня частина схеми вже написана. Це веб-сервер / jSON. Тепер перейдемо до нижньої частини, а саме до її шару [DAO]. Ми напишемо цей шар, а потім протестуємо його за допомогою консольного клієнта. Архітектура тестування буде такою:
![]() |
8.5.1. Проєкт консольного клієнта
Проєкт STS консольного клієнта матиме такий вигляд:
![]() |
8.5.2. Конфігурація Maven
Файл [pom.xml] консольного клієнта має такий вигляд:
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-webjson-client-console</name>
<description>Client console du serveur web / jSON</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- пошук батьківського елемента в репозиторії -->
</parent>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- бібліотека jSON, що використовується Spring -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- компонент, що використовується Spring RestTemplate -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
</project>
- рядки 15–20: батьківський проект Spring Boot;
- рядки 24–27: консольний клієнт веб-сервера / jSON базується на компоненті під назвою [RestTemplate], що надається залежністю [spring-web];
- рядки 29–36: для серіалізації/десеріалізації об’єктів jSON потрібна бібліотека jSON. Ми використовуємо варіант бібліотеки Jackson, який застосовується у Spring Web;
- рядки 38–41: на найнижчому рівні компонент [RestTemplate] взаємодіє з сервером через сокети TCP/IP. Ми хочемо встановити для них значення [timeout], тобто максимальний час очікування відповіді від сервера. Компонент [RestTemplate] не дозволяє нам це зробити. Щоб це зробити, ми передамо конструктору [RestTemplate] компонент нижнього рівня, наданий залежністю [org.apache.httpcomponents.httpclient]. Саме ця залежність дозволить нам встановити [timeout] для зв’язку;
8.5.3. Пакет [rdvmedecins.client.entities]
![]() |
Пакет [rdvmedecins.client.entities] об’єднує всі сутності, які веб-сервіс / jSON надсилає через свої різні URL. Ми не будемо детально їх описувати ще раз. Зазначимо лише, що сутності JPA та [Client, Creneau, Medecin, Rv, Personne] були позбавлені всіх своїх анотацій JPA, а також анотацій jSON. Ось, наприклад, клас [Rv]:
package rdvmedecins.client.entities;
import java.util.Date;
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// день зустрічі
private Date jour;
// зустріч пов'язана з клієнтом
private Client client;
// зустріч пов’язана з часовим проміжком
private Creneau creneau;
// зовнішні ключі
private long idClient;
private long idCreneau;
// виробник за замовчуванням
public Rv() {
}
// з параметрами
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// методи getter та setter
...
}
8.5.4. Пакет [rdvmedecins.client.requests]
![]() |
Пакет [rdvmedecins.client.requests] об’єднує два класи, значення яких jSON надсилається до URL, [/ajouterRv] та [supprimerRv]. Вони ідентичні тим, що знаходяться на стороні сервера.
8.5.5. Пакет [rdvmedecins.client.responses]
![]() |
[Response] є типом усіх відповідей веб-сервісу / jSON. Це загальний тип:
package rdvmedecins.client.responses;
import java.util.List;
public class Response<T> {
// ----------------- властивості
// статус операції
private int status;
// можливі повідомлення про помилки
private List<String> messages;
// тіло відповіді
private T body;
// конструктори
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// методи getter та setter
...
}
- рядок 5: тип [T] залежить від URL веб-сервісу / jSON;
8.5.6. Пакет [rdvmedecins.client.dao]
![]() |
- [IDao] є інтерфейсом рівня [DAO], а [Dao] — його реалізацією. Ми ще повернемося до цієї реалізації;
8.5.7. Пакет [rdvmedecins.client.config]
![]() |
Клас [DaoConfig] налаштовує додаток. Його код такий:
package rdvmedecins.client.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@ComponentScan({ "rdvmedecins.client.dao" })
public class DaoConfig {
@Bean
public RestTemplate restTemplate() {
// створення компонента RestTemplate
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
RestTemplate restTemplate = new RestTemplate(factory);
// результат
return restTemplate;
}
// маппери jSON
@Bean
public ObjectMapper jsonMapper(){
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",
creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- рядок 13: клас [DaoConfig] є класом конфігурації Spring;
- рядок 14: пакет [rdvmedecins.client.dao] буде проскановано на наявність компонентів Spring. У ньому буде знайдено компонент [Dao];
- рядки 17–24: визначають синглтон Spring із назвою [restTemplate] (назва методу). Цей метод повертає екземпляр [RestTemplate], який є базовим інструментом, що надається Spring для взаємодії з веб-сервісом / jSON;
- рядок 21: можна було б написати [RestTemplate restTemplate = new RestTemplate() ;]. У більшості випадків цього достатньо. Але тут ми хочемо встановити [timeout] клієнта. Для цього ми вводимо в компонент [RestTemplate] низькорівневий компонент типу [HttpComponentsClientHttpRequestFactory] (рядок 20), який дозволить нам встановити ці [timeout]. Необхідна залежність Maven вже була представлена;
- рядки 28–57: визначають маппери jSON. Це ті самі маппери jSON, які використовуються на стороні сервера (див. параграф 8.4.11.3) для серіалізації типу T у відповіді [Response<T>]. Ці самі конвертери тепер будуть використовуватися на стороні клієнта для десеріалізації типу T;
8.5.8. Інтерфейс [IDao]
Повернемося до архітектури додатка:
![]() |
Рівень [DAO] є адаптером між рівнем [console] та рівнями URL, що надаються веб-сервісом / jSON. Його інтерфейс [IDao] матиме такий вигляд:
package rdvmedecins.client.dao;
import java.util.List;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
public interface IDao {
// URL веб-сервісу
public void setUrlServiceWebJson(String url);
// час очікування
public void setTimeout(int timeout);
// аутентифікація
public void authenticate(User user);
// список клієнтів
public List<Client> getAllClients(User user);
// список лікарів
public List<Medecin> getAllMedecins(User user);
// список часових проміжків лікаря
public List<Creneau> getAllCreneaux(User user, long idMedecin);
// пошук клієнта за його ідентифікатором
public Client getClientById(User user, long id);
// пошук клієнта за його ідентифікатором
public Medecin getMedecinById(User user, long id);
// пошук прийому за ідентифікатором
public Rv getRvById(User user, long id);
// знайти час прийому за його ідентифікатором
public Creneau getCreneauById(User user, long id);
// додати RV
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient);
// видалити RV
public void supprimerRv(User user, long idRv);
// перелік прийомів лікаря на певний день
public List<Rv> getRvMedecinJour(User user, long idMedecin, String jour);
// календар
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
}
- рядок 14: метод, що дозволяє встановити кореневий URL веб-сервісу / jSON, наприклад [http://localhost:8080];
- рядок 17: метод, що дозволяє встановити [timeout] на стороні клієнта. Цей параметр потрібно контролювати, оскільки деякі клієнти HTTP іноді дуже довго чекають на відповідь, яка так і не надійде;
- рядок 20: метод, що дозволяє ідентифікувати користувача [login, passwd]. Викликає виняток, якщо користувач не розпізнається;
- рядки 22–53: кожному URL, що надається веб-сервісом / jSON, відповідає метод інтерфейсу, сигнатура якого випливає із сигнатури серверного методу, що обробляє наданий URL. Візьмемо, наприклад, такий серверний URL:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Response<String> getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
- рядок 1: бачимо, що [idMedecin] та [jour] є параметрами URL. Це будуть вхідні параметри методу, пов’язаного з цим URL на стороні клієнта;
- рядок 2: бачимо, що серверний метод повертає тип [Response<String>]. Цей тип [String] є типом значення jSON типу [AgendaMedecinJour]. Тип результату методу, пов’язаного з цим URL на стороні клієнта, буде [AgendaMedecinJour];
На стороні клієнта оголошується такий метод:
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
Цей підпис підходить, коли сервер надсилає відповідь [int status, List<String> messages, String body] із [status==0]. У цьому випадку ми маємо [messages==null && body!=null]. Він не підходить, коли [status!=0]. У цьому випадку ми отримуємо [messages!=null && body==null]. Нам потрібно якимось чином повідомити, що сталася помилка. Для цього ми викличемо виняток типу [RdvMedecinsException] наступним чином:
package rdvmedecins.client.dao;
import java.util.List;
public class RdvMedecinsException extends RuntimeException {
private static final long serialVersionUID = 1L;
// код помилки
private int status;
// список повідомлень про помилки
private List<String> messages;
public RdvMedecinsException() {
}
public RdvMedecinsException(int code, List<String> messages) {
super();
this.status = code;
this.messages = messages;
}
// методи getter та setter
...
}
- рядки 9 та 11: виняток перейме значення полів [status, messages] з об’єкта [Response<T>], надісланого сервером;
- рядок 5: клас [RdvMedecinsException] є похідним від класу [RuntimeException]. Отже, це неконтрольоване виключення, тобто немає обов’язку обробляти його за допомогою блоку try/catch та оголошувати його у сигнатурі методів інтерфейсу;
Крім того, усі методи інтерфейсу [IDao], які звертаються до веб-сервісу / jSON, мають у якості параметра наступний тип [User]:
package rdvmedecins.client.entities;
public class User {
// дані
private String login;
private String passwd;
// конструктори
public User() {
}
public User(String login, String passwd) {
this.login = login;
this.passwd = passwd;
}
// методи getter та setter
...
}
Дійсно, кожен обмін даними з веб-сервісом / jSON повинен супроводжуватися заголовком автентифікації HTTP.
8.5.9. Пакет [rdvmedecins.clients.console]
Тепер, коли ми ознайомилися з інтерфейсом рівня [DAO], можемо представити консольний додаток.
![]() |
Клас [Main] має такий вигляд:
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Main {
// серіалізатор jSON
static private ObjectMapper mapper = new ObjectMapper();
// тайм-аут з'єднань у мілісекундах
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// отримуємо посилання на рівні [DAO]
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// встановлюється URL веб-сервісу / json
dao.setUrlServiceWebJson("http://localhost:8080");
// встановлюються тайм-аути в мілісекундах
dao.setTimeout(TIMEOUT);
// Аутентифікація
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,x]";
try {
dao.authenticate(new User("user", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [x,x]";
try {
dao.authenticate(new User("x", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// список клієнтів
message = "/getAllClients";
try {
showResponse(message, dao.getAllClients(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// список лікарів
message = "/getAllMedecins";
try {
showResponse(message, dao.getAllMedecins(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// список вільних часів лікаря 2
message = "/getAllCreneaux/2";
try {
showResponse(message, dao.getAllCreneaux(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// пацієнт № 1
message = "/getClientById/1";
try {
showResponse(message, dao.getClientById(new User("admin", "admin"), 1L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// лікар № 2
message = "/getMedecinById/2";
try {
showResponse(message, dao.getMedecinById(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// час прийому № 3
message = "/getCreneauById/3";
try {
showResponse(message, dao.getCreneauById(new User("admin", "admin"), 3L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// прийом № 4
message = "/getRvById/4";
try {
showResponse(message, dao.getRvById(new User("admin", "admin"), 4L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// додавання зустрічі
message = "/AjouterRv [idClient=4,idCreneau=8,jour=2015-01-08]";
long idRv = 0;
try {
Rv response = dao.ajouterRv(new User("admin", "admin"), "2015-01-08", 8L, 4L);
idRv = response.getId();
showResponse(message, response);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// список зустрічей лікаря 1 за 08.01.2015
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// розклад лікаря 1 на 08.01.2015
message = "/getAgendaMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getAgendaMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// видалення доданого прийому
message = String.format("/supprimerRv [idRv=%s]", idRv);
try {
dao.supprimerRv(new User("admin", "admin"), idRv);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// список прийомів лікаря 1 від 08.01.2015
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// закриття контексту
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
private static <T> void showResponse(String message, T response) throws JsonProcessingException {
System.out.println(String.format("URL [%s]", message));
System.out.println(mapper.writeValueAsString(response));
}
}
- рядок 19: серіалізатор jSON, який дозволить нам відобразити відповідь сервера, рядок 184;
- рядок 25: компонент [AnnotationConfigApplicationContext] — це компонент Spring, здатний використовувати анотації конфігурації Spring-додатка. Ми передаємо в його конструктор клас [AppConfig], який налаштовує додаток;
- рядок 26: отримуємо посилання на рівень [DAO];
- рядки 27–30: налаштовуємо її;
- рядки 32–169: тестуємо всі методи інтерфейсу [IDao];
Отримано такі результати:
09:20:56.935 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
/authenticate [admin,admin] : OK
URL [/authenticate [user,user]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [user,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/authenticate [x,x]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [admin,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/getAllClients]
[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]
URL [/getAllMedecins]
[{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"},{"id":3,"version":1,"titre":"Mr","nom":"JANDOT","prenom":"Philippe"},{"id":4,"version":1,"titre":"Melle","nom":"JACQUEMOT","prenom":"Justine"}]
URL [/getAllCreneaux/2]
[{"id":25,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":2},{"id":26,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":2},{"id":27,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":2},{"id":28,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":2},{"id":29,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":2},{"id":30,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":2},{"id":31,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":2},{"id":32,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":2},{"id":33,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":2},{"id":34,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":2},{"id":35,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":2},{"id":36,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":2}]
URL [/getClientById/1]
{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"}
URL [/getMedecinById/2]
{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"}
URL [/getCreneauById/3]
{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1}
URL [/getRvById/4]
L'erreur n° [2] s'est produite :
Le rendez-vous d'id [4] n'existe pas
URL [/ajouterRv [idClient=4,idCreneau=8,jour=2015-01-08]]
{"id":144,"version":0,"jour":1420671600000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":0,"idCreneau":0}
URL [/getRvMedecinJour/1/2015-01-08]
[{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}]
URL [/getAgendaMedecinJour/1/2015-01-08]
{"medecin":{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},"jour":1420671600000,"creneauxMedecinJour":[{"creneau":{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":2,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":4,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":5,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":6,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":7,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"rv":{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}},{"creneau":{"id":9,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":10,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":11,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":12,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":13,"version":1,"hdebut":14,"mdebut":0,"hfin":14,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":14,"version":1,"hdebut":14,"mdebut":20,"hfin":14,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":15,"version":1,"hdebut":14,"mdebut":40,"hfin":15,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":16,"version":1,"hdebut":15,"mdebut":0,"hfin":15,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":17,"version":1,"hdebut":15,"mdebut":20,"hfin":15,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":18,"version":1,"hdebut":15,"mdebut":40,"hfin":16,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":19,"version":1,"hdebut":16,"mdebut":0,"hfin":16,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":20,"version":1,"hdebut":16,"mdebut":20,"hfin":16,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":21,"version":1,"hdebut":16,"mdebut":40,"hfin":17,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":22,"version":1,"hdebut":17,"mdebut":0,"hfin":17,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":23,"version":1,"hdebut":17,"mdebut":20,"hfin":17,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":24,"version":1,"hdebut":17,"mdebut":40,"hfin":18,"mfin":0,"medecin":null,"idMedecin":1},"rv":null}]}
URL [/getRvMedecinJour/1/2015-01-08]
[]
09:21:00.258 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
Ми залишаємо читачеві можливість самостійно зіставити результати з кодом. Цей код демонструє, як викликати кожен метод шару [DAO]. Зазначимо лише кілька моментів:
- рядки 2–14: показують, що в разі помилки автентифікації сервер повертає статус HTTP, [403 Forbidden] або [401 Unauthorized] залежно від випадку;
- рядки 30–31: до лікаря № 1 додається запис про зустріч (Rv);
- рядки 32–33: бачимо цей прийом. Це єдиний прийом на цей день;
- рядки 34–35: його також видно в календарі лікаря;
- рядки 36–37: зустріч зникла. Код тим часом її видалив;
Журнали консолі контролюються такими файлами:
![]() |
[application.properties]
logging.level.org.springframework.web=OFF
logging.level.org.hibernate=OFF
spring.main.show-banner=false
logging.level.httpclient.wire=OFF
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- кодерам за замовчуванням присвоєно тип ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- контроль рівня журналів -->
<root level="info"> <!-- вимкнено, інформація, налагодження, попередження -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
8.5.10. Реалізація шару [DAO]
Тепер нам залишається представити серцевину шару [DAO] — реалізацію його інтерфейсу [IDao]. Ми зробимо це поетапно.
![]() |
Інтерфейс [IDao] реалізовано абстрактним класом [AbstractDao] та його дочірнім класом [Dao].
Базовий клас [AbstractDao] має такий вигляд:
package rdvmedecins.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import rdvmedecins.client.entities.User;
public abstract class AbstractDao implements IDao {
// дані
@Autowired
protected RestTemplate restTemplate;
protected String urlServiceWebJson;
// URL веб-сервіс / jSON
public void setUrlServiceWebJson(String url) {
this.urlServiceWebJson = url;
}
public void setTimeout(int timeout) {
// встановлення тайм-ауту для запитів веб-клієнта
HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) restTemplate
.getRequestFactory();
factory.setConnectTimeout(timeout);
factory.setReadTimeout(timeout);
}
private String getBase64(User user) {
// кодуємо ім'я користувача та його пароль у форматі Base64 — потрібно
// Java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// загальний запит
protected String getResponse(User user, String url, String jsonPost) {
...
}
}
- рядок 20: клас є абстрактним, що не дозволяє нам позначити його як компонент Spring. Таким компонентом буде позначений його дочірній клас;
- рядки 23–24: ми вводимо бін [restTemplate], який ми визначили у класі конфігурації [AppConfig];
- рядок 25: кореневий URL веб-сервісу / jSON;
- рядки 32–38: встановлюють час очікування клієнта під час отримання відповіді від сервера;
- рядок 34: отримуємо компонент [HttpComponentsClientHttpRequestFactory], який ми вставили в bean [restTemplate] під час його створення (див. [AppConfig]);
- рядок 36: встановлюємо максимальний час очікування клієнта під час встановлення з’єднання з сервером;
- рядок 37: встановлюємо максимальний час очікування клієнта під час очікування відповіді на один із його запитів;
Реалізація методів зв’язку з сервером буде винесена в наступний узагальнений метод:
// загальний запит
protected String getResponse(User user, String url, String jsonPost) {
...
}
- рядок 2: параметри [getResponse] такі:
- [User user] — користувач, який здійснює підключення;
- [String url]: URL, до якого слід звернутися. Це кінець URL, перша частина якого надається полем [urlServiceWebJson] класу,
- [String jsonPost]: рядок jSON, який потрібно відправити. Якщо це значення присутнє, то URL буде запитуватися разом із POST, інакше — разом із GET;
Продовжимо:
// загальний запит
protected String getResponse(User user, String url, String jsonPost) {
// URL: URL для зв’язку
// jsonPost: значення jSON для відправлення
try {
// виконання запиту
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url))).accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// запит виконується
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e) {
throw new RdvMedecinsException(20, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(21, getMessagesForException(e));
}
}
- рядки 23–24: інструкція, яка надсилає запит до сервера та отримує відповідь. Компонент [RestTemplate] пропонує значну кількість методів взаємодії з сервером. Можна було б вибрати інший метод, ніж [exchange]. Другий параметр виклику визначає тип очікуваної відповіді, в даному випадку — рядок jSON. Перший параметр — це запит типу [RequestEntity] (рядок 7). Результат методу [exchange] має тип [ResponseEntity<String>]. Тип [ResponseEntity] інкапсулює повну відповідь сервера, заголовки HTTP та документ, надісланий сервером. Аналогічно, тип [RequestEntity] інкапсулює весь запит клієнта, включаючи заголовки HTTP та можливе значення, надіслане методом POST;
- рядок 23: це тіло об’єкта [ResponseEntity<String>], яке повертається викликаючому методу, тобто рядок jSON, надісланий сервером;
- рядки 9–21: нам потрібно побудувати запит типу [RequestEntity]. Він відрізняється залежно від того, чи використовується для формування запиту GET чи POST;
- рядок 9: запит для GET. Клас [RequestEntity] надає статичні методи для створення запитів GET, POST, HEAD,... Метод [RequestEntity.get] дозволяє створити запит GET шляхом ланцюгового виклику різних методів, що формують цей запит:
- метод [RequestEntity.get] приймає як параметр цільовий URL у вигляді екземпляра URI,
- метод [accept] дозволяє визначити елементи заголовка HTTP [Accept]. Тут ми вказуємо, що приймаємо тип [application/json], який надішле сервер;
- результатом цього ланцюжка методів є тип [HeadersBuilder];
- рядки 10–12: у разі, якщо параметр [User user] не дорівнює null, у запит включається заголовок HTTP [Authorization];
- рядок 13: метод [HeadersBuilder.build] використовує цю інформацію для формування типу запиту [RequestEntity];
- рядок 15: запит на POST. Метод [RequestEntity.post] дозволяє створити запит POST шляхом послідовного виклику різних методів, що формують його:
- метод [RequestEntity.post] приймає як параметр цільовий URL у вигляді екземпляра URI,
- метод [header] дозволяє визначити заголовки HTTP, які потрібно використовувати, в даному випадку — заголовок авторизації,
- наступний метод [header] включає в запит заголовок [Content-Type: application/json], щоб вказати, що відправлене значення надійде у вигляді рядка jSON;
- метод [accept] дозволяє вказати, що ми приймаємо тип [application/json], який надішле сервер;
- рядки 17–19: у разі, якщо параметр [User user] не дорівнює null, у запит включається заголовок HTTP [Authorization];
- рядок 20: метод [BodyBuilder.body] встановлює значення, що передається. Це значення є другим параметром загального методу [getResponse] (рядок 2);
- рядки 25–28: у разі виникнення будь-якої помилки генерується виняток типу [RdvMedecinsException];
Метод [getMessagesForException] у рядках 26 і 28 має такий вигляд:
// список повідомлень про помилки винятку
protected static List<String> getMessagesForException(Exception exception) {
// отримано список повідомлень про помилки винятку
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
// отримано повідомлення, лише якщо воно !=null і не порожнє
String message = cause.getMessage();
if (message != null) {
message = message.trim();
if (message.length() != 0) {
erreurs.add(message);
}
}
// наступна причина
cause = cause.getCause();
}
return erreurs;
}
Приватний метод [getBase64] повертає Base64-код рядка 'login:passwd' для заголовка автентифікації HTTP:
private String getBase64(User user) {
// кодування імені користувача та його пароля у форматі Base64 — потрібно Java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
Клас [Dao] розширює клас [AbstractDao] наступним чином:
package rdvmedecins.client.dao;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import rdvmedecins.client.requests.PostAjouterRv;
import rdvmedecins.client.requests.PostSupprimerRv;
import rdvmedecins.client.responses.Response;
@Service
public class Dao extends AbstractDao implements IDao {
// маппери jSON
@Autowired
ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
public List<Client> getAllClients(User user) {
...
}
public List<Medecin> getAllMedecins(User user) {
...
}
...
}
- рядок 22: клас [Dao] є компонентом Spring. Тут використано анотацію [@Service]. Можна було б і надалі використовувати анотацію [@Component], яка застосовувалася до цього моменту;
- рядки 26–36: ін’єкція чотирьох маперів jSON, визначених у класі конфігурації [DaoConfig];
Усі методи класу [Dao] побудовані за однаковою схемою. Ми детально розглянемо операцію GET та операцію POST.
Спочатку розглянемо запит [GET]:
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour) {
// відповідь
Response<AgendaMedecinJour> response;
// календар
String jsonResponse = getResponse(user, String.format("%s/%s/%s", "/getAgendaMedecinJour", idMedecin, jour), null);
try {
// календар AgendaMedecinJour
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<AgendaMedecinJour>>() {
});
} catch (IOException e) {
throw new RdvMedecinsException(401, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(402, getMessagesForException(e));
}
// аналіз відповіді
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- рядок 5: викликається загальний метод [getResponse]. Фактичні параметри, що використовуються, такі:
- 1: користувач;
- 2: ціль URL;
- 3: значення, яке потрібно відправити. У цьому випадку його немає;
- рядок 5: виклик не був обгорнутий блоком try / catch. Метод [getResponse] може викликати виняток типу [RdvMedecinsException]. Якщо такий виняток виникне, він буде переданий до методу, який викликав вищезазначений метод [getAgendaMedecinJour];
- рядок 8:URL [/getAgendaMedecinJour] надсилає тип [Response<AgendaMedecinJour>], який був серіалізований на стороні сервера в jSON за допомогою маппера jSON [jsonMapperLongRv]. Цей самий маппер використовується для десеріалізації отриманого рядка jSON;
- рядки 10–13: якщо у рядку 9 виникає помилка, запускається тип [RdvMedecinsException];
- рядки 16–21: аналізується відповідь, надіслана сервером;
- рядки 17–18: якщо сервер повідомив про помилку, то генерується виняток з інформацією, переданою сервером;
- рядки 19–21: в іншому випадку повертається розклад лікаря;
Розглянутий запит POST матиме такий вигляд:
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
// відповідь
Response<Rv> response;
try {
// зустріч
String jsonResponse = getResponse(user, "/ajouterRv",
jsonMapper.writeValueAsString(new PostAjouterRv(idClient, idCreneau, jour)));
// Rv Rv
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<Rv>>() {
});
} catch (RdvMedecinsException e) {
throw e;
} catch (IOException e) {
throw new RdvMedecinsException(381, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(382, getMessagesForException(e));
}
// аналіз відповіді
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- рядок 6: викликається метод [getResponse] із такими параметрами:
- 1: користувач;
- 2: цільовий URL,
- 3: значення, що передається: передається значення jSON типу [PostAjouter], сформоване на основі інформації, отриманої методом у якості параметрів. Використовується мапер jSON без фільтрів;
- рядок 9: на стороні сервера саме маппер jSON [jsonMapperLongRv] серіалізував відповідь сервера. На стороні клієнта цей самий маппер використовується для її десеріалізації;
- рядок 6: URL [/ajouterRv] повертає значення jSON типу [Response<Rv>];
- рядки 4–11: тут метод [getResponse] поміщено в блок try/catch, оскільки серіалізація надісланого значення може спричинити виняток. Метод [getResponse] може викликати виняток [RdvMedecinsException]. У цьому випадку його просто запускають знову (рядки 11–12);
Наступний код (рядки 13–24) аналогічний тому, що щойно розглядався. Отже, єдина відмінність від операції GET полягає у другому параметрі методу [getResponse], який повинен дорівнювати значенню jSON значення, що підлягає відправленню.
Інші методи побудовані за тим самим зразком.
8.5.11. Аномалія
Під час проведення різних тестів було виявлено аномалію, описану в наступному класі [Anomalie]:
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Anomalie {
// серіалізатор jSON
static private ObjectMapper mapper = new ObjectMapper();
// тайм-аут з'єднань у мілісекундах
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// отримано посилання на рівні [DAO]
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// встановлюється URL веб-сервісу / json
dao.setUrlServiceWebJson("http://localhost:8080");
// встановлюються часові ліміти у мілісекундах
dao.setTimeout(TIMEOUT);
// Аутентифікація
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// Аутентифікація
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// Аутентифікація
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// закриття контексту
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
}
- рядки 31–38: відбувається аутентифікація користувача [admin, admin];
- рядки 40–47: відбувається аутентифікація користувача [admin, x], який, отже, ввів неправильний пароль;
- рядки 49–56: аутентифікація користувача [user, user], який є існуючим, але неавторизованим користувачем;
Ось результати:
- рядок 2: всупереч очікуванням, користувач [admin, x] був прийнятий;
Якщо вивести рядки 33–38 коду в коментарі, отримаємо такий результат:
що є очікуваним результатом. Складається враження, ніби після того, як користувач [admin, admin] успішно авторизувався вперше, його пароль більше не був потрібний для наступних входів. Це дійсно так. Spring Security за замовчуванням використовує сесію, завдяки якій, після того як користувач пройшов автентифікацію, йому більше не потрібно повторювати її в наступних запитах. Можна змінити конфігурацію [Spring Security] на веб-сервері / jSON, щоб цього не відбувалося:
![]() |
Файл [SecurityConfig] слід змінити наступним чином:
@Override
protected void configure(HttpSecurity http) throws Exception {
...
// сесія відсутня
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
- у рядку 5 вказано, що сесія безпеки не потрібна;
Це вирішило проблему з аномалією.
8.6. Код сервера Spring / Thymeleaf
8.6.1. Вступ
Повернемося до архітектури клієнт-серверного додатка, який ми будемо створювати:
![]() |
- був побудований веб-сервер [Web2] / jSON;
- було створено клієнтський рівень [DAO] для клієнта [Web1];
Відносини між сервером [Web1] та клієнтськими браузерами є відносинами «клієнт-сервер», де сервер є веб-сервером / jSON. Фактично, [Web1] буде передавати потоки HTML, інкапсульовані в ланцюжок jSON. Архітектура «клієнт-сервер» виглядає наступним чином:
![]() |
- ми маємо архітектуру «клієнт» [2] / «сервер» [1], де клієнт і сервер взаємодіють за допомогою jSON;
- у [1] веб-шар Spring MVC / Thymeleaf видає представлення, фрагменти представлень та дані у форматі jSON. Отже, сервер є веб-сервером / jSON, як і сервер [Web1]. Він також є безстатусним;
- у [2]: код JavaScript, вбудований у вигляд, що завантажується під час запуску додатка, структурований за рівнями:
- шар [présentation] відповідає за взаємодію з користувачем,
- шар [DAO] відповідає за доступ до даних через сервер [Web2];
- клієнт [2] буде кешувати певні види, щоб зменшити навантаження на сервер;
Ми будемо створювати веб-сервер / jSON [Web1], реалізований за допомогою Spring MVC / Thymeleaf, у кілька етапів:
- ознайомлення з фреймворком CSS Bootstrap;
- написання представлених;
- написання контролера;
Потім, окремо, ми створимо клієнт JS для сервера [Web1]. Щоб наочно продемонструвати, що цей клієнт має певну незалежність від сервера [Web1], ми створимо його за допомогою інструменту [Webstorm], а не STS.
Далі деякі деталі будуть проігноровані, оскільки вони можуть відволікти нас від головного — організації коду. Зацікавлений читач зможе знайти повний код на сайті цього документа.
8.6.2. Проєкт STS
![]() |
- у [1] — коди Java;
- у [2] — представлення;
Конфігурація Maven у [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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-springthymeleaf-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-springthymeleaf-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
<properties>
<start-class>rdvmedecins.springthymeleaf.server.boot.Boot</start-class>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.7</java.version>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
...
</project>
- рядки 16–19: проект є проектом Thymeleaf;
- рядки 20–24: який базується на шарі [DAO], який ми щойно створили;
Конфігурація Java забезпечується двома файлами:
![]() |
Рівень [web] налаштовується за допомогою такого файлу [WebConfig]:
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.SpringTemplateEngine;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
public class WebConfig extends WebMvcConfigurerAdapter {
// ----------------- налаштування рівня [web]
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("i18n/messages");
return messageSource;
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
@Bean
SpringTemplateEngine templateEngine(SpringResourceTemplateResolver templateResolver) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
return templateEngine;
}
// конфігурація dispatcherservlet для заголовків CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
}
Ми вже стикалися, в той чи інший момент, з усіма елементами цієї конфігурації. Нагадаємо лише, що рядки 42–47 необхідні, коли потрібно надсилати на сервер міждоменні запити (CORS). Саме це ми й зробимо в даному випадку.
Клас [AppConfig] налаштовує весь додаток:
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.client.config.DaoConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// кореневий веб-сервіс / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// тайм-аут у мілісекундах
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
}
- рядок 11: [AppConfig] імпортує конфігурацію рівня [DAO] та рівня [web];
- рядки 15–16: ідентифікатори, які дозволять додатку отримати доступ до завантаження додатка для кешування лікарів та клієнтів;
- рядок 18: URL веб-сервісу / jSON [Web1];
- рядок 20: timeout для викликів HTTP з боку додатка;
- рядок 22: логічне значення для дозволу або заборони міждоменних викликів;
Нарешті, у [application.properties] сервер Tomcat налаштований на роботу на порту 8081:
![]() |
server.port=8081
8.6.3. Функціональні можливості додатка
Вони були описані в розділі 8.2. Тепер нагадаємо їх ще раз. За допомогою браузера надсилається запит на URL [http://localhost:8081/boot.html]:
![]() |
- на [1] — сторінку входу в додаток;
- в [2] та [3] — ім’я користувача та пароль того, хто хоче скористатися додатком. Є два користувачі: admin/admin (логін/пароль) з роллю (ADMIN) та user/user з роллю (USER). Лише роль ADMIN має право користуватися додатком. Роль USER наведено лише для того, щоб показати, що відповідає сервер у цьому випадку використання;
- у [4] — кнопка, що дозволяє підключитися до сервера;
- у [5] — мова додатка. Їх дві: французька (за замовчуванням) та англійська;
- у [6] — URL сервера [rdvmedecins-springthymeleaf-server];
![]() |
- у [1] здійснюється вхід;
![]() |
- після входу можна вибрати лікаря, до якого хочете записатися на прийом [2], та день прийому [3]. Як тільки вказано лікаря та день, автоматично відображається календар:
![]() |
- отримавши розклад лікаря, можна забронювати час прийому [5];
![]() |
- у [6] вибирають пацієнта для прийому та підтверджують цей вибір у [7];
![]() |
Після підтвердження прийому система автоматично повертає до календаря, де новий прийом тепер занесено. Цей прийом можна буде пізніше видалити за допомогою коду [8].
Основні функції описано. Вони прості. На завершення розглянемо налаштування мови:
![]() |
- у [1] можна перейти з французької на англійську;
![]() |
- у [2] інтерфейс переходить на англійську, включаючи календар;
8.6.4. Крок 1: вступ до фреймворку CSS Bootstrap
![]() |
У наведеному вище веб-клієнті сторінки HTML використовуватимуть фреймворк CSS Bootstrap [http://getbootstrap.com/], який ми зараз презентуємо.
8.6.4.1. Проект прикладів
Структура прикладів буде такою:
![]() |
- у [1] — проект у цілому;
- у [2] — Java-коди;
- в [3]: скрипти JavaScript;
![]() |
- у [4]: бібліотеки JavaScript;
- у [5]: шаблони Thymeleaf;
- у [6]: таблиці стилів;
8.6.4.1.1. Налаштування Maven
Файл [pom.xml] — це файл проекту Maven Thymeleaf:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-bootstrap</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-bootstrap</name>
<description>Démos Bootstrap</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath /> <!-- пошук батьківського елемента в репозиторії -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.BootstrapDemo</start-class>
<java.version>1.7</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
8.6.4.1.2. Конфігурація Java
![]() |
Клас [BootstrapDemo] налаштовує додаток Spring / Thymeleaf:
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
@ComponentScan({ "istia.st.rdvmedecins" })
public class BootstrapDemo extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(BootstrapDemo.class, args);
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
}
Ми вже зустрічали цей тип коду.
8.6.4.1.3. Контролер Spring
![]() |
Контролер [BootstrapController] має такий вигляд:
package istia.st.rdvmedecins;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class BootstrapController {
@RequestMapping(value = "/bs-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bso1() {
return "bs-01";
}
@RequestMapping(value = "/bs-02", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs02() {
return "bs-02";
}
@RequestMapping(value = "/bs-03", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs03() {
return "bs-03";
}
@RequestMapping(value = "/bs-04", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs04() {
return "bs-04";
}
@RequestMapping(value = "/bs-05", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs05() {
return "bs-05";
}
@RequestMapping(value = "/bs-06", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs06() {
return "bs-06";
}
@RequestMapping(value = "/bs-07", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs07() {
return "bs-07";
}
@RequestMapping(value = "/bs-08", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs08() {
return "bs-08";
}
}
Ці дії призначені лише для відображення подання, оброблені Thymeleaf.
8.6.4.1.4. Файл [application.properties]
Файл [application.properties] налаштовує вбудований сервер Tomcat:
server.port=8082
8.6.4.2. Приклад № 1: джамботрон
Дія [/bs-01] відображає наступний вигляд [bs-01.xml]:
![]() |
Вигляд [bs-01.xml] виглядає так:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- ядро Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
</head>
<body id="body">
<div class="container">
<!-- Jumbotron Bootstrap -->
<div th:include="jumbotron"></div>
<!-- вміст -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- помилка -->
<div id="erreur" class="alert alert-danger">
<span>Ici, un texte d'erreur</span>
</div>
</div>
</body>
</html>
- рядок 7: файл CSS фреймворку Bootstrap;
- рядок 8: локальний файл CSS;
- рядок 13: відображає [1];
- рядки 19–21: відображають [2];
- рядок 11: клас CSS [container] визначає область відображення всередині браузера;
- рядок 19: клас CSS [alert] відображає кольорову зону. Клас [alert-danger] використовує попередньо визначений колір. Існує кілька таких класів, наприклад [alert-info, alert-warning,...];
Джамботрон [1] генерується наступним видом [jumbotron.xml]:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1>
Les Médecins
<br />
associés
</h1>
</div>
</div>
</div>
</section>
- рядок 4: область має клас CSS [jumbotron];
- рядок 5: клас [row] визначає рядок із 12 стовпцями;
- рядок 6: клас [col-md-2] визначає область із двома стовпцями в цьому рядку;
- рядок 7: у ці два стовпці вставляється зображення;
- рядки 9–15: у решту 10 стовпців розміщується текст;
8.6.4.3. Приклад № 2: панель навігації
Дія [/bs-02] відображає такий вигляд [bs-02.xml]:
![]() |
Новинкою є панель навігації [1] з формою введення даних та кнопками:
Вигляд [bs-02.xml] такий:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- скрипти JS -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/js/bs-02.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar1"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- вміст -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- інформація -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 10: імпортується jQuery;
- рядок 11: локальний скрипт JS;
- рядок 16: панель навігації;
Панель навігації генерується наступним поданням [navbar1.xml]:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- форма ідентифікації -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
</div>
</div>
</div>
</div>
</section>
![]() |
- рядок 3: клас [navbar] визначає стиль панелі навігації. Клас [navbar-inverse] надає їй чорний фон. Клас [navbar-fixed-top] забезпечить, щоб під час прокрутки сторінки, що відображається браузером, панель навігації залишалася у верхній частині екрана;
- рядки 5–13: визначають зону [1]. Це типовий набір класів, який я не розумію. Я використовую компонент таким, яким він є;
- рядки 14–26: визначають «адаптивну» зону панелі керування. На смартфоні ця зона зникає в меню;
- рядок 15: зображення, яке наразі приховане;
- рядки 17–25: клас [navbar-form] оформлює форму панелі керування. Клас [navbar-right] розміщує її праворуч від неї;
- рядки 21–23: два поля введення форми з рядка 17 — [2]. Вони знаходяться всередині класу [form-group], який оформлює елементи форми, і кожне з них має клас [form-control];
- рядок 24: клас [btn], що визначає кнопку, доповнений класом [btn-success], який надає їй зелений колір;
- рядок 24: при натисканні на кнопку [Connexion] виконується наступна функція JS:
function connecter() {
showInfo("Connexion demandée...");
}
function showInfo(message) {
$("#info").text(message);
}
Ось приклад:

8.6.4.4. Приклад № 3: кнопка зі списком
Дія [/bs-03] відображає такий вигляд [bs-03.xml]:
![]() |
- Новинкою є список [1], який також називають «dropdown»;
Код подання [bs-03.xml] має такий вигляд:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- локальний скрипт -->
<script type="text/javascript" src="resources/js/bs-03.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar2"></div>
<!-- Jumbotron на базі Bootstrap -->
<div th:include="jumbotron"></div>
<!-- вміст -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- інформація -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 11: кнопка зі списком потребує файлу JS з Bootstrap;
- рядок 18: нова панель навігації;
Вигляд [navbar2.xml] має такий вигляд:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- форма входу -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
<!-- мови -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- початкова сторінка -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initNavBar2();
/*]]>*/
</script>
</section>
- рядки 25–40: визначають кнопку зі списком;
- рядок 27: клас [btn-danger] надає їй червоного кольору;
- рядки 32–39: елементи списку. Це посилання, кожне з яких пов’язане з функцією JS;
- рядки 46–51: скрипт JS, що виконується після завантаження документа;
Скрипт JS [bs-03.js] має такий вигляд:
function initNavBar2() {
// випадаюче меню мов
$('.dropdown-toggle').dropdown();
}
function connecter() {
showInfo("Connexion demandée...");
}
function setLang(lang) {
var msg;
switch (lang) {
case 'fr':
msg = "Vous avez choisi la langue française...";
break;
case 'en':
msg = "You have selected english language...";
break;
}
showInfo(msg);
}
function showInfo(message) {
$("#info").text(message);
}
- рядки 1–4: функція, яка ініціалізує [dropdown]. [$('.dropdown-toggle')] визначає елемент, що має клас [dropdown-toggle]. Це кнопка зі списком (рядок 28 у вікні). До нього застосовується функція JS [dropdown()], яка визначена у файлі JS [bootstrap.js]. Лише після цієї операції кнопка починає працювати як кнопка зі списком;
- рядки 10–21: функція, що виконується під час вибору мови;
Ось приклад:

8.6.4.5. Приклад № 4: меню
Дія [/bs-04] відображає такий вигляд [bs-04.xml]:
![]() |
Було додано меню [1].
Вигляд [bs-04.xml] має такий вигляд:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- локальний скрипт -->
<script type="text/javascript" src="resources/js/bs-04.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar3"></div>
<!-- Jumbotron на базі Bootstrap -->
<div th:include="jumbotron"></div>
<!-- вміст -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- інформація -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 18: додано нову навігаційну панель;
Вигляд [navbar3.xml] такий:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<ul class="nav navbar-nav">
<li class="active" id="lnkAfficherAgenda">
<a href="javascript:afficherAgenda()">Agenda </a>
</li>
<li class="active" id="lnkAccueil">
<a href="javascript:retourAccueil()">Retour Accueil </a>
</li>
<li class="active" id="lnkRetourAgenda">
<a href="javascript:retourAgenda()">Retour Agenda </a>
</li>
<li class="active" id="lnkValiderRv">
<a href="javascript:validerRv()">Valider </a>
</li>
</ul>
<!-- праві кнопки -->
<div class="navbar-form navbar-right" role="form">
<!-- вихід -->
<button type="button" class="btn btn-success" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- мови -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- початкова сторінка -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initNavBar3();
/*]]>*/
</script>
</section>
- рядки 16–29: створюють меню з чотирма пунктами, кожен з яких пов’язаний зі скриптом JS;
- рядки 55–60: скрипт, що виконується під час завантаження сторінки;
Скрипт JS [bs-04.js] має такий вигляд:
...
function initNavBar3() {
// випадаюче меню мов
$('.dropdown-toggle').dropdown();
// анімоване зображення
loading = $("#loading");
loading.hide();
}
function afficherAgenda() {
showInfo("option [Agenda] cliquée...");
}
function retourAccueil() {
showInfo("option [Retour accueil] cliquée...");
}
function retourAgenda() {
showInfo("option [Retour agenda] cliquée...");
}
function validerRv() {
showInfo("option [Valider] cliquée...");
}
function setMenu(show) {
// посилання в меню
var lnkAfficherAgenda = $("#lnkAfficherAgenda");
var lnkAccueil = $("#lnkAccueil");
var lnkValiderRv = $("#lnkValiderRv");
var lnkRetourAgenda = $("#lnkRetourAgenda");
// додаємо їх до словника
var options = {
"lnkAccueil" : lnkAccueil,
"lnkAfficherAgenda" : lnkAfficherAgenda,
"lnkValiderRv" : lnkValiderRv,
"lnkRetourAgenda" : lnkRetourAgenda
}
// приховуємо всі посилання
for ( var key in options) {
options[key].hide();
}
// відображаються ті, що запитуються
for (var i = 0; i < show.length; i++) {
var option = show[i];
options[option].show();
}
}
- рядки 2–18: функція ініціалізації сторінки;
- рядок 4: для відображення кнопки зі списком мов;
- рядки 6–7: анімоване зображення приховано;
- рядки 26–48: функція [setMenu], яка дозволяє вказати, які опції мають бути видимими;
Перейдемо до консолі розробника (Ctrl-Shift-I) і введемо наступний код [1]:
![]() |
Потім повернімося до браузера. Меню змінилося [2]:
8.6.4.6. Приклад № 5: випадаючий список
Дія [/bs-05] відображає такий вигляд [bs-05.xml]:
![]() |
Новинка — у [1]. Тут ми використовуємо компонент, що не входить до складу Bootstrap, [bootstrap-select] [http://silviomoreto.github.io/bootstrap-select/].
Код подання [bs-05.xml] має такий вигляд:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<!-- локальний скрипт -->
<script type="text/javascript" src="resources/js/bs-05.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar3"></div>
<!-- Jumbotron на базі Bootstrap -->
<div th:include="jumbotron"></div>
<!-- вміст -->
<div id="content" th:include="choixmedecin">
</div>
<!-- інформація -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 8: CSS, необхідний для випадаючого списку;
- рядок 13: файл JS, необхідний для випадаючого списку;
- рядок 24: випадаючий список;
Вигляд [choixmedecin.xml] такий:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
</div>
<!-- локальний скрипт -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізуємо сторінку
initChoixMedecin();
/*]]>*/
</script>
</section>
- рядки 7–12: тут міститься класичний тег [select], однак із особливим класом [combobox]. Атрибут [data-style="btn-primary"] надає компоненту синього кольору;
- рядки 16–21: скрипт, що виконується під час завантаження сторінки;
Файл JS [bs-05.js] має такий вигляд:
...
function afficherAgenda() {
var idMedecin = $('#idMedecin option:selected').val();
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin);
}
function initChoixMedecin() {
// список лікарів
$('#idMedecin').selectpicker();
// меню
setMenu([ "lnkAfficherAgenda" ]);
}
- рядки 7–12: функція, що виконується під час завантаження сторінки;
- рядок 9: інструкція, яка перетворює [select] на сторінці у випадаючий список Bootstrap. [$('#idMedecin')] посилається на [select] (рядок 7 подання [choixmedecin]), а функція JS [selectpicker] походить із файлу JS [bootstrap-select.js];
- рядок 11: відображається лише один із пунктів меню;
- рядки 2–5: функція JS виконується при натисканні на пункт меню [Agenda];
- рядок 3: отримується значення вибраного пункту зі списку, що розгортається: [$('#idMedecin option:selected')] спочатку знаходить компонент [id=idMedecin], а потім у цьому компоненті — вибраний пункт. Потім операція [..].val() отримує значення знайденого елемента, тобто атрибут [value] вибраної опції;
Ось приклад вибору лікаря:
![]() |
8.6.4.7. Приклад № 6: календар
Дія [/bs-06] відображає такий вигляд [bs-06.xml]:

Вибір лікаря або дати запускає функцію JS, яка відображає як обраного лікаря, так і обрану дату. Ось приклад:
![]() |
За допомогою кнопки «Список мов» можна перевести календар (і лише календар) на англійську мову:

Це найскладніший приклад із серії. Календар є компонентом [bootstrap-datepicker] [http://eternicode.github.io/bootstrap-datepicker].
Вигляд [bs-06.xml] такий:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<!-- локальний скрипт -->
<script type="text/javascript" src="resources/js/bs-06.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar3"></div>
<!-- Jumbotron на базі Bootstrap -->
<div th:include="jumbotron"></div>
<!-- вміст -->
<div id="content" th:include="choixmedecinjour">
</div>
<!-- інформація -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 8: файл CSS компонента [bootstrap-datepicker];
- рядок 16: файл JS компонента [bootstrap-datepicker];
- рядок 17: файл JS для роботи з французьким календарем. За замовчуванням він англійською мовою;
- рядок 15: файл JS із бібліотеки під назвою [moment], що надає доступ до численних функцій обчислення часу [http://momentjs.com/];
- рядок 28: вигляд календаря;
Вигляд [choixmedecinjour.xml] такий:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
<div class="col-md-3">
<h2>Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- локальний скрипт -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initChoixMedecinJour();
/*]]>*/
</script>
</section>
- рядки 17–23: календар;
- рядок 18: клас [btn-primary] надає йому синього кольору;
- рядок 18: атрибут [disabled="true"] не дозволяє вводити дату вручну. Необхідно обов’язково користуватися календарем;
- рядок 16: календар розміщено в секції [id="calendar_container"]. Щоб змінити мову календаря, його потрібно видалити, а потім створити заново. Отже, видалимо вміст компонента [id="calendar_container"], а потім розмістимо там новий календар з новою мовою;
- рядки 28–33: код ініціалізації сторінки;
Файл JS [bs-06.js] має такий вигляд:
...
var calendar_infos = {};
function initChoixMedecinJour() {
// календар
var calendar_container = $("#calendar_container");
calendar_infos = {
"container" : calendar_container,
"html" : calendar_container.html(),
"today" : moment().format('YYYY-MM-DD'),
"langue" : "fr"
}
// створення календаря
updateCalendar();
// вибір лікарів
$('#idMedecin').selectpicker();
$('#idMedecin').change(function(e) {
afficherAgenda();
})
// меню
setMenu([]);
}
- рядок 2: календар управляється кількома функціями JS. Змінна [calendar_infos] збиратиме інформацію про календар. Вона є глобальною, щоб її могли бачити різні функції;
- рядок 6: визначається контейнер календаря;
- рядки 7–12: інформація, що зберігається для календаря;
- рядок 8: посилання на його контейнер,
- рядок 9: код календаря HTML. Маючи ці дві відомості, можна видалити календар і створити його заново,
- рядок 10: сьогоднішня дата у форматі [aaaa-mm-jj],
- рядок 11: мова календаря;
- рядок 14: створення календаря;
- рядок 16: комбінований список лікарів;
- рядки 17–19: щоразу, коли змінюватиметься значення, вибране в цьому списку, виконуватиметься метод [afficherAgenda];
- рядок 21: відсутність меню в навігаційній панелі;
Функція [updateCalendar] має такий вигляд:
function updateCalendar(renew) {
if (renew) {
// оновлення поточного календаря
calendar_infos.container.html(calendar_infos.html);
}
// ініціалізація календаря
var calendar = $("#calendar");
var settings = {
format : "yyyy-mm-dd",
startDate : calendar_infos.today,
language : calendar_infos.langue,
};
calendar.datepicker(settings);
// вибір поточної дати
if (calendar_infos.date) {
calendar.datepicker('setDate', calendar_infos.date)
}
// події
calendar.datepicker().on('hide', function(e) {
// відображення вибраного дня
displayJour();
});
calendar.datepicker().on('changeDate', function(e) {
// записування нової дати
calendar_infos.date = moment(calendar.datepicker('getDate')).format("YYYY-MM-DD");
// відображення інформації про календар
afficherAgenda();
// відображення вибраного дня
displayJour();
});
// відображення вибраного дня
displayJour();
}
- рядок 1: функція [updateCalendar] приймає параметр, який може бути присутнім або відсутнім. Якщо він присутній, то календар перегенерується (рядок 4) на основі інформації, що міститься в [calendar_infos];
- рядок 7: здійснюється посилання на календар;
- рядки 8–12: його параметри ініціалізації;
- рядок 9: формат дат, що обробляються [aaaa-mm-jj],
- рядок 10: перша дата, яку можна вибрати в календарі. У даному випадку — сьогоднішня дата. Дати, що передують їй, вибрати не можна,
- рядок 11: мова календаря. Їх буде дві: ['en'] та ['fr'];
- рядок 13: календар налаштований;
- рядки 15–17: якщо дата [calendar_infos] була ініціалізована, то ця дата встановлюється як поточна дата календаря;
- рядки 19–22: щоразу, коли календар закриватиметься, відображатиметься вибрана дата;
- рядки 23–30: щоразу, коли в календарі відбуватиметься зміна дати:
- рядок 25: вибрану дату записують у [calendar_infos],
- рядок 27: відображається інформація про календар,
- рядок 29: відображається вибраний день;
- рядок 32: відображення вибраного дня, якщо такий є;
Метод [displayJour], який відображає вибраний день, виглядає так:
// відображає вибраний день
function displayJour() {
if (calendar_infos.date) {
var displayjour = $("#displayjour");
moment.locale(calendar_infos.langue);
jour = moment(calendar_infos.date).format('LL');
displayjour.val(jour);
}
}
- рядок 3: якщо дата вже обрана (спочатку у календарі немає обраної дати);
- рядок 4: визначаємо компонент, куди буде записано дату;
- рядок 5: ця дата може бути записана англійською або французькою мовою. Встановлюємо мову бібліотеки [moment];
- рядок 6: відображається вибрана дата обраною мовою та у розгорнутому форматі;
- рядок 7: ця дата відображається;
Ось два приклади:
![]() | ![]() |
Під час зміни лікаря або дати виконується метод [afficherAgenda]:
function afficherAgenda() {
// відображає лікаря та дату
var idMedecin = $('#idMedecin option:selected').val();
if (calendar_infos.date) {
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin + " et le jour " + calendar_infos.date);
}
}
8.6.4.8. Приклад № 7: «адаптивна» таблиця HTML
Примітка: «responsive» — це англійський термін, що означає, що компонент здатний адаптуватися до розміру екрана, на якому він відображається. Наведемо приклад.
Дія [/bs-07] відображає наступний вигляд [bs-07.xml] (на весь екран):
![]() |
Новинкою є таблиця HTML [1]. Ця таблиця управляється бібліотекою JS [footable]: [https://github.com/fooplugins/FooTable].
Якщо зменшити розмір вікна браузера, отримаємо таке:
![]() |
- таблиця HTML адаптувалася до розміру екрану;
- у [1], щоб побачити посилання [Réserver], потрібно натиснути на знак [+];
- у [2] — те, що бачимо, коли натискаємо на знак [+];
Вигляд [bs-07.xml] такий:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- локальний скрипт -->
<script type="text/javascript" src="resources/js/bs-07.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar3" />
<!-- Jumbotron на базі Bootstrap -->
<div th:include="jumbotron" />
<!-- вміст -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda" />
<!-- інформація -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 10: CSS з бібліотеки [footable];
- рядок 19: JS з бібліотеки [footable];
- рядок 31: таблиця HTML з календаря;
Вигляд [agenda.xml] такий:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="row alert alert-danger">
<div class="col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span>Créneau horaire</span>
</th>
<th>
<span>Client</span>
</th>
<th data-hide="phone">
<span>Action</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class='status-metro status-active'>
9h00-9h20
</span>
</td>
<td>
<span></span>
</td>
<td>
<a href="javascript:reserver(14)" class="status-metro status-active">
Réserver
</a>
</td>
</tr>
<tr>
<td>
<span class='status-metro status-suspended'>
9h20-9h40
</span>
</td>
<td>
<span>Mme Paule MARTIN</span>
</td>
<td>
<a href="javascript:supprimer(17)" class="status-metro status-suspended">
Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- початкова сторінка -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізується сторінка
initAgenda();
/*]]>*/
</script>
</body>
</html>
- рядок 4: розміщує таблицю в одному рядку [row] та кольорову рамку [alert alert-danger];
- рядок 5: таблиця займатиме 6 стовпців [col-md-6];
- рядок 6: таблиця HTML відформатована за допомогою Bootstrap [class='table'];
- рядок 9: атрибут [data-toggle] вказує на стовпець, у якому розміщений символ [+/-], що розгортає/складає рядок;
- рядок 15: атрибут [data-hide='phone'] вказує, що стовпець має бути прихований, якщо розмір екрану відповідає розміру екрану телефону. Також можна використовувати значення «tablet»;
- рядок 31: до посилання [Réserver] прив’язується функція JS;
- рядок 46: до посилання [Supprimer] прив’язується функція JS;
- рядки 56–61: ініціалізація сторінки;
Деякі класи CSS, що використовуються вище, походять із файлу CSS [bootstrapDemo.css]:
@CHARSET "UTF-8";
#слоти th {
text-align: center;
}
#creneaux td {
text-align: center;
font-weight: bold;
}
.status-metro {
display: inline-block;
padding: 2px 5px;
color:#fff;
}
.status-metro.status-active {
background: #43c83c;
}
.status-metro.status-suspended {
background: #fa3031;
}
Стилі [status-*] взяті з прикладу використання таблиці [footable], знайденого на сайті бібліотеки.
У файлі JS [bs-07.js] сторінка ініціалізується наступним чином:
function initAgenda() {
// таблиця часових проміжків
$("#creneaux").footable();
}
Ось і все. [$("#creneaux")] посилається на таблицю HTML, яку ми хочемо зробити «адаптивною». Крім того, є функції JS, пов’язані з двома посиланнями [Réserver] та [Supprimer]:
function reserver(idCreneau) {
showInfo("Réservation du créneau n° " + idCreneau);
}
function supprimer(idRv) {
showInfo("Suppression du rv n° " + idRv);
}
8.6.4.9. Приклад № 8: модальне вікно
Дія [/bs-08] відображає такий вигляд [bs-08.xml]:

Якщо раніше при натисканні на посилання [Réserver] у інформаційному вікні відображалася інформація, то тут ми відобразимо модальне вікно для вибору клієнта для RV:

Використовується компонент [bootstrap-modal] [https://github.com/jschr/bootstrap-modal/].
Вигляд [bs-08.xml] такий:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-modal.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- локальний скрипт -->
<script type="text/javascript" src="resources/js/bs-08.js"></script>
</head>
<body id="body">
<div class="container">
<!-- навігаційна панель -->
<div th:include="navbar3" />
<!-- Jumbotron на базі Bootstrap -->
<div th:include="jumbotron" />
<!-- вміст -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda-modal" />
<div th:include="resa" />
<!-- інформація -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- рядок 19: файл JS, необхідний для модальних вікон;
- рядок 32: представлення [agenda-modal] ідентичне представленню [agenda] за винятком однієї деталі: функції JS, яка керує посиланням [Réserver]:
<a href="javascript:showDialogResa(14)" class="status-metro status-active">Réserver</a>
Функція [showDialogResa] відповідає за відображення модального вікна вибору клієнта;
- рядок 33: представлення [resa.xml] — це модальне вікно вибору клієнта:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">Заголовок модального вікна</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span>Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2>Clients</h2>
<select id="idClient" class="combobox" data-style="btn-primary">
<option value="1">Mme Marguerite Planton</option>
<option value="2">Mr Maxime Franck</option>
<option value="3">Mlle Elisabeth Oron</option>
<option value="4">Mr Gaëtan Calot</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validateResa()">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- початкова сторінка -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізуємо сторінку
initResa();
/*]]>*/
</script>
</section>
- рядки 3–37: модальне вікно;
- рядки 13–30: вміст цього вікна (те, що буде відображатися);
- рядки 31–34: кнопки діалогового вікна;
- рядок 32: кнопка [Annuler], що керується функцією JS [cancelDialogResa];
- рядок 33: кнопка [Valider], що керується функціями JS та [validateResa];
- рядки 39–44: скрипт ініціалізації модального вікна;
У результаті отримуємо такий вигляд:
![]() |
Слід зауважити, що модальне вікно за замовчуванням не відображається. Тому його не видно під час запуску програми, хоча його код HTML присутній у документі.
Файл JS [bs-08.js] має такий вигляд:
var idCreneau;
var idClient;
var resa;
function showDialogResa(idCreneau) {
// зберігаємо ідентифікатор слота
this.idCreneau = idCreneau;
// відображається діалогове вікно бронювання
var resa = $("#resa");
resa.modal('show');
// журнал
showInfo("Réservation du créneau n° " + idCreneau);
}
function cancelDialogResa() {
// приховування діалогового вікна
resa.modal('hide');
}
// підтвердження бронювання
function validateResa() {
// отримуємо інформацію
var idClient = $('#idClient option:selected').val();
// приховування діалогового вікна
resa.modal('hide');
// інформація
showInfo("Réservation du créneau n° " + idCreneau + " pour le client n° " + idClient)
}
function initResa() {
// список клієнтів
$('#idClient').selectpicker();
// модальне вікно
resa = $("#resa");
resa.modal({});
}
- рядки 30–36: функція ініціалізації модального вікна;
- рядок 32: модальне вікно містить випадаючий список, який потрібно ініціалізувати;
- рядки 34–35: ініціалізація самого модального вікна;
- рядки 5–13: функція JS, пов’язана з посиланням [Réserver];
- рядок 7: параметр функції зберігається у глобальній змінній з рядка 1;
- рядки 9–10: модальне вікно стає видимим;
- рядок 12: у вікні інформації записується інформація;
- рядки 15–18: обробка кнопки [Annuler]. Просто приховується модальне вікно (рядок 17);
- рядки 21–31: функція JS, прив’язана до кнопки [Valider];
- рядок 23: отримуємо атрибут [value] вибраного клієнта;
- рядок 25: приховуємо діалогове вікно;
- рядок 27: записуємо дві інформації: номер заброньованого слоту та ім’я клієнта;
8.6.5. Етап 2: створення представлень
Тепер ми опишемо подані сервером [Web1] подання, а також їхні шаблони.
![]() |
8.6.5.1. Вигляд [navbar-start]
Вона відображає панель навігації стартової сторінки:

Код [navbar-start.xml] має такий вигляд:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- форма входу -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" th:placeholder="#{service.url}" class="form-control" id="urlService" />
</div>
<div class="form-group">
<input type="text" th:placeholder="#{username}" class="form-control" id="login" />
</div>
<div class="form-group">
<input type="password" th:placeholder="#{password}" class="form-control" id="passwd" />
</div>
<button type="button" class="btn btn-success" th:text="#{login}" onclick="javascript:connecter()">Sign in</button>
<!-- мови -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Action</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- початкова сторінка -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initNavBarStart();
/*]]>*/
</script>
</section>
Цей вигляд не має шаблону. Він має такі обробники подій:
évt | обробник |
натискання кнопки входу | |
натискання на посилання [Français] | |
натисніть на посилання [English] |
8.6.5.2. Вигляд [jumbotron]
Це вигляд, який відображається під панеллю навігації [navbar-start] на стартовій сторінці:

Його код [jumbotron.xml] такий:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- Jumbotron на базі Bootstrap -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1 th:utext="#{application.header}" />
</div>
</div>
</div>
</section>
Вигляд [jumbotron] не має ані шаблону, ані подій.
8.6.5.3. Вигляд [login]
Це вигляд, який відображається під джамботроном на сторінці завантаження:

Її код [login.xml] такий:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{identification}">Identification
</div>
</section>
У цього виду немає ні шаблону, ні подій.
8.6.5.4. Вигляд [navbar-run]
Це панель навігації, що відображається після успішного входу:

Його код [navbar-run.xml] такий:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- праві кнопки -->
<form class="navbar-form navbar-right" role="form">
<!-- вихід -->
<button type="button" class="btn btn-success" th:text="#{options.deconnecter}" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- мови -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</form>
</div>
</div>
</div>
<!-- ініціалізація сторінки -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initNavBarRun();
/*]]>*/
</script>
</section>
Цей вигляд не має шаблону. Він має такі обробники подій:
évt | обробник |
натискання кнопки «Вийти» | |
натискання на посилання [Français] | |
натисніть на посилання [English] |
8.6.5.5. Вигляд [accueil]
Це вікно, яке відображається безпосередньо під панеллю навігації [navbar-run]:

Його код [accueil.html] такий:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{choixmedecinjour.title}">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{rv.medecin}">Médecin</h2>
<select name="idMedecin" id="idMedecin" class="combobox" data-style="btn-primary">
<option th:each="medecinItem : ${rdvmedecins.medecinItems}" th:text="${medecinItem.texte}" th:value="${medecinItem.id}"/>
</select>
</div>
<div class="col-md-3">
<h2 th:text="#{rv.jour}">Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- календар -->
<div id="agenda"></div>
<!-- локальний скрипт -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initChoixMedecinJour();
/*]]>*/
</script>
</html>
Його шаблон такий:
- [rdvmedecins.medecinItems] (рядок 8): список лікарів;
У своєму поточному вигляді цей вигляд, здається, не має обробника подій. Насправді вони визначені у функції [initChoixMedecinJour]. Ця функція була представлена у розділі 8.6.4.7, на сторінці 466, а зокрема на сторінці 469. У ній містяться такі обробники подій:
подія | обробник |
вибір лікаря | |
вибір дати |
8.6.5.6. Вигляд [agenda]
Екран [agenda] відображає один день з розкладу лікаря:

Його код [agenda.xml] такий:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h3 class="alert alert-info" th:text="${agenda.titre}">Agenda de Mme Pélissier le 13/10/2014</h3>
<h4 class="alert alert-danger" th:if="${agenda.creneaux.length}==0" th:text="#{agenda.medecinsanscreneaux}">Ce médecin n'a pas encore de créneaux
de consultation</h4>
<th:block th:if="${agenda.creneaux.length}!=0">
<div class="row tab-content alert alert-warning">
<div class="tab-pane active col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span th:text="#{agenda.creneauhoraire}">Créneau horaire</span>
</th>
<th>
<span th:text="#{agenda.client}">Client</span>
</th>
<th data-hide="phone">
<span th:text="#{agenda.action}">Action</span>
</th>
</tr>
</thead>
<tbody>
<tr th:each="creneau,iter : ${agenda.creneaux}">
<td>
<span th:if="${creneau.action}==1" class="status-metro status-active" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
<span th:if="${creneau.action}==2" class="status-metro status-suspended" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
</td>
<td>
<span th:text="${creneau.client}">Client</span>
</td>
<td>
<a th:if="${creneau.action}==1" th:href="@{'javascript:reserverCreneau('+${creneau.id}+')'}" th:text="${creneau.commande}"
class="status-metro status-active">Réserver
</a>
<a th:if="${creneau.action}==2" th:href="@{'javascript:supprimerRv('+${creneau.idRv}+')'}" th:text="${creneau.commande}"
class="status-metro status-suspended">Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- бронювання -->
<section th:include="resa" />
</th:block>
<!-- ініціалізація сторінки -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initAgenda();
/*]]>*/
</script>
</body>
</html>
Шаблон цього виду містить лише один елемент:
- [agenda] (рядок 4): дещо складний шаблон, спеціально створений для відображення календаря;
Вона має такі обробники подій:
подія | обробник |
натискання на кнопку [Supprimer] | |
натискання на посилання [Réserver] |
Вигляд [resa] у рядку 47 — це вигляд, який відображається, коли користувач натискає на посилання [Réserver]:

Його код [resa.xml] такий:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">Заголовок модального вікна</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span th:text="#{resa.titre}">Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{resa.client}">Client</h2>
<select name="idClient" id="idClient" class="combobox" data-style="btn-primary">
<option th:each="clientItem : ${clientItems}" th:text="${clientItem.texte}" th:value="${clientItem.id}" />
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()" th:text="#{resa.annuler}">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validerRv()" th:text="#{resa.valider}">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- ініціалізація сторінки -->
<script th:inline="javascript">
/*<![CDATA[*/
// ініціалізація сторінки
initResa();
/*]]>*/
</script>
</body>
</html>
Його шаблон містить лише один елемент:
- [clientItems] (рядок 24): список клієнтів;
Вона має такі обробники подій:
подія | обробник |
натискання кнопки [Annuler] | |
натискання на кнопку [Valider] |
8.6.5.7. Екран [erreurs]
Цей вигляд відображається, якщо запитувана користувачем дія не була виконана:

Код [erreurs.xml] має такий вигляд:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-danger">
<h4>
<span th:text="#{erreurs.titre}">Les erreurs suivantes se sont produites :</span>
</h4>
<ul>
<li th:each="message : ${erreurs}" th:text="${message}" />
</ul>
</div>
</section>
Його шаблон містить лише один елемент:
- [erreurs] (рядок 8): список помилок, що мають відображатися;
У цьому подання немає обробника подій.
8.6.5.8. Résumé
У наступній таблиці наведено перелік подань та їхніх шаблонів:
представлення | шаблон | обробники подій |
navbar-start | ||
jumbotron | ||
login | ||
панель навігації-запуск | ||
Головна | ||
календар | ||
бронювання | ||
помилки |
8.6.6. Крок 3: написання дій
Повернемося до архітектури веб-сервісу [Web1]:
![]() |
Тепер розглянемо, які URL надаються сервісом [Web1], та їх реалізацію:
8.6.6.1. URL, що надаються сервісом [Web1]
Це такі:
- URL для кожного з попередніх переглядів або їхньої комбінації;
- URL для додавання RV;
- URL для видалення RV;
Усі вони повертають відповідь типу [Reponse], як показано нижче:
public class Reponse {
// ----------------- властивості
// статус операції
private int status;
// панель навігації
private String navbar;
// джамботрон
private String jumbotron;
// основна частина сторінки
private String content;
// календар
private String agenda;
...
}
- рядок 5: стан відповіді: 1 (OK), 2 (помилка);
- рядок 7: потік HTML з переглядів [navbar-start] або [navbar-run] залежно від випадку;
- рядок 9: потік HTML з подання [jumbotron];
- рядок 13: потік HTML з перегляду [agenda];
- рядок 9: потік HTML з переглядів [accueil], [erreurs], [login] залежно від випадку;
Наведені URL є такими
вставляє зображення [navbar-start] у [Reponse.navbar] | |
вставляє вигляд [navbar-run] у [Reponse.navbar] | |
вставляє вигляд [accueil] у [Reponse.content] | |
вставляє вигляд [jumbotron] у [Reponse.jumbotron] | |
вставляє вигляд [agenda] у [Reponse.agenda] | |
вставляє вигляд [login] у [Reponse.content] | |
| |
переміщує вигляд [navbar-run] у [Reponse.navbar], вигляд [jumbotron] у [Reponse.jumbotron], погляд [accueil] — у [Reponse.content], погляд [agenda] — у [Reponse.agenda] | |
додає вибрану зустріч і розміщує новий календар у [Reponse.agenda] | |
видаляє вибрану зустріч і додає новий календар у [Reponse.agenda] |
8.6.6.2. Сінглтон [ApplicationModel]
![]() |
Клас [ApplicationModel] інстанціюється як єдиний екземпляр і вводиться в контролер додатка. Його код такий:
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
....
}
- рядок 6: [ApplicationModel] — це компонент Spring;
- рядок 7: який реалізує інтерфейс шару [DAO]. Ми робимо це для того, щоб дії не мали знати про шар [DAO], а лише про синглтон [ApplicationModel]. Архітектура [Web1] тоді стає такою:
![]() |
Повернемося до коду класу [ApplicationModel]:
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
// шар [DAO]
@Autowired
private IDao dao;
// конфігурація
@Autowired
private AppConfig appConfig;
// дані з шару [DAO]
private List<ClientItem> clientItems;
private List<MedecinItem> medecinItems;
// дані конфігурації
private String userInit;
private String mdpUserInit;
private boolean corsAllowed;
// виняток
private RdvMedecinsException rdvMedecinsException;
// виробник
public ApplicationModel() {
}
@PostConstruct
public void init() {
// конфігурація
userInit = appConfig.getUSER_INIT();
mdpUserInit = appConfig.getMDP_USER_INIT();
dao.setTimeout(appConfig.getTIMEOUT());
dao.setUrlServiceWebJson(appConfig.getWEBJSON_ROOT());
corsAllowed = appConfig.isCORS_ALLOWED();
// кешуються списки лікарів та клієнтів
List<Medecin> medecins = null;
List<Client> clients = null;
try {
medecins = dao.getAllMedecins(new User(userInit, mdpUserInit));
clients = dao.getAllClients(new User(userInit, mdpUserInit));
} catch (RdvMedecinsException ex) {
rdvMedecinsException = ex;
}
if (rdvMedecinsException == null) {
// створюються елементи списків, що розгортаються
medecinItems = new ArrayList<MedecinItem>();
for (Medecin médecin : medecins) {
medecinItems.add(new MedecinItem(médecin));
}
clientItems = new ArrayList<ClientItem>();
for (Client client : clients) {
clientItems.add(new ClientItem(client));
}
}
}
// методи getter та setter
...
// реалізація інтерфейсу [IDao]
@Override
public void setUrlServiceWebJson(String url) {
dao.setUrlServiceWebJson(url);
}
@Override
public void setTimeout(int timeout) {
dao.setTimeout(timeout);
}
@Override
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
return dao.ajouterRv(user, jour, idCreneau, idClient);
}
...
}
- рядок 11: введення посилання на реалізацію шару [DAO]. Саме це посилання потім використовується для реалізації інтерфейсу [IDao] (рядки 64–80);
- рядок 14: введення конфігурації додатка;
- рядки 33–37: використання цієї конфігурації для налаштування різних елементів архітектури додатка;
- рядки 38–46: кешування інформації, яка буде заповнювати випадаючі списки лікарів та клієнтів. Отже, ми виходимо з припущення, що у разі зміни лікаря або клієнта додаток потрібно перезапустити. Ідея полягає в тому, щоб показати, що синглтон Spring може слугувати кешем для веб-додатка;
Класи [MedecinItem] та [ClientItem] походять від наступного класу [PersonneItem]:
package rdvmedecins.springthymeleaf.server.models;
import rdvmedecins.client.entities.Personne;
public class PersonneItem {
// елемент списку
private Long id;
private String texte;
// конструктор
public PersonneItem() {
}
public PersonneItem(Personne personne) {
id = personne.getId();
texte = String.format("%s %s %s", personne.getTitre(), personne.getPrenom(), personne.getNom());
}
// геттери та сеттери
...
}
- рядок 8: поле [id] буде значенням атрибута [value] одного з варіантів випадаючого списку;
- рядок 9: поле [texte] буде текстом, що відображається для одного з варіантів випадаючого списку;
8.6.6.3. Клас [BaseController]
![]() |
Клас [BaseController] є батьківським класом контролерів [RdvMedecinsController] та [RdvMedecinsCorsController]. Створення цього батьківського класу не було обов’язковим. У ньому зібрано допоміжні методи класу [RdvMedecinsController], які не є основними, за винятком одного. Їх можна розділити на три групи:
- допоміжні методи;
- методи, що забезпечують злиття представлень з їхніми моделями;
- метод ініціалізації дії
| дві службові методи, які повертають список повідомлень про помилки. Ми вже зустрічали їх і використовували; |
| виводить подання [accueil] без шаблону |
| повертає запит [agenda] та його шаблон |
| повертає зріз [login] без шаблону |
| повертає відповідь клієнту, коли запитувана дія завершилася з помилкою |
| Метод ініціалізації всіх дій контролера [RdvMedecinsController] |
Розглянемо два з цих методів.
Метод [getPartialViewAgenda] відповідає за формування найскладнішого виду — календаря. Його код такий:
// потік [agenda]
protected String getPartialViewAgenda(ActionContext actionContext, AgendaMedecinJour agenda, Locale locale) {
// контексти
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
// створюємо модель сторінки [agenda]
ViewModelAgenda modelAgenda = setModelforAgenda(agenda, springContext, locale);
// календар із його шаблоном
thymeleafContext.setVariable("agenda", modelAgenda);
thymeleafContext.setVariable("clientItems", application.getClientItems());
return engine.process("agenda", thymeleafContext);
}
- рядки 9–10: два елементи моделі календаря:
- рядок 9: відображений календар.
- рядок 10: список клієнтів, що відображається, коли користувач призначає зустріч;
Метод [setModelforAgenda] у рядку 7 має такий вигляд:
// шаблон сторінки [Agenda]
private ViewModelAgenda setModelforAgenda(AgendaMedecinJour agenda, WebApplicationContext springContext, Locale locale) {
// заголовок сторінки
String dateFormat = springContext.getMessage("date.format", null, locale);
Medecin médecin = agenda.getMedecin();
String titre = springContext.getMessage("agenda.titre", new String[] { médecin.getTitre(), médecin.getPrenom(),
médecin.getNom(), new SimpleDateFormat(dateFormat).format(agenda.getJour()) }, locale);
// часові слоти для запису
ViewModelCreneau[] modelCréneaux = new ViewModelCreneau[agenda.getCreneauxMedecinJour().length];
int i = 0;
for (CreneauMedecinJour creneauMedecinJour : agenda.getCreneauxMedecinJour()) {
// час прийому лікаря
Creneau créneau = creneauMedecinJour.getCreneau();
ViewModelCreneau modelCréneau = new ViewModelCreneau();
modelCréneaux[i] = modelCréneau;
// ідентифікатор
modelCréneau.setId(créneau.getId());
// часовий проміжок
modelCréneau.setCreneauHoraire(String.format("%02dh%02d-%02dh%02d", créneau.getHdebut(), créneau.getMdebut(),
créneau.getHfin(), créneau.getMfin()));
Rv rv = creneauMedecinJour.getRv();
// клієнт та замовлення
String commande;
if (rv == null) {
modelCréneau.setClient("");
commande = springContext.getMessage("agenda.reserver", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setAction(ViewModelCreneau.ACTION_RESERVER);
} else {
Client client = rv.getClient();
modelCréneau.setClient(String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom()));
commande = springContext.getMessage("agenda.supprimer", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setIdRv(rv.getId());
modelCréneau.setAction(ViewModelCreneau.ACTION_SUPPRIMER);
}
// наступний час
i++;
}
// повертаємо шаблон календаря
ViewModelAgenda modelAgenda = new ViewModelAgenda();
modelAgenda.setTitre(titre);
modelAgenda.setCreneaux(modelCréneaux);
return modelAgenda;
}
- рядок 6: календар має заголовок:

або:

Бачимо, що формат дати залежить від мови. Цей формат ми будемо шукати у файлах повідомлень (рядок 4).
- рядки 11–40: для кожного часового проміжку потрібно відобразити такий вигляд:
![]()
або такий вигляд:
![]()
- рядки 19–20: відображають часовий проміжок;
- рядки 25–28: випадок, коли часовий проміжок вільний. У цьому випадку потрібно відобразити кнопку [Réserver];
- рядки 31–36: випадок, коли часовий проміжок зайнятий. У цьому випадку потрібно відобразити як клієнта, так і кнопку [Supprimer];
Інший метод, який ми детальніше пояснюємо, — це метод [getActionContext]. Він викликається на початку кожної з дій методу [RdvMedecinsController]. Його сигнатура така:
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController)
Вона повертає такий тип [ActionContext]:
public class ActionContext {
// дані
private WebContext thymeleafContext;
private WebApplicationContext springContext;
private Locale locale;
private List<String> erreurs;
...
}
- рядок 4: контекст Thymeleaf дії;
- рядок 5: контекст дії Spring;
- рядок 6: локаль дії;
- рядок 7: можливий список повідомлень про помилки;
Її параметри такі:
- [lang]: мова, яка запитується для дії — «en» або «fr»;
- [origin]: заголовок HTTP [origin] у разі міждоменного виклику;
- [request]: запит HTTP, що обробляється, який вже деякий час називають дією;
- [response]: відповідь, яка буде надана на цей запит;
- [result]: кожна дія з [RdvMedecinsController] отримує значення, що передається методом POST, і перевіряється його правильність. [result] — це результат цієї перевірки;
- [rdvMedecinsController]: контролер-контейнер дій;
Метод [getActionContext] реалізовано наступним чином:
// контекст дії
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController) {
// мова?
if (lang == null) {
lang = "fr";
}
// локаль
Locale locale = null;
if (lang.trim().toLowerCase().equals("fr")) {
// французька
locale = new Locale("fr", "FR");
} else {
// все інше англійською
locale = new Locale("en", "US");
}
// заголовки CORS
rdvMedecinsCorsController.sendOptions(origin, response);
// ActionContext
ActionContext actionContext = new ActionContext(new WebContext(request, response, request.getServletContext(),locale), WebApplicationContextUtils.getWebApplicationContext(request.getServletContext()), locale, null);
// помилки ініціалізації
RdvMedecinsException e = application.getRdvMedecinsException();
if (e != null) {
actionContext.setErreurs(e.getMessages());
return actionContext;
}
// помилки POST?
if (result != null && result.hasErrors()) {
actionContext.setErreurs(getErreursForModel(result, locale, actionContext.getSpringContext()));
return actionContext;
}
// помилок немає
return actionContext;
}
- рядки 3–15: на основі параметра [lang] встановлюється локаль дії;
- рядок 17: надсилаються заголовки HTTP, необхідні для міждоменних запитів. Ми не будемо вдаватися в подробиці. Використовується техніка, описана в параграфі 8.4.14;
- рядок 19: побудова об’єкта [ActionContext] без помилок;
- рядок 21: у розділі 8.6.6.2 ми бачили, що синглтон [ApplicationModel] звертається до бази даних для отримання даних як про клієнтів, так і про лікарів. Цей доступ може завершитися невдачею. У такому разі ми фіксуємо виняток, що виник. У рядку 21 ми отримуємо цей виняток;
- рядки 22–25: якщо під час запуску програми сталося виключення, будь-які дії неможливі. Тоді для будь-якої дії повертається об’єкт [ActionContext] із повідомленнями про помилки цього виключення;
- рядки 27–20: аналізуємо параметр [result], щоб визначити, чи було надіслане значення дійсним. Якщо воно було недійсним, повертаємо об’єкт [ActionContext] із відповідними повідомленнями про помилки;
- рядок 32: випадок без помилок;
Тепер розглянемо дії контролера [RdvMedecinsController]
8.6.6.4. Дія [/getNavBarStart]
Дія [/getNavBarStart] повертає представлення [navbar-start]. Її сигнатура така:
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
Вона повертає такий тип [Reponse]:
public class Reponse {
// ----------------- властивості
// статус операції
private int status;
// панель навігації
private String navbar;
// джамботрон
private String jumbotron;
// основний вміст сторінки
private String content;
// календар
private String agenda;
...
}
і має такі параметри:
- [PostLang postlang]: наступне значення, що відправляється:
public class PostLang {
// дані
@NotNull
private String lang;
...
}
Клас [PostLang] є батьківським класом для всіх відправлених значень. Адже клієнт завжди повинен вказати мову, якою має виконуватися дія.
Метод [getNavbarStart] реалізовано таким чином:
// початкова панель навігації
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// повертається представлення [navbar-start]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
return reponse;
}
- рядок 7: ініціалізація дії;
- рядки 10–13: якщо метод ініціалізації дії повідомив про помилки, їх надсилають у відповіді клієнту (рядок 12) зі статусом 2:
- рядки 15–18: надсилається представлення [navbar-start] зі статусом 1:
Далі ми детально опишемо лише нововведення.
8.6.6.5. Дія [/getNavbarRun]
Дія [/getNavBarRun] створює вигляд [navbar-run]:
// navbar-run
@RequestMapping(value = "/getNavbarRun", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarRun(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// повертається вид [navbar-run]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
return reponse;
}
Ця дія може повернути два типи відповідей:
- відповідь з помилкою (рядки 10–13):
- відповідь із видом [navbar-run]:
8.6.6.6. Дія [/getJumbotron]
Дія [/getJumbotron] повертає вигляд [jumbotron]:
// джамботрон
@RequestMapping(value = "/getJumbotron", method = RequestMethod.POST)
@ResponseBody
public Reponse getJumbotron(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// повертається представлення [jumbotron]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
return reponse;
}
Ця дія може повернути два типи відповідей:
- відповідь з помилкою (рядки 10–13):
- відповідь із видом [jumbotron]:
8.6.6.7. Дія [/getLogin]
Дія [/getLogin] повертає вигляд [login]:
@RequestMapping(value = "/getLogin", method = RequestMethod.POST)
@ResponseBody
public Reponse getLogin(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// повертається представлення [login]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
reponse.setContent(getPartialViewLogin(thymeleafContext));
return reponse;
}
Ця дія може повернути два типи відповідей:
- відповідь з помилкою (рядки 9–11):
- відповідь із результатом [login]:
8.6.6.8. Дія [/getAccueil]
Дія [/getAccueil] повертає представлення [accueil]. Її сигнатура така:
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- у рядку 3 значення, що записується, має тип [PostUser], а саме:
public class PostUser extends PostLang {
// дані
@NotNull
private User user;
...
}
- рядок 1: клас [PostUser] розширює клас [PostLang] і, отже, містить мову;
- рядок 4: користувач, який намагається отримати перегляд;
Код реалізації такий:
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// погляд [accueil] захищений
try{
// користувач
User user = postUser.getUser();
// перевіряються облікові дані [userName, password]
application.authenticate(user);
}catch(RdvMedecinsException e){
// повертається помилка
return getViewErreurs(thymeleafContext, e.getMessages());
}
// повертається сторінка [accueil]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
- рядки 15–22: слід зауважити, що сторінка [accueil] захищена, а отже, користувач повинен пройти автентифікацію;
Ця дія може повернути два типи відповідей:
- відповідь з помилкою (рядки 11 та 21):
- відповідь із видом [accueil] (рядки 24–27):
8.6.6.9. Акція [/getNavbarRunJumbotronAccueil]
Акція [/getNavbarRunJumbotronAccueil] створює перегляди [navbar-run, jumbotron, accueil]. Вона має такий підпис:
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser post, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- рядок 3: значення, що передається, має тип [PostUser];
Реалізація дії така:
// навігаційна панель + джамботрон + головна сторінка
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// погляд [accueil] захищений
try {
// користувач
User user = postUser.getUser();
// перевіряються облікові дані [userName, password]
application.authenticate(user);
} catch (RdvMedecinsException e) {
// повертається помилка
return getViewErreurs(thymeleafContext, e.getMessages());
}
// надсилається відповідь
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
Дія може повернути два типи відповідей:
- відповідь з помилкою (рядки 13, 23):
- відповідь із переглядами [navbar-run, jumbotron, accueil] (рядки 26–31):
8.6.6.10. Дія [/getAgenda]
Дія [/getAgenda] повертає представлення [agenda]. Її сигнатура така:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- рядок 3: значення, що передається, має тип [PostGetAgenda], а саме:
public class PostGetAgenda extends PostUser {
// дані
@NotNull
private Long idMedecin;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- рядок 1: клас [PostGetAgenda] розширює клас [PostUser] і, отже, містить мову та користувача;
- рядок 5: номер лікаря, чий календар потрібно отримати;
- рядок 8: день календаря, який потрібно отримати;
Реалізація виглядає наступним чином:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postGetAgenda.getLang(), origin, request, response, result, rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
Locale locale = actionContext.getLocale();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// перевіряється правильність запиту
if (result != null) {
new PostGetAgendaValidator().validate(postGetAgenda, result);
if (result.hasErrors()) {
// повертається представлення [erreurs]
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
...
}
- до рядка 14 — це вже класичний код;
- рядки 16–21: проводиться додаткова перевірка переданого значення. Дата повинна бути не раніше сьогоднішньої. Для перевірки використовується валідатор:
package rdvmedecins.web.validators;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import rdvmedecins.springthymeleaf.server.requests.PostGetAgenda;
import rdvmedecins.springthymeleaf.server.requests.PostValiderRv;
public class PostGetAgendaValidator implements Validator {
public PostGetAgendaValidator() {
}
@Override
public boolean supports(Class<?> classe) {
return PostGetAgenda.class.equals(classe) || PostValiderRv.class.equals(classe);
}
@Override
public void validate(Object post, Errors errors) {
// обраний день зустрічі
Date jour = null;
if (post instanceof PostGetAgenda) {
jour = ((PostGetAgenda) post).getJour();
} else {
if (post instanceof PostValiderRv) {
jour = ((PostValiderRv) post).getJour();
}
}
// перетворення дат у формат рррр-ММ-дд
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String strJour = sdf.format(jour);
String strToday = sdf.format(new Date());
// обраний день не повинен бути раніше сьогоднішньої дати
if (strJour.compareTo(strToday) < 0) {
errors.rejectValue("jour", "todayandafter.postChoixMedecinJour", null, null);
}
}
}
- рядок 19: валідатор працює для двох класів: [PostGetAgenda] та [PostValiderRv];
Повернемося до коду операції [/getAgenda]:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
...
// дія
try {
// розклад лікаря
AgendaMedecinJour agenda = application.getAgendaMedecinJour(postGetAgenda.getUser(), postGetAgenda.getIdMedecin(),
new SimpleDateFormat("yyyy-MM-dd").format(postGetAgenda.getJour()));
// відповідь
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException e1) {
// повертається представлення [erreurs]
return getViewErreurs(thymeleafContext, e1.getMessages());
} catch (Exception e2) {
// повертається вид [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- рядки 9–10: за допомогою переданих параметрів запитується розклад прийомів лікаря;
- рядки 12–13: повертається розклад:
- рядки 17, 21: повертається відповідь з помилками:
8.6.6.11. Дія [/getNavbarRunJumbotronAccueilAgenda]
Дія [/getNavbarRunJumbotronAccueilAgenda] повертає подання [navbar-run, jumbotron, accueil, agenda]. Її реалізація така:
@RequestMapping(value = "/getNavbarRunJumbotronAccueilAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueilAgenda(@Valid @RequestBody PostGetAgenda post, BindingResult result,
HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(post.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// календар
Reponse agenda = getAgenda(post, result, request, response, null);
if (agenda.getStatus() != 1) {
return agenda;
}
// відправляється відповідь
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
reponse.setAgenda(agenda.getAgenda());
return reponse;
}
- рядки 15–18: використовуємо наявність дії [/getAgenda], щоб її викликати. Потім перевіряємо status у відповіді (рядок 16). Якщо виявлено помилку, процес не продовжується, і повертається відповідь;
- рядок 20: надсилаються запитувані перегляди:
8.6.6.12. Дія [/supprimerRv]
Дія [/supprimerRv] дозволяє видалити зустріч. Її сигнатура така:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- рядок 3: відправлене значення має тип [PostSupprimerRv], а саме:
public class PostSupprimerRv extends PostUser {
// дані
@NotNull
private Long idRv;
..
}
- рядок 1: клас [PostSupprimerRv] розширює клас [PostUser] і, отже, містить мову та користувача;
- рядок 5: номер зустрічі, яку потрібно видалити;
Реалізація дії така:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postSupprimerRv.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// опубліковані значення
User user = postSupprimerRv.getUser();
long idRv = postSupprimerRv.getIdRv();
// видаляємо зустріч
AgendaMedecinJour agenda = null;
try {
// відновлюємо його
Rv rv = application.getRvById(user, idRv);
Creneau creneau = application.getCreneauById(user, rv.getIdCreneau());
long idMedecin = creneau.getIdMedecin();
Date jour = rv.getJour();
// видалення пов'язаного запису
application.supprimerRv(user, idRv);
// відновлюється календар лікаря
agenda = application.getAgendaMedecinJour(user, idMedecin, new SimpleDateFormat("yyyy-MM-dd").format(jour));
// повертається новий календар
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// повертається представлення [erreurs]
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// повертається представлення [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- рядок 22: отримуємо зустріч, яку потрібно видалити. Якщо її не існує, виникає виняток;
- рядки 23–25: на основі цього запису визначаються лікар та відповідний день. Ця інформація необхідна для відновлення календаря лікаря;
- рядок 27: зустріч видаляється;
- рядок 29: запитується новий розклад лікаря. Це важливо. Окрім щойно звільненого часового проміжку, інші користувачі додатка могли внести зміни до розкладу. Важливо повернути користувачеві найновішу версію розкладу;
- рядки 31–34: надсилається розклад:
8.6.6.13. Дія [/validerRv]
Дія [/validerRv] додає зустріч до календаря лікаря. Її сигнатура така:
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- рядок 3: розміщене значення має тип [PostValiderRv], а саме:
public class PostValiderRv extends PostUser {
// дані
@NotNull
private Long idCreneau;
@NotNull
private Long idClient;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- рядок 1: клас [PostValiderRv] розширює клас [PostUser] і, отже, містить мову та користувача;
- рядок 5: номер часового слоту;
- рядок 7: номер клієнта, для якого здійснюється бронювання;
- рядок 10: день зустрічі;
Реалізація дії така:
// підтвердження зустрічі
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// контексти дії
ActionContext actionContext = getActionContext(postValiderRv.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebApplicationContext springContext = actionContext.getSpringContext();
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// помилки?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// перевіряється правильність дати зустрічі
if (result != null) {
new PostGetAgendaValidator().validate(postValiderRv, result);
if (result.hasErrors()) {
// повертається представлення [erreurs]
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
// опубліковані значення
User user = postValiderRv.getUser();
long idClient = postValiderRv.getIdClient();
long idCreneau = postValiderRv.getIdCreneau();
Date jour = postValiderRv.getJour();
// дія
try {
// отримуємо інформацію про часовий проміжок
Creneau créneau = application.getCreneauById(user, idCreneau);
long idMedecin = créneau.getIdMedecin();
// додаємо зустріч
application.ajouterRv(postValiderRv.getUser(), new SimpleDateFormat("yyyy-MM-dd").format(jour), idCreneau,idClient);
// оновлюємо календар
AgendaMedecinJour agenda = application.getAgendaMedecinJour(user, idMedecin,
new SimpleDateFormat("yyyy-MM-dd").format(jour));
// повертається новий календар
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// повертається вікно перегляду [erreurs]
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// повертається представлення [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
}
Код аналогічний коду дії [/supprimerRv].
8.6.7. Крок 4: тестування сервера Spring/Thymeleaf
Тепер ми протестуємо різні попередні дії за допомогою плагіна Chrome [Advanced Rest Client] (див. розділ 9.6).
8.6.7.1. Налаштування тестів
Усі дії очікують на значення, надіслане методом POST. Ми надішлемо різні варіанти наступного рядка jSON:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Це значення, що передається, містить інформацію, яка для більшості дій є зайвою. Однак дії, які її отримують, ігнорують цю інформацію, і це не спричиняє помилок. Перевага цього значення полягає в тому, що воно охоплює різні значення, які потрібно передати.
8.6.7.2. Акція [/getNavbarStart]
![]() |
- у [1] — тестована дія;
- у [2] — значення, що передається;
- в [3] значення, що публікується, є рядком jSON;
- у [4] запитується перегляд [navbar-start] англійською мовою;
Отримано такий результат:
![]() |
Ми отримали вигляд [navbar-start] англійською мовою (поля виділені).
Тепер зробимо помилку. Змінимо значення атрибута [lang] з того, що було надіслано, на null. Отримаємо такий результат:
![]() |
Ми отримали відповідь про помилку (статус 2), яка вказує, що поле [lang] є обов’язковим.
8.6.7.3. Дія [/getNavbarRun]
Ми надсилаємо запит на дію [getNavbarRun] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
8.6.7.4. Дія [/getJumbotron]
Ми викликаємо дію [getJumbotron] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
8.6.7.5. Дія [/getLogin]
Ми викликаємо дію [getLogin] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
8.6.7.6. Дія [/getAccueil]
Ми запитуємо дію [getAccueil] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
Повторюємо операцію з невідомим користувачем:
{"user":{"login":"x","passwd":"x"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
Почнемо знову з існуючого користувача, який не має дозволу на використання додатка:
{"user":{"login":"user","passwd":"user"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
8.6.7.7. Дія [/getAgenda]
Ми запитуємо дію [getAgenda] із таким значенням у запиті:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
Повторюємо операцію, вибравши дату, що передує сьогоднішній:
![]() |
Почнемо знову з неіснуючого лікаря:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":11, "idCreneau":2, "idClient":4, "idRv":93}
Отриманий результат такий:
![]() |
8.6.7.8. Дія [/getNavbarRunJumbotronAccueil]
Ми запитуємо дію [getNavbarRunJumbotronAccueil] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
Те саме відбувається з невідомим користувачем:
![]() |
8.6.7.9. Дія [/getNavbarRunJumbotronAccueilAgenda]
Ми запитуємо дію [getNavbarRunJumbotronAccueilAgenda] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
Вводимо лікаря, якого не існує:
![]() |
8.6.7.10. Операція [/supprimerRv]
Ми запитуємо дію [supprimerRv] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Rv № 93 не існує. Отримано такий результат:
![]() |
З існуючим записом:
![]() |
У базі даних можна перевірити, що зустріч дійсно була видалена. Новий календар повертається.
8.6.7.11. Дія [/validerRv]
Ми запитуємо операцію [validerRv] із таким значенням:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Отримано такий результат:
![]() |
У базі даних можна перевірити, що зустріч дійсно створено. Новий календар було надіслано.
Те саме робимо з номером часового проміжку, якого не існує:
![]() |
Те саме робимо з неіснуючим номером клієнта:
![]() |
8.6.8. Крок 5: Написання клієнтського JavaScript-коду
Повернемося до архітектури сервера [Web1]:
![]() |
Клієнт [2] сервера [Web1] є клієнтом JavaScript типу APU (односторінковий додаток):
- клієнт запитує стартову сторінку у веб-сервера (не обов’язково [Web1]);
- він запитує наступні сторінки у сервера [Web1] за допомогою викликів Ajax;
Для створення цього клієнта ми будемо використовувати інструмент [Webstorm] (див. розділ 9.8). Цей інструмент здався мені зручнішим, ніж STS. Його головна перевага полягає в тому, що він пропонує автозаповнення під час введення коду, а також деякі опції refactoring. Це дозволяє уникнути багатьох помилок.
8.6.8.1. Проєкт JS
Проєкт JS має таку структуру:
![]() |
- у [1] міститься весь клієнт JS. [boot.html] — це стартова сторінка. Це буде єдина сторінка, яку завантажить браузер;
- в [2] — таблиці стилів компонентів Bootstrap;
- у [3] — кілька зображень, що використовуються додатком;
![]() |
- у [4] — скрипти JS. Саме тут ми будемо працювати;
- у [5] — бібліотеки JS, що використовуються: переважно jQuery, а також бібліотеки компонентів Bootstrap;
8.6.8.2. Архітектура коду
Код було розділено на три рівні:
![]() |
- шар [présentation] об’єднує функції ініціалізації сторінки [boot.xml], а також функції різних компонентів Bootstrap. Він реалізований у файлі [ui.js];
- шар [événements] об’єднує всі обробники подій шару [présentation]. Він реалізований файлом [evts.js];
- шар [DAO] надсилає запити HTTP до сервера [Web1]. Він реалізований файлом [dao.js];
8.6.8.3. Рівень [présentation]
![]() |
Рівень [présentation] реалізовано за допомогою наступного файлу [ui.js]:
//шар [présentation]
var ui = {
// глобальні змінні;
"agenda": "",
"resa": "",
"langue": "",
"urlService": "http://localhost:8081",
"page": "login",
"jourAgenda": "",
"idMedecin": "",
"user": {},
"login": {},
"exceptionTitle": {},
"calendar_infos": {},
"erreur": "",
"idCreneau": "",
"done": "",
// компоненти подання
"body": "",
"navbar": "",
"jumbotron": "",
"content": "",
"exception": "",
"exception_text": "",
"exception_title": "",
"loading": ""
};
// шар подій
var evts = {};
// шар [dao]
var dao = {};
// ------------ документ готовий
$(document).ready(function () {
// ініціалізація документа
console.log("document.ready");
// компоненти сторінки
ui.navbar = $("#navbar");
ui.jumbotron = $("#jumbotron");
ui.content = $("#content");
ui.erreur = $("#erreur");
ui.exception = $("#exception");
ui.exception_text = $("#exception-text");
ui.exception_title = $("#exception-title");
// сторінка входу зберігається для подальшого відтворення
ui.login.lang = ui.langue;
ui.login.navbar = ui.navbar.html();
ui.login.jumbotron = ui.jumbotron.html();
ui.login.content = ui.content.html();
// URL сервісу
$("#urlService").val(ui.urlService);
});
// ------------------------ функції ініціалізації компонентів Bootstrap
ui.initNavBarStart = function () {
...
};
ui.initNavBarRun = function () {
...
};
ui.initChoixMedecinJour = function () {
...
};
ui.updateCalendar = function (renew) {
...
};
// відображає вибраний день
ui.displayJour = function () {
...
};
ui.initAgenda = function () {
...
};
ui.initResa = function () {
...
};
- щоб ізолювати шари один від одного, було вирішено розмістити їх у трьох об’єктах:
- [ui] для шару [présentation] (рядки 2–27),
- [evts] для шару управління подіями (рядок 29),
- [dao] для шару [DAO] (рядок 31);
Таке розділення рівнів на три об’єкти дозволяє уникнути низки конфліктів імен змінних та функцій. Кожен рівень використовує змінні та функції, іменам яких додається префікс об’єкта, що інкапсулює цей рівень.
- рядки 38–44: зберігаються поля, які завжди будуть присутні незалежно від того, які види відображаються. Це дозволяє уникнути повторюваних і непотрібних пошуків jQuery;
- рядки 46–49: локально зберігається стартову сторінку, щоб можна було відтворити її, коли користувач виходить із системи і не змінив мову;
- рядки 54–83: функції ініціалізації компонентів Bootstrap. Усі вони були розглянуті в розділі 8.6.4, присвяченому цим компонентам;
8.6.8.4. Допоміжні функції шару [événements]
![]() |
Обробники подій розміщено у файлі [evts.js]. Декілька функцій регулярно використовуються обробниками подій. Нижче наведено їх опис:
// початок очікування
evts.beginWaiting = function () {
// початок очікування
ui.loading = $("#loading");
ui.loading.show();
ui.exception.hide();
ui.erreur.hide();
evts.travailEnCours = true;
};
// завершення очікування
evts.stopWaiting = function () {
// кінець очікування
evts.travailEnCours = false;
ui.loading = $("#loading");
ui.loading.hide();
};
// відображення результату
evts.showResult = function (result) {
// відображення отриманих даних
var data = result.data;
// аналізується стан
switch (result.status) {
case 1:
// помилка?
if (data.status == 2) {
ui.erreur.html(data.content);
ui.erreur.show();
} else {
if (data.navbar) {
ui.navbar.html(data.navbar);
}
if (data.jumbotron) {
ui.jumbotron.html(data.jumbotron);
}
if (data.content) {
ui.content.html(data.content)
}
if (data.agenda) {
ui.agenda = $("#agenda");
ui.resa = $("#resa");
}
}
break;
case 2:
// відображення помилки
evts.showException(data);
break;
}
};
// ------------ різні функції
evts.showException = function (data) {
// відображення помилки
ui.exception.show();
ui.exception_text.html(data);
ui.exception_title.text(ui.exceptionTitle[ui.langue]);
};
- рядок 2: функція [evts.beginwaiting] викликається перед будь-якою асинхронною дією [DAO];
- рядки 4–5: відображається анімований значок очікування;
- рядки 6–7: приховується область відображення помилок та винятків (це не одне й те саме);
- рядок 8: відзначається, що виконується асинхронна операція;
- рядок 12: функція [evts.stopwaiting] викликається після того, як асинхронна операція [DAO] повернула свій результат;
- рядок 14: фіксується завершення асинхронної операції;
- рядок 15: приховується анімований зображення очікування;
- рядок 20: функція [evts.showResult] відображає результат [result] асинхронної дії [DAO]. Результатом є об’єкт JS такого вигляду: {'status':status,'data':data,'sendMeBack':sendMeBack}.
- рядки 47–50: використовуються, якщо [result.status==2]. Це відбувається, коли сервер [Web1] надсилає відповідь із заголовком помилки HTTP (наприклад, 403 forbidden). У цьому випадку [data] — це рядок jSON, надісланий сервером для повідомлення про помилку;
- рядок 25: випадок, коли отримано дійсну відповідь від сервера [Web1]. Поле [data] містить відповідь сервера: {'status':status,'navbar':navbar,'jumbotron':jumbotron,'agenda':agenda,'content':content};
- рядок 27: випадок, коли сервер [Web1] надіслав відповідь з помилкою {'status':2,'navbar':null,'jumbotron':null,'agenda':null,'content':помилки};
- рядки 28–29: відображається вид [erreurs];
- рядки 31–33: можливе відображення навігаційної панелі;
- рядки 34–36: можливе відображення джамботрона;
- рядки 37–39: можливе відображення поля [data.content]. Залежно від обставин представляє один із видів [accueil, agenda];
- рядки 40–43: якщо календар було оновлено, отримуються певні посилання на його компоненти, щоб не шукати їх щоразу, коли вони знадобляться;
- рядок 54: функція [evts.showException] призначена для відображення тексту винятку, що міститься в її параметрі [data];
- рядки 57–58: текст винятку виводиться на екран;
- рядок 58: назва винятку залежить від поточної мови;
Файл [evts.js] містить понад 300 рядків коду, і я не буду коментувати їх усі. Я просто наведу кілька прикладів, щоб продемонструвати суть цього рівня.
8.6.8.5. Вхід користувача

Вхід користувача забезпечується наступною функцією:
// ------------------------ підключення
evts.connecter = function () {
// отримуємо значення для відправки
var login = $("#login").val().trim();
var passwd = $("#passwd").val().trim();
// встановлюємо URL сервера
ui.urlService = $("#urlService").val().trim();
dao.setUrlService(ui.urlService);
// параметри запиту
var post = {
"user": {
"login": login,
"passwd": passwd
},
"lang": ui.langue
};
var sendMeBack = {
"user": {
"login": login,
"passwd": passwd
},
"caller": evts.connecterDone
};
// надсилання запиту
evts.execute([{
"name": "accueil-sans-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- рядки 4–5: отримуємо логін та пароль користувача;
- рядки 7–8: отримується URL із сервісу [Web1]. Вона зберігається як у шарі [ui], так і в шарі [dao];
- рядки 10–16: значення, яке потрібно відправити: поточна мова та користувач, який намагається увійти;
- рядки 17–23: об’єкт [sendMeBack] — це об’єкт, який передається до функції [DAO], що буде викликана, і який ця функція має повернути до функції з рядка 22. Тут об’єкт [sendMeBack] інкапсулює користувача, який намагається увійти;
- рядки 25–29: функція [evts.execute] здатна виконувати послідовність асинхронних дій. Тут передається список, що складається з однієї дії. Поля цієї дії такі:
- [name]: назва асинхронної дії, яку потрібно виконати,
- [post]: значення, яке потрібно відправити на сервер [Web1],
- [sendMeBack]: значення, яке асинхронна дія повинна повернути разом із результатом;
Перш ніж детально розглянути функцію [evts.execute], звернемося до функції [evts.connecterDone] у рядку 22. Саме цій функції викликана асинхронна функція [DAO] повинна повернути свій результат:
evts.connecterDone = function (result) {
// відображення результату
evts.showResult(result);
// з'єднання встановлено?
if (result.status == 1 && result.data.status == 1) {
// сторінка
ui.page = "accueil-sans-agenda";
// реєстрація користувача
ui.user = result.sendMeBack.user;
}
};
- рядок 3: відображається результат, повернутий сервером [Web1];
- рядок 5: якщо цей результат не містить помилок, то зберігаються тип нової сторінки (рядок 7) та автентифікований користувач (рядок 9);
Функція [evts.execute] виконує послідовність асинхронних дій:
// виконання послідовності дій
evts.execute = function (actions) {
// робота триває?
if (evts.travailEnCours) {
// нічого не робиться
return;
}
// очікування
evts.beginWaiting();
// виконання дій
dao.doActions(actions, evts.stopWaiting);
};
- рядок 2: параметр [actions] — це список асинхронних дій, які потрібно виконати;
- рядки 4–7: виконання приймається лише в тому випадку, якщо інше виконання ще не розпочалося;
- рядок 9: запускається очікування;
- рядок 11: надсилається запит до рівня [DAO] на виконання послідовності дій. Другий параметр — це ім’я функції, яку слід виконати, коли всі дії послідовності повернуть свої результати;
Зараз ми не будемо детально розглядати функцію [dao.doActions]. Перейдемо до розгляду іншої події.
8.6.8.6. Зміна мови

Зміну мови забезпечує така функція:
// ------------------------ зміна мови
evts.setLang = function (lang) {
// зміна мови?
if (lang == ui.langue) {
// нічого не робиться
return;
}
// нова мова
ui.langue = lang;
// яку сторінку потрібно перекласти?
switch (ui.page) {
case "login":
evts.getLogin();
break;
case "accueil-sans-agenda":
evts.getAccueilSansAgenda();
break;
case "accueil-avec-agenda":
evts.getAccueilAvecAgenda(ui);
break;
}
};
- рядок 2: параметр [lang] — це нова мова: «fr» або «en»;
- рядки 4–7: якщо нова мова є поточною, ніяких дій не виконується;
- рядок 9: запам'ятовується нова мова;
- рядки 12–20: у разі зміни мови необхідно перегенерувати сторінку, яка наразі відображається у браузері. Можливі три варіанти сторінок:
- сторінка з назвою [login], де відображається сторінка автентифікації,
- сторінка з назвою [accueil-sans-agenda] — це сторінка, що відображається одразу після успішної автентифікації,
- сторінка з назвою [accueil-avec-agenda] — це сторінка, що відображається після перегляду першого календаря. Далі вона залишається на екрані доти, доки користувач не вийде з системи;
Ми розглянемо випадок сторінки [accueil-avec-agenda]. Існує три версії цієї функції:
![]() |
- версія [ getAccueilAvecAgenda-one] виконує одну асинхронну дію;
- версія [ getAccueilAvecAgenda-parallel] виконує чотири асинхронні дії паралельно;
- версія [ getAccueilAvecAgenda-sequence] виконує чотири асинхронні дії послідовно;
8.6.8.7. Функція [ getAccueilAvecAgenda-one]
Це така функція:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// параметри запиту
var post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
var sendMeBack = {
"caller": evts.getAccueilAvecAgendaDone
};
// запит
evts.execute([{
"name": "accueil-avec-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- рядки 4–9: значення, що відправляється, містить інформацію про авторизованого користувача, бажану мову, номер лікаря, розклад якого потрібно отримати, та день, що цікавить;
- рядки 10–12: об’єкт [sendMeBack] — це об’єкт, який буде повернуто функції з рядка 11. Тут він не містить жодної інформації;
- рядки 14–18: виконання послідовності асинхронної дії, яка має назву [accueil-avec-agenda] (рядок 15);
- рядок 11: функція, що виконується, коли асинхронна дія [accueil-avec-agenda] поверне свій результат;
Функція [evts.getAccueilAvecAgendaDone] у рядку 11 відображає результат асинхронної функції з назвою [accueil-avec-agenda]:
evts.getAccueilAvecAgendaDone = function (result) {
// відображення результату
evts.showResult(result);
// нова сторінка?
if (result.status == 1 && result.data.status == 1) {
ui.page = "accueil-avec-agenda";
}
};
- рядок 1: [result] — це результат роботи асинхронної функції з іменем [accueil-avec-agenda];
- рядок 3: цей результат виводиться на екран;
- рядок 5: якщо результат без помилок, записуємо нову сторінку (рядок 6);
8.6.8.8. Функція [ getAccueilAvecAgenda-parallel]
Це така функція:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// дії [navbar-run, jumbotron, accueil, agenda] у //
// навігаційна панель-біг
var navbarRun = {
"name": "navbar-run"
};
navbarRun.post = {
"lang": ui.langue
};
navbarRun.sendMeBack = {
"caller": evts.showResult
};
// джамботрон
var jumbotron = {
"name": "jumbotron"
};
jumbotron.post = {
"lang": ui.langue
};
jumbotron.sendMeBack = {
"caller": evts.showResult
};
// головна
var accueil = {
"name": "accueil"
};
accueil.post = {
"lang": ui.langue,
"user": ui.user
};
accueil.sendMeBack = {
"caller": evts.showResult
};
// календар
var agenda = {
"name": "agenda"
};
agenda.post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin': ui.idMedecin,
'день': ui.jourAgenda,
"caller": evts.getAgendaDone
};
// виконання дій у //
evts.execute([navbarRun, jumbotron, accueil, agenda])
};
- рядок 51: цього разу виконуються чотири асинхронні дії. Вони будуть виконуватися паралельно;
- рядки 5–13: визначення дії [navbarRun], яка отримує панель навігації [navbar-run];
- рядок 12: функція, яку слід виконати, коли асинхронна дія [navbarRun] поверне результат;
- рядки 15–23: визначення дії [jumbotron], яка отримує представлення [jumbotron];
- рядок 22: функція, яку слід виконати, коли асинхронна дія [jumbotron] поверне результат;
- рядки 25–34: визначення дії [accueil], яка отримує представлення [accueil];
- рядок 33: функція, яку слід виконати, коли асинхронна дія [accueil] поверне результат;
- рядки 36–49: визначення дії [agenda], яка отримує представлення [jumbotron];
- рядок 48: функція, яку слід виконати, коли асинхронна дія [agenda] поверне результат;
8.6.8.9. Функція [ getAccueilAvecAgenda-sequence]
Це така функція:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// дії [navbar-run, jumbotron, accueil, agenda] у порядку
// календар
var agenda = {
"name" : "agenda"
};
agenda.post = {
"user" : ui.user,
"lang" : ui.langue,
"idMedecin" : ui.idMedecin,
"jour" : ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin' : ui.idMedecin,
'день: ui.jourAgenda,
"caller" : evts.getAgendaDone
};
// головна
var accueil = {
"name" : "accueil"
};
accueil.post = {
"lang" : ui.langue,
"user" : ui.user
};
accueil.sendMeBack = {
"caller" : evts.showResult,
"next" : agenda
};
// джамботрон
var jumbotron = {
"name" : "jumbotron"
};
jumbotron.post = {
"lang" : ui.langue
};
jumbotron.sendMeBack = {
"caller" : evts.showResult,
"next" : accueil
};
// навігаційна панель-запуск
var navbarRun = {
"name" : "navbar-run"
};
navbarRun.post = {
"lang" : ui.langue
};
navbarRun.sendMeBack = {
"caller" : evts.showResult,
"next" : jumbotron
};
// послідовне виконання дій
evts.execute([ navbarRun ])
};
- рядок 54: виконується дія [navbarRun]. Після її завершення переходять до наступної: [jumbotron], рядок 51. Ця дія, у свою чергу, виконується. Після її завершення переходять до наступної: [accueil], рядок 40. Ця дія, у свою чергу, виконується. Після її завершення переходять до наступної: [agenda], рядок 29. Ця дія виконується по черзі. Після її завершення процес зупиняється, оскільки дія [agenda] не має наступної дії.
8.6.8.10. Рівень [DAO]
![]() |
Файл [dao.js] об’єднує всі функції шару [DAO]. Ми розглянемо їх послідовно:
// URL, що надаються сервером
dao.urls = {
"login": "/getLogin",
"accueil": "/getAccueil",
"jumbotron": "/getJumbotron",
"agenda": "/getAgenda",
"supprimerRv": "/supprimerRv",
"validerRv": "/validerRv",
"navbar-start": "/getNavbarStart",
"navbar-run": "/getNavbarRun",
"accueil-sans-agenda": "/getNavbarRunJumbotronAccueil",
"accueil-avec-agenda": "/getNavbarRunJumbotronAccueilAgenda"
};
// --------------- інтерфейс
// URL-адреса сервера
dao.setUrlService = function (urlService) {
dao.urlService = urlService;
};
- рядки 16–18: функція, що дозволяє встановити URL для служби [Web1];
- рядки 2–13: словник, що пов’язує назву асинхронної дії з URL сервера [Web1], до якого слід надсилати запит;
// ------------------ загальне управління діями
// виконання послідовності асинхронних дій
dao.doActions = function (actions, done) {
// обробка дій
dao.actionsCount = actions.length;
dao.actionIndex = 0;
for (var i = 0; i < dao.actionsCount; i++) {
// асинхронний запит DAO
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, actions[i], done);
}
};
- рядок 3: функція [dao.doActions] виконує послідовність асинхронних дій [actions]. Параметр [done] — це функція, яку слід виконати, коли всі дії повернуть свої результати;
- рядки 7–12: асинхронні дії виконуються паралельно. Однак у разі, якщо одна з них має наступну дію, остання виконується після завершення попередньої дії;
- рядок 9: об’єкт [Deferred] перебуває у стані [pending];
- рядок 10: коли цей об’єкт перейде у стан [resolved], буде виконано функцію [dao.actionDone];
- рядок 11: дія № i зі списку виконується асинхронно. Параметр [done] з рядка 3 передається як аргумент;
Функція [dao.actionDone], яка виконується наприкінці кожної асинхронної дії, має такий вигляд:
// отримано результат
dao.actionDone = function (result) {
// виклик?
var sendMeBack = result.sendMeBack;
if (sendMeBack && sendMeBack.caller) {
sendMeBack.caller(result);
}
// наступний?
if (sendMeBack && sendMeBack.next) {
// асинхронний запит DAO
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, sendMeBack.next, sendMeBack.done);
}
// завершено?
dao.actionIndex++;
if (dao.actionIndex == dao.actionsCount) {
// готово?
if (sendMeBack && sendMeBack.done) {
sendMeBack.done(result);
}
}
};
- рядок 2: функція [dao.actionDone] отримує результат [result] від однієї з асинхронних дій зі списку дій, що підлягають виконанню;
- рядки 4–7: якщо завершена асинхронна дія вказала функцію, якій слід повернути результат, ця функція викликається;
- рядки 9–14: якщо завершена асинхронна дія має наступну, то ця дія, у свою чергу, виконується;
- рядок 16: дія завершена. Збільшується лічильник завершених дій. Дія, яка має невизначену кількість наступних дій, зараховується як одна дія;
- рядки 19–21: якщо спочатку було вказано, що функція [done] має виконуватися після того, як усі дії послідовності повернуть свої результати, то ця функція тепер виконується;
Метод [dao.doAction] виконує асинхронну дію:
// виконання дії
dao.doAction = function (deferred, action, done) {
// функція done, яку потрібно вбудувати в дію
if (action.sendMeBack) {
action.sendMeBack.done = done;
} else {
action.sendMeBack = {
"done": done
};
}
// виконання дії
dao.executePost(deferred, action.sendMeBack, dao.urls[action.name], action.post)
};
- рядки 4–10: як ми щойно бачили, функція, яка оброблятиме результат асинхронної дії, що буде виконана, повинна мати доступ до функції [done]. Для цього останню поміщають в об’єкт [sendMeBack], який стане частиною результату асинхронної операції;
- рядок 12: виконується функція [dao.executePost], яка здійснює виклик HTTP до сервера [Web1]. Цільовим об’єктом URL є URL, пов’язаний з назвою дії, що має бути виконана;
Функція [dao.executePost] виконує виклик HTTP:
// запит HTTP
dao.executePost = function (deferred, sendMeBack, url, post) {
// виконується ручний виклик Ajax
$.ajax({
headers: {
'«Accept»: 'application/json',
'Content-Type': 'application/json'
},
url: dao.urlService + url,
type: 'POST',
data: JSON3.stringify(post),
dataType: 'json',
success: function (data) {
// повертаємо результат
deferred.resolve({
"status": 1,
"data": data,
"sendMeBack": sendMeBack
});
},
error: function (jqXHR, textStatus, errorThrown) {
var data;
if (jqXHR.responseText) {
data = jqXHR.responseText;
} else {
data = textStatus;
}
// виводимо помилку
deferred.resolve({
"status": 2,
"data": data,
"sendMeBack": sendMeBack
});
}
});
};
Ми вже зустрічали цю функцію та коментували її. Зазначимо лише, що в рядку 9 ціллю функції URL є об’єднання функції URL сервера [Web1] з функцією URL, пов’язаною з назвою дії.
8.6.8.11. Сторінка завантаження
![]() |

Сторінка завантаження [boot.html] відображає вигляд, показаний вище. Це єдина сторінка, яка завантажується безпосередньо браузером. Решта сторінок отримуються за допомогою викликів Ajax. Її код такий:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>RdvMedecins</title>
<!-- Ядро Bootstrap CSS -->
<link rel="stylesheet" href="css/bootstrap-3.1.1-min.css"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap-select.min.css"/>
<link rel="stylesheet" type="text/css" href="css/datepicker3.css"/>
<link rel="stylesheet" type="text/css" href="css/footable.core.min.css"/>
<!-- Налаштовані стилі для цього шаблону -->
<link rel="stylesheet" type="text/css" href="css/rdvmedecins.css"/>
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="vendor/bootstrap.js"></script>
<script type="text/javascript" src="vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="vendor/footable.js"></script>
<!-- Скрипти користувачів -->
<script type="text/javascript" src="js/json3.js"></script>
<script type="text/javascript" src="js/ui.js"></script>
<script type="text/javascript" src="js/evts.js"></script>
<script type="text/javascript" src="js/getAccueilAvecAgenda-sequence.js"></script>
<script type="text/javascript" src="js/dao.js"></script>
</head>
<body id="body">
<div id="navbar">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="images/loading.gif" alt="waiting..." style="display: none"/>
<!-- форма входу -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" placeholder="URL du serveur" class="form-control" id="urlService"/>
</div>
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" id="login"/>
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" id="passwd"/>
</div>
<button type="button" class="btn btn-success" onclick="javascript:evts.connecter()">Connexion</button>
<!-- мови -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span> <span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li><a href="javascript:evts.setLang('fr')">Français</a></li>
<li><a href="javascript:evts.setLang('en')">English</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<!-- Jumbotron на базі Bootstrap -->
<div id="jumbotron">
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="images/caduceus.jpg" alt="RvMedecins"/>
</div>
<div class="col-md-10">
<h1>
Cabinet médical<br/>Les Médecins associés
</h1>
</div>
</div>
</div>
</div>
<!-- панелі помилок -->
<div id="erreur"></div>
<div id="exception" class="alert alert-danger" style="display: none">
<h3 id="exception-title"></h3>
<span id="exception-text"></span>
</div>
<!-- вміст -->
<div id="content">
<div class="alert alert-info">Authentifiez-vous pour accéder à l'application</div>
</div>
</div>
<!-- початкова сторінка -->
<script>
// ініціалізація сторінки
ui.langue = 'fr';
ui.exceptionTitle['fr'] = "L'erreur suivante s'est produite côté serveur :";
ui.exceptionTitle['en'] = "The following server error was met:";
ui.initNavBarStart();
</script>
</body>
</html>
- ми вже зустрічали цей тип сторінки в розділі про Bootstrap (параграф 8.6.4);
- рядки 99–105: ініціалізація деяких елементів шару [présentation];
- у рядку 27 використовується скрипт [getAccueilAvecAgenda-sequence.js]. Змінюючи скрипт у цьому рядку, ми отримуємо три різні варіанти поведінки для завантаження сторінки [accueil-avec-agenda]:
- [getAccueilAvecAgenda-one.js] отримує сторінку за допомогою одного виклику HTTP,
- [getAccueilAvecAgenda-parallel.js] отримує сторінку за допомогою чотирьох одночасних викликів HTTP,
- [getAccueilAvecAgenda-sequence.js] отримує сторінку за допомогою чотирьох послідовних викликів HTTP;
8.6.8.12. Tests
Існують різні способи проведення тестів. Тут ми будемо використовувати інструмент [Webstorm]:
![]() |
- у [1] відкриваємо проєкт. Просто вказуємо папку [2], що містить статичну структуру (HTML, CSS, JS) сайту, який потрібно протестувати;
![]() |
- у [3] — статичний сайт;
- в [4-5] завантажується сторінка [boot.html];
![]() |
- на [5], бачимо, що вбудований сервер на [Webstorm] надав сторінку [boot.html] з порту [63342]. Це важливо зрозуміти, оскільки це означає, що скрипти сторінки [boot.html] будуть здійснювати міждоменні виклики до сервера [Web1], який, у свою чергу, працює на [localhost:8081]. Браузер, який завантажив сторінку [boot.html], знає, що завантажив її з [localhost:63342]. Тому він не дозволить цій сторінці здійснювати виклики до сайту [localhost:8081], оскільки це не той самий порт. Тому він буде реалізовувати міждоменні виклики, описані в параграфі 8.4.14. З цієї причини додаток [Web1] має бути налаштований на прийняття таких міждоменних викликів. Це визначається у файлі [AppConfig] сервера Spring / Thymeleaf:
![]() |
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// адміністратор / адміністратор
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// кореневий веб-сервіс / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// тайм-аут у мілісекундах
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
Ми залишаємо читачеві можливість самостійно протестувати клієнт JS. Він повинен бути здатний відтворити функціонал, описаний у розділі 8.6.3.
Після того як клієнт JS буде визнаний правильним, його можна розгорнути в папці сервера [Web1], щоб уникнути необхідності авторизації міждоменних запитів:
![]() |
Вище ми скопіювали протестований сайт у папку [src / main / resources / static]. Далі можна звернутися до URL та [http://localhost:8081/boot.html]:

Тепер нам більше не потрібні міждоменні запити, і ми можемо внести відповідні зміни у файл конфігурації [AppConfig] сервера [Web1]:
// CORS
private final boolean CORS_ALLOWED=false;
Вищезазначений додаток продовжуватиме працювати. Якщо повернутися до додатка [Webstorm], він більше не працюватиме:


Якщо перейти до консолі розробника (Ctrl-Shift-I), можна побачити причину помилки:

Це помилка несанкціонованого міждоменного запиту.
8.6.8.13. Conclusion
Ми реалізували таку архітектуру JS:
![]() |
- шари досить чітко розділені;
- ми маємо додаток типу APU (односторінковий додаток). Саме ця особливість тепер дозволить нам створити нативний додаток для різних мобільних пристроїв (Android, IoS, Windows Phone);
- ми створили модель, здатну виконувати асинхронні дії паралельно, послідовно або поєднувати обидва підходи;
8.6.9. етап 6: створення нативного додатка для Android
Інструмент [Phonegap] [http://phonegap.com/] дозволяє створити виконуваний файл для мобільних пристроїв (Android, IoS, Windows 8, ...) на основі додатка HTML / JS / CSS. Існують різні способи досягнення цієї мети. Ми використовуємо найпростіший: онлайн-інструмент на сайті Phonegap [http://build.phonegap.com/apps]. Цей інструмент завантажить zip-файл статичного сайту, який потрібно перетворити. Сторінка завантаження повинна називатися [index.html]. Тому ми перейменовуємо сторінку [boot.html] на [index.html]:
![]() |
потім ми стискаємо папку, у даному випадку [rdvmedecins-client-js-03]. Далі ми переходимо на сайт Phonegap [http://build.phonegap.com/apps]:
![]() |
- перед тим, як перейти за посиланням [1], можливо, вам доведеться створити обліковий запис;
- на [1] починаємо роботу;
- на [2] обираємо безкоштовний тариф, який дозволяє створити лише один додаток Phonegap;
![]() |
- у [3] завантажуємо архів із додатком [4];
![]() |
- у [5] надайте назву додатку;
- у [6] — запускаємо процес створення. Ця операція може тривати 1 хвилину. Зачекайте, доки піктограми різних мобільних платформ не покажуть, що створення завершено;
![]() |
- були згенеровані лише бінарні файли для Android ([7]) та Windows ([8]);
- натисніть на [7], щоб завантажити бінарний файл для Android;
![]() |
- у [9] завантажений бінарний файл [apk];
Запустіть емулятор [GenyMotion] для планшета на базі Android (див. розділ 9.9):
![]() |
Вище показано запуск емулятора планшета з Android версії API 19. Після запуску емулятора
- розблокуйте його, потягнувши за засувку (якщо вона є) убік, а потім відпустивши її;
- за допомогою миші перетягніть завантажений файл [PGBuildApp-debug.apk] і відпустіть його на емуляторі. Після цього програма буде встановлена та запущена;
![]() |
Необхідно змінити URL на [1]. Для цього у командному вікні введіть команду [ipconfig] (рядок 1 нижче), яка відобразить різні адреси IP вашого комп’ютера:
C:\Users\Serge Tahé>ipconfig
Configuration IP de Windows
Carte réseau sans fil Connexion au réseau local* 15 :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
Carte Ethernet Connexion au réseau local :
Suffixe DNS propre à la connexion. . . : ad.univ-angers.fr
Adresse IPv6 de liaison locale. . . . .: fe80::698b:455a:925:6b13%4
Adresse IPv4. . . . . . . . . . . . . .: 172.19.81.34
Masque de sous-réseau. . . . . . . . . : 255.255.0.0
Passerelle par défaut. . . . . . . . . : 172.19.0.254
Carte réseau sans fil Wi-Fi :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
...
Запишіть або адресу Wi-Fi IP (рядки 6–9), або адресу в локальній мережі IP (рядки 11–17). Потім використовуйте цю адресу IP у налаштуваннях веб-сервера URL:
![]() |
Після цього підключіться до веб-сервісу:
![]() |
Перевірте роботу додатка на емуляторі. Він має працювати. На стороні сервера можна дозволити або заборонити заголовки CORS у класі [ApplicationModel]:
// CORS
private final boolean CORS_ALLOWED=false;
Для додатка Android це не має значення. Він не виконується в браузері. А вимога щодо заголовків CORS походить саме від браузера, а не від сервера.
8.6.10. Висновки з аналізу конкретного випадку
Ми розробили таку архітектуру:
![]() |
Це складна трирівнева архітектура. Її метою було повторне використання шару [Web2], який був серверним шаром додатка [AngularJS-Spring MVC] з документа [Tutoriel AngularJS / Spring 4] доURL та [http://tahe.developpez.com/angularjs-spring4/]. Саме з цієї причини ми маємо трирівневу архітектуру. Тоді як у додатку [AngularJS-Spring MVC] клієнт [Web2] був клієнтом [AngularJS], то тут клієнтом [Web2] є дворівнева архітектура [jQuery] / [Spring MVC / Thymeleaf]. Кількість рівнів збільшилася, тому ми втратимо в продуктивності.
Досліджуваний тут додаток розроблявся з часом у трьох різних документах:
- [Introduction aux frameworks JSF2, Primefaces et Primefaces mobile], URL та [http://tahe.developpez.com/java/primefaces/]. Тоді цей приклад був реалізований з використанням фреймворків JSF2 / Primefaces. Primefaces — це бібліотека AJAX-компонентів, яка дозволяє уникнути написання JavaScript-коду. Розроблений тоді додаток був менш складним, ніж той, що розглядається тут. Він мав класичну веб-версію для комп’ютерів та мобільну версію для телефонів;
- [Tutoriel AngularJS / Spring 4] до URL [http://tahe.developpez.com/angularjs-spring4/]. Розроблений тоді додаток мав ті самі характеристики, що й той, який розглядається в цьому документі. Додаток також було адаптовано для Android;
- цей документ;
З цієї роботи для мене випливають такі висновки:
- додаток [Primefaces] був, безперечно, найпростішим у розробці, а його мобільна веб-версія виявилася ефективною. Для роботи з ним не потрібні знання JavaScript. Неможливо нативно перенести її на OS різних мобільних пристроїв, але чи це взагалі необхідно? Змінити стиль додатка, здається, складно. Адже ми працюємо зі стильовими таблицями Primefaces. Це може бути недоліком;
- додаток [AngularJS-Spring MVC] було складно написати. Фреймворк [AngularJS] здався мені досить складним для освоєння, якщо хочеш його досконало опанувати. Архітектура [client Angular] / [service web / jSON implémenté par Spring MVC] є особливо чіткою та ефективною. Цю архітектуру можна застосувати до будь-якого веб-додатку. Саме ця архітектура видається мені найбільш перспективною, оскільки вона задіює різні навички як на стороні клієнта, так і на стороні сервера (JS+HTML+CSS на стороні клієнта, Java або інше на стороні сервера), що дозволяє розробляти клієнт і сервер паралельно;
- щодо додатка, розробленого в цьому документі з використанням трирівневої архітектури [client jQuery] / [serveur Web1 / Spring MVC / Thymeleaf] / [serveur Web2 / Spring MVC], деяким може здатися, що технологія [jQuery+Spring MVC+Thymelaf] простіша для освоєння, ніж технологія [AngularJS]. Написаний нами рівень [DAO] клієнтського JavaScript можна використовувати в інших додатках;

























































































































































































































































































