标签归档:laravel

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',
    ],
];

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参数,指定在任务失败时,是否继续,如果是并行运行的,这个参数似乎是无效的。另外,任务如果在多台机器上运行,只有全部成功才认为成功,可能发生前一个任务成功,后一个任务失败,这个时候需要设置回调做善后工作。

Laravel 容器源码研究

<?php
//error_reporting(0);

include "vendor/autoload.php";

class A
{
    public $b = null;

    public $p = 0;

    public function __construct($b, $p)
    {
        $this->b = $b;
        $this->p = $p;
    }

    public function test()
    {
        echo "A-------\n";
        var_dump($this->p);
        $this->b->test();
    }
}

class B
{
    public $c = null;

    public $o = 0;

    public function __construct(C $c, $o = 0)
    {
        $this->c = $c;
        $this->o = $o;
    }

    public function test()
    {
        echo "B-------\n";
        if ($this->o) {
            echo 'Great 0-----';
        }
        $this->c->test();
    }
}

class C
{
    public function test()
    {
        echo "C-------\n";
    }
}

$container = new \Illuminate\Container\Container();
$p = 10101000;

// A类构造函数依赖形参$p(没有默认值),需要传递: 通过when()方法指定
$b = $container->make(B::class, ['o' => 1]);
$container->when(A::class)->needs('$p')->give($p);
$container->when(A::class)->needs('$b')->give($b);
//$container->when(A::class)->needs(B::class)->give(function () use ($b) {
//    return $b;
//});
$a = $container->make(A::class);

// A类构造函数依赖形参$p(没有默认值),需要传递:通过make()方法的第二参数传递
//$a = $container->make(A::class, ['p' => $p]);
$a->test();

dd((array)$container);

一、对象构建:

    public function make($abstract, array $parameters = [])
    {
        return $this->resolve($abstract, $parameters);
    }

方法make只是resolve()方法的包装而已(注意:make方法是公共的,而resolve是受保护的):

    protected function resolve($abstract, $parameters = [])
    {
	// 如果是别名,取回原来的名称:比如aa是a的别名,传递aa进来,返回a
	// 传递a进来,直接返回a
        $abstract = $this->getAlias($abstract);

        $needsContextualBuild = ! empty($parameters) || ! is_null(
            $this->getContextualConcrete($abstract)
        );

        // 在instances中,并且没有上下文对象,直接返回
        if (isset($this->instances[$abstract]) && ! $needsContextualBuild) {
            return $this->instances[$abstract];
        }

	// 把传递进来的参数进行临时保存(入栈)
        $this->with[] = $parameters;

        $concrete = $this->getConcrete($abstract);

        // 递归构建对象
        if ($this->isBuildable($concrete, $abstract)) {
            $object = $this->build($concrete);
        } else {
            $object = $this->make($concrete);
        }

        // 应用对象装饰
        foreach ($this->getExtenders($abstract) as $extender) {
            $object = $extender($object, $this);
        }

        // 如果是share类型,并且没有上下文依赖,就存入instances:并不是设置为share就一定进入instances
        if ($this->isShared($abstract) && ! $needsContextualBuild) {
            $this->instances[$abstract] = $object;
        }

	// 回调
        $this->fireResolvingCallbacks($abstract, $object);

        // 标记已经取回
        $this->resolved[$abstract] = true;

	// 出栈
        array_pop($this->with);

        return $object;
    }

类目传递了A,这时$abstract是A, $parameters是[‘p’ => $p],$abstract经过getAlias后没有发送变化(不是别名),由于参数不是空,$needsContextualBuild为true; $this->getConcrete($abstract)后得到的$concrete是$abstract本身(都是A),然后判断是否可以构建:

    protected function isBuildable($concrete, $abstract)
    {
        return $concrete === $abstract || $concrete instanceof Closure;
    }

是否可以构建,条件是$concrete和$abstract相等,或者$concrete是一个闭包函数。所以这里会进入build()方法:

    public function build($concrete)
    {
        // 如果是闭包,调用闭包函数:第一参数是容器,第二参数是外部传递进来的参数
        if ($concrete instanceof Closure) {
            return $concrete($this, $this->getLastParameterOverride());
        }
	// 不是闭包就利用反射:$concrete这里应该是类名(或类对象)
        $reflector = new ReflectionClass($concrete);

        // 如果不可实例化,抛异常
        if (! $reflector->isInstantiable()) {
            return $this->notInstantiable($concrete);
        }

	// 由于可能会依赖其它对象,这里先把当前类目入栈
        $this->buildStack[] = $concrete;
	// 取回构造函数
        $constructor = $reflector->getConstructor();

        // 构造函数为空,直接实例化
        if (is_null($constructor)) {
            array_pop($this->buildStack);

            return new $concrete;
        }
	// 否则就是指定了构造函数,那么解析这个构造函数,取回参数
        $dependencies = $constructor->getParameters();

        // 根据构造函数的参数,实例化依赖,这里可能会产生递归
	// 另外,如果外部有传递参数进来,会优先使用
        $instances = $this->resolveDependencies(
            $dependencies
        );
	// 出栈
        array_pop($this->buildStack);

	// 把实例对象传递构造函数生成对象
        return $reflector->newInstanceArgs($instances);
    }

这个构建的过程,利用反射,如果依赖其它对象,则递归构建依赖。 这里面用到了两个栈,with是参数堆栈,每次构建从其中取出参数,填充到构造函数,另一个是buildStack。这里可以看到A传递进来,它不是闭包,那么就开始了下面的反射构建过程,A是可以实例化的,有构造函数,那么会进入resolveDependencies()递归解决依赖:

    protected function resolveDependencies(array $dependencies)
    {
        $results = [];

        foreach ($dependencies as $dependency) {
            if ($this->hasParameterOverride($dependency)) {
                $results[] = $this->getParameterOverride($dependency);

                continue;
            }

            $results[] = is_null($dependency->getClass())
                            ? $this->resolvePrimitive($dependency)
                            : $this->resolveClass($dependency);
        }

        return $results;
    }

这里看明白如下几个函数:

    protected function hasParameterOverride($dependency)
    {
        return array_key_exists(
            $dependency->name, $this->getLastParameterOverride()
        );
    }

    protected function getParameterOverride($dependency)
    {
        return $this->getLastParameterOverride()[$dependency->name];
    }

    protected function getLastParameterOverride()
    {
        return count($this->with) ? end($this->with) : [];
    }

方法getLastParameterOverride取回with堆栈的头元素,就是传递进来的参数,方法hasParameterOverride就是判断传递进来的参数有没有对应名称的变量,方法getParameterOverride就是取到传递进来的值。在这,传递进来的参数是[‘p’ => $p], 那么在构建A时,取到形参p,p出现在了[‘p’ => $p]中,所以A构造函数的$p就等于传递进来的变量$p。如果没有对应的参数,就检查是否有指定上下文参数,有则使用,没有,如果是类就去实例类(可能递归),如果是原始变量,就使用默认值。

由于A构造函数需要B对象,而B对象又需要C对象,所以会进入递归,最终返回的对象传递到A构造函数。从这个过程可见,容器的make方法,可以自动构建对象。如果没有构造函数,或构造函数没有依赖其它对象,直接new即可。到这里,还有问题问解决,当构建A时,会去构建B,如果希望使用一个现成的B对象,这个需要依靠上下文绑定来解决。

二、上下文绑定:

//当解析A::class,如果需要B::class类型对象,那么对应的对象是$b。
$container->when(A::class)->needs(B::class)->give(function () use ($b) {
    return $b;
});
//当解析A::class,如果需要形参是$b时,那么对应的值是$b。
$container->when(A::class)->needs('$b')->give($b);

注意语法,当需要的是类类型时,需要给一个闭包函数,否则直接指定值。另外,如果构造函数依赖的是B $b,这个时候直接给值的语法可能是无作用的,因为如果有类型限定,并且类型是类类型,那么总是根据类来实例化对象,这个过程会取回闭包执行返回对象。

