На работе была поставлена задача: в главное веб-приложение нашей фирмы добавить метод формирования бланка в формате 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]);
Поскольку каждый вариант имеет свои достоинства и недостатки, я решил свести их все в одну таблицу:
Варианты изменений, вносимых в исходный класс | Вариант с рефлексией | Анонимный класс с переопределением конструктора и полей | Анонимный класс с переопределением конструктора и полей |
---|---|---|---|
Изначальные трудозатраты | Минимальные (несколько строк кода, вызывающего рефлексию и порождающий экземпляр класса) и цикл, заполняющий поля | Размер код, создающий объект анонимного класса и переопределяющий открытость полей в нём, зависит от числа полей в мокируемом классе | Размер кода анонимного класса зависит от числа переопределяемых методов |
В исходный класс добавлено поле, используемое в методах в шаблоне | Добавить поле в клиентский код | Добавить поле в клиентский код и добавить его переопределение в анонимный класс | Если нужно, добавить поле в клиентский код и изменить метод в анонимном классе |
В исходный класс добавлено приватное поле, используемое в методах в шаблоне | Добавить поле в клиентский код |
| Если нужно, добавить поле в клиентский код и изменить метод в анонимном классе |
В исходный класс добавлен метод, который используется в шаблоне | Ничего не надо делать | Ничего не надо делать | Добавить метод в анонимный класс |
В исходный класс добавлен метод, который не используется в шаблоне (даже косвенно) | Ничего не надо делать | Ничего не надо делать | Ничего не надо делать |
Один из разработчиков решил сделать исходный класс финальным (а вдруг, например, по совету Маттиаса Нобака) | Ничего не надо делать |
|
|
Таким образом, вариант с рефлексией наименее трудозатратный и работает во всех
ситуациях. А вариант с анонимным классом, в котором переопределяются конструктор и методы, обеспечивает наиболее точную настройку данных, передаваемых в шаблон. Но в случае с финальным классом, он бессилен.