Domain-driven design советует создавать агрегаты и другие сложные объекты в фабриках. Но существует ли возможность запретить создание таких объектов вне фабрик? В PHP мы можем объявить конструктор класса приватным либо защищенным, и тогда объект этого класса можно создать только внутри статического метода этого же класса. С моей точки зрения, это нарушает принцип единственной ответственности (SRP) и не позволяет создавать объекты в классе-фабрике. Может есть другой способ?

Если мы хотим создавать объекты класса вне этого класса, его конструктор должен быть публичным. Значит ли это, что мы должны доверять другим разработчикам, что они не создадут объект вне его фабрики? Не обязательно.

Конструктор может контролировать откуда он вызван. Поможет в этом функция debug_backtrace.

Вас это смущает?

Да, название функции говорит о том, что она создана для отладки. Ну и что их этого? Для отладки лучше использовать отладчики, такие как Xdebug. Тут, хотя бы, ее использование оправдано.

<?php

trait FactoryChecking
{
    protected function checkFactory(string $factoryClass): void
    {
        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
        foreach($trace as $traceItem) {
            if ($traceItem['class'] == $factoryClass) {
                return;
            }
        }	
        throw new Exception('Cannot create class ' . static::class . ' outside of factory');
    }
}

class ClassA
{
    use FactoryChecking;

    public function __construct()
    {
        $this->checkFactory(Factory::class);
    }
}

class Factory
{
    public function create(): ClassA
    {
        return new ClassA();
    }
}

Метод checkFactory понадобится в каждом классе, экземпляры которого должны создаваться в фабрике, но это не повод для использования наследования. Агрегация здесь мне тоже кажется не уместной. Так что, наверное, это подходящий случай для использования трейтов.

Это работает, но класс classA все еще имеет лишнюю ответственность — контролировать где создаются его экземпляры. Так что мы возвращаемся к частным/защищенным конструкторам. Существует ли метод создания экземпляра класса с частным/защищенным конструктором вне самого класса? С этим нам поможет модуль Reflection, который является частью ядра PHP.

<?php

class ClassA
{    
    public const FRIEND_CLASSES = [Factory::class];

    protected function __construct() {}
}

trait Constructor
{
    protected function createObject(string $className, array $args = [])
    {
        if (!in_array(static::class, $className::FRIEND_CLASSES)) {
            throw new \Exception("Call to private or protected {$className}::__construct() from invalid context");
        }
        $reflection = new ReflectionClass($className);
        $constructor = $reflection->getConstructor();
        $constructor->setAccessible(true);
        $object = $reflection->newInstanceWithoutConstructor();
        $constructor->invokeArgs($object, $args);
        return $object;
    }
}

class Factory
{
    use Constructor;

    public function createA(): ClassA
    {
        return $this->createObject(ClassA::class);
    }
}

Константа FRIEND_CLASSES в ClassA используется чтобы определить список классов в которых экземпляр класса ClassA может быть создан.

Теперь у класса ClassA нет лишней ответственности и его экземпляры могут быть созданы только в дружественных классах. Но что с производительностью? Что ж, проверим.

В качестве ориентира используем фабрику без проверок:

test.php
<?php
class ClassA
{
    public function __construct() {}
}

class Factory
{
    public function create(): ClassA
    {
        return new ClassA();
    }
}

$start = microtime(true);
$factory  = new Factory();
for ($i = 0; $i < 100000; $i++) {
    $object = $factory->create();
}
echo microtime(true) - $start;
> php test.php
0.12572288513184

Делаем тоже самое с debug_backtrace:

> php backtrace.php
0.26334500312805

И с Reflection:

> php reflect.php
0.52497291564941

Проверки, конечно же, занимают некоторое время. Использование debug_backtrace замедляет создание объекта в 2 раза, а Reflection в 4. Но важно понимать, что конструктор класса и фабрика ничего не делают. Мы замеряем время создания пустого объекта. В реальной ситуации разница будет меньше.

Что вы думает о самой идее? Используете ли вы какие-то методы чтобы избежать создания объектов вне фабрики?

Update. В первых же комментариях мне написали, что несогласны с тем, что создание сложным объектом самого себя - это нарушение SRP. Как по мне, об этом можно спорить, добавил в текст, что это мое мнение. А в целом статья не об этом.

P.S. Выше представлен перевод моей собственной статьи опубликованной чуть ранее на Medium в публике "Level Up Coding": ссылка на статью.

