月度归档:2018年03月

Laravel 实践 – Api Token认证

常见的Session认证中
1 客户端如果没有传递SessionID过来,那么就认为未认证
2 如果传递了SessionID过来,服务端根据SessionID定位Session,如果定位不到,那么就认为未认证
3 如果定位到了Session,但是Session中的认证标记不存在或无效,那么就认为未认证
4 Session中的认证标记存在并有效,认证通过

客户端没有传递SessionID过来,服务端一般会回写一个携带了SessionID的Cookie(意味着启动一个会话,也可能不回写,比如浏览静态资源),下次这个Cookie就会回传到过来,这样就能确定多个请求之间是同一个会话。然后再从Session中判断认证标志从而确定是否已认证。

如果客户端没有回传SessionID(通常是使用Cookie作为载体),一般服务端就会启动一个新会话,那么之前的会话就无法定位了,但是服务端会话数据还是可能存在。如果客户端回传了SessionID,服务端定位不到这个会话,说明已经长久不登录,服务端的会话已经被删除,这个时候也产生一个新的会话,另外就是如果定位到会话(未被删除),但是会话中没有认证标记,或有标记,但是时间已经超时,这时可以不产生新的会话(一般登录成功后会更换一个SessionID)。

这里面,我们是否可以把这个SessionID(不重复),直接对应一个用户,如果传递了SessionID,但是定位不到用户,认为未认证。如果定位到了用户,并且token未超时,认为认证通过。和Session方案相比,Session可以存储认证凭证,从而不用每次都查询。这里的简化流程,就是API Token认证。

在Laravel中,提供了TokenGuard,简单来说就是比对Token,具体操作流程:
1 在users表中添加api_token字段

$table->string('api_token', 60)->unique()->nullable();

2 在users表中寻找一个用户,把api_token字段填写一个任意token

3 在routes的api.php中定义一个闭包路由,应用auth:api中间件(auth表示需要认证,默认使用web对应的Guard,冒号后可以指定Guard)

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

4 测试

curl -X http://demo.text/api/user \
-H "Accept: application/json" \
-d "token=xxxxxxxxxxxxxxxxxx"

注意:定义在api.php中的路由,访问时是不会产生会话的(确少StartSession中间件)。另外,Laravel的异常是根据请求是否是ajax或是否有Accept请求头并且包含json来渲染JSON返回的,否则就是渲染Html输出。但是API应该总是返回JSON,所以应该总是携带Accept: application/json请求头。不过每次都要这么写,多少有点麻烦,所以换一个姿势。API的URL都是以api开头的,所以可以修改$request的请求头,这个可以使用容器的rebinding来进行:

#文件:app/Providers/AppServiceProvider.php

    public function register()
    {
        $this->addAcceptableJsonType();
    }

    /**
     * Add "application/json" to the "Accept" header for the current request.
     */
    protected function addAcceptableJsonType()
    {
        $this->app->rebinding('request', function ($app, $request) {
            if ($request->is('api/*')) {
                $accept = $request->header('Accept');
                if (!str_contains($accept, ['/json', '+json'])) {
                    $accept = rtrim('application/json,' . $accept, ',');
                    $request->headers->set('Accept', $accept);
                    $request->server->set('HTTP_ACCEPT', $accept);
                    $_SERVER['HTTP_ACCEPT'] = $accept;
                }
            }
        });
    }

在用户登录后,让其在后台自己产生token,然后把token分发出去使用,这是一个很传统的做法。另外,token长期有效,会存在安全问题。可以改造一下,在登录成功后更换token,登出时擦除token。另外,还可以给token设置一个超时时间:

#文件:routes/api.php
// 登录(每次登录都更换token,登录相当于更换token,登录成功后返回用户实例,包含token)
// 请求体:email  password
Route::post('backend/login', 'API\Backend\Auth\LoginController@login');
// 注册(注册成功后返回用户实例,包含token)
// 请求体: name  email  password  password_confirmation
Route::post('backend/register', 'API\Backend\Auth\RegisterController@register');
// 重置密码 - 发送邮件
// 请求体: email
Route::post('backend/password/email', 'API\Backend\Auth\ForgotPasswordController@sendResetLinkEmail');
// 重置密码 - 输入新密码
// 请求体: token  email  password  password_confirmation
Route::post('backend/password/reset', 'API\Backend\Auth\ResetPasswordController@reset');

// Token保护的路由
// 添加请求头:Authorization  =>  "Bearer Token"
Route::group([
    'middleware' => ['auth:backend_api']
], function () {
    // 登出(撤销token),需要传递token
    Route::any('backend/logout', 'API\Backend\Auth\LoginController@logout');

    // 获取用户
    Route::get('backend/admin', function (Request $request) {
        return [
            'data' => $request->user()
        ];
    });
});

#文件 config/auth.php
#添加backend_api
<?php

return [

    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'backend' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],

        'backend_api' => [
            'driver' => 'token',
            'provider' => 'admins',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],

        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Models\Admin::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
        ],
        'admins' => [
            'provider' => 'admins',
            'table' => 'admin_password_resets',
            'expire' => 60,
        ],
    ],

];

这里把用户存储到admins表,密码找回存储到admin_password_resets表。拷贝一套Auth文件,针对性覆盖某些方法:

#文件: app/Http/Controllers/API/Backend/Auth/LoginController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;

class LoginController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
    */

    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
    }

    /**
     * 用户通过认证,返回JSON
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    protected function sendLoginResponse(Request $request)
    {
        //API调用,没有启动会话
        //$request->session()->regenerate();
        $this->clearLoginAttempts($request);
        $user = with($this->guard()->user())->makeToken();

        return response()->json([
            'data' => $user,
        ], 200);
    }

    /**
     * 取回需要验证的信息
     * 覆盖来自AuthenticatesUsers中的credentials方法
     *
     * @param  \Illuminate\Http\Request $request
     * @return array
     */
    public function logout(Request $request)
    {
        with($request->user())->freshToken();

        return response()->json(['data' => 'Token已经擦除'], 200);
    }

    protected function guard()
    {
        return Auth::guard('backend');
    }

    /**
     * 取回需要验证的信息
     * 覆盖来自AuthenticatesUsers中的credentials方法
     *
     * @param  \Illuminate\Http\Request $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only($this->username(), 'password') + ['is_active' => 1];
    }
}

#文件: app/Http/Controllers/API/Backend/Auth/RegisterController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\Admin;

class RegisterController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Register Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles the registration of new users as well as their
    | validation and creation. By default this controller uses a trait to
    | provide this functionality without requiring any additional code.
    |
    */

    use RegistersUsers;

    /**
     * Where to redirect users after registration.
     *
     * @var string
     */
    protected $redirectTo = '';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
    }

    /**
     * Get a validator for an incoming registration request.
     *
     * @param  array  $data
     * @return \Illuminate\Contracts\Validation\Validator
     */
    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:6|confirmed',
        ]);
    }

    /**
     * Create a new user instance after a valid registration.
     *
     * @param  array  $data
     * @return \App\Models\Admin
     */
    protected function create(array $data)
    {
        return Admin::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => bcrypt($data['password']),
        ]);
    }

    protected function guard()
    {
        return Auth::guard('backend');
    }

    /**
     * Handle a registration request for the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        event(new Registered($user = $this->create($request->all())));

        //不需要登录
        //$this->guard()->login($user);

        return response()->json([
            'data' => Admin::find($user->id)->makeToken(),
        ], 200);
    }
}

#文件: app/Http/Controllers/API/Backend/Auth/ForgotPasswordController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Support\Facades\Password;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class ForgotPasswordController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Password Reset Controller
    |--------------------------------------------------------------------------
    |
    | This controller is responsible for handling password reset emails and
    | includes a trait which assists in sending these notifications from
    | your application to your users. Feel free to explore this trait.
    |
    */

    use SendsPasswordResetEmails;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
    }

    /**
     * Get the response for a successful password reset link.
     *
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetLinkResponse($response)
    {
        return response()->json([
            'data' => trans($response),
        ], 200);
    }

    /**
     * Get the response for a failed password reset link.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetLinkFailedResponse(Request $request, $response)
    {
        return response()->json([
            'message' => '发生错误',
            'errors' => ['email' => trans($response)],
        ], 500);
    }

    /**
     * 验证邮件地址
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     */
    protected function validateEmail(Request $request)
    {
        $this->validate($request, ['email' => 'required|email']);
    }

    public function broker()
    {
        return Password::broker('admins');
    }
}


#文件: app/Http/Controllers/API/Backend/Auth/ResetPasswordController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Support\Str;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Auth\Events\PasswordReset;
use App\Http\Controllers\Controller;

