Skip to content

14. Веб-додаток MVC у трирівневій архітектурі — Приклад 1

14.1. Présentation

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

Image

Читачеві пропонується ще раз ознайомитися з принципами роботи веб-додатку MVC у трирівневій архітектурі, якщо він їх забув, у параграфі 4.

Веб-додаток, який ми будемо писати, дасть змогу керувати групою осіб за допомогою чотирьох операцій:

  • перелік осіб у групі
  • додавання особи до групи
  • редагування особи в групі
  • видалення особи з групи

Ці чотири базові операції відповідають операціям з таблицею бази даних. Ми напишемо дві версії цього додатка:

  • у версії 1 рівень [dao] не використовуватиме базу даних. Особи з групи будуть зберігатися у простому об’єкті [ArrayList], який внутрішньо керується шаром [dao]. Це дозволить читачеві протестувати додаток без обмежень, пов’язаних із базою даних.
  • У версії 2 ми розмістимо групу осіб у таблиці бази даних. Ми покажемо, що це відбудеться без впливу на веб-шар версії 1, який залишиться незмінним.

Наступні скріншоти демонструють сторінки, які додаток відображає користувачеві.

Image

Image

Image

 

14.2. Проєкт Eclipse

Проєкт додатка має назву [personnes-01]:

Image

Цей проєкт охоплює три рівні трирівневої архітектури додатка:

  • шар [dao] міститься в пакеті [istia.st.mvc.personnes.dao]
  • шар [metier] або [service] міститься в пакеті [istia.st.mvc.personnes.service]
  • шар [web] або [ui] міститься в пакеті [istia.st.mvc.personnes.web]
  • пакет [istia.st.mvc.personnes.entites] містить об’єкти, спільні для різних шарів
  • пакет [istia.st.mvc.personnes.tests] містить тести Junit для шарів [dao] та [service]

Ми по черзі розглянемо три шари: [dao], [service] та [web]. Оскільки описувати все це було б занадто довго, а читати — можливо, занадто нудно, ми іноді будемо трохи поспішати з поясненнями, за винятком тих випадків, коли йдеться про щось нове.

14.3. Представлення особи

Додаток керує групою осіб. На скріншотах у розділі 14.1 показано деякі характеристики особи. Формально вони представлені класом [Personne]:

Image

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

package istia.st.springmvc.personnes.entites;

import java.text.SimpleDateFormat;
import java.util.Date;

public class Personne {

     // унікальний ідентифікатор особи
    private int id;
     // поточна версія
    private long version;
     // прізвище
    private String nom;
     // ім'я
    private String prenom;
     // дата народження
    private Date dateNaissance;
     // сімейний стан
    private boolean marie = false;
     // кількість дітей
    private int nbEnfants;

     // гетери — сеттери
...

     // конструктор за замовчуванням
    public Personne() {

    }

     // конструктор з ініціалізацією полів особи
    public Personne(int id, String prenom, String nom, Date dateNaissance,
            boolean marie, int nbEnfants) {
        setId(id);
        setNom(nom);
        setPrenom(prenom);
        setDateNaissance(dateNaissance);
        setMarie(marie);
        setNbEnfants(nbEnfants);
    }

     // конструктор особи шляхом копіювання іншої особи
    public Personne(Personne p) {
        setId(p.getId());
        setVersion(p.getVersion());
        setNom(p.getNom());
        setPrenom(p.getPrenom());
        setDateNaissance(p.getDateNaissance());
        setMarie(p.getMarie());
        setNbEnfants(p.getNbEnfants());
    }


     // toString
    public String toString() {
        return "[" + id + "," + version + "," + prenom + "," + nom + ","
                + new SimpleDateFormat("dd/MM/yyyy").format(dateNaissance)
                + "," + marie + "," + nbEnfants + "]";
    }
}
  • особа ідентифікується за допомогою такої інформації:
    • id: номер, що однозначно ідентифікує особу
    • прізвище: прізвище особи
    • ім’я: її ім’я
    • dateNaissance: її дата народження
    • статус шлюбу: її сімейний стан (заміжня чи незаміжня)
    • nbEnfants: кількість дітей
  • атрибут [version] — це атрибут, штучно доданий для потреб додатка. З точки зору об’єктно-орієнтованого програмування, безсумнівно, було б краще додати цей атрибут у клас, похідний від [Personne]. Необхідність у ньому виникає під час аналізу сценаріїв використання веб-додатку. Один із них такий:

У момент часу T1 користувач U1 переходить до редагування особи P. На цей момент кількість дітей дорівнює 0. Він змінює цю кількість на 1, але перш ніж він підтвердить свою зміну, користувач U2 переходить до редагування тієї самої особи P. Оскільки U1 ще не підтвердив свою зміну, U2 бачить кількість дітей рівною 0. U2 переводить ім’я особи P у великі літери. Потім U1 і U2 підтверджують свої зміни в такому порядку. Переможе зміна, внесена U2: ім’я буде переведено у великі літери, а кількість дітей залишиться рівною нулю, хоча U1 вважає, що змінив її на 1.

Поняття «версії особи» допомагає нам вирішити цю проблему. Розглянемо той самий приклад використання:

У момент часу T1 користувач U1 переходить до редагування особи P. У цей момент кількість дітей дорівнює 0, а версія — V1. Він змінює кількість дітей на 1, але перш ніж він підтвердить свої зміни, користувач U2 переходить до редагування тієї самої особи P. Оскільки U1 ще не підтвердив свої зміни, U2 бачить, що кількість дітей дорівнює 0, а версія — V1. U2 змінює ім’я особи P на великі літери. Потім U1 та U2 затверджують свої зміни в цьому порядку. Перед затвердженням зміни перевіряється, чи користувач, який вносить зміни до особи P, має ту саму версію, що й особа P, яка наразі зареєстрована. Це стосується користувача U1. Отже, його зміна приймається, і версія зміненої особи змінюється з V1 на V2, щоб відзначити, що особа зазнала змін. Під час перевірки зміни, внесеної користувачем U2, ми побачимо, що він має версію V1 особи P, тоді як на даний момент її версія — V2. Тоді ми зможемо повідомити користувачу U2, що хтось встиг зробити це раніше за нього і що йому слід почати з нової версії особи P. Він це зробить, отримає особу P з версією V2, яка тепер має дитину, переведе ім’я у великі літери та підтвердить зміни. Його зміна буде прийнята, якщо зареєстрована особа P все ще має версію V2. У підсумку зміни, внесені U1 та U2, будуть враховані, тоді як у випадку використання без версій одна зі змін була б втрачена.

  • рядки 32–40: конструктор, здатний ініціалізувати поля особи. Поле [version] опущено.
  • рядки 43–51: конструктор, який створює копію особи, що передається йому як параметр. У результаті маємо два об’єкти з однаковим вмістом, але на які вказують два різні покажчики.
  • рядок 55: метод [toString] перевизначено, щоб повертати рядок, що відображає стан особи

14.4. Рівень [dao]

Рівень [dao] складається з таких класів та інтерфейсів:

Image

  • [IDao] — це інтерфейс, представлений рівнем [dao]
  • [DaoImpl] — це реалізація цього інтерфейсу, в якій група осіб інкапсульована в об’єкт [ArrayList]
  • [DaoException] — це тип неперевірених (unchecked) винятків, що генеруються рівнем [dao]

Інтерфейс [IDao] має такий вигляд:

package istia.st.springmvc.personnes.dao;

import istia.st.springmvc.personnes.entites.Personne;

import java.util.Collection;

public interface IDao {
     // список усіх осіб
    Collection getAll();
     // отримати конкретну особу
    Personne getOne(int id);
     // додати/змінити особу
    void saveOne(Personne personne);
     // видалити особу
    void deleteOne(int id);
}
  • Інтерфейс має чотири методи для чотирьох операцій, які потрібно виконати над групою осіб:
    • getAll: для отримання набору осіб
    • getOne: для отримання особи з певним id
    • saveOne: для додавання особи (id=-1) або редагування існуючої особи (id <> -1)
    • deleteOne: для видалення особи з певним id

У рівні [dao] можуть виникати винятки. Вони будуть типу [DaoException] :

package istia.st.springmvc.personnes.dao;

public class DaoException extends RuntimeException {

     // код помилки
    private int code;

    public int getCode() {
        return code;
    }

// конструктор
    public DaoException(String message,int code) {
        super(message);
        this.code=code;
    }
}
  • рядок 3: клас [DaoException], що походить від [RuntimeException], є типом неконтрольованого винятку: компілятор не вимагає від нас:
    • обробляти цей тип винятків за допомогою блоку try/catch під час виклику методу, який може його викликати
    • включати маркер «throws DaoException» у сигнатуру методу, який може викликати це виключення

Ця техніка дозволяє нам уникнути необхідності підписувати методи інтерфейсу [IDao] винятками певного типу. Будь-яка реалізація, що генерує неконтрольовані винятки, буде прийнятною, що забезпечить гнучкість архітектури.

  • рядок 6: код помилки. Рівень [dao] генеруватиме різні винятки, які ідентифікуватимуться за допомогою різних кодів помилок. Це дозволить рівню, який вирішить обробляти виняток, дізнатися точне джерело помилки та вжити відповідних заходів. Існують й інші способи досягнення того самого результату. Один із них — створити тип винятку для кожного можливого типу помилки, наприклад NomManquantException, PrenomManquantException, AgeIncorrectException, ...
  • рядки 13–16: конструктор, який дозволить створити виняток, ідентифікований за кодом помилки та повідомленням про помилку.
  • рядки 8–10: метод, який дозволить коду обробки винятку отримати код помилки.

Клас [DaoImpl] реалізує інтерфейс [IDao]:

package istia.st.springmvc.personnes.dao;

import istia.st.springmvc.personnes.entites.Personne;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;

public class DaoImpl implements IDao {

     // список осіб
    private ArrayList personnes = new ArrayList();

     // номер наступної особи
    private int id = 0;

     // ініціалізації
    public void init() {
        try {
            Personne p1 = new Personne(-1, "Joachim", "Major",
                    new SimpleDateFormat("dd/MM/yyyy").parse("13/11/1984"),
                    true, 2);
            saveOne(p1);
            Personne p2 = new Personne(-1, "Mélanie", "Humbort",
                    new SimpleDateFormat("dd/MM/yyyy").parse("12/02/1985"),
                    false, 1);
            saveOne(p2);
            Personne p3 = new Personne(-1, "Charles", "Lemarchand",
                    new SimpleDateFormat("dd/MM/yyyy").parse("01/03/1986"),
                    false, 0);
            saveOne(p3);
        } catch (ParseException ex) {
            throw new DaoException(
                    "Erreur d'initialisation de la couche [dao] : "
                            + ex.toString(), 1);
        }
    }

     // список осіб
    public Collection getAll() {
        return personnes;
    }

     // отримати конкретну особу
    public Personne getOne(int id) {
         // кого шукають
        int i = getPosition(id);
         // чи знайдено?
        if (i != -1) {
            return new Personne(((Personne) personnes.get(i)));
        } else {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        }
    }

     // додати або змінити особу
    public void saveOne(Personne personne) {
         // чи є параметр «особа» дійсним?
        check(personne);
         // додати чи змінити?
        if (personne.getId() == -1) {
             // додавання
            personne.setId(getNextId());
            personne.setVersion(1);
            personnes.add(personne);
            return;
        }
         // редагування — шукаємо особу
        int i = getPosition(personne.getId());
         // знайдено?
        if (i == -1) {
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] qu'on veut modifier n'existe pas", 2);
        }
         // чи маємо правильну версію оригіналу?
        Personne original = (Personne) personnes.get(i);
        if (original.getVersion() != personne.getVersion()) {
            throw new DaoException("L'original de la personne [" + personne
                    + "] a changé depuis sa lecture initiale", 3);
        }
         // очікуємо 10 мс
         //wait(10);
         // все гаразд — виконуємо зміну
        original.setVersion(original.getVersion()+1);
        original.setNom(personne.getNom());
        original.setPrenom(personne.getPrenom());
        original.setDateNaissance((personne.getDateNaissance()));
        original.setMarie(personne.getMarie());
        original.setNbEnfants(personne.getNbEnfants());
    }

     // видалення особи
    public void deleteOne(int id) {
         // шукаємо особу
        int i = getPosition(id);
         // знайшли?
        if (i == -1) {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        } else {
             // видаляємо особу
            personnes.remove(i);
        }
    }

     // генератор ідентифікатора
    private int getNextId() {
        id++;
        return id;
    }

     // пошук особи
    private int getPosition(int id) {
        int i = 0;
        boolean trouvé = false;
         // переглядаємо список осіб
        while (i < personnes.size() && !trouvé) {
            if (id == ((Personne) personnes.get(i)).getId()) {
                trouvé = true;
            } else {
                i++;
            }
        }
         // результат?
        return trouvé ? i : -1;
    }

     // перевірка особи
    private void check(Personne p) {
         // особа p
        if (p == null) {
            throw new DaoException("Personne null", 10);
        }
         // ідентифікатор
        if (p.getId() != -1 && p.getId() < 0) {
            throw new DaoException("Id [" + p.getId() + "] invalide", 11);
        }
         // дата народження
        if (p.getDateNaissance() == null) {
            throw new DaoException("Date de naissance manquante", 12);
        }
         // кількість дітей
        if (p.getNbEnfants() < 0) {
            throw new DaoException("Nombre d'enfants [" + p.getNbEnfants()
                    + "] invalide", 13);
        }
         // прізвище
        if (p.getNom() == null || p.getNom().trim().length() == 0) {
            throw new DaoException("Nom manquant", 14);
        }
         // ім'я
        if (p.getPrenom() == null || p.getPrenom().trim().length() == 0) {
            throw new DaoException("Prénom manquant", 15);
        }
    }

     // очікування
    private void wait(int N) {
         // очікування триває N мс
        try {
            Thread.sleep(N);
        } catch (InterruptedException e) {
             // виводиться трасування винятку
            e.printStackTrace();
            return;
        }
    }
}

Ми наведемо лише основні риси цього коду. Однак трохи зупинимося на найскладніших частинах.

  • рядок 13: об’єкт [ArrayList], який міститиме групу осіб
  • рядок 16: ідентифікатор останньої доданої особи. З кожним новим додаванням цей ідентифікатор збільшуватиметься на 1.

Клас [DaoImpl] буде інстанційовано в єдиному екземплярі. Це називається синглтоном. Веб-додаток обслуговує своїх користувачів одночасно. У певний момент часу веб-сервер виконує кілька потоків. Вони спільно використовують синглтони:

  • синглтон рівня [dao]
  • з рівня [service]
  • окремі екземпляри різних контролерів, валідаторів даних тощо з веб-шару

Якщо синглтон має приватні поля, слід одразу ж запитати себе, навіщо вони йому потрібні. Чи є вони виправданими? Адже вони будуть спільними для різних потоків. Якщо вони призначені лише для читання, це не становить проблеми, якщо їх можна ініціалізувати в момент, коли ми впевнені, що активний лише один потік. Зазвичай такий момент можна визначити. Це момент запуску веб-додатку, коли він ще не почав обслуговувати клієнтів. Якщо ж ці поля доступні як для читання, так і для запису, то необхідно забезпечити синхронізацію доступу до полів, інакше це призведе до катастрофи. Ми проілюструємо цю проблему під час тестування шару [dao].

  • Клас [DaoImpl] не має конструктора. Тому буде використано його конструктор за замовчуванням.
  • рядки 19–38: метод [init] буде викликано під час інстанціювання синглтона шару [dao]. Він створює список із трьох осіб.
  • рядки 41–43: реалізують метод [getAll] інтерфейсу [IDao]. Вони повертають посилання на список осіб.
  • рядки 46–55: реалізують метод [getOne] інтерфейсу [IDao]. Його параметром є ідентифікатор особи, яку шукають.

Щоб отримати її, викликається приватний метод [getPosition] у рядках 113–126. Цей метод повертає позицію шуканої особи у списку або -1, якщо особу не знайдено.

Якщо особу було знайдено, метод [getOne] повертає посилання (рядок 51) на копію цієї особи, а не на саму особу. Дійсно, коли користувач захоче змінити дані особи, інформація про неї буде запитана на рівні [dao] і передана до рівня [web] для редагування у вигляді посилання на об’єкт [Personne]. Ця посилання слугуватиме контейнером для введення даних у формі редагування. Коли у веб-шарі користувач надішле свої зміни, вміст контейнера для введення даних буде змінено. Якщо контейнер є посиланням на реальну особу з об’єкта [ArrayList] у шарі [dao], то вона змінюється, навіть якщо зміни не були передані до шарів [service] та [dao]. Останній є єдиним, уповноваженим керувати списком осіб. Тому веб-шар повинен працювати з копією особи, яку потрібно змінити. У цьому випадку шар [dao] надає цю копію.

Якщо шукану особу не знайдено, генерується виняток типу [DaoException] з кодом помилки 2 (рядок 53).

  • рядки 94–104: реалізують метод [deleteOne] інтерфейсу [IDao]. Його параметром є ідентифікатор особи, яку потрібно видалити. Якщо особа, яку потрібно видалити, не існує, генерується виняток типу [DaoException] з кодом помилки 2.
  • рядки 58–91: реалізують метод [saveOne] інтерфейсу [IDao]. Його параметром є об’єкт [Personne]. Якщо цей об’єкт має id=-1, то це додавання особи. В іншому випадку — це зміна даних особи зі списку, що має цей id, на значення, вказані в параметрі.
    • рядок 60: правильність параметра [Personne] перевіряється за допомогою приватного методу [check], визначеного в рядках 129–155. Цей метод виконує базові перевірки значень різних полів [Personne]. Щоразу, коли виявляється відхилення, запускається [DaoException] із конкретним кодом помилки. Оскільки метод [saveOne] не обробляє це виключення, воно передається до методу, що його викликав.
    • рядок 62: якщо ідентифікатор параметра [Personne] дорівнює -1, то це додавання. Об’єкт [Personne] додається до внутрішнього списку осіб (рядок 66) із першим доступним ідентифікатором (рядок 64) та номером версії, рівним 1 (рядок 65).
    • якщо параметр [Personne] має значення [id], відмінне від -1, то це означає, що потрібно змінити особу у внутрішньому списку, яка має цей [id]. Спочатку перевіряється (рядки 70–75), чи існує особа, яку потрібно змінити. Якщо ні, генерується виняток типу [DaoException] з кодом помилки 2.
    • Якщо особа дійсно існує, перевіряється, чи її поточна версія збігається з версією параметра [Personne], який містить зміни, що мають бути внесені до оригіналу. Якщо це не так, це означає, що особа, яка хоче внести зміни до даних особи, не має її останньої версії. Про це повідомляють, генеруючи виняток типу [DaoException] з кодом помилки 3 (рядки 79–80).
    • Якщо все гаразд, зміни вносяться до оригінального запису особи (рядки 85–90)

Очевидно, що цей метод має бути синхронізований. Наприклад, у проміжку часу між перевіркою наявності особи, яку потрібно змінити, та моментом внесення зміни ця особа могла бути видалена зі списку кимось іншим. Тому метод слід оголосити як [synchronized], щоб гарантувати, що його виконує лише один потік одночасно. Те саме стосується й інших методів інтерфейсу [IDao]. Ми цього не робимо, вважаючи за краще перенести цю синхронізацію на рівень [service]. Щоб висвітлити проблеми синхронізації, під час тестування шару [dao] ми зупинимо виконання [saveOne] на 10 мс (рядок 83) між моментом, коли ми знаємо, що можна внести зміну, і моментом, коли ми її фактично вносимо. Потік, що виконує [saveOne], втратить доступ до процесора на користь іншого. Таким чином ми збільшуємо ймовірність виникнення конфліктів доступу до списку осіб.

14.5. Тестування рівня [dao]

Для рівня [dao] написано тест JUnit:

[TestDao] — це тест JUnit. Щоб виявити проблеми з паралельним доступом до списку осіб, створюються потоки типу [ThreadDaoMajEnfants]. Їх завданням є збільшення на 1 кількості дітей певної особи.

[TestDao] містить п’ять тестів — від [test1] до [test5]. Ми наводимо лише два з них, а решту читач може ознайомитися у вихідному коді, що додається до цієї статті.

package istia.st.springmvc.personnes.tests;

import java.text.ParseException;
...

public class TestDao extends TestCase {

     // шар [dao]
    private DaoImpl dao;

     // конструктор
    public TestDao() {
        dao = new DaoImpl();
        dao.init();
    }

     // список осіб
    private void doListe(Collection personnes) {
        Iterator iter = personnes.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }

     // test1
    public void test1() throws ParseException {
...
    }

     // зміна-видалення неіснуючого елемента
    public void test2() throws ParseException {
...
    }

     // управління версіями осіб
    public void test3() throws ParseException, InterruptedException {
...
    }

     // оптимістична блокування — багатопотоковий доступ
    public void test4() throws Exception {
...
    }

     // перевірка правильності saveOne
    public void test5() throws ParseException {
    ...
}
  • рядок 9: посилання на реалізацію тестованого шару [dao]
  • рядки 12–15: конструктор тесту JUnit. Він створює екземпляр типу [DaoImpl] з тестованого шару [dao] та ініціалізує його.

Метод [test1] тестує чотири методи інтерфейсу [IDao] наступним чином:

    public void test1() throws ParseException {
         // поточний список
        Collection personnes = dao.getAll();
        int nbPersonnes = personnes.size();
         // відображення
        doListe(personnes);
         // додавання особи
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 1);
        dao.saveOne(p1);
        int id1 = p1.getId();
         // перевірка — відбудеться збій, якщо особу не знайдено
        p1 = dao.getOne(id1);
        assertEquals("X", p1.getNom());
         // редагування
        p1.setNom("Y");
        dao.saveOne(p1);
         // перевірка — відбудеться збій, якщо особу не знайдено
        p1 = dao.getOne(id1);
        assertEquals("Y", p1.getNom());
         // видалення
        dao.deleteOne(id1);
         // перевірка
        int codeErreur = 0;
        boolean erreur = false;
        try {
            p1 = dao.getOne(id1);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
         // має з’явитися помилка з кодом 2
        assertTrue(erreur);
        assertEquals(2, codeErreur);
         // список осіб
        personnes = dao.getAll();
        assertEquals(nbPersonnes, personnes.size());
    }
  • рядок 3: запитується список осіб
  • рядок 6: виводиться цей список
[1,1,Joachim,Major,13/01/1984,true,2]
[2,1,Mélanie,Humbort,12/01/1985,false,1]
[3,1,Charles,Lemarchand,01/01/1986,false,0]

Далі тест додає особу, змінює її та видаляє. Таким чином використовуються всі чотири методи інтерфейсу [IDao].

  • рядки 8–10: додається нова особа (id=-1).
  • рядок 11: отримуємо ідентифікатор доданої особи, оскільки під час додавання їй було присвоєно ідентифікатор. До цього вона його не мала.
  • рядки 13–14: запитується у шару [dao] копія особи, яку щойно додано. Слід пам’ятати, що якщо запитувану особу не знайдено, шар [dao] генерує виняток. У такому разі відбудеться збій у роботі програми на рядку 13. Цей випадок можна було б обробити більш коректно. У рядку 14 перевіряється ім’я знайденої особи.
  • рядки 16–17: змінюємо це ім’я та звертаємося до шару [dao] із запитом на збереження змін.
  • рядки 19–20: запитуємо у шару [dao] копію особи, яку щойно додали, і перевіряємо її нове ім’я.
  • рядок 22: видаляємо особу, додану на початку тесту.
  • рядки 23–34: запитуємо у шару [dao] копію особи, яку щойно видалили. Повинно бути отримано [DaoException] з кодом 2.
  • рядки 36–37: повторно запитується список осіб. Повинно бути отримано той самий список, що й на початку тесту.

Метод [test4] має на меті виявити проблеми одночасного доступу до методів шару [dao]. Нагадаємо, що ці методи не були синхронізовані. Код тесту такий:

    public void test4() throws Exception {
         // додавання особи
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
        dao.saveOne(p1);
        int id1 = p1.getId();
         // створення N потоків для оновлення кількості дітей
        final int N = 10;
        Thread[] taches = new Thread[N];
        for (int i = 0; i < taches.length; i++) {
            taches[i] = new ThreadDaoMajEnfants("thread n° " + i, dao, id1);
            taches[i].start();
        }
         // очікуємо завершення роботи потоків
        for (int i = 0; i < taches.length; i++) {
            taches[i].join();
        }
         // отримання особи
        p1 = dao.getOne(id1);
         // вона повинна мати N дітей
        assertEquals(N, p1.getNbEnfants());
         // видалення особи p1
        dao.deleteOne(p1.getId());
         // перевірка
        boolean erreur = false;
        int codeErreur = 0;
        try {
            p1 = dao.getOne(p1.getId());
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
         // має бути помилка з кодом 2
        assertTrue(erreur);
        assertEquals(2, codeErreur);
    }
  • рядки 3–6: до списку додається особа P, яка не має дітей. Записується її [id] (рядок 6).
  • рядки 7–13: запускаємо N потоків. Кожен із них збільшить кількість дітей особи P на 1 одиницю. У підсумку особа P повинна мати N дітей.
  • рядки 15–17: метод [test4], який запустив N потоків, чекає, поки вони завершать свою роботу, перш ніж перевірити нову кількість дітей особи P.
  • рядки 18–21: отримуємо особу P і перевіряємо, чи кількість її дітей дорівнює N.
  • рядки 22–35: особу P видаляють, а потім перевіряють, чи вона більше не існує у списку.

У рядку 11 бачимо, що потоки мають тип [ThreadDaoMajEnfants]. Конструктор цього типу має три параметри:

  1. назва, присвоєна потоку, для його відстеження за допомогою логів
  2. посилання на рівень [dao], щоб потік мав до нього доступ
  3. ідентифікатор особи, з якою має працювати потік

Тип [ThreadDaoMajEnfants] має такий вигляд:

package istia.st.mvc.personnes.tests;

import java.util.Date;

import istia.st.mvc.personnes.dao.DaoException;
import istia.st.mvc.personnes.dao.IDao;
import istia.st.mvc.personnes.entites.Personne;

public class ThreadDaoMajEnfants extends Thread {
     // назва потоку
    private String name;
     // посилання на шар [dao]
    private IDao dao;
     // ідентифікатор особи, з якою будемо працювати
    private int idPersonne;

     // конструктор
    public ThreadDaoMajEnfants(String name, IDao dao, int idPersonne) {
        this.name = name;
        this.dao = dao;
        this.idPersonne = idPersonne;
    }

     // ядро потоку
    public void run() {
         // відстеження
        suivi("lancé");
         // цикл повторюється, доки не вдасться збільшити значення на 1
         // кількість дітей особи idPersonne
        boolean fini = false;
        int nbEnfants = 0;
        while (!fini) {
             // отримуємо копію особи з idPersonne
            Personne personne = dao.getOne(idPersonne);
            nbEnfants = personne.getNbEnfants();
             // подальші дії
            suivi("" + nbEnfants + " -> " + (nbEnfants + 1) + " pour la version "+personne.getVersion());
             // очікування 10 мс перед звільненням процесора
            try {
                 // продовження
                suivi("début attente");
                 // призупиняється, щоб звільнити процесор
                Thread.sleep(10);
                 // продовження
                suivi("fin attente");
            } catch (Exception ex) {
                throw new RuntimeException(ex.toString());
            }
             // очікування завершено — намагаємося підтвердити копію
             // тим часом інші потоки могли змінити оригінал
            int codeErreur = 0;
            try {
                 // збільшуємо на 1 кількість дочірніх елементів цієї копії
                personne.setNbEnfants(nbEnfants + 1);
                 // намагаємося змінити оригінал
                dao.saveOne(personne);
                 // пройшли — оригінал було змінено
                fini = true;
            } catch (DaoException ex) {
                 // отримано код помилки
                codeErreur = ex.getCode();
                 // має бути помилкою версії 3 — інакше запускаємо знову
                 // виняток
                if (codeErreur != 3) {
                    throw ex;
                } else {
                     // відстеження
                    suivi(ex.getMessage());
                }
                 // оригінал змінився — починаємо все спочатку
            }
        }
         // продовження
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }

     // продовження
    private void suivi(String message) {
        System.out
                .println(name + " [" + new Date().getTime()+ "] : " + message);
    }
}
  • рядок 9: [ThreadDaoMajEnfants] дійсно є потоком
  • рядки 18–22: конструктор, який ініціалізує потік, використовуючи три параметри
    1. ім’я [name], присвоєне потоку
    2. посилання [dao] на рівень [dao]. Зазначимо, що знову ми працюємо з типом інтерфейсу [IDao], а не з типом реалізації [DaoImpl].
    3. ідентифікатор [id] особи, з якою повинен працювати потік

Коли [test4] запускає потік [ThreadDaoMajEnfants] (рядок 12 файлу test4), виконується його метод [run] (рядок 25):

  • рядки 78–81: приватний метод [suivi] дозволяє виводити повідомлення на екран. Метод [run] використовує його для відстеження виконання потоку.
  • потік намагатиметься збільшити на 1 кількість дітей особи P з ідентифікатором [id]. Це оновлення може вимагати декількох спроб. Розглянемо два потоки [TH1] та [TH2]. [TH1] запитує копію особи P у шару [dao]. Він отримує її та констатує, що вона має версію V1. [TH1] переривається. [TH2], який йшов за ним, робить те саме і отримує ту саму версію V1 особи P. [TH2] переривається. [TH2] повертає собі контроль, збільшує кількість дочірніх елементів P та зберігає свої зміни. Ми знаємо, що на цей момент ці зміни збережені, а версія P перейде до V2. [TH1] завершив свою роботу. [TH2] повертається до роботи і робить те саме. Його оновлення P буде відхилено, оскільки він має копію P версії V1, тоді як оригінальний P тепер має версію V2. Тож [TH2] повинен повторити весь цикл [lecture -> mise à jour -> sauvegarde]. Саме тому ми бачимо цикл у рядках 32–72. У ньому потік:
  • запитує копію особи P для редагування (рядок 34)
  • очікує 10 мс (рядок 43). Це штучно введена затримка, яка має на меті перервати роботу потоку між зчитуванням особи P та її фактичним оновленням у списку осіб, щоб збільшити ймовірність конфліктів.
  • збільшує кількість дітей P (рядок 54) та зберігає P (рядок 56). Якщо потік не має правильної версії P, рівень [dao] викличе виняток. Потім отримується код винятку (рядок 61) для перевірки, чи це дійсно код 3 (неправильна версія P). Якщо це не так, виняток перенаправляється до методу, що викликає, а саме до тестового методу [test4]. Якщо виникло виключення з кодом 3, то цикл [lecture -> mise à jour -> sauvegarde] повторюється. Якщо виключення не виникло, то оновлення виконано, і робота потоку завершена.

Які результати дають тести?

У першій протестованій конфігурації:

  • закоментували інструкцію очікування в методі [saveOne] з [DaoImpl] (рядок 83, параграф 14.4).
         // очікуємо 10 мс
         //wait(10);
  • метод [test4] створює 100 потоків (рядок 8, параграф 14.5).
         // створення N потоків для оновлення кількості дітей
        final int N = 100;

Отримуємо такі результати:

Image

Усі п’ять тестів пройшли успішно.

У другій перевіреній конфігурації:

  • видаляємо коментар з команди очікування в методі [saveOne] з [DaoImpl] (рядок 83, параграф 14.4).
         // очікування 10 мс
        wait(10);
  • метод [test4] створює 2 потоки (рядок 8, параграф 14.5).
         // створення N потоків для оновлення кількості дітей
        final int N = 2;

Отримуємо такі результати:

Тест [test4] завершився невдало. Було створено два потоки, кожен з яких мав збільшити на 1 кількість дітей особи P, яка спочатку не мала жодної дитини. Отже, після виконання обох потоків очікувалося 2 дітей, проте є лише одна.

Проаналізуємо екранні логи [test4], щоб зрозуміти, що сталося:

thread n° 0 [1145536368171] : lancé
thread n° 0 [1145536368171] : 0 -> 1 pour la version 1
thread n° 0 [1145536368171] : début attente
thread n° 1 [1145536368171] : lancé
thread n° 1 [1145536368171] : 0 -> 1 pour la version 1
thread n° 1 [1145536368171] : début attente
thread n° 0 [1145536368187] : fin attente
thread n° 1 [1145536368187] : fin attente
thread n° 0 [1145536368187] : a terminé et passé le nombre d'enfants à 1
thread n° 1 [1145536368187] : a terminé et passé le nombre d'enfants à 1
  • рядок 1: потік № 0 починає роботу
  • рядок 2: він отримав копію особи P і виявив, що кількість її дітей дорівнює 0
  • рядок 3: він натрапляє на [Thread.sleep(10)] свого методу [run] і, отже, зупиняється на часі [1145536368171] (мс)
  • рядок 4: потім потік № 1 отримує доступ до процесора і починає свою роботу
  • рядок 5: він отримав копію особи P і встановлює, що кількість її дітей дорівнює 0
  • рядок 6: він натрапляє на [Thread.sleep(10)] свого методу [run] і, отже, зупиняється
  • рядок 7: потік № 0 отримує доступ до процесора в момент часу [1145536368187] (мс), c.a.d — через 16 мс після втрати доступу.
  • рядок 8: те саме для потоку № 1
  • рядок 9: потік № 0 виконав оновлення та змінив кількість дочірніх процесів на 1
  • рядок 10: потік № 1 зробив те саме

Питання полягає в тому, чому потік № 1 зміг виконати оновлення, хоча зазвичай він уже не мав правильної версії особи P, яку щойно оновив потік № 0.

По-перше, можна помітити аномалію між рядками 7 і 8: схоже, що між цими двома рядками потік № 0 втратив контроль над процесором на користь потоку № 1. Що він робив у цей момент? Він виконував метод [saveOne] з шару [dao]. Цей метод має такий каркас (див. параграф 14.4):

    public void saveOne(Personne personne) {
...
         // зміна — шукаємо особу
....
         // чи маємо ми правильну версію оригіналу?
...
         // очікуємо 10 мс
        wait(10);
         // все гаразд — виконуємо зміну
    ...
}
  • потік № 0 виконав [saveOne] і дійшов до рядка 8, де був змушений звільнити процесор. Тим часом він зчитав версію особи P, і вона дорівнювала 1, оскільки особа P ще не була оновлена.
  • Оскільки процесор звільнився, його перейняв потік № 1. Він, у свою чергу, виконав [saveOne] і дійшов до рядка 8, де був змушений звільнити процесор. Тим часом він зчитав версію користувача P, і вона дорівнювала 1, оскільки дані користувача P ще не були оновлені.
  • Оскільки процесор став вільним, його перейняв потік № 0. Починаючи з 9-го рядка, він виконав оновлення та змінив кількість дітей на 1. Потім метод [run] потоку № 0 завершився, і потік вивів запис у журнал, який повідомляв, що він змінив кількість дітей на 1 (9-й рядок).
  • Оскільки процесор звільнився, його перейняв потік № 1. Починаючи з 9-го рядка, він виконав оновлення та змінив кількість дочірніх процесів на 1. Чому 1? Тому що він має копію P із кількістю дочірніх процесів, що дорівнює 0. Про це свідчить запис у журналі (5-й рядок). Потім метод [run] потоку № 1 завершився, і потік вивів у лог повідомлення про те, що він змінив кількість дітей на 1 (рядок 10).

У чому полягає проблема? Вона полягає в тому, що потік № 0 не встиг підтвердити свою зміну і, отже, змінити версію особи P до того, як потік № 1 спробував прочитати цю версію, щоб дізнатися, чи змінилася особа P. Такий сценарій малоймовірний, але не неможливий. Довелося примусово відібрати ресурси процесора у потоку № 0, щоб відтворити цю ситуацію лише з двома потоками. Без цього прийому попередня конфігурація не змогла відтворити цей самий випадок із 100 потоками. Тест [test4] пройшов успішно.

Яке рішення? Безсумнівно, їх є кілька. Одне з них, просте у реалізації, полягає в синхронізації методу [saveOne]:


    public synchronized void saveOne(Personne personne)

Ключове слово [synchronized] гарантує, що метод може виконувати лише один потік одночасно. Таким чином, потік № 1 отримає дозвіл на виконання [saveOne] лише тоді, коли потік № 0 вийде з нього. Тоді ми можемо бути впевнені, що версія особи P буде змінена, коли потік № 1 увійде в [saveOne]. Його оновлення буде відхилено, оскільки він не матиме правильної версії P.

Саме ці чотири методи шару [dao] слід було б синхронізувати. Однак ми вирішили залишити цей шар таким, як він описаний, і перенести синхронізацію на шар [service]. Для цього є кілька причин:

  • ми виходимо з припущення, що доступ до шару [dao] завжди здійснюється через шар [service]. Саме так відбувається в нашому веб-додатку.
  • може виникнути необхідність синхронізувати також доступ до методів шару [service] з інших причин, ніж ті, що змушують нас синхронізувати методи шару [dao]. У цьому випадку немає потреби синхронізувати методи шару [dao]. Якщо ми впевнені, що:
  • усі звернення до шару [dao] проходять через шар [service]
  • що одночасно лише один потік використовує шар [service]

то можна бути впевненим, що методи шару [dao] не будуть виконуватися двома потоками одночасно.

Тепер розглянемо рівень [service].

14.6. Рівень [service]

Рівень [service] складається з таких класів та інтерфейсів:

Image

  • [IService] — це інтерфейс, представлений рівнем [dao]
  • [ServiceImpl] є її реалізацією

Інтерфейс [IService] має такий вигляд:

package istia.st.springmvc.personnes.service;

import istia.st.springmvc.personnes.entites.Personne;

import java.util.Collection;

public interface IService {
     // список усіх осіб
    Collection getAll();
     // отримати конкретну особу
    Personne getOne(int id);
     // додати/змінити особу
    void saveOne(Personne personne);
     // видалити особу
    void deleteOne(int id);
}

Він ідентичний інтерфейсу [IDao].

Реалізація [ServiceImpl] інтерфейсу [IService] має такий вигляд:

package istia.st.springmvc.personnes.service;

import istia.st.springmvc.personnes.dao.IDao;
import istia.st.springmvc.personnes.entites.Personne;

import java.util.Collection;

public class ServiceImpl implements IService {

     // шар [dao]
    private IDao dao;

    public IDao getDao() {
        return dao;
    }

    public void setDao(IDao dao) {
        this.dao = dao;
    }

     // список осіб
    public synchronized Collection getAll() {
        return dao.getAll();
    }

     // отримати конкретну особу
    public synchronized Personne getOne(int id) {
        return dao.getOne(id);
    }

     // додати або змінити особу
    public synchronized void saveOne(Personne personne) {
        dao.saveOne(personne);
    }

     // видалення особи
    public synchronized void deleteOne(int id) {
        dao.deleteOne(id);
    }
}
  • рядки 10–19: атрибут [IDao dao] є посиланням на шар [dao]. Він буде ініціалізований Spring IoC.
  • рядки 22–24: реалізація методу [getAll] інтерфейсу [IService]. Метод просто делегує запит на рівень [dao].
  • рядки 27–29: реалізація методу [getOne] інтерфейсу [IService]. Цей метод просто делегує запит на рівень [dao].
  • рядки 32–34: реалізація методу [saveOne] інтерфейсу [IService]. Цей метод просто делегує запит на рівень [dao].
  • рядки 37–39: реалізація методу [deleteOne] інтерфейсу [IService]. Цей метод просто делегує запит на рівень [dao].
  • Усі методи синхронізовані (ключове слово `synchronized`), що гарантує, що одночасно лише один потік зможе використовувати рівень [service], а отже, і рівень [dao].

14.7. Тестування шару [service]

Для шару [service] написано тест JUnit:

[TestService] — це тест JUnit. Виконані тести є повністю ідентичними тим, що були виконані для шару [dao]. Структура [TestService] така:

package istia.st.springmvc.personnes.tests;

...

public class TestService extends TestCase {

     // шар [service]
    private ServiceImpl service;

     // розробник
    public TestService() {
        service = new ServiceImpl();
        DaoImpl dao=new DaoImpl();
        service.setDao(dao);
    }

     // список осіб
    private void doListe(Collection personnes) {
...
    }

     // тест1
    public void test1() throws ParseException {
         // поточний список
        Collection personnes = service.getAll();
        int nbPersonnes = personnes.size();
         // перегляд
        doListe(personnes);
         // додавання особи
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 1);
        service.saveOne(p1);
        int id1 = p1.getId();
         // перевірка — відбудеться збій, якщо особу не знайдено
        p1 = service.getOne(id1);
        assertEquals("X", p1.getNom());
...
    }

     // редагування-видалення елемента, що не існує
    public void test2() throws ParseException {
...
    }

     // управління версіями особи
    public void test3() throws ParseException, InterruptedException {
...
    }

     // оптимістична блокування — багатопотоковий доступ
    public void test4() throws Exception {
         // додавання особи
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
        service.saveOne(p1);
        int id1 = p1.getId();
         // створення N потоків для оновлення кількості дітей
        final int N = 100;
        Thread[] taches = new Thread[N];
        for (int i = 0; i < taches.length; i++) {
            taches[i] = new ThreadServiceMajEnfants("thread n° " + i, service,
                    id1);
            taches[i].start();
        }
...
    }

     // перевірка правильності saveOne
    public void test5() throws ParseException {
    ...
    }
}
  • рядок 9: тестований шар [service] типу [ServiceImpl].
  • рядки 11–15: конструктор тесту JUnit створює екземпляр шару [service], що підлягає тестуванню (рядок 12), створює екземпляр шару [dao] (рядок 13) та вказує шару [service], що він повинен використовувати цей шар [dao] (рядок 14).

Метод [test1] тестує чотири методи інтерфейсу [IService] так само, як і однойменний метод тестування шару [dao]. Просто замість шару [dao] здійснюється звернення до шару [service] (рядки 25, 32, 35).

Метод [test4] призначений для виявлення проблем одночасного доступу до методів шару [service]. Він, знову ж таки, ідентичний методу тестування [test4] рівня [dao]. Однак є кілька відмінностей:

  • звертаємося до шару [service], а не до шару [dao] (рядок 55)
  • потокам передається посилання на шар [service] замість шару [dao] (рядок 61)

Тип [ThreadServiceMajEnfants] також майже ідентичний типу [ThreadDaoMajEnfants], за винятком того, що він працює з шаром [service], а не з шаром [dao]:

package istia.st.springmvc.personnes.tests;

import istia.st.mvc.personnes.dao.DaoException;
import istia.st.mvc.personnes.entites.Personne;
import istia.st.mvc.personnes.service.IService;

public class ThreadServiceMajEnfants extends Thread {

     // назва потоку
    private String name;
     // посилання на рівень [service]
    private IService service;
     // ідентифікатор особи, над якою будемо працювати
    private int idPersonne;

    public ThreadServiceMajEnfants(String name, IService service, int idPersonne) {
        this.name = name;
        this.service = service;
        this.idPersonne = idPersonne;
    }

    public void run() {
...
    }

     // відстеження
    private void suivi(String message) {
        System.out.println(name + " : " + message);
    }

}
  • рядок 12: потік працює з шаром [service]

Ми проводимо тестування з конфігурацією, яка спричинила проблему на рівні [dao]:

  • видаляємо коментар з інструкції очікування в методі [saveOne] класу [DaoImpl] (рядок 83, параграф 14.4).
         // очікування 10 мс
        wait(10);
  • метод [test4] створює 100 потоків (рядок 65, параграф 14.7).
         // створення N потоків для оновлення кількості дітей
        final int N = 100;

Отримано такі результати:

Саме синхронізація методів рівня [service] забезпечила успішне виконання тесту [test4].

14.8. Шар [web]

Нагадаємо про трирівневу архітектуру нашого додатка:

Рівень [web] надаватиме користувачеві екрани, що дозволять йому керувати групою осіб:

  • список осіб у групі
  • додавання особи до групи
  • редагування особи в групі
  • видалення особи з групи

Для цього вона буде спиратися на рівень [service], який, у свою чергу, буде звертатися до рівня [dao]. Ми вже розглядали екрани, що обслуговуються шаром [web] (розділ 14.1). Для опису веб-шару ми послідовно розглянемо:

  • її конфігурацію
  • її подання
  • її контролер
  • деякі тести

14.8.1. Конфігурація веб-додатку

Проект Eclipse для додатка виглядає наступним чином:

Image

  • у пакеті [istia.st.mvc.personnes.web] міститься контролер [Application].
  • Сторінки JSP / JSTL знаходяться в [WEB-INF/vues].
  • Папка [lib] містить сторонні архіви, необхідні для роботи програми. Їх можна переглянути в папці [Web App Libraries].

[web.xml]


Файл [web.xml] — це файл, який використовується веб-сервером для завантаження програми. Його вміст такий:


<?xml version="1.0" encoding="UTF-8"?>
<web-app id="WebApp_ID" version="2.4"
    xmlns="http://java.sun.com/xml/ns/j2ee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">
    <display-name>mvc-personnes-01</display-name>
    <!--  ServletPersonne -->
    <servlet>
        <servlet-name>personnes</servlet-name>
        <servlet-class>
            istia.st.mvc.personnes.web.Application
        </servlet-class>
        <init-param>
            <param-name>urlEdit</param-name>
            <param-value>/WEB-INF/vues/edit.jsp</param-value>
        </init-param>
        <init-param>
            <param-name>urlErreurs</param-name>
            <param-value>/WEB-INF/vues/erreurs.jsp</param-value>
        </init-param>
        <init-param>
            <param-name>urlList</param-name>
            <param-value>/WEB-INF/vues/list.jsp</param-value>
        </init-param>
    </servlet>
    <!--  Мапування ServletPersonne-->
    <servlet-mapping>
        <servlet-name>personnes</servlet-name>
        <url-pattern>/do/*</url-pattern>
    </servlet-mapping>
    <!--  файли головних сторінок -->
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>
    <!--  Сторінка несподіваної помилки -->
    <error-page>
        <exception-type>java.lang.Exception</exception-type>
        <location>/WEB-INF/vues/exception.jsp</location>
    </error-page>
</web-app>
  • рядки 27–30: URL-адреси [/do/*] оброблятиме сервлет [personnes]
  • рядки 9–12: сервлет [personnes] є екземпляром класу [Application], який ми будемо створювати.
  • рядки 13–24: визначають три параметри [urlList, urlEdit, urlErreurs], що ідентифікують URL-адреси сторінок JSP для переглядів [list, edit, erreurs].
  • рядки 32–34: додаток має сторінку входу за замовчуванням [index.jsp], яка знаходиться в кореневій папці веб-додатка.
  • рядки 36–39: у додатка є сторінка помилок за замовчуванням, яка відображається, коли веб-сервер отримує виняток, що не обробляється додатком.
    • рядок 37: тег <exception-type> вказує тип винятку, що обробляється директивою <error-page>; у даному випадку це тип [java.lang.Exception] та його похідні, тобто всі винятки.
    • рядок 38: тег <location> вказує на сторінку JSP, яку слід відобразити, коли виникає виняток типу, визначеного тегом <exception-type>. Інформація про виняток, що виник, доступна на цій сторінці в об’єкті з іменем exception, якщо на сторінці є директива:

<%@ page isErrorPage="true" %>
  • (продовження)
    • якщо <exception-type> вказує тип T1, і виняток типу T2, що не є похідним від T1, передається до веб-сервера, останній надсилає клієнту власну сторінку винятку, яка зазвичай є малозручною. Звідси й корисність тегу <error-page> у файлі [web.xml].

[index.jsp]


Ця сторінка відображається, якщо користувач безпосередньо запитує контекст додатка, не вказавши URL-адресу, c.a.d. тут [/personnes-01]. Її вміст такий:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>

<c:redirect url="/do/list"/>

[index.jsp] перенаправляє клієнта на URL-адресу [/do/list]. Ця URL-адреса відображає список осіб у групі.

14.8.2. Сторінки JSP / JSTL додатка


Вигляд [list.jsp]


Він слугує для відображення списку осіб:

Image

Його код такий:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
<%@ taglib uri="/WEB-INF/taglibs-datetime.tld" prefix="dt" %>

<html>
    <head>
        <title>MVC - Personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
        <h2>Liste des personnes</h2>
        <table border="1">
            <tr>
                <th>Id</th>
                <th>Version</th>
                <th>Pr&eacute;nom</th>
                <th>Nom</th>
                <th>Date de naissance</th>
                <th>Mari&eacute;</th>
                <th>Nombre d'enfants</th>
                <th></th>
            </tr>
            <c:forEach var="personne" items="${personnes}">
                <tr>
                    <td><c:out value="${personne.id}"/></td>
                    <td><c:out value="${personne.version}"/></td>
                    <td><c:out value="${personne.prenom}"/></td>
                    <td><c:out value="${personne.nom}"/></td>
                    <td><dt:format pattern="dd/MM/yyyy">${personne.dateNaissance.time}</dt:format></td>
                    <td><c:out value="${personne.marie}"/></td>
                    <td><c:out value="${personne.nbEnfants}"/></td>
                    <td><a href="<c:url value="/do/edit?id=${personne.id}"/>">Modifier</a></td>
                    <td><a href="<c:url value="/do/delete?id=${personne.id}"/>">Supprimer</a></td>
                </tr>
            </c:forEach>
        </table>
        <br>
        <a href="<c:url value="/do/edit?id=-1"/>">Ajout</a>
    </body>
</html>

  • Цей вигляд отримує елемент у своїй моделі:
  • елемент [personnes], пов’язаний з об’єктом типу [ArrayList], що містить об’єкти типу [Personne]
  • рядки 22–34: виконується перебір списку ${personnes} для відображення таблиці HTML, що містить осіб із групи.
  • рядок 31: URL-адреса, на яку вказує посилання [Modifier], задається полем [id] поточної особи, щоб контролер, пов’язаний з URL-адресою [/do/edit], знав, яку саме особу потрібно редагувати.
  • рядок 32: те саме робиться для посилання [Supprimer].
  • рядок 28: щоб відобразити дату народження особи у форматі JJ/MM/AAAA, використовується тег <dt> із бібліотеки тегів [DateTime] проекту Apache [Jakarta Taglibs]:

Image

Файл опису цієї бібліотеки тегів визначено у рядку 3.

  • рядок 37: посилання [Ajout] для додавання нової особи має цільовим URL-адресу [/do/edit], так само як і посилання [Modifier] у рядку 31. Саме значення -1 параметра [id] вказує на те, що мова йде про додавання, а не про редагування.

Вигляд [edit.jsp]


Він слугує для відображення форми додавання нової особи або редагування існуючої особи:

Код подання [edit.jsp] такий:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
<%@ taglib uri="/WEB-INF/taglibs-datetime.tld" prefix="dt" %>

<html>
    <head>
        <title>MVC - Personnes</title>
    </head>
    <body background="../ressources/standard.jpg">
        <h2>Ajout/Modification d'une personne</h2>
        <c:if test="${erreurEdit != ''}">
            <h3>Echec de la mise à jour :</h3>
          L'erreur suivante s'est produite : ${erreurEdit}
            <hr>
        </c:if>
        <form method="post" action="<c:url value="/do/validate"/>">
            <table border="1">
                <tr>
                    <td>Id</td>
                    <td>${id}</td>
                </tr>
                <tr>
                    <td>Version</td>
                    <td>${version}</td>
                </tr>
                <tr>
                    <td>Pr&eacute;nom</td>
                    <td>
                        <input type="text" value="${prenom}" name="prenom" size="20">
                    </td>
                    <td>${erreurPrenom}</td>
                </tr>
                <tr>
                    <td>Nom</td>
                    <td>
                        <input type="text" value="${nom}" name="nom" size="20">
                    </td>
                    <td>${erreurNom}</td>
                </tr>
                <tr>
                <td>Date de naissance (JJ/MM/AAAA)</td>
                    <td>
                        <input type="text" value="${dateNaissance}" name="dateNaissance">
                    </td>
                    <td>${erreurDateNaissance}</td>
                </tr>
                <tr>
                    <td>Mari&eacute;</td>
                    <td>
                        <c:choose>
                            <c:when test="${marie}">
                                <input type="radio" name="marie" value="true" checked>Oui
                                <input type="radio" name="marie" value="false">Non
                            </c:when>
                            <c:otherwise>
                                <input type="radio" name="marie" value="true">Oui
                                <input type="radio" name="marie" value="false" checked>Non
                            </c:otherwise>
                        </c:choose>
                    </td>
                </tr>
                <tr>
                    <td>Nombre d'enfants</td>
                    <td>
                        <input type="text" value="${nbEnfants}" name="nbEnfants">
                    </td>
                    <td>${erreurNbEnfants}</td>
                </tr>
            </table>
            <br>
            <input type="hidden" value="${id}" name="id">
      <input type="hidden" value="${version}" name="version">
            <input type="submit" value="Valider">
            <a href="<c:url value="/do/list"/>">Annuler</a>
        </form>
    </body>
</html>

Цей вигляд містить форму для додавання нової особи або оновлення існуючої. Надалі, для спрощення викладу, ми будемо використовувати єдиний термін [mise à jour]. Кнопка [Valider] (рядок 73) викликає POST з URL-адреси [/do/validate] (рядок 16). Якщо POST завершується з помилкою, знову відображається сторінка [edit.jsp] із зазначенням помилки або помилок, що виникли; в іншому випадку відображається сторінка [list.jsp].

  • Вигляд [edit.jsp], що відображається як на GET, так і на POST, які завершуються з помилкою, отримує у свою модель такі елементи:
атрибут
GET
POST
id
ідентифікатор оновленої особи
те саме
version
його версія
те саме
prenom
його ім'я
введене ім'я
nom
його прізвище
введене прізвище
dateNaissance
дата народження
введена дата народження
marie
його сімейний стан
введений сімейний стан
nbEnfants
кількість дітей
введена кількість дітей
erreurEdit
поле порожнє
повідомлення про помилку, що вказує на невдале додавання або зміну під час виконання POST, викликаного кнопкою [Envoyer]. Поле залишається порожнім, якщо помилки немає.
erreurPrenom
порожнє
вказує на неправильне ім’я — у іншому випадку порожнє
erreurNom
порожнє
вказує на помилкове прізвище – у разі відсутності значення
erreurDateNaissance
порожнє
позначає неправильну дату народження – у протилежному випадку порожнє
erreurNbEnfants
порожнє
вказує на неправильну кількість дітей – у разі відсутності таких даних залишається порожнім
  • рядки 11–15: якщо виконання POST у формі завершиться з помилкою, буде отримано [erreurEdit!=''] і відобразиться повідомлення про помилку.
  • рядок 16: форма буде відправлена за URL-адресою [/do/validate]
  • рядок 20: відображається елемент шаблону [id]
  • рядок 24: відображається елемент [version] шаблону
  • рядки 26–32: введення імені особи:
    • під час початкового відображення форми (GET), ${ім'я} відображає поточне значення поля [prenom] оновленого об'єкта [Personne], а ${erreurPrenom} є порожнім.
    • у разі помилки після POST знову відображається введене значення ${prenom}, а також можливе повідомлення про помилку ${erreurPrenom}
  • рядки 33–39: введення прізвища особи
  • рядки 40–46: введення дати народження особи
  • рядки 47–61: введення сімейного стану особи за допомогою перемикача. Для визначення, який із двох перемикачів слід позначити, використовується значення поля [marie] об’єкта [Personne].
  • рядки 62–68: введення кількості дітей особи
  • рядок 71: приховане поле HTML з іменем [id], значенням якого є поле [id] особи, дані якої оновлюються; -1 — для додавання, інше значення — для редагування.
  • рядок 72: приховане поле HTML з іменем [version], значення якого дорівнює значенню поля [id] особи, що оновлюється.
  • рядок 73: кнопка [Valider] типу [Submit] у формі
  • рядок 74: посилання, що дозволяє повернутися до списку осіб. Воно отримало назву [Annuler], оскільки дозволяє покинути форму без її підтвердження.

Вигляд [exception.jsp]


Вона служить для відображення сторінки, що повідомляє про виникнення винятку, який не обробляється додатком і який був переданий на веб-сервер.

Наприклад, видалимо особу, якої немає в групі:

Код подання [exception.jsp] такий:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
<%@ page isErrorPage="true" %>

<%
  response.setStatus(200);
%>

<html>
    <head>
        <title>MVC - Personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
        <h2>MVC - personnes</h2>
        L'exception suivante s'est produite :
        <%= exception.getMessage()%>
        <br><br>
        <a href="<c:url value="/do/list"/>">Retour &agrave; la liste</a>
    </body>
</html>

  • Цей вигляд отримує у своїй моделі елемент [exception], який є винятком, перехопленим веб-сервером. Щоб цей елемент був включений веб-сервером у модель сторінки JSP, на сторінці має бути визначений тег у рядку 3.
  • рядок 6: код стану відповіді HTTP встановлюється на 200. Це перший заголовок відповіді HTTP. Код 200 означає для клієнта, що його запит було виконано. Зазвичай у відповідь сервера вбудовується документ HTML. Так і є в даному випадку. Якщо не встановити код стану відповіді HTTP на 200, він матиме тут значення 500, що означає, що сталася помилка. Дійсно, веб-сервер, перехопивши некероване виключення, розцінює цю ситуацію як аномальну та сигналізує про це кодом 500. Реакція на код 500 у відповіді HTTP відрізняється залежно від браузера: Firefox відображає документ HTML, який може супроводжувати цю відповідь, тоді як IE ігнорує цей документ і відображає власну сторінку. Саме з цієї причини ми замінили код 500 на код 200.
  • рядок 16: відображається текст винятку
  • рядок 18: користувачеві пропонується посилання для повернення до списку осіб

Вигляд [erreurs.jsp]


Вона призначена для відображення сторінки, що повідомляє про помилки ініціалізації додатка, c.a.d, та про помилки, виявлені під час виконання методу [init] сервлета контролера. Це може бути, наприклад, відсутність параметра у файлі [web.xml], як показано у прикладі нижче:

Image

Код сторінки [erreurs.jsp] такий:


<%@ page language="java" contentType="text/html; charset=ISO-8859-1"
    pageEncoding="ISO-8859-1"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>

<html>
    <head>
      <title>MVC - Personnes</title>
  </head>
  <body>
      <h2>Les erreurs suivantes se sont produites</h2>
    <ul>
            <c:forEach var="erreur" items="${erreurs}">
                <li>${erreur}</li>
            </c:forEach>
    </ul>
  </body>
</html>

Сторінка отримує у свій шаблон елемент [erreurs], який є об’єктом типу [ArrayList], що містить об’єкти [String], які, у свою чергу, є повідомленнями про помилки. Вони відображаються за допомогою циклу у рядках 13–15.

14.8.3. Контролер додатка

Контролер [Application] визначено в пакеті [istia.st.mvc.personnes.web]:

Image


Структу та ініціалізація контролера


Структура контролера [Application] виглядає наступним чином:

package istia.st.mvc.personnes.web;

import istia.st.mvc.personnes.dao.DaoException;
...

@SuppressWarnings("serial")
public class Application extends HttpServlet {
     // параметри екземпляра
    private String urlErreurs = null;
    private ArrayList erreursInitialisation = new ArrayList<String>();
    private String[] paramètres = { "urlList", "urlEdit", "urlErreurs" };
    private Map params = new HashMap<String, String>();

     // служба
    ServiceImpl service=null;

     // ініціалізація
    @SuppressWarnings("unchecked")
    public void init() throws ServletException {
         // отримано параметри ініціалізації сервлета
        ServletConfig config = getServletConfig();
         // обробка інших параметрів ініціалізації
        String valeur = null;
        for (int i = 0; i < paramètres.length; i++) {
             // значення параметра
            valeur = config.getInitParameter(paramètres[i]);
             // параметр присутній?
            if (valeur == null) {
                 // фіксується помилка
                erreursInitialisation.add("Le paramètre [" + paramètres[i]
                        + "] n'a pas été initialisé");
            } else {
                 // зберігаємо значення параметра
                params.put(paramètres[i], valeur);
            }
        }
         // URL-адреса подання [erreurs] обробляється окремо
        urlErreurs = config.getInitParameter("urlErreurs");
        if (urlErreurs == null)
            throw new ServletException(
                    "Le paramètre [urlErreurs] n'a pas été initialisé");
         // інстанціювання шару [dao]
        DaoImpl dao = new DaoImpl();
        dao.init();
         // створення екземпляра шару [service]
        service = new ServiceImpl();
        service.setDao(dao);
    }

     // GET
    @SuppressWarnings("unchecked")
    public void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
....
    }

     // відображення списку осіб
    private void doListPersonnes(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

     // редагування / додавання особи
    private void doEditPersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

     // підтвердження редагування / додавання особи
    private void doDeletePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

     // підтвердження редагування / додавання особи
    public void doValidatePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

     // перегляд попередньо заповненої форми
    private void showFormulaire(HttpServletRequest request,
            HttpServletResponse response, String erreurEdit) throws ServletException, IOException{
...
    }

     // відправка
    public void doPost(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
         // передача управління GET
        doGet(request, response);    }
}
  • рядки 20–36: отримуємо очікувані параметри з файлу [web.xml].
  • рядки 39–41: параметр [urlErreurs] має бути обов’язково присутнім, оскільки він вказує URL-адресу подання [erreurs], здатного відображати можливі помилки ініціалізації. Якщо його немає, додаток переривається шляхом запуску [ServletException] (рядок 40). Цей виняток буде передано на веб-сервер і оброблено тегом <error-page> у файлі [web.xml]. Отже, відображається сторінка [exception.jsp]:

Image

Наведене вище посилання [Retour à la liste] не працює. Його використання повертає той самий результат, доки додаток не буде змінено та перезавантажено. Воно корисне для інших типів винятків, як ми вже бачили.

  • рядок 43: створює екземпляр [DaoImpl], що реалізує шар [dao]
  • рядок 44: ініціалізує цей екземпляр (створення початкового списку з трьох осіб)
  • рядок 46: створює екземпляр [ServiceImpl], що реалізує шар [service]
  • рядок 47: ініціалізує шар [service], надаючи йому посилання на шар [dao]

Після ініціалізації контролера його методи отримують посилання [service] на шар [service] (рядок 15), яке вони використовуватимуть для виконання дій, запитуваних користувачем. Ці дії будуть перехоплені методом [doGet], який передасть їх на обробку спеціальному методу контролера:

Url
Метод HTTP
метод контролера
/do/list
GET
doListPersonnes
/do/edit
GET
doEditPersonne
/do/validate
POST
doValidatePersonne
/do/delete
GET
doDeletePersonne

Метод [doGet]


Цей метод призначений для спрямування обробки дій, запитуваних користувачем, до відповідного методу. Його код такий:

// GET
    @SuppressWarnings("unchecked")
    public void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {

         // перевіряємо, як пройшла ініціалізація сервлета
        if (erreursInitialisation.size() != 0) {
             // передаємо управління сторінці помилок
            request.setAttribute("erreurs", erreursInitialisation);
            getServletContext().getRequestDispatcher(urlErreurs).forward(request, response);
             // кінець
            return;
        }
         // отримуємо метод відправлення запиту
        String méthode = request.getMethod().toLowerCase();
         // отримуємо дію, яку потрібно виконати
        String action = request.getPathInfo();
         // дія?
        if (action == null) {
            action = "/list";
        }
         // виконання дії
        if (méthode.equals("get") && action.equals("/list")) {
             // список осіб
            doListPersonnes(request, response);
            return;
        }
        if (méthode.equals("get") && action.equals("/delete")) {
             // видалення особи
            doDeletePersonne(request, response);
            return;
        }
        if (méthode.equals("get") && action.equals("/edit")) {
             // відображення форми додавання/редагування особи
            doEditPersonne(request, response);
            return;
        }
        if (méthode.equals("post") && action.equals("/validate")) {
             // підтвердження форми додавання/редагування особи
            doValidatePersonne(request, response);
            return;
        }
         // інші випадки
        doListPersonnes(request, response);
    }
  • рядки 7–13: перевіряється, чи список помилок ініціалізації порожній. Якщо це не так, відображається вікно [erreurs(erreurs)], яке повідомить про помилку або помилки.
  • рядок 15: отримується метод [get] або [post], який клієнт використав для формування запиту.
  • рядок 17: отримуємо значення параметра [action] із запиту.
  • рядки 23–27: обробка запиту [GET /do/list], який запитує список осіб.
  • рядки 28–32: обробка запиту [GET /do/delete], який вимагає видалення особи.
  • рядки 33–37: обробка запиту [GET /do/edit], який вимагає форми для оновлення даних особи.
  • рядки 38–42: обробка запиту [POST /do/validate], який вимагає підтвердження оновлених даних особи.
  • рядок 44: якщо запитувана дія не є однією з п’яти попередніх, то виконується обробка, як для запиту [GET /do/list].

Метод [doListPersonnes]


Цей метод обробляє запит [GET /do/list], який вимагає переліку осіб:

Image

Його код такий:

1
2
3
4
5
6
7
8
9
     // відображення списку осіб
    private void doListPersonnes(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
         // шаблон подання [list]
        request.setAttribute("personnes", service.getAll());
         // відображення подання [list]
        getServletContext()
                .getRequestDispatcher((String) params.get("urlList")).forward(request, response);
    }
  • рядок 5: у шарі [service] запитується список осіб групи, який поміщається в модель під ключем «особи».
  • рядок 7: відображається вигляд [list.jsp], описаний у параграфі 14.8.2.

Метод [doDeletePersonne]


Цей метод обробляє запит [GET /do/delete?id=XX], який вимагає видалення особи з id=XX. URL-адреса [/do/delete?id=XX] відповідає посиланням [Supprimer] у поданні [list.jsp]:

Image

код якого такий:

...
<html>
    <head>
        <title>MVC - personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
...
            <c:forEach var="personne" items="${personnes}">
                <tr>
...
                    <td><a href="<c:url value="/do/edit?id=${personne.id}"/>">Modifier</a></td>
                    <td><a href="<c:url value="/do/delete?id=${personne.id}"/>">Supprimer</a></td>
                </tr>
            </c:forEach>
        </table>
        <br>
        <a href="<c:url value="/do/edit?id=-1"/>">Ajout</a>
    </body>
</html>

У рядку 12 ми бачимо URL-адресу [/do/delete?id=XX] посилання [Supprimer]. Метод [doDeletePersonne], який повинен обробити цей URL-адресу, повинен видалити особу з id=XX, а потім відобразити новий список осіб у групі. Його код такий:

     // підтвердження зміни / додавання особи
    private void doDeletePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
         // отримання ідентифікатора особи
        int id = Integer.parseInt(request.getParameter("id"));
         // видалення особи
        service.deleteOne(id);
         // перенаправлення до списку осіб
        response.sendRedirect("list");
    }
  • рядок 5: оброблюваний URL має вигляд [/do/delete?id=XX]. З параметра [id] отримуємо значення [XX].
  • рядок 7: до шару [service] надсилається запит на видалення особи з отриманим ідентифікатором. Ми не проводимо жодної перевірки. Якщо особа, яку ми намагаємося видалити, не існує, шар [dao] генерує виняток, який передається вгору шаром [service]. Ми також не обробляємо це тут, у контролері. Отже, виняток передаватиметься аж до веб-сервера, який, згідно з налаштуваннями, відобразить сторінку [exception.jsp], описану в параграфі 14.8.2:

Image

  • рядок 9: якщо видалення відбулося (винятку немає), клієнту пропонується перейти за відносним URL-адресою [list]. Оскільки щойно оброблено сторінку [/do/delete], URL-адреса перенаправлення буде [/do/list]. Отже, браузер перейде на сторінку [GET /do/list], що призведе до відображення списку осіб.

Метод [doEditPersonne]


Цей метод обробляє запит [GET /do/edit?id=XX], який запитує форму оновлення особи з id=XX. URL-адреса [/do/edit?id=XX] є адресою посилань [Modifier] та [Ajout] у поданні [list.jsp]:

Image

код якого такий:

...
<html>
    <head>
        <title>MVC - personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
...
            <c:forEach var="personne" items="${personnes}">
                <tr>
...
                    <td><a href="<c:url value="/do/edit?id=${personne.id}"/>">Modifier</a></td>
                    <td><a href="<c:url value="/do/delete?id=${personne.id}"/>">Supprimer</a></td>
                </tr>
            </c:forEach>
        </table>
        <br>
        <a href="<c:url value="/do/edit?id=-1"/>">Ajout</a>
    </body>
</html>

У рядку 11 ми бачимо URL-адресу [/do/edit?id=XX] для посилання [Modifier], а в рядку 17 — URL-адресу [/do/edit?id=-1] для посилання [Ajout]. Метод [doEditPersonne] повинен відобразити форму редагування особи з id=XX або, якщо це додавання, — порожню форму.

Код методу [doEditPersonne] такий:

     // редагування / додавання особи
    private void doEditPersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
         // отримуємо ідентифікатор особи
        int id = Integer.parseInt(request.getParameter("id"));
         // додати чи змінити?
        Personne personne = null;
        if (id != -1) {
             // редагування — отримуємо особу для редагування
            personne = service.getOne(id);
        } else {
             // додавання — створюється порожня особа
            personne = new Personne();
            personne.setId(-1);
        }
         // вставляємо об’єкт [Personne] у шаблон подання [edit]
        request.setAttribute("erreurEdit", "");
        request.setAttribute("id", personne.getId());
        request.setAttribute("version", personne.getVersion());
        request.setAttribute("prenom", personne.getPrenom());
        request.setAttribute("nom", personne.getNom());
        Date dateNaissance = personne.getDateNaissance();
        if (dateNaissance != null) {
            request.setAttribute("dateNaissance", new SimpleDateFormat(
                    "dd/MM/yyyy").format(dateNaissance));
        } else {
            request.setAttribute("dateNaissance", "");
        }
        request.setAttribute("marie", personne.getMarie());
        request.setAttribute("nbEnfants", personne.getNbEnfants());
         // відображення подання [edit]
        getServletContext()
                .getRequestDispatcher((String) params.get("urlEdit")).forward(request, response);
    }
  • GET має за ціль URL-адресу типу [/do/edit?id=XX]. У рядку 5 ми отримуємо значення [id]. Далі можливі два випадки:
  1. id відмінний від -1. Тоді це редагування, і потрібно відобразити форму, попередньо заповнену даними особи, яку потрібно редагувати. У рядку 10 ця особа запитується на рівні [service].
  2. id дорівнює -1. У цьому випадку йдеться про додавання, і потрібно відобразити порожню форму. Для цього у рядках 13–14 створюється порожня особа.
  • Отриманий об’єкт [Personne] розміщується в шаблоні сторінки [edit.jsp], описаному в розділі 14.8.2. Цей шаблон містить такі елементи: [erreurEdit, id, version, prenom, erreurPrenom, nom, erreurNom, dateNaissance, erreurDateNaissance, marie, nbEnfants, erreurNbEnfants]. Ці елементи ініціалізуються у рядках 17–30, за винятком тих, значенням яких є порожній рядок [erreurPrenom, erreurNom, erreurDateNaissance, erreurNbEnfants]. Відомо, що за їхньої відсутності в шаблоні бібліотека JSTL відображатиме порожній рядок як їхнє значення. Хоча елемент [erreurEdit] також має значенням порожній рядок, він все ж ініціалізується, оскільки на сторінці [edit.jsp] виконується перевірка його значення.
  • Після того як шаблон готовий, управління передається на сторінку [edit.jsp], рядки 32–33, яка згенерує представлення [edit].

Метод [doValidatePersonne]


Цей метод обробляє запит [POST /do/validate], який перевіряє форму оновлення. Цей POST запускається кнопкою [Valider]:

Image

Нагадаємо елементи введення даних у формі HTML, показаній на малюнку вище:

<form method="post" action="<c:url value="/do/validate"/>">
....
        <input type="text" value="${prenom}" name="prenom" size="20">
....
        <input type="text" value="${nom}" name="nom" size="20">
....
        <input type="text" value="${dateNaissance}" name="dateNaissance">
...
        <input type="radio" name="marie" value="true" checked>Oui
....
        <input type="text" value="${nbEnfants}" name="nbEnfants">
....
            <input type="hidden" value="${id}" name="id">
     <input type="hidden" value="${version}" name="version">
            <input type="submit" value="Valider">
</form>

Запит POST містить параметри [prenom, nom, dateNaissance, marie, nbEnfants, id, version] і надсилається на URL-адресу [/do/validate] (рядок 1). Він обробляється наступним методом [doValidatePersonne]:

// підтвердження зміни / додавання особи
    public void doValidatePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
         // отримання відправлених даних
        boolean formulaireErroné = false;
        boolean erreur;
         // ім'я
        String prenom = request.getParameter("prenom").trim();
         // ім'я правильне?
        if (prenom.length() == 0) {
             // фіксуємо помилку
            request.setAttribute("erreurPrenom", "Le prénom est obligatoire");
            formulaireErroné = true;
        }
         // прізвище
        String nom = request.getParameter("nom").trim();
         // ім'я правильне?
        if (nom.length() == 0) {
             // позначено помилку
            request.setAttribute("erreurNom", "Le nom est obligatoire");
            formulaireErroné = true;
        }
         // дата народження
        Date dateNaissance = null;
        try {
            dateNaissance = new SimpleDateFormat("dd/MM/yyyy").parse(request
                    .getParameter("dateNaissance").trim());
        } catch (ParseException e) {
             // зафіксовано помилку
            request.setAttribute("erreurDateNaissance", "Date incorrecte");
            formulaireErroné = true;
        }
         // сімейний стан
        boolean marie = Boolean.parseBoolean(request.getParameter("marie"));
         // кількість дітей
        int nbEnfants = 0;
        erreur = false;
        try {
            nbEnfants = Integer.parseInt(request.getParameter("nbEnfants")
                    .trim());
            if (nbEnfants < 0) {
                erreur = true;
            }
        } catch (NumberFormatException ex) {
             // відзначається помилка
            erreur = true;
        }
         // неправильна кількість дітей?
        if (erreur) {
             // повідомлено про помилку
            request.setAttribute("erreurNbEnfants",
                    "Nombre d'enfants incorrect");
            formulaireErroné = true;
        }
         // ідентифікатор особи
        int id = Integer.parseInt(request.getParameter("id"));
         // версія
        long version = Long.parseLong(request.getParameter("version"));
         // чи є помилки у формі?
        if (formulaireErroné) {
             // повторно відобразити форму з повідомленнями про помилки
            showFormulaire(request, response, "");
             // завершено
            return;
        }
         // форма правильна — особа зареєстрована
        Personne personne = new Personne(id, prenom, nom, dateNaissance, marie,
                nbEnfants);
        personne.setVersion(version);
        try {
             // реєстрація
            service.saveOne(personne);
        } catch (DaoException ex) {
             // повторно відображається форма з повідомленням про помилку, що сталася
            showFormulaire(request, response, ex.getMessage());
             // завершено
            return;
        }
         // перенаправлення до списку осіб
        response.sendRedirect("list");
    }

     // відображення попередньо заповненої форми
    private void showFormulaire(HttpServletRequest request,
            HttpServletResponse response, String erreurEdit)
            throws ServletException, IOException {
         // готується шаблон подання [edit]
        request.setAttribute("erreurEdit", erreurEdit);
        request.setAttribute("id", request.getParameter("id"));
        request.setAttribute("version", request.getParameter("version"));
        request.setAttribute("prenom", request.getParameter("prenom").trim());
        request.setAttribute("nom", request.getParameter("nom").trim());
        request.setAttribute("dateNaissance", request.getParameter(
                "dateNaissance").trim());
        request.setAttribute("marie", request.getParameter("marie"));
        request.setAttribute("nbEnfants", request.getParameter("nbEnfants")
                .trim());
         // відображення подання [edit]
        getServletContext()
                .getRequestDispatcher((String) params.get("urlEdit")).forward(request, response);
    }
  • рядки 8–14: витягується параметр [prenom] із запиту POST і перевіряється його правильність. Якщо він виявляється некоректним, елемент [erreurPrenom] ініціалізується з повідомленням про помилку та розміщується в атрибутах запиту.
  • рядки 16–22: аналогічні дії виконуються для параметра [nom]
  • рядки 24–32: аналогічним чином обробляється параметр [dateNaissance]
  • рядок 34: отримуємо параметр [marie]. Ми не перевіряємо його правильність, оскільки, як правило, він походить від значення перемикача. Однак ніщо не заважає програмі створити [POST /personnes-01/do/validate] разом із вигаданим параметром [marie]. Тому нам слід перевірити правильність цього параметра. Тут ми покладаємося на нашу систему обробки винятків, яка викликає відображення сторінки [exception.jsp], якщо контролер не обробляє їх самостійно. Отже, якщо перетворення параметра [marie] у логічне значення завершиться невдало у рядку 34, виникне виняток, що призведе до відправлення сторінки [exception.jsp] клієнту. Такий механізм нас влаштовує.
  • рядки 34–54: отримуємо параметр [nbEnfants] і перевіряємо його значення.
  • рядок 56: отримуємо параметр [id], не перевіряючи його значення
  • рядок 58: те саме робимо для параметра [version]
  • рядки 60–65: якщо форма містить помилки, її знову відображають разом із попередньо сформованими повідомленнями про помилки
  • рядки 67–69: якщо форма є дійсною, створюється новий об’єкт [Personne] з елементами форми
  • рядки 70–78: дані про особу зберігаються. Збереження може завершитися невдачею. У багатокористувацькому середовищі особа, яку потрібно змінити, могла бути видалена або вже змінена кимось іншим. У цьому випадку рівень [dao] викличе виняток, який ми обробляємо тут.
  • рядок 80: якщо винятку не сталося, клієнта перенаправляють на URL-адресу [/do/list], щоб показати йому новий стан групи.
  • рядок 75: якщо під час збереження сталося виключення, ми знову запитуємо перезавантаження початкової форми, передаючи їй повідомлення про помилку виключення (3-й параметр).

Метод [showFormulaire] (рядки 84–101) формує шаблон, необхідний для сторінки [edit.jsp], із введеними значеннями (request.getParameter(" ... ")). Нагадаємо, що повідомлення про помилки вже були вставлені в шаблон методом [doValidatePersonne]. Сторінка [edit.jsp] відображається у рядках 99–100.

14.9. Тестування веб-додатку

У розділі 14.1 було наведено низку тестів. Пропонуємо читачеві повторити їх. Тут ми наводимо інші знімки екрана, що ілюструють випадки конфліктів доступу до даних у багатокористувацькому середовищі:

[Firefox] — це браузер користувача U1. Він запитує URL-адресу [http://localhost:8080/personnes-01]:

Image

[IE] — це браузер користувача U2. Він запитує той самий URL:

Image

Користувач U1 переходить до редагування запису про особу [Lemarchand]:

Image

Користувач U2 робить те саме:

Image

Користувач U1 вносить зміни та підтверджує їх:

Користувач U2 робить те саме:

Користувач U2 повертається до списку осіб за посиланням [Annuler] у формі:

Image

Він знаходить особу [Lemarchand] у тому вигляді, в якому її змінив U1. Тепер U2 видаляє [Lemarchand]:

U1 як і раніше має власний список і хоче знову змінити [Lemarchand]:

U1 використовує посилання [Retour à la liste], щоб дізнатися, про що йдеться:

Image

Він виявляє, що [Lemarchand] дійсно більше не входить до списку...

14.10. Conclusion

Ми реалізували архітектуру MVC у трирівневій архітектурі [web, metier, dao] на прикладі простого управління списком осіб. Це дозволило нам застосувати концепції, представлені в попередніх розділах. У розглянутій версії список осіб зберігався в оперативній пам’яті. Незабаром ми розглянемо версії, в яких цей список зберігатиметься в таблиці бази даних.

Але спочатку ми ознайомимося з інструментом під назвою Spring IoC, який полегшує інтеграцію різних рівнів додатка ntier.