Паттерн Singleton в PHP

Сегодня мы с вами изучим ещё один паттерн – Singleton. Этот паттерн относится к числу порождающих паттернов проектирования, то есть тех, с помощью которых в нашей программе создаются объекты. Прежде чем перейти непосредственно к самому паттерну синглтон, давайте поймём проблему, которую он решает.

Давайте взглянем более детально на код наших сущностей User и Article. Оба этих класса наследуются от класса ActiveRecordEntity, а следственно имеют методы getById() и findAll(). Давайте посмотрим их код.

src/MyProject/Models/ActiveRecordEntity.php

/**
 * @return static[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}

/**
 * @param int $id
 * @return static|null
 */
public static function getById(int $id): ?self
{
    $db = new Db();
    $entities = $db->query(
        'SELECT * FROM `' . static::getTableName() . '` WHERE id=:id;',
        [':id' => $id],
        static::class
    );
    return $entities ? $entities[0] : null;
}

Как видим, каждый раз при вызове этих методов у нас создаётся новый объект класса Db. Разумеется, это приводит к тому, что каждый раз вызывается конструктор класса Db и устанавливается новое соединение с базой данных.

То есть вот такой код:

$article = Article::getById($articleId);
$nickname = $article->getAuthor()->getNickname();

приведёт к тому, что будет создан новый объект Db и установлено новое соединение с базой данных при:

  1. вызове метода Article::getById($articleId)
  2. вызове метода User::getById($this->authorId) внутри $article-> getAuthor()

То есть одно и то же действие будет выполнено дважды. А ведь создание нового объекта, установка соединения с базой данных - всё это занимает время, а программы нужно писать так, чтобы они выполнялись за минимальное количество времени.

Давайте чтобы убедиться в том, что объект действительно создаётся дважды, создадим статическое свойство у класса Db, в котором будем хранить число вызовов конструктора. Мы уже проделывали подобное в уроке “Статические методы и свойства в PHP”.

Итак, давайте добавим классу статическое свойство $instancesCount, по умолчанию равное нулю.

src/MyProject/Services/Db.php

<?php

namespace MyProject\Services;

class Db
{
    private static $instancesCount = 0;

    /** @var \PDO */
    private $pdo;

