Патерн синглтон
Патерн синглтон (Singleton) - це креаційний патерн проектування, який обмежує клас єдиним екземпляром і надає глобальну точку доступу до нього.
Теорія
Коротко
- Аналогія: Білий дім - одна будівля, один президент, усі звертаються через одну адресу.
- Головна різниця: звичайний клас дозволяє викликати
newскільки завгодно; синглтон блокує коженnewпісля першого. - Використовуй, коли один об'єкт має контролювати спільний ресурс (логер, пул DB, конфіг). Для утиліт без стану не потрібен.
- У Node.js clusters кожен процес отримує власний екземпляр синглтону. Між процесами він НЕ ділиться.
Швидкий приклад
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 ламають гарантію "одного екземпляру"
// Кожен воркер-процес виведе різний PID
const singleton = require('./singleton');
console.log(singleton.getInstance().id); // воркер 1: 1234, воркер 2: 1235Якщо застосунок запускає кілька Node.js процесів, синглтон не дає тобі одного спільного екземпляру. Для цього потрібен IPC або Redis.
Публічний конструктор дозволяє обійти захист
// Неправильно
class Config {
constructor() { this.settings = {}; } // ніщо не зупиняє new Config()
static getInstance() { /* ... */ }
}
const c1 = new Config(); // обходить getInstance
const c2 = Config.getInstance(); // інший об'єктЗроби конструктор приватним (TypeScript) або кидай помилку всередині нього (чистий JS).
Тести ламаються через спільний стан
// Забруднення тестів - Logger зберігає стан між тестами
test('логує помилку', () => {
Logger.getInstance().log('error');
});
test('лог порожній на старті', () => {
// Logger ще тримає запис з попереднього тесту
expect(Logger.getInstance().getLogs()).toHaveLength(0); // падає
});Додай метод resetInstance() для тестового середовища або використовуй dependency injection замість голого синглтону.
Серіалізація падає через циклічне посилання
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 синглтон для конфігу
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
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: Чому тести ламаються без скидання стану
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, залишений першим тестом. Спільний екземпляр - саме та причина, через яку синглтони забруднюють стан тестів, якщо не планувати це заздалегідь.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.