2. Сервер Spring 4
![]() |
У наведеній вище архітектурі ми переходимо до створення веб-сервісу / JSON, побудованого на базі фреймворку Spring 4. Ми будемо писати його у кілька етапів:
- спочатку рівні [métier] та [DAO] (Data Access Object). Тут ми використовуватимемо Spring Data;
- потім — веб-сервіс JSON без аутентифікації. Тут ми будемо використовувати Spring MVC;
- потім додамо модуль автентифікації за допомогою Spring Security.
Почнемо з опису структури бази даних, на якій базується додаток.
2.1. База даних
![]() |
База даних, яку надалі називатимемо [dbrdvmedecins] , є базою даних MySQL5 із такими таблицями:
![]() |
Записи про зустрічі зберігаються у таких таблицях:
- [medecins]: містить список лікарів клініки;
- [clients]: містить список пацієнтів клініки;
- [creneaux]: містить часові слоти кожного з лікарів;
- [rv]: містить список прийомів лікарів.
Таблиці [roles], [users] та [users_roles] пов’язані з автентифікацією. Наразі ми не будемо їх розглядати.
Зв’язки між таблицями, що керують записами на прийом, такі:
![]() |
- один часовий проміжок належить одному лікарю — один лікар має 0 або кілька часових проміжків;
- запис на прийом об’єднує клієнта та лікаря через часовий проміжок останнього;
- клієнт має 0 або кілька зустрічей;
- з одним часовим проміжком пов’язано 0 або кілька зустрічей (у різні дні).
2.1.1. Таблиця [MEDECINS]
Вона містить інформацію про лікарів, які обслуговуються додатком [RdvMedecins].
![]() | ![]() |
- ID: номер, що ідентифікує лікаря — первинний ключ таблиці
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 щоразу, коли до рядка вносяться зміни.
- NOM: прізвище лікаря
- PRENOM: його ім’я
- TITRE: його/її титул (пані, пані, пан)
2.1.2. Таблиця [CLIENTS]
Пацієнти різних лікарів заносяться до таблиці [CLIENTS]:
![]() | ![]() |
- ID: номер, що ідентифікує клієнта — первинний ключ таблиці
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 кожного разу, коли до рядка вносяться зміни.
- NOM: ім’я клієнта
- PRENOM: його ім’я
- TITRE: титул (пані, пані, пан)
2.1.3. Таблиця [CRENEAUX]
У ній перелічено часові проміжки, у яких можливі RV:
![]() |
![]() |
- ID: номер, що ідентифікує часовий проміжок — первинний ключ таблиці (рядок 8)
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 кожного разу, коли до рядка вносяться зміни.
- ID_MEDECIN: номер, що ідентифікує лікаря, якому належить цей часовий проміжок — зовнішній ключ у стовпці MEDECINS (ID).
- HDEBUT: час початку часового проміжку
- MDEBUT: хвилини початку часового проміжку
- HFIN: година закінчення часового проміжку
- MFIN: хвилини закінчення інтервалу
Другий рядок таблиці [CRENEAUX] (див. [1] вище) вказує, наприклад, що слот № 2 починається о 8:20 і закінчується о 8:40 та належить лікарю № 1 (пані Марі PELISSIER).
2.1.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.
2.2. Вступ до 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] — кінцевий проект.
2.2.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.0.2.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-файлу.
2.2.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 ніде не називається.
2.2.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] за допомогою простого інтерфейсу.
2.2.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);
- рядок 1: виконується статичний метод [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;
- рядок 12: з’являється [hibernate]. Саме ця реалізація JPA була обрана;
- рядок 19: діалектом Hibernate є варіант SQL, який слід використовувати разом із SGBD. Тут діалект [H2Dialect] вказує, що Hibernate працюватиме з SGBD та H2;
- рядки 22–24: створюється таблиця [CUSTOMER]. Це означає, що Hibernate було налаштовано на генерацію таблиць на основі визначень JPA, у даному випадку — визначення JPA класу [Customer];
- рядки 27–32: журнали Hibernate, що відображають вставку записів у таблицю [CUSTOMER]. Це означає, що Hibernate було налаштовано на генерацію журналів;
- рядки 35–39: п’ять введених клієнтів;
- рядки 42–44: результат виклику методу [findOne] інтерфейсу;
- рядки 47–50: результати методу [findByLastName];
- рядки 51 і наступні: журнали закриття контексту Spring.
2.2.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.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<!-- Транзакції Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<!-- Spring Data -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.5.2.RELEASE</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>1.0.2.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>
- рядки 3–17: базові бібліотеки Spring;
- рядки 19–28: бібліотеки Spring для управління транзакціями з базою даних;
- рядки 30–34: Spring Data, що використовується для доступу до бази даних;
- рядки 36–40: Spring Boot для запуску додатка;
- рядки 48–52: SGBD H2;
- рядки 54–63: бази даних часто використовуються з пулами відкритих з’єднань, що дозволяє уникнути багаторазового відкриття та закриття з’єднань. Тут використовується реалізація [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]. Він отримує як параметр бін із рядків 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 більше немає.
Виконання дає ті самі результати, що й раніше.
2.2.6. Створення виконуваного архіву
Щоб створити виконуваний архів проекту, можна виконати такі дії:
![]() |
- у [1]: створюється конфігурація виконання;
- у [2]: типу [Java Application]
- у [3]: вказує проект, який потрібно виконати (використовуйте кнопку Browse);
- у [4]: вказує клас, який потрібно виконати;
- у [5]: назва конфігурації виконання — може бути будь-якою;
![]() |
- у [6]: проект експортується;
- у [7]: у вигляді виконуваного архіву JAR;
- в [8]: вказує шлях та ім’я виконуваного файлу, що створюється;
- у [9]: ім'я конфігурації виконання, створеної у [5];
Після цього відкриваємо консоль у папці, що містить архів з виконуваним файлом:
Архів запускається наступним чином:
.....\dist>java -jar gs-accessing-data-jpa-2.jar
У консолі з’являються такі результати:
2.2.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.1.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, можемо доповнити його, щоб написати проект шару персистентності сервера нашого додатка для управління зустрічами.
2.3. Проєкт сервера в Eclipse
![]() |
![]() |
Основні елементи проєкту такі:
- [pom.xml]: файл конфігурації Maven для проекту;
- [rdvmedecins.entities]: сутності JPA;
- [rdvmedecins.repositories]: інтерфейси Spring Data для доступу до сутностей JPA;
- [rdvmedecins.metier]: рівень [métier];
- [rdvmedecins.domain]: сутності, з якими працює рівень [métier];
- [rdvmdecins.config]: класи конфігурації шару персистентності;
- [rdvmedecins.boot]: простий консольний додаток;
2.4. Конфігурація 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.0.0.RELEASE</version>
</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>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
</dependency>
<dependency>
<groupId>commons-pool</groupId>
<artifactId>commons-pool</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<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>istia.st.spring.data.main.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<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]. Для залежностей, які вже присутні в батьківському проекті, версію не вказують. Використовуватиметься версія, визначена в батьківському проекті. Інші залежності заявляють у звичайному порядку;
- рядки 14–17: для Spring Data;
- рядки 18–22: для тестів JUnit;
- рядки 23–26: драйвер JDBC для SGBD та MySQL5;
- рядки 27–34: пул з’єднань Commons DBCP;
- рядки 35–38: бібліотека Jackson для управління JSON;
- рядки 39–43: бібліотека Google для управління колекціями;
Версія 1.1.0.RC1 від [spring-boot-starter-parent] використовує такі версії бібліотек:
2.5. Об’єкти 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.AUTO)
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)) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id == other.id;
}
// гетери та сеттери
..
}
- рядок 11: анотація [@MappedSuperclass] вказує, що анотований клас є батьківським для сутностей JPA та [@Entity];
- рядки 15–17: визначають первинний ключ [id] для кожного об’єкта. Саме анотація [@Id] робить поле [id] первинним ключем. Анотація [@GeneratedValue(strategy = GenerationType.AUTO)] вказує, що значення цього первинного ключа генерується за допомогою SGBD і що жоден режим генерації не є обов’язковим;
- рядки 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;
Об’єкт [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];
2.6. Рівень [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é], який вимагає виконання з’єднання з таблицею, на яку вказує зовнішній ключ, для отримання сутності, на яку вказує цей ключ;
2.7. Рівень [métier]
![]() |
![]() |
- [IMetier] є інтерфейсом шару [métier], а [Metier] — його реалізацією;
- [AgendaMedecinJour] та [CreneauMedecinJour] — це два бізнес-об’єкти;
2.7.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: його часові проміжки з прийомами або без них;
2.7.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;
}
Читачеві пропонується ознайомитися з коментарями. Алгоритм такий:
- витягуються всі часові слоти вказаного лікаря;
- отримуємо всі його прийоми на вказаний день;
- маючи ці дві інформації, можна визначити, чи є часовий проміжок вільним чи зайнятим;
2.8. Конфігурація проєкту
![]() |
Клас [DomainAndPersitenceConfig] налаштовує весь проект:
package rdvmedecins.config;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.orm.jpa.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories" })
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins" })
@EntityScan(basePackages = { "rdvmedecins.entities" })
@EnableTransactionManagement
public class DomainAndPersistenceConfig {
// джерело даних 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;
}
// провайдер JPA — не потрібен, якщо вас влаштовують значення за замовчуванням, які використовує Spring Boot
// тут ми його визначаємо для ввімкнення/вимкнення журналів SQL
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(false);
hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory та TransactionManager налаштовуються Spring Boot із значеннями за замовчуванням
}
- рядок 45: ми не будемо визначати біни [EntityManagerFactory] та [TransactionManager]. Для цього ми скористаємося анотацією [@EnableAutoConfiguration] від Spring Boot (рядок 17);
- рядки 24–32: визначають джерело даних MySQL5. Це bean, який, як правило, Spring Boot не може визначити самостійно;
- рядки 36–43: ми також налаштовуємо реалізацію JPA, щоб встановити атрибут [showSql] Hibernate на «false» (рядок 39). За замовчуванням він має значення «true»;
- наразі єдиними компонентами, що підтримуються Spring, є біни з рядків 25 і 37, а також біни [EntityManagerFactory] і [TransactionManager], які налаштовуються автоматично. Нам потрібно додати біни з рівнів [métier] та [DAO];
- у рядку 16 до контексту Spring додаються інтерфейси з пакета [rdvmdecins.repositories], які успадковують інтерфейс [CrudRepository];
- у рядку 18 до контексту Spring додаються всі класи пакета [rdvmedecins] та його похідні, що мають анотацію Spring. У пакеті [rdvmdecins.metier] клас [Metier] з анотацією [@Service] буде знайдено та додано до контексту Spring;
- рядок 45: Spring Boot за замовчуванням визначить бін [entityManagerFactory]. Цьому біну потрібно вказати, де знаходяться сутності JPA, якими він повинен керувати. Це робиться у рядку 19;
- рядок 20: вказує, що методи інтерфейсів, які успадковують інтерфейс [CrudRepository], мають виконуватися в рамках транзакції;
2.9. Тести рівня [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] було перевизначено: два записи про прийом вважаються однаковими, якщо вони мають однаковий id. У цьому випадку це свідчить про те, що доданий запис про прийом дійсно було внесено до бази даних;
- рядки 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, що свідчить про те, що шуканий прийом не існує;
Виконання тесту завершилося успішно:
![]() |
2.10. Консоль програми
![]() |
Консольна програма є простою. Вона демонструє, як отримати чужий ключ:
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], лікар не повертається разом із слотом. Однак ми подбали про те, щоб у сутності [Creneau] передбачити поле [idMedecin] для отримання первинного ключа лікаря;
- рядок 35: отримуємо первинну інформацію про лікаря;
- рядок 36: виводиться список прийомів лікаря;
Результати у консолі такі:
2.11. Вступ до Spring MVC
![]() |
Тепер перейдемо до побудови веб-шару. Він переважно складається з методів, які обробляють конкретні запити URL і відповідають одним рядком тексту у форматі JSON (Javascript Object Notation). Цей веб-шар є веб-інтерфейсом, який іноді називають API-веб. Ми реалізуємо цей інтерфейс за допомогою Spring MVC — ще однієї гілки екосистеми Spring. Почнемо з вивчення одного з посібників, знайдених на [http://spring.io].
2.11.1. Демонстраційний проєкт
![]() |
- у [1] ми імпортуємо один із посібників Spring;
![]() |
- у [2] ми обираємо приклад [Rest Service];
- у [3] вибираємо проект Maven;
- у [4] вибираємо остаточну версію посібника;
- у [5] підтверджуємо;
- у [6] — імпортований проєкт;
Веб-сервіси, доступні через стандартні URL і які надають текст JSON, часто називають сервісами REST (REpresentational State Transfer). У цьому документі я просто називатиму сервіс, який ми будемо створювати, веб-сервісом / JSON. Сервіс вважається Restful, якщо він дотримується певних правил. Я не намагався дотримуватися цих правил.
Тепер розглянемо імпортований проєкт, насамперед його конфігурацію Maven.
2.11.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>org.springframework</groupId>
<artifactId>gs-rest-service</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-releases</id>
<url>http://repo.spring.io/release</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-releases</id>
<url>http://repo.spring.io/release</url>
</pluginRepository>
</pluginRepositories>
</project>
- рядки 10–14: як і в проєкті [Spring Data], тут вказано батьківський проєкт [Spring Boot];
- рядки 17–20: артефакт [spring-boot-starter-web] містить бібліотеки, необхідні для проекту Spring MVC. Зокрема, він містить вбудований сервер Tomcat. Саме на цьому сервері буде виконуватися додаток;
- рядки 21–24: бібліотека Jackson обробляє JSON: перетворення об’єкта Java у рядок JSON і навпаки;
У цій конфігурації використовується велика кількість бібліотек:
![]() | ![]() |
Вище показано три архіви сервера Tomcat.
2.11.3. Архітектура сервісу Spring REST
Spring MVC реалізує архітектурну модель, відому як MVC (Модель – Вигляд – Контролер), наступним чином:
![]() |
Обробка запиту клієнта відбувається наступним чином:
- запит — запитувані URL мають вигляд http://machine:port/contexte/Action/param1/param2/....?p1=v1&p2=v2&... [Dispatcher Servlet] — це клас Spring, який обробляє вхідні URL. Вона «направляє» URL до дії, яка має її обробити. Ці дії — це методи певних класів, що називаються [Contrôleurs]. Літера «C» у MVC тут відповідає рядку [Dispatcher Servlet, Contrôleur, Action]. Якщо для обробки вхідного запиту URL не було налаштовано жодної дії, сервлет [Dispatcher Servlet] відповість, що запитуваний URL не знайдено (помилка 404 NOT FOUND);
- обробка
- обрана дія може використовувати параметри parami, які їй передала сервлета [Dispatcher Servlet]. Вони можуть походити з кількох джерел:
- з шляху [/param1/param2/...] сервлета URL,
- з параметрів [p1=v1&p2=v2] сервлета URL,
- з параметрів, надісланих браузером разом із запитом;
- під час обробки запиту користувача дії може знадобитися рівень [metier] [2b]. Після обробки запиту клієнта це може викликати різні відповіді. Класичним прикладом є:
- сторінка з повідомленням про помилку, якщо запит не вдалося обробити належним чином
- сторінка підтвердження в іншому випадку
- дія вимагає відображення певного виду [3]. Цей вид відображатиме дані, які називаються моделлю виду. Це «М» у MVC. Дія створить цю модель M [2c] і вимагатиме відображення виду V [3];
- відповідь — обраний вигляд V використовує модель M, створену дією, для ініціалізації динамічних частин відповіді HTML, яку він повинен надіслати клієнту, а потім надсилає цю відповідь.
Для веб-сервісу / JSON попередня архітектура дещо змінена:
![]() |
- у [4a] модель, яка є класом Java, перетворюється на рядок JSON за допомогою бібліотеки JSON;
- у [4b] цей рядок JSON надсилається до браузера;
2.11.4. Контролер C
![]() |
Імпортований додаток має такий контролер:
package hello;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class GreetingController {
private static final String template = "Hello, %s!";
private final AtomicLong counter = new AtomicLong();
@RequestMapping("/greeting")
public @ResponseBody
Greeting greeting(@RequestParam(value = "name", required = false, defaultValue = "World") String name) {
return new Greeting(counter.incrementAndGet(), String.format(template, name));
}
}
- рядок 9: анотація [@Controller] перетворює клас [GreetingController] на контролер Spring, тобто його методи зареєстровані для обробки об’єктів URL;
- рядок 15: анотація [@RequestMapping] вказує на URL, який обробляє метод, у даному випадку URL [/greeting]. Пізніше ми побачимо, що цей URL можна налаштувати і що ці параметри можна отримати;
- рядок 16: анотація [@ResponseBody] вказує, що метод не створює шаблон для подання (JSP, JSF, Thymeleaf, ...), який потім буде надіслано до браузера клієнта, а сама генерує відповідь, що надсилається до браузера. У цьому випадку вона створює об’єкт типу [Greeting] (рядок 18). Хоча це тут і не видно, цей об’єкт спочатку буде перетворено на JSON, перш ніж його надіслати до браузера. Саме наявність бібліотеки JSON у залежностях проєкту змушує Spring Boot шляхом автоконфігурації налаштувати проєкт саме таким чином;
- рядок 17: метод [greeting] має параметр [String name]. Анотація [@RequestParam(value = "name", required = false, defaultValue = "World"] вказує, що цей параметр має бути ініціалізований за допомогою параметра з іменем [name](@RequestParam(value = "name")). Останній може бути параметром GET або POST. Цей параметр не є обов’язковим (required = false). В останньому випадку параметр [name] методу буде ініціалізовано значенням [World] (defaultValue = "World").
2.11.5. Модель M
Модель M, створена за допомогою попереднього методу, є таким об’єктом [Greeting]:
![]() |
package hello;
public class Greeting {
private final long id;
private final String content;
public Greeting(long id, String content) {
this.id = id;
this.content = content;
}
public long getId() {
return id;
}
public String getContent() {
return content;
}
}
Перетворення JSON цього об’єкта створить рядок {"id":n,"content":"текст"}. У підсумку рядок JSON, створений методом контролера, матиме такий вигляд:
або
2.11.6. Налаштування проєкту
![]() |
Проект налаштовується за допомогою наступного класу [Application]:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
@ComponentScan
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- рядок 11: цікаво, що цей клас запускається за допомогою методу [main], характерного для консольних додатків. Це дійсно так. Клас [SpringApplication] у рядку 12 запустить сервер Tomcat, що міститься у залежностях, і розгорне на ньому службу REST;
- рядок 4: бачимо, що клас [SpringApplication] належить до проєкту [Spring Boot];
- рядок 12: перший параметр — це клас, що налаштовує проєкт, другий — можливі параметри;
- рядок 8: анотація [@EnableAutoConfiguration] вимагає від Spring Boot виконати конфігурацію проєкту;
- рядок 7: анотація [@ComponentScan] забезпечує сканування папки, що містить клас [Application], з метою пошуку компонентів Spring. Буде знайдено клас [GreetingController], який має анотацію [@Controller], що робить його компонентом Spring;
2.11.7. Виконання проєкту
Запустимо проект:
![]() |
Ми отримуємо такі записи консолі:
____ _ __ _ _
- рядок 12: сервер Tomcat запускається на порту 8080 (рядок 11);
- рядок 16: сервлет [DispatcherServlet] присутній;
- рядок 19: виявлено метод [GreetingController.greeting];
Щоб протестувати веб-додаток, надсилаємо запит на URL [http://localhost:8080/greeting]:
![]() | ![]() |
Ми отримуємо очікуваний рядок JSON. Може бути цікаво переглянути заголовки HTTP, надіслані сервером. Для цього ми використаємо плагін для Chrome під назвою [Advanced Rest Client] (див. Додатки):
![]() |
- у [1] — запитуваний URL;
- у [2] використовується метод GET;
- у [3] — відповідь JSON;
- у [4] сервер повідомив, що надсилає відповідь у форматі JSON;
- у [5] запитується той самий URL, але цього разу з POST;
- у [7] інформація надсилається на сервер у формі [urlencoded];
- у [6] — параметр name із його значенням;
- у [8] браузер повідомляє серверу, що надсилає йому інформацію [urlencoded];
- у [9] — відповідь JSON від сервера;
2.11.8. Створення виконуваного архіву
Виконувальний архів можна створити поза Eclipse. Необхідні налаштування містяться у файлі [pom.xml]:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.Application</start-class>
<java.version>1.7</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
- рядки 9–12 визначають плагін, який створюватиме виконуваний архів;
- рядок 3 визначає клас виконуваного файлу проекту;
Діємо таким чином:
![]() |
- у [1]: виконується ціль Maven;
- в [2]: є дві цілі (goals): [clean] для видалення папки [target] з проекту Maven, [package] для її повторного створення;
- у [3]: згенерована папка [target] буде розміщена в цій папці;
- у [4]: створюється цільовий файл;
У журналах, що з’являються в консолі, важливо побачити плагін [spring-boot-maven-plugin]. Саме він створює виконуваний архів.
За допомогою консолі переходимо до створеної папки:
- рядок 5: згенерований архів;
Цей архів запускається наступним чином:
Тепер, коли веб-додаток запущено, його можна відкрити у браузері:
![]() |
2.11.9. Розгортання додатка на сервері Tomcat
Хоча Spring Boot дуже зручний у режимі розробки, ймовірно, що робочий додаток буде розгорнуто на реальному сервері Tomcat. Ось як це зробити:
Відредагуйте файл [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-rest-service</artifactId>
<version>0.1.0</version>
<packaging>war</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
....
</project>
Зміни потрібно внести у двох місцях:
- рядок 9: потрібно вказати, що буде створено архів war (Web ARchive);
- рядки 26–30: потрібно додати залежність від артефакту [spring-boot-starter-tomcat]. Цей артефакт додає всі класи Tomcat до залежностей проєкту;
- рядок 29: цей артефакт — [provided], тобто відповідні архіви не будуть розміщені у згенерованому WAR-файлі. Адже ці архіви будуть розміщені на сервері Tomcat, на якому працюватиме додаток;
Крім того, необхідно налаштувати веб-додаток. За відсутності файлу [web.xml] це робиться за допомогою класу, що успадковує [SpringBootServletInitializer]:
![]() |
Клас [ApplicationInitializer] має такий вигляд:
package hello;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.web.SpringBootServletInitializer;
public class ApplicationInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(Application.class);
}
}
- рядок 6: клас [ApplicationInitializer] розширює клас [SpringBootServletInitializer];
- рядок 9: метод [configure] перевизначено (рядок 8);
- рядок 10: вказано клас, який налаштовує проект;
Щоб виконати проект, можна вчинити так:
![]() |
- у [1] — запускаємо проект на одному із серверів, зареєстрованих у IDE Eclipse;
- у [2] вибираємо [tc Server Developer], який присутній за замовчуванням. Це варіант Tomcat;
Після цього можна запустити URL [http://localhost:8080/gs-rest-service/greeting/?name=Mitchell] у браузері:
![]() |
Тепер ми знаємо, як створити архів war. Далі ми продовжимо працювати зі Spring Boot та його виконуваним архівом jar.
2.11.10. Створення нового веб-проєкту
Щоб створити новий веб-проект, можна вчинити наступним чином:
![]() |
- у [1]: «Файл» / «Створити» / «Проєкт Spring Starter»
- у [2]: вибрати [Web]. Бібліотеки подання не вибираємо, оскільки у веб-сервісі / JSON подання відсутні;
- створений проєкт буде проєктом Maven. У [3] вказуємо групу артефакту Maven, який буде створено, у [4] — ім’я артефакту;
- у полі [5] вказуємо ім’я пакета, куди Spring розмістить клас конфігурації проєкту;
- у полі [6] вказуємо ім’я проекту Eclipse — воно може відрізнятися від значення в полі [4];
![]() |
2.12. Рівень [web]
![]() |
![]() |
Ми будемо створювати веб-шар у кілька етапів:
- етап 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, який не обов’язково належатиме до того самого домену, що й наш веб-сервіс. За замовчуванням він не матиме доступу до нього, якщо веб-сервіс не надасть йому дозвіл. Ми розглянемо, як це зробити;
2.12.1. Налаштування Maven
Файл [pom.xml] проекту має такий вигляд:
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.mvc</groupId>
<artifactId>rdvmedecins-webapi-v1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-webapi-v1</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.0.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
- рядки 7–11: батьківський проект Maven;
- рядки 13–16: залежності для проекту Spring MVC;
- рядки 17–21: залежності від проекту шарів [métier, DAO, JPA];
2.12.2. Інтерфейс веб-сервісу
![]() |
- у [1], наведеному вище, браузер може запитувати лише обмежену кількість URL із чітко визначеним синтаксисом;
- у [4] він отримує відповідь JSON;
Усі відповіді нашого веб-сервісу матимуть однаковий формат, що відповідає перетворенню JSON об’єкта типу [Reponse], а саме:
package rdvmedecins.web.models;
public class Reponse {
// ----------------- властивості
// статус операції
private int status;
// відповідь JSON
private Object data;
// ---------------виробники
public Reponse() {
}
public Reponse(int status, Object data) {
this.status = status;
this.data = data;
}
// методи
public void incrStatusBy(int increment) {
status += increment;
}
// ----------------------гетери та сеттери
...
}
- рядок 7: код помилки відповіді 0: OK, інше: KO;
- рядок 9: тіло відповіді;
Тепер наводимо знімки екрана, що ілюструють інтерфейс веб-сервісу / 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 від веб-сервісу. Поле [data] містить форму JSON доданої зустрічі;
Наявність нової зустрічі можна перевірити:
![]() |
Видалити зустріч [/supprimerRv]
![]() |
- у [1] використовується URL веб-сервісу;
- у [2] використовується метод POST;
- у [3] — текст JSON інформації, переданої веб-сервісу у формі {idRv};
- у [4] клієнт повідомляє веб-сервісу, що надсилає йому інформацію JSON;
Відповідь тоді виглядає так:
![]() |
- у [5]: поле [status] має значення 0, що свідчить про успішне виконання операції;
Видалення зустрічі можна перевірити:
![]() |
Як показано вище, зустріч пацієнта [Mme GERMAN] більше не відображається.
Веб-сервіс також дозволяє отримувати об’єкти за їхнім ідентифікатором:
![]() |
![]() |
![]() |
![]() |
Усі ці URL обробляються контролером [RdvMedecinsController], який ми зараз презентуємо.
2.12.3. Структура контролера [RdvMedecinsController]
![]() |
Контролер [RdvMedecinsController] має такий вигляд:
package rdvmedecins.web.controllers;
import java.text.ParseException;
...
@RestController
public class RdvMedecinsController {
@Autowired
private ApplicationModel application;
private List<String> messages;
@PostConstruct
public void init() {
// повідомлення про помилки програми
messages = application.getMessages();
}
// список лікарів
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET)
public Reponse getAllMedecins() {
...
}
// список клієнтів
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET)
public Reponse getAllClients() {
...
}
// список вільних часів лікаря
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET)
public Reponse getAllCreneaux(@PathVariable("idMedecin") long idMedecin) {
...
}
// список прийомів лікаря
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getRvMedecinJour(@PathVariable("idMedecin") long idMedecin,
@PathVariable("jour") String jour) {
...
}
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET)
public Reponse getClientById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET)
public Reponse getMedecinById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET)
public Reponse getRvById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET)
public Reponse getCreneauById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse ajouterRv(@RequestBody PostAjouterRv post) {
...
}
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse supprimerRv(@RequestBody PostSupprimerRv post) {
...
}
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getAgendaMedecinJour(
@PathVariable("idMedecin") long idMedecin,
@PathVariable("jour") String jour) {
...
}
}
- рядок 6: анотація [@RestController] перетворює клас [RdvMedecinsController] на контролер Spring. Крім того, це також означає, що методи, які обробляють URL, генеруватимуть відповідь, яка автоматично перетворюватиметься на JSON;
- рядки 9–10: об’єкт типу [ApplicationModel] буде введений сюди Spring;
- рядок 13: анотація [@PostConstruct] позначає метод, який має бути виконаний одразу після інстанціювання класу. Коли цей метод виконується, об’єкти, введені Spring, вже доступні;
- усі методи повертають об’єкт типу [Reponse], а саме:
package rdvmedecins.web.models;
public class Reponse {
// ----------------- властивості
// статус операції
private int status;
// відповідь
private Object data;
...
}
Цей об’єкт серіалізується у JSON перед відправкою до браузера клієнта;
- рядок 20: анотація [@RequestMapping] визначає умови виклику методу. Тут метод обробляє запит GET від URL [/getAllMedecins]. Якби цей URL запитувався POST, він би відхилився, і Spring MVC надіслав би веб-клієнту код помилки HTTP;
- рядок 32: URL налаштовується за допомогою {idMedecin}. Цей параметр отримується за допомогою анотації [@PathVariable] у рядку 33;
- рядок 33: єдиний параметр [long idMedecin] отримує своє значення від параметра {idMedecin} з URL [@PathVariable("idMedecin")]. Параметр у URL та параметр методу можуть мати різні імена. Слід зауважити, що [@PathVariable("idMedecin")] має тип String (увесь URL є String), тоді як параметр [long idMedecin] має тип [long]. Зміна типу відбувається автоматично. Якщо ця зміна типу не вдається, повертається код помилки HTTP;
- рядок 65: анотація [@RequestBody] позначає тіло запиту. У запиті типу GET тіло майже ніколи не вказується (хоча його можна вказати). У запиті типу POST тіло вказується найчастіше (хоча його можна не вказувати). Для запитів URL та [ajouterRv] веб-клієнт надсилає у своєму запиті POST такий рядок:
Синтаксис [@RequestBody PostAjouterRv post] (рядок 65) у поєднанні з тим, що метод очікує JSON [consumes = "application/json; charset=UTF-8"] у рядку 64, призведе до того, що рядок JSON, надісланий веб-клієнтом, буде десеріалізовано в об’єкт типу [PostAjouter]. Він має такий вигляд:
package rdvmedecins.web.models;
public class PostAjouterRv {
// дані запису
private String jour;
private long idClient;
private long idCreneau;
// методи getter та setter
...
}
Тут також необхідні зміни типу відбудуться автоматично;
- у рядках 69–70 міститься подібний механізм для URL та [/supprimerRv]. Надісланий рядок JSON має такий вигляд:
а тип [PostSupprimerRv] має такий вигляд:
package rdvmedecins.web.models;
public class PostSupprimerRv {
// дані запиту
private long idRv;
// методи getter та setter
...
}
2.12.4. Шаблони веб-сервісу
![]() |
Ми вже представляли моделі [Reponse, PostAjouterRv, PostSupprimerRv]. Модель [ApplicationModel] виглядає так:
package rdvmedecins.web.models;
import java.util.Date;
...
@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;
@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(Rv rv) {
métier.supprimerRv(rv);
}
@Override
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
return métier.getAgendaMedecinJour(idMedecin, jour);
}
}
- рядок 6: анотація [@Component] робить клас [ApplicationModel] компонентом Spring. Як і всі компоненти Spring, розглянуті до цього моменту (за винятком @Controller), буде створено лише один екземпляр цього типу (синглтон);
- рядок 7: клас [ApplicationModel] реалізує інтерфейс [IMetier];
- рядки 10–11: Spring вводить посилання на рівень [métier];
- рядок 19: анотація [@PostConstruct] забезпечує виконання методу [init] одразу після створення екземпляра класу [ApplicationModel];
- рядки 23–24: отримуємо списки лікарів та клієнтів із шару [métier];
- рядок 26: якщо виникає виняток, повідомлення зі стека винятків зберігаються у полі з рядка 17;
Клас [ApplicationModel] знадобиться нам для двох цілей:
- як кеш для зберігання списків лікарів та пацієнтів (клієнтів);
- як єдиний інтерфейс для контролерів;
Архітектура веб-шару розвивається наступним чином:
![]() |
- у [2b] методи контролера (або контролерів) взаємодіють із синглтоном [ApplicationModel];
Ця стратегія забезпечує гнучкість у керуванні кешем. Наразі часові слоти лікарів не кешуються. Щоб їх кешувати, достатньо змінити клас [ApplicationModel]. Це жодним чином не вплине на контролер, який продовжуватиме використовувати метод [List<Creneau> getAllCreneaux(long idMedecin)], як і раніше. Зміни торкнуться саме реалізації цього методу в класі [ApplicationModel].
2.12.5. Клас Static
Клас [Static] об’єднує набір статичних допоміжних методів, які не мають «бізнес-» чи «веб-» спрямованості:
![]() |
Його код такий:
package rdvmedecins.web.helpers;
import java.text.SimpleDateFormat;
...
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;
}
// мапери Object --> Map
// --------------------------------------------------------
....
}
- рядок 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()].
Клас [Static] містить інші допоміжні методи, до яких ми повернемося, коли натрапимо на них.
Тепер детально розглянемо обробку URL веб-сервісу. У цій обробці задіяні три основні класи:
- контролер [RdvMedecinsController];
- клас допоміжних методів [Static];
- клас кешу [ApplicationModel];
![]() |
2.12.6. Метод [init] контролера
Контролер [RdvMedecinsController] (див. розділ 2.12.3) має метод [init], який виконується одразу після його інстанціювання:
@Autowired
private ApplicationModel application;
private List<String> messages;
@PostConstruct
public void init() {
// повідомлення про помилки програми
messages = application.getMessages();
}
- рядок 8: повідомлення про помилки, що зберігаються в кеші додатка [ApplicationModel], записуються локально у поле рядка 3. Це дозволить методам визначити, чи правильно ініціалізувався додаток.
2.12.7. URL [/getAllMedecins]
URL [/getAllMedecins] обробляється наступним методом контролера [RdvMedecinsController]:
// список лікарів
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET)
public Reponse getAllMedecins() {
// стан додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// список лікарів
try {
return new Reponse(0, application.getAllMedecins());
} catch (Exception e) {
return new Reponse(1, Static.getErreursForException(e));
}
}
- рядок 5: перевіряється, чи додаток правильно ініціалізувався (messages==null). Якщо ні, повертається відповідь із status=-1 та data=messages;
- рядок 10: в іншому випадку повертається список лікарів із значенням status, рівним 0. Метод [application.getAllMedecins()] не генерує винятку, оскільки він просто повертає список, який знаходиться в кеші. Проте ми збережемо цю обробку винятків на випадок, якщо лікарі більше не будуть зберігатися в кеші;
Ми ще не розглянули випадок, коли додаток неправильно ініціалізувався. Зупинімо SGBD MySQL5, запустимо веб-сервіс, а потім зробимо запит на URL [/getAllMedecins]:

