На работе была поставлена задача: в главное веб-приложение нашей фирмы добавить метод формирования бланка в формате PDF «как вот в том микросервисе».
Форма бланка регулярно изменяется, и копировать её в веб-приложение означало нарушить принцип DRY («Не повторяйся») и обречь себя на постоянную двойную работу. Поэтому я решил оставить генерацию бланка в «том микросервисе».
«Тот микросервис» написан на PHP с использованием фреймворка Laravel, содержит большое число доменных объектов, экземпляры которых хранятся в БД MySQL, и имеет развитую систему API для обращения к своему функционалу.
И можно было добавить в него ещё одну точку доступа API, которая бы получала данные и на их основе формировала и возвращала бланк.
Проблема возникла из-за «неприлично» высокой связанности объектов в «том микросервисе». Так, в шаблоне, на основе которого строился бланк, использовались не просто примитивные типы данных, а объект-форма. И шаблон обращался к методам-геттерам этого объекта. А объект, в свою очередь, использовал другой доменный объект в своём конструкторе для заполнения полей.
Примерно вот так:
class DataForBladeTemplate extends SomeLaravelBasicClass { protected UnnecessaryDomainClass $unnecessaryDomainObject; protected string $fieldOne; protected string $fieldTwo; protected string $fieldThree; public function __construct(UnnecessaryDomainClass $unnecessaryDomainObject) { $this->unnecessaryDomainObject = $unnecessaryDomainObject; $this->fieldOne = $unnecessaryDomainObject->someMethodWhichReturnFieldOneValue(); $this->fieldTwo = $unnecessaryDomainObject->someMethodWhichReturnFieldTwoValue(); $this->fieldThree = $unnecessaryDomainObject->someMethodWhichReturnFieldThreeValue(); } /** * @return string */public function getFieldOne(): string{ return $this->fieldOne; } /** * @return string */public function getFieldTwo(): string{ return $this->fieldTwo; } /** * @return string */public function getFieldThree(): string{ return $this->fieldThree; } } // И далее где-нибудь в шаблоне {{ $dataForBladeTemplate->getFieldOne() }}
Из-за этой связанности нельзя было просто создать пустой объект DataForBladeTemplate и заполнить его нужными данными.
Конечно, имелась возможность поменять логику получения данных в шаблоне. Вместо объекта передавать в макет отдельные переменные с примитивными типами данных или массив таких переменных. И шаблон обращался бы не к методам-геттерам переданного объекта, а к этим переменным или элементам массива. Но такое решение потребовало бы менять уже работающий код во многих местах и повышало риск совершения ошибки. Хотелось уменьшить как риск, так и трудозатраты.
К счастью, UnnecessaryDomainClass не внедрял никаких зависимостей в своём конструкторе и можно было просто создать его пустой экземпляр.
Поэтому нужно было «всего лишь» создать экземпляр DataForBladeTemplate, в конструктор которого передаётся пустой объект UnnecessaryDomainClass и это не вызывает «возражений» в его конструкторе, и самим заполнить его защищённые поля данными, прилетевшими в метод.
Сказано — сделано.
Привычка при тестировании создавать компактные тестовые двойники вручную не подвела. Не успела в голове сформулироваться задача, а руки сами стали набирать нужный код. Создаём анонимный класс, наследуемого от нужного нам. В нём переопределяем конструктор, чтобы избежать зависимости от доменного объекта, и переобъявляем все защищённые поля открытыми.
// Создаем анонимный класс, чтобы переопределить жесткие связи // с кодом микросервиса. $dataForBladeTemplate = new class(new UnnecessaryDomainClass()) extends DataForBladeTemplate { protected UnnecessaryDomainClass $unnecessaryDomainObject; public string $fieldOne; public string $fieldTwo; public string $fieldThree; public function __construct(UnnecessaryDomainClass $unnecessaryDomainObject) { $this->unnecessaryDomainObject = $unnecessaryDomainObject; // Больше ничего не делаем. Конструктор родителя не вызываем. } }; ... // Заполняем поля этого объекта значениями ... foreach ($request->fields as $field => $fieldValue) { if (property_exists($dataForBladeTemplate, $field)) { $dataForBladeTemplate->$field = $fieldValue; } } ... // и передаем в шаблон. return view('view_name', ['data' => $dataForBladeTemplate]);
И, о чудо, всё заработало. Казалось, задачу можно закрывать.
Но не оставляло ощущение какой-то недоделанности. Например, что если в подменяемом классе изменится состав полей? Или часть, или все поля будут объявлены закрытыми (приватными)? Или если объект в шаблоне в свою очередь использовал бы другой доменный объект в своём конструкторе для заполнения полей. И внедряемый объект также зависел от другого доменного объекта, который в свою очередь… Ну вы поняли. В контейнере зависимостей могла использоваться длинная цепочка создания объектов.
Например, если бы сложилась вот такая ситуация:
class DataForBladeTemplate extends SomeLaravelBasicClass { protected UnnecessaryDomainClass $unnecessaryDomainObject; private string $fieldOne; private string $fieldTwo; private string $fieldThree; public function __construct(UnnecessaryDomainClass $unnecessaryDomainObject) { $this->unnecessaryDomainObject = $unnecessaryDomainObject; $this->fieldOne = $unnecessaryDomainObject->someMethodWhichReturnFieldOneValue(); $this->fieldTwo = $unnecessaryDomainObject->someMethodWhichReturnFieldTwoValue(); $this->fieldThree = $unnecessaryDomainObject->someMethodWhichReturnFieldThreeValue(); } /** * @return string */public function getFieldOne(): string{ return $this->fieldOne; } /** * @return string */public function getFieldTwo(): string{ return $this->fieldTwo; } /** * @return string */public function getFieldThree(): string{ return $this->fieldThree; } } class UnnecessaryDomainClass extends AnOtherLaravelBasicClass { ... public function __construct(AnotherUnnecessaryDomainClass $anotherUnnecessaryDomainObject) { ... } } class AnotherUnnecessaryDomainClass extends OneMoreLaravelBasicClass { ... public function __construct(OneMoreUnnecessaryDomainClass $oneMoreUnnecessaryDomainObject) { ... } }
Порождать всю эту цепочку зависимых объектов было бы рискованно (можно было невзначай записать какой-нибудь из них в БД) и хлопотно (надо бы было решать проблему с валидацией и прочими вещами).
Как в таком случае разорвать зависимость и «воткнуть» нужный нам двойник в шаблон? Может есть универсальный способ создать класс с открытыми полями и без конструктора?
Обращение к документации PHP, раздел Reflection, показало, что такая возможность существует. Вариант с анонимным классом был переписан вот так:
$reflection = new ReflectionClass(DataForBladeTemplate::class); // Создаем объект DataForBladeTemplate без вызова конструктора // (знаем, что класс не внутренний, исключений быть не должно). $dataForBladeTemplate = $reflection->newInstanceWithoutConstructor(); // Перебираем переданные в запросе поля и, если такое поле есть // в классе DataForBladeTemplate, то устанавливаем его значение // с помощью метода setValue объекта ReflectionProperty. // Причём для версии PHP выше 8.1 даже не нужно устанавливать // для поля доступность методом setAccessible, // т.к. оно доступно по-умолчанию. foreach ($request->fields as $field => $fieldValue) { if (property_exists($dataForBladeTemplate, $fieldName)) { $classField = $reflection->getProperty($fieldName); if (PHP_VERSION_ID < 80100) { // А вдруг ))) $classField->setAccessible(true); } $classField->setValue($dataForBladeTemplate, $fieldValue); } } ... return view('view_name', ['data' => $dataForBladeTemplate]);
И снова всё заработало как надо.
Найденное решение привлекает тем, что оно, как и задумывалось, не затрагивает уже работающие участки кода. Вся «магия» совершается только в методах добавляемого контроллера и сервиса формирования бланка. Остальная часть микросервиса ничего не знает об «издевательствах» над его классами. А это уменьшает вероятность поломать работу «чужого» кода.
Конечно, не всегда так просто разорвать слишком плотную связанность объектов. Например, можно представить код, когда в дублируемом объекте в его публичных методах-геттерах прямо или косвенно используется объект, внедрённый в конструкторе. Тогда бы пришлось или переопределять публичные методы-геттеры, или создавать дублёр для внедряемого объекта, или создавать всю цепочку объектов, или, в конце концов, переделывать логику получения данных в шаблоне, как было описано выше.
К счастью, для моего случая ничего этого не потребовалось. Всё закончилось хорошо. Задача была успешно закрыта.
Обновление от 27 апреля 2025 года.
Как справедливо напомнил уважаемый @zorn-v100500 PHP разрешает менять сигнатуру конструктора, и можно создать двойник с помощью анонимного класса, не обращая внимания на цепочку порождения доменных объектов:
$dataForBladeTemplate = new class() extends DataForBladeTemplate { public string $fieldOne; public string $fieldTwo; public string $fieldThree; public function __construct() {} }; ... // Заполняем поля этого объекта значениями ... foreach ($request->fields as $field => $fieldValue) { if (property_exists($dataForBladeTemplate, $field)) { $dataForBladeTemplate->$field = $fieldValue; } } ... // и передаем в шаблон. return view('view_name', ['data' => $dataForBladeTemplate]);
И, как предложил @zorn-v100500, можно даже не переобъявлять поля, а сразу переопределять методы, которые используются в процессе формирования шаблона, например, так:
$dataForBladeTemplate = new class($fieldValues) extends DataForBladeTemplate { protected Array $fieldValues; public function __construct($fieldValues) { $this->fieldValues = $fieldValues; } public function getFieldOne() { return $this->fieldValues['fieldOne'] ?? ''; } public function getFieldTwo() { return $this->fieldValues['fieldTwo'] ?? ''; } public function getFieldThree(): string { return $this->fieldValues['fieldThree'] ?? ''; } }; ... // и передаем в шаблон. return view('view_name', ['data' => $dataForBladeTemplate]);
Поскольку каждый вариант имеет свои достоинства и недостатки, я решил свести их все в одну таблицу:
Варианты изменений, вносимых в исходный класс | Вариант с рефлексией | Анонимный класс с переопределением конструктора и полей | Анонимный класс с переопределением конструктора и методов |
|---|---|---|---|
Изначальные трудозатраты | Минимальные (несколько строк кода, вызывающего рефлексию и порождающий экземпляр класса) и цикл, заполняющий поля | Размер код, создающий объект анонимного класса и переопределяющий открытость полей в нём, зависит от числа полей в мокируемом классе | Размер кода анонимного класса зависит от числа переопределяемых методов |
В исходный класс добавлено поле, используемое в методах в шаблоне | Добавить поле в клиентский код | Добавить поле в клиентский код и добавить его переопределение в анонимный класс | Если нужно, добавить поле в клиентский код и изменить метод в анонимном классе |
В исходный класс добавлено приватное поле, используемое в методах в шаблоне | Добавить поле в клиентский код |
| Если нужно, добавить поле в клиентский код и изменить метод в анонимном классе |
В исходный класс добавлен метод, который используется в шаблоне | Ничего не надо делать | Ничего не надо делать | Добавить метод в анонимный класс |
В исходный класс добавлен метод, который не используется в шаблоне (даже косвенно) | Ничего не надо делать | Ничего не надо делать | Ничего не надо делать |
Один из разработчиков решил сделать исходный класс финальным (а вдруг, например, по совету Маттиаса Нобака) | Ничего не надо делать |
|
|
Таким образом, вариант с рефлексией наименее трудозатратный и работает во всех
ситуациях. А вариант с анонимным классом, в котором переопределяются конструктор и методы, обеспечи��ает наиболее точную настройку данных, передаваемых в шаблон. Но в случае с финальным классом, он бессилен.
