标签归档:laravel

Laravel 实践 – 访问跟踪 pragmarx/tracker

https://github.com/antonioribeiro/tracker

依赖的包:

    "require": {
        "php": ">=5.3.7",
        "doctrine/dbal": "^2.6",
        "laravel/framework": "~4|~5",
        "pragmarx/support": "~0.6|~0.7",
        "ramsey/uuid": "~3",
        "jenssegers/agent": "~2.1",
        "ua-parser/uap-php" : "~3.4",
        "pragmarx/datatables": "1.4.11",
        "snowplow/referer-parser": "~0.1",
        "jaybizzle/crawler-detect": "~1.0",
        "psr/log": "~1.0"
    },

这个包只针对Laravel应用。依赖jaybizzle/crawler-detect来进行机器人检查。

安装使用:

composer require pragmarx/tracker

// Laravel5.5以下需要
vi app/config/app.php
PragmaRX\Tracker\Vendor\Laravel\ServiceProvider::class,

vi app/config/app.php
'Tracker' => 'PragmaRX\Tracker\Vendor\Laravel\Facade',

// 发布(产生config/chacker.php文件和对应的数据库迁移文件)
php artisan vendor:publish --provider=PragmaRX\\Tracker\\Vendor\\Laravel\\ServiceProvider

// 启用中间件(在web组添加)
vi app/Http/Kernel.php
\PragmaRX\Tracker\Vendor\Laravel\Middlewares\Tracker::class,

// 修改配置文件,启动该插件,并启用中间件
vi config/tracker.php
'enabled' => true,

'use_middleware' => true,

// 继续编辑配置件,启动一个新的mysql链接(链接到相同库,原因可以查看配置中的说明)
// 名称可以不叫tracker,需要到config/tracker.php中做对应修改
vi config/database.php
'tracker' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'strict' => false,
            'engine' => null,
        ],

// 运行迁移,产生数据表
php artisan migrate --database=tracker

// 如果需要跟踪地理位置,需要安装额外依赖包(注意这个包和PHP的geoip扩展冲突)
composer require "geoip2/geoip2":"~2.0"

// 然后运行以下文件产生IP数据库(config/geoip),文件几十兆,不需要提交版本库
php artisan tracker:updategeoip

// 然后修改配置,让其记录geoip
vi config/tracker.php

'log_geoip' => true,

基本配置就完成了。这里需要配置中间件的主要原因是让Laravel可以自动调用chacker来进行记录。提供了一个Facade: Tracker,可以方便的取回数据,或者手动记录:

$visitor = Tracker::currentSession();
var_dump( $visitor->client_ip );

var_dump( $visitor->device->is_mobile );

var_dump( $visitor->device->platform );

var_dump( $visitor->geoIp->city );

var_dump( $visitor->language->preference );

数据结构:

流程描述:

Web工具:
这个包还提供了一个基于Bootstrap的Web管理程序,可以查看相对应的信息:

git clone https://github.com/BlackrockDigital/startbootstrap-sb-admin-2.git public/templates/sb-admin-2
cd public/templates/sb-admin-2
git checkout tags/v3.3.7+1
git checkout -b v3.3.7+1

首先克隆一个管理面板,Web管理程序调用了这个模板里面的样式JS等。对应的路径实际是配置中指定的:

vi config/chacker.php
    /*
     * Enable the Stats Panel?
     */
    'stats_panel_enabled' => true,

    /*
     * Stats Panel routes middleware
     *
     */
    'stats_routes_middleware' => 'web',

    /*
     * Stats Panel template path
     */
    'stats_template_path' => '/tracker/tracker-admin',

这里把下载的模板改名为tracker/tracker-admin。

然后通过方法/stats就可以访问改Web程序,不过会提示要授权,需要在对应的用户表中,添加一个is_admin字段,然后标识某个用户的is_admin字段为1。

由于这个包的视图加载了一些CDN资源,这些可能对我们不友好,所以需要对这个包提供的视图做覆盖修改,所以拷贝:

cp -a vendor/pragmarx/tracker/views/* resources/pragmarx/tracker/

使用的加载使用了pragmarx/tracker::xxx,所以会首先使用覆盖的版本(Laravel的机制)。

对应对模板进行修改,把资源下载回来,适当调整一下模板,基本还是可用的。

Laravel 实践 – 验证码 mews/captcha

验证码原理:随机产生一段字符串,用这个字符串生成图片,对这个字符串进行签名(哈希),把签名记录到会话,把图片返回给客户端,客户端根据图片识别图片内容,提交到服务端,服务端根据提交的字符进行哈希比对,如果匹配,则验证码正确,否则验证失败。

另外,对于API,由于没有状态,所以产生了图片和签名后,需要把图片换成Base64编码后一起发送给客户端,客户端识别图片后,把识别的图片对应的字符串和签名回传到服务器端,服务器端根据签名和验证码进行比对。

这里面,产生图片是一个关键,这个直接决定了验证码的质量。

软件包mews/captcha提供了一个验证码方案,产生的图片有一定的干扰性,不过质量不高,对于普通应用来说,还是能满足要求的。

地址:
https://github.com/mewebstudio/captcha

安装:
composer require mews/captcha

以下研究一下这个软件包实现细节:
1 composer.json

"require": {
		"php": ">=5.4",
		"ext-gd": "*",
		"illuminate/config": "~5.0",
		"illuminate/filesystem": "~5.0",
		"illuminate/support": "~5.0",
		"illuminate/hashing": "~5.0",
		"intervention/image": "~2.2"
	},
	"autoload": {
		"psr-4": {
			"Mews\\Captcha\\": "src/"
		},
		"files": [
			"src/helpers.php"
		]
	},

依赖ext-gd库,依赖intervention/image,全局加载helpers.php。

对于针对Laravel的扩展包,首先需要看看它提供的ServiceProvider:

<?php

namespace Mews\Captcha;

use Illuminate\Support\ServiceProvider;

/**
 * Class CaptchaServiceProvider
 * @package Mews\Captcha
 */
