Skip to content

4. Дії: модель

Повернемося до архітектури додатка Spring MVC:

У попередньому розділі ми розглянули процес, який направляє запит [1] до контролера та дії [2a], які його оброблять — цей механізм називається маршрутизацією. Крім того, ми розглянули різні відповіді, які дія може надати браузеру. Наразі ми розглядали дії, які не використовували запит, що надходив до них. Запит [1] містить у собі різноманітну інформацію, яку Spring MVC передає [2a] дії у вигляді моделі. Не слід плутати цей термін із моделлю M подання V [2c], яке генерується цією дією:

  • запит клієнта HTTP надходить у [1];
  • у [2] інформація, що міститься у запиті, буде перетворена на модель дії [3] — часто, але не обов’язково, клас, який слугуватиме вхідними даними для дії [4];
  • у [4] дія, на основі цієї моделі, згенерує відповідь. Вона матиме дві складові: представлення V [6] та модель M цього представлення [5];
  • представлення V [6] використовуватиме свою модель M [5] для генерації відповіді HTTP, призначеної для клієнта.

У моделі MVC дія [4] є частиною C (контролера), модель подання [5] є M, а подання [6] є V.

У цьому розділі розглядаються механізми зв’язку між інформацією, що передається у запиті (яка за своєю природою є рядками символів), та моделлю дії, яка може бути класом із властивостями різних типів.

