简介

Laravel 在 Symfony Process 组件的基础上,提供了一个简洁且富有表现力的 API,让您可以方便地在 Laravel 应用中调用外部进程。Laravel 的进程功能聚焦于最常见的用例,致力于提供出色的开发者体验。

调用进程

要调用进程,您可以使用 Process 门面提供的 run 和 start 方法。run 方法会调用进程并等待该进程执行完成,而 start 方法则用于异步执行进程。本文档将探讨这两种方式。首先,我们来看如何调用一个基础的同步进程并检查其结果:

use Illuminate\Support\Facades\Process;

$result = Process::run('ls -la');

return $result->output();

当然,run 方法返回的 Illuminate\Contracts\Process\ProcessResult 实例提供了多种实用方法,可用于检查进程结果:

$result = Process::run('ls -la');

$result->command();
$result->successful();
$result->failed();
$result->output();
$result->errorOutput();
$result->exitCode();

抛出异常

如果您有一个进程结果,并且希望当退出码大于零(即表示执行失败)时抛出 Illuminate\Process\Exceptions\ProcessFailedException 实例,可以使用 throw 和 throwIf 方法。如果进程没有失败,则会返回 ProcessResult 实例:

$result = Process::run('ls -la')->throw();

$result = Process::run('ls -la')->throwIf($condition);

进程选项

当然,您可能需要在调用进程前自定义进程的行为。好在 Laravel 允许您调整多种进程特性,例如工作目录、超时时间以及环境变量。

工作目录路径

您可以使用 path 方法指定进程的工作目录。如果未调用此方法,进程将继承当前正在执行的 PHP 脚本的工作目录:

$result = Process::path(__DIR__)->run('ls -la');

输入

您可以使用 input 方法,通过进程的“标准输入”提供输入内容:

$result = Process::input('Hello World')->run('cat');

超时设置

默认情况下,进程执行时间超过 60 秒后,会抛出 Illuminate\Process\Exceptions\ProcessTimedOutException 实例。不过,您可以通过 timeout 方法自定义此行为:

$result = Process::timeout(120)->run('bash import.sh');

timeout 和 idleTimeout 方法也支持接收 CarbonInterval 实例:

use function Illuminate\Support\minutes;

$result = Process::timeout(minutes(2))->run('bash import.sh');

或者,如果您希望完全禁用进程超时,可以调用 forever 方法:

$result = Process::forever()->run('bash import.sh');

idleTimeout 方法可用于指定进程在未返回任何输出的情况下,最长可运行的秒数:

$result = Process::timeout(60)->idleTimeout(30)->run('bash import.sh');

环境变量

可以通过 env 方法为进程提供环境变量。被调用的进程还会继承系统中定义的所有环境变量:

$result = Process::forever()
    ->env(['IMPORT_PATH' => __DIR__])
    ->run('bash import.sh');

如果您希望从被调用的进程中移除某个继承而来的环境变量,可以给该环境变量赋予 false 值:

$result = Process::forever()
    ->env(['LOAD_PATH' => false])
    ->run('bash import.sh');

TTY 模式

可以使用 tty 方法为您的进程启用 TTY 模式。TTY 模式会将进程的输入输出与您程序的输入输出相连接,从而让您能够在进程中打开像 Vim 或 Nano 这样的编辑器:

Process::forever()->tty()->run('vim');

Windows 不支持 TTY 模式。

进程输出

正如前文所述,可以通过进程结果上的 output(stdout)和 errorOutput(stderr)方法来获取进程输出:

use Illuminate\Support\Facades\Process;

$result = Process::run('ls -la');

echo $result->output();
echo $result->errorOutput();

不过,也可以通过将一个闭包作为第二个参数传递给 run 方法,实现实时收集输出。该闭包会接收两个参数:“输出类型”(stdout 或 stderr)以及输出字符串本身:

$result = Process::run('ls -la', function (string $type, string $output) {
    echo $output;
});

Laravel 还提供了 seeInOutput 和 seeInErrorOutput 方法,可便捷地判断给定字符串是否包含在进程输出中:

if (Process::run('ls -la')->seeInOutput('laravel')) {
    // ...
}

禁用进程输出

如果您的进程产生了大量您不关心的输出,可以通过完全禁用输出获取来节省内存。为此,在构建进程时调用 quietly 方法即可:

use Illuminate\Support\Facades\Process;

$result = Process::quietly()->run('bash import.sh');

管道

