Software Engineering

3. Реалізація простої бізнес-логіки

Бізнес-логіка — найважливіша частина програмного забезпечення. Саме вона є першочерговою метою створення програмного забезпечення. Користувацький інтерфейс системи може бути привабливим, а її база даних може бути неймовірно швидкою та масштабованою. Але якщо програмне забезпечення марне для бізнесу, це не більше ніж дорога демонстрація технологій.

3. Реалізація простої бізнес-логіки

Бізнес-логіка — найважливіша частина програмного забезпечення. Саме вона є першочерговою метою створення програмного забезпечення. Користувацький інтерфейс системи може бути привабливим, а її база даних може бути неймовірно швидкою та масштабованою. Але якщо програмне забезпечення марне для бізнесу, це не більше ніж дорога демонстрація технологій.

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

Почнемо з двох патернів, придатних для вельми простої бізнес-логіки:
  • Транзакційний сценарій (Transaction Script)
  • Активний запис (Active Record)

Транзакційний сценарій

"Організовує бізнес-логіку за процедурами, де кожна процедура обробляє один запит від користувача." — Мартін Фаулер (Martin Fowler)

Опис патерну

Як показано на рисунку загальнодоступний інтерфейс системи можна розглядати як набір бізнес-транзакцій, доступних для виконання споживачами.

Ці транзакції можуть:

  • Витягувати інформацію із системи.
  • Змінювати її.
  • Робити і те, й інше.

Патерн вибудовує бізнес-логіку системи на основі процедур, де кожна процедура реалізує операцію, яку виконує споживач системи через публічний інтерфейс. По суті, публічні операції (інтерфейс) системи використовуються як межі інкапсуляції.


Реалізація

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

Вимога до процедур: Єдиною непорушною вимогою до процедур є їхня транзакційна поведінка. Кожна операція має завершуватися:

  • Успіхом.
  • Невдачею.

Але ніколи не призводити до неприпустимого стану.

Навіть якщо виконання транзакційного сценарію завершується збоєм у найневідповідніший момент, система має залишатися узгодженою:

  • Через відкат усіх змін.
  • Або через виконання компенсувальних дій.

Транзакційна поведінка відображена в назві патерну: транзакційний сценарій.


Приклад реалізації

Нижче наведено приклад транзакційного сценарію, що перетворює файл формату JSON у файл формату XML:

DB.StartTransaction();
var job = DB.LoadNextJob();
var json = LoadFile(job.Source);
var xml = ConvertJsonToXml(json);
WriteFile(job.Destination, xml.ToString());
DB.MarkJobAsCompleted(job);
DB.Commit();

Примітка

Це не так-то просто! Коли я представляю патерн транзакційного сценарію на своїх заняттях із предметно-орієнтованого проектування, мої студенти часто дивуються, а деякі навіть запитують:
"Чи варто на це витрачати наш час? Хіба ми тут не заради складніших моделей і прийомів програмування?"

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

Ба більше, незважаючи на його гадану простоту, у ньому найпростіше помилитися. Безліч виробничих проблем, які я так чи інакше допомагав налагодити і виправити, часто зводилися до неправильної реалізації транзакційної поведінки бізнес-логіки системи.


Відсутність транзакційної поведінки

Елементарний приклад нездатності реалізувати транзакційну поведінку — випуск одразу кількох оновлень без транзакції, що обертає їх. Розглянемо наступний метод, що оновлює запис у таблиці Users і вставляє запис у таблицю VisitsLog:

public class LogVisit
 {
    public void Execute(Guid userid, DateTime visitedOn)
    {
     _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
     visitedOn, userid);
     _db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
     VALUES(@p1, @p2)", userid, visitedOn);
 }
Якщо після оновлення запису в таблиці користувачів (рядок 7), але до успішного додавання запису до журналу в рядку 9 виникне будь-яка проблема, система опиниться в неузгодженому стані. Таблицю Users буде оновлено, але відповідний запис у таблицю VisitsLog записано не буде. Проблема може бути пов'язана з чим завгодно: від збою мережі і до тайм-ауту або взаємоблокування в базі даних, або навіть зі збоєм сервера, що виконує всю цю роботу.

Ситуацію можна виправити шляхом правильного додавання транзакції, що охоплює обидві зміни даних:

public class LogVisit
{
    public void Execute(Guid userid, DateTime visitedOn)
    {
        try
        {
            _db.StartTransaction();
            _db.Execute(@"UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
                        visitedOn, userid);
            _db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
                        VALUES(@p1, @p2)", userid, visitedOn);
            _db.Commit();
        }
        catch
        {
            _db.Rollback();
            throw;
        }
    }
}
Ці виправлення легко здійснити завдяки наявній у реляційних базах даних вбудованій підтримці транзакцій, що дають змогу атомарно виконувати відразу кілька операцій. Але коли потрібно виконати кілька оновлень у базі даних, яка не підтримує транзакції для атомарного виконання кількох операцій, або коли робота ведеться з декількома сховищами даних, що не піддаються об'єднанню в розподілену транзакцію, завдання сильно ускладнюється.

Давайте розглянемо приклад такого випадку.


Розподілені транзакції

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

 public class LogVisit
 {

    public void Execute(Guid userid, DateTime visitedOn)
    {
        _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
        visitedOn, userid);
        _messageBus.Publish("VISITS_TOPIC",
            new { Userid = userid, VisitDate = visitedOn });
    }
 }

Як і в попередньому прикладі, будь-який збій, що стався після виконання коду у рядку 7, але до успішного виконання коду в рядку 9, призведе до псування стану системи. Таблицю users буде оновлено, але інші компоненти повідомлені не будуть, оскільки публікація на шині повідомлень дасть збій.

На жаль, розв'язати проблему так само просто, як у попередньому прикладі, не вийде. Розподілені транзакції, що охоплюють відразу кілька сховищ даних, складні, важкомасштабовані, не стійкі до помилок, і тому їх зазвичай уникають.
У главі 8 буде розглянуто використання архітектурного патерну CQRS, призначеного для заповнення відразу декількох сховищ даних. Крім того, у главі 9 буде представлено патерн вихідних повідомлень (outbox pattern), що дає змогу забезпечити надійну публікацію повідомлення після внесення змін до бази даних.

Давайте розглянемо складніший приклад неправильної реалізації транзакційного проведення.


Неявні розподілені транзакції

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

public class LogVisit
{
    public void Execute(Guid userId)
    {
        _db.Execute("UPDATE Users SET visits=visits+1 WHERE user_id=@p1",userId);
    }
}

Замість відстеження, як у попередніх прикладах, дати останнього відвідування цей метод реалізує лічильник відвідувань кожного користувача. Виклик методу збільшує значення відповідного лічильника на одиницю. Цей метод усього лише оновлює одне значення в одній таблиці, що знаходиться в одній базі даних. І тим не менш, це все ще розподілена транзакція, яка потенційно може призвести до неузгодженого стану.

Цей приклад, як показано на рис. 5.2, являє собою розподілену транзакцію, оскільки вона передає інформацію до бази даних і зовнішнього процесу, який викликав метод.

Незважаючи на те, що метод Execute має тип void, тобто не повертає жодних даних, він усе ж повідомляє про результат виконання операції: так, у разі невдачі сторона, що викликає, отримає виняток. А що якщо метод завершиться успішно, але повернення результату суб'єкту, що викликає, дасть збій?

Наприклад:

  • Якщо LogVisit є частиною REST-сервісу і стався збій мережі.
  • Якщо і LogVisit, і суб'єкт, що викликає, запущені в одному і тому ж процесі, але процес завершується зі збоєм до того, як об'єкт, що викликає, встигає відстежити успішне виконання дії LogVisit.
В обох випадках споживач припускає, що сталася помилка і намагається знову викликати LogVisit. Повторне виконання логіки LogVisit призведе до некоректного збільшення значення лічильника. Вийде його збільшення на 2 замість 1.

Як і в попередніх двох прикладах, код не може правильно реалізувати патерн транзакційного сценарію, що ненавмисно призводить до неправильного стану системи.

Як і в попередньому прикладі, простого розв'язання проблеми не існує. Усе залежить від предметної області та її потреб. У цьому конкретному прикладі один зі способів забезпечити транзакційну поведінку — зробити операцію ідемпотентною, тобто такою, що приводить до одного й того самого результату навіть у разі багаторазового повторення.

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

public class LogVisit
{
    public void Execute(Guid userId, long visits)
    {
        _db.Execute("UPDATE Users SET visits = @p1 WHERE user_id=@p2", visits, userId);
    }
}

Ще один спосіб розв'язання цієї проблеми передбачає використання оптимістичного блокування: перед викликом операції LogVisit сторона, що викликає, зчитує поточне значення лічильника і передає його в LogVisit як параметр. LogVisit оновить значення лічильника тільки в тому разі, якщо воно дорівнює значенню, спочатку прочитаному програмою, що викликає:

public class LogVisit
{
    public void Execute(Guid userId, long expectedVisits)
    {
        _db.Execute(@"UPDATE Users SET visits=visits+1 WHERE user_id=@p1 and visits = @p2", userId, expectedVisits);
    }
}

Усі наступні виконання LogVisit з тими самими вхідними параметрами не змінять дані, оскільки не буде виконуватися умова WHERE ... visits = @p2.


Коли слід застосовувати транзакційний сценарій

Патерн транзакційного сценарію добре підійде найелементарнішим предметним областям, у яких бізнес-логіка нагадує прості процедурні операції. Наприклад, в операціях вилучення-перетворення-завантаження (extract-transform-load, ETL) кожна операція витягує дані з джерела, застосовує логіку трансформації для їх перетворення в іншу форму і завантажує результат у цільове сховище. Цей процес показано на рис. 5.3.

Паттерн транзакційного сценарію органічно вписується в допоміжні піддомени (supporting subdomains), де бізнес-логіка за визначенням не вирізняється особливою складністю. Його також можна використовувати як адаптер для інтеграції із зовнішніми системами, наприклад з універсальними піддоменами (generic subdomains) або як частину запобіжного шару (anticorruption layer) (докладніше цей варіант застосування буде розглянуто в розділі 9).

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

Але простота є й основним недоліком цього паттерну. Чим складнішою стає бізнес-логіка, тим більше наростає тенденція дублювання бізнес-логіки серед процедур і, як наслідок, відмінної поведінки, коли відбувається розсинхронізація продубльованого коду.
Через це транзакційний сценарій у жодному разі не слід використовувати в основних піддоменах (core subdomain), оскільки він не впорається з високою складністю їхньої бізнес-логіки.

Простота, притаманна транзакційному сценарію

Простота, притаманна транзакційному сценарію, створила йому сумнівну репутацію. Іноді цей паттерн навіть розглядають як антипаттерн. Зрештою, якщо складну бізнес-логіку реалізовано у вигляді транзакційного сценарію, то рано чи пізно вона перетвориться на непідтримувану велику грудку бруду (big ball of mud).

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


Активний запис

Об'єкт, що представляє рядок у таблиці або поданні бази даних, інкапсулює доступ до бази даних і бізнес-логіку, що оперує цими даними. — Мартін Фаулер (Martin Fowler)

Активний запис (active record), як і паттерн транзакційного сценарію, стане в пригоді у випадках простої бізнес-логіки. Але в цьому випадку бізнес-логіка може працювати зі складнішими структурами даних. Наприклад, як показано на рис. 5.4, замість простих записів можуть застосовуватися складніші дерева об'єктів та ієрархії.

Робота з такими структурами даних з використанням транзакційного сценарію призвела б до великого обсягу повторюваного коду: зіставлення даних з їхнім представленням у пам'яті буде дублюватися всюди.

Реалізація

Своєю чергою, цим патерном для представлення складних структур даних використовуються спеціальні об'єкти, відомі як активні записи (active records).

Крім структури даних, у цих об'єктах також реалізуються методи доступу до даних для створення, читання, оновлення та видалення записів — так звані CRUD-операції. У результаті об'єкти активних записів пов'язані з об'єктно-реляційним відображенням (ORM) або будь-яким іншим фреймворком доступу до даних. Назву патерну дано на підставі того факту, що кожна структура даних є "активною", тобто в ньому реалізується логіка доступу до даних.

Як і в попередньому патерні, бізнес-логіка системи організована у вигляді транзакційного сценарію. Різниця між цими двома патернами полягає в тому, що в цьому випадку замість прямого доступу до бази даних транзакційний сценарій маніпулює об'єктами активного запису. Після закінчення сценарію операція має або успішно завершитися, або дати збій, тобто поводитися як атомарна транзакція.

public class CreateUser {
    public void Execute(userDetails) {
        try {
            _db.StartTransaction();
            var user = new User();
            user.Name = userDetails.Name;
            user.Email = userDetails.Email;
            user.Save();
            _db.Commit();
        } catch {
            _db.Rollback();
            throw;
        }
    }
}

Мета паттерна

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

При цьому відмінною особливістю об'єкта активного запису є поділ структур даних і поведінки (бізнес-логіки). Зазвичай поля активного запису мають загальнодоступні методи читання і запису, що дозволяють зовнішнім процедурам змінювати їхній стан.


Коли слід застосовувати активний запис

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

