📘

Laravelでシェルコマンドを実行する方法

2021/08/24に公開

とあるプロジェクトで、コントローラの中でシェルコマンドを実行する必要があります。

基本的にPHPのexec, systemなどの関数を使うことで実行は可能ですが、他にLaravelに導入されているSymfonyのProcess Componentを使うやり方もあります。

exec系関数について

exec[系関数](PHP: Program execution Functions - Manual)ではいくつか選択肢がありますが、主にshell_exec, system, passthru, proc_open/proc_closeとあります。

exec関数、定義は以下となります。

exec(string $command, array &$output = null, int &$result_code = null): string|false

単純に入力されたコマンドを実行します。多くの場合はこちらを使うでしょう。

リターン値はstring|falseですが、実行成功の場合のstringがstdoutの最後の一行目の内容となります。失敗するとfalseがリターン。$output変数を入れることでアウトプットを取得可能ですが、配列としてリターンされるため、implode(PHP_EOL, $output)とかで文字列に変換するところが必要かもしれません。

また、要注意するのは、もしエラー時のエラー情報も取りたい場合、コマンドの最後にstderrのリダイレクト2>&1をつけましょう。$output変数はstdoutしか取得しないため、リダイレクトしないとエラーの場合は何も返ってきません。例えば:

try {
    $cmd = 'schtasks /run /tn task_name 2>&1'; // stderr->stdoutリダイレクト
    exec($cmd, $output, $code);
    $msg = implode(PHP_EOL, $output); // 場合によってUTF-8へ変換も必要

    if ($code != 0) {
        throw \Exception($msg);
    }
    // ...
} catch (\Exception $e) {
    // ...
    Log::error(__FILE__ . " (" . __LINE__ . ")" . PHP_EOL . $e->getMessage());
}

execと結構似ているのはshell_exec関数となりますが、定義は以下となります。

shell_exec(string $cmd): string|false|null

shell_execのリターン値stringの場合、stdoutの全ての内容がリターンされます。falseの場合は実行パイプが作られない、nullの場合はエラー。execよりは若干シンプルになります。

次にsystem関数もあります。

system(string $command, int &$result_code = null): string|false

tinkerとかで試してみると、stdout全部出てきますが、実はshell_execと違います。ここで紛らわしいのは、stdoutの全ての内容をリターンするのではなく、コンソールにプリントアウトすることです。実際のリターン値はexecと同じく、stdoutの最後の一行のみとなります。

また、$result_codeを使えば0=成功、以外=失敗で判断可能に、というところもexecと似ています。そのため、execと比べると、exec$outputのポインターを提供しているため、アウトプットをコントロールすることが可能になります。

それでpassthru関数もあります。

passthru(string $command, int &$return_var = ?): void

どちらかというと、system関数と似ていて、実行結果をコンソールなどにプリントアウトします。ただ、こちらの関数はバイナリーデータを直接出力可能なので、例えば、PDFとか、写真データなどブラウザーに表示することが可能らしい(実際に試したことがない)。

最後にproc_open/proc_close関数があります。

proc_open(
    mixed $cmd,
    array $descriptorspec,
    array &$pipes,
    string $cwd = null,
    array $env = null,
    array $other_options = null
): resource
proc_close(resource $process): int
proc_get_status(resource $process): array
proc_terminate(resource $process, int $signal = 15): bool

こちらはだいぶ複雑ですが、よりコントロールを強化しています。上記のいくつかの関数と比べて、直接使う機会が少ないかもしれませんが、このproc_open/proc_closeは次のsynfony process componentには利用されています。

Symfony Process Componentについて

公式ドキュメントはこちらとなります。Laravelはsymfonyのcomponentも取り入れているので、インストールは不要です。使い方がシンプルですが、上記のproc_open/closeを利用しているため、より機能も豊富となります。

use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

$process = new Process(['ls', '-lsa']);
$process->run();
$output = $process->getOutput() ?: $process->getErrorOutput();

// 'ls -lsa'の実行が終わった後に実行
if (!$process->isSuccessful()) {
    // ...
    throw new ProcessFailedException($process);
}

基本的に次のフォーマットで実行するコマンドを書きます。

$process = new Process(['command', '--flag', 'argument']);

ただ、コマンドが長くとなると、上記のフォーマットだけでは戸惑うかもしれませんが、原則として、スペースのあるところでコンマ入れることで大丈夫です。例えば:

// schtasks /run /s xxx.xxx.xx.xx /tn task_name
$process = new Process(['schtasks', '/run', '/s', 'xxx.xxx.xx.xx', '/tn', 'task_name']);

もしくは、こちらのスタティックメソッドを使って直接コマンド丸ごと投げることも可能:

// On Unix-like OSes (Linux, macOS)
$process = Process::fromShellCommandline('echo "$MESSAGE"');

// On Windows
$process = Process::fromShellCommandline('echo "!MESSAGE!"');

// On both Unix-like and Windows
$process->run(null, ['MESSAGE' => '実際のメッセージ']);

windowsではデフォルトとして、cmd.exeに渡して実行してもらうことになります。

また、非同期に実行することも可能。JavaScriptやPythonなどのasync/awaitと似ている感じで書けます。

$process = new Process(['ls', '-lsa']);
$process->start();

while ($process->isRunning())
{
    // ... 同時に処理したいコードを実行
}

$process->wait(); //コマンド実行が終わるまでブロック

if ($process->isSucessful())
// ...

Process component利用時の注意点

筆者が引っかかってしまいました。環境はwindowsとなりますが、Linux系やMacOSにはなさそうな問題です。

前節では、windowsにおいてデフォルトとして、コマンドをcmd.exeに実行してもらうことを述べました。

環境変数の設定の問題か、自分の環境ではProcess(['schtasks'])を実行しても、「schtasksが内部または外部のコマンドとして認識されていません」、いったエラーが出てきます。ただ、同じコマンドをexec系でやると問題なく実行できます。

筆者の解決法として、プログラムのフルパスを丸ごと入れることです。例えば:

$process = new Process(['C:\Windows\System32\schtasks.exe', '/run', '/tn', 'task_name']);

おそらくより良い解決法があるだと思いますが、もしこの問題に出会ったとしたら、一度フルパスで試してみても良いと思います。

コードの中でシェルコマンドを実行する必要が時々あるかもしれません。個人的にexecよりProcess Componentをお勧めします。

以上です!

Discussion