    public function __construct()
    {
        self::$instancesCount++;
        ...

Сделаем его приватным. В конструкторе в самом начале будем увеличивать этот счётчик на единицу.
Также давайте добавим публичный статический метод, который будет возвращать значение этого счётчика.

src/MyProject/Services/Db.php

...
public static function getInstancesCount(): int
{
    return self::$instancesCount;
}
...

Давайте теперь временно добавим вывод этого значения в конце выполнения программы. Просто добавим вывод с помощью var_dump() в конце нашего фронт-контроллера.

www/index.php

...
var_dump(\MyProject\Services\Db::getInstancesCount());

Теперь перейдём на страничку со списком статей http://myproject.loc/ и увидим внизу странички значение 1.

Вывод числа экземпляров класса

Всё в порядке – одно единственное соединение с базой данных. Но что будет, если мы перейдём на страничку с одной статьей, где мы выводим автора? Давайте проверим: http://myproject.loc/articles/1 - теперь значение уже 2. А что будет, если мы потом добавим статьям хотя бы такой функционал как рубрики и комментарии? Будет уже создано 4 объекта и установлено 4 соединения с базой! Это будет значительно замедлять наш скрипт.

А как на счёт того, чтобы использовать статическое свойство класса для того, чтобы хранить единственный созданный экземпляр этого класса? То есть в свойство класса мы положим созданный объект класса Db, а потом сможем использовать его, когда нам потребуется. Ведь статические свойства принадлежат классу и всем его объектам целиком и в единственном экземпляре.

Давайте создадим в классе Db статическое свойство $instance, в котором будет храниться созданный объект.

src/MyProject/Services/Db.php

<?php

namespace MyProject\Services;

class Db
{
    private static $instancesCount = 0;

    private static $instance;
    ...

А теперь давайте добавим в этот класс специальный статический метод, который будет делать следующее:

  1. Проверять, что свойство $instance не равно null
  2. Если оно равно null, будет создан новый объект класса Db, а затем помещён в это свойство
  3. Вернёт значение этого свойства.

Давайте напишем этот простейший код:

src/MyProject/Services/Db.php

...
public static function getInstance(): self 
{
    if (self::$instance === null) {
        self::$instance = new self();
    }

    return self::$instance;
}

Теперь мы можем создавать объекты класса Db с помощью этого метода, вот так:

$db = Db::getInstance();

Теперь, когда мы вызовем этот метод несколько раз подряд, то произойдёт следующее:

  1. Во время первого запуска self::$instance будет равен null, поэтому создастся новый объект класса Db и задастся в это свойство. Затем этот объект просто вернётся в качестве результата
  2. При всех последующих запусках в свойстве $instance уже будет лежать объект и условие не выполнится. Вместо создания нового объекта вернётся уже созданный ранее.

А для того чтобы нельзя было в других местах кода создать новые объекты этого класса, стоит сделать конструктор приватным – тогда создать объект можно будет только с помощью этого метода.

src/MyProject/Services/Db.php

private function __construct()
{
    self::$instancesCount++;

    $dbOptions = (require __DIR__ . '/../../settings.php')['db'];

    $this->pdo = new \PDO(
        'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
        $dbOptions['user'],
        $dbOptions['password']
    );
    $this->pdo->exec('SET NAMES UTF8');
}

Если мы теперь попробуем запустить наш скрипт http://myproject.loc/articles/1 то получим ошибку о том, что нельзя вызвать приватный конструктор.

Ошибка при создании объекта с приватным конструктором

Давайте изменим места в коде, в которых мы создавали новые объекты класса Db напрямую. Мы делали это в классе ActiveRecordEntity. Заменим все места с кодом

$db = new Db();

на

$db = Db::getInstance();

Получим следующее:

src/MyProject/Models/ActiveRecordEntity.php

<?php

namespace MyProject\Models;

use MyProject\Services\Db;

abstract class ActiveRecordEntity
{
    /** @var int */
    protected $id;

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    public function __set(string $name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }

    /**
     * @return static[]
     */
    public static function findAll(): array
    {
        $db = Db::getInstance();
        return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
    }

    /**
     * @param int $id
     * @return static|null
     */
    public static function getById(int $id): ?self
    {
        $db = Db::getInstance();
        $entities = $db->query(
            'SELECT * FROM `' . static::getTableName() . '` WHERE id=:id;',
            [':id' => $id],
            static::class
        );
        return $entities ? $entities[0] : null;
    }

    abstract protected static function getTableName(): string;
}

Попробуем снова открыть страничку с выводом одной статьи http://myproject.loc/articles/1.
И что мы видим внизу странички? Число 1! То есть несмотря на то, что мы выполнили 2 запроса к базе данных (получение статьи и получение пользователя), мы при этом создали только один объект базы данных и только одно соединение!

Так вот этот шаблон проектирования называется Singleton (синглтон). Этот паттерн говорит о том, что в рамках одного запущенного приложения будет гарантироваться что будет использован только один объект какого-то класса. Классы, реализующие паттерн синглтон сами гарантируют, что будет использоваться только один их экземпляр – создать объекты можно только с помощью специального метода, ведь конструктор больше недоступен извне. А этот метод следит за тем, чтобы не было более одного созданного объекта и предоставляет единую точку доступа к этому экземпляру. Вот и вся суть паттерна Singleton.

Давайте теперь приберемся и удалим вывод отладочной информации во фронт-контроллере www/index.php, а также удалим логику подсчёта числа созданных экземпляров в классе Db – она нам больше не нужна, так как всегда теперь будет один объект.

src/MyProject/Services/Db.php

<?php

namespace MyProject\Services;

class Db
{
    private static $instance;

    /** @var \PDO */
    private $pdo;

    private function __construct()
    {
        $dbOptions = (require __DIR__ . '/../../settings.php')['db'];

        $this->pdo = new \PDO(
            'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
            $dbOptions['user'],
            $dbOptions['password']
        );
        $this->pdo->exec('SET NAMES UTF8');
    }

    public function query(string $sql, array $params = [], string $className = 'stdClass'): ?array
    {
        $sth = $this->pdo->prepare($sql);
        $result = $sth->execute($params);

        if (false === $result) {
            return null;
        }

        return $sth->fetchAll(\PDO::FETCH_CLASS, $className);
    }

    public static function getInstance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }

        return self::$instance;
    }
}

Теперь во всех местах, где нам нужна будет база данных, мы будем писать:

Db::getInstance()

Вот такой вот довольно простой но очень полезный паттерн проектирования, о котором начинающих часто спрашивают на собеседовании.

Текущая версия проекта на гитхабе.

loader
Логические задачи с собеседований