Ломаем паттерн проектирования — Singleton в PHP

Одним прекрасным рабочим днём я писал unit-тесты для бизнес-логики на проекте, в котором работаю. Передо мною стояла задача инициализировать некоторые приватные свойства класса определёнными значениями.


Обычными сеттерами нельзя было пользоваться, так как там была прописана некая логика. Унаследовать или замокать класс тоже не получалось, потому что он объявлён финальным. И даже рефлексия не подошла. Поэтому я начал искать варианты решения этой проблемы.


Нашел интересную статью, в которой описано, как с помощью бибилотеки dg/bypass-finals можно замокать финальный класс. Этот вариант мне понравился и я попробовал его внедрить. К сожалению, у меня ничего не получилось, так как на проекте используется старая версия PHPUnit.


Поразмыслив, я вспомнил о классе Closure, а конкретно о его статическом методе bind(), который умеет внедрять анонимные функции в контекст нужного объекта какого-либо класса. Больше информации об этом можно найти в официальной документации. Поэтому я создал трейт, который использовал в своих тестах (может кому-то тоже будет полезен)


trait PrivatePropertySetterTrait
{
    protected function assignValue($object, string $attribute, $value)
    {
        $setter = function ($value) use ($attribute) {
            $this->$attribute = $value;
        };

        $setterClosure = \Closure::bind($setter, $object, \get_class($object));
        $setterClosure($value);
    }
}

Данный трейт принимает объект класса, название свойства, куда нужно установить значение и, собственно, само значение. Далее объявляется простая анонимная функция, которая с помощью указателя $this присваивает полученное значение в свойство класса. Дальше в бой идёт класс Closure с его статическим методом bind(). Метод принимает объект класса, анонимную функцию, описанную выше, и полное имя класса. Таким образом, анонимная функция внедряется в контекст объекта и метод bind() возвращает нам объект класса Closure, который мы можем вызвать как обычную функцию, потому как он определяет магический метод __invoke(). И вуаля!


В итоге мне удалось решить мою проблему, и тогда я вспомнил о шаблоне проектирования Singleton. Получится ли таким же способом внедрить анонимную функцию, которая будет создавать новые объекты класса? Конечно же я пошёл это проверять!


Написав небольшой кусок кода


Песочница с кодом

<?php

final class Singleton
{
    private static $instance;

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

        return self::$instance;
    }

    private function __construct()
    {
    }

    private function __clone()
    {
    }

    private function __wakeup()
    {
    }
}

$s1 = Singleton::getInstance();
\var_dump(\spl_object_id($s1));

$createNewInstance = function () {
    return new self();
};
$newInstanceClosure = Closure::bind($createNewInstance, $s1, Singleton::class);

$s2 = $newInstanceClosure();
\var_dump(\spl_object_id($s2));

который работает по такому же принципу, только вместо присвоения значения свойству класса — создаётся новый объект с помощью оператора new. Функция \spl_object_id() возвращает уникальный идентификатор объекта. Больше информации об этой функции можно найти в документации. С помощью spl_object_id() и var_dump() вывожу уникальные идентификаторы объектов и вижу то что они отличаются! Мне всё же удалось подтвердить эту теорию и создать новый екземпляр Singleton класса!


В этой статье я хотел поделиться с сообществом PHP моей весьма любопытной находкой.


Спасибо за внимание!

Share post