Ми дійсно отримуємо помилку. У звичайних умовах ми бачимо таке:
![]() |
2.12.8. URL [/getAllClients]
URL [/getAllClients] обробляється наступним методом контролера [RdvMedecinsController]:
// список клієнтів
@RequestMapping(value = "/getAllClients")
public Reponse getAllClients() {
// стан програми
if (messages != null) {
return new Reponse(-1, messages);
}
// список клієнтів
try {
return new Reponse(0, application.getAllClients());
} catch (Exception e) {
return new Reponse(1, Static.getErreursForException(e));
}
}
Вона аналогічна вже розглянутому методу [getAllMedecins]. Отримано такі результати:
![]() |
2.12.9. URL [/getAllCreneaux/{idMedecin}]
URL [/getAllCreneaux/{idMedecin}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
// перелік часових проміжків лікаря
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET)
public Reponse getAllCreneaux(@PathVariable("idMedecin") long idMedecin) {
// статус додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// отримання даних про лікаря
Reponse réponse = getMedecin(idMedecin);
if (réponse.getStatus() != 0) {
return réponse;
}
Medecin médecin = (Medecin) réponse.getData();
// часові слоти лікаря
List<Creneau> créneaux = null;
try {
créneaux = application.getAllCreneaux(médecin.getId());
} catch (Exception e1) {
return new Reponse(3, Static.getErreursForException(e1));
}
// повернення відповіді
return new Reponse(0, Static.getListMapForCreneaux(créneaux));
}
- рядок 9: лікар, ідентифікований параметром [id], запитується у локальному методі:
private Reponse getMedecin(long id) {
// отримано дані про лікаря
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// лікар вже існує?
if (médecin == null) {
return new Reponse(2, null);
}
// гаразд
return new Reponse(0, médecin);
}
З цього методу повертаємося з status у [0,1,2]. Повернемося до коду методу [getAllCreneaux]:
- рядки 10–12: якщо status!=0, то негайно повертається відповідь;
- рядок 13: отримуємо лікаря;
- рядок 17: отримуємо вільні часові слоти цього лікаря;
- рядок 22: у відповідь надсилається об’єкт [Static.getListMapForCreneaux(créneaux)];
Нагадаємо визначення класу [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, що позначає лікаря. Це зайве. Тому замість того, щоб серіалізувати об’єкт [Creneau], ми будемо серіалізувати об’єкт [Map], у який будемо вносити лише потрібні поля.
Повернемося до коду, який ми розглядали спочатку:
// повертається відповідь
return new Reponse(0, Static.getListMapForCreneaux(créneaux));
Метод [Static.getListMapForCreneaux] виглядає так:
// List<Creneau> --> List<Map>
public static List<Map<String, Object>> getListMapForCreneaux(List<Creneau> créneaux) {
// список словників <String, Object>
List<Map<String, Object>> liste = new ArrayList<Map<String, Object>>();
for (Creneau créneau : créneaux) {
liste.add(Static.getMapForCreneau(créneau));
}
// повертається список
return liste;
}
а метод [Static.getMapForCreneau] виглядає так:
// Creneau --> Map
public static Map<String, Object> getMapForCreneau(Creneau créneau) {
// що робити?
if (créneau == null) {
return null;
}
// словник <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("id", créneau.getId());
hash.put("hDebut", créneau.getHdebut());
hash.put("mDebut", créneau.getMdebut());
hash.put("hFin", créneau.getHfin());
hash.put("mFin", créneau.getMfin());
// повертаємо словник
return hash;
}
- рядок 8: створюється словник;
- рядки 9–13: додаємо до нього поля, які хочемо зберегти у рядку JSON. Поля [medecin] там немає;
- рядок 15: повертаємо цей словник;
Отримано такі результати:
![]() |
або ось такі, якщо інтервал не існує:
![]() |
або такі, якщо сталася помилка доступу до бази даних:
![]() |
2.12.10. URL [/getRvMedecinJour/{idMedecin}/{jour}]
URL [/getRvMedecinJour/{idMedecin}/{jour}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
// список прийомів лікаря
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour) {
// стан програми
if (messages != null) {
return new Reponse(-1, messages);
}
// перевіряємо дату
Date jourAgenda = null;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
return new Reponse(3, null);
}
// отримуємо дані про лікаря
Reponse réponse = getMedecin(idMedecin);
if (réponse.getStatus() != 0) {
return réponse;
}
Medecin médecin = (Medecin) réponse.getData();
// список його прийомів
List<Rv> rvs = null;
try {
rvs = application.getRvMedecinJour(médecin.getId(), jourAgenda);
} catch (Exception e1) {
return new Reponse(4, Static.getErreursForException(e1));
}
// повертається відповідь
return new Reponse(0, Static.getListMapForRvs(rvs));
}
- рядок 31: повертається об’єкт List<Map<String,Object>> замість об’єкта List<Rv>. Нагадаємо визначення класу [Rv]:
@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 кожного прийому. Однак ця дубльована інформація є зайвою. Повернемося до коду методу:
- рядок 31: ми самостійно формуємо словник, який потрібно серіалізувати, у вигляді JSON;
Словник, побудований для одного прийому, має такий вигляд:
// Rv --> Карта
public static Map<String, Object> getMapForRv(Rv rv) {
// щось потрібно зробити?
if (rv == null) {
return null;
}
// словник <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("id", rv.getId());
hash.put("client", rv.getClient());
hash.put("creneau", getMapForCreneau(rv.getCreneau()));
// повертаємо словник
return hash;
}
- рядок 11: ми використовуємо словник об’єкта [Creneau], який ми наводили раніше;
Отримані результати такі:
![]() |
або ось ці, з неправильною датою:
![]() |
або ось ці з неправильним лікарем:
![]() |
2.12.11. URL [/getAgendaMedecinJour/{idMedecin}/{jour}]
URL [/getAgendaMedecinJour/{idMedecin}/{jour}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour) {
// стан програми
if (messages != null) {
return new Reponse(-1, messages);
}
// перевіряємо дату
Date jourAgenda = null;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
return new Reponse(3, new String[] { String.format("jour [%s] invalide", jour) });
}
// отримуємо дані про лікаря
Reponse réponse = getMedecin(idMedecin);
if (réponse.getStatus() != 0) {
return réponse;
}
Medecin médecin = (Medecin) réponse.getData();
// отримуємо його календар
AgendaMedecinJour agenda = null;
try {
agenda = application.getAgendaMedecinJour(médecin.getId(), jourAgenda);
} catch (Exception e1) {
return new Reponse(4, Static.getErreursForException(e1));
}
// ОК
return new Reponse(0, Static.getMapForAgendaMedecinJour(agenda));
}
}
- у рядку 30 повертається об’єкт типу List<Map<String,Object>.
Метод [Static.getMapForAgendaMedecinJour] виглядає наступним чином:
// AgendaMedecinJour --> Карта
public static Map<String, Object> getMapForAgendaMedecinJour(AgendaMedecinJour agenda) {
// щось потрібно зробити?
if (agenda == null) {
return null;
}
// словник <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("medecin", agenda.getMedecin());
hash.put("jour", new SimpleDateFormat("yyyy-MM-dd").format(agenda.getJour()));
List<Map<String, Object>> créneaux = new ArrayList<Map<String, Object>>();
for (CreneauMedecinJour créneau : agenda.getCreneauxMedecinJour()) {
créneaux.add(getMapForCreneauMedecinJour(créneau));
}
hash.put("creneauxMedecin", créneaux);
// створюємо словник
return hash;
}
Створений словник має три поля:
- [medecin]: лікар, якому належить календар. Цю інформацію збережено, оскільки вона зустрічається лише один раз, тоді як у попередніх випадках вона повторювалася в кожному рядку JSON;
- [jour]: день календаря;
- [creneauxMedecin]: список вільних часових проміжків лікаря з можливим прийомом у цьому проміжку;
Метод [getMapForCreneauMedecinJour], що використовується в рядку 13, є таким:
// CreneauMedecinJour --> карта
public static Map<String, Object> getMapForCreneauMedecinJour(CreneauMedecinJour créneau) {
// що робити?
if (créneau == null) {
return null;
}
// словник <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("creneau", getMapForCreneau(créneau.getCreneau()));
hash.put("rv", getMapForRv(créneau.getRv()));
// повертаємо словник
return hash;
}
- рядки 9–10: використовуються словники, які вже розглядалися для типів [Creneau] та [Rv], які, отже, не містять об’єкта [Medecin];
Отримано такі результати:
![]() |
або ось такі, якщо день вказано неправильно:
![]() |
або такі, якщо номер лікаря недійсний:
![]() |
2.12.12. URL [/getMedecinById/{id}]
URL [/getMedecinById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET)
public Reponse getMedecinById(@PathVariable("id") long id) {
// стан програми
if (messages != null) {
return new Reponse(-1, messages);
}
// отримуємо лікаря
return getMedecin(id);
}
У рядку 8 метод [getMedecin] виглядає так:
private Reponse getMedecin(long id) {
// отримуємо лікаря
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// лікар існує?
if (médecin == null) {
return new Reponse(2, null);
}
// гаразд
return new Reponse(0, médecin);
}
Отримано такі результати:
![]() |
або ось такі, якщо номер лікаря вказано неправильно:
![]() |
2.12.13. URL [/getClientById/{id}]
URL [/getClientById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET)
public Reponse getClientById(@PathVariable("id") long id) {
// стан додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// завантажуємо клієнта
return getClient(id);
}
У рядку 8 метод [getClient] виглядає так:
private Reponse getClient(long id) {
// отримуємо клієнта
Client client = null;
try {
client = application.getClientById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// клієнт існує?
if (client == null) {
return new Reponse(2, null);
}
// ok
return new Reponse(0, client);
}
Отримано такі результати:
![]() |
або такі, якщо номер клієнта вказано неправильно:
![]() |
2.12.14. URL [/getCreneauById/{id}]
URL [/getCreneauById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET)
public Reponse getCreneauById(@PathVariable("id") long id) {
// стан додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// завантажується часовий проміжок
Reponse réponse = getCreneau(id);
if (réponse.getStatus() == 0) {
réponse.setData(Static.getMapForCreneau((Creneau) réponse.getData()));
}
// результат
return réponse;
}
У рядку 8 метод [getCreneau] виглядає так:
private Reponse getCreneau(long id) {
// отримано часовий проміжок
Creneau créneau = null;
try {
créneau = application.getCreneauById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// слот існує?
if (créneau == null) {
return new Reponse(2, null);
}
// гаразд
return new Reponse(0, créneau);
}
Отримано такі результати:
![]() |
або такі, якщо номер слота неправильний:
![]() |
2.12.15. URL [/getRvById/{id}]
URL [/getRvById/{id}] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET)
public Reponse getRvById(@PathVariable("id") long id) {
// стан додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// отримано час зустрічі
Reponse réponse = getRv(id);
if (réponse.getStatus() == 0) {
réponse.setData(Static.getMapForRv2((Rv) réponse.getData()));
}
// результат
return réponse;
}
У рядку 8 метод [getRv] виглядає наступним чином:
private Reponse getRv(long id) {
// отримано Rv
Rv rv = null;
try {
rv = application.getRvById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// Rv існує?
if (rv == null) {
return new Reponse(2, null);
}
// ОК
return new Reponse(0, rv);
}
У рядку 10 метод [Static.getMapForRv2] виглядає так:
// Rv --> Карта
public static Map<String, Object> getMapForRv2(Rv rv) {
// щось потрібно зробити?
if (rv == null) {
return null;
}
// словник <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("id", rv.getId());
hash.put("idClient", rv.getIdClient());
hash.put("idCreneau", rv.getIdCreneau());
// повертаємо словник
return hash;
}
Отримані результати такі:
![]() |
або такі, якщо номер зустрічі неправильний:
![]() |
2.12.16. URL [/ajouterRv]
URL [/ajouterRv] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse ajouterRv(@RequestBody PostAjouterRv post) {
// стан додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// отримуємо відправлені значення
String jour = post.getJour();
long idCreneau = post.getIdCreneau();
long idClient = post.getIdClient();
// перевіряємо дату
Date jourAgenda = null;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
return new Reponse(6, null);
}
// отримуємо часовий проміжок
Reponse réponse = getCreneau(idCreneau);
if (réponse.getStatus() != 0) {
return réponse;
}
Creneau créneau = (Creneau) réponse.getData();
// отримуємо клієнта
réponse = getClient(idClient);
if (réponse.getStatus() != 0) {
réponse.incrStatusBy(2);
return réponse;
}
Client client = (Client) réponse.getData();
// додаємо зустріч
Rv rv = null;
try {
rv = application.ajouterRv(jourAgenda, créneau, client);
} catch (Exception e1) {
return new Reponse(5, Static.getErreursForException(e1));
}
// повертається відповідь
return new Reponse(0, Static.getMapForRv(rv));
}
Тут немає нічого нового. У рядку 41 повертається зустріч, яка була додана в рядку 36.
Отримані результати для клієнта [Advanced Rest Client] виглядають так:
![]() |
або ось так, якщо, наприклад, вказати номер слота, якого не існує:
![]() |
![]() |
2.12.17. URL [/supprimerRv]
URL [/supprimerRv] обробляється за допомогою наступного методу контролера [RdvMedecinsController]:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse supprimerRv(@RequestBody PostSupprimerRv post) {
// стан додатка
if (messages != null) {
return new Reponse(-1, messages);
}
// отримано відправлені значення
long idRv = post.getIdRv();
// отримуємо rv
Reponse réponse = getRv(idRv);
if (réponse.getStatus() != 0) {
return réponse;
}
// видалення rv
try {
application.supprimerRv(idRv);
} catch (Exception e1) {
return new Reponse(3, Static.getErreursForException(e1));
}
// ok
return new Reponse(0, null);
}
Отримані результати такі:
![]() |
або такі, якщо номер зустрічі не існує:
![]() |
Ми завершили роботу з контролером. Тепер розглянемо, як налаштувати проект.
2.12.18. Налаштування веб-сервісу
![]() |
Клас конфігурації [AppConfig] має такий вигляд:
package rdvmedecins.web.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class })
public class AppConfig {
}
- рядок 9: переходимо в режим [AutoConfiguration], щоб Spring Boot міг налаштувати проект на основі архівів, які він знайде в Classpath проекту;
- рядок 10: задається, щоб компоненти Spring шукалися в пакеті [rdvmedecins.web] та його підпакетах. Таким чином будуть виявлені компоненти:
- [@RestController RdvMedecinsController] — у пакеті [rdvmedecins.web.controllers];
- [@Component ApplicationModel] у пакеті [rdvmedecins.web.models];
- рядок 11: імпортується клас [DomainAndPersistenceConfig], який налаштовує проект [rdvmedecins-metier-dao] для отримання доступу до бінів цього проекту;
2.12.19. Виконуваний клас веб-сервісу
![]() |
Клас [Boot] має такий вигляд:
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].
Журнали виконання мають такий вигляд:
- рядок 17: запускається сервер Tomcat;
- рядки 23–31: ініціалізуються шари [métier, DAO, JPA];
- рядок 34: виявлено метод, що обробляє URL [/getRvMedecinJour/{idMedecin}/{jour}]. Цей процес виявлення методів контролера повторюється аж до рядка 44;
- рядок 52: сервлет Spring MVC [DispatcherServlet] готовий відповідати на запити веб-клієнтів;
Тепер у нас є робочий веб-сервіс, до якого можна звернутися за допомогою веб-клієнта. Далі ми перейдемо до забезпечення безпеки цього сервісу: ми хочемо, щоб лише певні особи могли керувати записами на прийом до лікарів. Для цього ми використаємо фреймворк Spring Security — складову екосистеми Spring.
2.13. Вступ до Spring Security
Ми знову імпортуємо посібник Spring, виконавши кроки 1–3, наведені нижче:
![]() |
![]() |
Проєкт складається з таких елементів:
- у папці [templates] містяться сторінки HTML проекту;
- [Application] — це виконуваний клас проекту;
- [MvcConfig] — це клас конфігурації Spring MVC;
- [WebSecurityConfig] — це клас конфігурації Spring Security;
2.13.1. Конфігурація Maven
Проєкт [3] є проєктом Maven. Розглянемо його файл [pom.xml], щоб дізнатися про його залежності:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.1.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>
</dependencies>
- рядки 1–5: проект є проектом Spring Boot;
- рядки 8–11: залежність від фреймворку [Thymeleaf], який дозволяє створювати динамічні сторінки HTML. Цей фреймворк може замінити сторінки JSP (Java Server Pages), які до недавнього часу були стандартним фреймворком представлень Spring MVC;
- рядки 12–15: залежність від фреймворку Spring Security;
2.13.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>
- Атрибути [th:xx] є атрибутами Thymeleaf. Вони інтерпретуються Thymeleaf до того, як сторінка HTML надсилається клієнту. Клієнт їх не бачить;
- рядок 12: атрибут [th:href="@{/hello}"] згенерує атрибут [href] тегу <a>. Значення [@{/hello}] згенерує шлях [<context>/hello], де [context] — це контекст веб-додатку;
Згенерований код HTML має такий вигляд:
- рядок 10: контекст додатка — кореневий каталог /;
Вигляд [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 має такий вигляд:
- рядок 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 має такий вигляд:
Зверніть увагу, що в рядку 21 Thymeleaf додав приховане поле з назвою [_csrf].
2.13.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. У ньому встановлено такі зв’язки:
погляд | |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
Суфікс [html] та папка [templates] є значеннями за замовчуванням, які використовує Thymeleaf. Їх можна змінити у налаштуваннях. Папка [templates] має знаходитися в кореневому каталозі Classpath проекту:
![]() |
Вище за [1] папки [main] та [resources] є папками-джерелами (source folders). Це означає, що їхній вміст буде розміщений у кореневому каталозі Classpath проекту. Отже, у [2] папки [hello] та [templates] будуть у кореневому каталозі Classpath.
2.13.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 з правами доступу. У ньому встановлено такі зв’язки:
правило | код | |
доступ без автентифікації | | |
доступ лише після автентифікації |
- рядок 15: визначає метод аутентифікації. Аутентифікація здійснюється через форму URL [/login], доступну для всіх [http.formLogin().loginPage("/login").permitAll()]. Вихід із системи (logout) також доступний для всіх.
- рядки 19–21: перевизначають метод [configure(AuthenticationManagerBuilder auth)], який керує користувачами;
- рядок 20: автентифікація здійснюється за допомогою «жорстко» визначених користувачів [auth.inMemoryAuthentication()]. Користувач тут визначений з логіном [user], паролем [password] та роллю [USER]. Користувачам з однаковою роллю можна надати однакові права;
2.13.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] і що деякі з них були захищені правами доступу.
2.13.6. Тестування додатка
Почнемо з запиту на URL [/], який є одним із чотирьох прийнятих URL. Він пов’язаний із поданням [/templates/home.html]:
![]() |
Запитана URL [/] є загальнодоступною. Саме тому ми її отримали. Посилання [here] таке:
URL [/hello] буде запитано, коли ви натиснете на посилання. Воно захищене:
правило | код | |
доступ без авторизації | | |
доступ лише після автентифікації |
Щоб отримати доступ, необхідно пройти автентифікацію. 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 / переглядів ми нічого не визначили для 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>
2.13.7. Висновок
У попередньому прикладі ми могли б спочатку написати веб-додаток, а потім забезпечити його безпеку. Spring Security не є інтрузивним. Можна налаштувати безпеку вже написаного веб-додатка. Крім того, ми з’ясували наступне:
- можна визначити сторінку автентифікації;
- автентифікація повинна супроводжуватися токеном CSRF, виданим Spring Security;
- якщо автентифікація не вдалася, користувача перенаправляють на сторінку автентифікації, причому в параметрі URL додається параметр error;
- якщо автентифікація проходить успішно, користувач перенаправляється на сторінку, яку він запитував під час автентифікації. Якщо безпосередньо зайти на сторінку автентифікації, оминувши проміжну сторінку, Spring Security перенаправляє нас на URL [/] (цей випадок не розглядався);
- вихід із системи здійснюється шляхом запиту на сторінку URL [/logout] із параметром POST. Spring Security перенаправляє нас на сторінку автентифікації з параметром logout у URL;
Усі ці висновки ґрунтуються на стандартній поведінці Spring Security. Цю поведінку можна змінити шляхом конфігурації, перевизначивши певні методи класу [WebSecurityConfigurerAdapter].
Попередній підручник не дуже допоможе нам далі. Адже ми будемо використовувати:
- базу даних для зберігання користувачів, їхніх паролів та ролей;
- аутентифікацію за заголовком HTTP;
Посібників з того, що ми хочемо зробити тут, досить мало. Рішення, яке буде запропоновано, — це комбінація коду, знайденого тут і там.
2.14. Налаштування безпеки веб-сервісу для запису на прийом
2.14.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]:
![]() |
2.14.2. Новий проект Eclipse — [métier, DAO, JPA]
Ми дублюємо початковий проєкт [rdvmedecins-metier-dao] у [rdvmedecins-metier-dao-v2]:
![]() |
- у [1]: новий проєкт;
- у [2]: зміни, внесені з урахуванням вимог безпеки, були об’єднані в єдиний пакет [rdvmedecins.security]. Ці нові елементи належать до шарів [JPA] та [DAO], але для простоти я об’єднав їх в один пакет.
2.14.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];
2.14.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: те саме, але для користувача, ідентифікованого за логіном та паролем;
Інтерфейс [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], не додаючи до нього нових методів;
2.14.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]:
![]() |
Цей інтерфейс реалізовано наступним класом [AppUserDetails]:
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);
2.14.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];
Виконання тестів завершується успішно з такими записами в журналах:
2.14.7. Проміжний висновок
Додавання необхідних класів до Spring Security вдалося здійснити з мінімальними змінами в оригінальному проєкті. Нагадаємо їх:
- додавання залежності від Spring Security у файлі [pom.xml];
- створення трьох додаткових таблиць у базі даних;
- створення сутностей JPA та компонентів Spring у пакеті [rdvmedecins.security];
Цей дуже сприятливий випадок зумовлений тим, що три таблиці, додані до бази даних, є незалежними від існуючих таблиць. Їх навіть можна було б розмістити в окремій базі даних. Це стало можливим завдяки тому, що було вирішено, що користувач існує незалежно від лікарів та клієнтів. Якби останні були потенційними користувачами, довелося б створити зв’язки між таблицею [USERS] та таблицями [MEDECINS] і [CLIENTS]. Це мало б значний вплив на існуючий проєкт.
2.14.8. Проєкт Eclipse для шару [web]
![]() |
Попередній проєкт [rdvmedecins-webapi] дублюється у проєктах [rdvmedecins-webapi-v2] та [1]:
![]() |
Єдині зміни потрібно внести в пакет [rdvmedecins.web.config], де необхідно налаштувати Spring Security. Ми вже зустрічали клас конфігурації 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.boot.autoconfigure.EnableAutoConfiguration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import rdvmedecins.security.AppUserDetailsService;
@EnableAutoConfiguration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
@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();
// пароль передається у заголовку Authorization: Basic xxxx
http.httpBasic();
// лише роль ADMIN може використовувати додаток
http.authorizeRequests() //
.antMatchers("/", "/**") // усі URL
.hasRole("ADMIN");
}
}
- рядки 14–15: використано анотації з прикладу;
- рядки 17–18: ін'єктується клас [AppUserDetails], який надає доступ користувачам додатка;
- рядки 20–21: метод [configure(HttpSecurity http)] визначає користувачів та їхні ролі. Він отримує як параметр тип [AuthenticationManagerBuilder]. Цей параметр доповнюється двома даними:
- посилання на службу [appUserDetailsService] із рядка 18, яка надає доступ зареєстрованим користувачам. Слід зауважити, що інформація про те, що вони зареєстровані в базі даних, тут не з’являється. Отже, вони можуть зберігатися в кеші, надаватися веб-службою тощо;
- тип шифрування, що використовується для пароля. Нагадаємо, що ми використовували алгоритм BCrypt;
- рядки 27–40: метод [configure(HttpSecurity http)] визначає права доступу до URL веб-сервісу;
- рядок 30: у вступному проєкті ми бачили, що за замовчуванням Spring Security керує токеном CSRF (Cross Site Request Forgery), який користувач, що бажає пройти автентифікацію, повинен надіслати серверу. Тут цей механізм вимкнено;
- рядок 32: увімкнено режим автентифікації за допомогою заголовка HTTP. Клієнт повинен надіслати такий заголовок HTTP:
де code — це кодування рядка login:password за алгоритмом Base64. Наприклад, Base64-кодування рядка admin:admin — це YWRtaW46YWRtaW4=. Отже, користувач із логіном [admin] та паролем [admin] надішле такий заголовок HTTP для автентифікації:
- рядки 34–36: вказують, що всі URL веб-сервісу доступні для користувачів, які мають роль [ROLE_ADMIN]. Це означає, що користувач, який не має цієї ролі, не може отримати доступ до веб-сервісу;
Клас [AppConfig], який налаштовує весь додаток, змінюється наступним чином:
![]() |
package rdvmedecins.web.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class })
public class AppConfig {
}
- Зміна відбувається в рядку 11: вказується, що тепер використовуються два конфігураційні файли — [DomainAndPersistenceConfig] та [SecurityConfig].
2.14.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, запускаємо захищений веб-сервіс. Потім за допомогою клієнта Chrome [Advanced Rest Client] запитуємо список усіх лікарів:
![]() |
- у [1] ми запитуємо URL лікарів;
- у запиті [2] використовуємо метод GET;
- у [3] ми надаємо заголовок HTTP для автентифікації. Код [YWRtaW46YWRtaW4=] — це кодування Base64 рядка [admin:admin];
- у [4] ми надсилаємо команду HTTP;
Відповідь сервера така:
![]() |
- у [1] — заголовок автентифікації HTTP;
- у [2] сервер повертає відповідь JSON;
- у [3] — список лікарів.
Тепер спробуємо надіслати запит HTTP із неправильним заголовком автентифікації. У цьому випадку відповідь буде такою:
![]() |
- у [1] та [3]: заголовок автентифікації HTTP;
- на [2]: відповідь веб-сервісу;
Тепер спробуємо користувача user / user. Він існує, але не має доступу до веб-сервісу. Якщо запустити програму кодування Base64 з двома аргументами [user user]:
![]() |
отримаємо такий результат:
![]() |
- у [1] та [3]: заголовок автентифікації HTTP;
- на [2]: відповідь веб-сервісу. Вона відрізняється від попередньої, яка мала вигляд [401 Unauthorized]. Цього разу користувач успішно пройшов автентифікацію, але не має достатніх прав для доступу до URL;
2.15. Conclusion
Нагадаємо загальну архітектуру нашого клієнт-серверного додатка:
![]() |
Захищений веб-сервіс тепер працює. Ми побачимо, що його доведеться модифікувати через проблеми, які виявляться під час створення клієнта Angular JS. Але ми почекаємо, поки проблема виникне, щоб її вирішити. Тепер ми створимо клієнт Angular, який надаватиме веб-інтерфейс для управління зустрічами лікарів.

















































































































