class CaptchaServiceProvider extends ServiceProvider {

    /**
     * Boot the service provider.
     *
     * @return null
     */
    public function boot()
    {
        // Publish configuration files
        $this->publishes([
            __DIR__.'/../config/captcha.php' => config_path('captcha.php')
        ], 'config');

        // HTTP routing
        if (strpos($this->app->version(), 'Lumen') !== false) {
	        $this->app->get('captcha[/api/{config}]', 'Mews\Captcha\LumenCaptchaController@getCaptchaApi');
	        $this->app->get('captcha[/{config}]', 'Mews\Captcha\LumenCaptchaController@getCaptcha');
        } else {
            if ((double) $this->app->version() >= 5.2) {
	            $this->app['router']->get('captcha/api/{config?}', '\Mews\Captcha\CaptchaController@getCaptchaApi')->middleware('web');
	            $this->app['router']->get('captcha/{config?}', '\Mews\Captcha\CaptchaController@getCaptcha')->middleware('web');
            } else {
	            $this->app['router']->get('captcha/api/{config?}', '\Mews\Captcha\CaptchaController@getCaptchaApi');
	            $this->app['router']->get('captcha/{config?}', '\Mews\Captcha\CaptchaController@getCaptcha');
            }
        }

	    // Validator extensions
	    $this->app['validator']->extend('captcha', function($attribute, $value, $parameters)
	    {
		    return captcha_check($value);
	    });

	    // Validator extensions
	    $this->app['validator']->extend('captcha_api', function($attribute, $value, $parameters)
	    {
		    return captcha_api_check($value, $parameters[0]);
	    });
    }

    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        // Merge configs
        $this->mergeConfigFrom(
            __DIR__.'/../config/captcha.php', 'captcha'
        );

        // Bind captcha
        $this->app->bind('captcha', function($app)
        {
            return new Captcha(
                $app['Illuminate\Filesystem\Filesystem'],
                $app['Illuminate\Config\Repository'],
                $app['Intervention\Image\ImageManager'],
                $app['Illuminate\Session\Store'],
                $app['Illuminate\Hashing\BcryptHasher'],
                $app['Illuminate\Support\Str']
            );
        });
    }
}

首先是绑定路由(flat是配置的key):
captcha/api/flat
captcha/api
captcha/flat
captcha

然后注册一个验证规则:captcha,它验证值,内部实际调用了captcha_check(),从会话中取回签名哈希比对;如果是API则有点差别。

最后就是向容器中注册一个绑定:captch。它最终会产生一个Mews\Captcha\Captcha类实例。从构造函数可见,实现一个验证码方案,实际并不简单。需要读取文件,需要动态产生图片,需要读取配置,需要哈希比对,需要产生随机字符串。

验证码所有的内容都集中在Mews\Captcha\Captcha类中,只需要看几个方法:

    public function create($config = 'default', $api = false)
    {
        $this->backgrounds = $this->files->files(__DIR__ . '/../assets/backgrounds');
        $this->fonts = $this->files->files(__DIR__ . '/../assets/fonts');
        
        if (app()->version() >= 5.5){
            $this->fonts = array_map(function($file) {
                return $file->getPathName();
            }, $this->fonts);
        }
        
        $this->fonts = array_values($this->fonts); //reset fonts array index

        $this->configure($config);

        $generator = $this->generate();
        $this->text = $generator['value'];

        $this->canvas = $this->imageManager->canvas(
            $this->width,
            $this->height,
            $this->bgColor
        );

        if ($this->bgImage)
        {
            $this->image = $this->imageManager->make($this->background())->resize(
                $this->width,
                $this->height
            );
            $this->canvas->insert($this->image);
        }
        else
        {
            $this->image = $this->canvas;
        }

        if ($this->contrast != 0)
        {
            $this->image->contrast($this->contrast);
        }

        $this->text();

        $this->lines();

        if ($this->sharpen)
        {
            $this->image->sharpen($this->sharpen);
        }
        if ($this->invert)
        {
            $this->image->invert($this->invert);
        }
        if ($this->blur)
        {
            $this->image->blur($this->blur);
        }

        return $api ? [
	        'sensitive' => $generator['sensitive'],
	        'key'       => $generator['key'],
        	'img'       => $this->image->encode('data-url')->encoded
        ] : $this->image->response('png', $this->quality);
    }

/**
     * Generate captcha text
     *
     * @return string
     */
    protected function generate()
    {
        $characters = str_split($this->characters);

        $bag = '';
        for($i = 0; $i < $this->length; $i++)
        {
            $bag .= $characters[rand(0, count($characters) - 1)];
        }

        $bag = $this->sensitive ? $bag : $this->str->lower($bag);

        $hash = $this->hasher->make($bag);
        $this->session->put('captcha', [
            'sensitive' => $this->sensitive,
            'key'       => $hash
        ]);

        return [
        	'value'     => $bag,
	        'sensitive' => $this->sensitive,
	        'key'       => $hash
        ];
    }

这个是主要逻辑:读取背景图,字体,读取配置,产生随机字符串并生成签名并且如果是签名记录到当前会话(generate()方法完成),然后产生画布,插入图片文件,插入干扰等,最终生成一张图片,根据类型,返回一个数组(API方式)或直接返回一个图片响应。