有时您可能希望将一个进程的输出作为另一个进程的输入,这通常被称为将进程的输出“管道”传递到另一个进程。Process 门面提供的 pipe 方法可轻松实现这一点。pipe 方法会同步执行管道中的进程,并返回管道中最后一个进程的结果:

use Illuminate\Process\Pipe;
use Illuminate\Support\Facades\Process;

$result = Process::pipe(function (Pipe $pipe) {
    $pipe->command('cat example.txt');
    $pipe->command('grep -i "laravel"');
});

if ($result->successful()) {
    // ...
}

如果您无需对构成管道的单个进程进行自定义,可以直接将命令字符串数组传递给 pipe 方法:

$result = Process::pipe([
    'cat example.txt',
    'grep -i "laravel"',
]);

将闭包作为第二个参数传递给 pipe 方法,可实时收集进程输出。该闭包会接收两个参数:“输出类型”(stdout 或 stderr)以及输出字符串本身:

$result = Process::pipe(function (Pipe $pipe) {
    $pipe->command('cat example.txt');
    $pipe->command('grep -i "laravel"');
}, function (string $type, string $output) {
    echo $output;
});

Laravel 还允许您通过 as 方法为管道中的每个进程分配字符串键。该键也会传递给提供给 pipe 方法的输出闭包,方便您判断输出属于哪个进程:

$result = Process::pipe(function (Pipe $pipe) {
    $pipe->as('first')->command('cat example.txt');
    $pipe->as('second')->command('grep -i "laravel"');
}, function (string $type, string $output, string $key) {
    // ...
});

异步进程

run 方法用于同步调用进程,而 start 方法可用于异步调用进程。这样一来,在进程于后台运行的同时,您的应用程序可以继续执行其他任务。进程调用完成后,您可以使用 running 方法判断进程是否仍在运行:

$process = Process::timeout(120)->start('bash import.sh');

while ($process->running()) {
    // ...
}

$result = $process->wait();

正如您可能注意到的那样,您可以调用 wait 方法,等待进程执行完毕后获取 ProcessResult 实例:

$process = Process::timeout(120)->start('bash import.sh');

// ...

$result = $process->wait();

进程 ID 与信号

可以使用 id 方法获取操作系统为运行中的进程分配的进程 ID:

$process = Process::start('bash import.sh');

return $process->id();

你可以使用 signal 方法向运行中的进程发送一个“信号”。预定义的信号常量列表可在PHP 文档中找到:

$process->signal(SIGUSR2);

异步进程输出

当异步进程正在运行时,你可以使用 output 和 errorOutput 方法访问其当前全部输出;不过,你也可以使用 latestOutput 和 latestErrorOutput 来获取自上次获取输出以来进程所产生的输出:

$process = Process::timeout(120)->start('bash import.sh');

while ($process->running()) {
    echo $process->latestOutput();
    echo $process->latestErrorOutput();

    sleep(1);
}

与 run 方法类似,通过将闭包作为 start 方法的第二个参数传入,也可以从异步进程中实时收集输出。该闭包会接收两个参数:输出的“类型”(stdout 或 stderr)以及输出字符串本身:

$process = Process::start('bash import.sh', function (string $type, string $output) {
    echo $output;
});

$result = $process->wait();

无需一直等待进程结束,你可以使用 waitUntil 方法,根据进程的输出来决定何时停止等待。当传给 waitUntil 方法的闭包返回 true 时,Laravel 将停止等待该进程完成:

$process = Process::start('bash import.sh');

$process->waitUntil(function (string $type, string $output) {
    return $output === 'Ready...';
});

异步进程超时

当异步进程正在运行时,你可以使用 ensureNotTimedOut 方法来验证进程是否尚未超时。如果进程已超时,此方法将抛出超时异常:

$process = Process::timeout(120)->start('bash import.sh');

while ($process->running()) {
    $process->ensureNotTimedOut();

    // ...

    sleep(1);
}

并发进程

Laravel 还可以轻松管理一组并发的异步进程,让你能便捷地同时执行多个任务。要开始使用,请调用 pool 方法,该方法接受一个闭包,该闭包会接收 Illuminate\Process\Pool 的实例。

在此闭包内,你可以定义属于该进程池的进程。通过 start 方法启动进程池后,你可以使用 running 方法访问正在运行的进程的集合:

use Illuminate\Process\Pool;
use Illuminate\Support\Facades\Process;

$pool = Process::pool(function (Pool $pool) {
    $pool->path(__DIR__)->command('bash import-1.sh');
    $pool->path(__DIR__)->command('bash import-2.sh');
    $pool->path(__DIR__)->command('bash import-3.sh');
})->start(function (string $type, string $output, int $key) {
    // ...
});

