Обёртка полезна, когда перед обращением к модели нужно подготовить зависимость: например, открыть подключение или создать тяжёлый репозиторий только тогда, когда он действительно понадобится. Важно сохранить обычный контракт методов, возвращаемые значения и понятное поведение при ошибке.
Что меняем в старом приёме
В первоначальном подходе использовались wrapper($method, $data) и магическая переадресация через __call. Но пара [имя_класса, имя_нестатического_метода] не является корректной проверкой обычного вызова на экземпляре. Проверять таким способом конструктор также не нужно: конструктор вызывается оператором new, а его параметры известны фабрике.
Исправлять только опечатку $model-<wrapper на $model->wrapper недостаточно. Нужны договор о типе модели, явный способ её создания и решение о том, что происходит после исключения. Здесь используем типизированный proxy без строковых имён классов и методов.
Один интерфейс для модели и обёртки
Интерфейс ProductRepository описывает ровно доступную операцию. И настоящая модель, и её ленивый заместитель реализуют getName(int $id): string. Вызывающий код не выбирает метод из пользовательского ввода, а IDE и проверка типов видят сигнатуру.
Фабрика передаётся как Closure. Она может захватить заранее настроенные зависимости и параметры конструктора, поэтому обёртка не пытается угадать, как создать произвольный класс. В учебном примере модель работает с массивом: подключение к базе не требуется.
interface ProductRepository
{
public function getName(int $id): string;
}
final class ArrayProductRepository implements ProductRepository
{
/** @param array<int, string> $names */
public function __construct(private array $names)
{
}
public function getName(int $id): string
{
if (!array_key_exists($id, $this->names)) {
throw new OutOfBoundsException('Product was not found.');
}
return $this->names[$id];
}
}
final class LazyProductRepository implements ProductRepository
{
private ?ProductRepository $instance = null;
private bool $loading = false;
/** @param Closure(): ProductRepository $factory */
public function __construct(private readonly Closure $factory)
{
}
private function repository(): ProductRepository
{
if ($this->instance !== null) {
return $this->instance;
}
if ($this->loading) {
throw new LogicException('Recursive lazy initialization.');
}
$this->loading = true;
try {
$repository = ($this->factory)();
if (!$repository instanceof ProductRepository) {
throw new UnexpectedValueException('Factory must return ProductRepository.');
}
return $this->instance = $repository;
} finally {
$this->loading = false;
}
}
public function getName(int $id): string
{
return $this->repository()->getName($id);
}
}Когда создаётся модель
Конструктор proxy только запоминает фабрику. Первый вызов getName обращается к repository(), создаёт модель и проверяет, что фабрика вернула нужный интерфейс. Успешно созданный экземпляр сохраняется; последующие вызовы снова выполняют методы этой модели, но не создают её заново.
Это кэширование зависимости, а не результатов запросов. Два вызова getName(1) по-прежнему дважды вызывают getName репозитория. Если нужно кэшировать сами данные, это отдельная ответственность с собственными ключами и сроком жизни.
$loads = 0;
$products = new LazyProductRepository(
static function () use (&$loads): ProductRepository {
++$loads;
// Здесь могли бы открываться соединение и создаваться SQL-репозиторий.
// Для запуска примера достаточно массива, база данных не требуется.
return new ArrayProductRepository([0 => 'Нулевой товар', 1 => 'Книга']);
}
);
echo $loads; // 0: модель ещё не создана
echo $products->getName(1); // Книга
echo $products->getName(0); // Нулевой товар
echo $loads; // 1: обе операции используют один экземпляр
try {
$products->getName(99);
} catch (OutOfBoundsException $error) {
// Контроллер решает, как представить отсутствие товара пользователю.
// Обёртка не печатает внутренние ошибки и не выдаёт null вместо результата.
}Ошибки не должны превращаться в успешный ответ
Обёртка не ловит исключение ради echo и не продолжает работу так, будто операция удалась. Ошибка конструктора или метода доходит до вызывающего слоя. Контроллер может отдельно обработать отсутствие товара, временную недоступность хранилища и непредвиденную ошибку. В HTTP-ответ не нужно выводить SQL, внутренние пути или текст служебного исключения.
Если фабрика завершилась исключением, экземпляр не запоминается. Флаг загрузки сбрасывается в finally, поэтому следующая попытка может повторить инициализацию. При этом повторный вход в ту же фабрику до окончания загрузки немедленно отклоняется как рекурсивная инициализация.
Для настоящего SQL-адаптера подготовку запроса и преобразование инфраструктурных ошибок размещают в самом репозитории либо в отдельном явном декораторе. Ленивому proxy достаточно создать зависимость и передать вызов: смешивать загрузку, SQL, вывод HTML и логирование в один магический диспетчер не требуется.
Границы решения и проверки
- Фабрика задаётся в коде конфигурации приложения, а не именем класса из запроса.
- Успешный экземпляр создаётся один раз на объект proxy. Ошибочная попытка не кэшируется.
- Все публичные методы proxy сохраняют сигнатуры интерфейса; добавление операции требует явной реализации.
- Защита от рекурсивной загрузки не делает объект механизмом синхронизации потоков или распределённым контейнером.
- В проверках нужны нулевой идентификатор, отложенное создание, повторное использование, сохранение исходного исключения, повтор после сбоя, неверный результат фабрики и рекурсивный вызов.
Документация
is_callable и callable для экземпляра, интерфейсы объектов PHP, анонимные функции и Closure. Эти разделы объясняют договор вызова; они не заменяют проверку поведения конкретной обёртки.