1. Scope у Spring: сенс і lifetime
Scope часто вперше бачать у вигляді @Scope("prototype") і сприймають як перемикач «один об’єкт чи багато об’єктів». Це правда, але лише частково. На практиці scope — це домовленість між вами і контейнером про те, який час життя має bean і де в застосунку він узагалі має сенс. Якщо ми ігноруємо scopes, то найчастіше приходимо або до дивних костилів («давайте все пересоздавати вручну»), або до прихованого стану в singleton-ах, а потім дивуємося: «чому воно саме змінилося?».
Давайте сформулюємо scope «по-дорослому», але людською мовою. Scope відповідає на два запитання одночасно. По-перше: скільки екземплярів певного bean існуватиме для контейнера. По-друге: як довго контейнер вважатиме ці екземпляри «своїми» і керуватиме ними. Тобто scope — це не про красу коду, а про модель часу життя об’єкта в застосунку.
Щоб зафіксувати картинку, зручно тримати в голові ось таку схему:
flowchart TD
A[ApplicationContext] --> B[BeanDefinition: назва + тип + scope + метадані]
B -->|singleton| C[Один екземпляр на весь контекст]
B -->|prototype| D[Новий екземпляр на кожен запит до контейнера]
Сьогодні ми розберемо два базові scope, які вам потрібні вже зараз: singleton і prototype. Саме вони становлять фундамент, на який потім спираються інші, зокрема web-scopes.
2. Scope як властивість BeanDefinition
На старті найпростіше думати так: «є клас AuditService, отже він singleton». Але Spring мислить інакше: контейнер керує не класами, а BeanDefinition. Клас — це лише опис того, що потрібно створити. А ось «скільки створювати і як жити» — це вже метадані BeanDefinition. Тому один і той самий Java-клас, строго кажучи, можна зареєструвати в контейнері по-різному, хоча в реальному житті так роблять рідко й обережно.
Чому це важливо? Тому що це знімає одну шкідливу ілюзію: ніби scope — це щось на кшталт final або static, тобто назавжди пришите до типу. Ні, scope обирають під час реєстрації bean, а реєстрація може відбуватися різними способами: через @Component/@Service (через сканування), через @Bean у конфігурації, через XML (колись пізніше, без паніки).
Уявіть побутову ситуацію. У вас є «тип» — наприклад, OrderProcessingSession. Він може бути «одноразовим блокнотом для однієї операції». А може бути «спільним журналом, куди ми записуємо все». Тип один і той самий, але сенс і час життя різні. У Spring це й виражається тим, що scope — частина визначення bean, а не ДНК класу.
І ще одна маленька, але практична думка. Коли ви читаєте конфігурацію проєкту, а ми в ContextFlow хочемо навчитися це робити, корисно подумки ставити запитання: «Цей bean має жити довго, як сервіс, чи коротко, як об’єкт конкретної операції?» Відповідь майже завжди підказує відповідний scope.
3. singleton scope: поведінка за замовчуванням
Singleton — це scope за замовчуванням. Тобто якщо ви взагалі нічого не написали, Spring мовчки вважає bean singleton. І в нормальному проєкті це добре. Тому що більшість сервісів — це як кавомашина в офісі: вона одна, і ми не печемо на кожне чхання нову кавомашину, а просто користуємося однією й тією самою, поки живе офіс.
У термінах контейнера singleton означає: у межах одного ApplicationContext існує один екземпляр цього bean, і під час кожного запиту контейнер повертає саме його. Важливо, що це «один на контекст», а не «один на всю JVM, планету й мультивсесвіт». Ми вже обговорювали відмінність spring-singleton від GoF Singleton, тому сьогодні лише коротко нагадаю: ніяких private static final INSTANCE Spring від вас не вимагає.
Давайте подивимося на це в коді максимально прямо. Візьмімо, наприклад, наш сервіс аудиту, у ContextFlow він є як AuditService. За замовчуванням він буде singleton:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.stereotype.Service;
@Service
class AuditService {
// Singleton за замовчуванням: контейнер створить один екземпляр на ApplicationContext
}
class Demo {
public static void main(String[] args) {
// Піднімаємо мінімальний контекст лише з одним компонентом
try (var ctx = new AnnotationConfigApplicationContext(AuditService.class)) {
// Двічі запитуємо той самий bean у контейнера
AuditService first = ctx.getBean(AuditService.class);
AuditService second = ctx.getBean(AuditService.class);
// Для singleton це буде той самий об’єкт
System.out.println(first == second); // true
}
}
}
Тут важлива не сама перевірка ==, хоч вона й чесно показує «той самий об’єкт», а сенс: контейнер створив AuditService, запам’ятав його і потім видає той самий екземпляр.
Ще один нюанс, який приємно пов’язати з попередньою темою про життєвий цикл. Якщо bean зі singleton не lazy, то контекст зазвичай створює його під час старту, а під час закриття викликає callback-и знищення, наприклад @PreDestroy і destroyMethod. Тобто в singleton життєвий цикл найчастіше повний: контейнер не лише створив об’єкт, а й коректно завершив його життя.
4. prototype scope: новий екземпляр на кожен запит до контейнера
Prototype — це другий базовий scope, який реально потрібен у застосунках, де є короткоживучі об’єкти. Сенс prototype звучить просто: контейнер створює новий екземпляр bean щоразу, коли ви запитуєте цей bean у контейнера. Тобто ctx.getBean(...) повернув один об’єкт, другий getBean(...) повернув інший, і це саме очікувана поведінка.
Це схоже на одноразовий стаканчик у кав’ярні: ви не миєте і не перевикористовуєте його «на весь день», а берете новий на кожну порцію. У застосунку такими «стаканчиками» бувають об’єкти, які несуть стан однієї операції: одного оброблення замовлення, однієї генерації звіту, одного короткого кроку сценарію.
Мінімальний приклад виглядає так:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope("prototype") // Просимо контейнер створювати новий екземпляр на кожен getBean(...)
class OrderProcessingSession {
// Типовий prototype: об'єкт-стан для однієї операції/сценарію
}
class Demo {
public static void main(String[] args) {
// Контекст підніметься і знатиме про один компонент
try (var ctx = new AnnotationConfigApplicationContext(OrderProcessingSession.class)) {
// Кожен запит до контейнера створює новий екземпляр prototype-bean
OrderProcessingSession first = ctx.getBean(OrderProcessingSession.class);
OrderProcessingSession second = ctx.getBean(OrderProcessingSession.class);
System.out.println(first == second); // false
}
}
}
Тепер важлива застережна ремарка, яку новачок майже завжди пропускає, а потім страждає. Prototype означає «новий на кожен запит до контейнера», але не означає «новий на кожен виклик методу singleton-сервісу». Якщо ви один раз отримали prototype і зберегли його в полі, він перестав бути «одноразовим стаканчиком» і перетворився на «стаканчик, який ви носите тиждень». Поки що нам важливо зафіксувати саму пастку: prototype залишається короткоживучим лише доти, доки ви щоразу приходите за ним до контейнера.
Є ще один нюанс, який добре пов’язати з темою життєвого циклу з минулого дня. Для prototype-bean контейнер гарантує створення й впровадження залежностей, але не керує знищенням так само, як для singleton. Тобто Spring створив prototype, віддав вам, а далі ви доросла людина: якщо там є ресурси, їх потрібно закривати самостійно. У навчальних прикладах ми зазвичай робимо prototype легким, без файлових ручок і сокетів, щоб не входити в режим «курс із виживання».
5. Як задавати scope: @Scope
Коли ви вперше бачите @Scope("prototype"), хочеться запитати: «А чому рядком? Чому не enum? Чому не Scope.PROTOTYPE?» Ласкаво просимо в реальність Java enterprise, де багато чого історично строкове, і Spring тут не одинокий. Хороша новина: цей рядок ви писатимете не так уже й часто, бо режим за замовчуванням — singleton, а prototype потрібен лише для справді короткоживучих речей.
Scope можна задати у двох популярних місцях. Перший варіант — на компоненті, який потрапляє в контейнер через сканування:
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // Константа надійніша, ніж рядок "prototype"
class OrderProcessingSession {
// Кожен запит до контейнера поверне новий екземпляр цього компонента
}
Зверніть увагу: я використовую константу ConfigurableBeanFactory.SCOPE_PROTOTYPE, а не рядок "prototype". Так менше шансів помилитися в лапках і потім пів години дивитися на застосунок, який «чомусь не став prototype». Так, це дрібниця. Але саме з дрібниць складається життя.
Другий варіант — задати scope на @Bean-методі в @Configuration. Це корисно, коли ви не хочете або не можете анотувати клас як компонент, або коли створення потребує явного налаштування:
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
@Configuration
class SessionConfig {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // Новий екземпляр під час кожного звернення до контейнера
OrderProcessingSession orderProcessingSession() {
// Тут ми явно описуємо контейнеру, як створювати prototype-bean
return new OrderProcessingSession();
}
}
Ключова думка тут методична: ми знову бачимо, що scope — це характеристика BeanDefinition, а @Bean-метод якраз і є «фабрикою визначення bean». Клас OrderProcessingSession сам по собі не стає prototype. Він стає prototype тому, що ми так описали його контейнеру.
Якщо вам раптом спала на думку ідея написати @Scope("singleton") — технічно можна, але в більшості проєктів це зайвий шум. Singleton і так за замовчуванням, краще не розводити анотаційний шум заради відчуття контролю.
6. Scope і стан: правило stateful/stateless
Scope стає по-справжньому зрозумілим не в момент, коли ви побачили @Scope, а в момент, коли ви пов’язали його з питанням: «Чи можна цьому об’єкту зберігати стан між викликами?» Тут починаються дорослі розмови, але ми триматимемося в межах першого курсу, без фанатизму.
Є корисне правило, яке працює дивовижно часто: довгоживучі сервіси мають бути stateless і тому чудово підходять під singleton. Stateless означає, що сервіс не зберігає «поточні дані операції» в полях; він отримує дані параметрами методу, робить роботу і повертає результат. У ContextFlow такими об’єктами є OrderPlacementService, AuditService, ReportingService та інші сервіси координації: вони узгоджують кроки, але не мають перетворюватися на «скриньку з поточним станом».
А ось stateful об’єкти, які за змістом живуть «одну операцію», часто розумно зробити prototype. Наприклад, «сесія оброблення замовлення» може накопичувати проміжні кроки, виміри, часові мітки, список «що зробили». Їй не потрібно жити довше однієї операції. І саме тому вона є хорошим кандидатом на prototype.
Але важлива межа. Prototype — не обов’язковий для всього тимчасового. Іноді простіше й чесніше створити об’єкт звичайним new всередині методу та тримати його як локальну змінну. Контейнер потрібен там, де об’єкт є справжньою залежністю, яку ви хочете конфігурувати, підміняти або обгортати механізмами Spring, або де він логічно є частиною об’єктного графа. Якщо об’єкт — просто «тимчасовий список із трьох рядків», контейнер не зобов’язаний про нього знати, інакше Spring перетвориться на «менеджера всього на світі», а ви — на його заручника.
Щоб закріпити різницю, ось невелика таблиця, яку корисно тримати в голові:
| Питання | Скоріше singleton | Скоріше prototype |
|---|---|---|
| Об’єкт живе стільки ж, скільки застосунок | Так | Ні |
| Об’єкт — сервіс/інфраструктура, а не «дані однієї операції» | Так | Іноді |
| Усередині об’єкта немає змінюваного стану, прив’язаного до конкретного виклику | Так | Не обов’язково |
| Об’єкт несе стан одного сценарію/операції | Зазвичай ні | Так |
| Потрібен «новий стан» щоразу | Не про scope, а про проєктування | Часто так |
Сьогодні нам важливо не вивчити таблицю, а відчути думку: scope — це інструмент, який допомагає не переплутати час життя поведінки з часом життя даних.
7. Міні-інкремент для ContextFlow
Зараз ми зробимо маленький, чесний крок у бік нашого проєкту ContextFlow. Не будемо поки будувати складні схеми, не будемо впроваджувати session у сервіси — це саме та область, де легко сформувати неправильне очікування. Сьогодні наше завдання простіше: додати об’єкт, який за змістом справді «одноразовий», призначити йому scope prototype і переконатися, що контейнер видає різні екземпляри.
Додамо в проєкт клас OrderProcessingSession. Нехай він зберігає «кроки» оброблення — просто рядки. Це достатньо реалістично, можна потім використовувати для діагностики, але не перевантажує навчальний проєкт:
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
@Scope(ConfigurableBeanFactory.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);
}
}
Тепер — невелика демонстрація в точці входу. Це важливо: не в бізнес-сервісі, а в bootstrap/демо-коді. Ми просто попросимо у контейнера два екземпляри та перевіримо, що вони різні:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class ScopeDemo {
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(OrderProcessingSession.class)) {
var s1 = ctx.getBean(OrderProcessingSession.class);
var s2 = ctx.getBean(OrderProcessingSession.class);
System.out.println(s1 == s2); // false
}
}
}
Якщо вам хочеться побачити щось більш відчутне, можна додати кроки в першу сесію і переконатися, що в другій їх немає, бо це інший об’єкт:
s1.addStep("validate");
System.out.println(s1.steps()); // [validate]
System.out.println(s2.steps()); // []
Тут ми поки що зробили лише одне: ввели в проєкт кандидата на короткоживучу залежність і переконалися, що контейнер видає різні екземпляри. Цього вже достатньо, щоб розмежувати в голові дві ролі: «довгоживучі сервіси» і «стан однієї операції» — це не одне й те саме.
8. Типові помилки під час роботи зі scope
Помилка № 1: вважати, що scope — властивість класу, а не bean.
Іноді в голові з’являється формулювання «клас OrderProcessingSession — prototype». Але коректніше: «bean orderProcessingSession — prototype». Це не занудство: щойно ви почнете читати конфігурацію і розуміти зв’язування залежностей, вам потрібно мислити термінами контейнера, тобто термінами bean definitions, а не «магії класів».
Помилка № 2: плутати spring-singleton з глобальним GoF Singleton.
Якщо у вас у голові живе ідея «singleton — значить один на всю програму назавжди», ви легко почнете проєктувати сервіси так, ніби вони є глобальними статичними штуками. Spring так не працює: singleton — це один екземпляр на контейнер. Контейнерів може бути кілька, наприклад у тестах, в окремих модулях або в різних запускових сценаріях, і це нормально.
Помилка № 3: вмикати prototype «про всяк випадок», бо так безпечніше.
Prototype — не «режим підвищеної безпеки». Це режим іншого часу життя. Якщо ви без причини робите prototype для сервісу, який має бути спільним, ви ускладнюєте діагностику, збільшуєте кількість об’єктів і частіше приходите до неочікуваної поведінки. Краще спочатку чесно відповісти: об’єкт справді несе стан однієї операції чи ні?
Помилка № 4: очікувати, що prototype автоматично стане «новим об’єктом на кожен виклик методу» в singleton-сервісі.
Це найчастіша пастка. Prototype створюється на кожен запит до контейнера, але якщо ви отримали його один раз і тримаєте як поле, він перестав бути «одноразовим». Звідси народжується фраза «prototype всередині singleton не працює», хоча насправді працює, просто очікування було неправильним.
Помилка № 5: забувати, що контейнер не зобов’язаний автоматично знищувати prototype-bean-и.
Після теми життєвого циклу хочеться вірити, що @PreDestroy викличеться завжди. Але у prototype інша модель: контейнер створив, віддав — а далі ви керуєте часом життя самі. Тому prototype краще тримати легким, без ресурсів, що потребують обов’язкового закриття, особливо в навчальному проєкті.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