class ResetPasswordController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Password Reset Controller
    |--------------------------------------------------------------------------
    |
    | This controller is responsible for handling password reset requests
    | and uses a simple trait to include this behavior. You're free to
    | explore this trait and override any methods you wish to tweak.
    |
    */

    use ResetsPasswords;

    /**
     * Where to redirect users after resetting their password.
     *
     * @var string
     */
    protected $redirectTo = '';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {

    }

    /**
     * 如果账户已经禁用,不能再重置密码
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only(
            'email', 'password', 'password_confirmation', 'token'
        ) + ['is_active' => 1];
    }

    /**
     * 重置密码,重置后不需要重新登录
     *
     * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
     * @param  string  $password
     * @return void
     */
    protected function resetPassword($user, $password)
    {
        $user->password = Hash::make($password);

        $user->setRememberToken(Str::random(60));

        $user->save();

        event(new PasswordReset($user));

        //$this->guard()->login($user);
    }

    /**
     * Get the response for a successful password reset.
     *
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetResponse($response)
    {
        return response()->json([
            'data' => trans($response),
        ], 200);
    }

    /**
     * Get the response for a failed password reset.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetFailedResponse(Request $request, $response)
    {
        return response()->json([
            'message' => '发生错误',
            'errors' => ['email' => trans($response)],
        ], 500);
    }

    public function broker()
    {
        return Password::broker('admins');
    }

    protected function guard()
    {
        return Auth::guard('backend');
    }
}

发生错误时,状态码返回500,成功操作时状态码返回200,验证不通过状态码返回422,登录错误超过阈值状态码返回423,没有权限状态码返回401。

另外,返回的格式:

// 状态码
[
    'message' => '', // 成功时可不返回
    'data' => [],    // 成功时返回的数据
    'errors' => []   // 不成功时,返回的错误详细
]

Laravel 事件详解

https://github.com/illuminate/events

一个Laravel应用就是一个大容器,容器首先需要构建:

// bootstrap/app.php
$app = new Illuminate\Foundation\Application(
    realpath(__DIR__.'/../')
);

// Illuminate\Foundation\Application构造函数
// 注意$this->registerBaseServiceProviders()
class Application extends Container implements ApplicationContract, HttpKernelInterface
{
    public function __construct($basePath = null)
    {
        if ($basePath) {
            $this->setBasePath($basePath);
        }

        $this->registerBaseBindings();

        $this->registerBaseServiceProviders();

        $this->registerCoreContainerAliases();
    }

    protected function registerBaseServiceProviders()
    {
        $this->register(new EventServiceProvider($this));

        $this->register(new LogServiceProvider($this));

        $this->register(new RoutingServiceProvider($this));
    }
}

容器的register方法不过是调用具体的ServiceProvider的register方法,然后在调用具体的ServiceProvider的boot方法(如果没有boot过)。可见之类的Event,Log,Routing是核心服务提供者。

Illuminate\Events\EventServiceProvider:

class EventServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton('events', function ($app) {
            return (new Dispatcher($app))->setQueueResolver(function () use ($app) {
                return $app->make(QueueFactoryContract::class);
            });
        });
    }
}

往容器中注入绑定,events对应Illuminate\Events\Dispatcher类实例。这里的Illuminate\Events\Dispatcher类仅仅几百行代码,就是事件机制实现的全部。

总体上,首先是定义事件(一个事件类,用于承载事件的数据,类名就是事件名称,另一类是没有对应类,仅一个字符串,表示事件名称),事件可以对应一个或多个监听器,监听器接收事件对象(事件载体就是事件类对象,如果非事件类,则是自定义数据),进行代码处理,然后是触发事件,那么对应的监听器就会被执行。

添加事件的监听器:

    public function listen($events, $listener)
    {
        foreach ((array) $events as $event) {
            if (Str::contains($event, '*')) {
                $this->setupWildcardListen($event, $listener);
            } else {
                $this->listeners[$event][] = $this->makeListener($listener);
            }
        }
    }
// 用法1
dispatch.listen("TestEvent", $listener)
dispatch.listen("TestEvent2", $listener)
$this->listeners["TestEvent"][] = $listener;
$this->listeners["TestEvent2"][] = $listener;
// 用法2
dispatch.listen("TestEvent*", $listener)
$this->wildcards["TestEvent*"][] = $listener;
// 用法3
dispatch.listen(["TestEvent", "TestEvent*"], $listener)
$this->listeners["TestEvent"][] = $listener;
$this->wildcards["TestEvent*"][] = $listener;

如果要设置一批事件对应同一个监听器,可以传递一个事件数组。另外,事件名称对应的监听器是一个数组,如果多次调用,就多次添加监听器,哪怕监听器是一样的。

分发器的hasListeners不过是判断事件名称是否在$this->listeners或$this->wildcards中

    public function hasListeners($eventName)
    {
        return isset($this->listeners[$eventName]) || isset($this->wildcards[$eventName]);
    }

添加了事件监听器,但是事件监听器实际被makeListener方法进行规范处理:

    public function makeListener($listener, $wildcard = false)
    {
        if (is_string($listener)) {
            return $this->createClassListener($listener, $wildcard);
        }

        // 监听器不是字符串,认为是闭包函数。
        // 注:如果针对的是通配符事件,自定义的监听器第一参数必须是事件,第二参数必须是事件载体
        return function ($event, $payload) use ($listener, $wildcard) {
            if ($wildcard) {
                return $listener($event, $payload);
            }

            return $listener(...array_values($payload));
        };
    }

    // 1 监听器是字符串的情况,也是返回闭包,在执行闭包时,也是根据事件类型($wildcard),调用方法有所差别
    public function createClassListener($listener, $wildcard = false)
    {
        return function ($event, $payload) use ($listener, $wildcard) {
            if ($wildcard) {
                return call_user_func($this->createClassCallable($listener), $event, $payload);
            }

            return call_user_func_array(
                $this->createClassCallable($listener), $payload
            );
        };
    }
    
    // 从字符串解析出类目和需要执行的方法
    // 通过类反射,判断类是否实现了Illuminate\Contracts\Queue\ShouldQueue接口(该接口未定义方法)
    // 如果实现了该接口,返回一个闭包函数,这个作为call_user_func或call_user_func_array的第一参数,事件错误时,监听器代码被推入队列
    // 根据类名生成对象,按照[类对象,类方法]返回,这个作为call_user_func或call_user_func_array的第一参数
    protected function createClassCallable($listener)
    {
        list($class, $method) = $this->parseClassCallable($listener);
    
        if ($this->handlerShouldBeQueued($class)) {
            return $this->createQueuedHandlerCallable($class, $method);
        }

        return [$this->container->make($class), $method];
    }

    // Test@fire => ["Test", "fire"],表示要调用fire方法
    // 如果把包含@,比如Test,那么返回["Test", "handle"],表示要调用handle方法 
    protected function parseClassCallable($listener)
    {
        return Str::parseCallback($listener, 'handle');
    }

如果传递进来的$listener是字符串,解析字符串构建监听器实例。否则就认为是一个函数(或是闭包函数)。注意这里返回的是一个统一格式的闭包,第一个是事件名称(或事件对象),第二个参数是事件载体(payload),如果事件是一个类名称,它对应的对象就是载体,如果已经是事件对象,那么这个事件对象即是事件,也是事件对应的载体(这个情况在触发事件时,直接触发事件对象即可)。

事件触发:

public function dispatch($event, $payload = [], $halt = false)
    {
        // 如果传递进来的$event是对象,那么$payload设置为等于该对象,然后把$event改为该对象的类名
        // 如果传递进来的$event不是对象,$payload就是对应的载体
        list($event, $payload) = $this->parseEventAndPayload(
            $event, $payload
        );
        // 通过载体,判断是否需要广播($payload是一个数组,第一个元素理论上对应事件对象,如果事件对象声明要广播)
        if ($this->shouldBroadcast($payload)) {
            $this->broadcastEvent($payload[0]);
        }

        $responses = [];
        // 根据事件取回监听器(按照预期格式生成,格式统一的闭包),循环执行监听器
        foreach ($this->getListeners($event) as $listener) {
            $response = $listener($event, $payload);

            // $halt用来判断一旦有输出,就返回
            if ($halt && ! is_null($response)) {
                return $response;
            }

            // 如果返回false,说明后续的监听器不在运行
            if ($response === false) {
                break;
            }

            $responses[] = $response;
        }
        return $halt ? null : $responses;
    }

事件触发都是直接或间接调用该方法。如果一个监听器是需要推入队列的,那么这个时候会生成一个job推入队列。如果一个事件声明要广播的,也会在这里处理。另外,框架中有一个全局方法对应这个方法,event(), 直接调用event(‘事件或事件对象’)。

事件触发时,需要取回监听器:

    public function getListeners($eventName)
    {
        $listeners = $this->listeners[$eventName] ?? [];

        $listeners = array_merge(
            $listeners, $this->getWildcardListeners($eventName)
        );

        return class_exists($eventName, false)
                    ? $this->addInterfaceListeners($eventName, $listeners)
                    : $listeners;
    }

这个就是数组合并。
注意:getWildcardListeners()这个方法,实际是根据给定的事件名称,检查是否和通配事件名称匹配,如果匹配,对应的监听器也返回。
注意:$this->addInterfaceListeners($eventName, $listeners),这个举例来说明,比如B实现了A接口,如果A接口指定了监听器,B也对应了监听器,取回B事件对应的监听器时,也包含A的监听器(这个就是基于接口的事件)。

以上是添加事件监听器,事件监听器的构建,触发事件,以及触发事件时如何取回监听器。总体上就是把找到事件对应的监听器,然后把事件对象传递给监听器,监听器根据事件对象取回数据进行逻辑处理。

用法:

// 1 触发事件对象时,可以直接传递事件对象(常用),事件对象会直接传入监听器方法
# 定义了事件对象
class TestEvent {}
$testEvent = new TestEvent();
# 用法
dispatch($testEvent); #实际会解析成dispatch("TestEvent", $testEvent)
dispatch("TestEvent", $testEvent);

// 2 触发的事件非事件对象(只是一个名字)$payload会传入自定义的方法中(监听器,可以是闭包函数),这个需要根据具体情况编写
dispatch("aaaaa", $payload)

还有特殊的搞法:

    public function push($event, $payload = [])
    {
        $this->listen($event.'_pushed', function () use ($event, $payload) {
            $this->dispatch($event, $payload);
        });
    }

    public function flush($event)
    {
        $this->dispatch($event.'_pushed');
    }

    public function forgetPushed()
    {
        foreach ($this->listeners as $key => $value) {
            if (Str::endsWith($key, '_pushed')) {
                $this->forget($key);
            }
        }
    }

先push,然后flush直接分发。

另外有一个订阅的概念:

    public function subscribe($subscriber)
    {
        $subscriber = $this->resolveSubscriber($subscriber);

        $subscriber->subscribe($this);
    }

取到订阅器对象,执行订阅器的subscribe()方法。实际不过是一个二次包装。但是需要有一个地方运行分发器的subscribe()方法。这个在框架中:

#文件: app/Providers/EventServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    /**
     * 应用的事件监听器映射.
     *
     * @var array
     */
    protected $listen = [
        //
    ];

    /**
     * 要注册的订阅者类.
     *
     * @var array
     */
    protected $subscribe = [
         'App\Listeners\UserEventSubscriber',
    ];
}

定义订阅器:

<?php

namespace App\Listeners;

class UserEventSubscriber
{
    public function onUserLogin($event) {}

    public function onUserLogout($event) {}

    /**
     * 为订阅者注册监听器.
     *
     * @param  Illuminate\Events\Dispatcher  $events
     */
    public function subscribe($events)
    {
        $events->listen(
            'Illuminate\Auth\Events\Login',
            'App\Listeners\UserEventSubscriber@onUserLogin'
        );

        $events->listen(
            'Illuminate\Auth\Events\Logout',
            'App\Listeners\UserEventSubscriber@onUserLogout'
        );
    }

}