这个用法实际产生如下数组:

$this->contextual['A']['B'] = function () use ($b) {
    return $b;
};
$this->contextual['A']['$b'] = $b;

当A实例化是,遇到了需要实例化B,那么首先判断是否存在$this->contextual[‘A’][‘B’],如果存在直接用,否则就去构造。当遇到形参$b时,就去判断$this->contextual[‘A’][‘$b’]是否存在,存在直接用,否则利用反射API去使用默认值。

容器的when()方法不过是一个语法糖,最终会调用addContextualBinding()方法来设置上下绑定:

    public function addContextualBinding($concrete, $abstract, $implementation)
    {
        $this->contextual[$concrete][$this->getAlias($abstract)] = $implementation;
    }

可以这样用:

$container->addContextualBinding(A::class, B::class, function () use ($b) {
    return $b;
});
$container->addContextualBinding(A::class, '$b', $b);

这个上下文绑定,跟make()时传递的第二参数作用非常类似,不过make时传递的参数需要和构造函数的形参名称相同,并且总是优先使用的,比如:

$container->addContextualBinding(A::class, '$b', $b);

$container->make(A::class, ['$b' => $b]);

这里的设置了上下文,也传递了第二参数,那么make时传递的总是优先被使用。一旦设置了上下文,或make时传递了参数,那么生成的对象都是独立(不共享,不会压入$this->instances数组),哪怕设置的绑定是共享的,因为跟上下文相关的对象,是特定的。

上下文设置或构建时的参数传递严格意义上说并不是容器需要实现的内容。这里显然是为了能便利的构建依赖而引入的便利方法。

三、对象构建时的回调:
对象构建之后,可以设置回调,可以全局设置回调(每构建一个对象都调用),或者设置在构建某个类型的对象时才执行回调:

$container = new \Illuminate\Container\Container();
$callBack = function () {
    echo "Callback -------\n";
};

$globalAfterCallBack = function () {
    echo "Global After Callback -------\n";
};

$afterCallBack = function () {
    echo "After Callback -------\n";
};
$container->resolving($globalCallBack);
$container->resolving('a', $callBack);
$container->afterResolving($globalAfterCallBack);
$container->afterResolving('a', $afterCallBack);

四、对象装饰器:
对象构建后,需要执行对象的某个方法,或者运行某个逻辑来扩展一下这个对象,这个就是对象的装饰。在对象生成后,可以使用extend来装饰:

$container->extend(Service::class, function($service) {
    return new DecoratedService($service);
});

五、绑定
在理解了以上基本内容后,再来看看绑定,应该就很好理解了。

// 直接绑定一个类,可以这样做,除了类实例化会延后外,没有额外好处,一般应该避免这个用法
// 在需要是直接make即可
$container->bind(A::class);

// 设置服务,当make(‘a’)时,会实时生成一个A对象
$container->bind(‘a’, A::class);

// 绑定接口,AI实现了I的接口
$container->bind(‘I’, AI::class);

// 设置服务,指定闭包函数,多见
$container->bind(‘events’, function () {});

主要到以上用法尽管都是合法的,但是绑定闭包这种用法比较多见。另外,bing的第三参数控制这个bind是否是共享的,也就是生成实例是否是单例的,bind方法默认是不共享的,有一个快捷方法singleton()可以直接设置共享绑定。

如果绑定是共享的,生成的实例会压入instances数组,表示这些是共享对象,所以有一个方法,可以往这个数组中直接压入内容进行全局共享:instance($key, $instance)。

接下来看看具体过程:

    public function bind($abstract, $concrete = null, $shared = false)
    {
        $this->dropStaleInstances($abstract);

        if (is_null($concrete)) {
            $concrete = $abstract;
        }

        if (! $concrete instanceof Closure) {
            $concrete = $this->getClosure($abstract, $concrete);
        }

        $this->bindings[$abstract] = compact('concrete', 'shared');

        if ($this->resolved($abstract)) {
            $this->rebound($abstract);
        }
    }

简单来说就是对一个类型设置绑定,第二参数是要绑定的内容,第三参数设置是否共享,它决定了对象是否是单例对象。首先调用的$this->dropStaleInstances($abstract),对应的方法实际就是清空该类型对应的绑定和对应的实例:

    protected function dropStaleInstances($abstract)
    {
        unset($this->instances[$abstract], $this->aliases[$abstract]);
    }

如果第二参数是null(没有传递需要绑定的内容),那么绑定的内容就是类型本身。如果绑定的内容如果不是一个闭包函数,那么就调用getClosure()把内容变成一个闭包函数:

    protected function getClosure($abstract, $concrete)
    {
        return function ($container, $parameters = []) use ($abstract, $concrete) {
            if ($abstract == $concrete) {
                return $container->build($concrete);
            }

            return $container->make($concrete, $parameters);
        };
    }

这个getClosure()返回的是一个闭包函数,返回的这个闭包函数接受两个参数($container, $parameters = []),如果类型和内容一样,就直接build,否则就是make一个对象,总之现在知道其返回的是function ($container, $parameters = [])即可。

接下来按照一个统一的格式设置绑定:

$this->bindings[$abstract] = compact('concrete', 'shared');

if ($this->resolved($abstract)) {
    $this->rebound($abstract);
}

// 举例来说
$this->bindings[‘a’] = [
    ‘concrete’ => function ($container, $parameters = []) {},
    ‘shared’ => false
];

那么在a实例被生成时,concrete对应的闭包函数会被执行,其中会根据shared来决定是否设置共享实例。resolved()方法用来判断绑定是否已经生成了实例,如果生成了实例,调用rebound()方法重新生成一次(实际是调用make方法,不过,在重写生成对象时,可以对特定类型设置回调,这个方法会执行回调)。

绑定的其它情况分析:
第二参数只要不是闭包函数,那么总会调用getClosure()方法封装为一个闭包函数:
1 当第二参数为空时,把第一参数赋值给第二参数,调用getClosure()方法

class A {}

$container->bind(A::class);
// $abstract = $concrete = A::class
$container->bind['A'] = function ($container, $parameters = []) use ($abstract, $concrete) {
     if ($abstract == $concrete) {
         return $container->build($concrete);
     }

     return $container->make($concrete, $parameters);
};
// 此时获取该服务跟直接new基本一样
// 不一样的是实例生成时,如果构造函数依赖容器对象,可以自动注入

2 当是一个对象时,调用getClosure()方法

class A {}

$container->bind('aa', new A());
// $abstract = 'aa'; $concrete = new A();
$container->bind['aa'] = function ($container, $parameters = []) use ($abstract, $concrete) {
     if ($abstract == $concrete) {
         return $container->build($concrete);
     }

     return $container->make($concrete, $parameters);
};
// 在实例化时会运行$container->make('aa', []), ‘aa’对应的闭包函数被运行,该函数内再次运行$container->make($concrete, $parameters);
// 此时的$concrete是一个对象,PHP的isset($this->aliases[$concrete])会报警告,但不会影响程序运行
// 第二次运行的make最终会到达build函数,这是的build不过是根据$concrete(此时是一个对象)并应用反射,重新生成一个对象而已

所以这两种用法都应该避免。相反,在使用bind时,应该总是使用闭包。

另外,第二参数是闭包时,这个闭包函数在定义参数时注意:

$container->bind('aa', function ($a, $b, $c) {});

如果调用make时,将会报错。因为闭包调用时总是如此格式:

return $concrete($this, $this->getLastParameterOverride());

闭包可以不定义参数,以上的用法不会发生错误。如果定义了参数,第一个参数总是代表容器实例本身,第二个参数是一个数组(可不定义)用来从外传入数据到闭包中。如果多于2个参数就发生错误。如果定义了第二参数,那么在make对象时,就可以传递数据进去(注:如果是单例实例,一旦生成后不会再改变,除非强制rebund,所以不能期望通过传递参数改变单例对象的状态)

最终提炼一下公共方法:

public function when($concrete)