这里已经很情况,随机字符串的哈希写入了会话,接下只需要比对即可。提供的方法:

public function check($value)
	{
		if ( ! $this->session->has('captcha'))
		{
			return false;
		}

		$key = $this->session->get('captcha.key');
		$sensitive = $this->session->get('captcha.sensitive');

		if ( ! $sensitive)
		{
			$value = $this->str->lower($value);
		}

		$this->session->remove('captcha');

		return $this->hasher->check($value, $key);
	}

最后的问题是如何呈现给客户端:

   public function src($config = null)
    {
        return url('captcha' . ($config ? '/' . $config : '/default')) . '?' . $this->str->random(8);
    }

    /**
     * Generate captcha image html tag
     *
     * @param null $config
     * @param array $attrs HTML attributes supplied to the image tag where key is the attribute
     * and the value is the attribute value
     * @return string
     */
    public function img($config = null, $attrs = [])
    {
        $attrs_str = '';
        foreach($attrs as $attr => $value){
            if ($attr == 'src'){
                //Neglect src attribute
                continue;
            }
            $attrs_str .= $attr.'="'.$value.'" ';
        }
        return '<img src="' . $this->src($config) . '" '. trim($attrs_str).'>';
    }

产生一个URL,实际就是ServiceProvider中动态注册的路由。这个路由调用create()方法,返回图片。

最后,是关于如何使用:
在登录视图中添加captcha字段,并且输出图片:

@if ($errors->has('captcha'))
                            <br />
                            <strong>{{ $errors->first('captcha') }}</strong>
                        @endif


<input id="captcha" name="captcha" type="text" class="form-control" required="" placeholder="验证码">
                            <br />
                            {!! captcha_img('flat') !!}

然后在Auth\LoginController.php中,添加:

    protected function validateLogin(Request $request)
    {
        $this->validate($request, [
            $this->username() => 'required|string',
            'password' => 'required|string',
            'captcha' => 'required|captcha'
        ]);
    }

这里是验证captcha字段,必填并且要符合captcha规则。captcha规则实际就是比对验证码是否正确。由于captcha验证规则是新增的,验证不通过时的提示语没有定义(没有定义执行显示key,比如validation.captcha),所以需要进入resources/lang/zh-CN/validation.php添加:

'captcha' => '验证码不正确'

另外,产生验证码和图片是可以自定义的,主要是修改配置:

#直接从包中拷贝一个配置记录,也可以publish
cp mews/captcha/config/captcha.php conf/captcha.php

<?php

return [

    'characters' => '2346789abcdefghjmnpqrtuxyzABCDEFGHJMNPQRTUXYZ',

    'default'   => [
        'length'    => 5,
        'width'     => 120,
        'height'    => 36,
        'quality'   => 90,
    ],

    'flat'   => [
        'length'    => 6,
        'width'     => 160,
        'height'    => 46,
        'quality'   => 90,
        'lines'     => 6,
        'bgImage'   => false,
        'bgColor'   => '#ecf2f4',
        'fontColors'=> ['#2c3e50', '#c0392b', '#16a085', '#c0392b', '#8e44ad', '#303f9f', '#f57c00', '#795548'],
        'contrast'  => -5,
    ],

    'mini'   => [
        'length'    => 3,
        'width'     => 60,
        'height'    => 32,
    ],

    'inverse'   => [
        'length'    => 5,
        'width'     => 120,
        'height'    => 36,
        'quality'   => 90,
        'sensitive' => true,
        'angle'     => 12,
        'sharpen'   => 10,
        'blur'      => 2,
        'invert'    => true,
        'contrast'  => -5,
    ]

];

如果没有指定配置,那就是default。 比如这里可以指定flat。

验证码看不清楚时,可以点击验证码更换一个验证,由于验证码是请求一个地址动态产生的,所以客户端只需要重新让img标签发起请求即可:

Laravel 实践 – 自定义认证

Laravle认证原理:http://blog.ifeeline.com/2726.html

从一个旧系统迁移数据,密码取了哈希,为了兼容认证,比如修改认证方式。

Laravel中可以新增Guard,也可以新增Provider,所以一般都是通过新增的方式实现更换原有的Guard和Provider。

vi config/auth.php

<?php

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

    'guards' => [
        'web' => [
            'driver' => 'session',
            //'provider' => 'users',
            // 这里的compatible_users对应providers中的compatible_users,名字随意
            'provider' => 'compatible_users'
        ],

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

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\User::class,
        ],
        // 自定义Provider,driver对应的值必须和定义时一直
        'compatible_users' => [
            'driver' => 'compatible',
            'model' => App\User::class,
        ],
    ],

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

];
[/php
这里设置了一个自定义的Provider,他对应的driver叫compatible,通过\Auth::provider添加这个“compatible”

vi app/Providers/AuthServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use App\Extensions\CompatibleUserProvider;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        'App\Model' => 'App\Policies\ModelPolicy',
    ];

    public function boot()
    {
        $this->registerPolicies();

        \Auth::provider('compatible', function($app, array $config) {
            return new CompatibleUserProvider($app, $config['model']);
        });
    }
}

这里需要定义App\Extensions\CompatibleUserProvider,它集成了原来的类:

vi app/Extensions/CompatibleUserProvider.php

<?php

namespace App\Extensions;

use Illuminate\Support\Str;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Auth\EloquentUserProvider;

class CompatibleUserProvider extends EloquentUserProvider
{
    /**
     * Validate a user against the given credentials.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
     * @param  array  $credentials
     * @return bool
     */
    public function validateCredentials(UserContract $user, array $credentials)
    {
        $plain = $credentials['password'];
        $password = $user->getAuthPassword();
        if (strlen($password) <= 32) {
            return hash_equals(md5($plain), $password);
        }
        return $this->hasher->check($plain, $user->getAuthPassword());
    }
}

