Laravel под капотом: Facades

Источник: «Laravel Under The Hood - Facades»
Laravel предлагает элегантную возможность вызова методов под названием Facades. На первый взгляд, они напоминают статические методы, но это не так! Что за магию творит Laravel?

Привет, Facades 👋

Вы только что установили свежее Laravel приложение, загрузили его и получили страницу приветствия. Как и все остальные, вы пытаетесь посмотреть, как она отображается, поэтому заходите в файл web.php и встречаете следующий код

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
return view('welcome');
});

Очевидно, как мы получили представление/view приветствия, но вам интересно, как работает маршрутизатор Laravel, и вы решили погрузиться в код. Первоначальное предположение таково: существует класс Route, у которого мы вызываем статический метод get(). Однако при нажатии на него метод get() отсутствует. Так что же за тёмная магия происходит? Давайте разберёмся!

Обычные фасады

Обратите внимание, что я удалил большую часть PHPDocs и вставил для упрощения "...", это относится к большему количеству кода.

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

Следуя нашему примеру, давайте изучим класс Route

<?php

namespace Illuminate\Support\Facades;

class Route extends Facade
{
// ...

protected static function getFacadeAccessor(): string
{
return 'router';
}
}

Здесь нет ничего особенного, только метод getFacadeAccessor(), возвращающий строку router. Запомнив это, перейдём к родительскому классу

<?php

namespace Illuminate\Support\Facades;

use RuntimeException;
// ...

abstract class Facade
{
// ...

public static function __callStatic(string $method, array $args): mixed
{
$instance = static::getFacadeRoot();

if (! $instance) {
throw new RuntimeException('A facade root has not been set.');
}

return $instance->$method(...$args);
}
}

Внутри родительского класса есть множество методов, но нет метода get(). Но есть один интересный метод — __callStatic(). Это магический метод, вызываемый всякий раз, когда вызывается неопределённый статический метод, как в нашем случае get(). Поэтому наш вызов __callStatic('get', ['/', Closure()]) представляет собой то, что мы передали при вызове Route::get(), маршрут / и Closure(), который возвращает приветственное представление.

Когда срабатывает __callStatic(), сначала он пытается установить переменную $instance, вызывая getFacadeRoot(). В $instance хранится реальный класс, к которому должен быть направлен вызов, давайте посмотрим на это подробнее, скоро всё станет ясно.

// Facade.php

public static function getFacadeRoot()
{
return static::resolveFacadeInstance(static::getFacadeAccessor());
}

Смотрите, это getFacadeAccessor() из дочернего класса Route, который, как мы знаем, вернул строку router. Эта строка router затем передаётся в resolveFacadeInstance(), пытающийся преобразовать её в класс, своего рода маппинг, который говорит: Какой класс представляет эта строка?, давайте посмотрим.

// Facade.php

protected static function resolveFacadeInstance($name)
{
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}

if (static::$app) {
if (static::$cached) {
return static::$resolvedInstance[$name] = static::$app[$name];
}

return static::$app[$name];
}
}

Сначала он проверяет, есть ли в статическом массиве $resolvedInstance значение, заданное $name (которое, опять, является router). Если он находит совпадение, то просто возвращает это значение. Это кэширование Laravel, чтобы немного оптимизировать производительность. Кэширование происходит в рамках одного запроса, если этот метод вызывается несколько раз с одним и тем же аргументом в рамках одного запроса, он использует кэшированное значение. Давайте предположим, что это первый вызов, и продолжим.

Затем проверяется, установлено ли значение $app, и является ли $app экземпляром контейнера приложения.

// Facade.php

protected static \Illuminate\Contracts\Foundation\Application $app;

Если вам интересно, что такое контейнер приложения, думайте о нем как о коробке, в которой хранятся ваши классы. Когда вам нужны эти классы, вы просто тянетесь к этой коробке. Иногда этот контейнер проявляет некоторую магию, даже если ящик пуст, и вы тянетесь к нему, чтобы взять класс, он достанет его для вас. Это тема для другой статьи.

Теперь вы можете задаться вопросом: Когда задаётся $app?, потому что он должен быть задан, иначе у нас не будет нашего $instance. Контейнер приложения задаётся во время процесса загрузки нашего приложения. Давайте посмотрим на класс \Illuminate\Foundation\Http\Kernel

<?php

namespace Illuminate\Foundation\Http;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Facade;
use Illuminate\Contracts\Http\Kernel as KernelContract;
// ...