这个不过是定义事件与监听器的另一种方式而已,完成可以把对应关系直接写入到$listen数组中。

其它用法(来自文档):

#文件: app/Providers/EventServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        'App\Events\OrderShipped' => [
            'App\Listeners\SendShipmentNotification',
        ],
    ];
}
// 生成对应的事件对象和监听器对象
php artisan event:generate

对应生成的事件类,可以编写其它逻辑,让其承载数据,而生成的监听器的handle方法必须接收这个事件对象。

// 定义事件
<?php

namespace App\Events;

use App\Order;
use Illuminate\Queue\SerializesModels;

class OrderShipped
{
    use SerializesModels;

    public $order;

    /**
     * 创建一个新的事件实例.
     *
     * @param  Order  $order
     * @return void
     */
    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

// 监听器
<?php

namespace App\Listeners;

use App\Events\OrderShipped;

class SendShipmentNotification
{
    /**
     * 创建事件监听器.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * 处理事件.
     *
     * @param  OrderShipped  $event
     * @return void
     */
    public function handle(OrderShipped $event)
    {
        // 使用 $event->order 发访问订单...
    }
}

除了直接声明对应关系,也可以直接编写:

public function boot()
{
    parent::boot();

    Event::listen('event.name', function ($foo, $bar) {
        //
    });
}

通配符事件监听器:

// 闭包特殊,第一参数必须是事件名称,第二参数是承载的数据(事件对象)
$events->listen('event.*', function ($eventName, array $data) {
    //
});

如果希望事件往下传播,监听器的handle方法返回false即可。

如果希望监听器异步运行,只要监听器类实现ShouldQueue接口(空接口,不需要实现任何方法)即可:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendShipmentNotification implements ShouldQueue
{
    //
}

如果希望定义监听器进入的队列已经队列名称:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendShipmentNotification implements ShouldQueue
{
    /**
     * 任务将被推送到的连接名称.
     *
     * @var string|null
     */
    public $connection = 'sqs';

    /**
     * 任务将被推送到的连接名称.
     *
     * @var string|null
     */
    public $queue = 'listeners';
}

如果监听器还要和队列进行交互,需要使用Illuminate\Queue\InteractsWithQueue:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendShipmentNotification implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderShipped $event)
    {
        if (true) {
            $this->release(30);
        }
    }

    // 处理失败job
    public function failed(OrderShipped $event, $exception)
    {
        //
    }
}

Laravel 邮件详解

Laravel邮件支持:illuminate/mail(https://github.com/illuminate/mail)
查看composer.json:

    "require": {
        "php": ">=7.0",
        "erusev/parsedown": "~1.7",
        "illuminate/container": "5.5.*",
        "illuminate/contracts": "5.5.*",
        "illuminate/support": "5.5.*",
        "psr/log": "~1.0",
        "swiftmailer/swiftmailer": "~6.0",
        "tijsverkoyen/css-to-inline-styles": "~2.2"
    },

基于swiftmailer/swiftmailer。illuminate/mail中的MailServiceProvider是defer的。框架在config/app.php中加载这个服务提供者(Illuminate\Mail\MailServiceProvider::class)。

邮件发送需要通过邮局(理论上,直接投递也可以),驱动就是这里的邮局,在内部就叫Transport。基于API驱动的Transport,与邮局的概念本质上是相同的,它只是通过API接受邮件,然后其内部可能再进行分发到其它服务器,然后再把邮件进行投递(普通的邮局也类似,比如SMTP服务器)。

API驱动的Transport:

# API驱动,需要安装guzzlehttp/guzzle
composer require guzzlehttp/guzzle

#Mailgun
#config/mail.php 中设置 driver 选项为 mailgun
#config/services.php 包含
'mailgun' => [
    'domain' => 'your-mailgun-domain',
    'secret' => 'your-mailgun-key',
],

#SparkPost
#config/mail.php 中设置 driver 选项为 SparkPost
#config/services.php 包含
'sparkpost' => [
    'secret' => 'your-sparkpost-key',
],


#SparkPost
#首先安装SDK
composer require aws/aws-sdk-php

#config/mail.php 中设置 driver 选项为 ses
#config/services.php 包含
'ses' => [
    'key' => 'your-ses-key',
    'secret' => 'your-ses-secret',
    'region' => 'ses-region',  // e.g. us-east-1
],

非API驱动:

# smtp 配置较复杂,可以通过.env配置传递参数到config/mail.php,如下一个阿里云邮箱配置
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mxhichina.com
# 安全连接对应465
MAIL_PORT=25 
MAIL_USERNAME=xxx@xx.com
MAIL_PASSWORD=xxxxxxxxx
# 安全连接端口对应465,加密对应ssl
MAIL_ENCRYPTION=null

# log 邮件发送到日志

邮件设置好,接下来就是如果编写一个邮件类,然后把这个邮件类发送出去,可见,这个邮件类主要负责怎么产生内容,发送器把这些内容投递到邮局。

生成编写邮件类:

php artisan make:mail MailTest

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;

class MailTest extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {
        return $this->from('example@example.com')
        ->view('emails.mail.text');
    }
}

在build()中完成邮件的构建。如果整个系统的邮件都是使用同一个地址发送的,可以config/mail.php中设置’from’ => [‘address’ => ‘example@example.com’, ‘name’ => ‘App Name’],这样如果没有指定发件人,就使用这个设置(默认)。使用view来取到邮件模板,用text来取到纯文本模板(全部看做是纯文本,哪怕其包含html内容)。

在邮件模板中,邮件类的公共属性,模板中可以直接使用。 也可以通过with来传递变量到模板。

    public function build()
    {
        return $this->view('emails.orders.shipped')
            ->with([
                'orderName' => $this->order->name,
                'orderPrice' => $this->order->price,
            ]);
    }

添加数据

# 附件
public function build()
{
    return $this->view('emails.orders.shipped')
        ->attach('/path/to/file');
}
# 附件第二参数
public function build()
{
    return $this->view('emails.orders.shipped')
        ->attach('/path/to/file', [
            'as' => 'name.pdf',
            'mime' => 'application/pdf',
        ]);
}
#原生数据附件 传递字节流
public function build()
{
    return $this->view('emails.orders.shipped')
        ->attachData($this->pdf, 'name.pdf', [
            'mime' => 'application/pdf',
        ]);
}
#内联附件
#嵌套内联图片到邮件中通常是很重的,
#Laravel 提供了便捷的方式附加图片到邮件并获取相应的 CID,
#要嵌入内联图片,在邮件视图中使用 $message 变量上的embed 方法即可
#Laravel 在所有邮件视图中注入 $message 变量并使其自动有效
<body>
    Here is an image:

    <img src="{{ $message->embed($pathToFile) }}">
</body>

#嵌入原生数据附件
<body>
    Here is an image from raw data:

    <img src="{{ $message->embedData($data, $name) }}">
</body>

生成 Markdown 邮件类 — Laravel 5.4+

// --markdown=emails.orders.shipped 对应视图自动生成
php artisan make:mail OrderShipped --markdown=emails.orders.shipped

配置可邮寄类的 build 方法时,使用 markdown 方法取代 view 方法。markdown 方法接收 Markdown 模板的名称和一个可选的在模板中生效的数组数据:

@component('mail::message')
# Order Shipped

Your order has been shipped!

@component('mail::button', ['url' => $url])
View Order
@endcomponent

Thanks,<br>
{{ config('app.name') }}
@endcomponent

发送邮件
事件

Laravel 异常详解

流程启动:

#public/index.php

$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

首先,在__DIR__.’/../bootstrap/app.php’中,向容器注册了错误处理类:

$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Exceptions\Handler::class
);

然后,$kernel->handle()中,会调用kernel的bootstrap()方法,最终会运行\Illuminate\Foundation\Bootstrap\HandleExceptions::class类的bootstrap()方法:

class HandleExceptions
{
    public function bootstrap(Application $app)
    {
        $this->app = $app;

        error_reporting(-1);

        set_error_handler([$this, 'handleError']);

        set_exception_handler([$this, 'handleException']);

        register_shutdown_function([$this, 'handleShutdown']);

        if (! $app->environment('testing')) {
            ini_set('display_errors', 'Off');
        }
    }
    public function handleError($level, $message, $file = '', $line = 0, $context = [])
    {
        if (error_reporting() & $level) {
            throw new ErrorException($message, 0, $level, $file, $line);
        }
    }
    public function handleException($e)
    {
        if (! $e instanceof Exception) {
            $e = new FatalThrowableError($e);
        }

        try {
            $this->getExceptionHandler()->report($e);
        } catch (Exception $e) {
            //
        }

        if ($this->app->runningInConsole()) {
            $this->renderForConsole($e);
        } else {
            $this->renderHttpResponse($e);
        }
    }
}

这里挟持了PHP的错误和异常处理。分别对应handleError和handleException。一旦发生错误或异常,相应进入设置的回调。注意到handleError方法,如果$level是需要报告的,那么就抛出一个ErrorException异常,这里是把错误转换成了异常。

继续handleException()方法,首先如果接收的对象不是一个异常对象(Exception子类),就用FatalThrowableError类包装一下(变成异常对象)。然后调用异常处理类的report方法,接着判断是否运行在Console下,对应运行render方法。这里的$this->getExceptionHandler()方法从容器中取回绑定的异常处理对象,它就是前面绑定的App\Exceptions\Handler类对象,所以这个类应该实现了report和render方法。

最终,异常(包括错误),最终定位到App\Exceptions\Handler中的report和render方法(这两个方法都会运行):

<?php
// App\Exceptions\Handler

namespace App\Exceptions;

use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

class Handler extends ExceptionHandler
{
    protected $dontReport = [
        //
    ];

    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    public function report(Exception $exception)
    {
        parent::report($exception);
    }

    public function render($request, Exception $exception)
    {
        return parent::render($request, $exception);
    }
}

App\Exceptions\Handler中的report和render都是直接调用父类方法(这里可以注入自定义逻辑)。

