Singleton и Dependency Injection
Накратко: Singleton гарантира един-единствен обект от клас, а Dependency Injection подава зависимостите отвън, затова кодът се тества с подменена зависимост, например часовник.
Проблем
Тестът, който минава само в петък
В петък магазинът дава 10% отстъпка. Часовникът на програмата е един, затова е Singleton: клас, от който има само един обект, достъпен отвсякъде. Услугата за поръчки го пита кой ден е:
class SystemClock { private static final SystemClock INSTANCE = new SystemClock();
private SystemClock() { }
public static SystemClock getInstance() { return INSTANCE; }
public LocalDate today() { return LocalDate.now(); }}
class OrderService { public double price(double amount) { if (SystemClock.getInstance().today().getDayOfWeek() == DayOfWeek.FRIDAY) { return amount * 0.9; } return amount; }}Някой пише тест, за да е сигурен, че отстъпката работи:
OrderService service = new OrderService();System.out.printf("Петъчна отстъпка: очаквам 90.00 €, получих %.2f €%n", service.price(100));Пуснат в понеделник, 26 октомври 2026, тестът печата:
Петъчна отстъпка: очаквам 90.00 €, получих 100.00 €В петък същият тест печата 90.00. Тестът минава един ден в седмицата и пада в останалите шест, а кодът е същият. Да провериш отстъпката значи да чакаш петък или да местиш часовника на компютъра.
Часовникът сам по себе си е направен правилно. Проблемът е, че price сам посяга към него. Отвън не се вижда, че услугата зависи от часовник: new OrderService() не иска нищо. Тестът не може да ѝ даде друг ден, защото не може да ѝ даде нищо.
Идеята
Единственият обект и подаването отвън
Singleton (единак) пази две неща: обектът е само един, и всеки може да го вземе отвсякъде. Първото понякога е нужно. Второто е цената:
- скрита зависимост. Кой ползва часовника, личи само ако прочетеш всеки ред от всеки метод;
- общо състояние. Каквото един сменя в обекта, вижда всеки друг, включително следващият тест;
- не се сменя. Кодът сочи конкретния клас по име. Друг часовник за теста няма откъде да влезе.
Поправката не е да махнеш часовника, а да го подадеш отвън. Услугата не търси зависимостта си, а я получава в конструктора. Кой часовник ще е, решава този, който създава услугата: програмата подава истинския, а тестът свой, с дата, която той избира.
Това се казва Dependency Injection (подаване на зависимостите, DI). Ролите са три:
- услугата казва какво ѝ трябва: параметър от тип интерфейс в конструктора;
- зависимостта е обектът, който ѝ се дава: истинският часовник или тестовият;
- сглобяването става на едно място, в началото на програмата. Там се решава кой обект при кого отива.
Singleton може и да остане: единственият часовник се подава като всеки друг обект. Просто никой не посяга към него сам.
Виж го
Singleton, както го дава езикът
if (instance == null). Ако две нишки стигнат до проверката едновременно, обектите стават два. Езикът има по-сигурни начини.Часовникът от проблема е създаден в static final поле. Java го създава веднъж, когато зареди класа, и това е безопасно и при много нишки. Ако създаването е скъпо и трябва да стане чак при първото искане, обектът отива във вложен клас (holder). Java зарежда Holder едва при първото getInstance():class SystemClock { private SystemClock() { System.out.println("Създавам часовника"); }
private static class Holder { static final SystemClock INSTANCE = new SystemClock(); }
public static SystemClock getInstance() { return Holder.INSTANCE; }}System.out.println("Старт");SystemClock a = SystemClock.getInstance();SystemClock b = SystemClock.getInstance();System.out.println(a == b);СтартСъздавам часовникаtrueПо-точно, за любопитните
enum с една стойност, INSTANCE (видя го в „Шаблони за дизайн“). Java не позволява new за enum и създава стойността точно веднъж.Виж го
Часовникът идва отвън
Clock: интерфейс с един метод. SystemClock от проблема само добавя implements Clock:interface Clock { LocalDate today();}
class OrderService { private final Clock clock;
public OrderService(Clock clock) { this.clock = clock; }
public double price(double amount) { if (clock.today().getDayOfWeek() == DayOfWeek.FRIDAY) { return amount * 0.9; } return amount; }}В програмата истинският часовник се подава веднъж, при създаването:
OrderService service = new OrderService(SystemClock.getInstance());Сега new OrderService(...) казва на глас, че без часовник не може. Зависимостта се вижда в подписа на конструктора, а не някъде вътре в метод.
В големите приложения сглобяването го прави рамка: Spring създава обектите и подава всеки на тези, които го искат в конструктора си. Идеята е същата, само че на ръка в main е за малки програми.
Виж го
Тестът сам избира деня
За теста е нужен часовник, на който казваш коя дата да връща:
class FakeClock implements Clock { private LocalDate date;
public FakeClock(LocalDate date) { this.date = date; }
public void set(LocalDate date) { this.date = date; }
@Override public LocalDate today() { return date; }}Тестът подава него вместо истинския:
FakeClock clock = new FakeClock(LocalDate.of(2026, 10, 30));OrderService service = new OrderService(clock);System.out.printf("Петък: %.2f €%n", service.price(100));
clock.set(LocalDate.of(2026, 10, 27));System.out.printf("Вторник: %.2f €%n", service.price(100));Петък: 90.00 €Вторник: 100.00 €FakeClock clock = new FakeClock(LocalDate.of(2026, 10, 30));OrderService service = new OrderService(clock);System.out.printf("Петък: %.2f €%n", service.price(100));
clock.set(LocalDate.of(2026, 10, 27));System.out.printf("Вторник: %.2f €%n", service.price(100));Стъпка по стъпка, с думи
- Преди price сам посягаше към SystemClock.getInstance(): скрита връзка към глобален обект, която тестът не може да смени.
- Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
- Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
- price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
- Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
- Същото price(100): вторник, 100.00. Тестът дава тези две числа във всеки ден от седмицата.
Тестът дава тези две числа в понеделник, в петък и в неделя. OrderService е същият клас, който върви в магазина: тестът сменя само това, което му подава.
По-точно, за любопитните
Clock има един метод, затова тестов часовник за фиксиран ден може да е и ламбда: new OrderService(() -> LocalDate.of(2026, 10, 30)). Клас си заслужава, когато тестът мести датата, както тук. В Java има и вграден java.time.Clock с Clock.fixed(...) за тестове, а LocalDate.now(clock) пита него.Пробвай
Предвиди изхода
Класовете са от картите преди тази. 31 октомври 2026 е събота. Какво печата програмата?
FakeClock clock = new FakeClock(LocalDate.of(2026, 10, 30));OrderService shop = new OrderService(clock);clock.set(LocalDate.of(2026, 10, 31));OrderService app = new OrderService(clock);
System.out.printf("%.2f €%n", shop.price(50));System.out.printf("%.2f €%n", app.price(50));Пробвай
Намери грешката
Кошницата е Singleton, „за да е една“:
class Cart { private static final Cart INSTANCE = new Cart(); private final List<String> items = new ArrayList<>();
private Cart() { }
public static Cart getInstance() { return INSTANCE; }
public void add(String item) { items.add(item); }
public int size() { return items.size(); }}Два теста, всеки със своя страница за плащане. check печата OK или какво е очаквал тестът:
CheckoutPage first = new CheckoutPage();first.add("Кафе");first.add("Чаша");check("Тест 1", first.count(), 2);
CheckoutPage second = new CheckoutPage();second.add("Кафе");check("Тест 2", second.count(), 1);Тест 1: OKТест 2: FAIL, очаквах 1, получих 3Всеки тест поотделно минава. Заедно вторият пада. На кой ред е грешката?
Избери реда с грешката.
Страницата сама взима единствената кошница. Двете страници, и двата теста, пълнят един и същ обект: вторият тест вижда кафето и чашата на първия. В магазина е още по-лошо: всички клиенти пазаруват в една кошница. Кошницата не е нещо, което е едно за програмата, а е по една на клиент. Поправката: кошницата е обикновен клас с публичен конструктор, а страницата я получава в своя: new CheckoutPage(new Cart()). Всеки тест си прави своя кошница, и двата минават.
По-точно, за любопитните
Тестовите библиотеки пускат тестовете в ред, който не е гарантиран, понякога и едновременно. Тест, който минава само ако е първи, е тест, който пада случайно. Общото състояние в Singleton е най-честата причина.
Идеята
Кога не ти трябва
Singleton има смисъл, когато второ копие би било грешка, а обектът не пази нищо, което се сменя: настройките, прочетени веднъж при старта, логът, пулът от връзки към базата. Дори тогава е по-добре да се подава отвън, отколкото всеки да посяга към него сам.
Подаването отвън също има цена. Не си струва, когато:
- зависимостта никога не се сменя и не пречи на тестовете.
Math.roundне е зависимост, която се подава; - програмата е малка. Сглобяване на ръка в main стига; рамка за DI е за големи приложения;
- конструкторът иска осем зависимости. Това не е проблем на DI: класът прави твърде много.
Упражнение
Black Friday
Реши задачата в редактора си.
Black Friday. На 27 ноември 2026 магазинът дава 30% отстъпка, а в другите петъци пак 10%. Добави правилото в OrderService и го провери с FakeClock за три дати: Black Friday, 30 октомври (петък) и 27 октомври (вторник). Тестът дава еднакъв изход всеки ден.
Black Friday: 70.00 €Петък: 90.00 €Вторник: 100.00 €Още задачи
- Номера на поръчки. Броячът на номерата е Singleton и започва от 1001. Направи го зависимост на
OrderServiceи напиши два теста, които и двата получават 1001 като първи номер. - Кошница за всеки клиент. Поправи грешката от „Намери грешката“ докрай: кошницата не е Singleton, а всяка страница получава своята. Двама клиенти добавят различни артикули и всеки вижда само своите.
- Нощна отстъпка. Между 22:00 и 6:00 отстъпката е 5%. Часовникът вече трябва да дава и часа. Какво сменяш в Clock, и колко теста трябват, за да си сигурен?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове · Ламбди · Шаблони за дизайн · Strategy
- Продължава в: Adapter и Facade · Repository · Магазинът: кой шаблон къде · SOLID без догма
- Задачи по темата: Шаблони за дизайн