Skip to main content

Патерн синглтон

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

Теорія

Коротко

  • Аналогія: Білий дім - одна будівля, один президент, усі звертаються через одну адресу.
  • Головна різниця: звичайний клас дозволяє викликати new скільки завгодно; синглтон блокує кожен new після першого.
  • Використовуй, коли один об'єкт має контролювати спільний ресурс (логер, пул DB, конфіг). Для утиліт без стану не потрібен.
  • У Node.js clusters кожен процес отримує власний екземпляр синглтону. Між процесами він НЕ ділиться.

Швидкий приклад

typescript
class Singleton { private static instance: Singleton; private constructor() { // private: ніхто не може написати new Singleton() ззовні } static getInstance(): Singleton { if (!Singleton.instance) { Singleton.instance = new Singleton(); // створюється один раз } return Singleton.instance; } } const a = Singleton.getInstance(); const b = Singleton.getInstance(); console.log(a === b); // true - один і той самий об'єкт

Два виклики, один об'єкт. Ось і весь механізм.

Ключова різниця

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

Коли використовувати

  • Логер - один потік логів для всього застосунку, а не по одному на модуль.
  • Пул з'єднань з БД - розподіляємо обмежені з'єднання по всій кодовій базі.
  • Конфіг застосунку - завантажуємо налаштування один раз при старті, читаємо будь-де.
  • Кеш - одне сховище в пам'яті, консистентний стан між фічами.
  • Не використовуй, коли клас не тримає спільний стан. Утиліта з чистими функціями не потребує синглтону. Якщо потрібно мокати в тестах, розглянь dependency injection.

Як це працює під капотом

V8 (Node.js/Chrome) зберігає приватне статичне поле #instance як прихований атрибут класу, недоступний ззовні. Кожен виклик getInstance() робить швидкий пошук по статичному полю. Якщо поле null - алокує один раз через new і зберігає посилання. Наступні виклики пропускають алокацію повністю.

Одна річ, яка часто дивує: у Node.js з worker threads або в режимі cluster кожен процес має власний простір пам'яті. Тобто кожен отримує свій екземпляр синглтону. Більшість розробників натикаються на це вперше при деплої в балансоване середовище. Якщо потрібен справжній спільний стан між процесами, використовуй зовнішнє сховище типу Redis, а не синглтон.

Типові помилки

Clusters ламають гарантію "одного екземпляру"

javascript
// Кожен воркер-процес виведе різний PID const singleton = require('./singleton'); console.log(singleton.getInstance().id); // воркер 1: 1234, воркер 2: 1235

Якщо застосунок запускає кілька Node.js процесів, синглтон не дає тобі одного спільного екземпляру. Для цього потрібен IPC або Redis.

Публічний конструктор дозволяє обійти захист

javascript
// Неправильно class Config { constructor() { this.settings = {}; } // ніщо не зупиняє new Config() static getInstance() { /* ... */ } } const c1 = new Config(); // обходить getInstance const c2 = Config.getInstance(); // інший об'єкт

Зроби конструктор приватним (TypeScript) або кидай помилку всередині нього (чистий JS).

Тести ламаються через спільний стан

javascript
// Забруднення тестів - Logger зберігає стан між тестами test('логує помилку', () => { Logger.getInstance().log('error'); }); test('лог порожній на старті', () => { // Logger ще тримає запис з попереднього тесту expect(Logger.getInstance().getLogs()).toHaveLength(0); // падає });

Додай метод resetInstance() для тестового середовища або використовуй dependency injection замість голого синглтону.

Серіалізація падає через циклічне посилання

javascript
JSON.stringify(Singleton.getInstance()); // Error: circular structure

Статичне поле instance вказує назад на сам об'єкт. Додай метод toJSON(), який повертає тільки потрібні дані.

Де зустрічається в реальних проектах