App\Exceptions\Handler继承自Illuminate\Foundation\Exceptions\Handler,Illuminate\Foundation\Exceptions\Handler实现了Illuminate\Contracts\Debug\ExceptionHandler接口。

// Illuminate\Contracts\Debug\ExceptionHandler 接口
namespace Illuminate\Contracts\Debug;

use Exception;

interface ExceptionHandler
{
    public function report(Exception $e);

    public function render($request, Exception $e);

    public function renderForConsole($output, Exception $e);
}

注,report方法就是把异常记录到外部,这个过程是由日志处理器来完成的,在Laravel中是通过Monolog来处理的(可以设置多个处理器,以让日志发送到多个地方)。

以下就是Illuminate\Foundation\Exceptions\Handler中的report实现:

//Illuminate\Foundation\Exceptions\Handler
    public function report(Exception $e)
    {
        if ($this->shouldntReport($e)) {
            return;
        }

        if (method_exists($e, 'report')) {
            return $e->report();
        }

        try {
            $logger = $this->container->make(LoggerInterface::class);
        } catch (Exception $ex) {
            throw $e; // throw the original exception
        }

        $logger->error(
            $e->getMessage(),
            array_merge($this->context(), ['exception' => $e]
        ));
    }

首先$this->shouldntReport()实际就是判断异常是否在$this->dontReport和$this->internalDontReport数组中,在就是不需要报告。

protected $internalDontReport = [
        AuthenticationException::class,
        AuthorizationException::class,
        HttpException::class,
        HttpResponseException::class,
        ModelNotFoundException::class,
        TokenMismatchException::class,
        ValidationException::class,
    ];

而$this->dontReport是空的,可以在子类App\Exceptions\Handler的dontReport中添加不需要报告的异常类型。

接下来,如果一个异常包含了report方法,那么直接调用它的report方法。然后从容器中取回日志处理器(框架初始化时已经注册),然后调用日志处理器的error记录日志。 这里需要注意,report方法还是可能继续抛出异常。

对于render方法就比较复杂一点,因为需要渲染输出:

//Illuminate\Foundation\Exceptions\Handler
    public function render($request, Exception $e)
    {
        if (method_exists($e, 'render') && $response = $e->render($request)) {
            return Router::toResponse($request, $response);
        } elseif ($e instanceof Responsable) {
            return $e->toResponse($request);
        }

        $e = $this->prepareException($e);

        if ($e instanceof HttpResponseException) {
            return $e->getResponse();
        } elseif ($e instanceof AuthenticationException) {
            return $this->unauthenticated($request, $e);
        } elseif ($e instanceof ValidationException) {
            return $this->convertValidationExceptionToResponse($e, $request);
        }

        return $request->expectsJson()
                        ? $this->prepareJsonResponse($request, $e)
                        : $this->prepareResponse($request, $e);
    }

看看AuthenticationException异常的处理(认证异常):

    protected function unauthenticated($request, AuthenticationException $exception)
    {
        return $request->expectsJson()
                    ? response()->json(['message' => $exception->getMessage()], 401)
                    : redirect()->guest(route('login'));
    }

认证不通过,可能返回route(‘login’)路由。这里有点硬编码了。如果有两个后台,那么这里只要认证不通过就永远跳转到route(‘login’)。所以需要根据guard来判断跳转到哪个路由:

//App\Exceptions\Handler
    public function render($request, Exception $exception)
    {
        if ($exception instanceof \Illuminate\Auth\AuthenticationException) {
            $guards = $exception->guards();
            if (is_array($guards) && in_array('admin', $guards)) {
                return $request->expectsJson()
                    ? response()->json(['message' => $exception->getMessage()], 401)
                    : redirect()->guest(route('backend.login'));
            }
        }
        return parent::render($request, $exception);
    }

当使用默认auth中间件时,如果抛异常,$exception->guards()是空的(使用了默认的guard);当指定了guard后(比如auth:admin),这时$exception->guards()就包含admin,通过这个信息可以控制怎么渲染输出(特别是控制怎么跳转)。

Laravel 实践 – 多后台登录

一个简单站点,前台需要提供用户登录,后台需要实现管理员登录。这里就涉及两套登录系统。

前台的用户登录直接使用默认的。按照文档设置:

php artisan make:auth
php artisan migrate

以上命令最终添加路由和产生视图文件和产生一张用户表。
0 首先拷贝users为admins,password_resets为admin_password_resets; 拷贝app/User.php为app/Admin.php。

1 打开routes/web.php,添加路由

// 后台登录注册流程
Route::get('backend', 'Backend\Dashboard@index');
// 后台登录-Dashboard
Route::get('backend/dashboard', 'Backend\Dashboard@index')->name('backend.dashboard');
// 后台登录-认证
$this->get('backend/login', 'Backend\Auth\LoginController@showLoginForm')->name('backend.login');
$this->post('backend/login', 'Backend\Auth\LoginController@login');
$this->any('backend/logout', 'Backend\Auth\LoginController@logout')->name('backend.logout');
// 后台登录-注册
$this->get('backend/register', 'Backend\Auth\RegisterController@showRegistrationForm')->name('backend.register');
$this->post('backend/register', 'Backend\Auth\RegisterController@register');
// 后台登录-找回密码
$this->get('backend/password/reset', 'Backend\Auth\ForgotPasswordController@showLinkRequestForm')->name('backend.password.request');
$this->post('backend/password/email', 'Backend\Auth\ForgotPasswordController@sendResetLinkEmail')->name('backend.password.email');
$this->get('backend/password/reset/{token}', 'Backend\Auth\ResetPasswordController@showResetForm')->name('backend.password.reset');
$this->post('backend/password/reset', 'Backend\Auth\ResetPasswordController@reset');

2 进入app/Http/Controllers,建立目录Backend,把app/Http/Controllers下的Auth和home.php拷贝到新建的目录中(backend目录)。

#文件: app/Http/Controllers/Backend/Home.php
<?php
// 命名空间改为Backend
namespace App\Http\Controllers\Backend;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class Dashboard extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        // 中间件改为auth:admin
        // 原来为auth
        $this->middleware('auth:admin');
    }

    /**
     * Show the application dashboard.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        // 视图文件改为backend.home
        // 原来为home
        return view('backend.home');
    }
}

#文件: app/Http/Controllers/Backend/Auth/LoginController.php
<?php
// 命名空间改为Backend\Auth
namespace App\Http\Controllers\Backend\Auth;

use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;

class LoginController extends Controller
{
    use AuthenticatesUsers;

    // 登录后重定向改为/backend/home
    // 原来为home
    protected $redirectTo = '/backend/home';

    public function __construct()
    {
        // 中间件改为guest:admin
        // 原来为guest
        // 注:except不需要修改,except表示此中间件忽略logout方法
        $this->middleware('guest:admin')->except('logout');
    }

    // 新增方法,对应修改视图
    public function showLoginForm()
    {
        return view('backend.auth.login');
    }

    // 新增方法,指定使用admin这个拦截器
    protected function guard()
    {
        return Auth::guard('admin');
    }

    // 新增方法,管理员登出
    public function logout(Request $request)
    {
        $this->guard('admin')->logout();

        $request->session()->invalidate();

        return redirect('/backend/home');
    }
}

#文件: app/Http/Controllers/Backend/Auth/ForgotPasswordController.php
<?php
// 修改命名空间为Backend\Auth
namespace App\Http\Controllers\Backend\Auth;

use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class ForgotPasswordController extends Controller
{
    use SendsPasswordResetEmails;

    // 修改中间件(添加admin)
    public function __construct()
    {
        $this->middleware('guest:admin');
    }

    // 添加方法,修正视图
    public function showLinkRequestForm()
    {
        return view('backend.auth.passwords.email');
    }
}

#文件: app/Http/Controllers/Backend/Auth/RegisterController.php
<?php
// 修改命名空间为Backend\Auth
namespace App\Http\Controllers\Backend\Auth;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Foundation\Auth\RegistersUsers;
use App\Http\Controllers\Controller;
use App\Models\Admin;

class RegisterController extends Controller
{

    use RegistersUsers;

    // 修正为/backend/home,原来为home
    protected $redirectTo = '/backend/home';

    // 修正中间件为guest:admin
    public function __construct()
    {
        $this->middleware('guest:admin');
    }

    // 验证时针对的表改为unique:admins(原来为unique:users)
    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:admins',
            'password' => 'required|string|min:6|confirmed',
        ]);
    }

    /**
     * Create a new user instance after a valid registration.
     *
     * @param  array  $data
     * @return \App\Models\Admin   改为\App\Models\Admin
     */
    protected function create(array $data)
    {
        // 模型修改为Admin
        return Admin::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => bcrypt($data['password']),
        ]);
    }
    // 新增,注册成功后登陆
    protected function guard()
    {
        return Auth::guard('admin');
    }

    // 新增,修正视图
    public function showRegistrationForm()
    {
        return view('backend.auth.register');
    }
}

#文件: app/Http/Controllers/Backend/Auth/ResetPasswordController.php
<?php
// 修正命名空间为:Backend\Auth
namespace App\Http\Controllers\Backend\Auth;

// 新加Auth和Pawword
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class ResetPasswordController extends Controller
{
    use ResetsPasswords;

    // 修正为/backend/home,原来为home
    protected $redirectTo = '/backend/home';

    // 修正中间件为guest:admin
    public function __construct()
    {
        $this->middleware('guest:admin');
    }

    // 新增,指定broker为admins(config/auth.php中设置)
    public function broker()
    {
        return Password::broker('admins');
    }

    // 新增,重设密码后需要自动登录,设置使用正确的Guard
    protected function guard()
    {
        return Auth::guard('admin');
    }

    // 新增,修正视图
    public function showResetForm(Request $request, $token = null)
    {
        return view('backend.auth.passwords.reset')->with(
            ['token' => $token, 'email' => $request->email]
        );
    }
}

3 布局视图等
进入resources/views/layouts,拷贝app.blade.php为app_backend.blade.php。
进入resources/views,建立目录backend,把resources/views下的auth和home.php拷贝到新建的目录中(backend目录)。

进入到backend目录,对各个文件修改引用布局文件由app改为app_backend(@extends(‘layouts.app_backend’))。

然后对各个视图文件(包括resources/views/layouts/app_backend.blade.php)做修改(没有忽略):