// 服务是否已经设置
public function bound($abstract)

// 方法bound的别名
public function has($id)

// 服务是否已经实例化
public function resolved($abstract)

// 服务是否是共享的(单例)
public function isShared($abstract)

// 名称是否是别名
public function isAlias($name)

// 服务绑定
public function bind($abstract, $concrete = null, $shared = false)

public function hasMethodBinding($method)
public function bindMethod($method, $callback)
public function callMethodBinding($method, $instance)

// 上下文绑定
public function addContextualBinding($concrete, $abstract, $implementation)

// 条件绑定,bind方法的再封装
public function bindIf($abstract, $concrete = null, $shared = false)

// 单例绑定
public function singleton($abstract, $concrete = null)

// 服务器装饰器
public function extend($abstract, Closure $closure)

// 直接注入内容到容器
public function instance($abstract, $instance)

public function tag($abstracts, $tags)
public function tagged($tag)

// 服务别名设置
public function alias($abstract, $alias)

// 绑定回调设置
public function rebinding($abstract, Closure $callback)

public function refresh($abstract, $target, $method)
public function wrap(Closure $callback, array $parameters = [])
public function call($callback, array $parameters = [], $defaultMethod = null)

// 用一个闭包封装make
public function factory($abstract)

// 方法make的另一种形式
public function makeWith($abstract, array $parameters = [])

// 构建服务
public function make($abstract, array $parameters = [])
// 
public function get($id)

// 构建服务
public function build($concrete)

public function resolving($abstract, Closure $callback = null)
public function afterResolving($abstract, Closure $callback = null)

public function getBindings()
public function getAlias($abstract)
public function forgetExtenders($abstract)
public function forgetInstance($abstract)
public function forgetInstances()
public function flush()

实际常用的就是几个方法。

Laravel Scout使用实例

Laravel Scout是从Laravel 5.3引入的一个实现全文搜索的扩展包。

以下实例构建过程:

1 建立目录,安装框架
mkdir scout
cd scout
composer create-project --prefer-dist laravel/laravel .

vi app/Providers/AppServiceProvider.php
#对应添加
use Illuminate\Support\Facades\Schema;

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Schema::defaultStringLength(191);
    }
}

#
vi config/database.php
'mysql' => [
    'strict' => false, // true改为false
],

2 安装扩展包
composer require laravel/scout
composer require algolia/algoliasearch-client-php

3 配置laravel/scout
vi config/app.php
#添加服务提供者
'providers' => [
    ....
    Laravel\Scout\ScoutServiceProvider::class,
]
#产生配置文件config/scout.php
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

4 配置algolia驱动
到https://www.algolia.com/api-keys获取秘钥(先注册一个账户,选择香港节点),获取Application ID对应ALGOLIA_APP_ID, Admin API Key对应ALGOLIA_SECRET,然后按照如下格式写入.env文件:
ALGOLIA_APP_ID=Application ID
ALGOLIA_SECRET=ALGOLIA_SECRET

5 建表和模型
php artisan make:migration create_items_table

#编辑database/migrations的迁移文件:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateItemsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('items', function (Blueprint $table) {
           $table->increments('id');
           $table->string('title');
           $table->timestamps();
       });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::drop("items");
    }
}
#编辑.env,填写数据库相关信息

#运行迁移命令,开始建表
php artisan migrate

#建立模型
vi app/Item.php
<?php
namespace App;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Item extends Model
{

    use Searchable;

    public $fillable = ['title'];

    /**
     * 获取模型的索引名称
     *
     * @return string
     */
    public function searchableAs()
    {
        return 'items_index';
    }
}

6 添加路由
vi routes/web.php
Route::get('items-lists', ['as'=>'items-lists','uses'=>'ItemSearchController@index']);
Route::post('create-item', ['as'=>'create-item','uses'=>'ItemSearchController@create']);

7 添加控制器
vi app/Http/Controllers/ItemSearchController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Requests;
use App\Item;

class ItemSearchController extends Controller
{

    /**
     * items列表
     */
    public function index(Request $request)
    {
        if($request->has('titlesearch')){
            $items = Item::search($request->titlesearch)
                     ->paginate(6);
        }else{
            $items = Item::paginate(6);
        }
        return view('item-search',compact('items'));
    }


    /**
     * 创建新的item
     */
    public function create(Request $request)
    {
        $this->validate($request,['title'=>'required']);

        $items = Item::create($request->all());
        return back();
    }
}

8 添加视图
vi resources/views/item-search.blade.php

<!DOCTYPE html>
<html>
    <head>
        <title>Laravel 5.3 - laravel scout algolia search example</title>
        <link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
    </head>
    <body>
        <div class="container">
            <h2>Laravel Full Text Search using Scout and algolia</h2><br/>
            <form method="POST" action="{{ route('create-item') }}" autocomplete="off">
            @if(count($errors))
            <div class="alert alert-danger">
                <strong>Whoops!</strong> There were some problems with your input.
                <br/>
                <ul>
                @foreach($errors->all() as $error)
                    <li>{{ $error }}</li>
                @endforeach
                </ul>
            </div>
            @endif

            <input type="hidden" name="_token" value="{{ csrf_token() }}">

            <div class="row">
                <div class="col-md-6">
                    <div class="form-group {{ $errors->has('title') ? 'has-error' : '' }}">
                        <input type="text" id="title" name="title" class="form-control" placeholder="Enter Title" value="{{ old('title') }}">
                        <span class="text-danger">{{ $errors->first('title') }}</span>
                    </div>
                </div>
                <div class="col-md-6">
                    <div class="form-group">
                        <button class="btn btn-success">Create New Item</button>
                    </div>
                </div>
            </div>
        </form>

        <div class="panel panel-primary">
            <div class="panel-heading">Item management</div>
                <div class="panel-body">
                    <form method="GET" action="{{ route('items-lists') }}">
                    <div class="row">
                        <div class="col-md-6">
                            <div class="form-group">
                                <input type="text" name="titlesearch" class="form-control" placeholder="Enter Title For Search" value="{{ old('titlesearch') }}">
                            </div>
                        </div>
                        <div class="col-md-6">
                            <div class="form-group">
                                <button class="btn btn-success">Search</button>
                            </div>
                        </div>
                    </div>
                    </form>

                    <table class="table table-bordered">
                        <thead>
                             <th>Id</th>
                             <th>Title</th>
                             <th>Creation Date</th>
                             <th>Updated Date</th>
                        </thead>
                        <tbody>
                        @if($items->count())
                            @foreach($items as $key => $item)
                            <tr>
                                <td>{{ ++$key }}</td>
                                <td>{{ $item->title }}</td>
                                <td>{{ $item->created_at }}</td>
                                <td>{{ $item->updated_at }}</td>
                            </tr>
                            @endforeach
                        @else
                            <tr>
                                 <td colspan="4">There are no data.</td>
                            </tr>
                        @endif
                        </tbody>
                    </table>
                    {{ $items->links() }}
                </div>
            </div>
        </div>
    </body>
</html>

启动HTTP服务器(可以直接使用php artisan serve),访问http://localhost:8000/items-lists:

如果item表已经存在数据,可以:php artisan scout:import “App\Item”,这样就会把数据推送到algolia,尤其里建索引。可以开启队列的方式让其进行异步推送。具体可参考文档。

这里的全文搜索方式的实现是利用了离线搜索服务商-algolia,由于没有在中国大陆部署节点(在香港有节点),所以访问会比较慢(涉及数据推送与查询)。这种离线搜索服务大多是收费的,不过相比自己搭建维护服务器集群,成本还是低很多的。

国内的用户可以试试阿里云的OpenSearch,也是离线搜索服务。

Laravel 5.x – 起步