Примітка: термін [Modèle d'action] не є визнаним.

Ми створюємо новий контролер для цих нових дій:

  

Контролер [ActionModelController] наразі матиме такий вигляд:


package istia.st.springmvc.controllers;

import org.springframework.web.bind.annotation.RestController;

@RestController
public class ActionModelController {

}
  • рядок 5: нагадаємо, що анотація [@RestController] означає, що відповідь, яка надсилається клієнту, є серіалізацією у вигляді рядка символів результату дій контролера;

4.1. [/m01]: параметри контролера GET

Додаємо таку дію [/m01]:



    // ----------------------- отримання параметрів за допомогою GET------------------------
    @RequestMapping(value = "/m01", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m01(String nom, String age) {
        return String.format("Hello [%s-%s]!, Greetings from Spring Boot!", nom, age);
}
  • рядок 4: дія приймає два параметри з іменами [nom] та [age]. Вони будуть ініціалізовані параметрами з тими самими іменами у запиті HTTP GET;

У Chrome [1-3] результати такі:

  • у [1] — запит GET з параметрами [nom] та [age];
  • у [3] видно, що дія [/m01] успішно отримала ці параметри;

4.2. [/m02]: параметри дії POST

Ми додаємо наступну дію [/m02]:



    // ----------------------- отримання параметрів за допомогою POST------------------------
    @RequestMapping(value = "/m02", method = RequestMethod.POST, produces = "text/plain;charset=UTF-8")
    public String m02(String nom, String age) {
        return String.format("Hello [%s-%s]!, Greetings from Spring Boot!", nom, age);
}
  • рядок 4: дія приймає два параметри з іменами [nom] та [age]. Вони будуть ініціалізовані параметрами з тими самими іменами у запиті HTTP POST;

Результати для [Advanced rest Client] такі:

  • у [1-3] — запит POST з параметрами [nom] та [age];
  • у [4-5] закріплюється заголовок HTTP [Content-Type] запиту POST. Він має бути [Content-Type: application/x-www-form-urlencoded];
  • у запиті [6] запит [Form Data] надає список параметрів операції POST. Тут ми бачимо параметри [nom] та [age];
  • у [7] — відповідь сервера, яка показує, що операція [/m02] успішно отримала параметри [nom] та [age]; ;

4.3. [/m03]: параметри з однаковими іменами

У розділі 2.5.2.8 ми бачили, що список із можливістю множинного вибору може надсилати на сервер параметри з однаковими іменами. Давайте розглянемо, як дія може їх отримати. Додамо таку дію [/m03]:


    // ----------------------- отримання параметрів з однаковими іменами-----------------
    @RequestMapping(value = "/m03", method = RequestMethod.POST, produces = "text/plain;charset=UTF-8")
    public String m03(String nom[]) {
        return String.format("Hello [%s]!, Greetings from Spring Boot!", String.join("-", nom));
}
  • рядок 2: дія приймає параметр із назвою [назва[]]. Він буде ініціалізований тут усіма параметрами з цією назвою, незалежно від того, чи це GET, чи POST, оскільки тут тип запиту не було вказано;

Результати такі:

  • за допомогою POST [1] надсилаються параметри [2];
  • також вказуються параметри в URL та [3];
  • у [4] — чотири параметри з однаковою назвою [nom]: [Query String parameters] є параметрами URL, [Form Data] — це відправлені параметри;
  • у [5] видно, що дія [/m03] отримала чотири параметри з іменами [nom];

4.4. [/m04]: зіставити параметри дії з об’єктом Java

Розглянемо таку нову дію [/m04]:


    // ------ зіставити параметри з об’єктом (Command Object) ---------------
    @RequestMapping(value = "/m04", method = RequestMethod.POST)
    public Personne m04(Personne personne) {
        return person;
}
  • рядок 3: дія має параметр типу «особа» наступного типу:

public class Personne {

    // ідентифікатор
    private Integer id;
    // ім'я
    private String nom;
    // вік
    private int age;
....
    // гетери та сеттери
...
}
  • щоб створити параметр [Personne personne], Spring MVC створює [new Personne()];
  • потім, якщо є параметри з іменами полів [id, nom, age] створеного об’єкта, він створює екземпляр з цими полями за допомогою їхніх сеттерів;
  • рядок 4: дія повертає тип [Personne], який, отже, буде серіалізовано у рядок символів перед відправкою клієнту. Ми бачили, що за замовчуванням виконується серіалізація типу jSON. Отже, клієнт повинен отримати рядок jSON, що відповідає одній особі;

Ось приклад:

  • у [1] — параметри [id, nom, age] для побудови об’єкта [Personne];
  • у [2] — рядок jSON цієї особи;

Що станеться, якщо не надіслати всі поля, що стосуються цієї особи? Спробуємо:

  • у [2] було ініціалізовано лише параметр [id];

4.5. [/m05]: отримати елементи з URL

Нехай це буде така нова дія [/m05]:


    // ----------------------- отримання елементів з URL ------------------------
    @RequestMapping(value = "/m05/{a}/x/{b}", method = RequestMethod.GET)
    public Map<String, String> m05(@PathVariable("a") String a, @PathVariable("b") String b) {
        Map<String, String> map = new HashMap<String, String>();
        map.put("a", a);
        map.put("b", b);
        return map;
}
  • рядок 2: оброблена URL має вигляд [/m05/{a}/x/{b}], де {param} є елементом-параметром URL;
  • рядок 3: елементи-параметри URL витягуються з анотацією [@PathVariable];
  • рядки 4–6: отримані елементи [a] та [b] поміщаються у словник;
  • рядок 7: відповіддю буде рядок jSON із цього словника;

Результати такі:

 

4.6. [/m06]: витягнути елементи з URL та параметри

Ось нова дія [/m06]:


    // -------- отримання елементів з URL та параметрів---------------
    @RequestMapping(value = "/m06/{a}/x/{b}", method = RequestMethod.GET)
    public Map<String, Object> m06(@PathVariable("a") Integer a, @PathVariable("b") Double b, Double c) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("a", a);
        map.put("b", b);
        map.put("c", c);
        return map;
}
  • рядок 3: одночасно витягуються елементи з URL, [Integer a, Double b] та один параметр (GET або POST) [Double c];
  • рядки 4–7: ці елементи додаються до словника;
  • рядок 8: який формує відповідь клієнта, який, отже, отримає рядок jSON із цього словника;

Ось результати:

 

Зверніть увагу на символ / в кінці шляху [http://localhost:8080/m06/100/x/200.43/]. Без нього отримуємо такий неправильний результат:

 

4.7. [/m07]: доступ до повного запиту

Розглянемо таку нову дію [/m07]:


    // ------ отримати доступ до запиту HttpServletRequest ------------------------
    @RequestMapping(value = "/m07", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m07(HttpServletRequest request) {
        // заголовки HTTP
        Enumeration<String> headerNames = request.getHeaderNames();
        StringBuffer buffer = new StringBuffer();
        while (headerNames.hasMoreElements()) {
            String name = headerNames.nextElement();
            buffer.append(String.format("%s : %s\n", name, request.getHeader(name)));
        }
        return buffer.toString();
}
  • рядок 3: Spring MVC отримує запит на вставку об’єкта [HttpServletRequest request], який інкапсулює всю інформацію, яку можна отримати про запит;
  • рядки 5–10: витягуються всі заголовки HTTP із запиту для об’єднання їх у рядок, який надсилається клієнту (рядок 11);

Результати такі:

  • у [1] — заголовки HTTP запиту;
  • в [2] — відповідь. У ній дійсно містяться всі заголовки HTTP із запиту.

4.8. [/m08]: доступ до об’єкта [Writer]

Розглянемо таку дію:


    // ----------------------- введення даних у writer ------------------------
    @RequestMapping(value = "/m08", method = RequestMethod.GET)
    public void m08(Writer writer) throws IOException {
        writer.write("Bonjour le monde !");
}
  • рядок 3: Spring MVC вставляє об’єкт [Writer writer], що дозволяє записувати дані у потік відповіді клієнту;
  • рядок 3: дія повертає тип [void], що вказує на те, що вона повинна самостійно сформувати відповідь клієнту;
  • рядок 4: додавання тексту до потоку відповіді клієнту;

Результати такі:

  • у [2] видно, що заголовок HTTP [Content-Type] не було надіслано;
  • у [3] — відповідь;

4.9. [/m09]: доступ до заголовка HTTP

Розглянемо таку дію:


    // ----------------------- ін'єкція RequestHeader ------------------------
    @RequestMapping(value = "/m09", method = RequestMethod.GET)
    public String m09(@RequestHeader("User-Agent") String userAgent) {
        return userAgent;
}
  • рядок 3: анотація [@RequestHeader("User-Agent")] дозволяє отримати заголовок HTTP [User-Agent];
  • рядок 4: виводиться текст цього заголовка;

Результати такі:

  • у [2] — заголовок HTTP [User-Agent];
  • в [3], дія [/m08] правильно отримала цей заголовок;

Файл cookie, як правило, є заголовком HTTP, який:

  • сервер надсилає клієнту вперше;
  • а клієнт потім систематично повертає серверу;

Спочатку створимо дію, яка створює файл cookie:


    // ----------------------- створення файлу cookie ------------------------
    @RequestMapping(value = "/m10", method = RequestMethod.GET)
    public void m10(HttpServletResponse response) {
        response.addCookie(new Cookie("cookie1", "remember me"));
}
  • рядок 3: вставляємо об’єкт [HttpServletResponse response], щоб мати повний контроль над відповіддю;
  • рядок 4: створюємо файл cookie з ключем [cookie1] та значенням [remember me] (Примітка: символи з діакритичними знаками у значенні файлу cookie спричиняють помилки);
  • рядок 3: дія нічого не повертає. Крім того, вона нічого не записує в тіло відповіді. Отже, клієнт отримає порожній документ. Відповідь використовується лише для того, щоб додати до неї заголовок HTTP файлу cookie;

Подивимося на результати:

  • у [1]: запит;
  • [2]: відповідь порожня;
  • у [3]: файл cookie, створений дією;

Тепер створимо дію для отримання цього файлу cookie, який браузер відтепер надсилатиме з кожним запитом:


    // ----------------------- введення файлу cookie ------------------------
    @RequestMapping(value = "/m11", method = RequestMethod.GET)
    public String m10(@CookieValue("cookie1") String cookie1) {
        return cookie1;
}
  • рядок 3: анотація [@CookieValue("cookie1")] дозволяє отримати файл cookie з ключем [cookie1];
  • рядок 4: це значення буде відповіддю, надісланою клієнту;

Подивимося на результати:

  • у [2] видно, що браузер повертає файл cookie;
  • у [3] видно, що дія успішно відновила його;

4.11. [/m12]: доступ до тіла запиту POST

Параметри, що надсилаються методом POST, зазвичай супроводжуються заголовком HTTP [Content-Type: application/x-www-form-urlencoded]. Можна отримати доступ до всього надісланого рядка. Створюємо таку дію:


    // ----------- отримання тіла POST типу String------------------------
    @RequestMapping(value = "/m12", method = RequestMethod.POST)
    public String m12(@RequestBody String requestBody) {
        return requestBody;
}
  • рядок 3: анотація [@RequestBody] дозволяє отримати тіло POST. Тут припускаємо, що воно має тип [String];
  • рядок 4: це тіло повертається клієнту;

Ось перший приклад:

  • у [2] — відправлені значення;
  • у [3] — заголовок HTTP [Content-Type] запиту;
  • у [4] — відповідь сервера;

Параметри, що передаються методом POST, не завжди мають просту форму [p1=v1&p2=v2], яку ми часто використовували досі. Розглянемо більш складний випадок:

  • у [2-3]: ми вводимо надіслані значення у формі [clé:value];
  • у [5] — рядок, який було надіслано;

Для типу [Content-Type: application/x-www-form-urlencoded] ланцюжок, що відправляється, повинен мати вигляд [p1=v1&p2=v2]. Якщо потрібно відправити що завгодно, слід вибрати тип [Content-Type: text/plain]. Ось приклад:

  • у [2-3] створюється заголовок HTTP [Content-Type]. За замовчуванням [5] — саме він буде використаний замість того, що визначено в [6]. Атрибут [charset=utf-8] є важливим. Без нього втрачаються символи з діакритичними знаками у надісланому рядку;
  • у [4] рядок, що надсилається, коректно відновлюється у [7];

4.12. [/m13, /m14]: отримання значень, відправлених у форматі jSON

Можна надсилати параметри з заголовком HTTP [Content-Type: application/json]. Створюємо таку дію:


    // ----------------------- отримання тіла jSON з POST
    @RequestMapping(value = "/m13", method = RequestMethod.POST, consumes = "application/json")
    public String m13(@RequestBody Personne personne) {
        return personne.toString();
}
  • рядок 2: [consumes = "application/json"] вказує, що дія очікує на тіло jSON;
  • рядок 3: [@RequestBody] представляє це тіло. Ця анотація була пов’язана з об’єктом типу [Personne]. Тіло jSON буде автоматично десеріалізовано в цей об’єкт;
  • рядок 4: використовується метод [Personne].toString(), щоб повернути значення, відмінне від надісланого рядка jSON;

Ось приклад:

  • у [2] — надісланий рядок jSON;
  • в [3] — [Content-Type] із запиту;
  • на [4] — відповідь сервера;

Це можна зробити інакше:


    // ----------------------- отримати тіло jSON з POST 2 -------------------
    @RequestMapping(value = "/m14", method = RequestMethod.POST, consumes = "text/plain")
    public String m14(@RequestBody String requestBody) throws JsonParseException, JsonMappingException, IOException {
        Personne personne = new ObjectMapper().readValue(requestBody, Personne.class);
        return personne.toString();
}
  • рядок 2: вказано, що метод очікує потік типу [text/plain]. Тоді Spring MVC оброблятиме тіло запиту як тип [String] (рядок 3);
  • рядок 4: рядок jSON десеріалізується в об’єкт [Personne] (див. параграф 9.7, сторінка 542);

Результати такі:

  • у [3], слід вказати [text/plain];

4.13. [/m15]: відновити сесію

Повернемося до архітектури виконання дії:

Клас контролера інстанціюється на початку запиту клієнта і знищується в кінці цього запиту. Тому він не може використовуватися для збереження даних між двома запитами, навіть якщо його викликають повторно. Може виникнути потреба зберегти два типи даних:

  • дані, спільні для всіх користувачів веб-додатку. Зазвичай це дані, доступні лише для читання;
  • дані, спільні для запитів одного й того самого клієнта. Ці дані зберігаються в об’єкті, який називається «Сесія». У цьому випадку термін «сесія клієнта» використовується для позначення пам’яті клієнта. Усі запити одного клієнта мають доступ до цієї сесії. Вони можуть зберігати та зчитувати інформацію з неї.

Вище наведено типи пам’яті, до яких має доступ дія:

  • пам'ять додатка, яка здебільшого містить дані, доступні лише для читання, і до якої мають доступ усі користувачі;
  • пам'ять конкретного користувача, або сесія, яка містить дані для читання та запису і є доступною для послідовних запитів одного й того самого користувача;
  • не показана вище, існує пам’ять запиту, або контекст запиту. Запит користувача може оброблятися кількома послідовними діями. Контекст запиту дозволяє дії 1 передавати інформацію дії 2.

Розглянемо перший приклад, що ілюструє ці різні типи пам’яті:


    // ----------------------- отримати сесію ------------------------
    @RequestMapping(value = "/m15", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m15(HttpSession session) {
        // отримуємо об’єкт ключа [compteur] у сесії
        Object objCompteur = session.getAttribute("compteur");
        // перетворюємо його на ціле число для інкрементування
        int iCompteur = objCompteur == null ? 0 : (Integer) objCompteur;
        iCompteur++;
        // повертаємо його в сесію
        session.setAttribute("compteur", iCompteur);
        // повертається як результат дії
        return String.valueOf(iCompteur);
}

Spring MVC зберігає сесію користувача в об’єкті типу [HttpSession].

  • рядок 3: Spring MVC отримує запит на введення об’єкта [HttpSession] у параметри дії;
  • рядок 5: звідти витягується атрибут із назвою [compteur]. Сесія поводиться як словник, набір пар [clé, valeur]. Якщо ключ [compteur] не існує в сесії, отримується покажчик null;
  • рядок 7: значення, пов’язане з ключем [compteur], матиме тип [Integer];
  • рядок 8: інкремент лічильника;
  • рядок 10: оновлення лічильника в сесії;
  • рядок 12: значення лічильника надсилається клієнту;

Коли [/m15] буде виконано:

  • вперше, у рядку 12 лічильник матиме значення 1;
  • вдруге, у рядку 5 це значення 1 буде отримано та змінено на 2;
  • ...

Ось приклад виконання:

  • у [1] ми отримуємо саме перше значення лічильника;
  • у [2] сервер надіслав сесійний файл cookie. Він має ключ [JSESSIONID], а значенням є унікальний для кожного користувача рядок символів. Нагадаємо, що браузер завжди відправляє отримані файли cookie. Отже, коли ми вдруге надішлемо запит на дію [/m15], клієнт відправить цей файл cookie, що дозволить серверу впізнати його та прив’язати до сеансу. Саме так зберігається інформація про користувача;

Розглянемо другий запит:

  • у [3] ми бачимо, що клієнт відправляє сесійний файл cookie. Можна помітити, що у відповіді сервера цього сесійного файлу cookie вже немає. Тепер саме клієнт відправляє його, щоб його впізнали;
  • у [4] — друге значення лічильника. Воно дійсно було збільшено;

4.14. [/m16]: отримання об’єкта з області дії [session]

Може виникнути потреба розмістити всі дані сеансу користувача в одному об’єкті та додати до сеансу лише цей об’єкт. Ми йдемо цим шляхом. Ми розміщуємо лічильник у наступному об’єкті [SessionModel]:

  

package istia.st.sprinmvc.models;

import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionModel {

    private int compteur;

    public int getCompteur() {
        return compteur;
    }

    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }

}
  • рядок 7: анотація [@Component] є анотацією Spring (рядок 5), яка робить клас [SessionModel] компонентом, життєвий цикл якого керується Spring;
  • рядок 8: анотація [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] також є анотацією Spring (рядки 3–4). Коли Spring MVC зустрічає її, відповідний клас створюється та додається до сесії користувача. Атрибут [proxyMode = ScopedProxyMode.TARGET_CLASS] є важливим. Саме завдяки йому Spring MVC створює екземпляр для кожного користувача окремо, а не єдиний екземпляр для всіх користувачів (синглтон);
  • рядок 11: лічильник;

Щоб цей новий компонент Spring було розпізнано, потрібно перевірити конфігурацію додатка у класі [Application]:


package istia.st.springmvc.main;

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({"istia.st.springmvc.controllers"})
@EnableAutoConfiguration
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • рядок 9: компоненти Spring шукаються в пакеті [istia.st.springmvc.controllers]. Цього вже недостатньо. Ми змінюємо цей рядок наступним чином:

@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })

Ми додали пакет, у якому знаходиться клас [SessionModel].

Тепер додаємо таку дію:


    @Autowired
    private SessionModel session;
    
    // ------ управління об’єктом області дії (scope) сесії [Autowired] -----------
    @RequestMapping(value = "/m16", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m16() {
        session.setCompteur(session.getCompteur() + 1);
        return String.valueOf(session.getCompteur());
}
  • рядки 1–2: компонент Spring [SessionModel] вводиться в контролер як [@Autowired]. Нагадаємо, що контролер Spring є синглтоном. Тому парадоксально вводити в нього компонент з меншою областю дії, в даному випадку з областю дії [Session]. Саме тут вступає в дію анотація [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] компонента [SessionModel]. Кожного разу, коли код контролера звертається до поля [session] у рядку 2, виконується проксі-метод, щоб повернути сесію запиту, який наразі обробляється контролером;
  • рядок 6: об’єкт [HttpSession] у параметрах дії більше не потрібен;
  • рядок 7: отримуємо / інкрементуємо лічильник;
  • рядок 8: повертається його значення;

Ось приклад виконання:

Перший раз

Вдруге

Тепер візьмемо інший браузер, який буде символізувати другого користувача. У цьому випадку ми використовуємо браузер Opera:

Вище, у [1], цей другий користувач отримує значення лічильника, рівне 1. Це свідчить про те, що його сесія та сесія першого користувача є різними. Якщо подивитися на обмін даними між клієнтом і сервером (у Opera також Ctrl-Shift-I), то в [2] видно, що цей другий користувач має файл cookie сеансу, відмінний від файлу першого користувача. Саме це забезпечує незалежність сеансів.

4.15. [/m17]: отримання об’єкта області дії [application]

Повернемося до архітектури виконання дії:

Ми знаємо, як створити сесію користувача. Тепер створимо об’єкт області дії [application], вміст якого буде доступний лише для читання та відкритий для всіх користувачів. Вводимо клас [ApplicationModel], який буде об’єктом області дії [application]:

 

package istia.st.springmvc.models;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Component;

@Component
public class ApplicationModel {

    // лічильник
    private AtomicLong compteur = new AtomicLong(0);

    // гетери та сеттери
    public AtomicLong getCompteur() {
        return compteur;
    }

    public void setCompteur(AtomicLong compteur) {
        this.compteur = compteur;
    }

}
  • рядок 5: анотація [@Component] визначає, що клас [ApplicationModel] буде компонентом, який керується Spring. За замовчуванням компоненти Spring мають тип [singleton]: компонент створюється в єдиному екземплярі під час інстанціювання контейнера Spring, тобто, як правило, під час запуску додатка. Ми можемо використовувати цей життєвий цикл для зберігання в синглтоні інформації про конфігурацію, яка буде доступна всім користувачам;
  • рядок 11: лічильник типу [AtomicLong]. Цей тип має метод [incrementAndGet], який називають атомарним. Це означає, що потік, який виконує цей метод, має гарантію, що інший потік не зчитає значення лічильника (Get) між його зчитуванням (Get) та інкрементуванням (increment) першим потоком, що спричинило б помилки, оскільки два потоки зчитали б одне й те саме значення лічильника, і він, замість того щоб бути інкрементованим на два, був би інкрементований лише на один;

Ми створюємо нову дію [/m17] наступного вигляду:


@Autowired
    private ApplicationModel application;

    // ----- управління об’єктом з областю дії «додаток» [Autowired] ------------------------
    @RequestMapping(value = "/m17", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m17() {
        return String.valueOf(application.getCompteur().incrementAndGet());
    }
  • рядки 1–2: вбудовуємо компонент [ApplicationModel] у контролер. Це синглтон. Отже, кожен користувач матиме посилання на один і той самий об’єкт;
  • рядок 7: повертаємо лічильник області дії [application] після його інкрементування;

Ось два приклади: один із Chrome, інший — з Opera:

Зверху видно, що обидва браузери працювали з одним і тим самим лічильником, чого не було у випадку з сесією. Ці два браузери символізують двох різних користувачів, які обидва мають доступ до даних області дії [application]. Загалом слід уникати розміщення в об’єктах області дії [application] інформації для читання/запису, як це було зроблено вище з лічильником. Адже потоки виконання різних користувачів одночасно отримують доступ до даних області дії [application]. Якщо є дані, доступні для запису, необхідно синхронізувати запис, як це було зроблено вище з типом [AtomicLong]. Паралельний доступ є джерелом помилок у програмуванні. Тому краще розміщувати в об’єктах області дії [application] лише дані, доступні лише для читання.

4.16. [/m18]: отримання об’єкта області дії [session] за допомогою [@SessionAttributes]

Існує ще один спосіб отримати інформацію з області дії [session]. Ми додамо до сесії такий об’єкт:


package istia.st.springmvc.models;

public class Container {
    // лічильник
    public int compteur=10;

    // методи getter та setter
    public int getCompteur() {
        return compteur;
    }

    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }
}

Ми використаємо цей об’єкт у двох таких діях:


    // використання [@SessionAttribute] ----------------------
    @RequestMapping(value = "/m18", method = RequestMethod.GET)
    public void m18(HttpSession session) {
        // тут ми вносимо ключ [container] у сесію
        session.setAttribute("container", new Container());
    }

    // використання [@ModelAttribute] ----------------------
    // ключ сеансу [container] буде вставлений сюди
    @RequestMapping(value = "/m19", method = RequestMethod.GET)
    public String m19(@ModelAttribute("container") Container container) {
        container.setCompteur(1 + container.getCompteur());
        return String.valueOf(container.getCompteur());
    }
  • рядки 3–6: дія [/m18] не дає жодного результату. Вона служить лише для створення об’єкта в сесії з ключем [container];
  • рядок 11: у дії [/m19] використовується анотація [@ModelAttribute]. Поведінка цієї анотації є досить складною. Параметр [container] цієї анотації може позначати різні об’єкти, зокрема об’єкт сесії. Для цього об’єкт повинен бути оголошений з анотацією [@SessionAttributes] у самому класі:

@RestController
@SessionAttributes({"container"})
public class ActionModelController {
  • у рядку 2 вище ключ [container] позначено як один з атрибутів сесії;

Підсумуємо:

  • у [/m18] ключ [container] додається до сесії;
  • анотація [@SessionAttributes({"container"})] дозволяє вставляти цей ключ у параметр, анотований [@ModelAttribute("container")];
  • хоча це й не видно в наведеному нижче прикладі виконання, інформація, позначена анотацією [@ModelAttribute], автоматично стає частиною моделі M, що передається до подання V;

Ось приклад виконання. Спочатку додаємо ключ [container] до сесії за допомогою дії [/m18] [1]. Потім двічі викликаємо дію [/m19], щоб побачити, як збільшується лічильник.

4.17. [/m20-/m23]: введення даних за допомогою [@ModelAttribute]

Розглянемо таку нову дію:


    // атрибут p буде частиною всіх шаблонів [Model] перегляду ----------------
    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
    }

    // ---------------інстанціювання @ModelAttribute --------------------------
    // буде вставлено, якщо він є в сесії
    // буде вставлено, якщо контролер визначив метод для цього атрибута
    // може походити з полів URL, якщо існує конвертер «рядок» → тип атрибута
    // інакше створюється за допомогою конструктора за замовчуванням
    // потім атрибути моделі ініціалізуються параметрами GET або POST
    // кінцевий результат стане частиною моделі, створеної цією дією
    
    // атрибут p вставляється в аргументи------------------------
    @RequestMapping(value = "/m20", method = RequestMethod.GET)
    public Personne m20(@ModelAttribute("p") Personne personne) {
        return personne;
}
  • рядки 2–5: визначають атрибут шаблону з іменем [p]. Це шаблон M для подання V, який у Spring представлений типом [Model]. Модель поводиться як словник пар [clé, valeur]. Тут ключ [p] пов’язаний з об’єктом [Personne], створеним методом [getPersonne]. Назва методу може бути будь-якою;
  • рядок 17: атрибут шаблону ключа [p] вводиться в параметри дії. Це введення відбувається згідно з правилами, викладеними в рядках 8–12. Тут ми матимемо випадок, визначений у рядку 9. Отже, у рядку 17 параметр [Personne personne] буде об’єктом [Personne(7,'abcd',14)];
  • у рядку 18: для перевірки повертається об’єкт [personne]. Він буде серіалізований у jSON перед відправкою клієнту.

Ось приклад:

 

Тепер розглянемо таку дію:


    // --------- атрибут p автоматично стає частиною шаблону M подання V
    @RequestMapping(value = "/m21", method = RequestMethod.GET)
    public String m21(Model model) {
        return model.toString();
}

Дія, яка має на меті відобразити представлення V, повинна побудувати його модель M. Spring MVC обробляє її за допомогою типу [Model], який можна ввести в параметри дії. Спочатку ця модель є порожньою або містить інформацію, позначену анотацією [@ModelAttribute]. Акція може доповнювати цю модель або ні, перш ніж передати її до подання.

  • рядок 3: введення моделі M;
  • рядок 4: ми хочемо побачити, що в ній міститься. Ми серіалізуємо її у рядок символів, щоб надіслати клієнту. Тут буде використано метод [Personne.toString]. Отже, він має існувати;

Ось приклад виконання:

 

Вище ми бачимо, що інструкції:


    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
}

створили запис [p, Personne(7,'abcd',14)] у шаблоні. Так відбувається завжди.

Тепер розглянемо такий випадок:


    // інакше створюється за допомогою конструктора за замовчуванням
    // потім атрибути шаблону ініціалізуються параметрами GET або POST

з такою дією:


    // --------- атрибут моделі [param1] є частиною моделі, але не ініціалізований
    @RequestMapping(value = "/m22", method = RequestMethod.GET)
    public String m22(@ModelAttribute("param1") String p1, Model model) {
        return model.toString();
}
  • рядок 3: атрибут шаблону ключа [param1] не існує. У цьому випадку пов'язаний тип повинен мати конструктор за замовчуванням. Це стосується типу [String], але не можна написати [@ModelAttribute("param1") Integer p1], оскільки клас [Integer] не має конструктора за замовчуванням;
  • рядок 4: повертається модель, щоб перевірити, чи атрибут ключа [param1] є її частиною;

Ось приклад виконання:

 

Атрибут шаблону [param1] дійсно присутній у шаблоні, але метод [toString] відповідного значення не надає інформації щодо цього значення.

Тепер розглянемо таку дію, де ми явно вносимо інформацію до моделі:


    // --------- атрибут шаблону [param2] явно вказано в шаблоні
    @RequestMapping(value = "/m23", method = RequestMethod.GET)
    public String m23(String p2, Model model) {
        model.addAttribute("param2",p2);
        return model.toString();
}
  • рядок 4: значення [p2], отримане в рядку 3, вноситься до моделі, пов’язаної з ключем [param2]:

Ось приклад виконання:

 

Правила змінюються, якщо параметром дії є об’єкт. Ось перший приклад:


    // ------ атрибут шаблону [unePersonne] автоматично додано до шаблону
    @RequestMapping(value = "/m23b", method = RequestMethod.GET)
    public String m23b(@ModelAttribute("unePersonne") Personne p1, Model model) {
        return model.toString();
}

Дія не змінює наданий їй шаблон. Результат такий:

Бачимо, що анотація [@ModelAttribute("unePersonne") Personne p1] додала особу [p1] до моделі, пов’язану з ключем [unePersonne].

Тепер розглянемо таку дію:


    // --------- особа p1 автоматично додається до шаблону
    // -------- з ключем, що відповідає назві її класу, перша літера якої написана з малої літери
    @RequestMapping(value = "/m23c", method = RequestMethod.GET)
    public String m23c(Personne p1, Model model) {
        return model.toString();
}
  • рядок 4: анотація [@ModelAttribute] не додана;

Результат такий:

Бачимо, що наявність параметра [Personne p1] додала особу [p1] до моделі, пов’язану з ключем [personne], який є назвою класу [Personne] з першою літерою, написаною з малої літери.

4.18. [/m24]: перевірка моделі дії

Розглянемо таку модель дії [ActionModel01]:

 

package istia.st.springmvc.models;

import javax.validation.constraints.NotNull;

public class ActionModel01 {

    // дані
    @NotNull
    private Integer a;
    @NotNull
    private Double b;

    // гетери та сеттери
...
    }
  • рядки 8 і 9: анотація [@NotNull] є обмеженням перевірки, яке вказує, що анотовані дані не можуть мати значення null;

Тепер розглянемо таку дію:


    // ----------------------- перевірка моделі ------------------------
    @RequestMapping(value = "/m24", method = RequestMethod.GET)
    public Map<String, Object> m24(@Valid ActionModel01 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // помилки?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            // перегляд списку помилок
            for (FieldError error : result.getFieldErrors()) {
                buffer.append(String.format("[%s:%s:%s:%s:%s]", error.getField(), error.getRejectedValue(),
                        String.join(" - ", error.getCodes()), error.getCode(),error.getDefaultMessage()));
            }
            map.put("errors", buffer.toString());
        } else {
            // помилок немає
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
}
  • рядок 3: буде створено екземпляр об’єкта [ActionModel01], а його поля [a, b] будуть ініціалізовані параметрами з такими самими іменами. Анотація [@Valid] вказує, що обмеження валідності мають бути перевірені. Результати цієї перевірки будуть розміщені в параметрі типу [BindingResult] (другий параметр). Будуть виконані такі перевірки:
    • через анотації [@NotNull] параметри [a] та [b] мають бути присутніми;
    • через тип [Integer a] параметр [a], який за своєю природою має тип [String], повинен бути перетворюваним у тип [Integer];
    • через тип [Double b] параметр [b], який за своєю природою має тип [String], повинен бути перетворюваним у тип [Double];

З анотацією [@Valid] помилки перевірки будуть перенесені до параметра [BindingResult result]. Без анотації [@Valid] помилки валідації призводять до збою дії, і сервер надсилає клієнту відповідь HTTP зі статусом 500 (Internal server error).

  • рядок 3: результат дії має тип [Map]. Саме рядок jSON із цього результату буде надіслано клієнту. Створюються два типи словників:
    • у разі невдачі — словник із записом ['errors', value], де [value] — це рядок, що описує всі помилки (рядок 13);
    • у разі успіху — словник з одним записом ['data',value], де [value] сам є словником з двома записами: ['a', value], ['b', value] (рядок 19);
  • рядки 9–12: для кожної виявленої помилки [error] будується рядок [error.getField(), error.getRejectedValue(), error.Codes, error.getDefaultMessage()]:
    • перший елемент — це поле з помилкою, наприклад [a] або [b],
    • другий елемент — це відхилене значення, наприклад [x],
    • третій елемент — це список кодів помилок. Ми незабаром розглянемо їхні функції;
    • четвертий елемент — це код помилки. Він входить до попереднього списку;
    • останній елемент — це повідомлення про помилку за замовчуванням. Дійсно, може бути кілька повідомлень про помилки;

Ось кілька прикладів виконання:

З наведеного вище видно, що:

  • присвоєння значення «x» полю [ActionModel01.a] не вдалося, і повідомлення про помилку пояснює, чому;
  • присвоєння значення «y» полю [ActionModel01.b] не вдалося, і повідомлення про помилку пояснює, чому;

Зверніть увагу на коди помилок у полі [a]: [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]. Ми повернемося до цих кодів помилок, коли знадобиться налаштувати повідомлення про помилку. Зверніть увагу, що код помилки — [typeMismatch].

Ще один приклад:

У цьому випадку не було передано параметри [a] та [b]. Тоді свою роль виконали валідатори [@NotNull] моделі дії [ActionModel01];

Нарешті, правильні значення:

4.19. [m/24]: налаштування повідомлень про помилки

Повернімося до знімка екрана з попереднього прикладу:

Вище ми бачимо стандартні повідомлення про помилки. Очевидно, що в реальному додатку їх використовувати не можна. Ці повідомлення про помилки можна налаштувати. Для цього скористаємося кодами помилок. Вище ми бачимо, що помилка для поля [a] має такі коди: [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]. Ці коди помилок розташовані від найточнішого до найменш точного:

  • [typeMismatch.actionModel01.a]: помилка типу в полі [a] типу [ActionModel01];
  • [typeMismatch.a]: помилка типу в полі з іменем [a];
  • [typeMismatch.java.lang.Integer]: помилка типу для типу Integer;
  • [typeMismatch]: помилка типу;

Також слід зазначити, що код помилки для поля [a], отриманого з [error.getCode()], — це [typeMismatch] (див. знімок екрана вище).

Ми розмістимо повідомлення про помилки у файлі властивостей:

  

Вищезазначений файл [messages.properties] матиме такий вигляд:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.model01.a=Le paramètre [a] doit être entier

Кожен рядок має такий вигляд:

    clé=message

Тут ключем буде код помилки, а повідомленням — повідомлення про помилку, пов’язане з цим кодом.

Нагадаємо коди помилок для обох полів:

  • [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch], якщо параметр [a] є недійсним;
  • [typeMismatch.actionModel01.b - typeMismatch.b - typeMismatch.java.lang.Double - typeMismatch:typeMismatch ] — якщо параметр [b] є недійсним;
  • [NotNull.actionModel01.a - NotNull.a - NotNull.java.lang.Integer - NotNull] — якщо параметр [a] відсутній;
  • [NotNull.actionModel01.b - NotNull.b - NotNull.java.lang.Double - NotNull], якщо параметр [b] відсутній;

Файл [messages.properties] повинен містити повідомлення про помилку для всіх можливих випадків помилок. У випадку, коли:

  • відсутність параметрів [a] та [b], буде використано код [NotNull];
  • неправильний параметр [a] — ми додали повідомлення для двох кодів [typeMismatch.actionModel01.a, typeMismatch]. Побачимо, який із них буде використано;
  • якщо параметр [b] неправильний, буде використано код [typeMismatch];

Щоб використовувався файл [messages.properties], потрібно налаштувати Spring:

  

Ми видаляємо анотації конфігурації з класу [Application]:


package istia.st.springmvc.main;

import org.springframework.boot.SpringApplication;

public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Config.class, args);
    }
}
  • рядок 8: запускається додаток Spring Boot. Першим параметром статичного методу [SpringApplication.run] є клас, який відтепер налаштовує додаток;