@guest 改为 @guest('admin')
Auth::user() 改为 Auth::guard('admin')->user()
route('login') 改为 route('backend.login')
route('password.request') 改为 route('backend.password.request')
route('password.email') 改为 route('backend.password.email')

4 修改配置

#文件:config/auth.php
return [
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
	// 添加
        'admin' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],
        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
    ],
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\User::class,
        ],
        // 添加
        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Admin::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
        ],
        // 新增,provider需要准确对应providers数组
        'admins' => [
            'provider' => 'admins',
            'table' => 'admin_password_resets',
            'expire' => 60,
        ],
    ],
];

5 调整中间件

#文件 app/Http/Middleware/RedirectIfAuthenticated
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Auth;

class RedirectIfAuthenticated
{
    public function handle($request, Closure $next, $guard = null)
    {
        // 检查是否处于登录状态
        if (Auth::guard($guard)->check()) {
            if ($guard === 'admin') {
                return redirect('/backend/home');
            } else {
                return redirect('/home');
            }
        }

        return $next($request);
    }
}

注意,RedirectIfAuthenticated中间件别名是guest,实际也是调用对应Guard来判断是否是Guest。 这个Guest中间件只针对认证路由,用来判断当访问认证路由时如果已经登录那么将跳转到什么地方(这个跟auth中间件完全不同,auth中间件用于需要认证的路由)。

#涉及路由
backend/login
backend/register
backend/password/reset
backend/password/email
backend/password/reset/{token}

注意,backend/logout被排除了,logout不需要检查是否处于登录状态。

6 异常渲染调整
访问一个需要认证的页面(比如backend/home),这个时候如果检查到没有登录(认证不成功,auth中间件),那么这个时候就需要重定向到首页(如果是JSON,则返回JSON),默认直接跳转到/home没有问题,但是由于现在多加了一个登录,那么就必须针对这个做处理,否则总是跳转到/home。认证不通过抛出\Illuminate\Auth\AuthenticationException异常,这个异常中包括是由什么Guard认证不通过而抛的异常,异常处理自定义可以通过app/Exception/Handler.php来处理:

public function render($request, Exception $exception)
    {
        if ($exception instanceof \Illuminate\Auth\AuthenticationException) {
            $guards = $exception->guards();
            if (is_array($guards) && in_array('admin', $guards)) {
                return $request->expectsJson()
                    ? response()->json(['message' => $exception->getMessage()], 401)
                    : redirect()->guest(route('backend.login'));
            }
        }
        return parent::render($request, $exception);
    }

这里判断如果是admin这个Guard认证不通过抛的\Illuminate\Auth\AuthenticationException异常,则让其跳转到backend.login。

7 其它
对于使用了admin这个Guard保护的路由,需要使用auth:admin(默认是auth)。

如果登录了,session(‘status’)是ture,前后台登录都是如此。所以前后台登录由于使用不同的Guard,所以不存在问题。登出就有问题,如果前后台都已登录,其一登出,那么全部登出(由于销户Session的原因),不过这个没有什么关系了。

Laravel 管道详解

函数http://php.net/array_reduce用法(http://php.net/array_reduce
):

// array_reduce() 将回调函数 callback 迭代地作用到 array 数组中的每一个单元中,从而将数组简化为单一的值。
mixed array_reduce ( array $array , callable $callback [, mixed $initial = NULL ] )

第二参数的$callback调用格式:

//$carry携带上次迭代里的值; 如果本次迭代是第一次,那么这个值是 initial
//$item携带了本次迭代的值
mixed callback ( mixed $carry , mixed $item )

1 如果$array为空,直接返回$initial
2 如果$array不为空,那么$initial作为第一次迭代时的$carry
3 每次迭代调用的$callback的结果作为下次迭代调用$callback的$carry

这里主要是第三点,经过特殊处理,可以产生多层嵌套(C包装了B,B包装了A)。

列子:

$arr = [3, 2, 1];
$arr = [];
$fn = function ($carry, $hold) {
    return function() use ($carry, $hold) {
        echo "$hold -- Start\n";
        $carry();
        echo "$hold -- End \n";
    };
};
$init = function () {
    echo "init\n";
};
$callback = array_reduce($arr, $fn, $init);
$callback();

输出:

// $arr等于空时
init

// $arr = [3,2,1]时
1 -- Start
2 -- Start
3 -- Start
init
3 -- End 
2 -- End 
1 -- End

各种情况分析:
1 当$arr为空时,直接返回$init,这里返回一个闭包函数,执行后直接输出”init”字符串
2 当$arr等于[3]时,第一个次迭代,相当于如下代码:

// 相当于
$fn($init, 3);
// 相当于
$fn(function () {
    echo "init\n";
}, 3);
// 相当于
function (function () {
    echo "init\n";
}, 3);
// 相当于
function() use(function () {
    echo "init\n";
}, 3) {
    echo "$hold -- Start\n";
    (function () {
        echo "init\n";
    })();
    echo "$hold -- End \n";
}
// 最后运行这个闭包,输出
3 -- Start
init
3 -- End

3 对于$arr大于1的情况也是类似,最终会得到类似这样的结构

(function() use($carray, 1) {
    echo "1 -- Start\n";
    (function () use ($array, 2) {
        echo "2 -- Start\n";
        (function () use($carray, 1) {
            echo "3 -- Start\n";
            (function () {
                echo "init\n";
            })();
            echo "3 -- End\n";
        })();
        echo "2 -- End\n";
    })();
    echo "1 -- End\n";
})();

使用闭包,把每次迭代过程中的变量锁定,执行时,从外层逐层剥离。这个就是管道的实现方式。

在Laravel中,包Illuminate\Pipeline的管道实现原理与以上的描述类似。而管道的应用,最经典的就是中间件的设计。请求在分发到具体的路由前(或之后),可以设置其经过一些列的中间,要经过的中间件,就是一个个管道,在任何时刻,都可以非常无痛的插入和删除某个中间件,以下是模拟过程:

class VerifyCsrfToken
{
    public static function handle(Closure $next)
    {
        echo "VerifyCsrfToken\n";
        $next();
    }
}

class ShareErrorsFromSession
{
    public static function handle(Closure $next)
    {
        echo "ShareErrorsFromSession\n";
        $next();
    }
}

class StartSession
{
    public static function handle(Closure $next)
    {
        echo "StartSession -- Start\n";
        $next();
        echo "StartSession -- End\n";
    }
}

class AddQueuedCookiesToResponse
{
    public static function handle(Closure $next)
    {
        $next();
        echo "AddQueuedCookiesToResponse\n";
    }
}

class EncryptCookies
{
    public static function handle(Closure $next)
    {
        echo "EncryptCookies -- Start\n";
        $next();
        echo "EncryptCookies -- End\n";
    }
}

class CheckForMaintenanceMode
{
    public static function handle(Closure $next)
    {
        echo "CheckForMaintenanceMode\n";
        $next();
    }
}

class pipeline
{
    protected $hold = null;

    protected $pipes = [];

    public function send($hold)
    {
        $this->hold = $hold;
        return $this;
    }

    public function though(array $pipes)
    {
        $this->pipes = $pipes;
        return $this;
    }

    public function then(Closure $to)
    {
        $fn = function ($stack, $pipe) {
            return function() use ($stack, $pipe) {
                return $pipe::handle($stack);
            };
        };
        $callBack = array_reduce($this->pipes, $fn, $to);

        return $callBack();
    }
}

//////////////////////////////////////
$hold = [];
$pipes = array_reverse([
//    "CheckForMaintenanceMode",
//    "EncryptCookies",
//    "AddQueuedCookiesToResponse",
//    "StartSession",
//    "ShareErrorsFromSession",
//    "VerifyCsrfToken"
]);
$result = (new Pipeline())->though($pipes)->then(function () {
    echo "--------Request to Route------------\n";
});

这样,我们自定义的中间件(数组顺序),如果希望在路由前运行,就在$next()前执行,如果希望路由后执行,就在$next()后运行。当然,在执行某个中间件时(管道),也可以直接return,这样后面的代码就不会执行。

以上的演示代码已经把管道的实现基本说清楚,以下是Illuminate\Pipeline的用法:

<?php

require __DIR__.'/../vendor/autoload.php';

class MyPipe
{
    public function handle($request, $next, $param1 = 1, $param2 = 2)
    {
        echo "MyPipe Handle -- request is $request, param1 is $param1, param2 is $param2 \n";
        $request++;
        $next($request);
    }

    public function fire($request, $next, $param1 = 1, $param2 = 2)
    {
        echo "MyPipe Fire -- request is $request, param1 is $param1, param2 is $param2 \n";
        $request++;
        $next($request);
    }
}
$container = new Illuminate\Container\Container();

$request = 10;
// 三种使用方式
$pipes = [
    // 直接传递一个闭包
    function($request, $next) {
        $request++;
        $next($request);
    },
    // 直接生成一个包含"handle方法的对象",无法传递额外参数
    new MyPipe(),
    // 管道对象生成时需要把容器对象传入,因为需要依靠容器根据字符串类make对象
    // 直接指定一个包含"handle"方法的类名称
    // handle方法如果有超过2个参数,超过的参数可以在冒号后给出,并以逗号分隔多个参数
    "MyPipe:11,22"
];

// 更换默认调用的方法
$pipeline = new Illuminate\Pipeline\Pipeline($container);
$pipeline->via("fire")->send($request)->through($pipes)->then(function ($request) {
    echo "request is $request\n";
    echo "Finish ---\n";
});

// 使用默认的handle方法
$pipeline = new Illuminate\Pipeline\Pipeline($container);
$pipeline->send($request)->through($pipes)->then(function ($request) {
    echo "request is $request\n";
    echo "Finish ---\n";
});

// 输出
MyPipe Fire -- request is 11, param1 is 1, param2 is 2 
MyPipe Fire -- request is 12, param1 is 11, param2 is 22 
request is 13
Finish ---

MyPipe Handle -- request is 11, param1 is 1, param2 is 2 
MyPipe Handle -- request is 12, param1 is 11, param2 is 22 
request is 13
Finish ---

闭包函数应用:

$destination = function () {};