一 安装
1 服务器要求
官方文档推荐使用Laravel Homestead作为本地开发环境(Mac推荐使用Valet作为本地开发环境)。Laravel Homestead是一个虚拟机镜像文件,基于Ubuntu,里面安装了所有必须软件(比如Nginx/PHP/MySQL等)。否则,开发环境需要满足:
PHP版本 >= 5.6.4
PHP扩展 OpenSSL,加密用到,并且只支持使用OpenSSL
PHP扩展 PDO,数据库链接只通过PDO进行链接,不支持其它方案,比如专门针对MySQL的mysqli驱动
PHP扩展 Mbstring,Multi-Byte String,PHP中的多字节字符串处理
PHP扩展 Tokenizer,这个扩展一般默认都会安装,详细可参看PHP文档
PHP扩展 XML

注:或者已经注意到,Laravel并不要求PHP的Session扩展,对的,它确实不需要,Laravel自己实现来一套Session管理。

2 安装Laravel
PHP中的生态系统的繁荣,composer功不可没,现代的PHP应用几乎都使用composer来管理依赖。简单来说,composer就是一个命令,这个命令本身就是使用PHP来编写的,它是一个phar压缩包,它会去分析composer.json中的配置,把所有依赖下载到vendor目录,并在vendor目录下提供了autoload.php,只要包含它就可以自动装载类(更多内容需要参看composer官方网站:getcomposer.org)。

可以直接到https://getcomposer.org/download/下载composer,比如当期最新版本为:https://getcomposer.org/download/1.4.1/composer.phar,对于Window,可以下载Windows安装包,它会帮我们设置环境变量,设置代理等,建议使用。

在Linux下,一般建议把composer.phar安装到/usr/local/bin中,并把composer.phar改名为composer,赋予可执行权限,这样composer就是全局可用的了(/user/local/bin默认在path变量中)。

由于直接链接都国外地址下载软件包比较慢,所以要愉快使用Composer,还需要做一些配置,有两种方式可选:
1 设置代理
2 设置composer使用国内镜像
可以在C:\Users\Administrator\AppData\Roaming\Composer中放入config.json(Linux:~/.config/composer/config.json),也可以在具体的项目的composer.json中手动添加:

{
    "config": {},
    "repositories": {
        "packagist": {
            "type": "composer",
            "url": "https://packagist.phpcomposer.com"
        }
    }
}

这里的配置指向了国内镜像,优先使用本地缓存。以上的内容也可以通过命令行完成:

#添加全局的config.json
composer config -g repo.packagist composer https://packagist.phpcomposer.com

#在具体的项目下运行(自动在composer.json文件中添加内容
composer config repo.packagist composer https://packagist.phpcomposer.com

全局的config.json会和具体项目中的composer.json文件合并(具体的会覆盖全局的)。

现在composer已经安装好。接下来安装Laravel:
1)通过Laravel安装器安装

composer global require "laravel/installer"

Laravel提供了一个安装器(实际就是一个程序包),通过composer安装到全局,然后调用这个安装器提供的命令行工具安装Laravel。

在CentOS 7.x中,全局包安装到了$home/.config/composer/verdor,所以为了全局可以使用verdor/bin里面的命令行工具,需要把这个路径添加到path中:

echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/binll]

#临时生效
export /home/www/.config/composer/vendor/bin:$PATH

#永久生效
vi .bashrc
export /home/www/.config/composer/vendor/bin:$PATH
source .bashrc

然后运行laravel new安装Laravel应用:

laravel new blog

2)通过Composer直接安装
相比通过Laravel安装器安装(链接上laravel的一个服务,类似通过代理链接composer官方代码仓库),直接使用Composer来安装就相对简单直接一些:

composer create-project --prefer-dist laravel/laravel blog

#指定安装版本
composer create-project --prefer-dist laravel/laravel blog 5.3.*

注:可以使用PHP内置的开发环境服务器来运行应用,Laravel提供了支持:

php artisan serve
Laravel development server started: <http://127.0.0.1:8000>

不过,与其间接这样整,不如直接运行php命令:

php -S 0.0.0.0:8000 -t /home/www/blog/public

如果不是使用PHP内置的应用服务器来运行Laravel,就需要配置HTTP服务器执行public目录,其中的index.php作为前端控制器,是所有请求的入口。

需要注意的是:storage 和 bootstrap/cache 目录应该是可写的;如果通过安装器或composer直接安装,那么根目录下.env文件已经自动生成,并且其中的APP_KEY已经产生,否则就需要手动产生,拷贝.env.example为.env,运行php artisan key:generate自动生成。

以下是一个Nginx配置例子(来自Homestead的默认配置):

server {
    listen 80;
    #listen 443 ssl http2;
    server_name blog.app;
    root "/var/www/blog/public";

    index index.html index.htm index.php;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  /var/log/nginx/blog.app-error.log error;

    sendfile off;

    location ~ \.php$ {
        client_max_body_size 64M;
	fastcgi_intercept_errors off;
        fastcgi_connect_timeout 300;
        fastcgi_send_timeout 300;
        fastcgi_read_timeout 300;
        fastcgi_buffer_size 32k;
        fastcgi_buffers 64 32k;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }

    #ssl_certificate     /etc/nginx/ssl/blog.app.crt;
    #ssl_certificate_key /etc/nginx/ssl/blog.app.key;
}

二、配置
Larave的配置放在config目录下。

1 环境配置
Laravel 使用 Vance Lucas 开发的 PHP 库 DotEnv 来实现这一机制,在新安装的 Laravel 中,根目录下有一个 .env.example 文件,如果 Laravel 是通过 Composer 安装的,那么该文件已经被重命名为 .env,否则的话你要自己手动重命名该文件。(还可以创建一个 .env.testing 文件,该文件会在运行 PHPUnit 测试或执行带有 –env=testing 选项的 Artisan 命令时覆盖从 .env 文件读取的值。)

在应用每次接受请求时,.env 中列出的所有配置及其值都会被载入到 PHP 超全局变量 $_ENV 中,然后你就可以在应用中通过辅助函数 env 来获取这些配置值。

'debug' => env('APP_DEBUG', false),

注:.env不能提交给版本库;获取环境配置的值,应该总是在配置文件中进行(调用env()函数),因为配置可缓存;.env.example是一个模板文件,所有可用配置应该都写入模板文件。

当前应用环境由 .env 文件中的 APP_ENV 变量决定,可以通过App门面的environment方法来访问其值:

$environment = App::environment();

#也可以向 environment 方法中传递参数来判断当前环境是否匹配给定值
if (App::environment('local')) {
    // The environment is local
}

if (App::environment('local', 'staging')) {
    // The environment is either local OR staging...
}

配置值的访问可以使用config函数在任意位置访问配置值:

$value = config('app.timezone');

#动态改变配置值
config(['app.timezone' => 'America/Chicago']);

可以对config中的配置文件合并为一个文件:

#本地开发环境就不需要这样用
php artisan config:cache

生成环境中,尽管已经把配置缓存了(生成了一个文件),但每个请求都要读一次这个根本不改变的配置文件还是比较耗费资源的,完全可以写入共享内存,具体可以参考:http://blog.ifeeline.com/2157.html

维护模式:

php artisan down

php artisan up

返回503。当处于维护模式中时,所有的队列任务都不会执行

三、应用目录结构
https://laravel.com/docs/5.4/structure(http://laravelacademy.org/post/6681.html)

四、Homestead
这部分内容参考:http://blog.ifeeline.com/2524.html

虽然实际上并不使用Homestead作为开发环境,但是需要提一下的是,Laravel本身是一个框架,除了框架,还提供了之外的一系列工具(Homestead是一个),而且这些工具都不错,这个是其它框架无法达到的。

五、Valet

Laravel容器与服务提供者

Laravel容器继承图:
laravel-container
这里的Illuminate\Container类实现了Illuminate\Contracts\Container\Container和ArrayAccess接口提供的方法,Illuminate\Foundation\Application实现了Illuminate\Contracts\Foundation\Application接口提供的方法。注:Illuminate\Contracts\Foundation\Application接口继承自Illuminate\Contracts\Container\Container,大体上从语义上,Application也必须是一个容器,要实现容器提供的方法。