while ($pool->running()->isNotEmpty()) {
    // ...
}

$results = $pool->wait();

如你所见,你可以使用 wait 方法等待进程池中的所有进程执行完成,并获取它们的结果。wait 方法返回一个可访问的数组对象,你可以通过键访问池中每个进程的 ProcessResult 实例:

$results = $pool->wait();

echo $results[0]->output();

或者,为了方便起见,可以使用 concurrently 方法启动一个异步进程池,并立即等待其结果。与 PHP 的数组解构功能结合使用时,这种方式能提供特别简洁直观的语法:

[$first, $second, $third] = Process::concurrently(function (Pool $pool) {
    $pool->path(__DIR__)->command('ls -la');
    $pool->path(app_path())->command('ls -la');
    $pool->path(storage_path())->command('ls -la');
});

echo $first->output();

为进程池中的进程命名

通过数字键访问进程池结果的表达力不强;因此,Laravel 允许你使用 as 方法为进程池中的每个进程分配字符串键。该键也会传递给提供给 start 方法的闭包,以便你判断输出属于哪个进程:

$pool = Process::pool(function (Pool $pool) {
    $pool->as('first')->command('bash import-1.sh');
    $pool->as('second')->command('bash import-2.sh');
    $pool->as('third')->command('bash import-3.sh');
})->start(function (string $type, string $output, string $key) {
    // ...
});

$results = $pool->wait();

return $results['first']->output();

进程池的进程 ID 与信号

由于进程池的 running 方法会提供池中所有已调用进程的集合,因此你可以轻松访问底层的进程池进程 ID:

$processIds = $pool->running()->map->id();

并且,为了方便起见,你可以在进程池上调用 signal 方法,向池中的每个进程发送信号:

$pool->signal(SIGUSR2);

测试

许多 Laravel 服务都提供了便于你轻松且直观地编写测试的功能,Laravel 的进程服务也不例外。Process 门面的 fake 方法可让你指示 Laravel 在调用进程时返回桩(stubbed)/ 模拟(dummy)结果。

模拟进程

为了探索 Laravel 模拟进程的能力,我们设想一个会调用进程的路由:

use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Route;

Route::get('/import', function () {
    Process::run('bash import.sh');

    return 'Import complete!';
});

在测试该路由时,我们可以通过在 Process 门面上无参数调用 fake 方法,指示 Laravel 为每个被调用的进程返回一个模拟的成功进程结果。此外,我们甚至可以断言某个进程已被“运行”:

<?php

use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;

test('process is invoked', function () {
    Process::fake();

    $response = $this->get('/import');

    // Simple process assertion...
    Process::assertRan('bash import.sh');

    // Or, inspecting the process configuration...
    Process::assertRan(function (PendingProcess $process, ProcessResult $result) {
        return $process->command === 'bash import.sh' &&
               $process->timeout === 60;
    });
});
<?php

namespace Tests\Feature;

use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_process_is_invoked(): void
    {
        Process::fake();

        $response = $this->get('/import');

        // Simple process assertion...
        Process::assertRan('bash import.sh');

        // Or, inspecting the process configuration...
        Process::assertRan(function (PendingProcess $process, ProcessResult $result) {
            return $process->command === 'bash import.sh' &&
                   $process->timeout === 60;
        });
    }
}

如前所述,在 Process 门面上调用 fake 方法会指示 Laravel 始终返回一个无输出的成功进程结果。不过,你可以使用 Process 门面的 result 方法轻松指定模拟进程的输出和退出码:

Process::fake([
    '*' => Process::result(
        output: 'Test output',
        errorOutput: 'Test error output',
        exitCode: 1,
    ),
]);

模拟特定进程

正如你在之前的示例中可能注意到的,Process 门面允许你通过向 fake 方法传入一个数组,来为不同的进程指定不同的模拟结果。

该数组的键应代表你希望模拟的命令模式及其关联结果。* 字符可用作通配符。任何未被模拟的进程命令实际上都会被执行。你可以使用 Process 门面的 result 方法来构建这些命令的桩 / 模拟结果:

Process::fake([
    'cat *' => Process::result(
        output: 'Test "cat" output',
    ),
    'ls *' => Process::result(
        output: 'Test "ls" output',
    ),
]);

如果你不需要自定义模拟进程的退出码或错误输出,那么将模拟进程结果指定为简单的字符串可能会更方便:

Process::fake([
    'cat *' => 'Test "cat" output',
    'ls *' => 'Test "ls" output',
]);

模拟进程序列

如果你正在测试的代码使用相同的命令调用了多个进程,你可能希望为每次进程调用分配不同的模拟进程结果。你可以通过 Process 门面的 sequence 方法来实现这一点:

Process::fake([
    'ls *' => Process::sequence()
        ->push(Process::result('First invocation'))
        ->push(Process::result('Second invocation')),
]);

模拟异步进程生命周期

到目前为止,我们主要讨论的是通过 run 方法同步调用的进程模拟。不过,如果你想测试与通过 start 启动的异步进程交互的代码,可能需要用更精细的方式来描述模拟进程。

例如,假设有以下与异步进程交互的路由:

use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;

Route::get('/import', function () {
    $process = Process::start('bash import.sh');

    while ($process->running()) {
        Log::info($process->latestOutput());
        Log::info($process->latestErrorOutput());
    }

    return 'Done';
});

要准确地模拟此进程,我们需要能够指定 running 方法应返回 true 的次数。此外,我们可能还希望指定需要按顺序返回的多行输出。为此,我们可以使用 Process 门面的 describe 方法:

Process::fake([
    'bash import.sh' => Process::describe()
        ->output('First line of standard output')
        ->errorOutput('First line of error output')
        ->output('Second line of standard output')
        ->exitCode(0)
        ->iterations(3),
]);

让我们深入分析上面的示例。借助 output 和 errorOutput 方法,我们可以指定需要按顺序返回的多行输出。exitCode 方法可用于指定模拟进程的最终退出码。最后,iterations 方法可用于指定 running 方法应返回 true 的次数。

可用断言

如前所述,Laravel 为你的功能测试提供了若干进程断言。下面我们将逐一介绍这些断言。

assertRan

断言某个进程已被调用:

use Illuminate\Support\Facades\Process;

Process::assertRan('ls -la');

当进程是以参数数组的形式被调用时,你可以将相同的数组传给该断言:

Process::assertRan(['php', 'artisan', 'migrate']);

assertRanTimes 和 assertDidntRun 方法同样支持数组形式的命令。

assertRan 方法还接受一个闭包,该闭包会接收一个进程实例和一个进程结果,以便你检查该进程的配置选项。如果该闭包返回 true,断言即“通过”:

Process::assertRan(fn ($process, $result) =>
    $process->command === 'ls -la' &&
    $process->path === __DIR__ &&
    $process->timeout === 60
);

传递给 assertRan 闭包的 $process 是 Illuminate\Process\PendingProcess 的实例,而 $result 则是 Illuminate\Contracts\Process\ProcessResult 的实例。

assertDidntRun

断言某个进程未被调用:

use Illuminate\Support\Facades\Process;

Process::assertDidntRun('ls -la');

与 assertRan 方法类似,assertDidntRun 方法也接受一个闭包,该闭包会接收一个进程实例和一个进程结果,以便你检查该进程的配置选项。如果该闭包返回 true,断言即“失败”:

Process::assertDidntRun(fn (PendingProcess $process, ProcessResult $result) =>
    $process->command === 'ls -la'
);

assertRanTimes

断言某个进程被调用了指定次数:

use Illuminate\Support\Facades\Process;

Process::assertRanTimes('ls -la', times: 3);

assertRanTimes 方法也接受一个闭包,该闭包会接收 PendingProcess 和 ProcessResult 实例,以便你检查该进程的配置选项。如果该闭包返回 true,且该进程被调用了指定次数,断言即“通过”:

Process::assertRanTimes(function (PendingProcess $process, ProcessResult $result) {
    return $process->command === 'ls -la';
}, times: 3);

assertRanInOrder

断言进程是以指定顺序被调用的:

Process::assertRanInOrder([
    'git fetch',
    'composer install',
]);

与其它进程断言相同,assertRanInOrder 方法接受命令字符串、命令参数数组或闭包。

防止多余进程

如果你想确保在单个测试或整个测试套件中,所有被调用的进程都已被模拟,可以调用 preventStrayProcesses 方法。调用此方法后,任何没有对应模拟结果的进程都会抛出异常,而不是启动实际进程:

use Illuminate\Support\Facades\Process;

Process::preventStrayProcesses();

Process::fake([
    'ls *' => 'Test output...',
]);

// Fake response is returned...
Process::run('ls -la');

// An exception is thrown...
Process::run('bash import.sh');