$fn = function ($passable) use ($destination) {
    return $destination($passable);
};
$passable = 1;
// $destination() 和$destination($passable)等同
// $destination($passable) 和 $fn($passable)等同

当要把一个闭包以一个统一的格式执行时(总是带一个或多个参数),通常需要做这样的转换。$destination()这个闭包函数定义时不需要传递参数,但是在调用时传递传输参数过去也不会报错。

demo($destination) 
{
    return function ($passable) use ($destination) {
        return $destination($passable);
    }
}
//
demo($destination) === function ($passable) use ($destination) {
    return $destination($passable);
};
// 调用时就必须是fn(param)格式,外部参数注入到了闭包,闭包中就可以使用到外部传递进来的参数。

传递一个闭包函数给一个函数,这个函数返回了一个包装了这个闭包函数的闭包函数,这样外部的闭包函数执行,就可以把外部的变量传递给内部的闭包函数。

其它管道实现参考:
https://github.com/thephpleague/pipeline

Laravel 认证详解


管理器
管理多个拦截器,通过给定拦截器的名称,可以返回或生产拦截器并返回,那么管理器必定是一个工厂(生产拦截器)。管理器管理多个拦截器,那么必定就有一个默认拦截器的概念。

拦截器
拦截器要认证用户(用户登录),首先客户端需要提供凭证(比如客户端提供的token或对应的用户session中的用户id),如果没有凭证,就直接跳转到登录页(或返回认证失败信息),然后是拿到凭证后(如果是非一次性认证,这个时候还会到持久介质中检查是否已经登录,登录就直接通过 – 持久介质一般就是指session了),还需要知道到什么地方进行比对,这时需要有一个数据源(比如用户表)。这个数据源就是提供者,它向拦截器按照一致的方法提供用户实例。拦截器对于已经通过认证的用户,可能还需要保存认证凭证(下次请求直接通过),有保存认证凭证,当然还要包括注销凭证(用户登出)。

提供者
拦截器面对提供者,通过向提供者获取用户实例,那么提供者必须按照一致的方式编写(这样不同的拦截器就可以调用不同的提供者)。提供者是数据源的抽象,对外提供统一接口,比如根据某个一致的方法返回用户实例。

用户实例
提供者提供用户实例,拦截器使用这个用户实例来比对,很明显,如果用户实例没有一定的规范,也是不行的,比如要比对什么字段不能定死,但是可以定死方法,这个就是接口规范。

总结:
管理器是一个工厂,可以管理和生成拦截器。拦截器需要一个提供者,依赖提供者提供的用户来认证用户。提供者被拦截器调用,它需要提供一致的接口,以使得它可以插入到不同的拦截器中。提供者提供的用户实例也是面向拦截器的,为了能适应不同的拦截器,用户实例也需要按照一致的接口来实现。

由于有一致的接口,所以一个拦截器,可以更换提供者(比如使用不同的数据)。一个提供者,可以插入到不同的拦截器(比如拦截器都使用同一个提供者)。

可扩展:
对于一些常用的拦截器,比如token和session,可以预先实现,同理,对于提供者,也可以预先实现。如果需要插入自定义的拦截器或提供者,只需要按照预定实现相关方法就可以了。

以上就是用户认证的理论知识。以下就是针对这些理论知识进行展开。

管理器 – Illuminate\Auth\AuthManager:

这个类提供了怎么创建默认的Guard的方法(createSessionDriver、createTokenDriver),和怎么创建Provider的方法(createDatabaseProvider),还提供了如何插入自定义的Guard的方法(extend()方法)和插入自定义Provider的方法(provider()方法)。Guard实例的生成,对应的Provider实例必须先生成。

AuthManager中管理不同的Guard,Guard实例的生成需要知道Driver(就是怎么生成Guard实例,比如session,api),还需要知道Provider(数据源),Provider内置支持Database和Eloquent这两种类型,根据类型产生数据源对象(需要实现对应接口)。另外,还有一个默认Guard的概念。有了Guard实例后,需要从Guard实例中取回用户实例,AuthManager中记录默认Guard的用户实例(userResolver)

用户登录认证,首先需要拦截所有的请求,判断是否登录,如果没有登录,跳转到登录页,登录成功后,记录登录信息到会话(下次就知道已登录);如果登录了,跳转到首页, 还可以取到用户实例。

这里的拦截器就是Guard, 登录成功后保存信息,取回用户实例,是Guard需要实现的,另外,如何认证用户需要依赖传递给Guard的Provider,Guard收集认证信息,到Provider中查找比对做出判断。

提供者 – 用户提供者
考虑这样的应用场景:有多个系统公用一套管理员信息,即管理员A用同一套信息可以登录系统1,也可以登录系统2。

这里的管理员需要单独出来,并对外提供API,系统1和系统2以统一的方式调用这些API进行用户认证。

首先需要定义一个UserProriver,需要实现Illuminate\Contracts\Auth\UserProvider接口:

// 通过唯一标示符获取认证模型
public function retrieveById($identifier);
// 通过唯一标示符remember token获取模型
public function retrieveByToken($identifier, $token);
// 通过给定的认证模型更新remember token
public function updateRememberToken(Authenticatable $user, $token);
// 通过给定的凭证获取用户,比如 email 或用户名等等
public function retrieveByCredentials(array $credentials);
// 认证给定的用户和给定的凭证是否符合
public function validateCredentials(Authenticatable $user, array $credentials);

自定义的UserProriver需要实现这些方法(在其中调用外部API),从这个Provider返回的用户实例必须实现了Illuminate\Contracts\Auth\Authenticatable接口的实例(可以认证的):

<?php

namespace Illuminate\Contracts\Auth;

interface Authenticatable
{
    public function getAuthIdentifierName();
    public function getAuthIdentifier();
    public function getAuthPassword();
    public function getRememberToken();
    public function setRememberToken($value);
    public function getRememberTokenName();
}

注:App\User实现了该接口(可认证),实际是使用了Illuminate\Auth\Authenticatable特性。

Illuminate\Auth\GenericUser也实现了这个接口,可以直接套用(DatabaseUserProvider就用它来包装用户实例),否则需要自己实现(比如字段名称不一样),具体实现可以参考Illuminate\Auth\DatabaseUserProvider。

图中MyUser是一个自定义实现,除非需要自定义各种字段,否则没有必要,直接使用GenericUser来对应用户实例即可。

接下来是调用AuthManager的provider()方法来定义新的Provider(数据源):

// App\Providers\AuthServiceProvider
public function boot()
    {
        $this->registerPolicies();

        \Auth::provider('my-user', function() {
            return new MyUserProvider();    // 返回自定义的 user provider
        });
    }

然后修改配置,让Guard使用my-user作为Provider:

// app/config/auth.php
'guards' => [
        'web2' => [
            'driver' => 'session',
            'provider' => 'my-user'  //名字可以随意,只要在providers中找到
        ],
],
'providers' => [
        'my-user' => [
            'driver' => 'my-user'  //名字必须和在\Auth::provider()定义时的一样
        ]
],

这里的’providers’中的’my-user’是否需要其它的参数(driver参数必须,并且和定义时的值一样),是由自定义的Provider决定的,可以根据情况添加。

设置 config\auth.php 的 defaults.guard 为web2,就可以使用自定义的Provider进行认证了。

拦截器 – Guard
考虑这样的应用场景:如果需要修改登录认证逻辑,比如对认证做一些额外的处理,默认提供的Guard不能满足的情况。

Illuminate\Contracts\Auth\StatefulGuard接口在Illuminate\Contracts\Auth\Guard
上提供了额外方法,比如:login() attempt() logout()等(Session认证必须)。

每个Guard实际都注入了Illuminate\Auth\GuardHelpers, 这是一些辅助方法,比如:check() 、guest()通用的方式实现 。Guard中进行拦截是实际是跟对应的UserProvider进行交互,取回用户实例(必须实现Illuminate\Contracts\Auth\Authenticatable接口),

Illuminate\Contracts\Auth\Guard接口:

// 判断当前用户是否登录
public function check();
// 判断当前用户是否是游客(未登录)
public function guest();
// 获取当前认证的用户
public function user();
// 获取当前认证用户的 id,严格来说不一定是 id,应该是上个模型中定义的唯一的字段名
public function id();
// 根据提供的消息认证用户
public function validate(array $credentials = []);
// 设置当前用户
public function setUser(Authenticatable $user);

Illuminate\Contracts\Auth\StatefulGuard继承Guard接口,还提供了:

// 尝试根据提供的凭证验证用户是否合法
public function attempt(array $credentials = [], $remember = false);
// 一次性登录,不记录session or cookie
public function once(array $credentials = []);
// 登录用户,通常在验证成功后记录 session 和 cookie 
public function login(Authenticatable $user, $remember = false);
// 使用用户 id 登录
public function loginUsingId($id, $remember = false);
// 使用用户 ID 登录,但是不记录 session 和 cookie
public function onceUsingId($id);
// 通过 cookie 中的 remember token 自动登录
public function viaRemember();
// 登出
public function logout();

预定义的拦截器:
1 RequestGuard
Illuminate\Auth\RequestGuard
RequestGuard是一个非常简单的Guard。RequestGuard 是通过传入一个闭包来认证的。可以通过调用 Auth::viaRequest 添加一个自定义的 RequestGuard.(每个请求,都执行这个闭包,这个闭包负责怎么认证用户,当然,数据来源,怎么比对数据,都需要在这个闭包中完成)

2 SessionGuard
Illuminate\Auth\SessionGuard
SessionGuard 是 Laravel web 认证默认的 guard(用户认证后,记录到session,第二次请求就不会再次认证).

3 TokenGuard
Illuminate\Auth\TokenGuard
TokenGuard 适用于无状态 api 认证,通过 token 认证(请求中携带token,用token认证用户,每次请求都会进行认证).

要实现自定义的Guard,可以参考以上的实现,然后调用AuthManager的extend()方法来定义新的Guard(Driver):

// App\Providers\AuthServiceProvider
public function boot()
    {
        $this->registerPolicies();

        \Auth::extend('web2', function(){
              return new MyGuard();
        });
    }

Guard需要使用到Provider(构造函数中注入),完成验证,检查等操作。

另外
Laravel支持在认证过程中触发多种事件,可以在的EventServiceProvider中监听这些事件:

protected $listen = [
    'Illuminate\Auth\Events\Registered' => [
        'App\Listeners\LogRegisteredUser',
    ],


    'Illuminate\Auth\Events\Attempting' => [
        'App\Listeners\LogAuthenticationAttempt',
    ],

    'Illuminate\Auth\Events\Authenticated' => [
        'App\Listeners\LogAuthenticated',
    ],

    'Illuminate\Auth\Events\Login' => [
        'App\Listeners\LogSuccessfulLogin',
    ],

    'Illuminate\Auth\Events\Failed' => [
    'App\Listeners\LogFailedLogin',
],

    'Illuminate\Auth\Events\Logout' => [
        'App\Listeners\LogSuccessfulLogout',
    ],

    'Illuminate\Auth\Events\Lockout' => [
        'App\Listeners\LogLockout',
    ],

    'Illuminate\Auth\Events\PasswordReset' => [
        'App\Listeners\LogPasswordReset',
    ],
];

利用SSH的用户配置文件管理SSH会话

一般,在Shell中可以如下远程登录一台机器:

ssh user@hostname -p port

然后输入密码(如果有放置公钥则直接过)。

但是如果有很多机器要操作,就可以利用SSH中的用户配置文件来管理会话(man ssh_config)。

SSH的用户配置文件是放在当前用户根目录下的.ssh文件夹里(~/.ssh/config,不存在可新建),其配置写法如下:

Host			别名
	HostName	主机名
	Port		端口
	User		用户名
	IdentityFile	秘钥文件的路径(如果私钥不是放置默认位置,这里可以指定)

配置好后就可以使用别名登录:

ssh 别名
scp 别名:~/test .

如果有多个机器,对应在配置中写入。

可见,SSH的过程,总是首先读取config文件,然后解析,然后看看是否匹配别名,如果匹配,就使用匹配的别名对应的配置登录。

生成SSH秘钥:

root@vfeelit:~# ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key (/root/.ssh/id_rsa): 
Created directory '/root/.ssh'.
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /root/.ssh/id_rsa.
Your public key has been saved in /root/.ssh/id_rsa.pub.

把公钥放到远程机器的~/.ssh/authorized_keys文件中。

Bash Shell 变量

在Bash当中,当一个变量名称未被设置时,默认的内容是空的。

变量的设置规则:
1) 变量与变量内容以一个等号连接,等号两边不能接空格,注意,如果有空格则是逻辑比较
2) 变量名称只能是英文字母和数字,但开头不能是数字
3) 变量内容如含有空格,需要使用单引号或双引号括起来(如没有引号括起来,但是包含了转义,最终会转义输出),但双引号内特殊字符保持本性,单引号内的任何字符都是一般文本
4) 可以使用转义符将特殊字符变成一般字符
5) 如需要引用其它命令的结果,可以使用反单引号或$()语法,比如`uname –r`和$(uname -r)等同
6) 如要为变量增加内容,可以使用$变量名或${变量名}累加内容(不需要所谓的连接符)
7) 如变量需要在其它子进程中执行,需要以export来使变量变成环境变量:export PATH。
8) 通常大写字符为系统默认变量
9) 取消变量的方法是使用unset 变量名称

环境变量与自定义变量
在一个Shell中启用一个子Shell,子Shell中会继承父Shell的环境变量,但是自定义变量不会被继承(除非自定义的变量声明为环境变量)。
查看当前Shell中的环境变量,可以使用env命令。
查看当前Shell中的所有变量(包括环境变量),可以使用set命令。
命令set可以查看已经存在的Shell变量,以及设置Shell变量的新变量值(可改环境变量值),但是不能定义新的Shell变量。
命令declare用来定义自定义变量,如果需要把自定义变量变成环境变量,需要使用export变量名;如果需要直接声明一个环境变量,可以使用declare +x格式。
如果需要直接设置一个环境变量,可以使用export var=value格式。

自定义变量是指用户自己定义的变量,定义时可以声明它为环境变量(+x),也可以之后重新以declare +x格式变换成环境变量,或以export方式将其变为环境变量。
自定义变量不会因为它变成了环境变量而脱离它是自定义变量的事实。
变量变成环境变量后,可以set一个新值,可以unset,但是无法去掉环境变量标签(即:总是环境变量,也总是自定义变量)。

#查看环境变量
env

#查看环境变量和自定义变量
set

#查看自定义变量(不管定义是不是环境变量)
#export var=value,也是自定义变量
declare

#修改变量值(var必须先存着)
set var=value

#定义一个环境变量
export var=value

#定义一个自定义变量
declare var=value

#把自定义变量转换为环境变量
export var

#定义一个环境变量(或把变量变成环境变量)
declare -x var

#取消变量
unset var

当export一个变量,实际应该调用的是declare +x,所以export默认的输出是自定义变量变成环境变量的列表:

export
declare -x USER="root"
declare -x XDG_RUNTIME_DIR="/run/user/0"
declare -x XDG_SESSION_ID="2237"
declare -x var="value"

总之,用户自定义的变量,永远是自定义的,这个自定义的变量可以变换为环境变量(但是还是自定义的)。env查看环境变量,declare查看自定义变量(包括自定义变成环境变量的变量),set查看所有变量(环境变量和自定义变量的并集),export可以查看有哪些自定义变量升级为环境变量。 可以使用declare定义变量(+x声明为环境变量),export可以把变量转换为环境变量(实际是declare +x的使用),set对存在的变量设置新的值。一般使用declare定义一个自定义变量(或定义时指定值),export定义一个环境变量(或定义时指定值,export是declare的一种用法),用set来改变变量值。

如果一个变量同时出现在env和declare的输出结果中,说明这个变量是从自定义变量升级为环境变量的。如果只出现在env结果中,说明不是由自定义变量升级而来。

如下图:

特别用法:

#若指令返回值不等于0,则立即退出Shell
set -e

Laravel Envoy 详解

Envoy: 使节,外交官;全权公使;谈判代表

简单描述这个包的作用:以一种更加友好的方式,在本地操作远程服务器。

Laravel Envoy是一个标准的PHP组件,被定义为一个命令行工具,可以使用composer全局安装,也可以安装到具体项目中。

地址:
https://packagist.org/packages/laravel/envoy

检查composer.json文件:

"autoload": {
    "classmap": ["src"]
},
"require": {
    "illuminate/support": "~4.1 || ~5.0",
    "nategood/httpful": "~0.2",
    "symfony/console": "~3.0 || ~4.0",
    "symfony/process": "~3.0 || ~4.0"
}, 
"bin": [
    "envoy"
],

首先,这个包的类是以classmap方式直接映射的,另外它依赖4个包,其中symfony/console用来建立命令行工具的,symfony/process用来操作进程。

另外,bin指定了envoy,表明这个包下有一个叫envoy的文件,告诉Composer在vendor/bin中建立对应的符号链接(认为是执行文件)。

安装:

// 全局安装
composer global require laravel/envoy

// 在具体项目中安装
composer require laravel/envoy

不管以哪种方式安装,最终都需要在命令行中可以找到envoy这个工具,所以,可以把~/.composer/vendor/bin加入到PATH中:

vi .bash_profile
export PATH="/Users/vft/.composer/vendor/bin:/usr/local/sbin:$PATH"

用法(参考官方文档):

// 安装包中的Envoy.blade.php就是定义任务的地方(默认存在)
// 编写任务
@servers(['web' => 'user@192.168.1.1'])

@task('foo', ['on' => 'web'])
    ls -la
@endtask

任务foo是在web这台机器上运行(意思是登录到这个机器运行指定的任务)。

在运行任务前先执行一段PHP代码:

@setup
    $now = new DateTime();
    $environment = isset($env) ? $env : "testing";
@endsetup

引入其它文件:

@include('vendor/autoload.php')

@task('foo')
    # ...
@endtask

传递变量到命令:

envoy run deploy --branch=master
@servers(['web' => '192.168.1.1'])

@task('deploy', ['on' => 'web'])
    cd site
    @if ($branch)
        git pull origin {{ $branch }}
    @endif
    php artisan migrate
@endtask

任务分组:

@servers(['web' => '192.168.1.1'])

@story('deploy')
    git
    composer
@endstory

@task('git')
    git pull origin master
@endtask

@task('composer')
    composer install
@endtask

// 运行
envoy run deploy

任务分组用story指令来完成,所谓story,其实也可以看做是一个任务(执行时就看做是一个任务),不过它会依次运行分组里面的任务而已。

使用多个服务器:

@servers(['web-1' => '192.168.1.1', 'web-2' => '192.168.1.2'])

@task('deploy', ['on' => ['web-1', 'web-2']])
    cd site
    git pull origin {{ $branch }}
    php artisan migrate
@endtask

// 并行运行
@servers(['web-1' => '192.168.1.1', 'web-2' => '192.168.1.2'])

@task('deploy', ['on' => ['web-1', 'web-2'], 'parallel' => true])
    cd site
    git pull origin {{ $branch }}
    php artisan migrate
@endtask

在使用多个服务器的情况下,默认是一个服务器上的任务运行完成后,开启另一台服务器任务的运行,如果需要同时运行,那么就需要在定义任务时添加parallel为true。

运行任务:

./envoy run task

任务运行前确认:

@task('deploy', ['on' => 'web', 'confirm' => true])
    cd site
    git pull origin {{ $branch }}
    php artisan migrate
@endtask

命令执行后发送通知:

@after
    @slack('webhook-url', '#bots')
@endafter

发送通知这个不过是调用外部程序预留的钩子,达到通知外部的目的。

以下跟踪以下具体实现:
对应的文件:vendor/laravel/envoy/envoy是入口文件,vendor/laravel/envoy/ Envoy.blade.php就是定义任务的地方。

首先查看vendor/laravel/envoy/envoy:

#!/usr/bin/env php
<?php

if (file_exists(__DIR__.'/vendor/autoload.php')) {
    require __DIR__.'/vendor/autoload.php';
} else {
    require __DIR__.'/../../autoload.php';
}

$app = new Symfony\Component\Console\Application('Laravel Envoy', '1.4.1');

$app->add(new Laravel\Envoy\Console\RunCommand);
$app->add(new Laravel\Envoy\Console\SshCommand);
$app->add(new Laravel\Envoy\Console\InitCommand);
$app->add(new Laravel\Envoy\Console\TasksCommand);