Comments 24

    +3
    Если вам нужно инициализировать для тестов что-то приватное в классе через вот такие хитрые подвыподверты, то в первую очередь стоит подумать, а хороший ли это код? Описанный способ интересный, однако думаю стоит все таки отрефакторить класс в который вы так нагло врываетесь
      +1

      Здесь основной посыл статьи не в том, как тесты в итоге реализовать пришлось, а в том, что с помощью Closure::bind() действительно можно сломать то, что якобы сломать невозможно.


      Если мы заменим код на такой:


      $createNewInstance = function () {
          $instance =  new self();
          self::$instance = $instance;
          return $instance;
      };

      то мы вовсе заменим оригинальный объект новым.


      Что-то здесь действительно не так, мы с помощью этого функционала можем вообще влезть в логику любого класса, работать с приватными свойствами и методами. Понятие инкапсуляция теперь очень размытое.


      Следующим что будет, добавление новых методов в классы? :)

        0
        Да, я понимаю. Не только такие методы есть для того чтобы влезать внутрь «мозгов». Есть спец расширения для ре-дефайна переменных и подмены статических функций и тп. Но философский вопрос: а стоит ли о таком вообще знать и на кой черт это вообще создали разработчики PHP если вроде как мы тут все в мире боремся за качество кода?
          0

          Такие вещи часто нужны чтобы не выдавать наружу все кишки объекта для инфраструктурных задач типа (де)сериализации, (де)гидратации, ленивой загрузки и того же тестирования. Они позволяют не иметь в объекте сеттеров, не нарушать SRP и т. п.

            0

            Не совсем понятно чем рефлексия не подошла. Если только, как указали ниже, ради производительности

              +1
              Скорее всего именно в производительности дело, рефлексия — дорогая. А в универсальных решениях часто рефлексия используется для анализа, получения списка свойств, а кложуры для собственно манипуляций. В идеале вообще рефлексия в «компайл-тайме» (кодогенерация) осуществляется, а в «ран-тайме» уже заточенные под классы акцессоры.
                0
                Именно, не использовал рефлексию для улучшения производительности тестов.
              +3
              Более того, это вовсю используется в PSR-реализации кеша той же симфони: github.com/symfony/cache/blob/master/Adapter/AbstractAdapter.php#L41
              +2
              А рефлексия вам инкапсуляцию не размывала?
              0
              Мне нужно было задать начальное состояние объекта, для того чтобы протестировать изменение этого состояния. Грубо говоря — протестировать сеттер для свойства класса, так как в нём есть логика валидации.
                +1
                начальное состояние должно задаваться в самом классе вроде private $_var = null;
                перед запуском сеттера вы можете убедиться что начальное состояние задано в дефолт через геттер.
                тут вообще не нужно лезть в потроха класса, если там приватные переменные вас как вызывающего вообще не должно волновать что там внутри. сделайте black-box тестирование на этот класс и все
                  +1
                  Согласен. Спасибо!
                +2
                Ваши тесты проверяют не поведение, а внутреннее состояние экземпляра класса, которое, вобщем то, клиентский код волновать не должно.
                Зачем нужны модульные тесты, которые взаимодействуют с объектом совершенно иным образом, чем это делает клиентский код?
                  –2
                  Слово «модульные» вы произнесли первым, в статье этого нет.
                    +1

                    "Одним прекрасным рабочим днём я писал unit-тесты ..."

                    +1
                    Мне кажется это справедливо не только для модульных тестов. Если внешний код не может что-то сделать (неважно клиентский он или просто другой класс ваш же), то и проверять эту функциональность большого смысла нет.
                    Но при этом лезть в класс как это делал автор поста вполне может понадобиться — когда это легаси код рефакторить который сейчас слишком дорого, а его тестируемость хромает, то это может оказаться самым быстрым, простым и в итоге правильным способом протестировать что-либо. Например когда как в посте нужно проверить поведение в определенном состоянии.
                    0
                    Можно ли редактировать класс, в котором надо инициализировать приватные свойства? Если да, то можно добавить в класс функцию, которая инициализирует свойства.
                      0

                      Нарушение SRP, нет?

                      0
                      Обычными сеттерами нельзя было пользоваться, так как там была прописана некая логика. Унаследовать или замокать класс тоже не получалось, потому что он объявлён финальным. И даже рефлексия не подошла.

                      ЧЯДНТ:


                      final class Foo {
                        private $prop;
                      
                        public function __construct() {
                          $this->prop = 1;
                        }
                      
                        public function getProp() {
                          return $this->prop;
                        }
                      }
                      
                      $foo = new Foo();
                      $fooR = new ReflectionObject($foo);
                      $prop = $fooR->getProperty('prop');
                      $prop->setAccessible(true);
                      $prop->setValue($foo, 2);
                      echo $foo->getProp();

                      Песочница

                        +2
                        На сколько я помню из своих тестов, вариант с биндингом примерно в три раза быстрее рефлексии.
                        –1
                        Удаленный комментарий
                          0
                          есть ли смысл здесь да и вообще в Singleton использовать позднее статическое связывание —
                          вместо self — static?
                            0
                            Смысла нету так как класс объявлен финальным и мы не можем его унаследовать.

                          Only users with full accounts can post comments. Log in, please.