Отже, паттерн активного запису, як і паттерн транзакційного сценарію, підходить для допоміжних піддоменів, інтеграції зовнішніх рішень для універсальних піддоменів або для вирішення завдань перетворення моделі. Різниця між паттернами полягає в тому, що активний запис розв'язує завдання зіставлення складних структур даних на схему бази даних.

Паттерн активного запису також називають антипаттерном "анемічна модель" предметної області або ж невірно спроектованою моделлю предметної області. Я волію утримуватися від негативного підтексту слів "анемічний" і "антипаттерн". Цей паттерн є інструментом. Як і будь-який інструмент, він може вирішувати поставлені перед ним завдання, але, якщо його застосовувати у неправильному місці, потенційно він може завдати більше шкоди, ніж користі.

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

У наступному розділі буде розкрито поняття паттерна доменної моделі та показано, чим вона відрізняється від паттерна активного запису.


Важливість контексту

Важливо підкреслити, що в цьому контексті активний запис належить до патерну проектування, а не до фреймворку Active Record. Назву патерну придумав Мартін Фаулер (Martin Fowler) у книзі "Patterns of Enterprise Application Architecture". Фреймворк з'явився пізніше як один зі способів реалізації патерну. У контексті цього розділу йдеться про патерн проектування.

Дотримуйтеся прагматичного підходу

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

Бувають випадки, коли гарантії узгодженості даних можуть бути ослаблені, особливо за високих вимог масштабованості. Перевірте, чи справді один зіпсований запис із мільйона є непереборною перешкодою для бізнесу і чи може він негативно вплинути на ефективність і прибутковість бізнесу. Припустимо, приміром, що створюється система, яка щодня приймає мільярди подій з пристроїв Інтернету речей (IoT). Чи стане великою проблемою та обставина, за якої 0,001 % подій будуть продубльовані або втрачені?

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


Висновок

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

Транзакційний сценарій
Патерн
Цей патерн організовує операції системи у вигляді простих і зрозумілих процедурних сценаріїв. Процедури гарантують, що кожна операція є транзакційною — або успішною, або невдалою. Патерн транзакційного сценарію підходить для допоміжних піддоменів, а бізнес-логіка складається з простих операцій, подібних до вилучення-перетворення-завантаження (extract-transform-load, ETL).
Активний запис
Патерн
Коли бізнес-логіка не відрізняється особливою складністю, але передбачає роботу зі складними структурами даних, ці структури можна реалізувати у вигляді активних записів. Об'єкт активного запису — це структура даних, що надає прості CRUD-методи доступу до даних.

Розглянуті в цьому розділі два патерни орієнтовані на випадки застосування порівняно простої бізнес-логіки. У наступному розділі буде розглянуто більш складну бізнес-логіку та запропоновано способи подолання додаткових складнощів шляхом застосування патерну "модель предметної області".


Вправи

  1. Яким із розглянутих патернів слід скористатися для реалізації бізнес-логіки основного піддомену?
    • А) Транзакційний сценарій.
    • Б) Активний запис.
    • В) Для реалізації основної підобласті не повинен використовуватися жоден із цих патернів.
    • Г) Для реалізації основного піддомену можуть використовуватися обидва патерни.
  2. Подивіться на наступний код:
void CreateTicket(TicketData data)
{
    var agent = FindLeastBusyAgent();
    agent.ActiveTickets = agent.ActiveTickets + 1;
    agent.Save();
    var ticket = new Ticket();
    ticket.Id = Guid.New();
    ticket.Data = data;
    ticket.AssignedAgent = agent;
    ticket.Save();
    _alerts.Send(agent, "You have a new ticket!");
}

Якщо припустити відсутність високорівневого механізму транзакцій, то які потенційні проблеми з узгодженістю даних тут можуть виявитися?

  • А) При отриманні нового реєстрованого запиту лічильник активних запитів призначеного агента може бути збільшено більш ніж на 1.
  • Б) Лічильник активних запитів агента може бути збільшений на одиницю, але агенту не будуть призначені нові реєстровані запити.
  • В) Агент може отримати новий запит, але не буде про це повідомлений.
  • Г) Можливе виникнення всіх перерахованих вище проблем.
  1. У попередньому коді є принаймні ще один потенційний крайній випадок, здатний внести розлад у стан системи. Чи зможете ви його знайти?
  2. Повертаючись до згаданого в передмові прикладу WolfDesk, скажіть, яка частина системи потенційно може бути реалізована у вигляді транзакційного сценарію або ж у вигляді активного запису?