$app->run();

第一行文件:#!/usr/bin/env php,找到PHP,如果环境变量中找不到PHP,就需要指定运行该脚本。

接下来是把autoload.php包含进来,命令可能是vendor/bin中的envoy命令,也可能是组件中的envoy命令,所以包含autoload.php需要适配这两种情况。

然后就是一个典型symfony/console命令行工具:先建立一个Application,然后往Application中添加命令,然后运行命令。

关于symfony/console包的使用,参考:
http://symfonychina.com/doc/current/console.html

运行envoy:

./envoy 
Laravel Envoy 1.4.1

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display this help message
  -q, --quiet           Do not output any message
  -V, --version         Display this application version
      --ansi            Force ANSI output
      --no-ansi         Disable ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands:
  help   Displays help for a command
  init   Create a new Envoy file in the current directory.
  list   Lists commands
  run    Run an Envoy task.
  ssh    Connect to an Envoy server.
  tasks  Lists all Envoy tasks and macros.

命令envoy的用法是:envoy [options] [arguments], 这里的optins列表是默认添加的,在可用命令部分有help和list都是默认提供的(直接输入envoy时,默认就是调用list命令),主动添加的只有:init, tasks, ssh, run。

以下看看具体的命令实现,首先,添加一个命令,命令周边的操作都一样,而命令只需要实现configure()和fire()方法,所以命令周边的操作提取到了一个trait中,具体的命令引用这个trait即可,这个trait对应src/Console/Command.php。

1 命令:init

./envoy init 192.168.1.111

源码:

<?php

namespace Laravel\Envoy\Console;

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Command\Command as SymfonyCommand;

class InitCommand extends SymfonyCommand
{
    use Command;

    /**
     * Configure the command options.
     *
     * @return void
     */
    protected function configure()
    {
        $this
            ->setName('init')
            ->setDescription('Create a new Envoy file in the current directory.')
            ->addArgument('host', InputArgument::REQUIRED, 'The host server to initialize with.');
    }

    /**
     * Execute the command.
     *
     * @return void
     */
    protected function fire()
    {
        if (file_exists(getcwd().'/Envoy.blade.php')) {
            $this->output->writeln('<error>Envoy file already exists!</error>');

            return;
        }

        file_put_contents(getcwd().'/Envoy.blade.php', "@servers(['web' => '".$this->input->getArgument('host')."'])

@task('deploy')
    cd /path/to/site
    git pull origin master
@endtask
");

        $this->output->writeln('<info>Envoy file created!</info>');
    }
}

方法configure()配置了命令名,需要什么参数,什么选项,命令注释等。fire()方法是具体的实现,命令的输入可用$this->input,命令的输出可用$this->output。

这里的envoy init只是往envoy所在的目录写入一个模板文件而已。

2 命令:tasks

./envoy tasks
Available tasks:
  foo
  deploy
  git
  composer

Available stories:
  story

实现:

protected function fire()
{
    $container = $this->loadTaskContainer();

    $this->listTasks($container);

    $this->output->writeln('');

    $this->listMacros($container);
}

protected function loadTaskContainer()
{
    if (! file_exists($envoyFile = getcwd().'/Envoy.blade.php')) {
        echo "Envoy.blade.php not found.\n";

        exit(1);
    }

    with($container = new TaskContainer)->load($envoyFile, new Compiler);

    return $container;
}

把Envoy.blade.php加载到taskContainer中,并指定了编译器。编译器对应src/Compiler.php,它把Envoy.blade.php这种Blade语法转换为PHP语法:

@servers(['web' => ['127.0.0.1']])

@setup
$now = new DateTime();
$environment = isset($env) ? $env : "testing";
@endsetup

@include('../../../vendor/autoload.php')

@task('foo', ['on' => 'web'])
    ls -la
@endtask

@task('deploy', ['on' => 'web'])
    cd site

    @if ($branch)
        git pull origin {{ $branch }}
    @endif

    php artisan migrate
@endtask

@story('story')
    git
    composer
@endstory

@task('git')
    git pull origin master
@endtask

@task('composer')
    composer install
@endtask

经过编译后得到:

<?php $branch = isset($branch) ? $branch : null; ?>
<?php $env = isset($env) ? $env : null; ?>
<?php $environment = isset($environment) ? $environment : null; ?>
<?php $now = isset($now) ? $now : null; ?>
<?php $__container->servers(['web' => ['127.0.0.1']]); ?>

<?php
$now = new DateTime();
$environment = isset($env) ? $env : "testing";
?>

 <?php require_once('../../../vendor/autoload.php'); ?>

<?php $__container->startTask('foo', ['on' => 'web']); ?>
    ls -la
<?php $__container->endTask(); ?>

<?php $__container->startTask('deploy', ['on' => 'web']); ?>
    cd site

    <?php if ($branch): ?>
        git pull origin <?php echo $branch; ?>

    <?php endif; ?>

    php artisan migrate
<?php $__container->endTask(); ?>

<?php $__container->startMacro('story'); ?>
    git
    composer
<?php $__container->endMacro(); ?>

<?php $__container->startTask('git'); ?>
    git pull origin master
<?php $__container->endTask(); ?>

<?php $__container->startTask('composer'); ?>
    composer install
<?php $__container->endTask(); ?>

然后taskContainer再把这个编译后的文件include进来, 实际执行容器里面的方法, 然后相关的数据就已经解析进入容器了,然后就调用相关的容器方法,比如取回任务等。

这里的tasks命令,实际就是调用容器的getTasks()方法。

3 命令:run

./envoy run task –continue –pretend –path= --conf=

选项—continue用来控制当执行一个任务失败后是否继续,这种情况发生在使用story指令来包装一组任务时,运行这个story实际是运行一组任务,默认不需要指定,表示不继续。
选项—pretend是伪装的意思,即把任务要运行的命令输出,并返回1(未执行成功)
选项—path用来指定任务定义的目录
选项—conf用来指定任务定义的文件

命令run的流程:
1 初始化容器
2 从容器中取回任务(脚本)
3 SSH登录服务器运行
4 运行任务执行后的回调

protected function fire()
{
    $container = $this->loadTaskContainer();

    $exitCode = 0;

    foreach ($this->getTasks($container) as $task) {
        $thisCode = $this->runTask($container, $task);

        if (0 !== $thisCode) {
            $exitCode = $thisCode;
        }

        if ($thisCode > 0 && ! $this->input->getOption('continue')) {
            $this->output->writeln('[<fg=red>✗</>] <fg=red>This task did not complete successfully on one of your servers.</>');

            break;
        }
    }

    foreach ($container->getFinishedCallbacks() as $callback) {
        call_user_func($callback);
    }

    return $exitCode;
}

这里的foreach是针对任务组(多个任务)的情况,然后调用runTask():

protected function runTask($container, $task)
{
    $macroOptions = $container->getMacroOptions($this->argument('task'));

    $confirm = $container->getTask($task, $macroOptions)->confirm;

    if ($confirm && ! $this->confirmTaskWithUser($task, $confirm)) {
        return;
    }

    if (($exitCode = $this->runTaskOverSSH($container->getTask($task, $macroOptions))) > 0) {
        foreach ($container->getErrorCallbacks() as $callback) {
            call_user_func($callback, $task);
        }

        return $exitCode;
    }

    foreach ($container->getAfterCallbacks() as $callback) {
        call_user_func($callback, $task);
    }
}

这里会返回一个Envoy/Task的对象实例传递到runTaskOverSSH()方法,Envoy/Task的对象是一个任务的简单封装,比如规定了任务在什么服务器上运行,是否并行运行等。

然后是runTaskOverSSH()方法:

protected function runTaskOverSSH(Task $task)
{
    // If the pretending option has been set, we'll simply dump the script out to the command
    // line so the developer can inspect it which is useful for just inspecting the script
    // before it is actually run against these servers. Allows checking for errors, etc.
    if ($this->pretending()) {
        echo $task->script.PHP_EOL;

        return 1;
    } else {
        return $this->passToRemoteProcessor($task);
    }
}

protected function passToRemoteProcessor(Task $task)
{
    return $this->getRemoteProcessor($task)->run($task, function ($type, $host, $line) {
        if (starts_with($line, 'Warning: Permanently added ')) {
            return;
        }

        $this->displayOutput($type, $host, $line);
    });
}
protected function getRemoteProcessor(Task $task)
{
    return $task->parallel ? new ParallelSSH : new SSH;
}

一个任务最终会放入到一个Envoy\SSH或ParallelSSH进程中执行(调用进程类的run()方法)。

进程类中,需要完成登录远程服务器,然后在远程服务器上执行脚本。登录服务器这个步骤,除非是本地的服务器(127.0.0.1),那么就需要认证,密码或私钥,这个是ssh外部命令完成。不过这里需要知道的是,SSH登录可以利用~/.ssh/config中定义的别名,比如针对这个别名定义了非22端口,那么就要解析这个文件,找到别名,然后ssh 别名。Laravel\Envoy\SSHConfigFile就解析这个config文件。

4 命令:ssh
这个命令基本是Bash中的ssh的包装,不过主机必须配置中定义过的主机:

@servers(['web' => 'root@192.168.1.111'])

那么就可以使用如下命令来登录:

./envoy ssh web

这个命令不过是找到web对应的机器root@192.168.1.111,然后ssh root@192.168.1.111而已:

protected function fire()
    {
        $host = $this->getServer($container = $this->loadTaskContainer());

        passthru('ssh '.($this->getConfiguredServer($host) ?: $host));
    }

这里的$host就是root@192.168.1.111,当然,这个机器可能在~/.ssh/config中定义了其它参数(比如非22端口),那么需要通过它找到别名,然后ssh别名。

Laravel Envoy最后一个功能就是命令执行完毕后,可以把结果发送给第三方,实际就是通知功能。

另外,一个任务可以异步发送到多台机器运行,但是不能多个任务同时运行。任务运行时可以指定continue参数,指定在任务失败时,是否继续,如果是并行运行的,这个参数似乎是无效的。另外,任务如果在多台机器上运行,只有全部成功才认为成功,可能发生前一个任务成功,后一个任务失败,这个时候需要设置回调做善后工作。