6. Трирівневі архітектури
6.1. Introduction
Повернемося до останньої версії програми для розрахунку податків:
using System;
namespace Chap3 {
class Program {
static void Main() {
// інтерактивна програма для розрахунку податку
// користувач вводить три значення з клавіатури: одружений nbEnfants зарплата
// після чого програма відображає суму податку до сплати
...
// створення об’єкта IImpot
IImpot impot = null;
try {
// створення об’єкта IImpot
impot = new FileImpot("DataImpotInvalide.txt");
} catch (FileImpotException e) {
// виведення повідомлення про помилку
...
// зупинка програми
Environment.Exit(1);
}
// нескінченний цикл
while (true) {
// запитуються параметри розрахунку податку
Console.Write("Paramètres du calcul de l'Impot au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :");
string paramètres = Console.ReadLine().Trim();
...
// параметри правильні — виконується розрахунок податку
Console.WriteLine("Impot=" + impot.calculer(marié == "o", nbEnfants, salaire) + " euros");
// наступний платник податків
}//while
}
}
}
Попереднє рішення включає класичні операції в програмуванні:
- отримання даних, збережених у файлах, базах даних тощо (рядки 12–21)
- взаємодію з користувачем, рядки 26 (введення даних) та 29 (відображення)
- використання бізнес-алгоритму, рядок 29
Практика показала, що виділення цих різних операцій в окремі класи покращує зручність обслуговування додатків. Архітектура додатка, структурованого таким чином, виглядає наступним чином:
![]() |
Цю архітектуру називають «трирівневою архітектурою», що є перекладом з англійської «three-tier architecture». Термін «трирівнева» зазвичай позначає архітектуру, в якій кожен рівень розташований на окремій машині. Коли рівні знаходяться на одній машині, архітектура стає «тришаровою».
- Рівень [metier] містить бізнес-правила додатка. Для нашого додатка з розрахунку податків це правила, що дозволяють обчислити податок платника податків. Цей рівень потребує даних для роботи:
- податкові шкали — дані, що змінюються щороку
- кількість дітей, сімейний стан та річний дохід платника податків
На наведеному вище схематичному зображенні дані можуть надходити з двох джерел:
- з рівня доступу до даних або [dao] (DAO = Data Access Object) для даних, що вже записані у файлах або базах даних. У даному випадку це можуть бути податкові шкали, як це було зроблено в попередній версії програми.
- шар інтерфейсу з користувачем або [ui] (UI = User Interface) для даних, що вводяться користувачем або відображаються йому. У даному випадку це може стосуватися кількості дітей, сімейного стану та річного заробітку платника податків
- загалом, рівень [dao] відповідає за доступ до постійних (файли, бази даних) або тимчасових (мережа, датчики тощо) даних.
- Рівень [ui], у свою чергу, відповідає за взаємодію з користувачем, якщо такий є.
- Ці три рівні є незалежними завдяки використанню інтерфейсів.
Ми повернемося до додатка [Impots], який ми вже неодноразово розглядали, щоб надати йому трирівневу архітектуру. Для цього ми розглянемо шари [ui, metier, dao] по черзі, починаючи з шару [dao], який відповідає за постійні дані.
Перед цим нам потрібно визначити інтерфейси різних рівнів додатка [Impots].
6.2. Інтерфейси додатка [Impots]
Нагадаємо, що інтерфейс визначає набір сигнатур методів. Класи, що реалізують інтерфейс, наповнюють ці методи конкретним змістом.
Повернемося до трирівневої архітектури нашого додатка:
![]() |
У такій архітектурі ініціативу часто бере на себе користувач. Він надсилає запит у [1] і отримує відповідь у [8]. Це називається циклом «запит — відповідь». Розглянемо приклад розрахунку податку для платника податків. Цей процес потребуватиме кількох етапів:
- рівень [ui] повинен буде запитати у користувача кількість дітей, сімейний стан та річний заробіток. Це операція [1], описана вище.
- Після цього шар [ui] звернеться до бізнес-шару з проханням розрахувати податок. Для цього він передасть йому дані, отримані від користувача. Це операція [2].
- Рівень [metier] потребує певної інформації для виконання своєї роботи: податкових шкал. Він запитає цю інформацію у рівня [dao] за шляхом [3, 4, 5, 6]. [3] — це початковий запит, а [6] — відповідь на цей запит.
- Отримавши всі необхідні дані, шар [metier] обчислює податок.
- Рівень [metier] тепер може відповісти на запит рівня [ui], зроблений у пункті (b). Це шлях [7].
- Рівень [ui] оформить ці результати, а потім представить їх користувачеві. Це шлях [8].
- Можна уявити, що користувач проводить симуляції оподаткування і хоче зберегти їх. Для цього він використає шлях [1-8].
З цього опису видно, що кожен рівень використовує ресурси рівня, розташованого праворуч від нього, але ніколи — рівня, розташованого ліворуч. Розглянемо два суміжні рівні:
![]() |
Рівень [A] надсилає запити до рівня [B]. У найпростіших випадках рівень реалізується одним класом. Додаток з часом розвивається. Таким чином, рівень [B] може мати різні класи реалізації, наприклад [B1, B2, ...]. Якщо рівень [B] є рівнем [dao], то він може мати першу реалізацію [B1], яка отримує дані з файлу. Через кілька років може виникнути потреба розмістити дані в базі даних. Тоді буде створено другий клас реалізації [B2]. Якщо в початковому додатку рівень [A] працював безпосередньо з класом [B1], доводиться частково переписати код рівня [A]. Припустимо, наприклад, що в шарі [A] було написано щось на зразок:
- рядок 1: створюється екземпляр класу [B1]
- рядок 3: від цього екземпляра запитуються дані
Якщо припустити, що новий клас реалізації [B2] використовує методи з тією самою сигнатурою, що й клас [B1], то доведеться замінити всі [B1] на [B2]. Це дуже сприятливий і досить малоймовірний випадок, якщо не приділяли уваги цим сигнатурам методів. На практиці часто трапляється, що класи [B1] та [B2] мають різні сигнатури методів, і тому значну частину шару [A] доводиться повністю переписати.
Ситуацію можна покращити, якщо ввести інтерфейс між шарами [A] та [B]. Це означає, що в інтерфейсі фіксуються сигнатури методів, які рівень [B] надає рівню [A]. Тоді попередня схема набуває такого вигляду:
![]() |
Рівень [A] відтепер звертається не безпосередньо до рівня [B], а до його інтерфейсу [IB]. Таким чином, у коді рівня [A] клас реалізації [Bi] шару [B] з’являється лише один раз — під час реалізації інтерфейсу [IB]. Отже, у коді використовується саме інтерфейс [IB], а не його клас реалізації. Попередній код перетворюється на такий:
- рядок 1: екземпляр [ib], що реалізує інтерфейс [IB], створюється шляхом інстанціювання класу [B1]
- рядок 3: з екземпляра [ib] запитуються дані
Відтепер, якщо замінити реалізацію [B1] шару [B] на реалізацію [B2], і якщо обидві ці реалізації дотримуються одного й того самого інтерфейсу [IB], то змінити потрібно лише рядок 1 шару [A], і більше ніщо. Це велика перевага, яка сама по собі виправдовує систематичне використання інтерфейсів між двома шарами.
Можна піти ще далі й зробити шар [A] повністю незалежним від шару [B]. У наведеному вище коді рядок 1 створює проблему, оскільки містить жорстке посилання на клас [B1]. Ідеально було б, щоб рівень [A] міг мати реалізацію інтерфейсу [IB] без необхідності вказувати ім’я класу. Це відповідало б нашій схемі, наведеній вище. З неї видно, що рівень [A] звертається до інтерфейсу [IB], і не зрозуміло, навіщо йому знати ім’я класу, що реалізує цей інтерфейс. Ця деталь не є корисною для рівня [A].
Фреймворк Spring (http://www.springframework.org) дозволяє досягти такого результату. Попередня архітектура змінюється наступним чином:
![]() |
Трансверсальний рівень [Spring] дозволить одному рівню отримати за допомогою конфігурації посилання на рівень, розташований праворуч від нього, без необхідності знати ім’я класу, що реалізує цей рівень. Ця назва буде міститися у файлах конфігурації, а не у коді C#. Код C# шару [A] тоді набуває такого вигляду:
- рядок 1: екземпляр [ib], що реалізує інтерфейс [IB] шару [B]. Цей екземпляр створюється Spring на основі інформації, знайденої у файлі конфігурації. Spring подбає про створення:
- екземпляр [b], що реалізує рівень [B]
- екземпляр [a], що реалізує шар [A]. Цей екземпляр буде ініціалізовано. Поле [ib], зазначене вище, отримає як значення посилання [b] на об’єкт, що реалізує шар [B]
- рядок 3: запитуються дані з екземпляра [ib]
Тепер видно, що клас реалізації [B1] шару B ніде не з’являється в коді шару [A]. Коли реалізація [B1] буде замінена на нову реалізацію [B2], у коді класу [A] нічого не зміниться. Просто змінять конфігураційні файли Spring, щоб створити екземпляр [B2] замість [B1].
Поєднання Spring та інтерфейсів C# забезпечує суттєве поліпшення обслуговування додатків, роблячи їхні шари взаємонезалежними. Саме це рішення ми використаємо для нової версії додатка [Impots].
Повернемося до трирівневої архітектури нашого додатка:
![]() |
У простих випадках можна почати з шару [metier], щоб виявити інтерфейси додатка. Для роботи йому потрібні дані:
- які вже є у файлах, базах даних або через мережу. Їх надає рівень [dao].
- ще недоступні. У такому разі їх надає рівень [ui], який отримує їх від користувача додатка.
Який інтерфейс повинен надавати рівень [dao] рівню [metier]? Які можливі взаємодії між цими двома рівнями? Рівень [dao] повинен надавати наступні дані рівню [metier]:
- діапазони оподаткування
У нашому додатку рівень [dao] використовує наявні дані, але не створює нових. Визначення інтерфейсу рівня [dao] може бути таким:
using Entites;
namespace Dao {
public interface IImpotDao {
// податкові ставки
TrancheImpot[] TranchesImpot{get;}
}
}
- рядок 3: шар [dao] буде розміщений у просторі імен [Dao]
- рядок 6: інтерфейс IImpotDao визначає властивість TranchesImpot, яка надаватиме податкові шкали шару [métier].
- рядок 1: імпортує простір імен, у якому визначено структуру TrancheImpot:
namespace Entites {
// податкова ставка
public struct TrancheImpot {
public decimal Limite { get; set; }
public decimal CoeffR { get; set; }
public decimal CoeffN { get; set; }
}
}
Повернемося до трирівневої архітектури нашого додатка:
![]() |
Який інтерфейс повинен надавати рівень [metier] рівню [ui]? Нагадаємо про взаємодію між цими двома рівнями:
- шар [ui] запитує у користувача кількість дітей, сімейний стан та річний дохід. Це операція [1], описана вище.
- Після цього шар [ui] звернеться до бізнес-шару з проханням розрахувати кількість місць. Для цього він передасть йому дані, отримані від користувача. Це операція [2].
Визначення інтерфейсу шару [metier] може виглядати наступним чином:
namespace Metier {
interface IImpotMetier {
int CalculerImpot(bool marié, int nbEnfants, int salaire);
}
}
- рядок 1: все, що стосується шару [metier], буде розміщено в просторі імен [Metier].
- рядок 2: інтерфейс IImpotMetier визначає лише один метод — той, що дозволяє обчислити податок платника податків на основі його сімейного стану, кількості дітей та річного заробітку.
Ми розглядаємо першу реалізацію цієї багаторівневої архітектури.
6.3. Приклад програми — версія 4
6.3.1. Проєкт Visual Studio
Проєкт Visual Studio матиме такий вигляд:
![]() |
- [1]: папка [Entites] містить об’єкти, спільні для всіх рівнів [ui, metier, dao]: структура TrancheImpot, виняток FileImpotException.
- [2]: папка [Dao] містить класи та інтерфейси шару [dao]. Ми будемо використовувати дві реалізації інтерфейсу IImpotDao: клас HardwiredImpot, розглянутий у розділі 4.10, та FileImpot, розглянутий у розділі 5.8.
- [3]: папка [Metier] містить класи та інтерфейси рівня [metier]
- [4]: папка [Ui] містить класи шару [ui]
- [5]: файл [DataImpot.txt] містить податкові шкали, що використовуються реалізацією FileImpot шару [dao]. [6] налаштовано так, щоб його автоматично копіювали до папки виконання проєкту.
6.3.2. Елементи додатка
Повернемося до трирівневої архітектури нашого додатка:
![]() |
Класи, що перетинають рівні, ми називаємо entités. Зазвичай це класи та структури, що інкапсулюють дані рівня [dao]. Ці об’єкти зазвичай простягаються аж до рівня [ui].
Елементи додатка такі:
Структура TrancheImpot
namespace Entites {
// податкова ставка
public struct TrancheImpot {
public decimal Limite { get; set; }
public decimal CoeffR { get; set; }
public decimal CoeffN { get; set; }
}
}
L' Виняток FileImpotException
using System;
namespace Entites {
public class FileImpotException : Exception {
// коди помилок
[Flags]
public enum CodeErreurs { Acces = 1, Ligne = 2, Champ1 = 4, Champ2 = 8, Champ3 = 16 };
// код помилки
public CodeErreurs Code { get; set; }
// виробники
public FileImpotException() {
}
public FileImpotException(string message)
: base(message) {
}
public FileImpotException(string message, Exception e)
: base(message, e) {
}
}
}
Примітка: клас FileImpotException є корисним лише в тому випадку, якщо рівень [dao] реалізовано класом FileImpot.
6.3.3. Рівень [dao]
![]() |
Нагадаємо інтерфейс шару [dao]:
using Entites;
namespace Dao {
public interface IImpotDao {
// податкові шкали
TrancheImpot[] TranchesImpot{get;}
}
}
Ми реалізуємо цей інтерфейс двома різними способами.
По-перше, за допомогою класу HardwiredImpot, розглянутого в розділі 4.10:
using System;
using Entites;
namespace Dao {
public class HardwiredImpot : IImpotDao {
// таблиці даних, необхідні для розрахунку податку
decimal[] limites = { 4962M, 8382M, 14753M, 23888M, 38868M, 47932M, 0M };
decimal[] coeffR = { 0M, 0.068M, 0.191M, 0.283M, 0.374M, 0.426M, 0.481M };
decimal[] coeffN = { 0M, 291.09M, 1322.92M, 2668.39M, 4846.98M, 6883.66M, 9505.54M };
// податкові шкали
public TrancheImpot[] TranchesImpot { get; private set; }
// конструктор
public HardwiredImpot() {
// створення таблиці податкових шкал
TranchesImpot = new TrancheImpot[limites.Length];
// заповнення
for (int i = 0; i < TranchesImpot.Length; i++) {
TranchesImpot[i] = new TrancheImpot { Limite = limites[i], CoeffR = coeffR[i], CoeffN = coeffN[i] };
}
}
}// клас
}// простір імен
- рядок 5: клас HardwiredImpot реалізує інтерфейс IImpotDao
- рядок 12: реалізація властивості TranchesImpot інтерфейсу IImpotDao. Ця властивість є автоматичною. Вона реалізує метод get властивості TranchesImpot інтерфейсу IImpotDao. Крім того, було оголошено метод set приватним, тобто внутрішнім для класу, щоб конструктор у рядках 15–22 міг ініціалізувати масив податкових шкал.
Інтерфейс IImpotDao також буде реалізовано класом FileImpot, розглянутим у розділі 5.8:
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Entites;
namespace Dao {
class FileImpot : IImpotDao {
// файл даних
public string FileName { get; set; }
// податкові шкали
public TrancheImpot[] TranchesImpot { get; private set; }
// конструктор
public FileImpot(string fileName) {
// запам'ятовується ім'я файлу
FileName = fileName;
// дані
List<TrancheImpot> listTranchesImpot = new List<TrancheImpot>();
int numLigne = 1;
// виняток
FileImpotException fe = null;
// читання вмісту файлу fileName, рядок за рядком
Regex pattern = new Regex(@"s*:\s*");
// спочатку помилок немає
FileImpotException.CodeErreurs code = 0;
try {
using (StreamReader input = new StreamReader(FileName)) {
while (!input.EndOfStream && code == 0) {
// поточний рядок
string ligne = input.ReadLine().Trim();
// порожні рядки ігноруються
if (ligne == "")
continue;
// рядок розбитий на три поля, розділені:
string[] champsLigne = pattern.Split(ligne);
// чи є 3 поля?
if (champsLigne.Length != 3) {
code = FileImpotException.CodeErreurs.Ligne;
}
// перетворення 3 полів
decimal limite = 0, coeffR = 0, coeffN = 0;
if (code == 0) {
if (!Decimal.TryParse(champsLigne[0], out limite))
code = FileImpotException.CodeErreurs.Champ1;
if (!Decimal.TryParse(champsLigne[1], out coeffR))
code |= FileImpotException.CodeErreurs.Champ2;
if (!Decimal.TryParse(champsLigne[2], out coeffN))
code |= FileImpotException.CodeErreurs.Champ3;
;
}
// помилка?
if (code != 0) {
// фіксуємо помилку
fe = new FileImpotException(String.Format("Ligne n° {0} incorrecte", numLigne)) { Code = code };
} else {
// запам'ятовується новий податковий діапазон
listTranchesImpot.Add(new TrancheImpot() { Limite = limite, CoeffR = coeffR, CoeffN = coeffN });
// наступний рядок
numLigne++;
}
}
}
} catch (Exception e) {
// помічено помилку
fe = new FileImpotException(String.Format("Erreur lors de la lecture du fichier {0}", FileName), e) { Code = FileImpotException.CodeErreurs.Acces };
}
// чи слід повідомити про помилку?
if (fe != null) {
// генерується виняток
throw fe;
} else {
// повертаємо список listImpot у масив tranchesImpot
TranchesImpot = listTranchesImpot.ToArray();
}
}
}
}
- цей код вже розглядався у розділі 5.8.
- рядок 14: метод TranchesImpot інтерфейсу IImpotDao
- рядок 76: ініціалізація податкових шкал у конструкторі класу на основі файлу, ім’я якого конструктор отримав у рядку 17.
6.3.4. Шар [metier]
![]() |
Нагадаємо інтерфейс цього шару:
namespace Metier {
public interface IImpotMetier {
int CalculerImpot(bool marié, int nbEnfants, int salaire);
}
}
Реалізація ImpotMetier цього інтерфейсу виглядає наступним чином:
using Entites;
using Dao;
namespace Metier {
public class ImpotMetier : IImpotMetier {
// шар [dao]
private IImpotDao Dao { get; set; }
// податкові шкали
private TrancheImpot[] tranchesImpot;
// забудовник
public ImpotMetier(IImpotDao dao) {
// запам'ятовування
Dao = dao;
// податкові шкали
tranchesImpot = dao.TranchesImpot;
}
// розрахунок податку
public int CalculerImpot(bool marié, int nbEnfants, int salaire) {
// розрахунок кількості часток
decimal nbParts;
if (marié)
nbParts = (decimal)nbEnfants / 2 + 2;
else
nbParts = (decimal)nbEnfants / 2 + 1;
if (nbEnfants >= 3)
nbParts += 0.5M;
// розрахунок оподатковуваного доходу та сімейного коефіцієнта
decimal revenu = 0.72M * salaire;
decimal QF = revenu / nbParts;
// розрахунок податку
tranchesImpot[tranchesImpot.Length - 1].Limite = QF + 1;
int i = 0;
while (QF > tranchesImpot[i].Limite)
i++;
// повернення результату
return (int)(revenu * tranchesImpot[i].CoeffR - nbParts * tranchesImpot[i].CoeffN);
}//розрахувати
}// клас
}
- рядок 5: клас [Metier] реалізує інтерфейс [IImpotMetier].
- рядки 14–19: рівень [metier] повинен взаємодіяти з рівнем [dao]. Тому він повинен мати посилання на об’єкт, що реалізує інтерфейс IImpotDao. Саме тому це посилання передається як параметр конструктору.
- рядок 16: посилання на шар [dao] зберігається у приватному полі рядка 8
- рядок 18: на основі цього посилання конструктор запитує таблицю податкових шкал і зберігає посилання на неї у приватному полі рядка 8.
- рядки 22–41: реалізація методу CalculerImpot інтерфейсу IImpotMetier. Ця реалізація використовує таблицю податкових шкал, ініціалізовану конструктором.
6.3.5. Рівень [ui]
![]() |
Класи взаємодії з користувачем у версіях 2 та 3 були дуже схожими. У версії 2 він мав такий вигляд:
using System;
namespace Chap2 {
public class Program {
static void Main() {
...
// створення об’єкта IImpot
IImpot impot = new HardwiredImpot();
// нескінченний цикл
while (true) {
...
}//while
}
}
}
а у версії 3 — таким:
using System;
namespace Chap3 {
public class Program {
static void Main() {
...
// створення об’єкта IImpot
IImpot impot = null;
try {
// створення об'єкта IImpot
impot = new FileImpot("DataImpotInvalide.txt");
} catch (FileImpotException e) {
// виведення повідомлення про помилку
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// зупинка програми
Environment.Exit(1);
}
// нескінченний цикл
while (true) {
...
}//while
}
}
}
Змінюється лише спосіб створення екземпляра об’єкта типу IImpot, який дозволяє розрахувати податок. Цей об’єкт тут відповідає нашому шару [métier].
Для реалізації [dao] із класом HardwiredImpot клас діалогового вікна виглядає так:
using System;
using Metier;
using Dao;
using Entites;
namespace Ui {
public class Dialogue2 {
static void Main() {
...
// створення шарів [metier et dao]
IImpotMetier metier = new ImpotMetier(new HardwiredImpot());
// нескінченний цикл
while (true) {
...
// параметри правильні — обчислюємо податок
Console.WriteLine("Impot=" + metier.CalculerImpot(marié == "o", nbEnfants, salaire) + " euros");
// наступний платник податків
}//while
}
}
}
- рядок 12: створення екземплярів шарів [dao] та [metier]. Нагадаємо, що шар [metier] потребує шару [dao].
- рядок 18: використання шару [metier] для розрахунку податку
Для реалізації [dao] із класом FileImpot клас діалогового вікна має такий вигляд:
using System;
using Metier;
using Dao;
using Entites;
namespace Ui {
public class Dialogue {
static void Main() {
...
// створюються шари [metier et dao]
IImpotMetier metier = null;
try {
// створення шару [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (FileImpotException e) {
// виведення повідомлення про помилку
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// зупинка програми
Environment.Exit(1);
}
// нескінченний цикл
while (true) {
...
// параметри правильні — розраховується податок
Console.WriteLine("Impot=" + metier.CalculerImpot(marié == "o", nbEnfants, salaire) + " euros");
// наступний платник податків
}//while
}
}
}
- рядки 11–21: створення екземплярів шарів [dao] та [metier]. Оскільки створення екземпляра шару [dao] може спричинити виняток, його обробка здійснюється
- рядок 26: використання шару [metier] для розрахунку податку, як і в попередній версії
6.3.6. Висновок
Багатошарова архітектура та використання інтерфейсів забезпечили певну гнучкість нашому додатку. Це особливо помітно у тому, як шар [ui] створює екземпляри шарів [dao] та [métier]:
// створюються шари [metier et dao]
IImpotMetier metier = new ImpotMetier(new HardwiredImpot());
в одному випадку та:
// створюються шари [metier et dao]
IImpotMetier metier = null;
try {
// створення шару [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (FileImpotException e) {
// виведення повідомлення про помилку
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// зупинка програми
Environment.Exit(1);
}
в іншому. За винятком обробки винятку у випадку 2, інстанціювання шарів [dao] та [metier] є подібним в обох додатках. Після створення екземплярів шарів [dao] та [metier] код шару [ui] є однаковим в обох випадках. Це пов’язано з тим, що з шаром [métier] працюють через його інтерфейс IImpotMetier, а не через клас його реалізації. Зміна шару [metier] або шару [dao] у додатку без зміни їхніх інтерфейсів завжди зводитиметься лише до зміни попередніх рядків у шарі [ui].
Ще одним прикладом гнучкості, яку забезпечує ця архітектура, є реалізація шару [métier]:
using Entites;
using Dao;
namespace Metier {
public class ImpotMetier : IImpotMetier {
// шар [dao]
private IImpotDao Dao { get; set; }
// податкові шкали
private TrancheImpot[] tranchesImpot;
// виробник
public ImpotMetier(IImpotDao dao) {
// збереження
Dao = dao;
// податкові шкали
tranchesImpot = dao.TranchesImpot;
}
// розрахунок податку
public int CalculerImpot(bool marié, int nbEnfants, int salaire) {
...
}//розрахувати
}//клас
}
У рядку 14 видно, що шар [métier] побудовано на основі посилання на інтерфейс шару [dao]. Отже, зміна реалізації останнього жодним чином не впливає на рівень [métier]. Саме тому наша єдина реалізація рівня [métier] могла працювати без змін із двома різними реалізаціями рівня [dao].
6.4. Приклад застосування — , версія 5
![]() |
Ця нова версія базується на попередній, але містить такі зміни:
- шари [métier] та [dao] інкапсульовані в DLL та протестовані за допомогою фреймворку для модульних тестів NUnit.
- інтеграція шарів забезпечується фреймворком Spring
У великих проектах над одним проектом працюють кілька розробників. Багатошарові архітектури полегшують такий режим роботи: оскільки шари взаємодіють між собою за допомогою чітко визначених інтерфейсів, розробник, який працює над одним шаром, не повинен турбуватися про роботу інших розробників над іншими шарами. Достатньо лише, щоб усі дотримувалися інтерфейсів.
У наведеному вище прикладі розробнику шару [métier] під час тестування свого шару знадобиться реалізація шару [dao]. Доки ця реалізація не буде завершена, він може використовувати фіктивну реалізацію шару [dao], якщо вона відповідає інтерфейсу IImpotDao. Це також є перевагою багаторівневої архітектури: затримка на рівні [dao] не заважає тестуванню рівня [métier]. Фіктивна реалізація шару [dao] також має ту перевагу, що її часто простіше реалізувати, ніж справжній шар [dao], який може вимагати запуску SGBD, наявності мережевих з’єднань тощо.
Коли рівень [dao] буде завершено та протестовано, його нададуть розробникам рівня [métier] у вигляді DLL, а не у вигляді вихідного коду. У підсумку додаток часто поставляється у вигляді виконуваного файлу .exe (з рівня [ui]) та бібліотек класів .dll (інші рівні).
6.4.1. NUnit
Тести, проведені до цього часу для наших різних додатків, ґрунтувалися на візуальній перевірці. Ми перевіряли, чи на екрані відображається те, що очікується. Цей метод непридатний, коли потрібно провести велику кількість тестів. Адже людина схильна до втоми, і її здатність перевіряти тести знижується протягом дня. Тому тести мають бути автоматизовані й спрямовані на те, щоб не потребувати жодного втручання людини.
Додаток розвивається з плином часу. Під час кожного оновлення необхідно перевіряти, чи додаток не «регресує», c.a.d, чи він і надалі проходить тести на працездатність, які були проведені під час його початкового написання. Ці тести називаються «тестами на відсутність регресії». Для додатка середнього розміру може знадобитися сотні тестів. Фактично тестується кожен метод кожного класу додатка. Це називається модульними тестами. Вони можуть задіяти багато розробників, якщо не були автоматизовані.
Для автоматизації тестування було розроблено спеціальні інструменти. Один із них називається NUnit. Він доступний на сайті [http://www.nunit.org]:
![]() | ![]() |
Для цього документа (березень 2008 року) використовувалася саме версія 2.4.6, зазначена вище. Під час інсталяції на робочому столі з’являється піктограма [1]:
![]() |
Подвійне клацання на піктограмі [1] запускає графічний інтерфейс NUnit [2]. Це аж ніяк не сприяє автоматизації тестування, оскільки ми знову змушені вдаватися до візуальної перевірки: тестувальник перевіряє результати тестів, що відображаються у графічному інтерфейсі. Проте тести також можна виконувати за допомогою пакетних інструментів, а їхні результати — зберігати у файлах XML. Саме цей метод використовують команди розробників: тести запускаються вночі, а результати розробники отримують наступного ранку.
Розглянемо на прикладі принцип роботи тестів NUnit. Спочатку створимо новий проект C# типу Console Application:
![]() |
У [1] ми бачимо références цього проєкту. Ці посилання — це DLL, що містять класи та інтерфейси, які використовуються проєктом. Ті, що представлені в [1], за замовчуванням включаються в кожен новий проект на C#. Щоб мати змогу використовувати класи та інтерфейси фреймворку NUnit, нам потрібно додати [2] нове посилання до проекту.
![]() |
На вкладці .NET, показаній вище, ми вибираємо компонент [nunit.framework]. Компоненти [nunit.*], показані вище, не є компонентами, що присутні за замовчуванням у середовищі .NET. Вони потрапили туди внаслідок попередньої інсталяції фреймворку NUnit. Після підтвердження додавання посилання воно з’являється як [4] у списку посилань проєкту.
До генерації додатка папка [bin/Release] у проєкті порожня. Після генерації (F6) можна помітити, що папка [bin/Release] більше не порожня:
![]() |
У [6] видно наявність DLL та [nunit.framework.dll]. Саме додавання посилання [nunit.framework] спричинило копіювання цього файлу DLL у папку виконання. Ця папка є однією з тих, які будуть проскановані CLR (Common Language Runtime) та NET для пошуку класів та інтерфейсів, на які посилається проект.
Створимо перший тестовий клас NUnit. Для цього видалимо клас [Program.cs], згенерований за замовчуванням, а потім додамо до проєкту новий клас [Nunit1.cs]. Також видалимо непотрібні посилання [7].
Тестовий клас NUnit1 матиме такий вигляд:
using System;
using NUnit.Framework;
namespace NUnit {
[TestFixture]
public class NUnit1 {
public NUnit1() {
Console.WriteLine("constructeur");
}
[SetUp]
public void avant() {
Console.WriteLine("Setup");
}
[TearDown]
public void après() {
Console.WriteLine("TearDown");
}
[Test]
public void t1() {
Console.WriteLine("test1");
Assert.AreEqual(1, 1);
}
[Test]
public void t2() {
Console.WriteLine("test2");
Assert.AreEqual(1, 2, "1 n'est pas égal à 2");
}
}
}
- рядок 6: клас NUnit1 має бути публічним. Ключове слово public за замовчуванням не генерується Visual Studio. Його потрібно додати.
- рядок 5: атрибут [TestFixture] є атрибутом NUnit. Він вказує, що клас є тестовим класом.
- рядки 7–9: конструктор. Тут він використовується лише для виведення повідомлення на екран. Ми хочемо бачити, коли він виконується.
- рядок 10: атрибут [SetUp] визначає метод, що виконується перед кожним модульним тестом.
- рядок 14: атрибут [TearDown] визначає метод, що виконується після кожного модульного тесту.
- рядок 18: атрибут [Test] визначає метод тестування. Для кожного методу, позначеного атрибутом [Test], перед тестом буде виконано метод, позначений атрибутом [SetUp], а після тесту — метод, позначений атрибутом [TearDown].
- рядок 21: один із методів [Assert.*], визначених фреймворком NUnit. Існують такі методи [Assert]:
- [Assert.AreEqual(expression1, expression2)]: перевіряє, чи значення двох виразів рівні. Підтримується багато типів виразів (int, string, float, double, decimal тощо). Якщо два вирази не рівні, генерується виняток.
- [Assert.AreEqual(réel1, réel2, delta)]: перевіряє, чи два дійсні числа дорівнюють одне одному з похибкою delta, c.a.d abs(дійсне1-дійсне2)<=delta. Наприклад, можна написати [Assert.AreEqual(réel1, réel2, 1E-6)], щоб перевірити, чи два значення дорівнюють одне одному з похибкою 10⁻⁶.
- [Assert.AreEqual(expression1, expression2, message)] та [Assert.AreEqual(réel1, réel2, delta, message)] — це варіанти, що дозволяють вказати повідомлення про помилку, яке буде пов’язане з винятковим станом, що генерується у разі невдачі методу [Assert.AreEqual].
- [Assert.IsNotNull(object)] та [Assert.IsNotNull(object, message)]: перевіряють, чи об’єкт не дорівнює null.
- [Assert.IsNull(object)] та [Assert.IsNull(object, message)] : перевіряють, чи об’єкт дорівнює null.
- [Assert.IsTrue(expression)] та [Assert.IsTrue(expression, message)]: перевіряє, чи вираз дорівнює true.
- [Assert.IsFalse(expression)] та [Assert.IsFalse(expression, message)]: перевіряє, чи вираз дорівнює false.
- [Assert.AreSame(object1, object2)] та [Assert.AreSame(object1, object2, message)]: перевіряють, чи посилання object1 та object2 вказують на один і той самий об’єкт.
- [Assert.AreNotSame(object1, object2)] та [Assert.AreNotSame(object1, object2, message)]: перевіряє, чи посилання object1 та object2 не вказують на один і той самий об’єкт.
- рядок 21: перевірка повинна пройти успішно
- рядок 26: перевірка має завершитися невдало
Налаштуємо проєкт так, щоб під час його генерації створювався файл DLL замість виконуваного файлу .exe:
![]() |
- у [1]: властивості проєкту
- у [2, 3]: як тип проєкту вибираємо [Class Library] (Бібліотека класів)
- у [4]: у результаті генерації проекту буде створено DLL (асемблі), що називається [Nunit.dll]
Тепер використаємо NUnit для виконання тестового класу:
![]() |
- у [1]: відкриття проекту NUnit
- у [2, 3]: завантажується файл DLL bin/Release/Nunit.dll, створений під час генерації проекту C#
- в [4]: файл DLL завантажено
- в [5]: дерево тестів
- у [6]: вони виконуються
![]() |
- у [7]: результати: t1 пройшов успішно, t2 не пройшов
- в [8]: червона смуга вказує на загальний провал класу тестів
- у [9]: повідомлення про помилку, пов’язане з невдалим тестом
![]() |
- у [11]: різні вкладки вікна результатів
- у [12]: вкладка [Console.Out]. На ній видно, що:
- конструктор було виконано лише один раз
- метод [SetUp] виконувався перед кожним із двох тестів
- метод [TearDown] виконувався після кожного з двох тестів
Можна вказати методи, які потрібно протестувати:
![]() |
- у [1]: вимагається відображення прапорця поруч із кожним тестом
- у [2]: відмічаються тести, які потрібно виконати
- у [3]: вони виконуються
Щоб виправити помилки, достатньо внести зміни до проекту C# та перекомпілювати його. NUnit виявляє, що файл DLL, який він тестує, було змінено, і автоматично завантажує нову версію. Після цього достатньо просто запустити тести знову.
Розглянемо такий новий тестовий клас:
using System;
using NUnit.Framework;
namespace NUnit {
[TestFixture]
public class NUnit2 : AssertionHelper {
public NUnit2() {
Console.WriteLine("constructeur");
}
[SetUp]
public void avant() {
Console.WriteLine("Setup");
}
[TearDown]
public void après() {
Console.WriteLine("TearDown");
}
[Test]
public void t1() {
Console.WriteLine("test1");
Expect(1, EqualTo(1));
}
[Test]
public void t2() {
Console.WriteLine("test2");
Expect(1, EqualTo(2), "1 n'est pas égal à 2");
}
}
}
Починаючи з версії 2.4 NUnit, стала доступною нова синтаксична конструкція, яка використовується у рядках 21 та 26. Для цього тестовий клас повинен походити від класу AssertionHelper (рядок 6).
Відповідність (неповна) між старою та новою синтаксичною структурою така:
Додамо наступний тест до класу NUnit2:
[Test]
public void t3() {
bool vrai = true, faux = false;
Expect(vrai, True);
Expect(faux, False);
Object obj1 = new Object(), obj2 = null, obj3=obj1;
Expect(obj1, Not.Null);
Expect(obj2, Null);
Expect(obj3, SameAs(obj1));
double d1 = 4.1, d2 = 6.4, d3 = d1;
Expect(d1, EqualTo(d3).Within(1e-6));
Expect(d1, Not.EqualTo(d2));
}
Якщо згенерувати (F6) новий файл DLL з проекту C#, проект NUnit набуде такого вигляду:
![]() |
- у [1]: новий тестовий клас [NUnit2] було автоматично виявлено
- в [2]: виконується тест t3 з NUnit2
- в [3]: тест t3 пройшов успішно
Щоб дізнатися більше про NUnit, ознайомтеся з довідкою до NUnit:
![]() | ![]() |
6.4.2. Рішення для Visual Studio
![]() |
Ми поступово створимо таке рішення для Visual Studio:
![]() |
- в [1]: рішення ImpotsV5 складається з трьох проектів, по одному для кожного з трьох рівнів додатка
- в [2]: проект [dao] з рівня [dao]
- у [3]: проект [metier] з рівня [metier]
- в [4]: проект [ui] з рівня [ui]
Рішення ImpotsV5 можна побудувати таким чином:
1 ![]() | 234 ![]() | 5 ![]() |
- у [1]: створити новий проєкт
- у [2]: вибрати консольний додаток
- у [3]: викликати проект [dao]
- в [4]: створити проект
- в [5]: після створення проєкту зберегти його
![]() |
- в [6]: зберегти ім’я [dao] для проекту
- в [7]: вказати папку для збереження проекту та його рішення
- у [8]: надати ім’я рішенню
- у [9]: вказати, що рішення має мати власну папку
- у [10]: зберегти проект та його рішення
- у [11]: проект [dao] у його рішенні ImpotsV5
![]() |
- у [12]: папку рішення ImpotsV5. Вона містить папку [dao] з папки [dao].
- у [13]: вміст папки [dao]
- у [14]: до розв’язку ImpotsV5 додається новий проєкт
![]() |
- у [15]: новий проєкт називається [metier]
- в [16]: рішення з двома проектами
- у [17]: рішення після додавання третього проєкту [ui]
![]() |
- в [18]: папка рішення та папки трьох проектів
- під час запуску рішення за допомогою (Ctrl+F5) виконується активний проект. Те саме відбувається під час генерації (F6) рішення. Назва активного проєкту виділена жирним шрифтом [19] у рішенні.
- у [20]: щоб змінити активний проект у рішенні
- на [21]: проект [metier] тепер є активним проектом у рішенні
6.4.3. Шар [dao]
![]() |
![]() |
Посилання на проект (див. [1] у проекті)
Додаємо посилання [nunit.framework], необхідне для тестування [NUnit]
Об’єкти (див. [2] у проєкті)
Клас [TrancheImpot] — це клас попередніх версій. Клас [FileImpotException] попередньої версії перейменовано на [ImpotException], щоб зробити його більш загальним і не пов’язувати з конкретним шаром [dao]:
using System;
namespace Entites {
public class ImpotException : Exception {
// код помилки
public int Code { get; set; }
// виробники
public ImpotException() {
}
public ImpotException(string message)
: base(message) {
}
public ImpotException(string message, Exception e)
: base(message, e) {
}
}
}
Рівень [dao] (див. [3] у проєкті)
Інтерфейс [IImpotDao] відповідає попередній версії. Те саме стосується класу [HardwiredImpot]. Клас [FileImpot] оновлено з урахуванням зміни винятку [FileImpotException] на [ImpotException]:
...
namespace Dao {
public class FileImpot : IImpotDao {
// коди помилок
[Flags]
public enum CodeErreurs { Acces = 1, Ligne = 2, Champ1 = 4, Champ2 = 8, Champ3 = 16 };
...
// конструктор
public FileImpot(string fileName) {
// запам'ятовується ім'я файлу
FileName = fileName;
...
// спочатку помилки немає
CodeErreurs code = 0;
try {
using (StreamReader input = new StreamReader(FileName)) {
while (!input.EndOfStream && code == 0) {
...
// помилка?
if (code != 0) {
// помилка зафіксована
fe = new ImpotException(String.Format("Ligne n° {0} incorrecte", numLigne)) { Code = (int)code };
} else {
...
}
}
}
} catch (Exception e) {
// записуємо помилку
fe = new ImpotException(String.Format("Erreur lors de la lecture du fichier {0}", FileName), e) { Code = (int)CodeErreurs.Acces };
}
// чи слід повідомити про помилку?
...
}
}
}
- рядок 8: коди помилок, що раніше містилися у класі [FileImpotException], перенесені до класу [FileImpot]. Це, власне, коди помилок, специфічні для цієї реалізації інтерфейсу [IImpotDao].
- рядки 26 і 34: для інкапсуляції помилки використовується клас [ImpotException], а не клас [FileImpotException].
Тест [Test1] (див. [4] у проєкті)
Клас [Test1] лише виводить податкові шкали на екран:
using System;
using Dao;
using Entites;
namespace Tests {
class Test1 {
static void Main() {
// створюється шар [dao]
IImpotDao dao = null;
try {
// створено шар [dao]
dao = new FileImpot("DataImpot.txt");
} catch (ImpotException e) {
// відображення помилки
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// зупинка програми
Environment.Exit(1);
}
// відображення податкових шкал
TrancheImpot[] tranchesImpot = dao.TranchesImpot;
foreach (TrancheImpot t in tranchesImpot) {
Console.WriteLine("{0}:{1}:{2}", t.Limite, t.CoeffR, t.CoeffN);
}
}
}
}
- рядок 13: шар [dao] реалізовано класом [FileImpot]
- рядок 14: обробляється виняток типу [ImpotException], який може виникнути.
Файл [DataImpot.txt], необхідний для тестування, автоматично копіюється у папку виконання проєкту (див. [5] у проєкті). Проєкт [dao] матиме кілька класів, що містять метод [Main]. Тому потрібно явно вказати клас, який слід виконати, коли користувач запускає проєкт за допомогою Ctrl-F5:
![]() |
- у [1]: перейти до властивостей проєкту
- у [2]: вказати, що це консольний додаток
- у [3]: вказати клас, який потрібно виконати
Виконання попереднього класу [Test1] дає такі результати:
4962:0:0
8382:0,068:291,09
14753:0,191:1322,92
23888:0,283:2668,39
38868:0,374:4846,98
47932:0,426:6883,66
0:0,481:9505,54
Тест [Test2] (див. [4] у проєкті)
Клас [Test2] виконує ті самі функції, що й клас [Test1], реалізуючи рівень [dao] за допомогою класу [HardwiredImpot]. Рядок 13 у [Test1] замінюється на такий:
dao = new HardwiredImpot();
Проект змінено таким чином, щоб відтепер виконувався клас [Test2]:
![]() |
Результати на екрані такі самі, як і раніше.
Тест NUnit [NUnit1] (див. [4] у проекті)
Одиничний тест [NUnit1] має такий вигляд:
using System;
using Dao;
using Entites;
using NUnit.Framework;
namespace Tests {
[TestFixture]
public class NUnit1 : AssertionHelper{
// шар [dao] для тестування
private IImpotDao dao;
// розробник
public NUnit1() {
// ініціалізація шару [dao]
dao = new FileImpot("DataImpot.txt");
}
// тест
[Test]
public void ShowTranchesImpot(){
// відображення податкових шкал
TrancheImpot[] tranchesImpot = dao.TranchesImpot;
foreach (TrancheImpot t in tranchesImpot) {
Console.WriteLine("{0}:{1}:{2}", t.Limite, t.CoeffR, t.CoeffN);
}
// деякі тести
Expect(tranchesImpot.Length,EqualTo(7));
Expect(tranchesImpot[2].Limite,EqualTo(14753));
Expect(tranchesImpot[2].CoeffR, EqualTo(0.191));
Expect(tranchesImpot[2].CoeffN, EqualTo(1322.92));
}
}
}
- клас тесту походить від класу [AssertionHelper], що дозволяє використовувати статичний метод Expect (рядки 27–30).
- рядок 10: посилання на шар [dao]
- рядки 13–16: конструктор створює екземпляр шару [dao] із класом [FileImpot]
- рядки 19–20: метод тестування
- рядок 22: отримуємо масив податкових шкал із шару [dao]
- рядки 23–25: їх виводимо на екран, як і раніше. У реальному модульному тесті такого виведення не мало б бути. Тут це зроблено з навчальною метою.
- рядок 27: перевіряємо, чи дійсно є 7 податкових шкал
- рядки 28–30: перевіряємо значення податкової групи № 2
Для виконання цього модульного тесту проект повинен мати тип [Class Library]:
![]() |
- у [1]: тип проекту було змінено
- на [2]: згенерований файл DLL отримає назву [ImpotsV5-dao.dll]
- у [3]: після генерації (F6) проєкту папка [dao/bin/Release] містить DLL та [ImpotsV5-dao.dll]
Потім DLL [ImpotsV5-dao.dll] завантажується у фреймворк NUnit і виконується:
![]() |
- у [1]: тести пройшли успішно. Тепер ми вважаємо рівень [dao] працездатним. Його DLL містить усі класи проєкту, включаючи тестові класи. Останні є непотрібними. Ми перебудовуємо DLL, щоб виключити з нього тестові класи.
- у [2]: папка [tests] виключена з проєкту
- у [3]: новий проєкт. Він перегенерується за допомогою F6 для створення нового DLL.
6.4.4. Шар [metier]
![]() |
![]() |
- у [1], проект [metier] став активним проектом рішення
- у [2]: посилання на проект
- у [3]: рівень [metier]
- у [4]: тестові класи
- у [5]: файл [DataImpot.txt] із податковими шкалами, налаштований [6] для автоматичного копіювання у папку виконання проєкту [7]
Посилання на проект (див. [2] у проекті)
Як і у випадку з проектом [dao], додається посилання [nunit.framework], необхідне для тестів [NUnit]. Рівень [metier] потребує рівня [dao]. Тому йому потрібне посилання на DLL цього рівня. Діємо таким чином:
![]() |
- у [1]: додаємо нове посилання до списку посилань проекту [metier]
- в [2]: вибираємо вкладку [Browse]
- у [3]: вибираємо папку [dao/bin/Release]
- у [4]: вибираємо DLL та [ImpotsV5-dao.dll], створені в проєкті [dao]
- у [5]: новий номер
Шар [metier] (див. [3] у проєкті)
Інтерфейс [IImpotMetier] відповідає попередній версії. Те саме стосується класу [ImpotMetier].
Тест [Test1] (див. [4] у проєкті)
Клас [Test1] виконує лише кілька розрахунків заробітної плати:
using System;
using Dao;
using Entites;
using Metier;
namespace Tests {
class Test1 {
static void Main() {
// створюється шар [metier]
IImpotMetier metier = null;
try {
// створення шару [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (ImpotException e) {
// виведення повідомлення про помилку
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// зупинка програми
Environment.Exit(1);
}
// розраховуються деякі податки
Console.WriteLine(String.Format("Impot(true,2,60000)={0} euros", metier.CalculerImpot(true, 2, 60000)));
Console.WriteLine(String.Format("Impot(false,3,60000)={0} euros", metier.CalculerImpot(false, 3, 60000)));
Console.WriteLine(String.Format("Impot(false,3,60000)={0} euros", metier.CalculerImpot(false, 3, 6000)));
Console.WriteLine(String.Format("Impot(false,3,60000)={0} euros", metier.CalculerImpot(false, 3, 600000)));
}
}
}
- рядок 14: створення шарів [metier] та [dao]. Шар [dao] реалізовано за допомогою класу [FileImpot]
- рядки 12–21: обробка можливого винятку типу [ImpotException]
- рядки 23–26: повторні виклики єдиного методу CalculerImpot інтерфейсу [IImpotMetier].
Проєкт [metier] налаштований наступним чином:
![]() |
- [1]: проект є консольним додатком
- [2]: класом, що виконується, є клас [Test1]
- [3]: у результаті генерації проекту буде створено виконуваний файл [ImpotsV5-metier.exe]
Виконання проекту дає такі результати:
Тест [NUnit1] (див. [4] у проєкті)
Клас одиничних тестів [NUnit1] повторює чотири попередні обчислення та перевіряє їхні результати:
using Dao;
using Metier;
using NUnit.Framework;
namespace Tests {
[TestFixture]
public class NUnit1:AssertionHelper {
// шар [metier] для тестування
private IImpotMetier metier;
// виробник
public NUnit1() {
// ініціалізація шару [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
}
// тест
[Test]
public void CalculsImpot(){
// виводимо податкові діапазони
Expect(metier.CalculerImpot(true, 2, 60000), EqualTo(4282));
Expect(metier.CalculerImpot(false, 3, 60000), EqualTo(4282));
Expect(metier.CalculerImpot(false, 3, 6000), EqualTo(0));
Expect(metier.CalculerImpot(false, 3, 600000), EqualTo(179275));
}
}
}
- рядок 14: створення шарів [metier] та [dao]. Шар [dao] реалізовано за допомогою класу [FileImpot]
- рядки 21–24: повторні виклики єдиного методу CalculerImpot інтерфейсу [IImpotMetier] з перевіркою результатів.
Проєкт [metier] тепер налаштований наступним чином:
![]() |
- [1]: проект типу «бібліотека класів»
- [2]: у результаті генерації проекту буде створено DLL [ImpotsV5-metier.dll]
Проєкт згенеровано (F6). Потім згенеровані файли DLL, [ImpotsV5-metier.dll] та завантажуються у NUnit і тестуються:
![]() |
Як бачимо вище, тестування пройшло успішно. Тепер ми вважаємо шар [metier] працездатним. Його DLL містить усі класи проекту, включаючи тестові класи. Останні є непотрібними. Ми перебудовуємо DLL, щоб виключити з нього тестові класи.
![]() |
- у [1]: папка [tests] виключена з проєкту
- у [2]: новий проєкт. Він перегенерується з F6 для створення нового DLL.
6.4.5. Шар [ui]
![]() |
![]() |
- у [1], проект [ui] став активним проектом рішення
- у [2]: посилання на проект
- у [3]: шар [ui]
- у [4]: файл [DataImpot.txt] з податковими шкалами, налаштований [5] для автоматичного копіювання у папку виконання проекту [6]
Посилання на проект (див. [2] у проекті)
Шар [ui] потребує шарів [metier] та [dao] для виконання розрахунків податків. Тому йому потрібне посилання на DLL цих двох шарів. Діємо так само, як було показано для шару [metier]
Головний клас [Dialogue.cs] (див. [3] у проєкті)
Клас [Dialogue.cs] — це клас попередньої версії.
Тестування
Проєкт [ui] налаштований наступним чином:
![]() |
- [1]: проект є «консольним додатком»
- [2]: у результаті генерації проекту буде створено виконуваний файл [ImpotsV5-ui.exe]
- [3]: клас, який буде виконуватися
Приклад виконання (Ctrl+F5) виглядає так:
Paramètres du calcul de l'Impot au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :o 2 60000
Impot=4282 euros
6.4.6. Шар [Spring]
Повернемося до коду в [Dialogue.cs], який створює шари [dao] та [metier]:
// створення шарів [metier et dao]
IImpotMetier metier = null;
try {
// створення шару [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (ImpotException e) {
// виведення повідомлення про помилку
...
// зупинка програми
Environment.Exit(1);
}
У рядку 5 створюються шари [dao] та [metier] із явним зазначенням класів реалізації обох шарів: FileImpot для шару [dao], ImpotMetier — для шару [metier]. Якщо реалізація одного з шарів здійснюється за допомогою нового класу, рядок 5 буде змінено. Наприклад:
metier = new ImpotMetier(new HardwiredImpot());
Окрім цієї зміни, у роботі додатка нічого не зміниться, оскільки кожен шар взаємодіє з наступним через інтерфейс. Доки останній не змінюється, взаємодія між шарами також залишається незмінною. Фреймворк Spring дозволяє нам піти ще далі у напрямку незалежності шарів, надаючи можливість винести в конфігураційний файл імена класів, що реалізують різні шари. Зміна реалізації шару тоді зводиться до зміни конфігураційного файлу. Це жодним чином не впливає на код додатка.
![]() |
У наведеному вище прикладі шар [ui] вимагатиме від Springстворити екземпляри шарів [dao], [1], [metier] та [2] на основі інформації, що міститься у файлі конфігурації. Потім шар [ui] запитає у Spring [3] посилання на шар [metier]:
// створення шарів [metier et dao]
IImpotMetier metier = null;
try {
// контекст Spring
IApplicationContext ctx = ContextRegistry.GetContext();
// запит на посилання на шар [metier]
metier = (IImpotMetier)ctx.GetObject("metier");
} catch (Exception e1) {
...
}
- рядок 5: інстанціювання шарів [dao] та [metier] за допомогою Spring
- рядок 7: отримуємо посилання на шар [metier]. Зазначимо, що шар [ui] отримав це посилання, не вказавши назву класу, що реалізує шар [metier].
Фреймворк Spring існує у двох версіях: Java та .NET. Версія .NET доступна за адресою (березень 2008 р.) [http://www.springframework.net/]:
![]() |
- на [1]: сайт [Spring.net]
- на [2]: сторінка завантажень
![]() |
- у [3]: завантажити Spring 1.1 (березень 2008 р.)
![]() |
- в [4]: завантажити версію .exe, а потім встановити її
- в [5]: папка, створена під час інсталяції
- у [6]: папка [bin/net/2.0/release] містить файли DLL з пакета Spring для проектів Visual Studio версії 2.0 або вище. Spring — це багатофункціональний фреймворк. Той аспект Spring, який ми будемо використовувати тут для управління інтеграцією рівнів у додатку, називається IoC: «Інверсія контролю» (Inversion of Control) або DI: «Введення залежностей» (Dependence Injection). Spring надає бібліотеки для доступу до баз даних за допомогою NHibernate, а також для створення та використання веб-сервісів, веб-додатків тощо...
- DLL, необхідні для управління інтеграцією рівнів у додатку, — це DLL, [7] та [8].
Ми зберігаємо ці три DLL у папці [lib] нашого проєкту:
![]() |
- [1]: ці три файли DLL розміщені у папці [lib] за допомогою Провідника Windows
- [2]: у проєкті [ui] відображаються всі файли
- [3]: папка [ui/lib] тепер видима. Її додано до проекту
- [4]: папка [ui/lib] є частиною проекту
Створення папки [lib] аж ніяк не є обов’язковим. Посилання можна було б створити безпосередньо на три файли DLL у папці [bin/net/2.0/release] з [Spring.net]. Однак створення папки [lib] дозволяє розробляти додаток на комп’ютері, на якому відсутній файл [Spring.net], що робить його менш залежним від наявного середовища розробки.
Ми додаємо до проєкту [ui] посилання на три нові DLL:
![]() |
- [1]: створюємо посилання на три DLL з папки [lib] [2]
- [3]: три DLL входять до переліку посилань проекту
Повернемося до загального огляду архітектури додатка:
![]() |
На малюнку вище рівень [ui] вимагатиме від Springстворити екземпляри шарів [dao], [1], [metier] та [2] на основі інформації, що міститься у файлі конфігурації. Потім шар [ui] запитає у Spring [3] посилання на шар [metier]. У шарі [ui] це буде реалізовано таким кодом:
// створюються шари [metier et dao]
IImpotMetier metier = null;
try {
// контекст Spring
IApplicationContext ctx = ContextRegistry.GetContext();
// запитується посилання на шар [metier]
metier = (IImpotMetier)ctx.GetObject("metier");
} catch (Exception e1) {
...
}
- рядок 5: інстанціювання шарів [dao] та [metier] за допомогою Spring
- рядок 7: отримуємо посилання на шар [metier].
Наведений вище рядок [5] використовує файл конфігурації [App.config] проекту Visual Studio. У проєкті C# цей файл слугує для налаштування додатка. Отже, [App.config] — це не поняття Spring, а поняття Visual Studio, яке використовує Spring. Spring вміє використовувати й інші конфігураційні файли, окрім [App.config]. Тому представлене тут рішення не є єдиним можливим.
Створимо файл [App.config] за допомогою майстра Visual Studio:
![]() |
- у [1]: додавання нового елемента до проєкту
- у [2]: виберіть «Application Configuration File»
- у [3]: [App.config] — це ім’я цього файлу конфігурації за замовчуванням
- у [4]: файл [App.config] було додано до проєкту
Вміст файлу [App.config] такий:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
</configuration>
[App.config] — це файл XML. Налаштування проєкту здійснюється між тегами <configuration>. Необхідні налаштування для Spring такі:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="spring">
<section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core" />
<section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
</sectionGroup>
</configSections>
<spring>
<context>
<resource uri="config://spring/objects" />
</context>
<objects xmlns="http://www.springframework.net">
<object name="dao" type="Dao.FileImpot, ImpotsV5-dao">
<constructor-arg index="0" value="DataImpot.txt"/>
</object>
<object name="metier" type="Metier.ImpotMetier, ImpotsV5-metier">
<constructor-arg index="0" ref="dao"/>
</object>
</objects>
</spring>
</configuration>
- рядки 11–23: розділ, обмежений тегом <spring>, називається групою розділів <spring>. У [App.config] можна створити скільки завгодно груп розділів.
- Група розділів містить розділи: саме так і є в даному випадку:
- рядки 12–14: розділ <spring/context>
- рядки 15–22: розділ <spring/objects>
- рядки 4–9: область <configSections> визначає список обробників (handlers) груп розділів, присутніх у [App.config].
- рядки 5–8: визначають список обробників розділів групи <spring> (name="spring").
- рядок 6: обробник розділу <context> групи <spring>:
- name: назва керованої секції
- type: ім’я класу, що керує секцією, у форматі NomClasse, NomDLL.
- секція <context> групи <spring> управляється класом [Spring.Context.Support.ContextHandler], який можна знайти в DLL [Spring.Core.dll]
- рядок 7: менеджер розділу <objects> групи <spring>
Рядки 4–9 є стандартними у файлі [App.config] із Spring. Їх просто копіюють з одного проєкту в інший.
- рядки 12–14: визначають розділ <spring/context>.
- рядок 13: тег <resource> призначений для вказівки місця розташування файлу, що визначає класи, які Spring має інстанціювати. Вони можуть бути у файлі [App.config], як у цьому випадку, але можуть також міститися в іншому конфігураційному файлі. Розташування цих класів вказується в атрибуті uri тегу <resource>:
- <resource uri="config://spring/objects> вказує, що список класів, які потрібно інстанціювати, міститься у файлі [App.config] (config:), у розділі //spring/objects, c.a.d, у тезі <objects> теги <spring>.
- <resource uri="file://spring-config.xml"> вказує, що список класів, які потрібно інстанціювати, міститься у файлі [spring-config.xml]. Цей файл слід розмістити в папках виконання (bin/Release або bin/Debug) проекту. Найпростіше розмістити його, як це було зроблено для файлу [DataImpot.txt], у кореневій папці проєкту разом із властивістю [Copy to output directory=always].
Рядки 12–14 є стандартними для файлу [App.config] із Spring. Їх просто копіюють з одного проєкту в інший.
- рядки 15–22: визначають класи, які потрібно інстанціювати. Саме в цій частині здійснюється специфічна конфігурація додатка. Тег <objects> обмежує розділ визначення класів, які потрібно інстанціювати.
- рядки 16–18: визначають клас, який потрібно інстанціювати для шару [dao]
- рядок 16: кожен об’єкт, інстанційований Spring, позначається тегом <object>. Цей тег має атрибут name, який є іменем інстанційованого об’єкта. Саме через нього додаток запитує у Spring посилання: «надай мені посилання на об’єкт, що називається dao». Атрибут type визначає клас, який потрібно інстанціювати, у формі NomClasse, NomDLL. Таким чином, у рядку 16 визначається об’єкт із назвою «dao», екземпляр класу «Dao.FileImpot», який знаходиться у «DLL» «ImpotsV5-dao.dll». Слід зауважити, що вказується повна назва класу (включно з простором імен), а суфікс .dll у назві DLL не вказується.
У Spring клас можна інстанціювати двома способами:
- за допомогою конкретного конструктора, якому передаються параметри: саме це робиться у рядках 16–18.
- через конструктор за замовчуванням без параметрів. У цьому випадку об’єкт ініціалізується за допомогою його публічних властивостей: тег <object> містить підтеги <property> для ініціалізації цих різних властивостей. Прикладу такого випадку тут немає.
- (продовження)
- рядок 16: інстанційований клас — це клас FileImpot. Він має такий конструктор:
public FileImpot(string fileName);
Параметри конструктора визначаються за допомогою тегів <constructor-arg>.
- рядок 17: визначає перший і єдиний параметр конструктора. Атрибут index — це номер параметра конструктора, атрибут value — його значення: <constructor-arg index="i" value="valuei"/>
- рядки 19–21: визначають клас, який потрібно інстанціювати для шару [metier]: клас [Metier.ImpotMetier], що міститься в DLL [ImpotsV5-metier.dll].
- рядок 19: інстанційованим класом є клас ImpotMetier. Він має такий конструктор:
public ImpotMetier(IImpotDao dao);
- (продовження)
- рядок 20: визначає перший і єдиний параметр конструктора. У наведеному вище прикладі параметр dao конструктора є посиланням на об’єкт. У цьому випадку в тезі <constructor-arg> використовується атрибут ref замість атрибута value, який застосовувався для шару [dao]: <constructor-arg index="i" ref="refi"/>. У наведеному вище конструкторі параметр dao представляє екземпляр на рівні [dao]. Цей екземпляр було визначено у рядках 16–18 файлу конфігурації. Отже, у рядку 20:
<constructor-arg index="0" ref="dao"/>
ref="dao" представляє об’єкт Spring «dao», визначений у рядках 16–18.
Підсумовуючи, файл [App.config]:
- створює екземпляр шару [dao] за допомогою класу FileImpot, який отримує як параметр DataImpot.txt (рядки 16–18). Результуючий об’єкт називається «dao»
- створює екземпляр шару [metier] за допомогою класу ImpotMetier, який отримує як параметр попередній об’єкт «dao» (рядки 19–21).
Залишається лише використати цей файл конфігурації Spring у шарі [ui]. Для цього ми дублюємо клас [Dialogue.cs] у [Dialogue2.cs] і робимо останній головним класом проєкту [ui]:
![]() |
- в [1]: копія [Dialogue.cs]
- в [2]: об’єднання
- у [3]: копія [Dialogue.cs]
- в [4]: перейменовано з [Dialogue2.cs]
![]() |
- в [6]: [Dialogue2.cs] стає головним класом проекту [ui].
Наступний код з [Dialogue.cs]:
// створюються шари [metier et dao]
IImpotMetier metier = null;
try {
// створення шару [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (ImpotException e) {
// виведення повідомлення про помилку
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// зупинка програми
Environment.Exit(1);
}
// нескінченний цикл
while (true) {
...
у [Dialogue2.cs] перетворюється на такий:
// створення шарів [metier et dao]
IApplicationContext ctx = null;
try {
// контекст Spring
ctx = ContextRegistry.GetContext();
} catch (Exception e1) {
// виведення повідомлення про помилку
Console.WriteLine("Chaîne des exceptions : \n{0}", "".PadLeft(40, '-'));
Exception e = e1;
while (e != null) {
Console.WriteLine("{0}: {1}", e.GetType().FullName, e.Message);
Console.WriteLine("".PadLeft(40, '-'));
e = e.InnerException;
}
// зупинка програми
Environment.Exit(1);
}
// запит на посилання на шар [metier]
IImpotMetier metier = (IImpotMetier)ctx.GetObject("metier");
// нескінченний цикл
while (true) {
....................................
- рядок 2: IApplicationContext надає доступ до всіх об’єктів, інстанційованих Spring. Цей об’єкт називають контекстом Spring додатка або, простіше кажучи, контекстом додатка. Наразі цей контекст ще не ініціалізовано. Це робить наступний блок try/catch.
- рядок 5: конфігурація Spring у файлі [App.config] зчитується та обробляється. Після цієї операції, якщо не сталося жодного винятку, усі об’єкти з розділу <objects> були інстанційовані:
- об’єкт Spring «dao» є екземпляром на рівні [dao]
- об’єкт Spring «metier» є екземпляром на рівні [metier]
- рядок 19: клас [Dialogue2.cs] потребує посилання на рівні [metier]. Воно запитується у контексті додатка. Об’єкт IApplicationContext надає доступ до об’єктів Spring через їхні імена (атрибут name тегу <object> у конфігурації Spring). Повернуте посилання є посиланням на генеричний тип Object. Необхідно перетипізувати отримане посилання у відповідний тип, у даному випадку — тип інтерфейсу шару [metier]: IImpotMetier.
Якщо все пройшло успішно, після рядка 19 [Dialogue2.cs] має посилання на шар [metier]. Код у рядках 21 і далі належить класу [Dialogue.cs], який ми вже розглядали.
- рядки 6–17: обробка винятку, що виникає, коли обробка конфігураційного файлу Spring не може бути завершена. Причини цього можуть бути різними: неправильний синтаксис самого конфігураційного файлу або неможливість створити екземпляр одного з налаштованих об’єктів. У нашому прикладі останній випадок міг би статися, якби файл DataImpot.txt із рядка 17 файлу [App.config] не було знайдено в папці виконання проєкту.
Виняток, що виникає у рядку 6, є ланцюжком винятків, у якому кожен виняток має дві властивості:
- Message: повідомлення про помилку, пов’язане з винятком
- InnerException: попереднє виключення в ланцюжку виключень
Цикл у рядках 10–14 відображає всі винятки ланцюга у форматі: клас винятку та відповідне повідомлення.
Під час виконання проєкту [ui] із дійсним файлом конфігурації отримуємо звичні результати:
Paramètres du calcul de l'Impot au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :o 2 60000
Impot=4282 euros
При виконанні проекту [ui] із неіснуючим файлом [DataImpotInexistant.txt],
<object name="dao" type="Dao.FileImpot, ImpotsV5-dao">
<constructor-arg index="0" value="DataImpotInexistant.txt"/>
</object>
отримуємо такі результати:
- рядок 17: вихідне виключення типу [FileNotFoundException]
- рядок 15: шар [dao] інкапсулює це виключення в тип [Entites.ImpotException]
- рядок 9: виняток, згенерований Spring, оскільки йому не вдалося створити екземпляр об’єкта з іменем «dao». У процесі створення цього об’єкта раніше виникли ще два винятки: у рядках 11 та 13.
- Оскільки об’єкт «dao» не вдалося створити, не вдалося створити контекст додатка. У цьому полягає суть винятку в рядку 5. Раніше стався ще один виняток — у рядку 7.
- Рядок 3: виняток найвищого рівня, останній у ланцюжку: повідомляється про помилку конфігурації.
З усього цього слід винести, що саме найглибше виключення, в даному випадку те, що в рядку 17, часто є найбільш значущим. Однак слід зазначити, що Spring зберіг повідомлення про помилку з рядка 17, щоб передати його до виключення вищого рівня в рядку 3, щоб визначити першопричину помилки на найвищому рівні.
Spring сам по собі заслуговує на окрему книгу. Тут ми лише побіжно торкнулися цієї теми. Більш детально її можна вивчити за допомогою документа [spring-net-reference.pdf], який знаходиться в папці з інсталяційними файлами Spring:
![]() |
Також можна ознайомитися з [http://tahe.developpez.com/dotnet/springioc] — підручником зі Spring, представленим у контексті VB.NET.






























