Клас [Config] має такий вигляд:


package istia.st.springmvc.main;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("i18n/messages");
        return messageSource;
    }
}
  • рядки 11–13: тут містяться анотації конфігурації, які раніше були в класі [Application];
  • рядок 14: щоб налаштувати додаток Spring MVC, потрібно розширити клас [WebMvcConfigurerAdapter];
  • рядок 15: анотація [@Bean] вводить компонент Spring — синглтон;
  • рядок 16: визначається бін з іменем [messageSource] (ім'я методу). Цей бін служить для визначення файлів повідомлень додатка і обов'язково повинен мати саме це ім'я;
  • рядки 17–19: вказують Spring, що файл повідомлень:
    • знаходиться у папці [i18n] у Classpath проекту (рядок 18),
    • називається [messages.properties] (рядок 18). Насправді термін [messages] є коренем імен файлів повідомлень, а не самим іменем. Ми побачимо, що в рамках інтернаціоналізації може існувати кілька файлів повідомлень — по одному для кожної підтримуваної культури. Так, наприклад, для французької мови може бути файл [messages_fr.properties], а для англійської — [messages_en.properties]. Суфікси, що додаються до кореня [messages], є стандартизованими. Не можна вказувати що завгодно;

У проєкті STS папку [i18n] потрібно помістити в папку ресурсів, оскільки вона додається до Classpath проєкту:

  

Щоб використовувати цей файл, ми створюємо таку нову дію:


// перевірка моделі, обробка повідомлень про помилки ------------------------
    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
        // словник результатів
        Map<String, Object> map = new HashMap<String, Object>();
        // контекст додатка Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // локаль
        Locale locale = RequestContextUtils.getLocale(request);
        // помилок?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            for (FieldError error : result.getFieldErrors()) {
                // пошук повідомлення про помилку на основі кодів помилок
                // повідомлення шукається у файлах повідомлень
                // коди помилок у вигляді таблиці
                String[] codes = error.getCodes();
                // у вигляді рядка
                String listCodes = String.join(" - ", codes);
                // пошук
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // знайдено?
                if (msg == null) {
                    throw new Exception(String.format("Indiquez un message pour l'un des codes [%s]", listCodes));
                }
                // знайдено — додаємо повідомлення про помилку до списку повідомлень про помилки
                buffer.append(String.format("[%s:%s:%s:%s]", locale.toString(), error.getField(), error.getRejectedValue(),
                        String.join(" - ", msg)));
            }
            map.put("errors", buffer.toString());
        } else {
            // ok
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
    }

