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