在一个服务提供者中,可以通过$this->app变量来访问到应用容器。也可以在任何地方使用app()全局方法获取到。应用容器提供了很多方法,主要分两类:一类是基本容器方法 、一类是服务提供者容器方法。

Laravel中对于容器中的对象叫服务,一般可以认为是一个类名(或者是接口名,用接口名对应具体的类),由于类名可能比较长,所以引入了别名,别名就是一个映射关系。

一般情况,一个服务要可用,首先需要绑定到容器,容器根据绑定生成实例:

$app = app();

$app->bind(‘Ifeeline\Test’, function($app){
	return new Ifeeline\TestInstance();
});

当调用make()时就会取出绑定,然后生成实例。具体实现上就稍微复杂一些:

public function bind($abstract, $concrete = null, $shared = false)
{
    // If the given types are actually an array, we will assume an alias is being
    // defined and will grab this "real" abstract class name and register this
    // alias with the container so that it can be used as a shortcut for it.
    if (is_array($abstract)) {
        list($abstract, $alias) = $this->extractAlias($abstract);

        $this->alias($abstract, $alias);
    }

    // If no concrete type was given, we will simply set the concrete type to the
    // abstract type. This will allow concrete type to be registered as shared
    // without being forced to state their classes in both of the parameter.
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) {
        $concrete = $abstract;
    }

    // If the factory is not a Closure, it means it is just a class name which is
    // bound into this container to the abstract type and we will just wrap it
    // up inside its own Closure to give us more convenience when extending.
    if (! $concrete instanceof Closure) {
        $concrete = $this->getClosure($abstract, $concrete);
    }

    $this->bindings[$abstract] = compact('concrete', 'shared');

    // If the abstract type was already resolved in this container we'll fire the
    // rebound listener so that any objects which have already gotten resolved
    // can have their copy of the object updated via the listener callbacks.
    if ($this->resolved($abstract)) {
        $this->rebound($abstract);
    }
}

第一个参数可以是一个数组,用来指定类型的同时指定别名,举例:
Ifeeline\ExampleClass 对应别名为 example-class:

// 别名类似
// use XXX\YY as XX
$aliases[‘example-class’] = ‘Ifeeline\ExampleClass’;

$app->bind([‘Ifeeline\ExampleClass’ => ‘example-class’], function($app) {});

第二参数可以是空,也可以是字符串,或者是闭包、第三参数表明这个绑定生成的对象是否是共享的。

如果第二参数未提供,就是直接实例化类。如果提供了字符串,就是实例化这个类型的对象。如果是闭包,对象的实例化由该闭包完成。

对于希望用一个接口对应某个实现的情况:

$this->app->bind('接口', '接口实现类');

从实现上可以看到,一个bind()调用实际是产生如下内容:

$this->bindings[‘类名’] = [
‘concrete’ => ,
‘shared’ =>
];

其中concrete必定对应一个闭包函数,这个闭包函数第一个参数是容器实例,第二参数是生成实例时需要用到的参数,所以,如果自己传递一个闭包进去,可以这样:

$app->bind(‘类名’, function($app, $params) {}); 

如果调用bind()时第二参数不是闭包则会自动构建一个闭包函数,只是这个闭包函数第二参数为空而已。自动产生的闭包已经指定了如何产生对象,比如如果bind()未提供第二参数,就是直接调用$app->build()生成对象而已,如果提供了则使用$app->make()来生成对象。

容器的build和make方法是不同的,build()方法是根据反射来生成对象,具体来说是取出构造方法,然后解析构造方法的依赖,自动注入然后实例化对象。而make()方法直接从已有实例中返回实例,如果没有则从binding中取回concrete,由它来决定接下来是执行build方法还是make方法(递归)。总体来说,最终产生对象的,还是会调用到build方法。make方法最后还会根据binding中的shared来决定这个实例是否放入$this->instances,一旦放入这个数组,那么以后都用它。

如果需要产生一个单例绑定,可以使用$app->singlton(),它实际是bind()方法的封装而已。

如果希望把一个实例直接放入$instances数组,可以用$app->instance(‘类目’, new Object())。

如果要直接产生一个类的实例,不需要首先绑定,直接调用make即可。换句话说,绑定是在需要把接口对应到实现类,或者需要对生成的实例做配置时(传递闭包)才需要这样做。

不管信不信,对于一个容器,就只有这些内容。其它的都是一些扩展。比如绑定可以做所谓的上下文绑定,绑定分标签,容器事件等。

接下来就是需要知道在框架初始化的时候,绑定是怎么写入进来的,这个就是ServiceProvider的概念。

框架初始化流程:
实例化Application后,建设两个单例Kernel绑定到容器,然后调用Kernel的handle方法,这个方法最终会调用到Kernel的bootstrap()方法,这个方法最终把它自己的bootstrap数组(bootstrappers方法返回)传递到容器的bootstrapWith
方法,这个方法循环实例化从Kernel传递过来的bootstrap数组,然后调用其bootstrap方法,Kernel传递过来的bootstrap数组在其父类中(子类可覆盖):

//Illuminate\Foundation\Http\Kernel
protected $bootstrappers = [
    'Illuminate\Foundation\Bootstrap\DetectEnvironment',
    'Illuminate\Foundation\Bootstrap\LoadConfiguration',
    'Illuminate\Foundation\Bootstrap\ConfigureLogging',
    'Illuminate\Foundation\Bootstrap\HandleExceptions',
    'Illuminate\Foundation\Bootstrap\RegisterFacades',
    'Illuminate\Foundation\Bootstrap\RegisterProviders',
    'Illuminate\Foundation\Bootstrap\BootProviders',
];

//Illuminate\Foundation\Console\Kernel
protected $bootstrappers = [
    'Illuminate\Foundation\Bootstrap\DetectEnvironment',
    'Illuminate\Foundation\Bootstrap\LoadConfiguration',
    'Illuminate\Foundation\Bootstrap\ConfigureLogging',
    'Illuminate\Foundation\Bootstrap\HandleExceptions',
    'Illuminate\Foundation\Bootstrap\RegisterFacades',
    'Illuminate\Foundation\Bootstrap\SetRequestForConsole',
    'Illuminate\Foundation\Bootstrap\RegisterProviders',
    'Illuminate\Foundation\Bootstrap\BootProviders',
];

Console的Kernel多了一个SetRequestForConsole。这里主要关注Illuminate\Foundation\Bootstrap\RegisterProviders类:

<?php

namespace Illuminate\Foundation\Bootstrap;

use Illuminate\Contracts\Foundation\Application;

class RegisterProviders
{
    /**
     * Bootstrap the given application.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return void
     */
    public function bootstrap(Application $app)
    {
        $app->registerConfiguredProviders();
    }
}

仅仅调用了容器的registerConfiguredProviders()方法,继续跟踪这个方法:

// Illuminate\Foundation\Application
public function registerConfiguredProviders()
{
    $manifestPath = $this->getCachedServicesPath();

    (new ProviderRepository($this, new Filesystem, $manifestPath))
                ->load($this->config['app.providers']);
}

这里的$this->getCachedServicesPath()返回bootstrap/cache/services.json路径,然后由Illuminate\Foundation\ProviderRepository类实例来加载ServiceProvider。

首先去检测bootstrap/cache/services.json文件是否存在,如果存在并且与$this->config[‘app.providers’]比较无变化则直接使用这个结果,否则就循环$this->config[‘app.providers’]:

foreach ($providers as $provider) {
//创建实例
    $instance = $this->createProvider($provider);

    // When recompiling the service manifest, we will spin through each of the
    // providers and check if it's a deferred provider or not. If so we'll
    // add it's provided services to the manifest and note the provider.
    if ($instance->isDeferred()) {
        foreach ($instance->provides() as $service) {
            $manifest['deferred'][$service] = $provider;
        }

        $manifest['when'][$provider] = $instance->when();
    }

    // If the service providers are not deferred, we will simply add it to an
    // array of eagerly loaded providers that will get registered on every
    // request to this application instead of "lazy" loading every time.
    else {
        $manifest['eager'][] = $provider;
    }
}

这里是判断服务提供者是否是延时的(就是用到是才实例化服务),如果是就调用provides()来获取延时服务,添加到$manifest[‘deferred’][$service]中,其值是服务提供者的名称,表示这个服务由它提供。并且调用when()方法,用来实现当某事件触发时,实例化这个服务提供者。最后把非延时的服务提供者放入$manifest[‘eager’]。

然后把$manifest数组保存到bootstrap/cache/services.json,下次就继续使用这个数组,看起来是这个样子:

{
    "providers": [
        "Illuminate\\Foundation\\Providers\\ArtisanServiceProvider",
    ],
    "eager": [
        "Illuminate\\Auth\\AuthServiceProvider",
    ],
    "deferred": {
        "command.app.name": "Illuminate\\Foundation\\Providers\\ArtisanServiceProvider",
    },
    "when": {
        "Illuminate\\Foundation\\Providers\\ArtisanServiceProvider": [],
    }
}

取回这个ServiceProvider数组后,会执行如下代码:

//Illuminate\Foundation\ProviderRepository 的load()方法
foreach ($manifest['when'] as $provider => $events) {
    $this->registerLoadEvents($provider, $events);
}

// We will go ahead and register all of the eagerly loaded providers with the
// application so their services can be registered with the application as
// a provided service. Then we will set the deferred service list on it.
foreach ($manifest['eager'] as $provider) {
    $this->app->register($this->createProvider($provider));
}

$this->app->addDeferredServices($manifest['deferred']);

循环when,注册when中注册的事件,实现当某时间触发时实例化此服务提供者并调用其register方法。然后循环调用eager中的服务提供者的register方法。最后把延时服务合并到容器的deferredServices中。

从这个过程可见,服务提供者的provides方法暴露了其提供的服务,对一个延时服务提供者来说是必须的。同样,为了实现某些事件触发时对应的延时服务可用,可以定义when方法。另外,如果一个服务提供者是延时的,那么它提供的所有服务都是延时的,实例化一个延时服务时,在同一个延时服务提供者提供的服务都会实例化,因为实例化一个延时服务实际调用服务提供者的register方法。

最后,服务提供者的boot方法被调用(延时服务提供者则在register后被调用)

关于延时服务的生成:

public function make($abstract, array $parameters = [])
{
    $abstract = $this->getAlias($abstract);

    if (isset($this->deferredServices[$abstract])) {
        $this->loadDeferredProvider($abstract);
    }

    return parent::make($abstract, $parameters);
}

当make一个延时服务时,首先把服务提供者实例调用register方法,然后就跟一般的make无异。

服务提供者的register方法主要提供服务,往容器中注入绑定(不完全是)。举例如下:

protected function registerManager()
{
    $this->app->singleton('queue', function ($app) {
        // Once we have an instance of the queue manager, we will register the various
        // resolvers for the queue connectors. These connectors are responsible for
        // creating the classes that accept queue configs and instantiate queues.
        $manager = new QueueManager($app);

        $this->registerConnectors($manager);

        return $manager;
    });

    $this->app->singleton('queue.connection', function ($app) {
        return $app['queue']->connection();
    });
}

这样,如果需要queue实例,只需要$app->make(‘queue’)即可。

对应非延时服务,注册到容器中的仅仅是绑定,只有真需要的时候才会产生实例,避免了用不到也实例化。另外,对应延时服务,则是在用到时才实例化服务提供者,然后才开始做绑定,最后才实例化,所有对于使用概率较低的服务,可以把其变为延时服务。

Laravel 数据统计实例

以下程序大概对几百万数据进行汇总统计,依赖关系数据库而不是在程序中进行计算,所以需要先导入数据。

<?php
/* 统计订单重复购买率等
 * 
 * 用法:
 * 1 数据导入(定位文件:storage/app/orders/amazon/default/2015_01.txt)
 * php artisan statistic:order:purchase:rate --platform=amazon --account=default --year=2016 --month=01
 *  
 * 2 数据统计 (1月份)
 * php artisan statistic:order:purchase:rate --platform=amazon --account=default --year=2016 --month=01 --fire
 * 
 * 3 数据统计 (全年,--month=all表示全年)
 * php artisan statistic:order:purchase:rate --platform=amazon --account=default --year=2016 --month=all --fire
 * 
 */

namespace App\Console\Commands;

use Illuminate\Console\Command;

use DB,Storage,Validator;

class StatisticOrderPurchaseRate extends Command
{
    protected $signature = 'statistic:order:purchase:rate {--platform=} {--account=} {--year=} {--month=} {--fire}';
    protected $description = '';
    
    public function __construct()
    {       
        parent::__construct();
    }