覆盖了父类方法:validateCredentials(),通过这个方法注入自己的验证逻辑。

另外,为了validateCredentials()方法需要得到用户实例,如果没有用户实例,那就没有验证这个说法了,用户实例是通过retrieveByCredentials获取的:

//Illuminate\Auth\EloquentUserProvider

/**
     * Retrieve a user by the given credentials.
     *
     * @param  array  $credentials
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveByCredentials(array $credentials)
    {
        if (empty($credentials) ||
           (count($credentials) === 1 &&
            array_key_exists('password', $credentials))) {
            return;
        }

        // First we will add each credential element to the query as a where clause.
        // Then we can execute the query and, if we found a user, return it in a
        // Eloquent User "model" that will be utilized by the Guard instances.
        $query = $this->createModel()->newQuery();

        foreach ($credentials as $key => $value) {
            if (! Str::contains($key, 'password')) {
                $query->where($key, $value);
            }
        }

        return $query->first();
    }

可以通过修改这个方法来修改取回用户实例的逻辑,不过仔细看就知道它不过是根据$credentials来查询用户表而已,所以只需要定义$credentials即可:

#Illuminate\Foundation\Auth是一个trait,它被App\Http\Controllers\Auth\LoginController使用
#用来从请求中抽取需要认证的信息
    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only($this->username(), 'password');
    }

可以添加需要验证的字段和对应的值。这样的条件会被用来查找用户实例。如果找不到用户实例,最终会返回验证不通过。

Laravel FileSystem详解

存储一个文件,需要知道存放在哪个磁盘以及磁盘上的那个位置,还需要设置文件的可见性(private或public),如果是public的,还需要知道怎么访问。

不管是本地磁盘还是网络磁盘,都可以抽象为磁盘,在磁盘上,可做的操作一样,这个就是League\Flysystem包做的事情。而Laravel的文件系统,是对这个包的二次封装(提供一个新的适配器)。

服务提供:
Illuminate\Filesystem\FilesystemServiceProvider

class FilesystemServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->registerNativeFilesystem();

        $this->registerFlysystem();
    }

    protected function registerNativeFilesystem()
    {
        $this->app->singleton('files', function () {
            return new Filesystem;
        });
    }

    protected function registerFlysystem()
    {
        $this->registerManager();

        $this->app->singleton('filesystem.disk', function () {
            return $this->app['filesystem']->disk($this->getDefaultDriver());
        });

        $this->app->singleton('filesystem.cloud', function () {
            return $this->app['filesystem']->disk($this->getCloudDriver());
        });
    }

    protected function registerManager()
    {
        $this->app->singleton('filesystem', function () {
            return new FilesystemManager($this->app);
        });
    }
}

首先注意到这里的registerNativeFilesystem直接是往容器中注册files,指向Illuminate\Filesystem\Filesystem实例。它是Laravel提供的一个本地文件系统封装,只是用来方便操作本地任意的文件或目录。这个对象内部使用了Symfony\Component\Finder\Finder来实现文件的查找操作,同时提供了一致的API,比如get()、put()、exists()、delete()、prepend()、append()等等,特别:

// 先上锁
    public function sharedGet($path)
    {
        $contents = '';

        $handle = fopen($path, 'rb');

        if ($handle) {
            try {
                if (flock($handle, LOCK_SH)) {
                    clearstatcache(true, $path);

                    $contents = fread($handle, $this->size($path) ?: 1);

                    flock($handle, LOCK_UN);
                }
            } finally {
                fclose($handle);
            }
        }

        return $contents;
    }

// 创建文件或目录的符号链接(Windows下如何实现)
    public function link($target, $link)
    {
        if (! windows_os()) {
            return symlink($target, $link);
        }

        $mode = $this->isDirectory($target) ? 'J' : 'H';

        exec("mklink /{$mode} \"{$link}\" \"{$target}\"");
    }

注意,可以通过app(‘files’)获取这个对象,另外它对应了一个叫File的Facade,可以充分使用:

$path = public_path();
$file = $path . '/t.txt';
if (\File::exists($file)) {
    \File::delete($file);
}

回到服务提供者,除了registerNativeFilesystem,还有registerManager,实际是一个文件系统管理器,文件系统可以看做是一个磁盘,可以有很多磁盘(比如本地磁盘,网络磁盘),那就需要一个管理器管理这些磁盘,也方便切换。这里的磁盘又可分为默认磁盘和默认网络磁盘(这两个不冲突),默认磁盘和默认网络磁盘可以一样。对应的配置文件:

return [
    'default' => env('FILESYSTEM_DRIVER', 'local'),

    'cloud' => env('FILESYSTEM_CLOUD', 's3'),

    'disks' => [

         'local' => [
             'driver' => 'local',
             'root' => storage_path('app'),
         ],

        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],

        's3' => [
            'driver' => 's3',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION'),
            'bucket' => env('AWS_BUCKET'),
        ],

    ],

];

注意disks数组,Key是磁盘名称,原则上可以随意命名,切换磁盘时用的就是这个名称。里面的driver对于内置的来说,就是固定的,如果是自定义的驱动,对应的就是自定义的名字(需要按照League\Flysystem套路来实现驱动),其它配置是因驱动不同而不同。

这里需要看明白的是local和public磁盘的差别,public磁盘存储路径在/storage/app/public中,可见性是“public”的,访问链接是/public/storage,这里意味着需要把/storage/app/public和/public/storage建立一个符号链接,可以调用Illuminate\Filesystem\Filesystem的link()方法可以完成,Laravel本身提供了一个专门针对这个情况的命令行工具:

php artisan storage:link

// Illuminate\Foundation\Console\StorageLinkCommand
// 如下代码
        $this->laravel->make('files')->link(
            storage_path('app/public'), public_path('storage')
        );

默认存储到本地磁盘中的文件(位置:/storage/app/)不是公开的,但是/storage/app/public是公开的,所有需要做一个符号链接。

如果需要存放到一个公开的文件,那么需要切换到public磁盘,然后保存即可:

$public = \Storage::disk("public");
$public->put('folder', 'file.txt');

Storage对应的就是磁盘管理器(app(“filesystem”))。在Illuminate\Filesystem\FilesystemManager中,disk就是一个Illuminate\Filesystem\FilesystemAdapter的实例,它接收一个League\Flysystem\FilesystemInterface类型的实例(League\Flysystem\Filesystem是实现类),而这个实例又需要一个League\Flysystem\AdapterInterface的实例,配置会传递到这个适配器。最终结果就是Laravel通过一个自己的适配器,把League\Flysystem\FilesystemInterface接口的方法更换了一套API名称(当然还有其它的集成)。

1 自定义文件系统
Laravel的适配器需要一个League\Flysystem\Filesystem实例,而League\Flysystem\Filesystem需要一个League\Flysystem\AdapterInterface适配器实例,所以套路是:

class XxxAdaper implements \League\Flysystem\AdapterInterface
{}

Storage::extend('xxx', function ($app, $config) {
            return new Filesystem(new League\Flysystem\Filesystem(new XxxAdaper($config)));
        });

Storage::extend()第一个参数是驱动名称(不是磁盘名称),然后这个磁盘就可用,这里需要做的就是实现XxxAdaper适配器。

2 启用和配置S3
对于S3, Laravel提供了内置支持,不过还需要引入S3的SDK才能正常工作。

AWS提供的SDK,底层服务使用GuzzleHttp来通信,所以理论上应该提供了针对它的相关的控制选项。 SDK中的Aws\S3\S3Client继承自Aws\AwsClient,查看其构造函数说明:

__construct ( array $args ) 
 
http: (array, default=array(0)) Set to an array of SDK request options to apply to each request (e.g., proxy, verify, etc.).

针对Http控制的仅仅有这一样的说明。也没有点出默认是使用GuzzleHttp。实际上,所有针对GuzzleHttp可用的配置参数,这里都可以传递进来:

'http' => [
                'connect_timeout' => 20,
                'timeout' => 60,
                'verify' => false
            ],

比如这里的超时控制,GuzzleHttp默认是0,意思就不超时,这个在后台进程中,经常因为这个默认值,导致进程僵死。又比如,如果要控制其走代理:

// 控制S3是否使用代理
$http_proxy = env('S3_PROXY', false);
if(!empty($http_proxy)) {
    $proxy = env($http_proxy, false);
    if(!empty($proxy)) {
        $config['disks']['s3']['http']['proxy'] = [
            'http'  => 'tcp://'.$proxy,
            'https' => 'tcp://'.$proxy
        ];
    }
}

2 文件下载
磁盘重新中提供了一个download()方法,可以非常方便地下载文件:

return \Storage::download('files/zVolRIUofhOdx9BDJP04WMGoJJonmLcZfITcXI2s.docx');

3 文件上传
Illuminate\Http\Request使用了Illuminate\Http\Concerns\InteractsWithInput,这个trait包含了与输入相关的方法:

// 上传一个文件
<input type="file" name="file" />

// 上传多个文件
<input type="file" name="file[]" />
<input type="file" name="file[]" />

$request->hasFile("file");
$request->file("file");

服务端,控制前中都可以使用$request->hasFile(“file”)判断是否存在文件,然后通过$request->file(“file”)把文件取回。

public function hasFile($key)
    {
        if (! is_array($files = $this->file($key))) {
            $files = [$files];
        }

        foreach ($files as $file) {
            if ($this->isValidFile($file)) {
                return true;
            }
        }

        return false;
    }

protected function isValidFile($file)
    {
        return $file instanceof SplFileInfo && $file->getPath() !== '';
    }

public function file($key = null, $default = null)
    {
        return data_get($this->allFiles(), $key, $default);
    }

   public function allFiles()
    {
        $files = $this->files->all();

        return $this->convertedFiles
                    ? $this->convertedFiles
                    : $this->convertedFiles = $this->convertUploadedFiles($files);
    }

    protected function convertUploadedFiles(array $files)
    {
        return array_map(function ($file) {
            if (is_null($file) || (is_array($file) && empty(array_filter($file)))) {
                return $file;
            }

            return is_array($file)
                        ? $this->convertUploadedFiles($file)
                        : UploadedFile::createFromBase($file);
        }, $files);
    }

注意,$this->files是所有上传的文件(集合),每个文件都会转换为Illuminate\Http\UploadedFile对象。

PHP中,对文件有三个标准类:

SplFileInfo
SplFileObject
SplTempFileObject

SplTempFileObject继承SplFileObject,SplFileObject继承SplFileInfo,SplFileInfo实现了一个文件的封装,提供了非常常用的方法。

Symfony\Component\HttpFoundation\File\File继承了SplFileInfo,在它之上,提供了guessExtension()、getMimeType()方法。

Symfony\Component\HttpFoundation\File\UploadedFile继承了Symfony\Component\HttpFoundation\File\File,在它基础之上提供了文件上传需要的特定方法,比如:getClientOriginalName()、getClientOriginalExtension()、getClientMimeType()、guessClientExtension()、isValid()、move()等方法,

Illuminate\Http\UploadedFile继承了Symfony\Component\HttpFoundation\File\UploadedFile,提供了与框架相关的保存文件的方法。比如:store()、storePublicly()、storeAs()等。

例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/test/upload" method="post" enctype="multipart/form-data">
    {{ csrf_field() }}
    {{-- 单选 --}}
    <input type="file" name="file" />               
    {{-- 多选,表单名字不带[]--}}
    <input type="file" name="files" multiple />
     {{-- 多选,表单名字带[]--}}
    <input type="file" name="files1[]" multiple />
    {{-- 多选加单选 --}}
    <input type="file" name="files2[]" multiple />
    <input type="file" name="files2[]" />
    
    <br />
    <input type="submit" value="提交" />
</form>
</body>
</html>

// 文件处理
   public function upload(Request $request)
   {
      $files = $request->allFiles();
      print_r($files);
    }

输出:

Array
(
    [file] => Illuminate\Http\UploadedFile Object
        (
            [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
            [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => a.jpg
            [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/jpeg
            [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 3305
            [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
            [hashName:protected] => 
            [pathName:SplFileInfo:private] => /private/var/tmp/php07Byk0
            [fileName:SplFileInfo:private] => php07Byk0
        )

    [files] => Illuminate\Http\UploadedFile Object
        (
            [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
            [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => test.png
            [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/png
            [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 17417
            [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
            [hashName:protected] => 
            [pathName:SplFileInfo:private] => /private/var/tmp/phpd9uAtT
            [fileName:SplFileInfo:private] => phpd9uAtT
        )

    [files1] => Array
        (
            [0] => Illuminate\Http\UploadedFile Object
                (
                    [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
                    [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => a.jpg
                    [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/jpeg
                    [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 3305
                    [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
                    [hashName:protected] => 
                    [pathName:SplFileInfo:private] => /private/var/tmp/phpzJvo3C
                    [fileName:SplFileInfo:private] => phpzJvo3C
                )

            [1] => Illuminate\Http\UploadedFile Object
                (
                    [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
                    [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => test.png
                    [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/png
                    [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 17417
                    [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
                    [hashName:protected] => 
                    [pathName:SplFileInfo:private] => /private/var/tmp/phpHCVvIK
                    [fileName:SplFileInfo:private] => phpHCVvIK
                )

        )

    [files2] => Array
        (
            [0] => Illuminate\Http\UploadedFile Object
                (
                    [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
                    [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => a.jpg
                    [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/jpeg
                    [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 3305
                    [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
                    [hashName:protected] => 
                    [pathName:SplFileInfo:private] => /private/var/tmp/php50xae1
                    [fileName:SplFileInfo:private] => php50xae1
                )

            [1] => Illuminate\Http\UploadedFile Object
                (
                    [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
                    [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => test.png
                    [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/png
                    [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 17417
                    [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
                    [hashName:protected] => 
                    [pathName:SplFileInfo:private] => /private/var/tmp/phpuHaPFq
                    [fileName:SplFileInfo:private] => phpuHaPFq
                )

            [2] => Illuminate\Http\UploadedFile Object
                (
                    [test:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 
                    [originalName:Symfony\Component\HttpFoundation\File\UploadedFile:private] => a.jpg
                    [mimeType:Symfony\Component\HttpFoundation\File\UploadedFile:private] => image/jpeg
                    [size:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 3305
                    [error:Symfony\Component\HttpFoundation\File\UploadedFile:private] => 0
                    [hashName:protected] => 
                    [pathName:SplFileInfo:private] => /private/var/tmp/phpXU4PAy
                    [fileName:SplFileInfo:private] => phpXU4PAy
                )

        )
)

这个结果非常清晰:
1 对于单选,识别为一个文件对象
2 对于多选,如果表单name不带[],还是识别为一个文件对象,这个文件对象为最后选中的文件
3 对于多选,表单name必须携带[],这样可以识别为一个数组
4 可以混合使用单选和多选,只要表单name相同即可
5 识别的Key不带[]

文件上传到临时目录后,如果正确上传,会通过验证:

    public function isValid()
    {
        $isOk = UPLOAD_ERR_OK === $this->error;

        return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname());
    }

通过验证后需要保存到一个正常的目录,可以使用move()方法,不过Laravel提供了一套方案,可以方便地保存到磁盘:

public function store($path, $options = [])
    {
        return $this->storeAs($path, $this->hashName(), $this->parseOptions($options));
    }

直接调用store可以保存文件到默认磁盘,如果需要切换磁盘,可以定义$options,比如要保存到s3,可以设置$options = [‘disk’ => ‘s3’],背后的逻辑实际就是:

\Storage::disk("s3")->putFile($path, new UploadFile());

如果要设置可见性,可以调用storePublicly(),实际调用storeAs(), 修正$options而已。所以:

storePublicly($path)
//等同
store($path, $option = ["visibility" => "public"]);

对于本地驱动,visibility是一个模拟,理应保存到/storage/app/public中,实际上和store并没有差异。如果要保存到public中还是要[‘disk’ => ‘pubic’]才行。

Laravel Socialite 详解

Laravel Socialite提供OAuth认证,目前支持的认证驱动包括Facebook、Twitter、Google、LinkedIn、GitHub 和 Bitbucket。注:其它平台可以到https://socialiteproviders.github.io/中找到。

composer require laravel/socialite

vi config/services.php
'github' => [
    'client_id' => env('GITHUB_CLIENT_ID'),         // Your GitHub Client ID
    'client_secret' => env('GITHUB_CLIENT_SECRET'), // Your GitHub Client Secret
    'redirect' => 'http://your-callback-url',
],

vi .env
GITHUB_CLIENT_ID=4a9750c26b0b62000748
GITHUB_CLIENT_SECRET=0b9bfa15f6d0cdc50000082bd364631cd973c000

注:Key必须为facebook、twitter、linkedin、google、github 或bitbucket,配置哪些key取决于应用需要的提供者。

这里拿Github来测试,到https://github.com/settings/developers添加OAuth应用,对应填写相关信息(注意回调的填写)。

控制器路由:


<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Laravel\Socialite\Facades\Socialite;

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 = '/home';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }

    public function redirectTo($platform)
    {
        return Socialite::driver($platform)->redirect();
    }

    public function handleCallback($platform)
    {
        $user = Socialite::driver($platform)->user();

        dd($user);
    }
}


// 路由

Route::get('login/{platform}', 'Auth\LoginController@redirectTo');
Route::get('login/{platform}/callback', 'Auth\LoginController@handleCallback');

首次登陆,把用户插入用户表,然后用Auth::login()让其变成登录状态。以后再次登录,根据ID定位到用户,然后用Auth::login()让其登录。

在重定向到社交媒体页面,要求登录,确认授权,确认后会把一次性授权码重定向到handleCallback,在这个回调中,Socialite::driver($platform)->user()做了深层封装,实际上它首先根据一次性授权码code换取token(刷新token可能也返回),取到token后,再发起一个正常的API调用(携带token),把用户信息取回来。

如果一次性code已经失效,那么将返回401状态码(认证失败),所以安全性的保证是一次性code有效性。

Laravel 多语言详解

要实现多语言,必定是对文本进行提取,表示为key,然后根据语言寻找到到语言文件,定位key对应的值。

先链接一些用法:

/resources
    /lang
        /en
            messages.php
        /zh-CN
            messages.php

# messages.php文件
<?php

return [
    'welcome' => 'Welcome to our application'
];

默认需要使用什么语言,在config/app.php中配置:

'locale' => 'zh-CN',
'fallback_locale' => 'en',

默认语言是zh-CN,备用是en,备用是指当在默认找不到时,切换为备用的寻找语言文件。可以在运行中修改:

$locale = App::getLocale();

if (App::isLocale('en')) {
    App::setLocale($locale);
}

定义翻译文件,根据语言,在resources/lang下面建立JSON文件:

/resources/lang/zh-CN.json

{
    'hello' => '你好' 
}

使用翻译:

echo __("hello")
echo __('messages.welcome');
#在模板中
{{ __("hello") }}
@lang("hello")

双下滑线函数首先到zh-CN.json中定位,如果没有再到lang/zh-CN中根据一定规则定位,如果无法定位,返回key本身。具体来说,如果messages.welcome这个key在zh-CN.json中有定义,就直接使用,如果没有就到lang/zh-CN中定位message文件,然后从这个文件中定位welcome这个key,如还是无法定位,直接返回messages.welcome。

另外,key对应的值可以有占位符,所以双下滑线函数第二参数可以携带一个关联数组:

'welcome' => 'Welcome, :name',

echo __('messages.welcome', ['name' => 'laravel']);

注意:占位符可以是首字母大写,也可以全大写,对应传入的字符串也会做对应的转换(首字母大写或全大写)

具体实现。在config/app.php中会载入serviceProvider(Illuminate\Translation\TranslationServiceProvider):

<?php

namespace Illuminate\Translation;

use Illuminate\Support\ServiceProvider;

class TranslationServiceProvider extends ServiceProvider
{
    protected $defer = true;

    public function register()
    {
        $this->registerLoader();

        $this->app->singleton('translator', function ($app) {
            $loader = $app['translation.loader'];

            $locale = $app['config']['app.locale'];

            $trans = new Translator($loader, $locale);

            $trans->setFallback($app['config']['app.fallback_locale']);

            return $trans;
        });
    }

    protected function registerLoader()
    {
        $this->app->singleton('translation.loader', function ($app) {
            return new FileLoader($app['files'], $app['path.lang']);
        });
    }

    public function provides()
    {
        return ['translator', 'translation.loader'];
    }
}

首先注意到这个serviceProvider是defer的(在需要时才注册服务)。translation.loader到一个FileLoader实例,它需要把文件系统对象注入,它负责怎么取文件,而去哪里取则是第二参数指定的。 loader只是解决了如何load文件,根据什么条件load,然后怎么取回key,是Translator负责,它需要把loader注入,还要告诉默认什么语言和备用语言(对应代码中的translator)。

首先看一下Loader:

<?php

namespace Illuminate\Translation;

use RuntimeException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Contracts\Translation\Loader;

class FileLoader implements Loader
{
    public function load($locale, $group, $namespace = null)
    {
        if ($group == '*' && $namespace == '*') {
            return $this->loadJsonPaths($locale);
        }

        if (is_null($namespace) || $namespace == '*') {
            return $this->loadPath($this->path, $locale, $group);
        }

        return $this->loadNamespaced($locale, $group, $namespace);
    }
}

主要就是这个load方法,用法举例:

// 解析resources/lang/zh-CN.json
load('zh-CN', '*', '*');

// 解析resources/lang/zh-CN/下的message.php文件
load('zh-CN', 'message') 
load('zh-CN', 'message', '*') 

// 解析ifeeline命名空间(需要先设置ifeeline => vender/ifeeline/ifeeline/src/lang/)中zh-CN/下的message.php
// 然后用resources/lang/vender/ifeeline/zh-CN/message.php覆盖后返回
load('zh-CN', 'message', 'ifeeline') 

第三种用法看起来有点不好理解。这个主要用来对付一些包,它自己本身处理了多语言,举例:

ifeeline/example => vender/ifeeline/example/src 

包名称和命名空间可一样,也可以不一样(勿混淆)。在这个包装中,通常暴露一个serviceProvider,其中会调用translation.loader的addNamespace方法,告诉loader自己对应的包名对应的语言路径,实际就是:

ifeeline-example => vender/ifeeline/example/src/lang

public function addNamespace($namespace, $hint)
    {
        $this->hints[$namespace] = $hint;
    }

当要取回某个文件时,通过loader.load(‘zh-CN’, ‘message’, ‘ifeeline-example’)取回vender/ifeeline/example/src/lang/zh-CN/message.php文件,然后通过translator再定位key。

所以为了可以扩展(添加没有的语言),和修改key对应的值,translator还会操作loader去resource/lang/vender/ifeeline-example/zh-CN下寻找message文件进行覆盖(原来没有就是新增),所以可以把语言包放入到对应位置就实现了覆盖或添加新语言包。一般包中的serviceProvider会提供publish钩子,可以把语言文件拷贝对对应位置(我们进行对应修改以实现覆盖)。

注意到loader的load()方法还是非常涩的,而且没有定位key,这个就是translator的具体实现。

Illuminate\Translation\Translator继承自Illuminate\Support\NamespacedItemResolver,它的parseKey方法用法:

# 返回[ifeeline-example, message, hello]
parseKey("ifeeline-example::message.hello")

# 返回[ifeeline-example, message, hello.hi]]
parseKey("ifeeline-example::message.hello.hi")

# 返回[ifeeline-example, message, null]
parseKey("ifeeline-example::message")

# 返回[null, message, hello]
parseKey("message.hello")

# 返回[null, message, hello.hi]]
parseKey("message.hello.hi")

# 返回[null, message, null]
parseKey("message")

最终来到Illuminate\Translation\Translator:

<?php

namespace Illuminate\Translation;

use Countable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Translation\Loader;
use Illuminate\Support\NamespacedItemResolver;
use Illuminate\Contracts\Translation\Translator as TranslatorContract;

class Translator extends NamespacedItemResolver implements TranslatorContract
{
    public function get($key, array $replace = [], $locale = null, $fallback = true)
    {
        list($namespace, $group, $item) = $this->parseKey($key);

        // Here we will get the locale that should be used for the language line. If one
        // was not passed, we will use the default locales which was given to us when
        // the translator was instantiated. Then, we can load the lines and return.
        $locales = $fallback ? $this->localeArray($locale)
                             : [$locale ?: $this->locale];

        foreach ($locales as $locale) {
            if (! is_null($line = $this->getLine(
                $namespace, $group, $locale, $item, $replace
            ))) {
                break;
            }
        }

        // If the line doesn't exist, we will return back the key which was requested as
        // that will be quick to spot in the UI if language keys are wrong or missing
        // from the application's language files. Otherwise we can return the line.
        if (isset($line)) {
            return $line;
        }

        return $key;
    }

    protected function getLine($namespace, $group, $locale, $item, array $replace)
    {
        $this->load($namespace, $group, $locale);

        $line = Arr::get($this->loaded[$namespace][$group][$locale], $item);

        if (is_string($line)) {
            return $this->makeReplacements($line, $replace);
        } elseif (is_array($line) && count($line) > 0) {
            return $line;
        }
    }

}

首先,如果无法定时,直接返回$key。循环语言(指定语言和备用),所以主语言找不到,会去备用语言中找。注意这里的$this->parseKey($key),如果namespace为null,这个方法把其改为*。

具体实现在getLine()方法: 首先load()文件,三个情况,1 /lang/zh-CN.json 2 /lang/zh-CN/message.php 3 包语言文件(以及覆盖文件)。然后到文件中定位具体的key。比如是a => b.c, 那么a文件中应该有一个b数组,它有一个c元素。当然还包括占位符替换的处理。

翻译器get方法最终调用getLine()方法,而get方法都是先解析key的,意味着”hello word.”这样的key会被解析成[null,hello word,”],很明显,无法定位到,所以这里就要说到双下划线函数了:

if (! function_exists('__')) {
    /**
     * Translate the given message.
     *
     * @param  string  $key
     * @param  array  $replace
     * @param  string  $locale
     * @return string|array|null
     */
    function __($key, $replace = [], $locale = null)
    {
        return app('translator')->getFromJson($key, $replace, $locale);
    }
}

