JavaRush /Курси /Spring Core /ContextFlow: сервіси...

ContextFlow: сервіси без стану та сесія

Spring Core
Рівень 11 , Лекція 4
Відкрита

1. Проблема: сервіси з «памʼяттю»

У ContextFlow спокуса та сама, що й у будь-якому невеликому проєкті: покласти currentOrderId, список кроків або інший тимчасовий фрагмент даних просто в поле сервісу. Спочатку все працює: ви задоволені, кіт задоволений. А потім singleton-bean починає памʼятати попередні операції й тихо перетворюється на спільний блокнот для всього застосунку. Навіть без вебшару це робить поведінку крихкою: сервісу вже недостатньо просто викликати placeOrder() — важливо ще знати, що залишилося в його полях після минулого разу.

2. Поведінка живе довго, дані операції — ні

Тому межа тут проста. OrderPlacementService, AuditService, NotificationDispatchService та інші сервіси живуть довго й відповідають за поведінку: координують кроки, викликають залежності, приймають параметри. А дані однієї обробки замовлення — кроки сценарію, тимчасові позначки, проміжні прапорці — мають жити або всередині методу, або в окремому короткоживучому об’єкті. Інакше ми змішуємо «хто виконує сценарій» і «що накопичилося всередині конкретного запуску».

3. Робимо сервіси ContextFlow stateless — і це нормально

Stateless тут не означає «без полів узагалі». Поля із залежностями залишаються: OrderStore, AuditWriter, NotificationSender. Не мають залишатися дані конкретного виклику — currentOrderId, currentChannel, список кроків і все, що стосується лише однієї операції.

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

4. Проєктуємо OrderProcessingSession: що це і що в ній зберігати

OrderProcessingSession — не нова доменна сутність рівня Order. Це прикладний об’єкт однієї операції: своєрідна робоча папка, у якій накопичуються кроки обробки замовлення. Її не потрібно зберігати в OrderStore і не потрібно робити головним об’єктом системи.

Для навчального проєкту достатньо списку кроків обробки. А якщо хочеться швидко перевірити, що на різні виклики приходять різні session-об’єкти, для демонстраційного коду вистачає System.identityHashCode(...) — не потрібно роздувати сам клас заради однієї діагностики. Цього вже досить, щоб стан операції став явним і перестав розповзатися по полях сервісів.

5. Реєструємо OrderProcessingSession як prototype-bean

Тут prototype потрібен не задля ще однієї анотації. Якби нам був потрібен просто локальний список рядків, звичайний new був би чеснішим. Але OrderProcessingSession — осмислена частина графа ContextFlow: ми хочемо, щоб вона народжувалася заново на кожну операцію й водночас залишалася звичайною залежністю контейнера. Якщо пізніше їй знадобляться власні налаштування, Clock або інший допоміжний клас, сервіс не почне збирати її вручну.

Тому фінальна версія цього вузла в ContextFlow виглядає так:

import java.util.ArrayList;
import java.util.List;

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

@Component
@Scope("prototype") // Важливо: новий екземпляр під час кожного запиту до контейнера
class OrderProcessingSession {
    private final List<String> steps = new ArrayList<>();

    void addStep(String step) {
        steps.add(step);
    }

    List<String> steps() {
        return List.copyOf(steps);
    }
}

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

6. Сесія на операцію через ObjectProvider

Далі все впирається в момент отримання екземпляра. Якщо впровадити OrderProcessingSession просто в конструктор singleton-сервісу, контейнер створить її один раз на старті — і ніякої «сесії на операцію» не вийде. Тому сервіс тримає не саму session, а ObjectProvider<OrderProcessingSession>:

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
class OrderPlacementService {
    // Тримаємо provider, а не саму session
    private final ObjectProvider<OrderProcessingSession> sessions;

    OrderPlacementService(ObjectProvider<OrderProcessingSession> sessions) {
        this.sessions = sessions;
    }
}

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

7. Вбудовуємо OrderProcessingSession у сценарій створення замовлення

Тепер підхід складається цілком: сервіс бере нову session на початку placeOrder(...), записує туди кроки поточної обробки й після виходу з методу більше не тримає на неї посилання.

void placeOrder(CreateOrderCommand command) {
    // Новий екземпляр для кожної операції
    OrderProcessingSession session = sessions.getObject();

    // Кроки поточної обробки живуть усередині session, а не в полях сервісу
    session.addStep("validate");
    session.addStep("save");
    session.addStep("notify");

    System.out.println(session.steps()); // [validate, save, notify]
}

