Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Adds Process convenience layer 🧘🏻 #43977

Open
wants to merge 72 commits into
base: 9.x
Choose a base branch
from
Open

Conversation

nunomaduro
Copy link
Member

@nunomaduro nunomaduro commented Sep 2, 2022

This pull request proposes an expressive, minimal API around Symfony's Process, allowing you to quickly run processes in your Laravel application. Just like the HTTP facade, this pull request introduces a Process facade that is focused on the most common use cases, testing, and a wonderful developer experience.

usage

Running Processes

To run a process, you may use the run method provided by the Process facade. Let's examine how to run a basic ls command:

use Illuminate\Support\Facades\Process;

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

$result->ok(); // true
$result->output(); // my-file-1, my-file-2

The run method returns an instance of Illuminate\Console\Contracts\ProcessResult, which provides a variety of methods that may be used to inspect the process result:

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

$result->output(): string;
$result->errorOutput(): string;
$result->exitCode(): int;
$result->ok(): bool;
$result->failed(): bool;

The Illuminate\Console\Contracts\ProcessResult object also implements the PHP ArrayAccess interface, allowing you to iterate over each line of the output. The output, is "exploded" via the \n character:

$files = Process::run('ls');

foreach ($files as $file) {
    dd($file); my-file-1
}

$files[0]; // my-file-1
$files[1]; // my-file-2

Accessing process's output at real-time

By default, when using the output method, Laravel waits for the process to be finished before giving you the entire process's output:

$result = Process::run('echo 1; sleep 1; echo 2');

dd($result->output()); // 1, 2

If you wish to access the process's output at real-time, you may use a closure as second argument of the run method:

$result = Process::run('echo 1; sleep 1; echo 2', function ($output) {
    dump($output); // Runs twice, outputting 1 and 2 respectively ...
});

dd($result->output()); // 1, 2

In addition, if you with to know if the given output is from the type stderr or stderr, you may use the second argument of given closure:

Process::run('echo 1; sleep 1; echo 2', function ($output, $type) {
    dump($type == "out"); // true
    dump($type == "err"); // false
});

Dumping processes

If you would like to dump the outgoing process instance before it is sent and terminate the script's execution, you may add the dump or dd method to the beginning of your process definition:

Process::dd()->run('ls');

// ^ Illuminate\Console\Process^ {#662
//  -commandline: "ls"
//  -cwd: "/Users/nunomaduro/Work/Code/laravel"
// ...

Options

Of course, it is common when running processes to specify multiple command arguments, configure the path, timeouts, and more.

Process Command

When running processes, you may pass an string or an array of strings as the first argument to the run method:

Process::run('ls -la');
Process::run(['ls', '-la', 'other-option']);

Process Path

You may use the path method if you would like to specify the working directory / base path of the process:

$path = storage_path('images');

Process::path($path)->run('ls'); // image-1.jpg, image-2.jpg

Process Timeout

The timeout method may be used to specify the maximum number of seconds to wait for a process:

Process::timeout(3)->run('sleep 2');

If the given timeout is exceeded, an instance of Illuminate\Console\Process\Exceptions\ProcessTimedOutException will be thrown.

try {
    Process::timeout(3)->run('sleep 4');
} catch (ProcessTimedOutException $e) {
    dd($e->result()->exitCode()); // 143
}

If you don't care about the time a process takes to run, and you would just like a process to run forever, you may use the forever method:

Process::forever()->run('something that takes minutes');

Process Exceptions

If you have a process result and would like to throw an instance of Illuminate\Console\Exceptions\ProcessFailedException if the exit code indicates an error, you may use the throw, throwIf, or throwUnless methods:

$result = Process::run('exit 1');
$result->exitCode(); // 1 

// Throw an exception if an error occurred...
try {
    $result->throw();
}  catch (ProcessFailedException $e) {
    dd($e->result()->exitCode()); // 1
}
 
// Throw an exception if an error occurred and the given condition is true...
$result->throwIf($condition);
 
// Throw an exception if an error occurred and the given condition is false...
$result->throwUnless($condition);

The throw method returns a regular result instance if no error occurred, allowing you to chain other operations onto the throw method:

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

$result->throw()->output(); // my-file-1, my-file-2

If you would like to perform some additional logic before the exception is thrown, you may pass a closure to the throw, throwIf, or throwUnless methods. The exception will be thrown automatically after the closure is invoked, so you do not need to re-throw the exception from within the closure:

return Process::run('exit 1');->throw(function (ProcessFailedException $e) {
    // Perform some clean-up ...
})->ok();

Concurrent Processes

Sometimes, you may wish to run multiple processes requests concurrently. In other words, you want several processes to be dispatched at the same time instead of issuing the processes sequentially. This can lead to substantial performance improvements when interacting with slow processes.

Thankfully, you may accomplish this using the pool method. The pool method accepts a closure which receives an Illuminate\Console\Process\Pool instance, allowing you to easily add processes to the process pool for dispatching:

$results = Process::pool(fn (Pool $pool) => [
    $pool->run('sleep 1'),
    $pool->run('sleep 1'),
    $pool->run('sleep 1'),
]); // 1 second

return $results[0]->ok() &&
       $results[1]->ok() &&
       $results[2]->ok();

Asynchronous Processes

By default, processes are synchronous, meaning the method run will automatically wait for the process to be finished before returning the process's result. Yet, if you need to run processes asynchronously while performing other tasks in your code, you may use the async method:

$result = Process::async()->run(/* ... */);

// Do other tasks...

return $result->wait()->output();

Macros