  • Winston (Node логер): createLogger() повертає один логер на конфіг транспорту.
  • Express: об'єкт app - один екземпляр на серверний процес.
  • Redux DevTools: один екземпляр стора підключається до React-застосунку.
  • React Context: Provider створює одне значення, яке поширюється вниз по дереву компонентів, фактично синглтон для цього піддерева.
  • Lodash: утилітарний об'єкт є синглтоном на рівні модуля, але варто імпортувати окремі функції для tree-shaking.

Коли потрібні тестовані, мокабельні залежності, подивись на InversifyJS або звичайний DI. Синглтон для справжніх унікальних ресурсів.

Питання на співбесіді

Q: Як зробити синглтон thread-safe у JavaScript?
A: В однопотоковому JS простий if (!instance) достатній. З worker threads паралельні виклики getInstance() під час async-ініціалізації можуть змагатися. Використовуй ??= для умовно атомарного присвоєння або захищай ініціалізацію прапором.

Q: У чому різниця між синглтоном і глобальною змінною?
A: Глобальна змінна - просто значення, яке будь-хто може перезаписати. Синглтон контролює створення (lazy init, рівно один раз) і може нести методи, валідацію та стан. Обидва мають проблему прихованих залежностей.

Q: Як синглтон порушує SOLID?
A: Порушує Single Responsibility (клас керує власним життєвим циклом І виконує свою пряму роботу) і Dependency Inversion (той, хто викликає, хардкодить getInstance() замість отримання інтерфейсу).

Q: Як мокати синглтон у Jest?
A: Використовуй jest.spyOn(MyClass, 'getInstance').mockReturnValue(mockInstance). Або додай статичний resetInstance(), який очищає приватне поле, і викликай його в beforeEach.

Q: (Senior) Чому синглтон для пулу з'єднань з БД - погана ідея в мікросервісах?
A: Кожен pod масштабується незалежно і потребує власного пулу під своє навантаження. Синглтон всередині одного процесу - нормально, але припущення "один пул на весь застосунок" ламається, коли застосунок розбитий на 20 pods. Використовуй рядок підключення в конфізі та бібліотеку пулу типу pg-pool на кожен сервіс.

Приклади

Базовий: TypeScript синглтон для конфігу

typescript
class AppConfig { private static instance: AppConfig; private settings: Record<string, string> = {}; private constructor() { // Конфіг завантажується один раз this.settings = { env: process.env.NODE_ENV ?? 'development' }; } static getInstance(): AppConfig { if (!AppConfig.instance) { AppConfig.instance = new AppConfig(); } return AppConfig.instance; } get(key: string): string { return this.settings[key] ?? ''; } } const config1 = AppConfig.getInstance(); const config2 = AppConfig.getInstance(); console.log(config1 === config2); // true console.log(config1.get('env')); // 'development'

Обидві змінні тримають один і той самий об'єкт. settings завантажується рівно один раз, скільки б модулів не викликали getInstance().

Середній: Спільний логер в Express

javascript
class Logger { static #instance; #logs = []; constructor() { if (Logger.#instance) throw new Error('Використовуй Logger.getInstance()'); Logger.#instance = this; } static getInstance() { return Logger.#instance ?? (Logger.#instance = new Logger()); } log(message) { const entry = `${new Date().toISOString()}: ${message}`; this.#logs.push(entry); console.log(entry); } getLogs() { return this.#logs; } } // Маршрут A і маршрут B використовують один Logger і один масив #logs app.get('/users', (req, res) => { Logger.getInstance().log('GET /users'); res.json([]); }); app.post('/orders', (req, res) => { Logger.getInstance().log('POST /orders'); res.json({ id: 1 }); });

Обидва маршрути викликають getInstance() і отримують один об'єкт, тому #logs накопичує записи з кожного запиту в одному місці.

Senior: Чому тести ламаються без скидання стану

javascript
class Counter { static #instance; #count = 0; static getInstance() { return Counter.#instance ?? (Counter.#instance = new Counter()); } static resetInstance() { Counter.#instance = null; // тільки для тестів } increment() { this.#count++; } value() { return this.#count; } } // Тестовий файл beforeEach(() => Counter.resetInstance()); test('починається з нуля', () => { expect(Counter.getInstance().value()).toBe(0); // проходить щоразу }); test('інкрементує коректно', () => { Counter.getInstance().increment(); expect(Counter.getInstance().value()).toBe(1); // проходить щоразу });

Без resetInstance() другий тест бачив би count рівний 1, залишений першим тестом. Спільний екземпляр - саме та причина, через яку синглтони забруднюють стан тестів, якщо не планувати це заздалегідь.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?