Цей код аналогічний коду дії [/m24]. Пояснимо відмінності:

  • рядок 3: ми вводимо запит [HttpServletRequest request] у параметри дії. Він нам знадобиться;
  • рядки 7–8: отримуємо контекст Spring. Цей контекст містить усі Spring-біни додатка. Він також дає доступ до файлів повідомлень;
  • рядок 10: отримуємо локаль додатка. Це поняття пояснюється трохи далі;
  • рядки 15–31: для кожної помилки шукаємо повідомлення, що відповідає одному з цих кодів помилок. Їх шукають у порядку кодів, знайдених у [error.getCodes()]. Як тільки повідомлення знайдено, процес зупиняється;
  • рядок 26: як відновити повідомлення в [messages.properties]:
    • перший параметр — це код, який шукається в [messages.properties],
    • другий — це масив параметрів, оскільки іноді повідомлення містять параметри. У цьому випадку це не так,
    • третій — це використовувана локаль (отримана в рядку 10). Локаль позначає мову, що використовується: [fr_FR] — для французької мови Франції, [en_US] — для англійської мови USA. Повідомлення шукається у файлі messages_[locale].properties, наприклад, у [messages_fr_FR.properties]. Якщо цей файл не існує, повідомлення шукається у [messages_fr.properties]. Якщо цей файл не існує, повідомлення шукається у файлі [messages.properties]. Саме останній випадок підійде нам;
  • рядки 25–29: дещо несподівано, коли ми шукаємо код, якого немає у файлі повідомлень, ми отримуємо виняток, а не нульовий покажчик;
  • рядки 33–35: обробляємо випадок відсутності повідомлення про помилку;
  • рядки 37–38: формуємо рядок помилки. У нього включаємо локаль та знайдене повідомлення про помилку;

