支持 Laravel.io 的持续发展 →

Laravel Under The Hood - Facades

2024年2月10日 阅读时间:13分钟

你好 Facades 👋

你刚刚安装了一个新的Laravel应用程序,启动了它,并看到了欢迎页面。像其他人一样,你试着看它是如何渲染的,因此你跳进 web.php 文件,遇到了以下代码

<?php

use Illuminate\Support\Facades\Route;

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

很显然,我们是如何获取欢迎视图的,但你很好奇Laravel的路由器是如何工作的,所以你决定深入研究代码。最初的想法是:有一个 Route 类,我们在它上面调用一个静态方法 get()。然而,当你点击它时,那里没有 get() 方法。那么,这里发生了什么奇特的魔术?让我们揭开这层神秘吧!

普通的Facades

请注意,为了简单起见,我已经删除了大部分PHPDocs并将类型内联到代码中,“...”代表更多的代码。

我强烈建议您打开您的IDE并跟随代码,以避免任何混淆。

按照我们的示例,让我们来探索一下 Route

<?php

namespace Illuminate\Support\Facades;

class Route extends Facade
{
    // ...

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

这里没有什么,只有一个返回字符串 routergetFacadeAccessor() 方法。记住这一点,让我们转向父类

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

嘿,这就是从子类Route中继承的getFacadeAccessor(),我们知道返回了字符串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()方法,它会遍历这些启动器,在bootstrapWith()中调用它们的bootstrap()方法。为了简单起见,让我们只关注包含会被bootstrapWith()中调用的bootstrap()方法的\Illuminate\Foundation\Bootstrap\RegisterFacades

<?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,尽管它是一个实例化的应用程序容器对象。正确!然而,应用程序容器实现了PHP接口ArrayAccess,允许数组风格的访问。我们可以检查一下以确认这一点。

<?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外观,通常你会在其附近找到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());

现在,你可以确认在\Illuminate\Routing\Router类中存在get()方法。

<?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中完成了这种绑定,我们指示它当有人请求hello-world时提供HelloWorld的实例。因此,任何调用,如greet(),都会在这个检索到的HelloWorld实例上运行。就是这样。

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

Laravel实时门面

现在你已经很好地理解了门面,还有一个魔法技巧要揭晓。想象一下,能够不创建门面就调用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\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();  // 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() 生成的,根据其名称,它似乎创建了一个从stub生成的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 类不需要任何构造参数。所以,容器解析它,返回它,所有的调用都会代理到它上面。

总结实时facade:我们已经把类名前缀加上了 Facades。在应用程序引导过程中,Laravel注册了 spl_autoload_register(),当我们调用未定义的类时会触发它。这最终会引领到 load() 方法。在 load() 内部,我们检查当前未定义的类是否以 Facades 前缀开头。匹配成功,因此Laravel尝试加载它。由于facade不存在,它会从stub创建它并需要该文件。すると、你得到了一个普通的facade,但这个facade是即时创建的。很酷,不是吗?

结论

恭喜你坚持到了这里!我理解这可能会有些令人不知所措。如果你对某些部分不太明白,请自由地回头重新阅读。使用你的IDE进行跟进也会有所帮助。但是,嘿,没有更多的黑魔法了,至少我第一次使用时是这种感觉!

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

最后更新 5个月前。

driesvints, ol-serdiuk 赞赏了这篇文章

2
喜欢这篇文章? 让作者知道,并给他们点个赞!
oussamamater (Oussama Mater) 我是一名软件工程师和CTF玩家。我使用Laravel和Vue.js将想法转变成应用程序 🚀

你可能还感兴趣的其他文章

如何使用 Larastan 将你的 Laravel 应用从0到9进行优化

在 Laravel 应用执行之前就发现错误是可能的,归功于 Larastan,它可以...

阅读文章

无需 Traits 标准化 API 响应

我发现大多数用于 API 响应的库都是使用 Traits 实现,并且...

阅读文章

通过 Discord 通知在 Laravel 项目中收集反馈

如何在 Laravel 项目中创建反馈模块并在收到消息时接收 Discord 通知

阅读文章

我们感谢这些 惊人的公司 对我们的支持

您的标志在这里?

Laravel.io

Laravel 问题的解决、知识共享和社区建设的门户。

Laravel.io

社交媒体

社区

© 2024 Laravel.io - 所有权利保留。