說明
Laravel 內建了傳送郵件重置密碼的功能,本文分析其傳送請求重置密碼郵件的功能,瞭解其執行流程。首先,假設我們已經有一個大概長這樣的User
模型:
.
.
.
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
.
.
.
}
一個必要特徵是繼承Illuminate\Foundation\Auth\User
類,並且引入Notifiable
這個 trait。
流程分析
從路由:
Route::post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email')
定位到控制器:app\Http\Controllers\Auth\ForgotPasswordController.php
,發起重置密碼請求的操作方法在其引入的SendsPasswordResetEmails
trait 中,具體程式碼:
public function sendResetLinkEmail(Request $request)
{
//驗證輸入的引數
$this->validateEmail($request);
$response = $this->broker()->sendResetLink(
$this->credentials($request)
);
return $response == Password::RESET_LINK_SENT
? $this->sendResetLinkResponse($request, $response)
: $this->sendResetLinkFailedResponse($request, $response);
}
主要的邏輯都在中間的一句。
$this->broker() 分析
首先,邏輯從$this->broker()
開始。broker
方法:
public function broker()
{
// Password 是 Illuminate\Support\Facades\Password.php類
// 該類是Facade類
// dd(get_class(resolve('auth.password')))列印出其對應的實現類是:
// Illuminate\Auth\Passwords\PasswordBrokerManager
return Password::broker();
}
真正的實現在 Illuminate\Auth\Passwords\PasswordBrokerManager
類的broker
方法:
public function broker($name = null)
{
$name = $name ?: $this->getDefaultDriver();
return $this->brokers[$name] ?? ($this->brokers[$name] = $this->resolve($name));
}
由於前面的$name
沒有傳值,所以會先執行$this->getDefaultDriver()
獲取$name
:
public function getDefaultDriver()
{
// app['config']['auth.defaults.passwords'] == 'user'
return $this->app['config']['auth.defaults.passwords'];
}
因此預設情況下,$name的值為users。
接下來,由於$this->brokers[$name]
還沒有值,所以呼叫後面的resolve
方法,其程式碼如下:
protected function resolve($name)
{
// A
$config = $this->getConfig($name);
if (is_null($config)) {
throw new InvalidArgumentException("Password resetter [{$name}] is not defined.");
}
// B
return new PasswordBroker(
$this->createTokenRepository($config),
$this->app['auth']->createUserProvider($config['provider'] ?? null)
);
}
該方法主要做個兩個操作,其一是獲取配置:
protected function getConfig($name)
{
return $this->app['config']["auth.passwords.{$name}"];
}
可以看出,得到的配置是 auth.php
檔案中,auth.passwords.users
鍵的值(各值的作用見註釋):
[
'provider' => 'users', // 資料提供者
'table' => 'password_resets', //儲存token的表
'expire' => 60, //token過期時間
]
其二是例項化一個Illuminate\Auth\Passwords\PasswordBroker
類的例項。注意到例項化的時候傳入的兩個引數,這兩個引數分別是:
-
token 倉庫類
列印一下第一個引數,
$this->createTokenRepository($config)
的值,得到:
其中,$table
屬性的值為password_resets
,即存放token的資料表名稱,該值來自我們的auth.php
配置檔案,因此,我們可以根據實際需要修改存放token的表名,同理,也可以配置token的過期時間。 -
資料提供類
第二個引數,
$this->app['auth']->createUserProvider($config['provider'] ?? null)
,預設情況下是一個EloquentUserProvider
類:
語句中的createUserProvider
方法位於AuthManager
類引入的CreatesUserProviders
trait 中,其主要邏輯是:讀取auth.php
檔案中的provider.users
的值,然後根據獲取到的驅動去建立驅動例項,一般有database和eloquent驅動,預設是使用eloquent驅動。
最終 $this->broker()
得到的值為:
一個PasswordBroker
類的例項。
sendResetLink 方法
得到 PasswordBroker
類的例項後,程式接著呼叫其旗下的 sendResetLink
方法:
public function sendResetLink(array $credentials)
{
# A
$user = $this->getUser($credentials);
if (is_null($user)) {
return static::INVALID_USER;
}
# B
$user->sendPasswordResetNotification(
$this->tokens->create($user)
);
return static::RESET_LINK_SENT;
}
A)根據傳入的引數$credentials
查詢對應使用者並建立模型
再看一下開頭的程式碼片段:
$response = $this->broker()->sendResetLink(
$this->credentials($request)
);
sendResetLink
傳入的引數值為:$this->credentials($request),其credentials
方法的返回值為:$request->only('email'), 然後該值傳遞給getUser
方法,從而獲得 user 模型。由此可知,預設是使用郵箱查詢使用者。如果我們想要使用的是使用者名稱查詢,可以將$this->credentials($request)
替換為$request->only('username')
。
B)sendPasswordResetNotification 方法
我們的郵件是如何發出去的,都要在這裡展開來分析。首先,先來分析傳入的引數。它接收的引數為$this->tokens->create($user)
。$this->tokens
為前面獲取到的token倉庫類DatabaseTokenRepository
,該類旗下的 create
方法:
public function create(CanResetPasswordContract $user)
{
# 獲取使用者的email
$email = $user->getEmailForPasswordReset();
# 刪除password_resets表中的對應記錄
$this->deleteExisting($user);
# 建立新的token:hash_hmac('sha256', Str::random(40), $this->hashKey)
# 傳入的 $this->hashKey 是來自 .env 檔案的 APP_KEY
$token = $this->createNewToken();
# 將token資料儲存到password_resets表
$this->getTable()->insert($this->getPayload($email, $token));
# 最後將建立的token返回
return $token;
}
由此以上程式碼可知,在獲得token的過程中,程式還帶做了另外幾件事:1. 刪除password_resets表中的對應記錄(如果有的話);2.建立token並儲存到password_resets表。
得到 token 後,我們就可以著手分析sendPasswordResetNotification
方法了。該方法位於Illuminate\Foundation\Auth\User
類引入的CanResetPassword
trait 中(從最開頭的程式碼片段可以看出,User模型繼承了Illuminate\Foundation\Auth\User
類,所以擁有該方法的),該方法具體實現:
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPasswordNotification($token));
}
首先,我們先看傳入的引數,是一個ResetPassword
類的例項,該類繼承了Notification
類,列印下傳入的引數,是這樣子的:
接著,我們來分析notify
方法,它位於我們建立的User模型引入的Notifiable
trait 中,而實際又是在Notifiable
trait 引入的RoutesNotifications
trait 中:
public function notify($instance)
{
# Dispatcher::class 對應 Illuminate\Contracts\Notifications\Dispatcher介面
app(Dispatcher::class)->send($this, $instance);
}
Dispatcher::class
只是一個介面類,那麼它的具體實現是哪個類呢?由Illuminate\Notifications\RoutesNotifications
類定位到其所在的資料夾Notifications
,在這個資料夾中,有一個服務提供者NotificationServiceProvider
,正是在這裡定義了Dispatcher::class
由哪個類來實現,定義程式碼如下:
public function register()
{
//B ChannelManager::class的實現繫結為 ChannelManager 類的例項
$this->app->singleton(ChannelManager::class, function ($app) {
return new ChannelManager($app);
});
// A 將Dispatcher介面類別名設為ChannelManager::class
$this->app->alias(
ChannelManager::class, DispatcherContract::class
);
$this->app->alias(
ChannelManager::class, FactoryContract::class
);
}
這裡倒過來看,先看A語句設定了別名,在看B語句繫結介面到一個例項。所以,app(Dispatcher::class)->send($this, $instance);
中的send
方法是屬於ChannelManager::class
類的,其實現如下:
public function send($notifiables, $notification)
{
return (new NotificationSender(
$this, $this->container->make(Bus::class), $this->container->make(Dispatcher::class), $this->locale)
)->send($notifiables, $notification);
}
該方法接收了兩個引數,第一個是被通知的物件,比如這裡是傳入User模型,第二個是訊息例項(通知的內容),從前面分析可知,這裡是傳入了一個ResetPassword
類的例項。該方法例項化了NotificationSender
類並呼叫其send
方法,讓我們跳轉到這個send
方法:
public function send($notifiables, $notification)
{
// 將被通知物件格式化成模型集合
$notifiables = $this->formatNotifiables($notifiables);
// 如果使用佇列
if ($notification instanceof ShouldQueue) {
return $this->queueNotification($notifiables, $notification);
}
return $this->sendNow($notifiables, $notification);
}
該方法主要是格式化了一遍傳入的被通知物件,然後呼叫sendNow
方法:
public function sendNow($notifiables, $notification, array $channels = null)
{
// 將被通知物件格式化成模型集合
$notifiables = $this->formatNotifiables($notifiables);
// 克隆通知訊息例項
$original = clone $notification;
// 檢查被通知物件是否有channel,比如database,mail
// 沒有的話就略過,不對其作通知
// A
foreach ($notifiables as $notifiable) {
if (empty($viaChannels = $channels ?: $notification->via($notifiable))) {
continue;
}
// B 設定使用的語言
$this->withLocale($this->preferredLocale($notifiable, $notification), function () use ($viaChannels, $notifiable, $original) {
$notificationId = Str::uuid()->toString();
// C 傳送通知到每一個頻道
foreach ((array) $viaChannels as $channel) {
$this->sendToNotifiable($notifiable, $notificationId, clone $original, $channel);
}
});
}
}
(A) 分析$notification->via($notifiable)
。從前面可知,$notification
是ResetPassword
類的例項,所以via
方法是其旗下的方法,程式碼如下:
public function via($notifiable)
{
return ['mail'];
}
由此可以看出,預設是使用傳送郵件的方式。
(B) 分析 preferredLocale
方法。
protected function preferredLocale($notifiable, $notification)
{
return $notification->locale ?? $this->locale ?? value(function () use ($notifiable) {
// 如果被通知物件實現了HasLocalePreference介面
if ($notifiable instanceof HasLocalePreference) {
return $notifiable->preferredLocale();
}
});
}
由以上程式碼可知,被通知物件可以實現HasLocalePreference
介面,從而通過實現preferredLocale
方法指定使用的語言。
(C) 分析 sendToNotifiable
方法。
protected function sendToNotifiable($notifiable, $id, $notification, $channel)
{
if (! $notification->id) {
$notification->id = $id;
}
// 觸發通知將要傳送的事件,如果返貨false,通知將不會被髮送
if (! $this->shouldSendNotification($notifiable, $notification, $channel)) {
return;
}
// $this->manager->driver($channel)將根據傳入的$channel建立對應channel類的例項
// 比如,mail channel,將建立MailChannel類的例項
// C-1
$response = $this->manager->driver($channel)->send($notifiable, $notification);
// 觸發訊息已傳送的事件
$this->events->dispatch(
new Events\NotificationSent($notifiable, $notification, $channel, $response)
);
}
(C-1) $this->manager->driver($channel)
得到的是MailChannel
類的例項,程式接著呼叫它的send
方法:
public function send($notifiable, Notification $notification)
{
// $notification是Illuminate\Auth\Notifications\ResetPassword類的例項
// C-1-1
$message = $notification->toMail($notifiable);
if (! $notifiable->routeNotificationFor('mail', $notification) &&
! $message instanceof Mailable) {
return;
}
// 如果實現了Mailable介面
if ($message instanceof Mailable) {
return $message->send($this->mailer);
}
// 傳送郵件
$this->mailer->send(
$this->buildView($message),
array_merge($message->data(), $this->additionalMessageData($notification)),
$this->messageBuilder($notifiable, $notification, $message)
);
}
(C-1-1) Illuminate\Auth\Notifications\ResetPassword類的toMail
方法:
public function toMail($notifiable)
{
if (static::$toMailCallback) {
return call_user_func(static::$toMailCallback, $notifiable, $this->token);
}
return (new MailMessage)
->subject(Lang::get('Reset Password Notification'))
->line(Lang::get('You are receiving this email because we received a password reset request for your account.'))
->action(Lang::get('Reset Password'), url(config('app.url').route('password.reset', ['token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset()], false)))
->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]))
->line(Lang::get('If you did not request a password reset, no further action is required.'));
}
傳送郵件通知的內容都在這裡設定。$message = $notification->toMail($notifiable);
最終得到的值$message
如下:
最後,根據$message
例項的各種屬性設定傳送郵件,限於篇幅,具體細節就不再分析了。
傳送完郵件訊息之後,Illuminate\Auth\Passwords\PasswordBroker
類的方法返回static::RESET_LINK_SENT
。回到開頭的sendResetLinkEmail
方法:
public function sendResetLinkEmail(Request $request)
{
$this->validateEmail($request);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink(
$this->credentials($request)
);
return $response == Password::RESET_LINK_SENT
? $this->sendResetLinkResponse($request, $response)
: $this->sendResetLinkFailedResponse($request, $response);
}
由於$response == Password::RESET_LINK_SENT
為true,所以執行$this->sendResetLinkResponse($request, $response)
:
protected function sendResetLinkResponse(Request $request, $response)
{
return back()->with('status', trans($response));
}
所以傳送訊息成功後,頁面後退,同時帶上訊息傳送成功的資訊。