编程

Laravel 底层原理 - Facades

154 2024-04-09 22:40:00

你刚刚安装了一个全新的 Laravel 应用,启动了它,并得到了一个欢迎页面。和其他人一样,你尝试查看它是如何渲染的,所以转到 web.php 文件中,遇到以下代码

<?php

use Illuminate\Support\Facades\Route;

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

很明显,我们来到 welcome 视图,但你对 Laravel 的路由是如何工作的很好奇,所以你决定深入研究代码。最初的假设是:有一个 Route 类,我们在它上调用一个静态方法 get()。但是,单击它时,那里没有 get() 方法。那么,发生了什么样的黑暗魔法呢?让我们解开这个谜!

常规 Facades

请注意,为了简单起见,我去掉了大部分 PHPDoc 并内联了类型,“…” 指的是更多的代码。

强烈建议你打开 IDE 并打开代码,以免出现混淆。

按照我们的示例,让我们探究 Route

<?php

namespace Illuminate\Support\Facades;

class Route extends Facade
{
    // ...

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

这里没有太多内容,只有返回字符串 routergetFacadeAccess() 方法。记住这一点,让我们转到父类

<?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() 触发时,它首先尝试通过调用 getFacadeRoot() 方法设置变量 $instance,该 $instance 持有该调用转发的实际类,让我们仔细看看,这会有点道理

// Facade.php

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

看,这是来自子类 RoutegetFacadeAccessor(),我们知道它返回了字符串 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, // <- this guy
        \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 数组来准备应用。如果你探索 \Illuminate\Foundation\Application 中的 bootstrapWith() 方法,它遍历了这些 bootstrappers,调用它们的 bootstrap() 方法,为了简化,我们只关注 \Illuminate\Foundation\Bootstrap\RegisterFacades,我们知道使用 bootstrapWith() 解析时,其包含 bootstrap() 方法

<?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); // Interested here

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

现在,我们使用静态方法 setFacadeApplication() Facade 类上设置应用容器

// RegisterFacades.php

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

请看,我们在 resolveFacadeInstance() 中赋值了正在测试的 `$app· 属性。这就是问题的答案,让我们继续

// 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 是应用容器的一个实例,但为什么我们可以像访问数组一样访问它,它是一个对象。是的!应用容器实现了一个名为 ArrayAccess 的 PHP 接口,允许类似数组的访问。我们可以查看该类来证实这一事实

<?php

namespace Illuminate\Container;

use ArrayAccess; // <- this guy
use Illuminate\Contracts\Container\Container as ContainerContract;

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

resolveFacadeInstance() 确实返回了绑定到 router 字符串的实例,具体来说,就是 \Illuminate\Routing\Router。我怎么知道的呢?看看 Route Facade,通常你可以看到一个 PHPDoc @see 提示该 Facade 所隐藏的,或者更准确地说,我们调用的方法所代理的是哪个类。

现在,回到 __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);
    }
}

这就结束了!最后不是很难吗?概括一下,facade 返回一个绑定到容器的字符串。例如,hello-world 可能绑定到 HelloWorld 类。当我们在 facade 上静态调用未定义的方法时,例如 HelloWorldFacade__callStatic()。它将在其 getFacadeAccess() 方法中注册的字符串解析为容器中绑定的任何字符串,并将我们的调用代理到检索到的实例。因此,我们最终得到了 (new HelloWorld())->method()。这就是它的本质!你还是没点击?那么让我们创建我们的 facade。

创建 Facade

假设我们有这么一个类

<?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-word 字符串的 facade。

<?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!
});

我们知道 HelloWorldFacade 中不存在 greet(),因此 __callStatic() 触发了。它从应用容器中拉起一个代表字符串(本例中的 hello-world)的类。并且,我们已经使其在 AppServiceProvider 中做了绑定,我们让其在请求 hello-world 时提供 HelloWorld 实例。因此,任何的调用,比如 greet(),会在 HelloWorld 实例上执行。就是这样

恭喜!你成功创建了自己的 facade!

Laravel 实时 Facades

现在你已经对 facades 有了很好的了解,还有一个魔术要揭开。想象一下,在不创建 facade 的情况下,可以使用实时 facade 调用HelloWorld::greet()

让我们来看看

<?php

use Facades\App\Http\Controllers; // Notice the prefix
use Illuminate\Support\Facades\Route;

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

在控制器命名空间中加上前缀 Facades,我们获得了像之前一样的结果。当然 HelloWorld 控制器并没有任何名为 greet() 的静态方法!Facades\App\Http\Controllers\Hellworld 究竟来自哪里呢?我知道这看起来像是妖术,不过一旦你掌握了,它其实很简单。

我们来仔细看看前述的 \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();  // Interested here
    }
}

你可以看到在最后调用了 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 的设置开头

该逻辑检测 $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。如果你查看 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\\ 前缀后,使用了未定义的类替换了伪内容。然后保存了这个更新后的 facade。因此,当 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: 在类前使用前缀 Facades。在应用启动区间,Laravel 注册 spl_autoload_register(),其在调用未定义的类时触发。它最终导向 load() 方法。在 load() 中, 检测当前未定义类是否有 Facades 前缀。如果匹配,Laravel 尝试加载它。由于该 facade 不存在,它从模板(stub)中创建,并引入该文件。就这样,你得到了一个普通的 facade,不过该 facade 是即时创建的。很酷吧?

总结

祝贺你走到这一步!我知道这可能有点让人不知所措。请随意返回并重读任何你不太明白的部分。通过 IDE 跟进也会有所帮助。不过,不再有黑魔法了,一定感觉很好,至少我第一次是这样感觉的!

记住,下次静态调用方法时,情况可能并非如此