Ось приклади виконання:

 

Бачимо, що:

  • локаль додатка — [fr_FR]. Це значення за замовчуванням, оскільки ми нічого не робили для його ініціалізації;
  • повідомлення, яке використовується для обох полів, має такий вигляд:

NotNull=Le champ ne peut être vide

Ще один приклад:

 

Бачимо, що:

  • повідомлення про помилку, яке використовується для параметра [a], має такий вигляд:

typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
  • повідомлення про помилку, яке використовується для параметра [b], має такий вигляд:

typeMismatch=Format invalide

Чому два різних повідомлення? Для параметра [a] існували два можливі повідомлення:


typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier

Коди помилок досліджувалися у порядку, наведеному в таблиці [error.getCodes()]. Виявляється, цей порядок йде від найточнішого коду до найзагальнішого. Саме тому код [typeMismatch.model01.a] було знайдено першим.

4.20. [/m25]: інтернаціоналізація Spring-додатку MVC

Тепер, коли ми знаємо, як налаштовувати повідомлення про помилки французькою мовою, ми хотіли б мати їх також англійською, що приводить нас до інтернаціоналізації Spring-додатку MVC. Щоб це реалізувати, ми розширимо клас конфігурації [Config], який набуде такого вигляду:


package istia.st.springmvc.main;