Комментарии (18)


  1. Standfest
    06.04.2022 10:25
    +1

    Что вы думает о самой идее?

    "Велосипед - два колеса. Одно тебе, другое мне."

    Используете ли вы какие-то методы чтобы избежать создания объектов вне фабрики?

    Используем. Технология называется code review.


    1. marenkov Автор
      06.04.2022 10:40

      К сожалению code review не дает гарантии. Возможно тот, кто его проводит, сам не знает о том, что объект должен быть создан в фабрике, либо просто не заметил. С таким же успехом можно ограничится комментарием к классу.

      Мне в этом плане больше понравился комментарий к англоязычной версии, где автор писал о сплошном использовании DI, получении всего из контейнера и запрете "new <something>" в статическом анализаторе кода. Но я не уверен не перебор ли это.


      1. Standfest
        06.04.2022 10:49

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

        По-моему private/protected constructor и публичный статичный метод для создания себя - это нормальный, повсеместно используемый и непорицаемый компромисс.

        Просто невозможно в рамках хоть сколько большого или даже среднего проекта везде и всюду следовать S.O.L.I.D., ничего не нарушая. Мы живем в реальном мире, где нужно решать поставленные задачи, и S.O.L.I.D как и сам ООП, не дает идеального решения для всего.


        1. marenkov Автор
          06.04.2022 11:16

          В данном случае, меня больше волнует не SOLID, а то, чтобы не проектировать "в расчете на стечение обстоятельств". В данном случае в надежде, что все будут правильно создавать объект.

          P.S. Когда то заметил, что в Symfony, если к чему либо трудно добраться, значит ты не должен этим оперировать и ты делаешь что-то не так.


  1. r_zaycev
    06.04.2022 10:27
    -1

    А разве этот прием так же не нарушает SRP?

    И как тестировать, добавлять в

    FRIEND_CLASSES

    SomeFactoryMock::class?


    1. marenkov Автор
      06.04.2022 10:32

      Чем именно этот прием нарушает SRP? В самом классе единственным изменением является добавление константы FRIEND_CLASSES. Но это не ответственность, это мета-данные. И если хотите, можете ее не использовать, убрав проверку в конструкторе.

      По тестированию. Пока не исследовал это вопрос, но как вы собираетесь тестировать объект, сборка которого происходит в фабрике, не используя саму фабрику?


      1. r_zaycev
        06.04.2022 10:35

        Но это не ответственность, это мета-данные

        ИМХО, сомнительно, но допустим

        как вы собираетесь тестировать объект, сборка которого происходит в фабрике

        Но мы хотим написать юнит-тест для класса объекта, а не класса фабрики (:


        1. marenkov Автор
          06.04.2022 10:50

          Вы не используете фабрики совсем?
          Если используете, то как тестируете объекты, которые они создают, не используя при этом саму фабрику?

          Я понимаю, что юнит-тест должен быть максимально чистым и проводиться над одним единственным классом и что все его зависимости надо заменить заглушками. Но как быть с объектом, который создается в фабрике? Писать свою фабрику внутри теста?

          Как по мне, сама фабрика должна получать части для сборки с помощью DI, а дальше тестировать отдельно фабрику, а отдельно созданный ею объект. Можете предложить что-то лучше?


          1. r_zaycev
            06.04.2022 11:08

            Конечно я тестирую и фабрики, и порождаемые ими объекты.

            Проблема вашего метода в том, что мы не сможем создать (без костылей и хаков) в тесте объект без вызова фабрики (настоящей или её мока). А если у фабрики есть зависимости, которые нужно тоже нужно замокать? Получается, что для написания теста объекта будет требоваться: или использовать настоящую фабрику (в тч. настраивать её моки, если потребуется), или создавать объект минуя фабрику через костыли (ту же самую рефлексию), либо писать "дружественную" мок-фабрику и регистрировать её в FRIEND_CLASSES (что я считаю совсем неприемлемым).

            Подытожив, в предложенном вами методе лично для себя я вижу больше вытекающих проблем, чем профитов.


            1. marenkov Автор
              06.04.2022 12:09

              С описанными вами проблемами согласен. Но это общие проблемы тестирования объектов создаваемых фабриками. В любом случае возникает проблема создания такого объекта. Что касается рефлексии, то в тестах это нормально, также как в служебных библиотеках/классах. Загляните в код PHPUnit и Doctrine - она там используется довольно широко.

              Получается выбор между чистотой кода и чистотой тестов.


  1. milinsky
    06.04.2022 11:17
    -1

    >Но это нарушает принцип единственной ответственности (SRP)

    Ничего он не нарушает. Вы вероятно не правильно понимаете SRP. "Должна быть только одна причина для изменения класса", и речь здесь вовсе не о том что делает класс, а то, кто, когда и какие требования предъявляет. Не думаю что тетя Клава из бухгалтерии потребует что-то изменить в логике приложения что это изменит логику вашего объекта или самой фабрики, который принадлежит не ее зоне ответственности. А вот если одна фабрика будет создавать объекты для логики продаж и одновременно логики например регистрации, то это будет нарушение SRP.


    1. marenkov Автор
      06.04.2022 11:38

      По вашей логике, класс реализующий бизнес-логику продаж, вполне может самостоятельно записывать данные в базу, высылать мейлы и делать еще кучу других вещей, лишь бы они были связаны с продажами? Дядя Боб писал не об этом.

      Если класс реализует бизнес-логику, а его конструктор становиться довольно сложным, то что вам дает то, что вы перенесли это код в статический метод этого класса? Что это принципиально меняет? В DDD такой прием используют только для того, чтобы дать конструктору осмысленное название. В данном же случае, в одном классе останется мешанина кода отвечающего за бизнес-логику, и нечто системное не имеющее к логике никакого отношения, и изменения каких-то системных вещей могут потребовать изменения класса реализующего бизнес-логику. Это еще какое нарушение SPR.


      1. milinsky
        06.04.2022 11:57

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

        Роберт Мартин - Чистая архитектура, издание 2018 г. , глава 7. В русском переводе, страница 79.


      1. milinsky
        06.04.2022 12:02

        И я не хочу сказать что нужно зубрить то что сказал дядя Боб. Тем не менее ошибочное использование терминов и трактовок в статьях, провоцирует все большее их непонимание людьми, и как следствие, изобретению ненужных велосипедов.


        1. marenkov Автор
          06.04.2022 12:21

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


          1. milinsky
            06.04.2022 12:29

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


  1. Klenov_s
    06.04.2022 12:33

    Такое построение и поведение класса нарушает паттерн Low Coupling. Вы разрабатываете класс, который сильно зависит от окружения и не может быть переиспользован.


    1. marenkov Автор
      06.04.2022 12:38

      Каким образом он зависит от окружения? Речь идет о том, чтобы запретить создание экземпляров класса вне фабрики предназначенной для этого. Связан он только с фабрикой, обязанностью которой является создание таких экземпляров.