这个双下划线函数是从getFromJson()进入的:

    public function getFromJson($key, array $replace = [], $locale = null)
    {
        $locale = $locale ?: $this->locale;

        // For JSON translations, there is only one file per locale, so we will simply load
        // that file and then we will be ready to check the array for the key. These are
        // only one level deep so we do not need to do any fancy searching through it.
        $this->load('*', '*', $locale);

        $line = $this->loaded['*']['*'][$locale][$key] ?? null;

        // If we can't find a translation for the JSON key, we will attempt to translate it
        // using the typical translation file. This way developers can always just use a
        // helper such as __ instead of having to pick between trans or __ with views.
        if (! isset($line)) {
            $fallback = $this->get($key, $replace, $locale);

            if ($fallback !== $key) {
                return $fallback;
            }
        }

        return $this->makeReplacements($line ?: $key, $replace);
    }

先解析zh-CN.json,没有在调用get()。换个说法就是,双下划线函数总是去loader对应json文件,然后才开始常规定位。如果不需要考虑这个全局的json文件,可以使用trans()方法。

写了那么多,实际使用时,只要把全局的key(翻译文件),写入到json, 提炼出来的key写入到某个文件,使用双下划线函数加上完整的key(message.hello)即可搞定多语言问题。

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

发送邮件
事件