class Kernel implements KernelContract
{
// ...

protected $app;

protected $bootstrappers = [
\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
\Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
\Illuminate\Foundation\Bootstrap\RegisterFacades::class, // <- этот парень
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
\Illuminate\Foundation\Bootstrap\BootProviders::class,
];

public function bootstrap(): void
{
if (! $this->app->hasBeenBootstrapped()) {
$this->app->bootstrapWith($this->bootstrappers());
}
}
}

Когда поступает запрос, он отправляется в маршрутизатор. Непосредственно перед этим вызывается метод bootstrap(), использующий массив bootstrappers для подготовки приложения. Если вы изучите метод bootstrapWith() в классе \Illuminate\Foundation\Application, то увидите, что он перебирает эти бутстрапперы, вызывая их метод bootstrap(). Для простоты остановимся на \Illuminate\Foundation\Bootstrap\RegisterFacades, как мы знаем, содержащий метод bootstrap(), который будет вызван в bootstrapWith()

<?php

namespace Illuminate\Foundation\Bootstrap;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\AliasLoader;
use Illuminate\Foundation\PackageManifest;
use Illuminate\Support\Facades\Facade;

class RegisterFacades
{
// ...

public function bootstrap(Application $app): void
{
Facade::clearResolvedInstances();

Facade::setFacadeApplication($app); // Интересное здесь

AliasLoader::getInstance(array_merge(
$app->make('config')->get('app.aliases', []),
$app->make(PackageManifest::class)->aliases()
))->register();
}
}

И вот он, мы задаём контейнер приложения в классе Facade с помощью статического метода setFacadeApplication().

// RegisterFacades.php

public static function setFacadeApplication($app)
{
static::$app = $app;
}

Видите, мы задаём свойство $app, которое тестируем, в resolveFacadeInstance(). Это ответ на вопрос, давайте продолжим

// Facade.php

protected static function resolveFacadeInstance($name)
{
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}

if (static::$app) {
if (static::$cached) {
return static::$resolvedInstance[$name] = static::$app[$name];
}

return static::$app[$name];
}
}

Мы подтвердили, что $app задан во время загрузки приложения. Следующим шагом будет проверка того, нужно ли кэшировать разрешённый экземпляр, для чего проверяем $cached, по умолчанию имеющий значение true. Наконец, мы получаем экземпляр из контейнера приложения, в нашем случае это как запрос к static::$app['router'] на предоставление любого класса, связанного со строкой router. Теперь вы можете удивиться, почему мы обращаемся к $app как к массиву, несмотря на то, что это экземпляр контейнера приложения, то есть объект. Что ж, вы правы! Однако контейнер приложений реализует PHP-интерфейс ArrayAccess, позволяющий обращаться к нему как к массиву. Мы можем взглянуть на него, чтобы подтвердить этот факт

<?php

namespace Illuminate\Container;

use ArrayAccess; // <- этот парень
use Illuminate\Contracts\Container\Container as ContainerContract;

class Container implements ArrayAccess, ContainerContract {
// ...
}

Итак, resolveFacadeInstance() действительно возвращает экземпляр, привязанный к строке router, а именно, \Illuminate\Routing\Router. Как я узнал? Посмотрите на фасад Route, и вы обнаружите в PHPDoc @see, намекающий на то, что скрывает этот фасад, или, точнее, на то, к какому классу будут проксироваться вызовы наших методов.

Теперь вернёмся к нашему методу __callStatic.

<?php

namespace Illuminate\Support\Facades;

use RuntimeException;
// ...

abstract class Facade
{
// ...

public static function __callStatic(string $method, array $args): mixed
{
$instance = static::getFacadeRoot();

if (! $instance) {
throw new RuntimeException('A facade root has not been set.');
}

return $instance->$method(...$args);
}
}

У нас есть $instance, объект класса \Illuminate\Routing\Router. Мы проверяем, установлен ли он (что в нашем случае подтверждается), и напрямую вызываем метод на нем. В итоге мы получаем

// Facade.php

return $instance->get('/', Closure());

И теперь вы можете подтвердить, что get() существует в классе \Illuminate\Routing\Router

<?php

namespace Illuminate\Routing;

use Illuminate\Routing\Route;
use Illuminate\Contracts\Routing\BindingRegistrar;
use Illuminate\Contracts\Routing\Registrar as RegistrarContract;
// ...

class Router implements BindingRegistrar, RegistrarContract
{
// ...

public function get(string $uri, array|string|callable|null $action = null): Route
{
return $this->addRoute(['GET', 'HEAD'], $uri, $action);
}
}

Вот и всё! Не так уж и сложно, в конце концов? Напомним, что фасад возвращает строку, привязанную к контейнеру. Например, hello-world может быть связана с классом HelloWorld. Когда мы статически вызываем неопределённый метод фасада, например HelloWorldFacade, в дело вступает __callStatic(). Она разрешает строку, зарегистрированную в методе getFacadeAccessor(), в то, что связано внутри контейнера, и проксирует наш вызов этому полученному экземпляру. Таким образом, в итоге мы получаем (new HelloWorld())->method(). Вот и вся суть! Вы всё ещё не поняли? Тогда давайте создадим наш фасад!

Давайте создадим свой Фасад

Допустим, у нас есть класс

<?php

namespace App\Http\Controllers;

class HelloWorld
{
public function greet(): string {
return "Hello, World!";
}
}

Задача состоит в том, чтобы вызвать HelloWorld::greet(). Для этого мы привяжем наш класс к контейнеру приложения. Сначала перейдите к AppServiceProvider.

<?php

namespace App\Providers;

use App\Http\Controllers;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind('hello-world', function ($app) {
return new HelloWorld;
});
}

// ...
}

Теперь, когда мы запрашиваем hello-world у нашего контейнера приложений (или коробки, как я уже говорил), он возвращает экземпляр HelloWorld. Что остаётся? Просто создать фасад, возвращающий строку hello-world.

<?php

namespace App\Http\Facades;
use Illuminate\Support\Facades\Facade;

class HelloWorldFacade extends Facade
{
protected static function getFacadeAccessor()
{
return 'hello-world';
}
}

Всё готово к использованию. Давайте вызовем его в нашем файле web.php

<?php

use App\Http\Facades;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
return HelloWorldFacade::greet(); // Hello, World!
});

Мы знаем, что greet() не существует в фасаде HelloWorldFacade, поэтому срабатывает __callStatic(). Она извлекает класс, представленный строкой (в нашем случае hello-world), из контейнера приложения. И мы уже сделали эту привязку в AppServiceProvider, поручив ему предоставлять экземпляр HelloWorld всякий раз, когда кто-то запрашивает hello-world. Следовательно, любой вызов, например greet(), будет работать с этим полученным экземпляром HelloWorld. Вот и всё.

Поздравляем! Вы создали свой собственный фасад!

Laravel Facades реального времени

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

Давайте посмотрим

<?php

use Facades\App\Http\Controllers; // Обратите внимание на префикс
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
return HelloWorld::greet(); // Hello, World!
});

Добавив к пространству имён контроллеров префикс Facades, мы получим тот же результат, что и раньше. Но, конечно, контроллер HelloWorld не имеет статического метода greet()! И откуда вообще взялись Facades\App\Http\Controllers\HelloWorld? Я понимаю, что это может показаться каким-то колдовством, но как только вы поймёте, всё окажется довольно просто.

Давайте подробнее рассмотрим класс \Illuminate\Foundation\Bootstrap\RegisterFacades, который мы рассматривали ранее, класс, отвечающий за установку $app

<?php

namespace Illuminate\Foundation\Bootstrap;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\AliasLoader;
use Illuminate\Foundation\PackageManifest;
use Illuminate\Support\Facades\Facade;

class RegisterFacades
{
public function bootstrap(Application $app): void
{
Facade::clearResolvedInstances();

Facade::setFacadeApplication($app);

AliasLoader::getInstance(array_merge(
$app->make('config')->get('app.aliases', []),
$app->make(PackageManifest::class)->aliases()
))->register(); // Интересное здесь
}
}

Вы видите, что в самом конце вызывается метод register(). Давайте заглянем внутрь

<?php

namespace Illuminate\Foundation;

class AliasLoader
{
// ...

protected $registered = false;

public function register(): void
{
if (! $this->registered) {
$this->prependToLoaderStack();

$this->registered = true;
}
}
}

Изначально переменная $registered имеет значение false. Поэтому мы вводим оператор if и вызываем метод prependToLoaderStack(). Теперь давайте рассмотрим его реализацию

// AliasLoader.php

protected function prependToLoaderStack(): void
{
spl_autoload_register([$this, 'load'], true, true);
}

Вот где происходит магия! Laravel вызывает функцию spl_autoload_register(), встроенную PHP функцию, срабатывающую при попытке доступа к неопределённому классу. Она определяет логику действий в таких ситуациях. В данном случае Laravel предпочитает вызывать метод load() при встрече с неопределённым вызовом. Кроме того, функция spl_autoload_register() автоматически передаёт имя неопределённого класса тому методу или функции, которые она вызывает.

Давайте изучим метод load(), он должен быть ключевым.

// AliasLoader.php

public function load($alias)
{
if (static::$facadeNamespace && str_starts_with($alias, static::$facadeNamespace)) {
$this->loadFacade($alias);

return true;
}

if (isset($this->aliases[$alias])) {
return class_alias($this->aliases[$alias], $alias);
}
}

Логика проверяет, установлен ли $facadeNamespace и начинается ли переданный класс, в нашем случае Facades\App\Http\Controllers\HelloWorld (который не определён), со значения, указанного в $facadeNamespace

// AliasLoader.php

protected static $facadeNamespace = 'Facades\\';

Поскольку мы присвоили пространству имён нашего контроллера префикс Facades, удовлетворяющий условию, мы переходим к loadFacade().

// AliasLoader.php

protected function loadFacade($alias)
{
require $this->ensureFacadeExists($alias);
}

Здесь метод требует путь, возвращаемый функцией ensureFacadeExists(). Поэтому следующим шагом будет изучение её реализации

// AliasLoader.php

protected function ensureFacadeExists($alias)
{
if (is_file($path = storage_path('framework/cache/facade-'.sha1($alias).'.php'))) {
return $path;
}

file_put_contents($path, $this->formatFacadeStub(
$alias, file_get_contents(__DIR__.'/stubs/facade.stub')
));

return $path;
}

Сначала проверяется, существует ли файл с именем framework/cache/facade-'.sha1($alias).'.php'. В нашем случае этот файл отсутствует, что вызывает следующий шаг: file_put_contents(). Эта функция создаёт файл и сохраняет его в указанный $path. Содержимое файла генерируется функцией formatFacadeStub(), которая, судя по названию, создаёт фасад из заглушки. Если просмотреть файл facade.stub, то можно обнаружить следующее

<?php

namespace DummyNamespace;

use Illuminate\Support\Facades\Facade;

/**
* @see \DummyTarget
*/

class DummyClass extends Facade
{
/**
* Get the registered name of the component.
*/

protected static function getFacadeAccessor(): string
{
return 'DummyTarget';
}
}

Выглядит знакомо? По сути, это то, что мы делали вручную. Теперь функция formatFacadeStub() заменяет фиктивный контент на наш неопределённый класс после удаления префикса Facades\\. Затем этот обновлённый фасад сохраняется. Следовательно, когда loadFacade() требует файл, она делает это правильно, и в итоге требуется следующий файл

<?php

namespace Facades\App\Http\Controllers;

use Illuminate\Support\Facades\Facade;

/**
* @see \App\Http\Controllers\HelloWorld
*/

class HelloWorld extends Facade
{
/**
* Get the registered name of the component.
*/

protected static function getFacadeAccessor(): string
{
return 'App\Http\Controllers\HelloWorld';
}
}

А теперь, как обычно, мы просим контейнер приложения вернуть любой экземпляр, привязанный к строке App\Http\Controllers\HelloWorld. Вы можете удивиться, ведь мы ни к чему не привязывали эту строку, мы даже не трогали наш AppServiceProvider. Но помните, что я говорил о контейнере приложений в самом начале? Даже если ящик пуст, он вернёт экземпляр, но с одним условием — у класса не должно быть конструктора. Иначе он не будет знать, как создать его для вас. В нашем случае класс HelloWorld не нуждается в аргументах для создания. Поэтому контейнер разрешает его, возвращает и все вызовы проксируются на него.

Вспомним о фасадах в реальном времени: Мы присвоили нашему классу префикс Facades. Во время загрузки приложения Laravel регистрирует spl_autoload_register(), срабатывающий, при вызове неопределённых классов. В конечном итоге это приводит к методу load(). Внутри load() мы проверяем, имеет ли текущий неопределённый класс префикс Facades. Если совпадает, то Laravel пытается загрузить его. Поскольку фасад не существует, он создаёт его из заглушки, а затем требует файл. И вуаля! У вас есть обычный фасад, но этот был создан на лету. Довольно круто, не так ли?

Заключение

Поздравляю, что вы зашли так далеко! Я понимаю, что это может быть немного ошеломляющим. Не стесняйтесь возвращаться назад и перечитывать разделы, которые не совсем понятны для вас. Вам также может помочь обращение к вашей IDE. Но эй, больше никакой чёрной магии, вы должны чувствовать себя хорошо, по крайней мере, так я чувствовал себя в первый раз!

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

Дополнительные материалы

Предыдущая Статья

Понимание BOM в JavaScript: Быстрое объяснение с примерами

Следующая Статья

Как использовать CSS свойство gap