Каждый раз при обсуждении программного обеспечения с другими разработчиками всплывает тема синглтонов, особенно в контексте развития WordPress’а. Я часто пытаюсь объяснить, почему их надо избегать, даже если они считаются стандартным шаблоном.
В данной статье я попытаюсь раскрыть тему того, почему синглтоны никогда не должны использоваться в коде и какие есть альтернативы для решения похожих проблем.
Что такое синглтон?
Синглтон — это шаблон проектирования в разработке программного обеспечения, описанный в книге Design Patterns: Elements of Reusable Object-Oriented Software (авторы — Банда четырёх), благодаря которой о шаблонах проектирования заговорили как об инструменте разработки ПО.
Идея в том, что вам может потребоваться, чтобы существовал лишь один экземпляр класса и чтобы вы предоставляли глобальную единую точку доступа к нему.
Это на самом деле достаточно просто объяснить и понять, и для многих людей синглтон — это лёгкий вход в мир шаблонов проектирования, что делает его самым популярным шаблоном.
Синглтон популярен, он был одним из первых шаблонов, описанных и стандартизированных в книге. Как же так получается, что некоторые разработчики считают его антишаблоном? Неужели он может быть настолько плохим?
Да.
Да, может.
Но синглтоны полезны и важны!
Я заметил, что многие люди путают два смежных понятия. Когда они говорят, что им нужен синглтон, им на самом деле нужно использовать один экземпляр объекта в разных операциях инстанцирования. В общем, когда вы создаёте инстанс, вы создаёте новый экземпляр этого класса. Но для некоторых объектов нужно всегда использовать один и тот же общий экземпляр (shared instance) объекта, вне зависимости от того, где он используется.
Но синглтон не является верным решением для этого.
Путаница вызвана тем, что синглтон объединяет две функции (responsibilities) в одном объекте. Допустим, есть синглтон для подключения к базе данных. Давайте назовём его (очень изобретательно) DatabaseConnection
. У синглтона теперь две главных функции:
- Управление подключением.
- Управление инстансами
DatabaseConnection
.
Именно из-за второй функции люди выбирают синглтон, но эту задачу должен решать другой объект.
Нет ничего плохого в общем экземпляре. Но объект, который вы хотите для этого использовать, — не место для такого ограничения.
Ниже я покажу несколько альтернатив. Но сначала хочу рассказать, какие проблемы может вызвать синглтон.
Проблемы с синглтоном
Синглтон и SOLID
Прежде всего, и это может показаться скорее теоретической проблемой, синглтон нарушает многие принципы SOLID.
- S — принцип единственной ответственности. Очевидно, что синглтон противоречит ему, как уже говорилось раньше.
- O — принцип открытости/закрытости: объекты должны быть открыты для расширения, но закрыты для изменения. Синглтон нарушает данный принцип, так как контролирует точку доступа и возвращает только самого себя, а не расширение.
- L — принцип подстановки Барбары Лисков: объекты могут быть заменены экземплярами своих подтипов без изменения использующего их кода. Это неверно в случае с синглтоном, потому что наличие нескольких разных версий объекта означает, что это уже не синглтон.
- I — принцип разделения интерфейса: много специализированных интерфейсов лучше, чем один универсальный. Это единственный принцип, который синглтон нарушает не напрямую, но лишь потому, что он не позволяет использовать интерфейс.
- D — принцип инверсии зависимостей: вы должны зависеть только от абстракций, а не от чего-то конкретного. Синглтон нарушает его, потому что в данном случае зависеть можно только от конкретного экземпляра синглтона.
Шаблон синглтон нарушает четыре из пяти принципов SOLID. Он, возможно, хотел бы нарушить и пятый, если бы только мог иметь интерфейсы на первом месте…
Легко сказать, что ваш код не работает только из-за каких-то теоретических принципов. И хотя, согласно моему собственному опыту, эти принципы — самое ценное и надёжное руководство, на которое можно опираться при разработке программного обеспечения, я понимаю, что просто слова «это факт» для многих звучат неубедительно. Мы должны проследить влияние синглтона на вашу повседневную практику.
Использования шаблона синглтон
Здесь перечислены недостатки, с которыми можно столкнуться, если иметь дело с синглтоном:
- Вы не можете передавать/внедрять аргументы в конструктор. Поскольку при первом вызове синглтона реально исполняется только конструктор и вы не можете знать заранее, какой код первым обратится к синглтону, то во всём потребляющем коде нужно использовать один и тот же набор аргументов для передачи в конструктор, что во многих случаях почти невыполнимо, а вначале вообще бессмысленно. В результате синглтон делает бесполезным основной механизм инстанцирования в ООП-языках.
- Вы не можете имитировать (mock away) синглтон при тестировании компонентов, использующих его. Это делает почти невозможным корректное модульное тестирование, потому что вы не добьётесь полной изоляции тестируемого кода. Проблема вызвана даже не самой логикой, которую вы хотите тестировать, а произвольным ограничением инстанцирования, в которое вы её оборачиваете.
- Так как синглтон — глобально доступная конструкция, которая используется всей вашей кодовой базой, то идут прахом любые усилия по инкапсуляции, отчего появляются те же проблемы, что и в случае с глобальными переменными. То есть, как бы вы ни пытались изолировать синглтон в инкапсулированной части кода, любой другой внешний код может привести к побочным эффектам и багам в синглтоне. А без надлежащей инкапсуляции выхолащиваются сами принципы ООП.
- Если у вас когда-либо был сайт или приложение, разросшееся настолько, что синглтону
DatabaseConnection
неожиданно понадобилось подключение ко второй, отличной от первой базе данных, значит, вы в беде. Придётся заново пересмотреть саму архитектуру и, возможно, полностью переписать значительную часть кода. - Все тесты, прямо или косвенно использующие синглтон, не могут корректно переключаться с одного на другой. Они всегда сохраняют состояние посредством синглтона, что может привести к неожиданному поведению там, где ваши тесты зависят от очерёдности запуска, или оставшееся состояние скроет от вас реальные баги.
- Своё одиночное инстанцирование синглтоны принудительно применяют к пространству текущего процесса, что подходит для статичной области видимости. Это означает появление проблем с распараллеливанием, когда у вас несколько процессов или распределённое выполнение. Это должно намекать на вероятность того, что синглтоны — всего лишь ошибочная концепция, которая ломается сразу же, как только вы начинаете работать в распределённой системе.
Альтернативы синглтону
Я не хочу быть тем человеком, который во всём видит плохое, но не может предложить решение проблемы. Хотя я считаю, что вы должны оценивать всю архитектуру приложения, чтобы решить, как первым делом избежать использования синглтона, я предлагаю несколько самых распространённых способов в WordPress, когда синглтон можно легко заменить механизмом, который удовлетворяет всем требованиям и лишён большинства недостатков. Но прежде чем рассказать об этом, я хочу отметить, почему все мои предложения — только компромисс.
Существует «идеальная структура» для разработки приложений. Теоретически лучший вариант — единственный инстанцирующий вызов в загрузочном коде, создающий дерево зависимости приложения целиком, сверху донизу. Это будет работать так:
- Инстанцирование
App
(нужныConfig
,Database
,Controller
). - Инстанцирование
Config
для внедрения вApp
. - Инстанцирование
Database
для внедрения вApp
. - Инстанцирование
Controller
для внедрения вApp
(нужныRouter
,Views
). - Инстанцирование
Router
для внедрения вController
(нуженHTTPMiddleware
). - …
С помощью одного вызова выстроится сверху донизу весь стек приложения с внедрением зависимостей по мере необходимости. Цели такого подхода:
- У каждого объекта есть точный список нужных ему зависимостей, и только их он должен использовать. Когда что-либо ломается, вы можете легко изолировать ответственный за это код.
- Тесной связи между объектами нет, все объекты при использовании внедрённых реализаций полагаются только на интерфейсы.
- Глобального статуса нет. Каждое отдельное поддерево, стоящее выше по иерархии, будет корректно изолировано от остальных, благодаря чему разработчик не наделает багов в модуле Б, изменяя модуль А.
Однако, как бы хорошо это ни звучало, в WordPress’е так сделать невозможно, так как он не предоставляет централизованный контейнер или механизм внедрения, все плагины/темы загружаются изолированно.
Держите это в уме, пока мы будем обсуждать подходы. Идеальное решение, при котором весь стек WordPress’а инстанцируется через централизованный механизм внедрения, нам недоступно, поскольку оно требует поддержки WordPress Core. Всем описываемым далее подходам свойственны те или иные общие недостатки вроде сокрытия зависимостей посредством обращения к ним напрямую из логики вместо их внедрения.
Код синглтона
Пример кода с использованием синглтон-подхода, который мы будем сравнивать с другими:
// Синглтон.
final class DatabaseConnection {
private static $instance;
private function __construct() {}
// Вызов для получения одного настоящего экземпляра.
public static function get_instance() {
if ( ! isset( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
// Код логики смешан с кодом механизма инстанцирования.
public function query( ...$args ) {
// Исполняется запрос и возвращается результат.
}
}
// Потребляющий код.
$database = DatabaseConnection::get_instance();
$result = $database->query( $query );
Я не включил сюда все подробности реализации, с которыми часто загружаются синглтоны, потому что они неважны для теоретической дискуссии.
Фабричный метод
В большинстве случаев лучший способ уйти от проблем, связанных с синглтоном, — использовать шаблон проектирования «фабричный метод». Фабрика — это объект, чья единственная обязанность — инстанцировать другие объекты. Вместо DatabaseConnectionManager
, который делает собственный экземпляр с помощью метода get_instance()
, у вас есть DatabaseConnectionFactory
, создающий экземпляры объекта DatabaseConnection
. В общем, фабрика всегда будет производить новые экземпляры нужного объекта. Но на основании запрошенного объекта и контекста фабрика может сама решать, создавать ли новый экземпляр или всегда расшаривать какой-то один.
Учитывая название шаблона, вы можете подумать, что он больше похож на код Java, чем PHP-код, так что не стесняйтесь отклоняться от слишком строгого (и ленивого) соглашения об именовании и называйте фабрику более изобретательно.
Пример фабричного метода:
// Фабрика.
final class Database {
public function get_connection(): DatabaseConnection {
static $connection = null;
if ( null === $connection ) {
// Здесь может быть произвольная логика, решающая, какую реализацию использовать.
$connection = new MySQLDatabaseConnection();
}
return $connection;
}
}
// Здесь у нас интерфейс, так что вы можете работать с несколькими реализациями и корректно имитировать (mock) ради тестирования.
interface DatabaseConnection {
public function query( ...$args );
}
// Используемая в данный момент реализация.
final class MySQLDatabaseConnection implements DatabaseConnection {
public function query( ...$args ) {
// Исполняет запрос и возвращает результат.
}
}
// Потребляющий код.
$database = ( new Database )->get_connection();
$result = $database->query( $query );
Как видите, потребляющий код не так объёмен и несложен, только есть один нюанс. Мы решили называть фабрику Database
вместо DatabaseConnection
, так как это часть предоставляемого нами API, и мы всегда должны стремиться к балансу между логической точностью и элегантной краткостью.
Приведённая версия фабрики избавлена почти от всех ранее описанных недостатков, за одним исключением.
- Мы убрали тесную взаимосвязь с объектом
DatabaseConnection
, но вместо этого создали новую, с фабрикой. Это не проблематично, потому что фабрика — чистая абстракция, вероятность того, что в какой-то момент понадобится отойти от концепции «инстанцирования», очень мала. Если это произойдёт, то, возможно, придётся пересмотреть всю парадигму ООП.
Вы, наверное, начинаете удивляться, что мы больше не можем принудительно ограничиваться единственным инстанцированием. Хотя мы всегда отдаём общий экземпляр реализации DatabaseConnection
, кто угодно всё ещё может выполнить new MySOLDatabaseConnection
и получить доступ к дополнительному экземпляру. Да, это так, и это одна из причин отказа от синглтона. Но это не всегда даёт преимущества в реальных задачах, поскольку делает невозможным соблюдение базовых требований вроде модульного тестирования.
Статичные заместители
Статичный заместитель (Static Proxy) — другой шаблон проектирования, на который можно поменять синглтон. Он подразумевает ещё более тесную связь, чем фабрика, но это хотя бы связь с абстракцией, а не с конкретной реализацией. Идея в том, что у вас есть статичное сопоставление (static mapping) интерфейса, и эти статичные вызовы прозрачно перенаправляются конкретной реализации. Таким образом, прямой связи с фактической реализацией нет, и статичный заместитель сам решает, как выбирать реализацию для использования.
// Статичный заместитель.
final class Database {
public static function get_connection(): DatabaseConnection {
static $connection = null;
if ( null === $connection ) {
// You can have arbitrary logic in here to decide what
// implementation to use.
$connection = new MySQLDatabaseConnection();
}
return $connection;
}
public static function query( ...$args ) {
// Forward call to actual implementation.
self::get_connection()->query( ...$args );
}
}
// Здесь у нас интерфейс, так что мы можем работать с несколькими реализациями и корректно имитировать (mock) ради тестирования.
interface DatabaseConnection {
public function query( ...$args );
}
// Используемая в данный момент реализация.
final class MySQLDatabaseConnection implements DatabaseConnection {
public function query( ...$args ) {
// Исполняется запрос и возвращается результат.
}
}
// Потребляющий код.
$result = Database::query( $query );
Как видите, статичный заместитель создаёт очень короткий и чистый API. К недостаткам можно отнести то, что возникает тесная связь кода с сигнатурой класса. При использовании в правильном месте особых проблем это не вызывает, так как это связь с абстракцией, которую можно контролировать напрямую, а не с конкретной реализацией. Вы всё ещё можете заменить код одной базы данных на код другой, который вы считаете нужным, а реализация всё ещё является совершенно нормальным объектом, который может быть протестирован.
API WordPress Plugin
API WordPress Plugin может заменить синглтоны, когда те используются ради возможности обеспечения глобального доступа через плагины. Это самое чистое решение с учётом ограничений WordPress’а, с оговоркой, что вся инфраструктура и архитектура вашего кода привязывается к API WordPress Plugin. Не применяйте этот способ, если вы собираетесь заново использовать ваш код в разных фреймворках.
// Здесь у нас интерфейс, так что мы можем работать с несколькими реализациями и корректно имитировать (mock) ради тестирования.
interface DatabaseConnection {
const FILTER = 'get_database_connection';
public function query( ...$args );
}
// Используемая в данный момент реализация.
class MySQLDatabaseConnection implements DatabaseConnection {
public function query( ...$args ) {
// Исполняется запрос и возвращается результат.
}
}
// Инициализирующий код.
$database = new MySQLDatabaseConnection();
add_filter( DatabaseConnection::FILTER, function () use ( $database ) {
return $database;
} );
// Потребляющий код.
$database = apply_filters( DatabaseConnection::FILTER );
$result = $database->query( $query );
Один из основных компромиссов состоит в том, что ваша архитектура напрямую привязана к API WordPress Plugin. Если вы планируете когда-либо предоставлять функциональность плагина для Drupal-сайтов, то код придётся полностью переписать.
Другая возможная проблема — теперь вы зависите от тайминга WordPress-перехватчиков (hooks). Это может привести к багам, связанным с таймингом, их зачастую трудно воспроизвести и исправить.
Service Locator
Локатор служб — это одна из форм контейнера инверсии управления (Inversion of Control Container). Некоторые сайты описывают метод как антишаблон. С одной стороны, это правда, но с другой, как мы уже обсуждали выше, все предложенные здесь рекомендации можно считать лишь компромиссами.
Локатор служб — это контейнер, который предоставляет доступ к службам, реализованным в других местах. Контейнер по большей части — это коллекция экземпляров, сопоставленных с идентификаторами. Более сложные реализации локатора служб могут привносить такие возможности, как ленивое инстанцирование или генерирование заместителей.
// Здесь у нас интерфейс контейнера, так что мы можем менять реализации локатора служб.
interface Container {
public function has( string $key ): bool;
public function get( string $key );
}
// Базовая реализация локатора служб.
class ServiceLocator implements Container {
protected $services = [];
public function has( string $key ): bool {
return array_key_exists( $key, $this->services );
}
public function get( string $key ) {
$service = $this->services[ $key ];
if ( is_callable( $service ) ) {
$service = $service();
}
return $service;
}
public function add( string $key, callable $service ) {
$this->services[ $key ] = $service;
}
}
// Здесь у нас интерфейс, так что мы можем работать с несколькими реализациями и корректно имитировать (mock) ради тестирования.
interface DatabaseConnection {
public function query( ...$args );
}
// Используемая в данный момент реализация.
class MySQLDatabaseConnection implements DatabaseConnection {
public function query( ...$args ) {
// Исполняется запрос и возвращается результат.
}
}
// Инициализирующий код.
$services = new ServiceLocator();
$services->add( 'Database', function () {
return new MySQLDatabaseConnection();
} );
// Потребляющий код.
$result = $services->get( 'Database' )->query( $query );
Как вы уже могли догадаться, проблема получения ссылки на экземпляр $services
не пропала. Её можно решить, объединив этот метод с любым из предыдущих трёх.
- С фабрикой:
$result = ( new ServiceLocator() )->get( 'Database' )->query( $query );
- Со статичным заместителем:
$result = Services::get( 'Database' )->query( $query );
- С API WordPress Plugin:
$services = apply_filters( 'get_service_locator' ); $result = $services->get( 'Database' )->query( $query );
Однако всё ещё нет ответа на вопрос, нужно ли пользоваться антишаблоном локатор служб вместо антишаблона синглтон… С локатором служб связана проблема: он «прячет» зависимости. Представим кодовую базу, которая использует правильное внедрение конструктора. В таком случае достаточно взглянуть на конструктор конкретного объекта, и можно сразу понять, от какого объекта он зависит. Если объект имеет доступ к ссылке на локатор служб, то вы можете обойти это явное разрешение зависимостей и извлечь ссылку (а следовательно, начать зависеть) на любой объект из реальной логики. Вот что имеют в виду, когда говорят, что локатор служб «прячет» зависимости.
Но, учитывая контекст WordPress, мы должны принять тот факт, что с самого начала нам недоступно идеальное решение. Нет технических возможностей реализовать правильное внедрение зависимостей по всей кодовой базе. Это значит, что нам в любом случае придётся искать компромисс. Локатор служб — не идеальное решение, однако этот шаблон хорошо укладывается в легаси-контекст и как минимум позволяет вам собрать все «компромиссы» в одном месте, а не раскидывать их по кодовой базе.
Внедрение зависимостей
Если вы работаете только в собственном плагине и вам не надо предоставлять доступ к своим объектам другим плагинам, то вам повезло: вы можете использовать настоящее внедрение зависимостей, чтобы избежать глобального доступа к зависимостям.
// Здесь у нас интерфейс, так что мы можем работать с несколькими реализациями и корректно имитировать (mock) ради тестирования.
interface DatabaseConnection {
public function query( ...$args );
}
// Используемая в данный момент реализация.
class MySQLDatabaseConnection implements DatabaseConnection {
public function query( ...$args ) {
// Исполняется запрос и возвращается результат.
}
}
// Для демонстрации идеи мы вынуждены смоделировать весь плагин.
class Plugin {
private $database;
public function __construct( DatabaseConnection $database ) {
$this->database = $database;
}
public function run() {
$consumer = new Consumer( $this->database );
return $consumer->do_query();
}
}
// Потребляющий код.
// Для демонстрации внедрения конструктора также смоделирован как целый класс.
class Consumer {
private $database;
public function __construct( DatabaseConnection $database ) {
$this->database = $database;
}
public function do_query() {
// А вот настоящий потребляющий код.
// В этом месте у нас внедрено произвольное подключение к базе данных.
return $this->database->query( $query );
}
}
// Внедрение зависимости из загрузочного кода по всему дереву.
$database = new MySQLDatabaseConnection();
$plugin = new Plugin( $database );
$result = $plugin->run();
Выглядит немного сложнее, но имейте в виду, что мы должны были написать базовую версию всего плагина, чтобы продемонстрировать внедрение зависимостей.
Хотя у нас не может быть полного внедрения зависимостей во всём приложении, но мы, по крайней мере, можем получить его с ограничениями в плагине.
Это пример того, как всё соединить (wiring) вручную с помощью явного инстанцирования самих зависимостей. В более сложной кодовой базе вам захочется использовать автосоединяющееся внедрение зависимостей (Dependency Injector) (специализированный контейнер), которое принимает предварительную информацию о конфигурации и может рекурсивно инстанцировать целое дерево за один вызов.
Вот пример того, как можно сделать это соединение с помощью такого внедрения зависимостей (даны те же классы/интерфейсы, как и в предыдущем примере):
// Позволяет внедрению узнать, какую реализацию использовать для разрешения (resolving) интерфейса DatabaseConnection.
$injector->alias( DatabaseConnection::class, MySQLDatabaseConnection::class );
// Позволяет внедрению узнать, что на запросы DatabaseConnection оно всегда должно возвращать один и тот же общий экземпляр.
$injector->share( DatabaseConnection::class );
// Позволяет внедрению инстанцировать класс Plugin, который заставит его рекурсивно обойти все конструкторы и инстанцировать объекты, чтобы решить зависимости.
$plugin = $injector->make( Plugin::class );
Комбинации
Для более сложных потребностей, которые разбросаны по нескольким пользовательским и сторонним плагинам, рассмотрите способы комбинирования рассмотренных подходов, которые не будут противоречить вашим условиям.
Например, в более сложных проектах я использовал следующий подход, чтобы приблизиться к идеальному решению, который описано выше:
- Каждый плагин инстанцирован с помощью централизованного автосоединяющегося внедрения зависимостей.
- Каждый плагин — это поставщик услуг (service provider), который может регистрировать службы с помощью централизованного локатора службы.
- Зависимости внутри плагина —> внедрение зависимостей.
- Зависимости между плагинами —> обнаружение служб (service location).
- Сторонние зависимости —> виртуальные службы, в которые обёрнута сторонняя функциональность.
На странице Bright Nucleus Architecture вы можете почитать об этом подходе и посмотреть записи.
Заключение
Несколько возможных подходов позволяют избавиться от синглтонов. Хотя ни один из них не идеален в контексте WordPress’а, они все без исключения предпочтительней синглтона.
Помните, что связанные с синглтонами проблемы заключаются не в том, что те расшарены, а в том, что синглтоны заставляют инстанцировать их самих.
Если вы знаете ситуации, когда синглтон — единственное подходящее решение, пишите в комментариях!
Автор: AloneCoder