Ось тут і зустрічаються обидві ідеї дня: singleton-сервіс залишився stateless, а стан однієї операції перейшов в окремий prototype-об’єкт.

flowchart TD
    A["OrderPlacementService
singleton"] -->|має| P["ObjectProvider<OrderProcessingSession>"] A --> S1["OrderStore
singleton"] A --> N["NotificationDispatchService
singleton"] A --> AU["AuditService
singleton"] P -->|"getObject() для кожного виклику"| OS["OrderProcessingSession
prototype (новий щоразу)"]

Якщо хочеться зробити приклад трохи більш схожим на життя, можна вивести технічну мітку екземпляра:

OrderProcessingSession session = sessions.getObject();
// Технічна перевірка: у різних викликів буде інший об’єкт
System.out.println("сесія=" + System.identityHashCode(session)); // сесія=123456789 (приклад)

Значення в коментарі — лише приклад: на різних запусках число буде іншим. Але сенс лишається тим самим — на кожен виклик приходить новий екземпляр.

І ще важлива дисципліна: не переносіть у session всю логіку координації. Сервіс і далі керує сценарієм (створити замовлення, зберегти, сповістити), а session залишається «папкою з документами» одного запуску.

8. Перевіряємо під час виконання: один виклик — одна session

Зрозуміти щось у Spring — це лише половина справи. Друга половина — побачити на власні очі, що контейнер справді робить те, чого ви очікуєте. Тому корисно зробити маленьку перевірку у вашому ScenarioRunner (який уже запускає сценарії застосунку). Нам не потрібні повноцінні тести чи окрема інфраструктура — достатньо двох викликів одного й того самого сценарію та порівняння session-екземплярів.

Наприклад, якщо ви тимчасово додасте до OrderPlacementService метод для діагностики:

boolean newSessionEachTime() {
    // Два звернення до provider мають повернути різні prototype-екземпляри
    OrderProcessingSession a = sessions.getObject();
    OrderProcessingSession b = sessions.getObject();

    // Порівнюємо посилання: нам важливо саме "це інший об’єкт"
    return a != b;
}

то в runner можна вивести:

// Очікуємо true: provider видав дві різні session
System.out.println(orderPlacementService.newSessionEachTime()); // true

Це маленька «інженерна лупа»: ви переконуєтеся, що provider справді видає різні екземпляри, тобто scope працює так, як ми очікуємо.

Якщо ви хочете прив’язати перевірку до реальної операції, можна в runner викликати placeOrder(...) два рази й подивитися логи кроків. Головне — не намагатися перетворювати identityHashCode на «бізнес-факт», ми використовуємо його лише як демонстрацію.

І ще один приємний ефект: щойно ви впровадили такий session-підхід, стає набагато легше пояснювати студенту (і собі) різницю між «станом операції» та «станом сервісу». Сервіс має бути передбачуваним. А session якраз і дає місце, де «тимчасове» дозволено.

9. Типові помилки під час роботи з prototype-сесіями

Помилка № 1: зберігати OrderProcessingSession у полі singleton-сервісу.
Щойно session пережила метод, вона перестала бути станом однієї операції й перетворилася на спільний об’єкт.

Помилка № 2: викликати sessions.getObject() один раз наперед і кешувати результат.
prototype тоді знову перетворюється на один екземпляр на весь час життя сервісу, тільки вже в обхідний спосіб.

Помилка № 3: замість ObjectProvider тягнути ApplicationContext і викликати getBean() із бізнес-коду.
Тут достатньо ObjectProvider; контекст у сервісі швидко перетворює код на service locator і робить залежності прихованими.

Помилка № 4: перетворювати session-bean на «новий сервіс», куди переїжджає вся бізнес-логіка.
Іноді студент думає: «Раз session зберігає кроки, нехай вона сама й виконує кроки». І раптом session перетворюється на другий OrderPlacementService, тільки зі станом. Це небезпечне змішування відповідальності. Нехай координація сценарію залишається в сервісах, а session зберігає лише тимчасові дані (кроки, позначки, накопичені попередження).

Помилка № 5: робити prototype-bean із доменних об’єктів (Order, Customer).
Це часта спроба «прив’язати все до Spring»: раз контейнер уміє створювати об’єкти, нехай створює і Order. Зазвичай це зайве. Доменні об’єкти — це дані сценарію, вони створюються в бізнес-коді, живуть у пам’яті чи в сховищі, серіалізуються тощо. У контейнер варто віддавати сервіси та інфраструктуру, а не кожну сутність предметної області.

1
Опитування
Spring Scope, рівень 11, лекція 4
Недоступний
Spring Scope
Області життєвого циклу бінів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