The Process's factory is also "macroable", meaning that you may define the macro within the boot method of your application's App\Providers\AppServiceProvider class:

use Illuminate\Support\Facades\Process;
 
/**
 * Bootstrap any application services.
 *
 * @return void
 */
public function boot()
{
    Process::macro('ls', fn () => Process::command('ls'));
}

Once your macro has been configured, you may invoke it from anywhere in your application to create a pending process with the specified configuration:

Process::ls()->run();

Testing

The Process facade's fake method allows you to instruct Laravel to return stubbed / dummy results when processes are made.

For example, to instruct the Process to return successful empty result, you may call the fake method with no arguments:

use Illuminate\Support\Facades\Process;
 
Process::fake();
 
$result = Process::run(/* ... */);
$result->ok(); // true
$result->output(); // ''

Faking specific processes

Alternatively, you may pass an array to the fake method. The array's keys should represent command patterns that you wish to fake and their associated results. The * character may be used as a wildcard character. Any processes made to Process commands that have not been faked will actually be executed. You may use the Process facade's result method to construct stub / fake results for these processes:

Process::fake([
    'ls' => Process::result(['file-1.php', 'file-2.php'),
    'curl *' => Process::result(json_encode(['item'])),
]);

If you would like to specify a fallback command pattern that will stub all unmatched commands, you may use a single * character:

Process::fake([
    'ls' => Process::result(['file-1.php', 'file-2.php'),

    '*' => Process::result(),
]);

Fake callback

If you require more complicated logic to determine what results to return for certain processes, you may pass a closure to the fake method. This closure will receive an instance of Illuminate\Console\Process and should return a result instance. Within your closure, you may perform whatever logic is necessary to determine what type of result to return:

Process::fake(fn () => Process::result('An error message', 1));

Inspecting Processes

When faking processes, you may occasionally wish to inspect the processes in order to make sure your application is sending the correct commands. You may accomplish this by calling the Process::assertRan method after calling Process::fake.

The assertRan method accepts a closure which will receive an Illuminate\Console\Process instance and should return a boolean value indicating if the process matches your expectations. In order for the test to pass, at least one request must have been issued matching the given expectations:

use Illuminate\Support\Facades\Process;
 
Process::fake();
 
Process::timeout(50)->run('sleep 30');
 
Process::assertRan(function (\Illuminate\Console\Process $process) {
    return $process->command() == 'sleep 30') && $process->timeout() == 30.0;
});

Process::assertRan('sleep 30');

If needed, you may assert that a specific process was not ran using the assertNotRan method:

use Illuminate\Support\Facades\Process;
 
Process::fake();
 
Process::run('ls');
 
Process::assertNotRan(function (\Illuminate\Console\Process $process) {
    return $process->command() == 'ls -la');
});

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

You may use the assertRanCount method to assert how many processes were "ran" during the test:

Process::fake();
 
Process::assertRanCount(5);

Or, you may use the assertNothingRan method to assert that no processes were ran during the test:

Process::fake();
 
Process::assertNothingRan();

@nunomaduro nunomaduro requested a review from taylorotwell Sep 2, 2022
@nunomaduro nunomaduro self-assigned this Sep 2, 2022
@timacdonald
Copy link
Member

timacdonald commented Sep 5, 2022

@nunomaduro loving where this is headed.

@nunomaduro nunomaduro changed the title [9.x] Adds Process convenience layer [9.x] Adds Process convenience layer Sep 5, 2022
@nunomaduro nunomaduro changed the title [9.x] Adds Process convenience layer [9.x] Adds Process convenience layer ⚗️ Sep 5, 2022
@nunomaduro nunomaduro changed the title [9.x] Adds Process convenience layer ⚗️ [9.x] Adds Process convenience layer 🧘🏻 Sep 5, 2022
@whoami-idk
Copy link

whoami-idk commented Sep 6, 2022

add some methods to run tasks in the background or get the proccess pid.

@ralphjsmit
Copy link
Contributor

ralphjsmit commented Sep 7, 2022

First of all, I just saw this PR and I really love it. Thanks for the great implementation!

You showed that the ->throw() method receives a closure to handle some addition logic before an exception is thrown. Developers don't need to re-throw the exception again, because it is automatically thrown after the closures has been called.

Personally I think that it would be somewhat more flexible to not automatically throw the exception after executing the closure. That would allow developers to provide just a closure and handle the exception themselves, without throwing (e.g. using report()). Sometimes a command isn't really that important. That's why I'd propose 1) to not automatically throw the exception when there is a closure passed, or 2) to add a method like ->catch() that receives a closure as well, but doesn't throw the exception after executing the closure.

@ralphjsmit
Copy link
Contributor

ralphjsmit commented Sep 7, 2022

Could be that you already planned on doing this bc it's still a draft, but an interesting idea would be to add a Process::sequence() method to fake a sequence of process results, similar to the Http::sequence().

@nunomaduro nunomaduro marked this pull request as ready for review Sep 7, 2022
@DarkGhostHunter
Copy link
Contributor

DarkGhostHunter commented Sep 9, 2022

  1. I would change "path" for "atPath".
  2. Can a process receive a signal?
  3. Does have checks on the process output? Like "contains", "missing", "findLine" and so forth? Because most of the time I will need the output to check if something was handled properly.

@timacdonald
Copy link
Member

timacdonald commented Sep 13, 2022

@ralphjsmit changing the throwing logic would make it inconsistent with the Http client. I think it might be nice if it was consistent and perhaps had additional affodances to handle other cases.

@aneesdev
Copy link

aneesdev commented Sep 19, 2022

Name a better love story than @nunomaduro x artisan console ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants