Skip to content

17. Веб-додаток MVC у трирівневій архітектурі — Приклад 3 — ССБД Firebird

17.1. База даних Firebird

У цій новій версії ми розмістимо список осіб у таблиці бази даних Firebird. Інформацію щодо встановлення та управління цією базою даних можна знайти в документі [http://tahe.developpez.com/divers/sql-firebird/]. Наведені нижче знімки екрана взяті з IBExpert — клієнта для адміністрування баз даних Interbase та Firebird.

База даних має назву [dbpersonnes.gdb]. Вона містить таблицю [PERSONNES]:

Image

Таблиця [PERSONNES] міститиме список осіб, якими керує веб-додаток. Вона була створена за допомогою таких команд SQL:

CREATE TABLE PERSONNES (
    ID             INTEGER NOT NULL,
    "VERSION"      INTEGER NOT NULL,
    NOM            VARCHAR(30) NOT NULL,
    PRENOM         VARCHAR(30) NOT NULL,
    DATENAISSANCE  DATE NOT NULL,
    MARIE          SMALLINT NOT NULL,
    NBENFANTS      SMALLINT NOT NULL
);


ALTER TABLE PERSONNES ADD CONSTRAINT CHK_PRENOM_PERSONNES check (PRENOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_MARIE_PERSONNES check (MARIE=0 OR MARIE=1);
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_NOM_PERSONNES check (NOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_ENFANTS_PERSONNES check (NBENFANTS>=0);


ALTER TABLE PERSONNES ADD CONSTRAINT PK_PERSONNES PRIMARY KEY (ID);
  • рядки 2–10: структура таблиці [PERSONNES], призначеної для зберігання об’єктів типу [Personne], відображає структуру цього об’єкта. Оскільки у Firebird не існує булевого типу, поле [MARIE] (рядок 8) було оголошено типом [SMALLINT], тобто цілим числом. Його значенням буде 0 (неодружений) або 1 (одружений).
  • рядки 13–16: обмеження цілісності, що відповідають обмеженням валідатора даних [ValidatePersonne].
  • рядок 19: поле ID є первинним ключем таблиці [PERSONNES]

Таблиця [PERSONNES] може мати такий вміст:

Image

База даних [dbpersonnes.gdb], окрім таблиці [PERSONNES], містить об’єкт, який називається генератором і має ім’я [GEN_PERSONNES_ID]. Цей генератор видає послідовні цілі числа, які ми будемо використовувати для присвоєння значення первинному ключу [ID] класу [PERSONNES]. Розглянемо приклад, щоб проілюструвати його роботу:

Можна помітити, що значення генератора [GEN_PERSONNES_ID] змінилося (двічі клацніть на ньому + F5, щоб оновити):

 

Порядок SQL

SELECT GEN_ID ( GEN_PERSONNES_ID,1 ) FROM RDB$DATABASE

отже, дозволяє отримати таке значення генератора: [GEN_PERSONNES_ID]. GEN_ID — це внутрішня функція Firebird, а [RDB$DATABASE] — системна таблиця цього SGBD.

17.2. Проєкт Eclipse для шарів [dao] та [service]

Для розробки шарів [dao] та [service] нашого додатка з базою даних ми використовуватимемо такий проект Eclipse [mvc-personnes-03]:

Image

Цей проєкт є простим проєктом на Java, а не веб-проєктом Tomcat. Нагадаємо, що версія 2 нашого додатка використовуватиме шар [web] з версії 1. Отже, цей шар не потрібно писати заново.


Папка [src]


Ця папка містить вихідний код шарів [dao] та [service]:

Image

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

  • [istia.st.mvc.personnes.dao]: містить шар [dao]
  • [istia.st.mvc.personnes.entites]: містить клас [Personne]
  • [istia.st.mvc.personnes.service]: містить клас [service]
  • [istia.st.mvc.personnes.tests]: містить тести JUnit для шарів [dao] та [service]

а також файли конфігурації, які повинні знаходитися в ClassPath додатка.


Папка [database]


Ця папка містить базу даних Firebird з інформацією про осіб:

Image

  • [dbpersonnes.gdb] — це база даних.
  • [dbpersonnes.sql] — це скрипт SQL для створення бази даних:
/******************************************************************************/
/***           Створено за допомогою IBExpert 07.03.2006 27         .04.2006 10:27:11 ***/
/******************************************************************************/

SET SQL DIALECT 3;

SET NAMES NONE;

CREATE DATABASE 'C:\data\2005-2006\webjava\dvp-spring-mvc\mvc-38\database\DBPERSONNES.GDB'
USER 'SYSDBA' PASSWORD 'masterkey'
PAGE_SIZE 16384
DEFAULT CHARACTER SET NONE;



/******************************************************************************/
/***                                                               Генератори ***/
/******************************************************************************/

CREATE GENERATOR GEN_PERSONNES_ID;
SET GENERATOR GEN_PERSONNES_ID TO 787;



/******************************************************************************/
/***                                                                   Таблиці ***/
/******************************************************************************/



CREATE TABLE PERSONNES (
    ID             INTEGER NOT NULL,
    "VERSION"      INTEGER NOT NULL,
    NOM            VARCHAR(30) NOT NULL,
    PRENOM         VARCHAR(30) NOT NULL,
    DATENAISSANCE  DATE NOT NULL,
    MARIE          SMALLINT NOT NULL,
    NBENFANTS      SMALLINT NOT NULL
);

INSERT INTO PERSONNES (ID, "VERSION", NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) VALUES (1, 1, 'Major', 'Joachim', '1984-11-13', 1, 2);
INSERT INTO PERSONNES (ID, "VERSION", NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) VALUES (2, 1, 'Humbort', 'Mélanie', '1985-02-12', 0, 1);
INSERT INTO PERSONNES (ID, "VERSION", NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) VALUES (3, 1, 'Lemarchand', 'Charles', '1986-03-01', 0, 0);

COMMIT WORK;



/* Перевірка визначення обмежень */

ALTER TABLE PERSONNES ADD CONSTRAINT CHK_PRENOM_PERSONNES check (PRENOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_NOM_PERSONNES check (NOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_MARIE_PERSONNES check (MARIE=0 OR MARIE=1);
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_ENFANTS_PERSONNES check (NBENFANTS>=0);


/******************************************************************************/
/***                                                             Первинні ключі ***/
/******************************************************************************/

ALTER TABLE PERSONNES ADD CONSTRAINT PK_PERSONNES PRIMARY KEY (ID);

Папка [lib]


Ця папка містить архіви, необхідні для роботи програми:

Слід зазначити наявність драйвера JDBC [firebirdsql-full.jar] для Firebird, а також низки архівів [spring-*.jar]. Ми могли б використати єдиний архів [spring.jar], який знаходиться у папці [dist] дистрибутива і містить усі класи Spring. Також можна використовувати лише ті архіви, які необхідні для проєкту. Саме це ми й зробили, орієнтуючись на повідомлення Eclipse про відсутні класи та назви часткових архівів Spring. Усі ці архіви з папки [lib] було розміщено в архіві Classpath проєкту.


Папка [dist]


Ця папка міститиме архіви, отримані в результаті компіляції класів додатка:

Image

  • [personnes-dao.jar]: архів шару [dao]
  • [personnes-service.jar]: архів шару [service]

17.3. Шар [dao]

17.3.1. Компоненти шару [dao]

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

Image

  • [IDao] — це інтерфейс, представлений шаром [dao]
  • [DaoImplCommon] — це реалізація цього інтерфейсу, в якій група осіб зберігається в таблиці бази даних. [DaoImplCommon] об’єднує функціональні можливості, незалежні від SGBD.
  • [DaoImplFirebird] — це клас, похідний від [DaoImplCommon], призначений спеціально для роботи з базою даних Firebird.
  • [DaoException] — це тип неконтрольованих винятків, що генеруються рівнем [dao]. Цей клас належить до версії 1.

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

package istia.st.mvc.personnes.dao;

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

import java.util.Collection;

public interface IDao {
     // список усіх осіб
    Collection getAll();
     // отримати конкретну особу
    Personne getOne(int id);
     // додати/змінити особу
    void saveOne(Personne personne);
     // видалити особу
    void deleteOne(int id);
}
  • Інтерфейс має ті самі чотири методи, що й у попередній версії.

Клас [DaoImplCommon], що реалізує цей інтерфейс, матиме такий вигляд:

package istia.st.mvc.personnes.dao;

import istia.st.mvc.personnes.entites.Personne;
import org.springframework.orm.ibatis.support.SqlMapClientDaoSupport;

import java.util.Collection;

public class DaoImplCommon extends SqlMapClientDaoSupport implements
        IDao {

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

     // отримати інформацію про конкретну особу
    public Personne getOne(int id) {
...
    }

     // видалення особи
    public void deleteOne(int id) {
...
    }

     // додати або змінити особу
    public void saveOne(Personne personne) {
         // Чи є параметр «personne» дійсним?
        check(personne);
         // Додавання чи зміна?
        if (personne.getId() == -1) {
             // додати
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }

     // додати особу
    protected void insertPersonne(Personne personne) {
...
    }

     // змінити особу
    protected void updatePersonne(Personne personne) {
...
    }

     // перевірка дійсності особи
    private void check(Personne p) {
...
    }

...
}
  • рядки 8–9: клас [DaoImpl] реалізує інтерфейс [IDao] і, отже, чотири методи [getAll, getOne, saveOne, deleteOne].
  • рядки 27–37: метод [saveOne] використовує два внутрішні методи — [insertPersonne] та [updatePersonne] — залежно від того, чи потрібно додати особу, чи змінити її дані.
  • рядок 50: приватний метод [check] — це метод з попередньої версії. Ми не будемо до нього повертатися.
  • рядок 8: для реалізації інтерфейсу [IDao] клас [DaoImpl] походить від класу Spring [SqlMapClientDaoSupport].

17.3.2. Рівень доступу до даних [iBATIS]

Клас Spring [SqlMapClientDaoSupport] використовує сторонній фреймворк [Ibatis SqlMap], доступний за адресою [http://ibatis.apache.org/]:

Image

[iBATIS] — це проект Apache, що полегшує побудову шарів [dao] на основі баз даних. З [iBATIS] архітектура шару доступу до даних виглядає наступним чином:

[iBATIS] розміщується між рівнем [dao] додатка та драйвером JDBC бази даних. Існують альтернативи [iBATIS], наприклад, [Hibernate]:

Image

Для використання фреймворку [iBATIS] потрібні два архіви [ibatis-common, ibatis-sqlmap], які були розміщені в папці [lib] проекту:

Клас [SqlMapClientDaoSupport] інкапсулює загальну частину використання фреймворків [iBATIS] та c.a.d. частини коду, які зустрічаються у всіх рівнях [dao], що використовують інструмент [iBATIS]. Щоб написати негенеральну частину коду, тобто те, що є специфічним для шару [dao], достатньо створити похідний клас від класу [SqlMapClientDaoSupport]. Саме це ми й робимо тут.

Клас [SqlMapClientDaoSupport] визначається наступним чином:

Image

Серед методів цього класу один із них дозволяє налаштувати клієнт [iBATIS], за допомогою якого ми будемо працювати з базою даних:

Image

Об’єкт [SqlMapClient sqlMapClient] — це об’єкт [IBATIS], що використовується для доступу до бази даних. Сам по собі він реалізує рівень [iBATIS] нашої архітектури:

Типова послідовність дій з цим об’єктом така:

  1. запит на підключення до пулу з’єднань
  2. відкрити транзакцію
  3. виконання серії команд SQL, збережених у конфігураційному файлі
  4. закрити транзакцію
  5. повернути з'єднання до пулу

Якби наша реалізація [DaoImplCommon] працювала безпосередньо з [iBATIS], їй довелося б повторювати цю послідовність дій. Лише операція 3 є специфічною для рівня [dao], тоді як інші операції є загальними. Клас Spring [SqlMapClientDaoSupport] самостійно виконає операції 1, 2, 4 та 5, делегуючи операцію 3 своєму похідному класу, в даному випадку класу [DaoImplCommon].

Для роботи класу [SqlMapClientDaoSupport] потрібне посилання на об’єкт iBATIS [SqlMapClient sqlMapClient], який забезпечуватиме взаємодію з базою даних. Для роботи цього об’єкта потрібні дві речі:

  • об’єкт [DataSource], підключений до бази даних, у якого він буде запитувати з’єднання;
  • файл (або файли) конфігурації, в якому (яких) винесені назовні команди SQL, що підлягають виконанню. Дійсно, ці команди не містяться в коді Java. Вони ідентифікуються за кодом у конфігураційному файлі, і об’єкт [SqlMapClient sqlMapClient] використовує цей код для виконання конкретної команди SQL.

Початковий варіант конфігурації нашого рівня [dao], який відображав би наведену вище архітектуру, виглядав би так:


    <!-- класи доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
</bean>

Тут ініціалізується властивість [sqlMapClient] (рядок 3) класу [DaoImplCommon] (рядок 2). Ініціалізація відбувається за допомогою методу [setSqlMapClient] класу [DaoImpl]. Цей клас не має цього методу. Він є у його батьківському класі [SqlMapClientDaoSupport]. Отже, саме він насправді ініціалізується тут.

Тепер у рядку 4 згадується об’єкт із назвою «sqlMapClient», який ще потрібно створити. Як уже зазначалося, цей об’єкт має тип [SqlMapClient], а тип [iBATIS]:

Image

[SqlMapClient] — це інтерфейс. Spring надає клас [SqlMapClientFactoryBean] для отримання об’єкта, що реалізує цей інтерфейс:

Image

Нагадаємо, що ми прагнемо створити екземпляр об’єкта, який реалізує інтерфейс [SqlMapClient]. Очевидно, що це не стосується класу [SqlMapClientFactoryBean]. Він реалізує інтерфейс [FactoryBean] (див. вище). Цей інтерфейс має такий метод [getObject()]:

Image

Коли Spring отримує запит на екземпляр об’єкта, що реалізує інтерфейс [FactoryBean], він:

  • створює екземпляр класу [I] — у даному випадку створюється екземпляр типу [SqlMapClientFactoryBean].
  • повертає викликаючому методу результат роботи методу [I].getObject() — метод [SqlMapClientFactoryBean].getObject() поверне тут об’єкт, що реалізує інтерфейс [SqlMapClient].

Щоб повернути об’єкт, що реалізує інтерфейс [SqlMapClient], клас [SqlMapClientFactoryBean] потребує двох відомостей, необхідних для цього об’єкта:

  • об’єкт [DataSource], підключений до бази даних, у якого він буде запитувати з’єднання
  • файл (або файли) конфігурації, в якому (яких) винесені назовні команди SQL, що підлягають виконанню

Клас [SqlMapClientFactoryBean] має методи set для ініціалізації цих двох властивостей:

Image

Ми просуваємося... Наш файл конфігурації уточнюється і набуває такого вигляду:


<!-- SqlMapCllient -->
    <bean id="sqlMapClient" 
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
        <property name="configLocation">
            <value>classpath:sql-map-config-firebird.xml</value>
        </property>
    </bean>
    <!-- класи доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
  • рядки 2–3: бін «sqlMapClient» має тип [SqlMapClientFactoryBean]. З того, що щойно було пояснено, ми знаємо, що коли ми запитуємо у Spring екземпляр цього біна, ми отримуємо об’єкт, що реалізує інтерфейс iBATIS [SqlMapClient]. Саме цей об’єкт і буде отримано в рядку 14.
  • рядки 7–9: ми вказуємо, що файл конфігурації, необхідний для об’єкта iBATIS [SqlMapClient], називається «sql-map-config-firebird.xml» і що його слід шукати в ClassPath додатка. Тут використовується метод [SqlMapClientFactoryBean].setConfigLocation.
  • Рядки 4–6: ми ініціалізуємо властивість [dataSource] об’єкта [SqlMapClientFactoryBean] за допомогою його методу [setDataSource].

У рядку 5 ми посилаємося на бін під назвою «dataSource», який ще потрібно створити. Якщо подивитися на параметр, який очікує метод [setDataSource] класу [SqlMapClientFactoryBean], то бачимо, що він має тип [DataSource]:

Image

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

  • відкривати з’єднання
  • розпочати транзакцію
  • відправляти команди SQL
  • закрити транзакцію
  • закрити з’єднання

Повторне відкриття та закриття з'єднань забирає багато часу. Щоб вирішити ці дві проблеми (одночасно обмежити кількість відкритих з'єднань у певний момент часу та зменшити витрати на їх відкриття/закриття), класи, що реалізують інтерфейс [DataSource], часто діють наступним чином:

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

Існують різні реалізації інтерфейсу [DataSource], доступні у відкритому доступі. Тут ми будемо використовувати реалізацію [commons DBCP], доступну за адресою [http://jakarta.apache.org/commons/dbcp/]:

Image

Для використання інструменту [commons DBCP] потрібні два архіви [commons-dbcp, commons-pool], які були розміщені в папці [lib] проекту:

Клас [BasicDataSource] з [commons DBCP] забезпечує реалізацію [DataSource], яка нам потрібна:

Image

Цей клас надасть нам пул з’єднань для доступу до бази даних Firebird [dbpersonnes.gdb] нашого додатка. Для цього потрібно надати йому інформацію, необхідну для створення з’єднань у пулі:

  1. назву драйвера JDBC, який слід використовувати — ініціалізується за допомогою [setDriverClassName]
  2. ім’я URL-адреси бази даних, з якою потрібно працювати — ініціалізовано значенням [setUrl]
  3. ідентифікатор користувача-власника з’єднання — ініціалізовано значенням [setUsername] (а не setUserName, як можна було б очікувати)
  4. його пароль — ініціалізовано значенням [setPassword]

Файл конфігурації нашого шару [dao] може мати такий вигляд:


<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
    <!-- джерело даних DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <!-- увага: не залишайте пробілів між двома тегами <value> в URL-адресі -->
        <property name="url">
            <value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
        </property>
        <property name="username">
            <value>sysdba</value>
        </property>
        <property name="password">
            <value>masterkey</value>
        </property>
    </bean>
    <!-- SqlMapCllient -->
    <bean id="sqlMapClient" 
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
        <property name="configLocation">
            <value>classpath:sql-map-config-firebird.xml</value>
        </property>
    </bean>
    <!-- клас доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
</beans>
  • рядки 7–9: ім’я драйвера JDBC для Firebird SGBD
  • рядки 11–13: URL-адреса бази даних Firebird [dbpersonnes.gdb]. Слід бути особливо уважним при її введенні. Між тегами <value> та URL-адресою не повинно бути пробілів.
  • рядки 14–16: власник з’єднання — у даному випадку [sysdba], який є адміністратором за замовчуванням у дистрибутивах Firebird
  • рядки 17–19: його пароль [masterkey] — також значення за замовчуванням

Ми значно просунулися, але все ще залишаються моменти налаштування, які потрібно з’ясувати: рядок 28 посилається на файл [sql-map-config-firebird.xml], який повинен налаштовувати клієнт [SqlMapClient] для iBATIS. Перш ніж розглянути його вміст, покажемо, де саме у нашому проєкті Eclipse знаходяться ці файли конфігурації:

Image

  • [spring-config-test-dao-firebird.xml] — це файл конфігурації шару [dao], який ми щойно розглянули
  • На файл [sql-map-config-firebird.xml] посилається файл [spring-config-test-dao-firebird.xml]. Ми його розглянемо.
  • На файл [personnes-firebird.xml] посилається файл [sql-map-config-firebird.xml]. Ми розглянемо його.

Три попередні файли знаходяться в папці [src]. У середовищі Eclipse це означає, що під час виконання вони будуть присутні в папці [bin] проекту (не показано вище). Ця папка є частиною ClassPath додатка. Отже, у підсумку три вищезазначені файли дійсно будуть присутні у папці ClassPath додатка. Це необхідно.

Файл [sql-map-config-firebird.xml] має такий вигляд:


<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMapConfig
    PUBLIC "-//iBATIS.com//DTD SQL Map Config 2.0//EN"
    "http://www.ibatis.com/dtd/sql-map-config-2.dtd">

<sqlMapConfig>
    <sqlMap resource="personnes-firebird.xml"/>
</sqlMapConfig>
  • цей файл повинен мати тег-корінь <sqlMapConfig> (рядки 6 і 8)
  • рядок 7: тег <sqlMap> використовується для позначення файлів, що містять команди SQL, які потрібно виконати. Часто, хоча це й не є обов’язковим, на кожну таблицю припадає один файл. Це дозволяє об’єднати команди SQL для певної таблиці в одному файлі. Однак часто зустрічаються команди SQL, що стосуються кількох таблиць. У цьому випадку попередня схема не діє. Потрібно просто пам’ятати, що всі файли, позначені тегами <sqlMap>, будуть об’єднані. Ці файли шукаються у файлі ClassPath програми.

Файл [personnes-firebird.xml] описує команди SQL, які будуть виконані над таблицею [PERSONNES] у базі даних Firebird [dbpersonnes.gdb]. Його вміст такий:


<?xml version="1.0" encoding="UTF-8" ?>

<!DOCTYPE sqlMap
    PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN"
    "http://www.ibatis.com/dtd/sql-map-2.dtd">

<sqlMap>
    <!-- псевдонім класу [Personne] -->
    <typeAlias alias="Personne.classe" 
        type="istia.st.mvc.personnes.entites.Personne"/>
    <!-- таблиця відповідності [PERSONNES] — об’єкт [Personne] -->
    <resultMap id="Personne.map" 
        class="Personne.classe">
        <result property="id" column="ID" />
        <result property="version" column="VERSION" />
        <result property="nom" column="NOM"/>
        <result property="prenom" column="PRENOM"/>
        <result property="dateNaissance" column="DATENAISSANCE"/>
        <result property="marie" column="MARIE"/>
        <result property="nbEnfants" column="NBENFANTS"/>
    </resultMap>
    <!-- список усіх осіб -->
    <select id="Personne.getAll" resultMap="Personne.map" > select ID, VERSION, NOM, 
        PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES</select>
    <!-- отримати конкретну особу -->
        <select id="Personne.getOne" resultMap="Personne.map" >select ID, VERSION, NOM, 
        PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES WHERE ID=#значення#</select>
    <!-- додати особу -->
    <insert id="Personne.insertOne" parameterClass="Personne.classe">
        <selectKey keyProperty="id">
            SELECT GEN_ID(GEN_PERSONNES_ID,1) as "value" FROM RDB$$DATABASE
        </selectKey>         
        insert into 
        PERSONNES(ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) 
        VALUES(#id#, #версія#, #прізвище#, #ім'я#, #dateNaissance#, #статус шлюбу#, 
        #nbEnfants#) </insert>
    <!-- оновити дані особи -->
    <update id="Personne.updateOne" parameterClass="Personne.classe"> update 
        PERSONNES set VERSION=#версія#+1, NOM=#прізвище#, PRENOM=#ім'я#, DATENAISSANCE=#dateNaissance#, 
        MARIE=#marie#, NBENFANTS=#nbEnfants# WHERE ID=#id# та 
        VERSION=#version#</update>
    <!-- видалити особу -->
    <delete id="Personne.deleteOne" parameterClass="int"> delete FROM PERSONNES WHERE 
        ID=#значення# </delete>
</sqlMap>
  • файл повинен мати <sqlMap> як кореневий тег (рядки 7 і 45)
  • рядки 9–10: для зручності написання файлу класу [istia.st.springmvc.personnes.entites.Personne] надано псевдонім (синонім) [Personne.classe].
  • рядки 12–21: встановлюються відповідності між стовпцями таблиці [PERSONNES] та полями об’єкта [Personne].
  • рядки 23–24: запит SQL [select] для отримання всіх осіб із таблиці [PERSONNES]
  • рядки 26–27: запит SQL [select] для отримання конкретної особи з таблиці [PERSONNES]
  • рядки 29–36: команда SQL [insert], яка вносить особу до таблиці [PERSONNES]
  • рядки 38–41: команда SQL [update], яка оновлює запис про особу в таблиці [PERSONNES]
  • рядки 42–44: наказ SQL [delete], який видаляє особу з таблиці [PERSONNES]

Роль та значення вмісту файлу [personnes-firebird.xml] будуть пояснені на прикладі класу [DaoImplCommon], який реалізує шар [dao].

17.3.3. Клас [DaoImplCommon]

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

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

package istia.st.mvc.personnes.dao;

import istia.st.mvc.personnes.entites.Personne;
import org.springframework.orm.ibatis.support.SqlMapClientDaoSupport;

import java.util.Collection;

public class DaoImplCommon extends SqlMapClientDaoSupport implements
        IDao {

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

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

     // видалення особи
    public void deleteOne(int id) {
...
    }

     // додати або змінити особу
    public void saveOne(Personne personne) {
         // чи є параметр «особа» дійсним?
        check(personne);
         // додавання чи редагування?
        if (personne.getId() == -1) {
             // додавання
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }

     // додати особу
    protected void insertPersonne(Personne personne) {
...
    }

     // змінити особу
    protected void updatePersonne(Personne personne) {
...
    }

     // перевірка дійсності особи
    private void check(Personne p) {
...
    }

...
}

Ми розглянемо методи по черзі.


getAll


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

1
2
3
4
     // список осіб
    public Collection getAll() {
        return getSqlMapClientTemplate().queryForList("Personne.getAll", null);
}

Спочатку нагадаємо, що клас [DaoImplCommon] є похідним від класу Spring [SqlMapClientDaoSupport]. Саме цей клас містить метод [getSqlMapClientTemplate()], який використовується у рядку 3 вище. Цей метод має таку сигнатуру:

Image

Тип [SqlMapClientTemplate] інкапсулює об’єкт [SqlMapClient] з рівня [iBATIS]. Саме через нього ми отримаємо доступ до бази даних. Тип [iBATIS] SqlMapClient можна використовувати безпосередньо, оскільки клас [SqlMapClientDaoSupport] має до нього доступ:

Image

Недоліком класу [iBATIS] SqlMapClient є те, що він генерує винятки типу [SQLException], контрольований тип винятку, c.a.d. які повинні оброблятися за допомогою блоку try/catch або бути оголошені в сигнатурах методів, що їх генерують. Однак слід пам’ятати, що рівень [dao] реалізує інтерфейс [IDao], методи якого не містять винятків у своїх сигнатурах. Отже, методи класів, що реалізують інтерфейс [IDao], також не можуть мати винятків у своїх сигнатурах. Тому нам потрібно перехоплювати кожне виняток [SQLException], що генерується рівнем [iBATIS], та інкапсулювати його в неконтрольоване виняток. Тип [DaoException] нашого проєкту підійде для такої інкапсуляції.

Замість того, щоб самостійно обробляти ці винятки, ми доручимо це типу Spring [SqlMapClientTemplate], який інкапсулює об’єкт [SqlMapClient] з рівня [iBATIS]. Дійсно, [SqlMapClientTemplate] було створено для перехоплення винятків [SQLException], що генеруються рівнем [SqlMapClient], та їх інкапсуляції в неконтрольований тип [DataAccessException] . Така поведінка нас влаштовує. Слід лише пам’ятати, що рівень [dao] відтепер може генерувати два типи неконтрольованих винятків:

  • наш власний тип [DaoException]
  • тип Spring [DataAccessException]

Тип [SqlMapClientTemplate] визначено так:

Image

Він реалізує такий інтерфейс [SqlMapClientOperations]:

Image

Цей інтерфейс визначає методи, здатні обробляти вміст файлу [personnes-firebird.xml]:

[queryForList]

Image

Цей метод дозволяє відправити замовлення [SELECT] та отримати його результат у вигляді списку об’єктів:

  • [statementName]: ідентифікатор (id) запиту [select] у файлі конфігурації
  • [parameterObject]: об’єкт «параметр» для налаштованого [select]. Об’єкт «параметр» може мати дві форми:
    • об’єкт, що відповідає стандарту Javabean: у цьому випадку параметри замовлення [select] є іменами полів Javabean. Під час виконання замовлення [select] вони замінюються значеннями цих полів.
    • словник: у цьому випадку параметри запиту [select] є ключами словника. Під час виконання запиту [select] ці ключі замінюються відповідними значеннями зі словника.
  • якщо [SELECT] не повертає жодного рядка, результат [List] є об’єктом, порожнім від елементів, але не null (треба перевірити).

[queryForObject]

Image

Цей метод за своєю суттю ідентичний попередньому, але повертає лише один об’єкт. Якщо [SELECT] не повертає жодного рядка, результатом є покажчик null.

[insert]

Image

Цей метод дозволяє виконати команду SQL [insert], параметри якої задаються другим параметром. Повернутий об’єкт — це первинний ключ рядка, що було вставлено. Використання цього результату не є обов’язковим.

[update]

Image

Цей метод дозволяє виконати команду SQL [update], параметри якої задаються другим параметром. Результатом є кількість рядків, змінених командою SQL [update].

[delete]

Image

Цей метод дозволяє виконати команду SQL [delete], параметри якої задаються другим параметром. Результатом є кількість рядків, видалених командою SQL [delete].

Повернемося до методу [getAll] класу [DaoImplCommon]:

1
2
3
4
     // список осіб
    public Collection getAll() {
        return getSqlMapClientTemplate().queryForList("Personne.getAll", null);
}
  • рядок 4: виконується наказ [select] з назвою «Personne.getAll». Він не має параметрів, тому об’єктом «параметр» є null.

У [personnes-firebird.xml] наказ [select] з назвою «Personne.getAll» має такий вигляд:


<?xml version="1.0" encoding="UTF-8" ?>

<!DOCTYPE sqlMap
    PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN"
    "http://www.ibatis.com/dtd/sql-map-2.dtd">

<sqlMap>
    <!-- псевдонім класу [Personne] -->
    <typeAlias alias="Personne.classe" 
        type="istia.st.mvc.personnes.entites.Personne"/>
    <!-- таблиця відповідності [PERSONNES] — об’єкт [Personne] -->
    <resultMap id="Personne.map" 
        class="Personne.classe">
        <result property="id" column="ID" />
        <result property="version" column="VERSION" />
        <result property="nom" column="NOM"/>
        <result property="prenom" column="PRENOM"/>
        <result property="dateNaissance" column="DATENAISSANCE"/>
        <result property="marie" column="MARIE"/>
        <result property="nbEnfants" column="NBENFANTS"/>
    </resultMap>
    <!-- список усіх осіб -->
    <select id="Personne.getAll" resultMap="Personne.map" > select ID, VERSION, NOM, 
        PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES</select>
...
</sqlMap>
  • рядок 23: команда SQL «Personne.getAll» не має параметрів (у тексті запиту відсутні параметри).
  • у рядку 3 методу [getAll] вимагається виконання запиту [select], який називається «Personne.getAll». Цей запит буде виконано. [iBATIS] базується на JDBC. Отже, відомо, що результат запиту буде отримано у вигляді об’єкта [ResultSet]. У рядку 23 атрибут [resultMap] тегу <select> вказує [iBATIS], який «resultMap » він повинен використовувати для перетворення кожного рядка отриманого об’єкта [ResultSet]. Це «resultMap» [Personne.map], визначений у рядках 12–21, який вказує, як перейти від рядка таблиці [PERSONNES] до об’єкта типу [Personne]. [iBATIS] використовуватиме ці відповідності, щоб надати список об’єктів [Personne] на основі рядків об’єкта [ResultSet].
  • Потім у рядку 3 методу [getAll] повертається колекція об’єктів [Personne]
  • метод [queryForList] може викликати виняток Spring [DataAccessException]. Ми дозволяємо йому поширюватися вгору.

Інші методи класу [AbstractDaoImpl] ми розглянемо коротше, оскільки основне щодо використання [iBATIS] вже було викладено під час розгляду методу [getAll].


getOne


Цей метод дозволяє отримати особу, ідентифіковану за її [id]. Її код такий:

         // отримати конкретну особу
    public Personne getOne(int id) {
         // її можна отримати з BD
        Personne personne = (Personne) getSqlMapClientTemplate()
                .queryForObject("Personne.getOne", new Integer(id));
         // чи було щось отримано?
        if (personne == null) {
             // викликається виняток
            throw new DaoException(
                    "La personne d'id [" + id + "] n'existe pas", 2);
        }
         // повертаємо особу
        return personne;
    }
  • рядок 4: вимагає виконання команди [select] під назвою «Personne.getOne». Вона виглядає наступним чином у файлі [personnes-firebird.xml]:

<!-- отримати конкретну особу -->
        <select id="Personne.getOne" resultMap="Personne.map" parameterClass="int">
            select ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM 
            PERSONNES WHERE ID=#значення#</select>

Команда SQL налаштовується за допомогою параметра #value# (рядок 4). Атрибут #value# позначає значення параметра, переданого до команди SQL, коли цей параметр є простим типом: Integer, Double, String тощо. У атрибутах тегу <select> атрибут [parameterClass] вказує, що параметр є цілим числом (рядок 2). У рядку 5 [getOne] видно, що цей параметр є ідентифікатором особи, яку шукають, у формі об’єкта Integer. Ця зміна типу є обов’язковою, оскільки другий параметр [queryForList] повинен бути типу [Object].

Результат запиту [select] слід перетворити на об’єкт за допомогою атрибута [resultMap="Personne.map"] (рядок 2). Таким чином, ми отримаємо тип [Personne].

  • рядки 7–11: якщо запит [select] не повернув жодного рядка, то в рядку 4 отримуємо покажчик null. Це означає, що шукану особу не знайдено. У цьому випадку запускається запит [DaoException] з кодом 2 (рядки 9–10).
  • рядок 13: якщо винятків не сталося, то повертається запитуваний об’єкт [Personne].

deleteOne


Цей метод дозволяє видалити особу, ідентифіковану за її [id]. Його код такий:

     // видалення особи
    public void deleteOne(int id) {
         // видаляємо особу
        int n = getSqlMapClientTemplate().delete("Personne.deleteOne",
                new Integer(id));
         // чи вдалося
        if (n == 0) {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        }
    }
  • рядки 4–5: запит на виконання команди [delete] під назвою «Personne.deleteOne». У файлі [personnes-firebird.xml] вона виглядає так:

<!-- видалити особу -->
    <delete id="Personne.deleteOne" parameterClass="int"> delete FROM PERSONNES WHERE 
        ID=#значення# </delete>

Команда SQL налаштовується за допомогою параметра #value# (рядок 3) типу [parameterClass="int"] (рядок 2). Це буде ідентифікатор особи, яку шукають (рядок 5 у deleteOne)

  • рядок 4: результатом методу [SqlMapClientTemplate].delete є кількість видалених рядків.
  • рядки 7–8: якщо запит [delete] не видалив жодного рядка, це означає, що така особа не існує. Запускається запит [DaoException] з кодом 2 (рядок 8).

saveOne


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

         // додати або змінити особу
    public void saveOne(Personne personne) {
         // чи є параметр «особа» дійсним?
        check(personne);
         // додати чи змінити?
        if (personne.getId() == -1) {
             // додати
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }
...
  • рядок 4: перевіряємо дійсність особи за допомогою методу [check]. Цей метод вже існував у попередній версії і тоді був закомментований. Він запускає [DaoException], якщо особа є недійсною. Дозволяємо цьому коду пройти далі.
  • рядок 6: якщо ми дійшли до цього місця, значить, винятків не було. Отже, особа є дійсною.
  • рядки 6–11: залежно від ідентифікатора особи маємо справу з додаванням (id = -1) або оновленням (id <> -1). В обох випадках викликаються два внутрішні методи класу:
    • insertPersonne: для додавання
    • updatePersonne: для оновлення

insertPersonne


Цей метод дозволяє додати нову особу. Його код такий:

// додати особу
    protected void insertPersonne(Personne personne) {
         // 1-ша версія
        personne.setVersion(1);
         // очікуємо 10 мс  для тестування вкажіть «true» замість «false»
        if (true)
            wait(10);
         // вносимо нову особу до таблиці BD
        getSqlMapClientTemplate().insert("Personne.insertOne", personne);
    }
  • рядок 4: встановлюємо значення 1 для номера версії особи, яку створюємо
  • рядок 9: виконується вставка за допомогою запиту з назвою «Personne.insertOne», який має такий вигляд:

        <insert id="Personne.insertOne" parameterClass="Personne.classe">
            <selectKey keyProperty="id">
                SELECT GEN_ID(GEN_PERSONNES_ID,1) as "value" FROM RDB$$DATABASE
            </selectKey>         
        insert into 
        PERSONNES(ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) 
        VALUES(#id#, #version#, #nom#, #prenom#, #dateNaissance#, #marie#, 
    #nbEnfants#) </insert>

Це запит із параметром, який має тип [Personne] (parameterClass = «Personne.classe», рядок 1). Поля об’єкта [Personne], передані як параметр (рядок 9 у insertPersonne), використовуються для заповнення стовпців рядка, який буде вставлено в таблицю [PERSONNES] (рядки 5–8). Перед нами стоїть проблема, яку потрібно вирішити. Під час вставки об’єкт [Personne], що вставляється, має ідентифікатор, рівний -1. Це значення потрібно замінити на дійсний первинний ключ. Для цього використовуються рядки 2–4 тегу <selectKey>, наведеного вище. Вони вказують:

  • (продовження)
    • запит SQL, який потрібно виконати для отримання значення первинного ключа. Зазначений тут запит є тим самим, що ми навели в розділі 17.1. Слід звернути увагу на два моменти:
      • as " value " є обов’язковим. Можна також написати as value, але value є ключовим словом Firebird, яке необхідно було взяти в лапки.
      • Таблиця Firebird насправді називається [RDB$DATABASE]. Однак символ $ інтерпретується як [iBATIS]. Його було захищено шляхом подвоєння.
    • Поле об’єкта [Personne], яке потрібно ініціалізувати значенням, отриманим за допомогою команди [SELECT], — у даному випадку це поле [id]. Саме атрибут [keyProperty] у рядку 2 вказує на це поле.
  • рядки 6–7: для потреб тестування нам доведеться зачекати 10 мс перед вставкою, щоб перевірити, чи не виникають конфлікти між потоками, які намагаються одночасно виконати додавання.

updatePersonne


Цей метод дозволяє змінити запис про особу, що вже існує в таблиці [PERSONNES]. Його код такий:

// редагувати особу
    protected void updatePersonne(Personne personne) {
         // очікування 10 мс  для тестування вкажіть true замість false
        if (true)
            wait(10);
         // зміна
        int n = getSqlMapClientTemplate()
                .update("Personne.updateOne", personne);
        if (n == 0)
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] n'existe pas ou bien a été modifiée", 2);
    }
  • Оновлення може завершитися невдачею щонайменше з двох причин:
    1. особа, яку потрібно оновити, не існує
    2. особа, яку потрібно оновити, існує, але потік, який намагається її змінити, має неправильну версію
  • рядки 7–8: виконується запит SQL [update] під назвою «Personne.updateOne». Він має такий вигляд:

    <!-- оновити особу -->
    <update id="Personne.updateOne" parameterClass="Personne.classe"> update 
        PERSONNES set VERSION=#версія#+1, NOM=#прізвище#, PRENOM=#ім'я#, DATENAISSANCE=#dateNaissance#, 
        MARIE=#marie#, NBENFANTS=#nbEnfants# WHERE ID=#id# та 
VERSION=#версія#</update>
  • (продовження)
    • рядок 2: запит налаштований і приймає як параметр тип [Personne] (parameterClass = «Personne.classe»). Це особа, яку потрібно змінити (рядок 8 — updatePersonne).
    • Ми хочемо змінити лише ту особу з таблиці [PERSONNES], яка має той самий номер [id] і ту саму версію [version], що й параметр. Саме тому встановлено обмеження [WHERE ID=#id# and VERSION=#version#]. Якщо така особа знайдена, її оновлюють відповідно до параметра, а її версію збільшують на 1 (рядок 3 вище).
  • рядок 9: отримуємо кількість оновлених рядків.
  • рядки 10–11: якщо ця кількість дорівнює нулю, запускається [DaoException] з кодом 2, що вказує на те, що або особа, яку потрібно оновити, не існує, або її версія тим часом змінилася.

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

17.4.1. Тестування реалізації [DaoImplCommon]

Тепер, коли ми написали рівень [dao], пропонуємо протестувати його за допомогою тестів JUnit:

Image

Перш ніж проводити інтенсивне тестування, ми можемо почати з простої програми типу [main], яка відображатиме вміст таблиці [PERSONNES]. Це клас [MainTestDaoFirebird]:

package istia.st.mvc.personnes.tests;

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

import java.util.Collection;
import java.util.Iterator;

import org.springframework.beans.factory.xml.XmlBeanFactory;
import org.springframework.core.io.ClassPathResource;

public class MainTestDaoFirebird {
    public static void main(String[] args) {
        IDao dao = (IDao) (new XmlBeanFactory(new ClassPathResource(
                "spring-config-test-dao-firebird.xml"))).getBean("dao");
         // поточний список
        Collection personnes = dao.getAll();
         // відображення на консолі
        Iterator iter = personnes.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }
}

Файл конфігурації [spring-config-test-dao-firebird.xml] для шару [dao], який використовується у рядках 13–14, має такий вигляд:


<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
    <!-- джерело даних DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <!-- увага: не залишайте пробілів між двома тегами <value> -->
        <property name="url">
            <value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
        </property>
        <property name="username">
            <value>sysdba</value>
        </property>
        <property name="password">
            <value>masterkey</value>
        </property>
    </bean>
    <!-- SqlMapCllient -->
    <bean id="sqlMapClient" 
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
        <property name="configLocation">
            <value>classpath:sql-map-config-firebird.xml</value>
        </property>
    </bean>
    <!-- клас доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
</beans>

Цей файл розглядався в розділі 17.3.2.

Для тестування запускається Firebird з файлом SGBD. Вміст таблиці [PERSONNES] є таким:

Image

Виконання програми [MainTestDaoFirebird] дає такі результати на екрані:

Image

Ми дійсно отримали список осіб. Можна перейти до тесту JUnit.

Тест JUnit [TestDaoFirebird] виглядає наступним чином:

package istia.st.mvc.personnes.tests;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Iterator;
import org.springframework.beans.factory.xml.XmlBeanFactory;
import org.springframework.core.io.ClassPathResource;

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

public class TestDaoFirebird extends TestCase {

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

    public IDao getDao() {
        return dao;
    }

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

     // виробник
    public void setUp() {
        dao = (IDao) (new XmlBeanFactory(new ClassPathResource(
                "spring-config-test-dao-firebird.xml"))).getBean("dao");
    }

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

     // тест1
    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 {
....
    }

     // багатопотокове вставлення
    public void test6() throws ParseException, InterruptedException{
...
}
  • Тести від [test1] до [test5] є такими самими, як у версії 1, за винятком [test4], який дещо змінився. Тест [test6] є новим. Ми коментуємо лише ці два тести.

[test4]


[test4] призначений для тестування методу [updatePersonne - DaoImplCommon]. Нагадаємо його код:

// редагування особи
    protected void updatePersonne(Personne personne) {
         // очікування 10 мс  для тестів вкажіть true замість false
        if (true)
            wait(10);
         // редагування
        int n = getSqlMapClientTemplate()
                .update("Personne.updateOne", personne);
        if (n == 0)
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] n'existe pas ou bien a été modifiée", 2);
    }
  • рядки 4–5: очікуємо 10 мс. Таким чином ми змушуємо потік, що виконує [updatePersonne], втратити доступ до процесора, що може збільшити наші шанси побачити конфлікти доступу між конкуруючими потоками.

[test4] запускає N=100 потоків, завданням яких є одночасне збільшення на 1 кількості дітей однієї й тієї ж особи. Ми хочемо побачити, як обробляються конфлікти версій та конфлікти доступу.

    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 = 100;
        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);
    }

Потоки створюються у рядках 8–13. Кожен з них збільшуватиме на 1 кількість дітей особи, створеної у рядках 3–5. Потоки оновлення [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();
                 // якщо сталася помилка ID або код помилки версії 2, повторюється спроба оновлення
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                default:
                     // необроблений виняток — передаємо далі
                    throw ex;
                }
            }
        }
         // відстеження
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }

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

Оновлення особи може завершитися невдачею, оскільки особа, яку потрібно змінити, не існує або її вже було оновлено іншим потоком. Ці два випадки обробляються в рядках 67–69. У обох випадках метод [updatePersonne] запускає [DaoException] з кодом 2. Після цього потік повертається до початку процедури оновлення (цикл while, рядок 34).


[test6]


[test6] призначений для тестування методу [insertPersonne - DaoImplCommon]. Нагадаємо його код:

// додати особу
    protected void insertPersonne(Personne personne) {
         // 1-ша версія
        personne.setVersion(1);
         // очікуємо 10 мс  для тестування встановити true замість false
        if (true)
            wait(10);
         // вносимо нову особу до таблиці BD
        getSqlMapClientTemplate().insert("Personne.insertOne", personne);
    }
  • рядки 6–7: очікуємо 10 мс, щоб змусити потік, який виконує [insertPersonne], втратити доступ до процесора і таким чином збільшити ймовірність виникнення конфліктів через те, що потоки виконують вставки одночасно.

Код [test6] такий:

     // багатопотокове вставлення
    public void test6() throws ParseException, InterruptedException{
         // створення особи
        Personne p = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
         // яку дублюємо N разів у масиві
        final int N = 100;
        Personne[] personnes=new Personne[N];
        for(int i=0;i<personnes.length;i++){
            personnes[i]=new Personne(p);
        }
         // створення N потоків вставки — кожен потік вставляє 1 особу
        Thread[] taches = new Thread[N];
        for (int i = 0; i < taches.length; i++) {
            taches[i] = new ThreadDaoInsertPersonne("thread n° " + i, dao, personnes[i]);
            taches[i].start();
        }
         // очікування завершення роботи потоків
        for (int i = 0; i < taches.length; i++) {
             // потік № i
            taches[i].join();
             // видалення особи
            dao.deleteOne(personnes[i].getId());
        }
}

Ми створюємо 100 потоків, які одночасно вставлятимуть 100 різних осіб. Ці 100 потоків отримають первинний ключ для особи, яку вони мають вставити, а потім будуть призупинені на 10 мс (рядок 10 — insertPersonne), перш ніж зможуть здійснити вставку. Ми хочемо перевірити, чи все відбувається правильно, а саме, чи вони дійсно отримують різні значення первинного ключа.

  • рядки 7–11: створюється масив із 100 осіб. Усі ці особи є копіями особи p, створеної в рядках 4–5.
  • рядки 14–17: запускаються 100 потоків вставки. Кожен із них відповідає за вставку однієї зі 100 осіб, створених раніше.
  • рядки 19–23: [test6] очікує завершення кожного зі 100 запущених ним потоків. Виявивши завершення потоку № i, він видаляє особу, яку цей потік щойно вставив.

Потік введення [ThreadDaoInsertPersonne] має такий вигляд:

package istia.st.mvc.personnes.tests;

import java.util.Date;

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

public class ThreadDaoInsertPersonne extends Thread {
     // назва потоку
    private String name;

     // посилання на шар [dao]
    private IDao dao;

     // ідентифікатор особи, з якою будемо працювати
    private Personne personne;

     // конструктор
    public ThreadDaoInsertPersonne(String name, IDao dao, Personne personne) {
        this.name = name;
        this.dao = dao;
        this.personne = personne;
    }

     // ядро потоку
    public void run() {
         // відстеження
        suivi("lancé");
         // вставлення
        dao.saveOne(personne);
         // відстеження
        suivi("a terminé");
    }

     // відстеження
    private void suivi(String message) {
        System.out.println(name + " [" + new Date().getTime() + "] : "
                + message);
    }
}
  • рядки 19–22: конструктор потоку запам’ятовує особу, яку він має вставити, та шар [dao], який він має використати для здійснення цієї вставки.
  • рядок 30: особа вставляється. Якщо виникає виняток, він передається до [test6].

Тестування


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

Отже, тест [test4] завершився невдало. Кількість дітей змінилася на 69 замість очікуваних 100. Що сталося? Давайте розглянемо екранні журнали. Вони показують наявність винятків, згенерованих Firebird:


Exception in thread "Thread-62" org.springframework.jdbc.UncategorizedSQLException: SqlMapClient operation; uncategorized SQLException for SQL []; SQL state [HY000]; error code [335544336];   
--— Помилка сталася в personnes-firebird.xml.  
--— Помилка сталася під час застосування карти параметрів.  
--— Перевірте Personne.updateOne-InlineParameterMap.  
--— Перевірте оператор (оновлення не вдалося).  
--— Причина: org.firebirdsql.jdbc.FBSQLException: Виняток GDS. 335544336. тупикова ситуація
update conflicts with concurrent update; nested exception is com.ibatis.common.jdbc.exception.NestedSQLException:   
--— Помилка сталася в personnes-firebird.xml.  
--— Помилка сталася під час застосування карти параметрів.  
  • рядок 1 — сталося виключення Spring [org.springframework.jdbc.UncategorizedSQLException]. Це неконтрольоване виключення, яке було використано для інкапсуляції виключення, згенерованого драйвером Firebird JDBC, описаного в рядку 6.
  • рядок 6 — драйвер Firebird JDBC викинув виняток типу [org.firebirdsql.jdbc.FBSQLException] з кодом помилки 335544336.
  • рядок 7: вказує, що стався конфлікт доступу між двома потоками, які одночасно намагалися оновити один і той самий рядок таблиці [PERSONNES].

Це не є незворотною помилкою. Потік, який перехоплює це виключення, може повторити спробу оновлення. Для цього потрібно змінити код у [ThreadDaoMajEnfants]:

            try {
                 // збільшує на 1 кількість дочірніх елементів цієї копії
                personne.setNbEnfants(nbEnfants + 1);
                 // здійснюється спроба змінити оригінал
                dao.saveOne(personne);
                 // операція завершилася  оригінал було змінено
                fini = true;
            } catch (DaoException ex) {
                 // отримано код помилки
                codeErreur = ex.getCode();
                 // якщо сталася помилка ID або помилка коду версії 2, повторюється спроба оновлення
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                default:
                     // необроблене виключення  передаємо його вище
                    throw ex;
                }
  • рядок 8: обробляється виняток типу [DaoException]. Згідно з тим, що було сказано, нам слід обробляти виняток, який виник під час тестування, — тип [org.springframework.jdbc.UncategorizedSQLException]. Однак не можна обмежитися обробкою лише цього типу, який є загальним типом Spring, призначеним для інкапсуляції винятків, про які він не знає. Spring розпізнає винятки, що генеруються драйверами JDBC для низки SGBD, таких як Oracle, MySQL, Postgres, DB2, SQL Server тощо, але не Firebird. Також будь-який виняток, що генерується драйвером JDBC для Firebird, інкапсулюється у тип Spring [org.springframework.jdbc.UncategorizedSQLException]:

Image

Як бачимо вище, клас [UncategorizedSQLException] походить від класу [DataAccessException], про який ми згадували в розділі 17.3.3. Дізнатися, яке виключення було інкапсульовано в [UncategorizedSQLException], можна за допомогою його методу [getSQLException]:

Image

Це виключення типу [SQLException] — це те, що було згенеровано рівнем [iBATIS], який, у свою чергу, інкапсулює виключення, згенероване драйвером JDBC бази даних. Точну причину виникнення винятку типу [SQLException] можна дізнатися за допомогою методу:

Image

Отримуємо об’єкт типу [Throwable], який був згенерований драйвером JDBC:

Image

Тип [Throwable] є батьківським класом для [Exception].

Тут нам потрібно перевірити, чи об’єкт типу [Throwable], запущений драйвером JDBC Firebird і який став причиноювинятку [SQLException], який був згенерований рівнем [iBATIS], дійсно є винятком типу [org.firebirdsql.gds.GDSException] з кодом помилки 335544336. Щоб отримати код помилки, ми можемо використати метод [getErrorCode()] класу [org.firebirdsql.gds.GDSException].

Якщо в коді [ThreadDaoMajEnfants] ми використаємо виняток [org.firebirdsql.gds.GDSException], то цей потік зможе працювати лише з Firebird типу SGBD. Те саме стосуватиметься тесту [test4], який використовує цей потік. Ми хочемо цього уникнути. Адже ми прагнемо, щоб наші тести JUnit залишалися дійсними незалежно від того, який SGBD використовується. Щоб досягти цього результату, ми вирішили, що рівень [dao] запускатиме [DaoException] з кодом 4, коли буде виявлено виняток типу «конфлікт оновлення», і це відбуватиметься незалежно від базового SGBD. Таким чином, потік [ThreadDaoMajEnfants] можна переписати наступним чином:

package istia.st.mvc.personnes.tests;
...

public class ThreadDaoMajEnfants extends Thread {
...

     // ядро потоку
    public void run() {
...
        while (!fini) {
             // отримуємо копію запису про особу з idPersonne
            Personne personne = dao.getOne(idPersonne);
            nbEnfants = personne.getNbEnfants();
...
             // очікування завершено — намагаємося перевірити копію
             // тим часом інші потоки могли змінити оригінал
            int codeErreur = 0;
            try {
                 // збільшуємо на 1 кількість дочірніх елементів цієї копії
                personne.setNbEnfants(nbEnfants + 1);
                 // намагаємося змінити оригінал
                dao.saveOne(personne);
                 // пройшли — оригінал було змінено
                fini = true;
            } catch (DaoException ex) {
                 // отримуємо код помилки
                codeErreur = ex.getCode();
                 // якщо сталася помилка ID або версії 2, або виник зациклення 4, то
                 // повторюємо спробу оновлення
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                case 4:
                    suivi("conflit de mise à jour");
                    break;
                default:
                     // необроблене виключення — дозволяємо йому пройти нагору
                    throw ex;
                }
            }
        }
         // відстеження
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }
...
}
  • рядки 34–36: перехоплено виняток типу [DaoException] з кодом 4. Потік [ThreadDaoMajEnfants] буде змушений розпочати процедуру оновлення спочатку (рядок 10)

Отже, наш рівень [dao] повинен бути здатний розпізнавати виняток типу «конфлікт оновлення». Цей виняток генерується драйвером JDBC і є специфічним для нього. Цей виняток має оброблятися в методі [updatePersonne] класу [DaoImplCommon]:

// змінити особу
    protected void updatePersonne(Personne personne) {
         // очікуємо 10 мс  для тестування вкажіть true замість false
        if (true)
            wait(10);
         // зміна
        int n = getSqlMapClientTemplate()
                .update("Personne.updateOne", personne);
        if (n == 0)
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] n'existe pas ou bien a été modifiée", 2);
    }

Рядки 7–11 повинні бути обгорнуті блоком try/catch. Для драйвера SGBD Firebird нам потрібно перевірити, чи виняток, що спричинив збій оновлення, має тип [org.firebirdsql.gds.GDSException] і код помилки 335544336. Якщо застосувати такий тест до класу [DaoImplCommon], ми пов’яжемо цей клас із класом SGBD для Firebird, що, очевидно, небажано. Якщо ми хочемо зберегти загальний характер класу [DaoImplCommon], нам потрібно створити його похідний клас і обробляти виняток у класі, призначеному спеціально для Firebird. Саме це ми й зробимо зараз.

17.4.2. Клас [DaoImplFirebird]

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

package istia.st.mvc.personnes.dao;

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

public class DaoImplFirebird extends DaoImplCommon {

     // змінити особу
    protected void updatePersonne(Personne personne) {
         // очікування 10 мс — для тестування вкажіть «true» замість «false»
        if (true)
            wait(10);
         // зміна
        try {
             // змінюємо особу, яка має правильну версію
            int n = getSqlMapClientTemplate().update("Personne.updateOne",
                    personne);
            if (n == 0)
                throw new DaoException("La personne d'Id [" + personne.getId()
                        + "] n'existe pas ou bien a été modifiée", 2);
        } catch (org.springframework.jdbc.UncategorizedSQLException ex) {
            if (ex.getSQLException().getCause().getClass().isAssignableFrom(
                    org.firebirdsql.jdbc.FBSQLException.class)) {
                org.firebirdsql.jdbc.FBSQLException cause = (org.firebirdsql.jdbc.FBSQLException) ex
                        .getSQLException().getCause();
                if (cause.getErrorCode() == 335544336) {
                    throw new DaoException(
                            "Conflit d'accès au même enregistrement", 4);
                }
            } else {
                throw ex;
            }
        }
    }

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

}
  • рядок 5: клас [DaoImplFirebird] походить від [DaoImplCommon], класу, який ми щойно розглянули. У рядках 8–33 він перевизначає метод [updatePersonne], який створює нам проблему.
  • рядок 20: ми перехоплюємо виняток Spring типу [UncategorizedSQLException]
  • рядки 21–22: ми перевіряємо, що причиною базового винятку типу [SQLException], який генерується рівнем [iBATIS], є виняток типу [org.firebirdsql.jdbc.FBSQLException]
  • рядок 25: додатково перевіряємо, чи код помилки цього винятку Firebird дорівнює 335544336 — коду помилки «deadlock».
  • рядки 26–27: якщо всі ці умови виконані, запускається [DaoException] з кодом 4.
  • рядки 36–44: метод [wait] дозволяє призупинити поточний потік на N мілісекунд. Він використовується лише для тестування.

Ми готові до тестування нового рівня [dao].

17.4.3. Тестування реалізації [DaoImplFirebird]

Файл конфігурації тестів [spring-config-test-dao-firebird.xml] змінено для використання реалізації [DaoImplFirebird]:


<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
    <!-- джерело даних DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <!-- увага: не залишайте пробілів між двома тегами <value> -->
        <property name="url">
            <value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
        </property>
        <property name="username">
            <value>sysdba</value>
        </property>
        <property name="password">
            <value>masterkey</value>
        </property>
    </bean>
    <!-- SqlMapCllient -->
    <bean id="sqlMapClient" 
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
        <property name="configLocation">
            <value>classpath:sql-map-config-firebird.xml</value>
        </property>
    </bean>
    <!-- клас доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
</beans>
  • рядок 32: нова реалізація [DaoImplFirebird] для шару [dao].

Результати тесту [test4], який раніше завершився невдачею, такі:

Image

[test4] пройшов успішно. Останні рядки екранних журналів такі:

1
2
3
4
5
6
7
thread n° 36 [1145977145984] : fin attente
thread n° 75 [1145977145984] : a terminé et passé le nombre d'enfants à 99
thread n° 36 [1145977146000] : version corrompue ou personne inexistante
thread n° 36 [1145977146000] : 99 -> 100 pour la version 100
thread n° 36 [1145977146000] : début attente
thread n° 36 [1145977146015] : fin attente
thread n° 36 [1145977146031] : a terminé et passé le nombre d'enfants à 100

Останній рядок вказує, що останнім завершився потік № 36. У рядку 3 показано конфлікт версій, який змусив потік № 36 повторити процедуру оновлення даних особи (рядок 4). Інші записи журналу показують конфлікти доступу під час оновлень:

1
2
3
thread n° 52 [1145977145765] : version corrompue ou personne inexistante
thread n° 75 [1145977145765] : conflit de mise à jour
thread n° 36 [1145977145765] : version corrompue ou personne inexistante

У рядку 2 показано, що потік № 75 зазнав невдачі під час оновлення через конфлікт оновлень: коли команда SQL [update] була відправлена до таблиці [PERSONNES], рядок, який потрібно було оновити, був заблокований іншим потоком. Цей конфлікт доступу змусить потік № 75 повторити спробу оновлення.

На завершення, щодо [test4], слід зазначити суттєву відмінність від результатів того самого тесту у версії 1, де він завершився невдачею через проблеми із синхронізацією. Оскільки методи шару [dao] у версії 1 не були синхронізовані, виникали конфлікти доступу. У цьому випадку нам не довелося синхронізувати шар [dao]. Ми просто обробили конфлікти доступу, про які повідомив Firebird.

Тепер виконаємо весь тест JUnit для шару [dao]:

Image

Отже, схоже, що ми маємо дійсний рівень [dao]. Щоб з високою ймовірністю визнати його дійсним, нам потрібно провести більше тестів. Проте ми вважатимемо його працездатним.

17.5. Шар [service]

17.5.1. Компоненти шару [service]

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

Image

  • [IService] — це інтерфейс, представлений шаром [service]
  • [ServiceImpl] — це реалізація цього інтерфейсу

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

package istia.st.mvc.personnes.service;

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

import java.util.Collection;

public interface IService {
     // список усіх осіб
    Collection getAll();

     // отримати інформацію про конкретну особу
    Personne getOne(int id);

     // додати/змінити особу
    void saveOne(Personne personne);

     // видалити особу
    void deleteOne(int id);

     // зберегти декількох осіб
    void saveMany(Personne[] personnes);

     // видалити декількох осіб
    void deleteMany(int ids[]);
}
  • інтерфейс має ті самі чотири методи, що й у версії 1, але також має ще два додаткові:
    • saveMany: дозволяє одночасно зберегти декількох осіб атомарно. Або всі вони зберігаються, або жодна.
    • deleteMany: дозволяє видалити одразу декількох осіб атомарно. Або всі вони видаляються, або жодна.

Ці два методи не використовуватимуться веб-додатком. Ми додали їх, щоб проілюструвати поняття транзакції в базі даних. Обидва методи повинні виконуватися в рамках однієї транзакції, щоб забезпечити бажану атомарність.

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

package istia.st.mvc.personnes.service;

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

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 Collection getAll() {
        return dao.getAll();
    }

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

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

     // видалення особи
    public void deleteOne(int id) {
        dao.deleteOne(id);
    }

     // зберегти колекцію осіб
    public void saveMany(Personne[] personnes) {
         // цикл по масиву осіб
        for (int i = 0; i < personnes.length; i++) {
            dao.saveOne(personnes[i]);
        }
    }

     // видалити колекцію осіб
    public void deleteMany(int[] ids) {
         // ids: ідентифікатори осіб, яких потрібно видалити
        for (int i = 0; i < ids.length; i++) {
            dao.deleteOne(ids[i]);
        }
    }
}
  • Методи [getAll, getOne, insertOne, saveOne] викликають методи однойменного рівня [dao].
  • рядки 42–47: метод [saveMany] по черзі зберігає осіб із масиву, переданого як параметр.
  • рядки 50–55: метод [deleteMany] видаляє по одному осіб, масив яких передано йому як параметр з методу id

Ми вже зазначали, що методи [saveMany] та [deleteMany] повинні виконуватися в рамках транзакції, щоб забезпечити принцип «все або нічого» для цих методів. Ми бачимо, що наведений вище код повністю ігнорує це поняття транзакції. Воно з’явиться лише у файлі конфігурації шару [service].

17.5.2. Конфігурація шару [service]

Вище, у рядку 11, бачимо, що реалізація [ServiceImpl] містить посилання на шар [dao]. Цей шар, як і у версії 1, буде ініціалізований Spring під час створення екземпляра шару [service - ServiceImpl]. Файл конфігурації, який дозволить створити екземпляр шару [service], буде таким:


<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
    <!-- джерело даних DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <property name="url">
            <!-- увага: не залишайте пробілів між двома тегами <value> -->
            <value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
        </property>
        <property name="username">
            <value>sysdba</value>
        </property>
        <property name="password">
            <value>masterkey</value>
        </property>
    </bean>
    <!-- SqlMapCllient -->
    <bean id="sqlMapClient" 
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
        <property name="configLocation">
            <value>classpath:sql-map-config-firebird.xml</value>
        </property>
    </bean>
    <!-- клас доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
    <!-- менеджер транзакцій -->
    <bean id="transactionManager" 
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
    </bean>
    <!-- класи доступу до рівня [service] -->
    <bean id="service" 
        class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
        <property name="transactionManager">
            <ref local="transactionManager"/>
        </property>
        <property name="target">
            <bean class="istia.st.mvc.personnes.service.ServiceImpl">
                <property name="dao">
                    <ref local="dao"/>
                </property>
            </bean>
        </property>
        <property name="transactionAttributes">
            <props>
                <prop key="get*">PROPAGATION_SUPPORTS,readOnly</prop>
                <prop key="save*">PROPAGATION_REQUIRED</prop>
                <prop key="delete*">PROPAGATION_REQUIRED</prop>
            </props>
        </property>
    </bean>
</beans>
  • рядки 1–36: конфігурація шару [dao]. Ця конфігурація була пояснена під час розгляду шару [dao] у розділі 17.3.2.
  • рядки 38–64: конфігурація шару [service]

У рядку 46 видно, що реалізація шару [service] здійснюється типом [TransactionProxyFactoryBean]. Ми очікували знайти тип [ServiceImpl]. [TransactionProxyFactoryBean] — це попередньо визначений тип Spring. Як може попередньо визначений тип реалізовувати інтерфейс [IService], який, у свою чергу, є специфічним для нашого додатка?

Спочатку розглянемо клас [TransactionProxyFactoryBean]:

Image

Ми бачимо, що він реалізує інтерфейс [FactoryBean]. Ми вже зустрічали цей інтерфейс. Ми знаємо, що коли додаток запитує у Spring екземпляр типу, що реалізує [FactoryBean], Spring повертає не екземпляр [I] цього типу, а об’єкт, повернений методом [I].getObject():

Image

У нашому випадку шар [service] буде реалізовано об’єктом, який повертає метод [TransactionProxyFactoryBean].getObject(). Якою є природа цього об’єкта? Ми не будемо заглиблюватися в деталі, оскільки вони є складними. Вони належать до так званого Spring AOP (аспектно-орієнтоване програмування). Спробуємо прояснити ситуацію за допомогою простих схем. AOP дозволяє зробити наступне:

  • маємо два класи C1 та C2, причому C1 використовує інтерфейс [I2], представлений C2:
  • завдяки AOP можна, не впливаючи на роботу обох класів, розмістити перехоплювач між класами C1 та C2:

Клас [C1] було скомпільовано для роботи з інтерфейсом [I2], який реалізує [C2]. Під час виконання AOP розміщує клас [intercepteur] між [C1] та [C2]. Щоб це стало можливим, клас [intercepteur], звісно, повинен надавати класу [C1] той самий інтерфейс [I2], що й [C2].

Для чого це може знадобитися? У документації Spring наведено кілька прикладів. Наприклад, може знадобитися вести журнали під час викликів певного методу M класу [C2], щоб провести аудит цього методу. У [intercepteur] тоді буде написано метод [M], який здійснює ці записи в журнал. Виклик методу M з [C1] до [C2] відбуватиметься таким чином (див. схему вище):

  1. [C1] викликає метод M у [C2]. Насправді буде викликано метод M класу [intercepteur]. Це можливо, якщо [C1] звертається до інтерфейсу [I2], а не до конкретної реалізації [I2]. Тоді достатньо, щоб [intercepteur] реалізовував [I2].
  2. Метод M класу [intercepteur] веде журнал і викликає метод M класу [C2], на який спочатку посилався [C1].
  3. Метод M класу [C2] виконується і повертає свій результат методу M класу [intercepteur], який, за необхідності, може додати щось до того, що було зроблено у кроці 2.
  4. метод M класу [intercepteur] повертає результат викликаючому методу класу [C1]

Бачимо, що метод M класу [intercepteur] може виконувати певні дії до та після виклику методу M класу [C2]. Отже, щодо [C1] вона розширює метод M класу [C2]. Таким чином, технологію AOP можна розглядати як спосіб розширення інтерфейсу, представленого класом.

Як ця концепція застосовується до нашого шару [service]? Якщо реалізувати шар [service] безпосередньо за допомогою екземпляра [ServiceImpl], наш веб-додаток матиме таку архітектуру:

Якщо реалізувати шар [service] за допомогою екземпляра [TransactionProxyFactoryBean], ми отримаємо таку архітектуру:

Можна сказати, що рівень [service] інстанціюється за допомогою двох об’єктів:

  • об’єкт, який ми вище називаємо [proxy transactionnel] і який, фактично, є об’єктом, повернутим методом [getObject] з [TransactionProxyFactoryBean]. Саме цей об’єкт буде виконувати роль інтерфейсу між шаром [service] та шаром [web]. Він за своєю структурою реалізує інтерфейс [IService].
  • екземпляр [ServiceImpl], який також реалізує інтерфейс [IService]. Тільки він знає, як працювати з шаром [dao], тому він є необхідним.

Уявімо, що шар [web] викликає метод [saveMany] інтерфейсу [IService]. Ми знаємо, що з функціональної точки зору додавання/оновлення, що виконуються цим методом, повинні здійснюватися в рамках транзакції. Або всі вони виконуються успішно, або жодне з них не виконується. Ми розглянули метод [saveMany] класу [ServiceImpl] і зазначили, що він не підтримує поняття транзакції. Метод [saveMany] класу [proxy transactionnel] доповнить метод [saveMany] класу [ServiceImpl] цим поняттям транзакції. Розглянемо схему, наведену вище:

  1. рівень [web] викликає метод [saveMany] інтерфейсу [IService].
  2. Виконується метод [saveMany] з [proxy transactionnel]. Він розпочинає транзакцію. Для цього він повинен мати достатню інформацію, зокрема об’єкт [DataSource], щоб встановити з’єднання з SGBD. Потім він викликає метод [saveMany] з [ServiceImpl].
  3. Цей об’єкт виконується. Він неодноразово звертається до рівня [dao] для виконання операцій вставки або оновлення. Команди SQL, що виконуються при цьому, виконуються в транзакції, розпочатій у кроці 2.
  4. Припустимо, що одна з цих операцій завершилася невдало. Рівень [dao] дозволить передати виняток на рівень [service], а саме до методу [saveMany] екземпляра [ServiceImpl].
  5. Цей екземпляр нічого не робить і пропускає виняток далі до методу [saveMany] екземпляра [proxy transactionnel].
  6. Отримавши виняток, метод [saveMany] класу [proxy transactionnel], який є власником транзакції, виконує [rollback] для цієї транзакції, щоб скасувати всі оновлення, а потім дозволяє винятковій ситуації пройти вгору до рівня [web], який буде відповідати за її обробку.

На етапі 4 ми припустили, що одне з вставлень або оновлень завершилося невдало. Якщо це не так, у [5] виняток не передається. Те саме стосується [6]. У цьому випадку метод [saveMany] класу [proxy transactionnel] виконує [commit] транзакції для підтвердження всіх оновлень.

Тепер ми маємо більш чітке уявлення про архітектуру, реалізовану біном [TransactionProxyFactoryBean]. Повернемося до його конфігурації:


    <!-- менеджер транзакцій -->
    <bean id="transactionManager" 
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
    </bean>
    <!-- клас доступу до шару [service] -->
    <bean id="service" 
        class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
        <property name="transactionManager">
            <ref local="transactionManager"/>
        </property>
        <property name="target">
            <bean class="istia.st.mvc.personnes.service.ServiceImpl">
                <property name="dao">
                    <ref local="dao"/>
                </property>
            </bean>
        </property>
        <property name="transactionAttributes">
            <props>
                <prop key="get*">PROPAGATION_REQUIRED,readOnly</prop>
                <prop key="save*">PROPAGATION_REQUIRED</prop>
                <prop key="delete*">PROPAGATION_REQUIRED</prop>
            </props>
        </property>
    </bean>

Розглянемо цю конфігурацію у світлі налаштованої архітектури:

  • [proxy transactionnel] буде керувати транзакціями. Spring пропонує кілька стратегій управління ними. [proxy transactionnel] потребує посилання на обраний менеджер транзакцій.
  • рядки 11–13: визначають атрибут [transactionManager] біна [TransactionProxyFactoryBean] із посиланням на менеджер транзакцій. Останній визначено в рядках 2–7.
  • рядки 2–7: менеджер транзакцій має тип [DataSourceTransactionManager]:

Image

[DataSourceTransactionManager] — це менеджер транзакцій, пристосований до SGBD, доступ до яких здійснюється через об’єкт [DataSource]. Він може керувати лише транзакціями на одному об’єкті SGBD. Він не може керувати транзакціями, розподіленими між кількома об’єктами SGBD. У даному випадку ми маємо лише один об’єкт SGBD. Отже, цей менеджер транзакцій підходить. Коли [proxy transactionnel] розпочне транзакцію, він зробить це через з’єднання, прив’язане до потоку. Саме це з’єднання буде використовуватися у всіх рівнях, що ведуть до бази даних: [ServiceImpl, DaoImplCommon, SqlMapClientTemplate, JDBC].

Клас [DataSourceTransactionManager] повинен знати джерело даних, до якого він має звернутися з запитом на з’єднання, щоб прив’язати його до потоку. Воно визначено в рядках 4–6: це те саме джерело даних, що й те, яке використовується шаром [dao] (див. параграф 17.5.2).

  • рядки 14–19: атрибут «target» вказує клас, який має бути перехоплений, у даному випадку — клас [ServiceImpl]. Ця інформація необхідна з двох причин:
    • клас [ServiceImpl] має бути інстанційований, оскільки саме він забезпечує взаємодію з шаром [dao]
    • [TransactionProxyFactoryBean] повинен генерувати проксі, який надає шару [web] той самий інтерфейс, що й [ServiceImpl].
  • рядки 21–27: вказують, які методи [ServiceImpl] повинен перехоплювати проксі. Атрибут [transactionAttributes] у рядку 21 вказує, які методи [ServiceImpl] вимагають транзакції та які атрибути цієї транзакції:
  • рядок 23: методи, назви яких починаються з get [getOne, getAll], виконуються в транзакції з атрибутом [PROPAGATION_REQUIRED,readOnly]:
    • PROPAGATION_REQUIRED: метод виконується в транзакції, якщо до потоку вже прив’язана транзакція; інакше створюється нова, і метод виконується в ній.
    • readOnly: транзакція тільки для читання

Тут методи [getOne] та [getAll] з [ServiceImpl] виконуватимуться в транзакції, хоча насправді в цьому немає потреби. У кожному випадку йдеться про операцію, що складається з єдиного запиту SELECT. Не бачимо сенсу поміщати цей SELECT у транзакцію.

  • рядок 24: методи, назви яких починаються з «save», [saveOne, saveMany], виконуються в транзакції атрибуту [PROPAGATION_REQUIRED].
  • рядок 25: методи [deleteOne] та [deleteMany] класу [ServiceImpl] налаштовані так само, як і методи [saveOne, saveMany].

У нашому шарі [service] лише методи [saveMany] та [deleteMany] потребують виконання в транзакції. Налаштування можна було б скоротити до таких рядків:


        <property name="transactionAttributes">
            <props>
                <prop key="saveMany">PROPAGATION_REQUIRED</prop>
                <prop key="deleteMany">PROPAGATION_REQUIRED</prop>
            </props>
</property>

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

Тепер, коли ми написали та налаштували рівень [service], пропонуємо протестувати його за допомогою тестів JUnit:

Image

Файл конфігурації [spring-config-test-service-firebird.xml] для шару [service] — це той, що було описано в розділі 17.5.2.

Тест JUnit [TestServiceFirebird] має такий вигляд:

package istia.st.mvc.personnes.tests;

...

public class TestServiceFirebird extends TestCase {

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

    public IService getService() {
        return service;
    }

    public void setService(IService service) {
        this.service = service;
    }

     // налаштування
    public void setUp() {
        service = (IService) (new XmlBeanFactory(new ClassPathResource(
                "spring-config-test-service-firebird.xml"))).getBean("service");
    }

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

     // тест1
    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 {
...
    }

         // багатопотокові вставки
    public void test6() throws ParseException, InterruptedException{
...
    }

     // тестування методу deleteMany
    public void test7() throws ParseException {
         // поточний список
        Collection personnes = service.getAll();
        int nbPersonnes1 = personnes.size();
         // відображення
        doListe(personnes);
         // створення трьох осіб
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 1);
        Personne p2 = new Personne(-1, "Y", "Y", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/03/2006"), false, 0);
        Personne p3 = new Personne(-2, "Z", "Z", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/04/2006"), true, 2);
         // додавання 3 осіб — особа p3 з ідентифікатором -2  спричинить
         // виникнення винятку
        boolean erreur = false;
        try {
            service.saveMany(new Personne[] { p1, p2, p3 });
        } catch (Exception ex) {
            erreur = true;
            System.out.println(ex.toString());
        }
         // перевірка
        assertTrue(erreur);
         // новий список — кількість елементів не мала змінитися
         // через автоматичний відкат транзакції
        int nbPersonnes2 = service.getAll().size();
        assertEquals(nbPersonnes1, nbPersonnes2);
         // додано двох дійсних осіб
         // їхні ідентифікатори встановлено на -1
        p1.setId(-1);
        p2.setId(-1);
        service.saveMany(new Personne[] { p1, p2 });
         // відновлюємо їхні ідентифікатори
        int id1 = p1.getId();
        int id2 = p2.getId();
         // перевірки
        p1 = service.getOne(id1);
        assertEquals(p1.getNom(), "X");
        p2 = service.getOne(id2);
        assertEquals(p2.getNom(), "Y");
         // новий список — має бути на 2 елементи більше
        int nbPersonnes3 = service.getAll().size();
        assertEquals(nbPersonnes1 + 2, nbPersonnes3);
         // видалення p1 і p2 та неіснуючої особи
         // має статися виняток
        erreur = false;
        try {
            service.deleteMany(new int[] { id1, id2, -1 });
        } catch (Exception ex) {
            erreur = true;
            System.out.println(ex.toString());
        }
         // перевірка
        assertTrue(erreur);
         // новий список
        personnes = service.getAll();
        int nbPersonnes4 = personnes.size();
         // ніхто не мав бути видалений (автоматичний відкат
         // автоматичний відкат транзакції)
        assertEquals(nbPersonnes4, nbPersonnes3);
         // видаляються обидві дійсні особи
        service.deleteMany(new int[] { id1, id2 });
         // перевірки
         // особа p1
        erreur = false;
        int codeErreur = 0;
        try {
            p1 = service.getOne(id1);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
         // має бути помилка з кодом 2
        assertTrue(erreur);
        assertEquals(2, codeErreur);
         // особа p2
        erreur = false;
        codeErreur = 0;
        try {
            p1 = service.getOne(id2);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
         // має бути помилка з кодом 2
        assertTrue(erreur);
        assertEquals(2, codeErreur);
         // новий список
        personnes = service.getAll();
        int nbPersonnes5 = personnes.size();
         // перевірка — ми маємо повернутися до вихідної точки
        assertEquals(nbPersonnes5, nbPersonnes1);
         // відображення
        doListe(personnes);
    }

}
  • рядки 19–22: програма тестує шари [dao] та [service], налаштовані файлом [spring-config-test-service-firebird.xml], який розглядався в попередньому розділі.
  • Тести від [test1] до [test6] за своєю суттю ідентичні своїм однойменним аналогам у класі тестів [TestDaoFirebird] шару [dao]. Єдина відмінність полягає в тому, що за налаштуваннями методи [saveOne] та [deleteOne] тепер виконуються в рамках транзакції.
  • Метод [test7] призначений для тестування методів [saveMany] та [deleteMany]. Ми хочемо перевірити, чи вони дійсно виконуються в рамках транзакції. Прокоментуємо код цього методу:
  • рядки 62–63: підраховується кількість осіб [nbPersonnes1], які наразі знаходяться у списку
  • рядки 67–72: створюються три особи
  • рядки 73–83: ці три особи зберігаються за допомогою методу [saveMany] — рядок 77. Дві перші особи p1 і p2, що мають ідентифікатор -1, будуть додані до таблиці [PERSONNES]. Особа p3 має ідентифікатор -2. Отже, це не вставка, а оновлення. Воно завершиться невдачею, оскільки в таблиці [PERSONNES] немає жодної особи з ідентифікатором -2. Отже, рівень [dao] згенерує виняток, який буде передано до рівня [service]. Наявність цього винятку перевіряється у рядку 83.
  • Через попереднє виключення рівень [service] повинен створити [rollback] із набору команд SQL, виданих під час виконання методу [saveMany], оскільки цей метод виконується в транзакції. У рядках 86–87 перевіряється, що кількість осіб у списку не змінилася, а отже, вставки p1 та p2 не відбулися.
  • рядки 88–103: додаються лише особи p1 та p2, після чого перевіряється, що у списку з’явилося ще дві особи.
  • рядки 106–114: видаляється група осіб, що складається з щойно доданих осіб p1 і p2 та неіснуючої особи (id = -1). Для цього використовується метод [deleteMany], рядок 108. Цей метод завершиться з помилкою, оскільки в таблиці [PERSONNES] немає жодної особи з id, рівним –1. Отже, рівень [dao] викличе виняток, який буде передано до рівня [service]. Наявність цього винятку перевіряється у рядку 114.
  • Через попереднє виключення рівень [service] повинен створити [rollback] із набору команд SQL, виданих під час виконання методу [deleteMany], оскільки цей метод виконується в транзакції. У рядках 116–117 перевіряється, чи кількість осіб у списку не змінилася, а отже, видалення p1 та p2 не відбулося.
  • рядок 122: видаляється група, що складається виключно з осіб p1 та p2. Це має відбутися успішно. У решті методу перевіряється, чи це дійсно так.

Виконання тестів дає такі результати:

Image

Усі сім тестів пройшли успішно. Ми вважатимемо наш рівень [service] працездатним.

17.7. Рівень [web]

Нагадаємо загальну архітектуру веб-додатку, який потрібно створити:

Ми щойно створили шари [dao] та [service], які дозволяють працювати з базою даних Firebird. Ми написали версію 1 цього додатка, у якій шари [dao] та [service] працювали зі списком осіб, що зберігався в оперативній пам’яті. Шар [web], написаний тоді, залишається дійсним. Адже він був призначений для шару [service], що реалізовував інтерфейс [IService]. Оскільки новий шар [service] реалізує той самий інтерфейс, шар [web] не потребує змін.

У попередній статті версія 1 додатка тестувалася з проектом Eclipse [mvc-personnes-02B], де шари [web, service, dao, entites] були поміщені в архіви .jar:

Папка [src] була порожньою. Класи шарів містилися в архівах [personnes-*.jar ]:

Щоб протестувати версію 2, у Eclipse ми дублюємо папку Eclipse [mvc-personnes-02B] у [mvc-personnes-03B] (копіювання/вставлення):

Image

У проєкті [mvc-personnes-03] експортуємо шари [dao] та [service] відповідно в архіви [personnes-dao.jar] та [personnes-service.jar] з папки [dist] у рамках проєкту:

Image

Ми копіюємо ці два файли, а потім у програмі Eclipse вставляємо їх у папку [WEB-INF/lib] проекту [mvc-personnes-03B], де вони замінять архіви з такими самими іменами з попередньої версії.

Також скопіюємо та вставимо архіви [commons-dbcp-*.jar, commons-pool-*.jar, firebirdsql-full.jar, ibatis-common-2.jar, ibatis-sqlmap-2.jar] з папки [lib] проекту [mvc-personnes-03] у папку [WEB-INF/lib] проекту [mvc-personnes-03B]. Ці архіви необхідні для нових шарів [dao] та [service].

Після цього ми додаємо нові архіви до Classpath проекту: [clic droit sur projet -> Properties -> Java Build Path -> Add Jars].

Папка [src] містить файли конфігурації шарів [dao] та [service]:

Image

Файл [spring-config.xml] налаштовує шари [dao] та [service] веб-додатку. У новій версії він ідентичний файлу [spring-config-test-service-firebird.xml], який використовувався для налаштування тесту сервісного шару в проєкті [mvc-personnes-03]. Тому ми копіюємо та вставляємо вміст одного файлу в інший:


<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
    <!-- джерело даних DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <property name="url">
            <!-- Увага: не залишайте пробілів між двома тегами <value> -->
            <value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
        </property>
        <property name="username">
            <value>sysdba</value>
        </property>
        <property name="password">
            <value>masterkey</value>
        </property>
    </bean>
    <!-- SqlMapCllient -->
    <bean id="sqlMapClient" 
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
        <property name="configLocation">
            <value>classpath:sql-map-config-firebird.xml</value>
        </property>
    </bean>
    <!-- клас доступу до шару [dao] -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
    <!-- менеджер транзакцій -->
    <bean id="transactionManager" 
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
    </bean>
    <!-- класи доступу до рівня [service] -->
    <bean id="service" 
        class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
        <property name="transactionManager">
            <ref local="transactionManager"/>
        </property>
        <property name="target">
            <bean class="istia.st.mvc.personnes.service.ServiceImpl">
                <property name="dao">
                    <ref local="dao"/>
                </property>
            </bean>
        </property>
        <property name="transactionAttributes">
            <props>
                <prop key="get*">PROPAGATION_SUPPORTS,readOnly</prop>
                <prop key="save*">PROPAGATION_REQUIRED</prop>
                <prop key="delete*">PROPAGATION_REQUIRED</prop>
            </props>
        </property>
    </bean>
</beans>
  • рядок 12: URL-адреса бази даних Firebird. Ми продовжуємо використовувати базу, яка використовувалася для тестування шарів [dao] та [service]

Розгортаємо веб-проект [mvc-personnes-03B] у Tomcat:

Ми готові до тестування . Запускається Firebird SGBD. Вміст таблиці [PERSONNES] на цей момент є таким:

Image

Потім запускається Tomcat. У браузері ми вводимо URL-адресу [http://localhost:8080/mvc-personnes-03B]:

Image

Ми додаємо нову особу за посиланням [Ajout]:

Перевіряємо, чи додано запис до бази даних:

Image

Користувачеві пропонується провести інші тести [modification, suppression].

Тепер проведемо тест на конфлікти версій, який виконувався у версії 1. [Firefox] буде браузером користувача U1. Останній запитує URL-адресу [http://localhost:8080/mvc-personnes-03B]:

Image

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

Image

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

Image

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

Image

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

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

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

Image

Він знаходить особу [Perrichon] у тому вигляді, в якому її змінив U1 (ім’я перетворено на великі літери).

А що ж з базою даних у всьому цьому? Давайте подивимося:

Image

Ім’я особи № 899 дійсно написано великими літерами внаслідок зміни, внесеної U1.

17.8. Conclusion

Нагадаємо, що ми хотіли зробити. У нас був веб-додаток із такою трирівневою архітектурою:

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

  • версія 3: SGBD працює з Postgres
  • версія 4: SGBD — це MySQL
  • версія 5: SGBD — це SQL Server Express 2005

Зміни внесені в таких місцях:

  • клас [DaoImplFirebird] реалізує функції шару [dao], пов’язані з SGBD Firebird. Якщо така потреба збережеться, його буде замінено відповідно на класи [DaoImplPostgres], [DaoImplMySQL] та [DaoImplSqlExpress].
  • Файл маппінгу [personnes-firebird.xml] з iBATIS для SGBD Firebird буде замінено відповідно на файли маппінгу [personnes-postgres.xml], [personnes-mysql.xml] та [personnes-sqlexpress.xml].
  • Конфігурація об’єкта [DataSource] шару [dao] є специфічною для конкретного SGBD. Тому вона змінюватиметься з кожною версією.
  • драйвер JDBC для SGBD також змінюється з кожною версією

Окрім цих моментів, все інше залишається без змін. Далі ми опишемо ці нові версії, зосередившись виключно на нововведеннях, які приносить кожна з них.