    public function handle()
    {   
        $prefix = \DB::connection()->getTablePrefix();
        
        $platform = strtolower(trim($this->option('platform')));
        if(!in_array($platform, ['amazon', "ebay"])) {
            echo "平台必须是:amazon 和 ebay";
            return;
        }
        
        $account = strtolower(trim($this->option('account')));
        if(empty($account)) {
            $account = "default";
        }
        
        $year = strtolower(trim($this->option('year')));
        $month = strtolower(trim($this->option('month')));
        
        if(empty($year) || empty($month)) {
            echo "年 月必须指定,用来定位文件";
            return;
        }
        
        $fire = $this->option('fire');
        if(empty($fire)) {
            $file = "orders/".$platform."/".$account."/".$year."_".$month.".txt";
            
            if(!Storage::exists($file)){
                echo "数据文件无法定位:$file";
                return;
            }
            
            $filePath = storage_path("app/".$file);
            
            // 读取数据文件  一次性读完,然后批量插入数据库
            if($platform === "amazon") {
                $fp = fopen($filePath,"r");
                $data = array();
                while($line = fgetcsv ($fp, 0, "\t", ' ')) {
                    if(!isset($line[0])) { continue; }
                    
                    $order_id = $line[0];
                    if($order_id === 'amazon-order-id') {
                        continue;
                    }
                    $email = $line[10];
                    $total = round($line[17],2);
                    
                    if(isset($data[$order_id])) {
                        $data[$order_id]["total"] += $total;
                    } else {
                        $data[$order_id] = [
                            "platform" => $platform,
                            "account" => $account,
                            "year" => $year,
                            "month" => $month,
                            "order_id" => $order_id,
                            "email" => $email,
                            "total" => $total
                        ];
                    }
                }
                fclose($fp);
                
                // 数据分块批量插入数据库
                if(\DB::statement("DELETE FROM ".$prefix."order_summary WHERE platform='".$platform."' and account='".$account."' and year='".$year."' and month='".$month."'")) {
                    
                    // 分块插入
                    $chunks = array_chunk($data, 1000);
                    foreach($chunks as $chunk) {
                        \DB::table('order_summary')->insert($chunk);
                    }
                    
                }            
            } else {
                // 
            }
        
        } else {
            
            if($month === 'all') {
                // 客单价
                $total_order = DB::select("SELECT sum(total) as total_sale, count(order_id) as total_order, (sum(total)/count(order_id)) as avg_price,
                count(distinct email) as total_customer
                FROM ".$prefix."order_summary
                WHERE platform='".$platform."' and account='".$account."' and year='".$year."' and email != ''");
            } else {
                // 客单价
                $total_order = DB::select("SELECT sum(total) as total_sale, count(order_id) as total_order, (sum(total)/count(order_id)) as avg_price,
                count(distinct email) as total_customer
                FROM ".$prefix."order_summary
                WHERE platform='".$platform."' and account='".$account."' and year='".$year."' and month='".$month."' and email != ''"); 
            }
            
            $has = DB::table("order_lookup")->where("platform", $platform)->where("account", $account)
            ->where("year", $year)->where("month", $month)->first();
            
            if(isset($has->id)) {
                \DB::table("order_lookup")->where("platform", $platform)->where("account", $account)
                ->where("year", $year)->where("month", $month)->update([
                    "total_sale" => $total_order[0]->total_sale,
                    "total_order" => $total_order[0]->total_order,
                    "avg_price" => $total_order[0]->avg_price,
                    "total_customer" => $total_order[0]->total_customer
                ]);
            } else {
                \DB::table("order_lookup")->insert([
                    "platform" => $platform,
                    "account" => $account,
                    "year" => $year,
                    "month" => $month,
                    "total_sale" => $total_order[0]->total_sale,
                    "total_order" => $total_order[0]->total_order,
                    "avg_price" => $total_order[0]->avg_price,
                    "total_customer" => $total_order[0]->total_customer
                ]);
            }
            
            if($month === 'all') {
                // 获取重复购买
                $repeate = DB::table("order_summary")->where("platform", $platform)->where("account", $account)
                ->where("year", $year)->where("email", "!=", "")->groupBy("email")->havingRaw("count(order_id) > 1")->get(["email"]); 
            } else {
                // 获取重复购买
                $repeate = DB::table("order_summary")->where("platform", $platform)->where("account", $account)
                ->where("year", $year)->where("month", $month)->where("email", "!=", "")->groupBy("email")->havingRaw("count(order_id) > 1")->get(["email"]);
            }
            $repeate_total = count($repeate);
            
            if($total_order[0]->total_customer > 0) {
                \DB::table("order_lookup")->where("platform", $platform)->where("account", $account)
                ->where("year", $year)->where("month", $month)->update([
                    "total_customer_repeat" => $repeate_total,
                    "total_customer_uniq" => (int)($total_order[0]->total_customer - $repeate_total),
                    "total_customer_repeat_rate" => round($repeate_total/$total_order[0]->total_customer,5)
                ]);
            }
        }
    }
}
/*
SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for order_summary
-- ----------------------------
DROP TABLE IF EXISTS `order_summary`;
CREATE TABLE `order_summary` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `platform` varchar(32) COLLATE utf8_unicode_ci DEFAULT NULL,
  `account` varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL,
  `year` varchar(16) COLLATE utf8_unicode_ci DEFAULT NULL,
  `month` varchar(16) COLLATE utf8_unicode_ci DEFAULT NULL,
  `order_id` varchar(32) COLLATE utf8_unicode_ci DEFAULT NULL,
  `email` varchar(128) COLLATE utf8_unicode_ci DEFAULT NULL,
  `total` decimal(10,2) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_order` (`platform`,`account`,`year`,`month`,`order_id`),
  KEY `account` (`account`) USING BTREE,
  KEY `year` (`year`) USING BTREE,
  KEY `month` (`month`) USING BTREE,
  KEY `email` (`email`) USING BTREE,
  KEY `platform` (`platform`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1108415 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

-- ----------------------------
-- Table structure for order_lookup
-- ----------------------------
DROP TABLE IF EXISTS `order_lookup`;
CREATE TABLE `order_lookup` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `platform` varchar(32) COLLATE utf8_unicode_ci DEFAULT NULL,
  `account` varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL,
  `year` varchar(16) COLLATE utf8_unicode_ci DEFAULT NULL,
  `month` varchar(16) COLLATE utf8_unicode_ci DEFAULT NULL,
  `total_sale` decimal(10,2) DEFAULT NULL,
  `total_order` int(11) DEFAULT NULL,
  `avg_price` decimal(10,2) DEFAULT NULL COMMENT '客单价(总销售/总订单)',
  `total_customer` int(11) DEFAULT NULL,
  `total_customer_uniq` int(11) DEFAULT NULL COMMENT '没有重复购买',
  `total_customer_repeat_rate` decimal(10,4) DEFAULT NULL,
  `total_customer_repeat` int(11) DEFAULT NULL COMMENT '有重复购买',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
*/

PHP Laravel Excel

PHP Laravel Excel官方网址:http://www.maatwebsite.nl/laravel-excel/docs;

GitHub: https://github.com/Maatwebsite/Laravel-Excel;
Packagist: https://packagist.org/packages/maatwebsite/excel

这个程序包依赖PHPOffice/PHPExcel,实际就是在这个工具包上做了一些易于用户操作的封装。需要说明的是,目录依赖的PHPOffice/PHPExcel版本是1.8.*(也是当前的稳定版),在1.9.*和2.0.*中开始引入PHP的命名空间,意味着PHP版本至少要5.3以上,这两个分支还在开发中,看起来这个包的作者非常的保守。

安装就按照Laravel套路来就好:

#往composer.json中添加"maatwebsite/excel": "~2.1.0",然后update

#添加ServiceProvider
vi config/app.php
'Maatwebsite\Excel\ExcelServiceProvider',

#添加Facade(可选)
'Excel' => 'Maatwebsite\Excel\Facades\Excel',

#配置(会添加excel.php配置文件)
php artisan vendor:publish --provider="Maatwebsite\Excel\ExcelServiceProvider"

#获取excel实例
$excel = App::make('excel');

Maatwebsite/excel本身有一个默认的配置文件,如果应用级别有配置文件,那么应用配置将覆盖默认配置配置,具体实现是在ExcelServiceProvider中:

    public function boot()
    {
        $this->publishes([
            __DIR__ . '/../../config/excel.php' => config_path('excel.php'),
        ]);

        $this->mergeConfigFrom(
            __DIR__ . '/../../config/excel.php', 'excel'
        );

        //Set the autosizing settings
        $this->setAutoSizingSettings();
    }

表格操作,主要涉及输入和输出。

Excel::load('file.xls', function($reader) {

    // Getting all results
    $results = $reader->get();

    // ->all() is a wrapper for ->get() and will work the same
    $results = $reader->all();

});

使用get和all方法获取结果,默认,如果表格只有一个sheet,那么直接返回行集合,如果有多个sheet,那么返回sheet的集合,每个sheet又是行的集合。为了统一操作,可以设置配置文件中的force_sheets_collection设置为true,这样都会返回sheet的集合。

表格sheet的第一行是头部,默认会被转换成slug(别名),可以设置import.heading为false表示不使用文件头,可用值true|false|slugged|slugged_with_count|ascii|numeric|hashed|trans|original,设置为original表示使用字面值作为key,这个比较常见。

Sheet/行/单元格都是集合,使用了get()之后,就可以使用集合的方法。

#
$reader->get()->groupBy('firstname');

#依赖force_sheets_collection,可能返回第一个sheet或第一个行
$reader->first();
// Get workbook title
$workbookTitle = $reader->getTitle();

foreach($reader as $sheet)
{
    // get sheet title
    $sheetTitle = $sheet->getTitle();
}

// You can either use ->take()
$reader->take(10);

// Or ->limit()
$reader->limit(10);

// Skip 10 results
$reader->skip(10);

// Skip 10 results with limit, but return all other rows
$reader->limit(false, 10);

// Skip and take
$reader->skip(10)->take(10);

// Limit with skip and take
$reader->($skip, $take);

$reader->toArray();

$reader->toObject();

// Dump the results
$reader->dump();

// Dump results and die
$reader->dd();

#也可以使用foreach
// Loop through all sheets
$reader->each(function($sheet) {

    // Loop through all rows
    $sheet->each(function($row) {

    });

});

选择Sheet和列

#仅加载sheet1
Excel::selectSheets('sheet1')->load("xx.xls", function($reader) {});
Excel::selectSheets('sheet1', 'sheet2')->load();

#通过下标选择比较靠谱
// First sheet
Excel::selectSheetsByIndex(0)->load();

// First and second sheet
Excel::selectSheetsByIndex(0, 1)->load();

#选择列,很熟悉的用法?
#All get methods (like all(), first(), dump(), toArray(), ...) accept an array of columns.
// Select
$reader->select(array('firstname', 'lastname'))->get();

// Or
$reader->get(array('firstname', 'lastname'));

日期:
By default the dates will be parsed as a Carbon object.

分批导入:

Excel::filter('chunk')->load('file.csv')->chunk(250, function($results)
{
        foreach($results as $row)
        {
            // do stuff
        }
});

每次读入250行,处理完毕在导入250行??

批量导入:

Excel::batch('app/storage/uploads', function($rows, $file) {

    // Explain the reader how it should interpret each row,
    // for every file inside the batch
    $rows->each(function($row) {

        // Example: dump the firstname
        dd($row->firstname);

    });

});

$files = array(
    'file1.xls',
    'file2.xls'
);

Excel::batch($files, function($rows, $file) {

});

Excel::batch('app/storage/uploads', function($sheets, $file) {

    $sheets->each(function($sheet) {

    });

});

导出也有很多定制化的操作,参考:http://www.maatwebsite.nl/laravel-excel/docs/export

例子:

// 关联数组,输出表头
$excel_array = [
    [
        "表头1" => "xxxx",
        "表头2" => "yyyy"
    ],
    [
        "表头1" => "xxxx2",
        "表头2" => "yyyy3"
    ]
];
// 直接数据输入
$excel_array2 = [
    [
        "表头1", "表头2"
    ],
    [
        "xxxx", "yyyy"
    ],
    [
        "xxxx2", "yyyy3"
    ]
];

        \Excel::create("test1", function ($excel) use($excel_array) {
            $excel->sheet('sheet1', function ($sheet) use($excel_array) {
                $sheet->fromArray($excel_array);
            });
        })->store("xls","d:/");
        
        \Excel::create("test2", function ($excel) use($excel_array2) {
            $excel->sheet('sheet1', function ($sheet) use($excel_array2) {
                $sheet->fromArray($excel_array2, null, 'A1', false, false);
            });
        })->save("xls");

默认,如果不指定导入的路径,会保存到storage_path(‘exports’),即app/storage/exports。可以修改配置文件export.store.path的值。

导出的sheet,默认第一行总是头部,这个可以修改配置文件的export.generate_heading_by_indices为false取消这个默认值。也可以指定fromArray的第5参数为false达到同样目的。

store()的第一参数是导入文件的类型,第二参数是路径(不需要包含文件名),第三参数控制是否返回保存文件的信息(比如保存的路径,扩展名等)。

Laravel 任务调度

Laravel中提供了一个任务调度实现,基本实现原理大体并不复杂,依赖操作系统的Crontab服务,每分钟运行一次schedule:run命令,然后判断到期任务,需要注意的是,如果schedule:run命令不是每分钟执行一次,那么调度就有可能有问题,比如:

0-59/3 * * * * 		php /mnt/www/ebt/artisan schedule:run 1>> /dev/null 2>&1

如下调度:

    protected function schedule(Schedule $schedule)
    {
        $schedule->command('test:test2')
                 ->cron('0-59/2 * * * *')
		 ->sendOutputTo("/home/www/schedule.txt");
    }

这个调度本意是每两分钟执行一次,而调度命令是3分钟执行一次,所以无法符合预期。从输出的情况来看,30分和36分执行了一次test:test2命令,那么就基本可以肯定,所谓的调度,实际就是根据当前时间,比对分钟数字,比如设置每两分钟执行一次,那么就比对调度命令被运行时的分钟数是不是2的倍数,其它的情况也是类似的。这个是最简单的实现方案了。所以,调度程序运行需要设置为每分钟执行一次。

以下示例:

<?php

namespace App\Console;

use DB;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel{
    /**
     * 应用提供的Artisan命令
     *
     * @var array
     */
    protected $commands = [
        'App\Console\Commands\Inspire',
    ];

    /**
     * 定义应用的命令调度
     *
     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        // 闭包
        $schedule->call(function () {
            DB::table('recent_users')->delete();
        })->daily();

        // Artisan命令
        $schedule->command('emails:send --force')->daily();

        // 系统命令
        $schedule->exec('node /home/forge/script.js')->daily();
    }
}

可用方法列表:

->cron('* * * * *');
->everyMinute();
->everyFiveMinutes();
->everyTenMinutes();
->everyThirtyMinutes();
->hourly();
->daily();
->dailyAt('13:00');
->twiceDaily(1, 13);
->weekly();
->monthly();

额外的调度约束列表:
->weekdays();
->sundays();
->mondays();
->tuesdays();
->wednesdays();
->thursdays();
->fridays();
->saturdays();
->when(Closure);

大体上,对应Crontab的设置。这里的when()方法接收一个闭包函数,当这个函数返回true时,才执行。如果使用Crontab调度,就需要在运行脚本里面做控制。

由于这个任务调度器实际就是一个启动其它命令的命令,所以对于判断重复运行是很容易做到的,比如上一次运行没有结束,那么当前虽然也满足了运行的条件,也不去运行它,这个在Crontab中也是做不到的(需要在实际运行命令中控制):

$schedule->command('emails:send')->everyMinute()->withoutOverlapping();

方法withoutOverlapping()控制重复运行。

运行结果输出(结果输出到指定文件):

$schedule->command('emails:send')
         ->daily()
         ->sendOutputTo($filePath);

// 发邮件,需要先调用sendOutputTo
// emailOutputTo和sendOutputTo方法只对command方法有效,不支持call方法
$schedule->command('foo')
         ->daily()
         ->sendOutputTo($filePath)
         ->emailOutputTo('foo@example.com');

预留钩子:

$schedule->command('emails:send')
         ->daily()
         ->before(function () {
             // Task is about to start...
         })
         ->after(function () {
             // Task is complete...
         });

Laravel 中使用 Redis

Laravel中对Redis的访问是通过predis/predis实现的,这个包是纯PHP实现(注意:PHP中有一个redis扩展实现,如果追求高效,可以使用它)。

安装predis/predis

php composer.phar require predis/predis

Laravel本身提供了一个叫Redis的Facade:

Redis      redis       Illuminate\Redis\Database

Illuminate\Redis\Database是一层简单的封装,内部使用Predis\Client(predis/predis包提供)。大概看看serviceProvider:

<?php

namespace Illuminate\Redis;

use Illuminate\Support\ServiceProvider;

class RedisServiceProvider extends ServiceProvider
{
    /**
     * Indicates if loading of the provider is deferred.
     *
     * @var bool
     */
    protected $defer = true;

    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton('redis', function ($app) {
            return new Database($app['config']['database.redis']);
        });
    }

    /**
     * Get the services provided by the provider.
     *
     * @return array
     */
    public function provides()
    {
        return ['redis'];
    }
}

首先,这是一个延时服务(defer为true)。说明实际使用到时才实例化。redis对应一个Illuminate\Redis\Database实例。可以看到,它单独放入了Redis目录,可见这个东西确实是一个比较特殊的东西,不太好跟其它的东西并列。

针对Redis的配置,在config/database.php中用redis键配置:

    'redis' => [

        'cluster' => false,

        'default' => [
            'host'     => '127.0.0.1',
            'port'     => 6379,
            'database' => 0,
        ],
        'cache' => [
            'host'     => '127.0.0.1',
            'port'     => 6380,
            'database' => 0,
        ],
        'session' => [
            'host'     => '127.0.0.1',
            'port'     => 6381,
            'database' => 0,
        ],
    ]

很明显,可以设置多个配置。如果需要使用Redis作为缓存,需要修改config/cache.php:

'default' => env('CACHE_DRIVER', 'redis'),

'stores' => [
        'redis' => [
            'driver' => 'redis',
            'connection' => 'default',
        ],
],

其中的connection对应的default,就是database.php中redis的default,可以为cache专门指定一个链接(Redis实例)。

如果要把Session数据存储到Redis中,可以修改session.php文件:

'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => 'session', // 对应Redis的配置

使用上,很简单,通过Redis进行:

Redis::get($key);
Redis::keys("*");
Redis::set($key,$value);
Redis::exists($key);

基本上,取决于你对Redis的熟悉程度了。

另外,PHP中的redis扩展会在全局空间中产生一个叫redis的类,这个名称和这里的Redis冲突,所以,如果安装redis扩展,你需要到app.php中将Redis别名改为其它的。