import java.util.Locale;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;

@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("i18n/messages");
        return messageSource;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }

    @Bean
    public CookieLocaleResolver localeResolver() {
        CookieLocaleResolver localeResolver = new CookieLocaleResolver();
        localeResolver.setCookieName("lang");
        localeResolver.setDefaultLocale(new Locale("fr"));
        return localeResolver;
    }
}
  • рядки 28–32: створюємо перехоплювач запиту. Перехоплювач запиту розширює інтерфейс [HandlerInterceptor]. Такий клас перевіряє вхідний запит перед тим, як його обробить дія. Тут перехоплювач [localeChangeInterceptor] шукатиме у вхідному запиті параметр із назвою [lang], GET або POST і змінить локаль додатка відповідно до цього параметра. Отже, якщо параметр дорівнює [lang=en_US], локаль додатка зміниться на англійську з USA;
  • рядки 34–37: перевизначається метод [WebMvcConfigurerAdapter.addInterceptors] для додавання попереднього перехоплювача;
  • рядки 39–45: слугують для налаштування способу інкапсуляції локалі в файл cookie. Відомо, що файл cookie може слугувати пам’яттю користувача, оскільки клієнтський браузер систематично надсилає його на сервер. Попередній інтерцептор [localeChangeInterceptor] створює файл cookie, що інкапсулює локаль. У рядку 42 цьому файлу cookie присвоюється ім’я [lang]. Цей файл cookie також використовується для зміни локалі;
  • рядок 43: вказує, що за відсутності файлу cookie [lang] локаль буде [fr];

