Laravel 从学徒到工匠
服务容器篇
IoC 容器(控制反转容器),借助于“第三方”实现具有依赖关系的对象之间的解耦,如下图所示:
通过 IoC 容器可以帮助我们更方便地管理类依赖
依赖注入 就是传递的参数是一个对象。
在服务提供者中将实现类绑定到所实现的接口,这项工作可以在服务提供者的 register() 方法中完成
public function register()
{
$this->app->bind(BillingNotifierInterface::class, function ($app) {
return new EmailBillingNotifier();
});
}
注:注意到我们在定义绑定关系的时候使用的是匿名函数,这样做的好处是用到该依赖时才会实例化,从而提升了应用的性能。
$this->app->bind(BillerInterface::class, StripeBiller::class);
注:这种方式会在绑定时就会实例化 StripeBiller,性能不及匿名函数。
这里,我们只传了一个字符串进去,而不是一个匿名函数。这个字符串告诉容器总是使用 StripBiller 类作为 BillerInterface 接口的默认实现类。
服务容器就是个用来注册各种接口与实现绑定的地方。一旦一个类在容器里注册了以后,就可以很容易地在应用的任何位置解析并调用它。
一旦我们使用了服务容器,切换接口的实现就是一行代码的事儿。举个例子,考虑以下代码:
class UserController extends BaseController{
public function __construct(BillerInterface $biller)
{
$this->biller = $biller;
}
}
当这个控制器被服务容器实例化的时候,引用 EmailBillingNotifier 的 StripeBiller 会被注入到这个控制器中。现在,如果我们想要换一种通知的实现方式,比如通过短信发送通知(仿照 EmailBillingNotifier 新建一个 SmsBillingNotifier 类),只需在服务提供者中修改绑定到通知接口的实现类即可,其它任何地方都不用修改:
$this->app->bind(BillingNotifierInterface::class, function ($app) {
return new SmsBillingNotifier();
});
利用这种架构设计,我们的应用可以在各种服务的不同实现方式之间快速切换。只改一行代码就能切换接口实现,真的是很强大。
有时候,你可能想在整个应用生命周期中只实例化某类一次,类似单例模式,可以通过 singleton 方法来注册接口与实现类:
$this->app->singleton(BillingNotifierInterface::class, function ($app) {
return new SmsBillingNotifier();
});
现在,只要服务容器解析过这个账单通知对象实例一次,在剩余的请求生命周期中都会使用同一个实例。
单独使用容器:即使你的项目不是基于 Laravel 框架的,依然可以使用Laravel 的服务容器,只要通过 Composer 安装 illuminate/container 就好了。
Laravel 服务容器中最强大的功能之一就是通过反射来自动解析类的依赖。
$reflection = new ReflectionClass(\App\Services\StripeBiller::class);
dump($reflection->getMethods()); # 获取 StripeBiller 类中的所有方法
dump($reflection->getNamespaceName()); # 获取 StripeBiller 的命名空间
dump($reflection->getProperties()); # 获取 StripeBiller 上的所有属性
在 PHP 中,我们不必显式告诉一个方法需要什么类型的参数。
PHP 混合了强类型和鸭子类型(弱类型)结构。为了说明这点,我们来重写一下 billUser 方法:
public function billUser(User $user)
{
$this->biller->bill($user->getId(), $amount);
}
给方法签名加上了 User 类型约束后,我们现在可以确保所有传入billUser 方法的对象,要么是 User 类的实例,要么是一个继承自 User 类的对象实例。
掌握容器:想了解更多关于容器的知识?去读源码吧!容器在底层只有一个类Illuminate\Container\Container,读完了你就会对容器如何工作有更深的理解。
一个接口有多钟实现方式。例如,UserRepositoryInterface 可以有 MySQL 和 Redis 两种实现,并且每一种实现都是 UserRepositoryInterface 的一个实例。
服务提供者篇
作为引导者
Laravel 服务提供者主要用来进行注册服务容器绑定(即注册接口及其实现类的绑定)。
一个服务提供者必须至少有一个 register 方法。你可以在这个方法里将类绑定到容器。当一个请求进入应用,框架启动时,所有罗列在配置文件里的服务提供者的 register 方法就会被调用。这在应用请求生命周期很早的阶段就会发生,所以在我们编写业务逻辑代码时,所有的服务都已经准备好了。
register Vs. boot 方法:永远不要在 register 方法里面使用任何服务。该方法只是用来将对象绑定到服务容器的地方。所有关于绑定类的解析、交互都要在 boot 方法(服务提供者的另一个方法)里进行。
一些通过 Composer 安装的第三方扩展包也会有服务提供者。这些第三方扩展包的安装说明里一般都会告诉你要在配置文件 config/app.php 的 providers 数组里注册其提供的服务提供者(如果支持包自动发现,则不必这么做)。只有注册了对应的服务提供者,才能使用扩展包提供的服务。
延迟加载的服务提供者
不是每一个罗列在配置文件 config/app.php 的 providers 数组里的服务提供者在每次请求时都需要被实例化。这会对性能有影响,尤其是服务提供者注册的服务在这个请求中根本用不到的情况下。例如,QueueServiceProvider 注册的服务就不是每次请求都用得到,只有在请求用到队列时才会用到。
在 Laravel 5 中,我们通过一种新的方式来实现延迟加载服务提供者,在需要延迟加载的服务提供者中将属性 $defer 设置为 true,并重写 providers 方法即可,在这个方法中,我们会以数组方式返回该服务提供者注册的服务容器绑定:
<?php
namespace App\Providers;
use Riak\Connection;
use Illuminate\Support\ServiceProvider;
class RiakServiceProvider extends ServiceProvider{
/**
* 服务提供者加是否延迟加载.
*
* @var bool
*/
protected $defer = true;
/**
* 注册服务提供者
*
* @return void
*/
public function register()
{
$this->app->singleton(Connection::class, function ($app) {
return new Connection($app['config']['riak']);
});
}
/**
* 获取由提供者提供的服务.
*
* @return array
*/
public function provides()
{
return [Connection::class];
}
}
作为管理者
构建一个架构良好的 Laravel 应用的关键就是学习使用服务提供者作为管理工具。
我们先来看个例子吧。
也许我们的应用正在使用 Pusher 通过 WebSocket 推送消息给客户端。为了将我们的应用和 Pusher 解耦,最好创建一个新的 EventPusherInterface 接口和对应的实现类 PusherEventPusher,这样随着需求变化或应用增长,我们就可以随时轻松切换 WebSocket 提供商:
interface EventPusherInterface
{
public function push($message, array $data = array());
}
<?php
namespace App\Services;
use App\Contracts\EventPusherInterface;
use App\Contracts\PusherSdkInterface;
class PusherEventPusher implements EventPusherInterface
{
public function __construct(PusherSdkInterface $pusher)
{
$this->pusher = $pusher;
}
public function push($message, array $data = array())
{
// 通过 Pusher SDK 推送消息
}
}
接下来,我们创建一个服务提供者 EventPusherServiceProvider:
<?php
namespace App\Providers;
use App\Contracts\EventPusherInterface;
use App\Contracts\PusherSdkInterface;
use App\Services\PusherEventPusher;
use Illuminate\Support\ServiceProvider;
use Pusher\Pusher;
class EventPusherServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(PusherSdkInterface::class, function () {
return new Pusher('app-key', 'secret-key', 'app-id');
});
$this->app->singleton(EventPusherInterface::class, PusherEventPusher::class);
}
}
现在我们对事件推送进行了清晰的抽象,同时也有了一个很方便的地方注册和绑定其他相关对象到容器里。最后,只需要将 EventPusherServiceProvider 注册到 config/app.php 配置文件的providers 数组就可以了。现在我们就可以将 EventPusherInterface 注入到应用代码里的任何控制器或类中。
服务提供者的功能不仅仅局限于注册特定类型的服务。我们还可以使用它们注册云存储服务、数据库访问服务、自定义的视图引擎如 Twig 等等。服务提供者只是应用程序的引导和管理工具,没什么其他的。
启动提供者
在所有服务提供者都注册以后(register 方法调用完),它们就进入了「启动」状态。这将会触发每个服务提供者执行各自的 boot 方法。在使用服务提供者时,一种常见的错误就是在register 方法里面调用其他提供者注册的服务。由于在某个服务提供者的 register 方法里,不能保证所有其他服务都已经被注册,在该方法里调用别的服务有可能会出现该服务不可用。因此,调用其它服务的代码应该被定义在服务提供者的 boot 方法中。register 方法只能用于注册服务到容器。
在 boot 方法中,你想做什么都可以:注册事件监听器、引入路由文件、注册过滤器、或者任何其他你能想到的事。再次强调,使用服务提供者作为管理工具的时候,如果你想将几个相关的事件监听器聚合到一起,就将它们放到该服务提供者的 boot 方法里。
框架核心
至此,你可能已经注意到,在 config/app.php 配置文件里面已经有了很多服务提供者,其中每一个都负责引导框架核心的一部分服务。比如MigrationServiceProvider 负责引导用于运行数据库迁移的类,包括Artisan 迁移命令。EventServiceProvide 负责引导和注册事件调度类。尽管不同的服务提供者有着不同的复杂度,有些比较大,另一些相对较小,但它们都负责引导核心的一部分功能。
提升对 Laravel 核心代码理解的最好方法是去读核心服务提供者的源码。如果你对这些服务提供者的功能以及每个服务提供者注册了什么都很熟悉,那么你将会对Laravel 底层是如何工作的有更加深刻的理解。
大部分核心服务提供者是延迟加载的,这意味着不是每次请求都会加载它们;不过,一些用于引导框架基础服务的服务提供者是每一次请求都会被加载的,比如 FilesystemServiceProvide 和 ExceptionServiceProvider。核心服务提供者和应用容器将框架的不同部分联系起来,形成一个单一的、内聚的整体。这些核心服务提供者就是框架的构建块。
目录结构篇
核心思想就是分层
应用架构篇
在本章,我们将讨论如何解耦各种处理器:队列处理器、事件处理器,甚至其他「类似事件」的结构,比如路由过滤器。
大部分的「处理器」可以被当作传输层组件。也就是说,它们通过队列处理器、被触发的事件、或者外部发来的请求等接收调用。这样一来,我们可以将这些处理器理解为控制器,同样需要避免在它们内部堆积太多具体的业务逻辑实现。
解耦处理器
首先,我们看一个例子。假设有一个队列处理器用来给用户发送手机短信。信息发送后,处理器会记录消息日志以便保存给用户发送过的所有消息历史。对应代码如下:
class SendSMS
{
public function fire($job, $data)
{
$twilio = new Twilio_SMS($apiKey);
$twilio->sendTextMessage(array(
'to'=> $data['user']['phone_number'],
'message'=> $data['message'],
));
$user = User::find($data['user']['id']);
$user->messages()->create(array(
'to'=> $data['user']['phone_number'],
'message'=> $data['message'],
));
$job->delete();
}
}
简单审查下这个类,你可能会发现一些问题。首先,它难以测试。在 fire 方法里直接实例化了 Twilio_SMS 类,意味着我们没法注入一个模拟的服务。其次,我们直接在处理器中使用了 Eloquent 模型,导致在测试时肯定会对数据库造成影响。最后,我们没法在队列以外发送短信。所有短信发送逻辑和 Laravel 队列耦合在一起了。
通过将短信发送逻辑提取到一个单独的「服务」类,就可以将其和 Laravel 队列解耦。这样我们就可以在应用的任何位置发送短信了。此外,解耦的同时也令其变得更易于测试。
那么,我们按照这个思路重构前面的代码:
class User extends Eloquent
{
/**
* Send the User an SMS message
*
* @param SmsCourierInterface $courier
* @param string $message
* @return SmsMessage
*/
public function sendSmsMessage(SmsCourierInterface $courier, $message)
{
$courier->sendMessage($this->phone_number, $message);
return $this->sms()->create([
'to' => $this->phone_number,
'message' => $message,
]);
}
}
在重构后的示例代码中,我们将短信发送逻辑提取到 User 模型类的 sendSmsMessage 方法中。同时我们将 SmsCourierInterface 的实现注入到该方法里,这样我们可以更容易对该流程进行测试。现在,我们已经重构了短信发送逻辑,接下来,让我们来重写队列处理器:
class SendSMS
{
public function __construct(UserRepository $users, SmsCourierInterface $courier)
{
$this->users = $users;
$this->courier = $courier;
}
public function fire($job, $data)
{
$user = $this->users->find($data['user']['id']);
$user->sendSmsMessage($this->courier, $data['message']);
$job->delete();
}
}
可以看到在重构后的代码中,队列处理器更加轻量化了。它实际上变成了队列系统和真正的业务逻辑之间的转换层。这非常好!意味着我们可以很轻松地在队列系统之外发送短信。
其他处理器
使用类似的方式,我们可以优化和解耦很多其他类型的「处理器」。通过将这些处理器限制为简单的转换层,你可以将繁重的业务逻辑整齐地组织起来,并且与框架的其他部分解耦。为了进一步加深理解,我们来看一个路由过滤器。该过滤器用来验证当前用户是否已经订阅高级用户套餐。
路由过滤器在 Laravel 5 版本中已经废弃,改为通过中间件来实现相应功能。所以在 Laravel 5 中可以通过中间件实现类似代码。
Route::filter('premium', function()
{
return Auth::user() && Auth::user()->plan == 'premium';
});
乍一看这个路由过滤器没什么问题啊。这么简单的过滤器能有什么错误?然而,即使是在这么小的一个过滤器中,我们却将应用实现的细节暴露了出来。我们在该过滤器中手动检查了 plan 变量的值,这使得将业务逻辑中「套餐方案」的表示值硬编码到了路由/传输层。现在,如果想调整「高级套餐」在数据库或用户模型的表示值,竟然需要同步修改这个路由过滤器!
所以,我们需要调整这段代码:
Route::filter('premium', function()
{
return Auth::user() && Auth::user()->isPremium();
});
在这里我们又一次讨论了职责的概念。记住,要始终明确一个类的职责边界,该知道什么,不该知道什么,并适时进行调整和优化。避免在传输层(如处理器)中直接编写应用的业务逻辑代码。
框架扩展篇
为了方便你自定义框架的核心组件功能,甚至是完全替换它们,Laravel 提供了大量可以对应用进行扩展的地方。例如,哈希服务实现了 Illuminate\Contracts\Hashing\Hasher 契约,你可以按照自己应用的需求来重新实现它。你还可以继承 Request 对象类,添加自己用的顺手的方法。你甚至可以添加全新的用户认证、缓存和会话驱动!
Laravel 组件功能通常有两种扩展方式:在服务容器里面绑定新实现,或者通过采用工厂模式实现的 Manager 类注册一个自定义的扩展。
管理类和工厂
Laravel 有多个 Manager 类用来管理基于驱动的组件的创建。这些组件包括缓存、会话、用户认证、队列组件等。管理类负责根据应用程序的配置来创建特定的驱动实例。例如,CacheManager 可以创建 APC、Memcached、Redis 以及其他不同的缓存驱动的实现。
同时,每个管理类都包含 extend 方法,该方法可用于将新的驱动解决方案注入到管理类中。下面我们将逐个介绍这些管理类,并向你展示如何将自定义的驱动注入它们。
了解你的管理类:请花点时间看看 Laravel 中每个 Manager 类的代码,比如 CacheManager 和 SessionManager。通过阅读这些代码能让你对Laravel 底层工作原理有更加全面的了解。所有管理类都继承自Illuminate\Support\Manager 基类,该基类每个管理类提供了一些有用的通用功能(适用于 Laravel 4,Laravel 5并非如此)。 vendor/laravel/framework/src/Illuminate/ 目录下的源码,几乎每个组件目录下都有一个 XXXManager 类
缓存
要扩展 Laravel 的缓存服务,需要使用 CacheManager 里的 extend 方法,该方法用来绑定自定义的缓存驱动到管理类。在所有管理类中都是这个逻辑,所以扩展其他的管理类也是按照这个思路来。例如,我们想注册一个新的缓存驱动,名叫「mongo」,代码可以这样写:
Cache::extend('mongo', function($app) {
// Return Illuminate\Cache\Repository instance...
});
extend 方法的第一个参数是自定义缓存驱动的名字。该名字对应 config/cache.php 配置文件中的 driver 配置项。第二个参数是一个会返回 Illuminate\Cache\Repository 实例的匿名函数,传入该匿名函数的 $app 参数是 Illuminate\Foundation\Application 的实例,即全局服务容器。
要创建自定义的缓存驱动,首先要实现 Illuminate\Contracts\Cache\Store 接口。所以,基于 MongoDB 实现的缓存驱动代码结构如下:
use Illuminate\Contracts\Cache\Store;
class MongoStore implements Store
{
public function get($key)
{
// TODO: Implement get() method.
}
public function many(array $keys)
{
// TODO: Implement many() method.
}
public function put($key, $value, $minutes)
{
// TODO: Implement put() method.
}
public function putMany(array $values, $minutes)
{
// TODO: Implement putMany() method.
}
public function increment($key, $value = 1)
{
// TODO: Implement increment() method.
}
public function decrement($key, $value = 1)
{
// TODO: Implement decrement() method.
}
public function forever($key, $value)
{
// TODO: Implement forever() method.
}
public function forget($key)
{
// TODO: Implement forget() method.
}
public function flush()
{
// TODO: Implement flush() method.
}
public function getPrefix()
{
// TODO: Implement getPrefix() method.
}
}
我们只需使用 MongoDB 连接来实现上面的每一个方法即可。一旦实现完毕,就可以像下面这样完成自定义驱动的注册:
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
Cache::extend('mongo', function($app)
{
return new Repository(new MongoStore);
}
正如上面的例子所示,在创建自定义驱动的时候你可以直接使用 Illuminate\Cache\Repository 基类。通常你不需要创建自己的 Repository 类。
如果你不知道要把自定义的缓存驱动代码放到哪里,可以考虑将其放到扩展包然后发布到 Packagist 。或者,你也可以在应用的 app 目录下创建一个 Extensions 目录,然后将 MongoStore.php 放到该目录下。不过,Laravel 并没有对应用程序的目录结构做硬性规定,所以你完全可以按照自己喜欢的方式组织应用程序的代码。
如果你还为在哪里存放注册代码发愁,服务提供者是个不错的地方。我们之前就讲过,使用服务提供者来管理框架扩展代码是一个非常不错的方式,将相应代码放到服务提供者的 boot 方法即可。
用户认证
用户认证的扩展方式和缓存、会话的扩展方式一样,使用认证管理类上的 extend 方法就可以了:
Auth::extend('riak', function($app) {
// Return implementation of Illuminate\Contracts\Auth\UserProvider
});
Illuminate\Contracts\Auth\UserProvider 接口的实现类负责从某个持久化存储系统(如 MySQL、Riak 等)中获取 Illuminate\Contracts\Auth\Authenticatable 接口的实现类实例。这两个接口使得 Laravel 的用户认证机制得以在不用关心用户数据如何存储以及使用何种类型表示用户的情况下继续工作。
下面我们来看看 Illuminate\Contracts\Auth\UserProvider 接口的代码:
<?php
namespace Illuminate\Contracts\Auth;
interface UserProvider
{
/**
* Retrieve a user by their unique identifier.
*
* @param mixed $identifier
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveById($identifier);
/**
* Retrieve a user by their unique identifier and "remember me" token.
*
* @param mixed $identifier
* @param string $token
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByToken($identifier, $token);
/**
* Update the "remember me" token for the given user in storage.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param string $token
* @return void
*/
public function updateRememberToken(Authenticatable $user, $token);
/**
* Retrieve a user by the given credentials.
*
* @param array $credentials
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByCredentials(array $credentials);
/**
* Validate a user against the given credentials.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param array $credentials
* @return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials);
}
retrieveById 方法通常接受一个表示用户ID的数字键,比如 MySQL 数据库的自增 ID。该方法会返回与给定 ID 匹配的 Illuminate\Contracts\Auth\Authenticatable 的实现类实例,如 User 模型类实例。
当用户尝试登录到应用时,retrieveByCredentials 方法会接受传递给 Auth::attempt 方法的认证凭证数组。然后该方法会「查询」底层的持久化存储系统,来找到与给定凭证信息匹配的用户。通常,该方法会执行一个带有「where」条件的查询来匹配参数里的 $credentials['username']。该方法不应该尝试做任何密码验证。
validateCredentials 方法会通过比较给定 $user 和$credentials 来认证用户。例如,该方法会比较 $user->getAuthPassword() 方法返回的字符串和 $credentials['password'] 经过 Hash::make 处理后的结果,如果相等,则认为认证通过,否则认证失败。
retrieveByToken 方法和 updateRememberToken 则用于在登录认证时实现「记住我」的功能,让用户在 Token 有效期内不用输入登录凭证即可自动登录。
现在,我们已经探索了 Illuminate\Contracts\Auth\UserProvider 接口的每一个方法,接下来,我们来看看 Illuminate\Contracts\Auth\Authenticatable 接口。别忘了,Authenticatable 接口实现的实例是通过是 UserProvider 实现实例的 retrieveById 和 retrieveByCredentials 方法返回的:
<?php
namespace Illuminate\Contracts\Auth;
interface Authenticatable
{
/**
* Get the name of the unique identifier for the user.
*
* @return string
*/
public function getAuthIdentifierName();
/**
* Get the unique identifier for the user.
*
* @return mixed
*/
public function getAuthIdentifier();
/**
* Get the password for the user.
*
* @return string
*/
public function getAuthPassword();
/**
* Get the token value for the "remember me" session.
*
* @return string
*/
public function getRememberToken();
/**
* Set the token value for the "remember me" session.
*
* @param string $value
* @return void
*/
public function setRememberToken($value);
/**
* Get the column name for the "remember me" token.
*
* @return string
*/
public function getRememberTokenName();
}
这个接口很简单。getAuthIdentifier 方法返回用户的「主键」。如果在 MySQL 数据库中,就是自增主键了。getAuthPassword 方法返回经过散列处理的用户密码。getAuthIdentifierName 方法会返回用户的唯一标识,比如用户名或邮箱信息。其他几个方法都是和「记住我」功能相关的。
有了这个接口,用户认证系统就可以处理任何用户类,而不用关心该用户类使用了什么 ORM 框架或者存储抽象层。默认情况下,Laravel 已经在 app 目录下提供了实现 Authenticatable 接口的 User 类。所以你可以将这个类作为实现示例。
最后,当我们实现了 Illuminate\Contracts\Auth\UserProvider 接口后,就可以将对应扩展注册进 Auth 里面:
Auth::extend('riak', function($app) {
return new RiakUserProvider($app['riak.connection']);
});
使用 extend 方法注册好驱动以后,你就可以在 config/auth.php 配置文件中切换到新的驱动了(对应配置项是 providers.users.driver)。
容器默认绑定
几乎所有 Laravel 框架自带的服务提供者都会绑定一些对象到服务容器里。你可以在 config/app.php 配置文件里找到服务提供者列表。如果你有时间的话,你应该大致过一遍每个服务提供者的源码。这么做的好处是你可以对每个服务提供者有更深的理解,明白它们都往框架里加了什么东西,以及对应的绑定到服务容器的键是什么,通过这些键我们就可以从容器中解析相应的服务。
由于 Laravel 5.5 中新增了包自动发现功能,所以 config/app.php 配置文件的 providers 数组提供的服务服务者列表并不全,最全的列表在 bootstrap/cache/services.php 的 providers 数组中。
举个例子,AuthServiceProvider 向服务容器内绑定了一个 auth 键,通过这个键解析出来的服务是一个 Illuminate\Auth\AuthManager 的实例。你可以在自己的应用中通过覆盖这个服务容器绑定来轻松实现扩展并重写该类。例如,你可以创建一个继承自 AuthManager 类的子类:
namespace App\Extensions;
class MyAuthManager extends Illuminate\Auth\AuthManager
{
//
}
子类写好以后,你可以在服务提供者 AuthServiceProvider 的 boot 方法中覆盖默认的 auth:
public function boot()
{
...
$this->app->singleton('auth', function ($app) {
return new MyAuthManager;
});
}
这就是扩展绑定进容器的任意核心类的通用方法。基本上每一个核心类都以这种方式绑定进了容器,都可以被重写。还是那一句话,读一遍框架自带的服务提供者源码可以帮助你熟悉各种类是怎么绑定进容器的,都绑定到哪些键上。这是学习 Laravel 框架底层究竟如何运转的最佳实践。