Визначення
Шаблон проектування Singleton є творчим шаблоном проектування, створеним для того, щоб забезпечити наявність лише одного екземпляра класу протягом усього життєвого циклу програми, одночасно надаючи глобальну точку доступу до цього екземпляра.
Категорія
Шаблон проектування Singleton належить до категорії творчих шаблонів проектування, які зосереджуються на забезпеченні механізмів створення об'єктів для підвищення гнучкості та підтримуваності.
Проблема
Розглянемо ситуацію, коли у вас є спільний ресурс, наприклад, пул підключень до бази даних або конфігураційний файл, і вам потрібно забезпечити контрольований та синхронізований доступ до цього ресурсу. Дозволити кільком екземплярам одночасно отримувати до нього доступ може призвести до несумісності даних, умов гонки чи конкуренції за ресурси. Для вирішення цієї проблеми використовується шаблон проектування Singleton. Цей шаблон гарантує, що буде створено лише один екземпляр класу, який керує спільним ресурсом.
Усі подальші виклики для створення екземпляра класу повертають той самий екземпляр, тим самим забезпечуючи єдину точку контролю та послідовність протягом усього життєвого циклу програми.
UML діаграма класу
UML діаграма класу для шаблону проектування Singleton
Як показано на UML діаграмі класу вище, реалізація класу Singleton включає три основні компоненти:
- приватний статичний екземпляр для зберігання єдиного екземпляра класу.
- приватний конструктор за замовчуванням для запобігання прямій інстанціації.
3.
Метод public static getter для надання контрольованого доступу до єдиного екземпляра.
Реалізація
public class DatabaseManager {
// Крок 1: Створити приватний статичний екземпляр класу Singleton
private static DatabaseManager instance;
// Крок 2: Створити приватний конструктор для запобігання інстанціації
private DatabaseManager() {
}
// Крок 3: Надати публічний статичний метод для отримання єдиного екземпляра
public static DatabaseManager getInstance() {
if (instance == null) // Лінива ініціалізація
instance = new DatabaseManager();
return instance;
}
}
Наведену вище частину коду демонструє реалізацію шаблону проектування Singleton для класу менеджера бази даних. Вона відповідає трьом принципам, обговореним раніше: приватний статичний екземпляр, приватний конструктор за замовчуванням і публічний статичний метод getter. Однак, на даний момент, клас не містить жодної значущої бізнес-логіки.
Щоб зробити клас функціональним, давайте розширимо його, включивши основні обов'язки менеджера бази даних, такі як встановлення з'єднання з базою даних — ресурсозатратна операція, що виправдовує використання шаблону Singleton.
Крім того, ми додамо метод для виконання SQL запитів, щоб ще більше продемонструвати його корисність.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class DatabaseManager {
// Крок 1: Створення приватної статичної змінної для єдиного екземпляра класу Singleton
private static DatabaseManager instance;
private Connection connection;
private final String url = "jdbc:mysql://localhost:3306/database_name";
private final String username = "username";
private final String password = "password";
// Крок 2: Створення приватного конструктора для запобігання інстанціації
private DatabaseManager() {
try {
connection = DriverManager.getConnection(url, username, password);
} catch (SQLException e) {
throw new RuntimeException("Не вдалося підключитися до бази даних", e);
}
}
// Крок 3: Надання публічного статичного методу для отримання єдиного екземпляра
public static DatabaseManager getInstance() {
if (instance == null) // Лінива ініціалізація
instance = new DatabaseManager();
return instance;
}
// Крок 4: Надання методів бізнес-логіки
public ResultSet executeQuery(String query, Object...
params) {
try {
PreparedStatement statement = connection.prepareStatement(query);
for (int i = 0; i < params.length; i++) {
statement.setObject(i + 1, params[i]);
}
return statement.executeQuery();
} catch (SQLException e) {
throw new RuntimeException("Помилка при виконанні запиту", e);
}
}
public int executeUpdate(String query, Object...
params) {
try {
PreparedStatement statement = connection.prepareStatement(query);
for (int i = 0; i < params.length; i++) {
statement.setObject(i + 1, params[i]);
}
return statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Помилка при виконанні оновлення", e);
}
}
public void closeConnection() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
}
} catch (SQLException e) {
throw new RuntimeException("Не вдалося закрити з'єднання з базою даних", e);
}
}
}
У вдосконаленому класі DatabaseManager
вище ми додали бізнес-логіку, включивши три публічні методи — executeQuery()
, executeUpdate()
і closeConnection()
, які можуть бути викликані через екземпляр Singleton.
Також ми змінили приватний за замовчуванням конструктор, щоб встановити з'єднання з базою даних, гарантуючи, що воно ініціалізується лише один раз.
Переваги
Контрольований доступ до єдиного екземпляра
- Гарантує, що в додатку існує лише один екземпляр класу.
- Забезпечує глобальний доступ до цього екземпляра.
Ефективність використання ресурсів
- Запобігає непотрібному споживанню ресурсів шляхом обмеження створення об'єктів.
- Корисно для керування дорогими ресурсами, такими як з'єднання з базою даних, конфігураційні налаштування або пули потоків.
Централізоване управління
- Централізує контроль і управління спільними ресурсами.
- Спрощує налагодження та моніторинг, оскільки всі виклики проходять через один і той самий екземпляр.
Послідовність
- Забезпечує послідовний доступ до одного екземпляра, гарантуючи, що всі частини програми використовують один і той самий об'єкт з певним станом.
Лінива ініціалізація
- Екземпляр Singleton може бути створений лише тоді, коли він необхідний (лінива ініціалізація), що дозволяє заощадити пам'ять та обчислювальні ресурси в деяких випадках.
Обмін глобальним станом
- Сприяє обміну даними або станом між різними частинами додатка без необхідності передавати параметри.
Недоліки
Порушення принципу єдиної відповідальності (SRP)
- Клас Singleton виконує три відповідальності: керує своїм екземпляром, надає публічну точку доступу та реалізує бізнес-логіку.
Ускладнене юніт-тестування
- Singleton вводить глобальний стан, що може призвести до прихованих залежностей між тестами.
- Оскільки більшість тестових фреймворків використовують успадкування для створення моків, важко протестувати бізнес-логіку синглетонів.
Щільна зв'язаність
- Класи, що залежать від Singleton, сильно зв'язані з ним, що знижує гнучкість коду.
- Стає важче змінювати реалізації або динамічно впроваджувати залежності.
Проблеми з безпекою потоків
- Правильна реалізація безпечного для потоків Singleton у багатопотоковому середовищі може бути складною.
- Погана реалізація може призвести до таких проблем, як умови гонки або подвійна ініціалізація.
Ось оновлена версія методу getInstance()
всередині класу DatabaseManager
, щоб працювати у багатопоточному середовищі
public static DatabaseManager getInstance() {
if (instance == null) {
synchronized (DatabaseManager.class) { // Використання ключового слова synchronized дозволяє лише одному потоку використовувати блок
if (instance == null) { // Перевірка, чи інший потік не змінив стан
instance = new DatabaseManager();
}
}
}
return instance;
}
Уразливості
Окрім уже згаданих недоліків, шаблон проектування Singleton у Java може бути порушений за допомогою різних методів, включаючи:
1.
Використання рефлексії
Рефлексія дозволяє Java-коду отримувати інформацію про поля, методи та конструктори завантажених класів, а також використовувати ці рефлексивні поля, методи та конструктори для роботи з їх підлеглими компонентами в межах обмежень безпеки.
Oracle
Цю можливість можна використовувати для обходу приватного конструктора Singleton і створення кількох екземплярів.
public class Singleton{
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null)
instance = new Singleton();
return instance;
}
}
import java.lang.reflect.Constructor;
public class SingletonBreaker {
public static void main(String[] args) {
try {
Singleton instance1 = Singleton.getInstance();
Singleton instance2 = null;
Constructor constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
instance2 = constructor.newInstance();
System.out.println("Instance 1 hashcode: " + instance1.hashCode());
System.out.println("Instance 2 hashcode: " + instance2.hashCode());
} catch (Exception e) {
e.printStackTrace();
}
}
}
Виведення:
Instance 1 hashcode: 865113938
Instance 2 hashcode: 312714112
Різні хеш-коди підтверджують, що було створено два різні екземпляри, що порушує принцип Singleton.
**2.
Використання власних ClassLoader
Власний ClassLoader може завантажувати клас Singleton в окремі простори імен, ефективно створюючи кілька екземплярів Singleton у різних контекстах.
import java.io.*;
public class SingletonClassLoaderExample {
public static void main(String[] args) throws Exception {
Singleton instance1 = Singleton.getInstance();
// Завантажуємо клас Singleton в окремому ClassLoader
CustomClassLoader loader = new CustomClassLoader(Singleton.class.getProtectionDomain()
.getCodeSource().getLocation().getPath());
Class singletonClass = loader.loadClass(Singleton.class.getName());
Object instance2 = singletonClass.getMethod("getInstance").invoke(null);
System.out.println("Instance 1 hashcode: " + instance1.hashCode());
System.out.println("Instance 2 hashcode: " + instance2.hashCode());
}
}
class CustomClassLoader extends ClassLoader {
private final String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class findClass(String name) throws ClassNotFoundException {
try {
String fileName = classPath + name.replace('.', '/') + ".class";
byte[] classData = loadClassData(fileName);
return defineClass(name, classData, 0, classData.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
private byte[] loadClassData(String fileName) throws IOException {
try (InputStream input = new FileInputStream(fileName);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1) {
output.write(buffer, 0, bytesRead);
}
return output.toByteArray();
}
}
}
Виведення:
Instance 1 hashcode: 1163157884
Instance 2 hashcode: 1163157884
**3.
Використання серіалізації
Інший спосіб порушити патерн Singleton в Java — це через серіалізацію та десеріалізацію.
Коли клас Singleton серіалізується і потім десеріалізується, процес десеріалізації створює новий екземпляр класу, порушуючи обмеження Singleton.
import java.io.Serializable;
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null)
instance = new Singleton();
return instance;
}
}
Порушення Singleton через серіалізацію:
import java.io.*;
public class SingletonSerializationBreaker {
public static void main(String[] args) {
try {
Singleton instance1 = Singleton.getInstance();
// Серіалізуємо екземпляр Singleton
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("singleton.ser"));
out.writeObject(instance1);
out.close();
// Десеріалізуємо екземпляр Singleton
ObjectInputStream in = new ObjectInputStream(new FileInputStream("singleton.ser"));
Singleton instance2 = (Singleton) in.readObject();
in.close();
System.out.println("Instance 1 hashcode: " + instance1.hashCode());
System.out.println("Instance 2 hashcode: " + instance2.hashCode());
} catch (Exception e) {
e.printStackTrace();
}
}
}
Вивід:
Instance 1 hashcode: 865113938
Instance 2 hashcode: 312714112
Щоб запобігти цьому, можна реалізувати метод readResolve
в класі Singleton.
Цей метод гарантує, що під час десеріалізації буде повернуто оригінальний екземпляр Singleton замість створення нового.
import java.io.Serializable;
public class Singleton implements Serializable {
...
// Гарантуємо, що десеріалізація поверне той самий екземпляр
private Object readResolve() {
return instance;
}
}
Ці приклади демонструють, як можна обійти патерн Singleton у Java, що робить необхідним використання додаткових засобів захисту, таких як енумерації (enums) або практики оборонного програмування, для збереження його цілісності.
Усунення проблем з Singleton за допомогою Enums
Енумерації (enums) вбудовано забезпечують захист від проблем з рефлексією та серіалізацією, що робить їх ідеальним вибором для реалізації Singleton.
public enum DatabaseManager {
INSTANCE
}
Тепер DatabaseManager є енумом з єдиною константою, INSTANCE.
В Java енум з єдиною константою є потокобезпечним і безпечним щодо серіалізації варіантом реалізації патерну Singleton.
Переваги використання енумів для Singleton:
- Потокова безпека (Thread Safety): Ініціалізація енумів за замовчуванням є потокобезпечною в Java.
- Безпека серіалізації (Serialization Safety): Енум правильно обробляє серіалізацію, гарантуючи, що властивість Singleton не буде порушена під час десеріалізації.
- Стійкість до рефлексії (Reflection Resistance): Енум є стійким до атак за допомогою рефлексії, що робить його більш безпечним порівняно з традиційними реалізаціями Singleton.
Використання
public class Main {
public static void main(String[] args) {
DatabaseManager manager = DatabaseManager.INSTANCE;
...
}
}
}
Тепер давайте реалізуємо залишкові методи бізнес-логіки.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public enum DatabaseManager {
INSTANCE;
private Connection connection;
private final String url = "jdbc:mysql://localhost:3306/database_name";
private final String username = "username";
private final String password = "password";
// Ініціалізуємо з'єднання в конструкторі енуму
DatabaseManager() {
try {
connection = DriverManager.getConnection(url, username, password);
} catch (SQLException e) {
throw new RuntimeException("Не вдалося підключитися до бази даних", e);
}
}
public static DatabaseManager getInstance() {
return INSTANCE;
}
public ResultSet executeQuery(String query, Object...
params) {
try {
PreparedStatement statement = connection.prepareStatement(query);
for (int i = 0; i < params.length; i++) {
statement.setObject(i + 1, params[i]);
}
return statement.executeQuery();
} catch (SQLException e) {
throw new RuntimeException("Помилка при виконанні запиту", e);
}
}
public int executeUpdate(String query, Object...
params) {
try {
PreparedStatement statement = connection.prepareStatement(query);
for (int i = 0; i < params.length; i++) {
statement.setObject(i + 1, params[i]);
}
return statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Помилка при виконанні оновлення", e);
}
}
public void closeConnection() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
}
} catch (SQLException e) {
throw new RuntimeException("Не вдалося закрити з'єднання з базою даних", e);
}
}
}
Різниці в коді не так багато, але це значно підвищує стійкість патерну Singleton (Одинак), вирішуючи багато його вразливих місць.
Висновок
Хоча патерн Singleton (Одинак) може бути корисним для керування спільними ресурсами або надання глобального доступу, його недоліки часто переважають над перевагами, особливо в великих або складних системах.
Сучасні підходи, такі як інжекція залежностей (dependency injection) або об'єкти з обмеженим контекстом (scoped objects), часто є кращими альтернативами. Використовуйте патерн Singleton (Одинак) обережно та обмежено, переконуючись, що він відповідає вашому конкретному випадку.
Перекладено з: Understanding the Singleton Design Pattern: Enums, Challenges, and Why It’s Often Criticized as an Anti-Pattern