Підсумовуючи, локаль запиту можна встановити двома способами:

  • шляхом передачі параметра з іменем [lang];
  • шляхом надсилання файлу cookie з іменем [lang]. Цей файл cookie створюється автоматично після виконання попереднього методу;

Щоб використовувати цю локаль, ми створимо файли повідомлень для локалей [fr] та [en]:

 

Файл [messages_fr.properties] має такий вигляд:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier

Файл [messages_en.properties] має такий вигляд:


NotNull=The field can't be empty
typeMismatch=Invalid format
typeMismatch.actionModel01.a=Parameter [a] must be an integer

Файл [messages.properties] є копією файлу [messages_en.properties]. Нагадуємо, що файл [messages.properties] використовується, коли не знайдено жодного файлу, що відповідає локалі запиту. У нашому випадку, якщо користувач надішле параметр [lang=en], оскільки файл [messages_en.properties] не існує, буде використано файл [messages.properties]. Отже, повідомлення користувача будуть англійською мовою.

Давайте спробуємо. Спочатку у середовищі розробки Chrome (Ctrl-Shift-I) перевірте свої файли cookie:

 

Якщо у вас є файл cookie з назвою [lang], видаліть його. Потім у Chrome завантажте файли URL та [http://localhost:8080/m25]:

 

Браузер надіслав такі заголовки HTTP:

GET /m25 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36
Referer: http://localhost:8080/m25
Accept-Encoding: gzip, deflate, sdch
Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.6,en;q=0.4

Бачимо, що в цих заголовках немає файлу cookie [lang]. У цьому випадку наш код використовує локаль [fr]. Це видно на знімку екрана. Спробуємо інший випадок:

  • у [1] ми передали параметр [lang=en], щоб змінити локаль на [en];
  • у [2] ми бачимо нову локаль;
  • у [3] повідомлення переведено на англійську;

Тепер розглянемо обмін даними HTTP:

 

Вище ми бачимо, що сервер надіслав файл cookie [lang]. Це має важливий наслідок: локаль наступного запиту знову буде [en] через файл cookie [lang], який буде надісланий браузером. Тому слід залишити повідомлення англійською мовою. Перевіримо це:

 

Вище ми бачимо, що локаль залишилася [en]. Через файл cookie, який браузер надсилає систематично, вона залишатиметься такою, доки користувач не змінить її, надіславши параметр [lang] наступним чином:

 

4.21. [/m26]: введення локалі в шаблон дії

У попередньому прикладі ми розглянули спосіб отримання локалі із запиту:


    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
...
        // локальне
        Locale locale = RequestContextUtils.getLocale(request);
// помилки?

Локаль можна безпосередньо вставити в параметри дії. Ось приклад:


    @RequestMapping(value = "/m26", method = RequestMethod.GET)
    public String m26(Locale locale) {
        return String.format("locale=%s", locale.toString());
}
 

Як бачимо вище, перевірка правильності запитуваної локалі не проводиться. Проте наступний запит браузера викликає виняток на стороні сервера, оскільки отриманий ним файл cookie локалі є некоректним.

4.22. [/m27]: перевірка валідності моделі за допомогою Hibernate Validator

Розглянемо таку нову дію:


    //перевірка моделі за допомогою Hibernate Validator ------------------------
    @RequestMapping(value = "/m27", method = RequestMethod.POST)
    public Map<String, Object> m27(@Valid ActionModel02 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // помилки?
        if (result.hasErrors()) {
            // перегляд списку помилок
            for (FieldError error : result.getFieldErrors()) {
                map.put(error.getField(),
                        String.format("[message=%s, codes=%s]", error.getDefaultMessage(), String.join("|", error.getCodes())));
            }
        } else {
            // помилок немає
            map.put("data", data);
        }
        return map;
}

Тут ми бачимо код, який вже неодноразово зустрічався:

  • рядок 3: акція [/m27] викликається через POST;
  • рядки 8–11: кожна помилка буде позначена як [champ, message] із зазначенням:
    • поле: поле, в якому сталася помилка,
    • повідомлення: відповідне повідомлення про помилку, а також список кодів помилок;
  • рядок 14: якщо помилок немає, повертається рядок jSON із відправленими значеннями;

У рядку 3 використовується наступний шаблон дії [ActionModel02]:

  

package istia.st.springmvc.models;

import java.util.Date;

import javax.validation.constraints.AssertFalse;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Future;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.URL;

public class ActionModel02 {

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
    private Boolean assertFalse;
    
    @NotNull(message = "La donnée est obligatoire")
    @AssertTrue(message = "Seule la valeur [true] est acceptée")
    private Boolean assertTrue;
    
    @NotNull(message = "La donnée est obligatoire")
    @Future(message = "Il faut une date postérieure à aujourd'hui")
    private Date dateInFuture;
    
    @NotNull(message = "La donnée est obligatoire")
    @Past(message = "Il faut une date antérieure à aujourd'hui")
    private Date dateInPast;
    
    @NotNull(message = "La donnée est obligatoire")
    @Max(value = 100, message = "Maximum 100")
    private Integer intMax100;
    
    @NotNull(message = "La donnée est obligatoire")
    @Min(value = 10, message = "Minimum 10")
    private Integer intMin10;
    
    @NotNull(message = "La donnée est obligatoire")
    @NotBlank(message = "La chaîne doit être non blanche")
    private String strNotBlank;
    
    @NotNull(message = "La donnée est obligatoire")
    @Size(min = 4, max = 6, message = "La chaîne doit avoir entre 4 et 6 caractères")
    private String strBetween4and6;
    
    @NotNull(message = "La donnée est obligatoire")
    @Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$", message = "Le format doit être hh:mm:ss")
    private String hhmmss;
    
    @NotNull(message = "La donnée est obligatoire")
    @Email(message = "Adresse invalide")
    private String email;
    
    @NotNull(message = "La donnée est obligatoire")
    @Length(max = 4, min = 4, message = "La chaîne doit avoir 4 caractères exactement")
    private String str4;
    
    @Range(min = 10, max = 14, message = "La valeur doit être dans l'intervalle [10,14]")
    @NotNull(message = "La donnée est obligatoire")
    private Integer int1014;
    
    @URL(message = "URL invalide")
    private String url;

    // геттери та сеттери

...
}

Клас використовує обмеження валідації з двох пакетів:

  • [javax.validation.constraints] у рядках 5–13;
  • [org.hibernate.validator.constraints] у рядках 15–19;

Залежності Maven цих двох пакетів присутні в проєкті:

  

Тут ми не будемо використовувати інтернаціоналізовані повідомлення, а повідомлення, визначені всередині обмеження за допомогою атрибута [message]. Для тестування цієї дії ми використаємо [Advanced Rest Client]:

  • у [1-2] — запит POST;
  • у [3] — заголовок HTTP, який слід використовувати;
  • у [4] посилання [Add new value] дозволяє додати пару [paramètre, value];
  • у [5] слід вказати поле з [ActionModel02], у даному випадку поле [assertFalse]:

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
private Boolean assertFalse;
  • у [6] введіть неправильне значення, щоб побачити повідомлення про помилку. Вище наведене обмеження [@AssertFalse] вимагає, щоб поле [assertFalse] мало значення [false];
  • у [7] — відповідь сервера: спрацювало обмеження [@NotNull] щодо порожніх полів, і було відображено відповідне повідомлення про помилку;
  • у [8] — повідомлення з поля [assertFalse], для якого не було перевірено обмеження [@AssertFalse], а також коди цієї помилки. Нагадуємо, що ці коди можуть бути пов’язані з інтернаціоналізованими повідомленнями;

Ось ще один приклад:

 

Image

Читачеві пропонується перевірити різні випадки помилок аж до POST, де всі дані є дійсними:

Примітка: формат дати — англосаксонський: мм/дд/рррр.

4.23. [/m28]: винесення повідомлень про помилки

У класі [ActionModel02] ми вбудували повідомлення «на жорстко». Краще винести їх у файли повідомлень. Ми слідуємо прикладу дії [/m25]. Створюємо новий шаблон дії [ActionModel03] наступним чином:

  

package istia.st.springmvc.models;

import java.util.Date;

import javax.validation.constraints.AssertFalse;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Future;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.URL;

public class ActionModel03 {

    @NotNull
    @AssertFalse
    private Boolean assertFalse;
    
    @NotNull
    @AssertTrue
    private Boolean assertTrue;
    
    @NotNull
    @Future
    private Date dateInFuture;
    
    @NotNull
    @Past
    private Date dateInPast;
    
    @NotNull
    @Max(value = 100)
    private Integer intMax100;
    
    @NotNull
    @Min(value = 10)
    private Integer intMin10;
    
    @NotNull
    @NotBlank
    private String strNotBlank;
    
    @NotNull
    @Size(min = 4, max = 6)
    private String strBetween4and6;
    
    @NotNull
    @Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$")
    private String hhmmss;
    
    @NotNull
    @Email
    private String email;
    
    @NotNull
    @Length(max = 4, min = 4)
    private String str4;
    
    @Range(min = 10, max = 14)
    @NotNull
    private Integer int1014;
    
    @URL
    private String url;

    // геттери та сеттери
        ...
}

Повідомлення про помилки винесені у файли [messages.properties]:

  

Файл [messages_fr.properties] має такий вигляд:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
Range.actionModel03.int1014=La valeur doit être dans l'intervalle [10,14]
NotBlank.actionModel03.strNotBlank=La chaîne doit être non blanche
AssertFalse.actionModel03.assertFalse=Seule la valeur [false] est acceptée
Pattern.actionModel03.hhmmss=Le format doit être hh:mm:ss
Past.actionModel03.dateInPast=Il faut une date antérieure ou égale à celle d'aujourd'hui
Future.actionModel03.dateInFuture=Il faut une date postérieure à celle d'aujourd'hui
Length.actionModel03.str4=La chaîne doit avoir 4 caractères exactement
Min.actionModel03.intMin10=Minimum 10
Max.actionModel03.intMax100=Maximum 100
AssertTrue.actionModel03.assertTrue=Seule la valeur [true] est acceptée
Email.actionModel03.email=Adresse invalide
Size.actionModel03.strBetween4and6=La chaîne doit avoir entre 4 et 6 caractères
URL.actionModel03.url=URL invalide

Повідомлення про помилки додано до рядків 4–16. Вони мають такий вигляд:

code=message

Коди не можуть бути довільними. Це ті коди, що відображаються в попередній дії [/m27]. Наприклад:

Image

У файлах повідомлень для поля [int1014] слід використовувати один із чотирьох наведених вище кодів.

Файл [messages_en.properties] має такий вигляд:


NotNull=The field can't be empty
typeMismatch=Invalid format
typeMismatch.actionModel01.a=Parameter [a] must be an integer
Range.actionModel03.int1014=Value must be in [10,14] interval
NotBlank.actionModel03.strNotBlank=String can't be empty
AssertFalse.actionModel03.assertFalse=Only boolean [false] is allowed
Pattern.actionModel03.hhmmss=String format is hh:mm:ss
Past.actionModel03.dateInPast=Date must be before or equal to today's date
Future.actionModel03.dateInFuture=Date must be after today's date
Length.actionModel03.str4=String must be four characters long
Min.actionModel03.intMin10=Minimum 10
Max.actionModel03.intMax100=Maximum 100
AssertTrue.actionModel03.assertTrue=Only boolean [true] is allowed
Email.actionModel03.email=Invalid email
Size.actionModel03.strBetween4and6=String must be between four and six characters long
URL.actionModel03.url=Invalid URL

Шаблон дії [ActionModel03] використовується в такій дії:


// ----------------------- виведення повідомлень про помилки ------------------------
    @RequestMapping(value = "/m28", method = RequestMethod.POST)
    public Map<String, Object> m28(@Valid ActionModel03 data, BindingResult result, HttpServletRequest request) {
        Map<String, Object> map = new HashMap<String, Object>();
        // контекст додатка Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // локаль
        Locale locale = RequestContextUtils.getLocale(request);
        // помилок?
        if (result.hasErrors()) {
            for (FieldError error : result.getFieldErrors()) {
                // пошук повідомлення про помилку на основі кодів помилок
                // повідомлення шукається у файлах повідомлень
                // коди помилок у вигляді таблиці
                String[] codes = error.getCodes();
                // у вигляді рядка
                String listCodes = String.join(" - ", codes);
                // пошук
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // знайдено?
                if (msg == null) {
                    msg = String.format("Indiquez un message pour l'un des codes [%s]", listCodes);
                }
                // знайдено — додаємо помилку до словника
                map.put(error.getField(), msg);
            }
        } else {
            // помилок немає
            map.put("data", data);
        }
        return map;
    }

Ми вже коментували цей тип коду. Єдине, що дійсно важливо, — це рядок 23: отримане повідомлення про помилку залежить від локалі запиту.

Ось приклад французькою мовою:

а тепер англійською: