Процессы и команды Artisan в Laravel

Источник: «Processes and Artisan commands in Laravel»
Узнайте как создавать и тестировать команды для взаимодействия с Laravel приложением и сервером. Откройте для себя новые советы и рекомендации по написанию команд Artisan, а также по использованию фасада Process в Laravel 10.

Оглавление

Интерфейс командной строки (CLI) может быть мощным инструментом для разработчиков. Вы можете использовать его как часть рабочего процесса разработки для добавления новых функций в приложение и для выполнения задач в продакшене. Laravel позволяет создавать "команды Artisan", чтобы добавить индивидуальную функциональность в приложение. Он также предоставляет фасад Process, который можно использовать для запуска процессов OS (операционной системы) из приложения Laravel для выполнения таких задач, как запуск пользовательских шелл скриптов.

В этой статье мы рассмотрим, что такое команды Artisan, а также дадим советы и рекомендации по их созданию и тестированию. Мы также рассмотрим, как запускать процессы операционной системы (ОС) из приложения Laravel и проверять правильность их вызова.

Что такое команды Artisan

Команды Artisan можно запускать из CLI для выполнения различных задач в Laravel приложении. Они позволяют оптимизировать процесс разработки и могут использоваться для выполнения задач в продакшне.

Как Laravel разработчик, вы, скорее всего, уже использовали некоторые встроенные команды Artisan, такие, как php artisan make:model, php artisan migrate и php artisan down.

Например, некоторые команды Artisan можно использовать в процессе разработки, такие как php artisan make:model и php artisan make:controller. Как правило, они не используются в продакшне и служат исключительно для ускорения процесса разработки путём создания шаблонных файлов.

Некоторые команды Artisan можно использовать для выполнения задач в продакшне, например php artisan migrate и php artisan down. Они используются для выполнения таких задач, как миграция базы данных и отключение приложения на время проведения технического обслуживания или установки обновления.

Таким образом, команды Artisan могут использоваться для выполнения различных задач и применяться как в разработке, так и в продакшне.

Создание собственных команд Artisan

Теперь, когда мы лучше понимаем, что такое команды Artisan, давайте посмотрим, как мы можем создать свои собственные команды.

Получение данных от пользователей

Для примера того, что можно сделать с помощью команд Artisan, давайте рассмотрим один из распространённых случаев их использования, с которым вы можете столкнуться в своих проектах: создание нового супер администратора в базе данных. В этом примере для создания нового супер администратора нам понадобится следующая информация:

Давайте создадим для этого команду. Назовём команду CreateSuperAdmin и создадим её, выполнив следующую команду:

php artisan make:command CreateSuperAdmin

Эта команда создаст новый файл app/Console/Commands/CreateSuperAdmin.php. Предположим, что у нас есть доступ к методу createSuperAdmin в классе UserService. Для целей этой статьи нам не нужно знать, что он делает и как работает, поскольку мы сосредоточены на том, как работают команды.

Класс команды, созданный командой make:command, будет выглядеть примерно так:

namespace App\Console\Commands;

use Illuminate\Console\Command;

class CreateSuperAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/

protected $signature = 'app:create-super-admin';

/**
* The console command description.
*
* @var string
*/

protected $description = 'Command description';

/**
* Execute the console command.
*/

public function handle(): void
{
//
}
}

Теперь нам нужно добавить аргументы в команду, чтобы мы могли принимать имя, электронную почту и пароль для каждого нового пользователя. Это можно сделать, обновив свойство signature класса команды. Свойство signature используется для определения имени команды, аргументов и опций, принимаемых командой.

Свойство signature должно выглядеть так:

protected $signature = 'app:create-super-admin {--email=} {--password=} {--name=}';

Возможно, мы также захотим добавить в команду опцию, позволяющую пользователю указать, следует ли отправлять письмо для подтверждения учётной записи. По умолчанию мы будем считать, что пользователю не нужно отправлять письмо. Чтобы добавить эту опцию в код, мы можем обновить свойство signature, чтобы оно выглядело следующим образом:

protected $signature = 'app:create-super-admin {--email=} {--password=} {--name=} {--send-email}';

Важно также обновить свойство description команды, чтобы описать, что она делает. Оно будет отображаться, когда пользователь выполнит команды php artisan list или php artisan help.

Теперь, когда мы настроили опции на получение данных, мы можем передать их в метод createSuperAdmin. Давайте посмотрим, как теперь будет выглядеть класс команды:

namespace App\Console\Commands;

use App\Services\UserService;
use Illuminate\Console\Command;

class CreateSuperAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/

protected $signature = 'app:create-super-admin {--email=} {--password=} {--name=} {--send-email}';

/**
* The console command description.
*
* @var string
*/

protected $description = 'Store a new super admin in the database';

/**
* Execute the console command.
*/

public function handle(UserService $userService): int
{
$userService->createSuperAdmin(
email: $this->option('email'),
password: $this->option('password'),
name: $this->option('name'),
sendEmail: $this->option('send-email'),
);

$this->components->info('Super admin created successfully!');

return self::SUCCESS;
}
}

Теперь команда должна быть готова к запуску. Мы можем выполнить её с помощью следующей команды:

php artisan app:create-super-admin --email="hello@example.com" --name="John Doe" --password="password" --send-email

Если пойти ещё дальше, то можно добавить вопросы к команде, чтобы пользователю предлагалось ввести информацию, если он не указал её в качестве аргумента или опции. Это может обеспечить дружелюбный пользовательский опыт для разработчиков, особенно если они впервые используют команду или если она имеет несколько аргументов и опций.

Если мы хотим обновить команду, чтобы использовать вопросы, команда будет выглядеть следующим образом:

namespace App\Console\Commands;

use App\Services\UserService;
use Illuminate\Console\Command;

class CreateSuperAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/

protected $signature = 'app:create-super-admin {--email=} {--password=} {--name=} {--send-email}';

/**
* The console command description.
*
* @var string
*/

protected $description = 'Store a new super admin in the database';

/**
* Execute the console command.
*/

public function handle(UserService $userService): int
{
$userService->createSuperAdmin(
email: $this->getInput('email'),
password: $this->getInput('password'),
name: $this->getInput('name'),
sendEmail: $this->getInput('send-email'),
);

$this->components->info('Super admin created successfully!');

return self::SUCCESS;
}

public function getInput(string $inputKey): string
{
return match($inputKey) {
'email' => $this->option('email') ?? $this->ask('Email'),
'password' => $this->option('password') ?? $this->secret('Password'),
'name' => $this->option('name') ?? $this->ask('Name'),
'send-email' => $this->option('send-email') === true
? $this->option('send-email')
: $this->confirm('Send email?'),
default => null,
};
}
}

Как видите, мы добавили новый метод getInput в класс команды. В этом методе мы проверяем, был ли передан команде аргумент. Если нет, мы запрашиваем ввод данных от пользователя. Вы также могли заметить, что для получения нового пароля используется метод secret. Этот метод используется для того, чтобы скрыть вводимый пароль в терминале. Если бы мы не использовали метод secret, пароль отображался бы в терминале. Аналогично, мы используем метод confirm для определения того, следует ли отправлять письмо новому пользователю. Он предложит пользователю ответить на вопрос yes или no, так что это хороший способ получить от пользователя логическое значение, а не использовать метод ask.

Выполнение команды только с аргументом email приведёт к следующему результату:

❯ php artisan app:create-super-admin --email="hello@example.com"

Password:
>

Name:
> Joe Smith

Send email? (yes/no) [no]:
> yes

Предугадывание ввода

Если вы создаёте команду для пользователя и предоставляете несколько вариантов для выбора из известного набора данных (например, строк в базе данных), то иногда может появиться желание предугадать ввод пользователя. В этом случае он может предложить пользователю варианты с автозавершением.

Например, представим, что у вас есть команда, которая используется для создания нового пользователя и позволяет указать его роль. Возможно, вы захотите предугадать возможные роли, которые может выбрать пользователь. Это можно сделать с помощью метода anticipate:

$roles = [
'super-admin',
'admin',
'manager',
'user',
];

$role = $this->anticipate('What is the role of the new user?', $roles);

Когда пользователю будет предложено выбрать роль, команда попытается автоматически дополнить поле. Например, если пользователь начал вводить sup, команда предложит в качестве автозавершения er-admin.

Выполнение команды приведёт к следующему результату:

What is the role of the new user?:
> super-admin

Важно помнить, что метод anticipate лишь даёт подсказку пользователю, и он может ввести любое значение. Поэтому все вводимые данные должны быть проверены, чтобы их можно было использовать.

Множество аргументов и вариантов ввода

Бывают случаи, когда вы создаёте команду Artisan и хотите дать пользователям возможность выбрать несколько вариантов из списка. Если вы использовали Laravel Sail, вы уже пользовались этой функцией, когда выполняли команду php artisan sail:install и вам предлагалось выбрать различные сервисы, которые вы хотите установить.

Вы можете разрешить передавать несколько значений в качестве аргументов команды. Например, если мы хотим создать команду, используемую для установки некоторых сервисов для локальной среды разработки, мы можем разрешить пользователю передавать несколько сервисов в качестве аргументов команды:

protected $signature = 'app:install-services {services?*}';

Теперь мы можем вызвать эту команду следующим образом, для установки сервисов mysql и redis:

php artisan app:install-services mysql redis

В команде Artisan, $this->argument('services') вернёт массив, содержащий два элемента: mysql и redis.

Мы также можем добавить эту функциональность в команду, чтобы отображать варианты пользователю, если он не предоставил никаких аргументов, используя метод choice:

$installableServices = [
'mysql',
'redis',
'mailpit',
];

$services = $this->choice(
question: 'Which services do you want to install?',
choices: $installableServices,
multiple: true,
);

Выполнение этой команды без каких-либо аргументов приведёт к следующему выводу:

Which services do you want to install?:
[0] mysql
[1] redis
[2] mailpit
> mysql,redis

Используя метод choice, мы можем позволить пользователю выбрать несколько вариантов из списка. Этот метод обеспечивает пользователю автозавершение, аналогично методу anticipate. Он также удобен тем, что позволяет пользователю выбирать варианты только из предложенного списка. Если пользователь попытается ввести вариант, которого нет в списке, будет предложено повторить попытку. Таким образом, этот метод может служить формой валидации вводимых пользователями данных.

Валидация ввода

Подобно тому, как вы проверяете данные HTTP-запроса, вы также можете проверить данные получаемые командой Artisan. Это позволит вам убедиться, что вводимые данные корректны и могут быть переданы в другие части кода приложения.

Давайте рассмотрим возможный способ валидации ввода. Для начала мы рассмотрим код, а затем я разберу его на части.

Для валидации вводимых данных можно сделать что-то вроде этого:

namespace App\Console\Commands;

use App\Services\UserService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\MessageBag;
use InvalidArgumentException;

class CreateSuperAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/

protected $signature = 'app:create-super-admin {--email=} {--password=} {--name=} {--send-email}';

/**
* The console command description.
*
* @var string
*/

protected $description = 'Store a new super admin in the database';

private bool $inputIsValid = true;

/**
* Execute the console command.
*/

public function handle(UserService $userService): int
{
$input = $this->validateInput();

if (!$this->inputIsValid) {
return self::FAILURE;
}

$userService->createSuperAdmin(
email: $input['email'],
password: $input['password'],
name: $input['name'],
sendEmail: $input['send-email'],
);

$this->components->info('Super admin created successfully!');

return self::SUCCESS;
}

/**
* Validate and return all the input from the command. If any of the input
* was invalid, an InvalidArgumentException will be thrown. We catch this
* and report it so it's still logged or sent to a bug-tracking system.
* But we don't display it to the console. Only the validation error
* messages will be displayed in the console.
*
* @return array
*/

private function validateInput(): array
{
$input = [];

try {
foreach (array_keys($this->rules()) as $inputKey) {
$input[$inputKey] = $this->validated($inputKey);
}
} catch (InvalidArgumentException $e) {
$this->inputIsValid = false;

report($e);
}

return $input;
}

/**
* Validate the input and then return it. If the input is invalid, we will
* display the validation messages and then throw an exception.
*
* @param string $inputKey
* @return string
*/

private function validated(string $inputKey): string
{
$input = $this->getInput($inputKey);

$validator = Validator::make(
data: [$inputKey => $input],
rules: [$inputKey => $this->rules()[$inputKey]]
);

if ($validator->passes()) {
return $input;
}

$this->handleInvalidData($validator->errors());
}

/**
* Loop through each of the error messages and output them to the console.
* Then throw an exception so we can prevent the rest of the command
* from running. We will catch this in the "validateInput" method.
*
* @param MessageBag $errors
* @return void
*/

private function handleInvalidData(MessageBag $errors): void
{
foreach ($errors->all() as $error) {
$this->components->error($error);
}

throw new InvalidArgumentException();
}

/**
* Define the rules used to validate the input.
*
* @return array<string,string>
*/

private function rules(): array
{
return [
'email' => 'required|email',
'password' => 'required|min:8',
'name' => 'required',
'send-email' => 'boolean',
];
}

/**
* Attempt to get the input from the command options. If the input wasn't passed
* to the command, ask the user for the input.
*
* @param string $inputKey
* @return string|null
*/

private function getInput(string $inputKey): ?string
{
return match($inputKey) {
'email' => $this->option('email') ?? $this->ask('Email'),
'password' => $this->option('password') ?? $this->secret('Password'),
'name' => $this->option('name') ?? $this->ask('Name'),
'send-email' => $this->option('send-email') === true
? $this->option('send-email')
: $this->confirm('Send email?'),
default => null,
};
}
}

Давайте разберём приведённый выше код.

Сначала мы вызываем метод validateInput, прежде чем попытаемся что-то сделать с вводом. Этот метод перебирает все ключи ввода, которые были указаны в нашем методе правил, и вызывает метод validated. Массив, возвращаемый методом validateInput, будет содержать только валидные данные.

Если что-то из вводимых данных окажется невалидным, будут выведены соответствующие сообщения об ошибках. Вы, наверное, заметили, что мы также перехватываем все возникающие исключения InvalidArgumentException. Это делается для того, чтобы можно было записать исключение в лог или отправить его в систему отслеживания ошибок, но без отображения сообщения об исключении в консоли. Таким образом, мы можем сохранить аккуратный вид консоли, показывая только сообщения об ошибках валидации.

Запустив приведённый выше код и указав некорректный адрес электронной почты, вы получите следующий результат:

❯ php artisan app:create-super-admin

Email:
> INVALID

ERROR The email field must be a valid email address.

Сокрытие команд из списка

В зависимости от типа создаваемой команды, вы можете захотеть скрыть её из списка php artisan list, который отображает все доступные команды вашего приложения. Это полезно, если вы создаёте команду, которая будет выполняться только один раз, например, команду установки пакета.

Чтобы скрыть команду из списка, вы можете использовать свойство setHidden, установив значение свойства команды hidden в true. Например, если вы создаёте команду установки как часть пакета, который публикует некоторые ресурсы, вы можете захотеть проверить, существуют ли эти ресурсы в файловой системе. Если да, то можно предположить, что команда уже выполнялась один раз и не нуждается в отображении в выводе команды list.

Давайте рассмотрим, как это можно сделать. Представим, что наш пакет публикует файл конфигурации my-new-package.php во время установки. Если такой файл существует, мы скроем команду из списка. Код нашей команды может выглядеть следующим образом:

namespace App\Console\Commands;

use Illuminate\Console\Command;

class PackageInstall extends Command
{
protected $signature = 'app:package-install';

protected $description = 'Install the package and publish the assets';

public function __construct()
{
parent::__construct();

if (file_exists(config_path('my-new-package.php'))) {
$this->setHidden();
}
}

public function handle()
{
// Выполните команду, как обычно...
}
}

Стоит отметить, что скрытие команды не мешает её выполнению. Если кто-то знает имя команды, он всё равно сможет её выполнить.

Помощь по команде

При создании команд очень важно использовать очевидные имена для аргументов и опций. Так другим разработчикам будет проще понять, что делает команда и как её использовать.

Однако бывают случаи, когда необходимо предоставить дополнительную справочную информацию, чтобы её можно было отобразить в консоли, выполнив команду php artisan help.

Например, если мы хотим взять пример CreateSuperAdmin из начала этой статьи, мы можем обновить сигнатуру таким образом:

protected $signature = 'app:create-super-admin
{--email= : The email address of the new user}
{--password= : The password of the new user}
{--name= : The name of the new user}
{--send-email : Send a welcome email after creating the user}'
;

Теперь, если мы запустим php artisan help app:create-super-admin, то увидим следующий результат:

❯ php artisan help app:create-super-admin
Description:
Store a new super admin in the database

Usage:
app:create-super-admin [options]

Options:
--email[=EMAIL] The email address of the new user
--password[=PASSWORD] The password of the new user
--name[=NAME] The name of the new user
--send-email Send a welcome email after creating the user
-h, --help Display help for the given command. When no command is given display help for the list command
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi|--no-ansi Force (or disable --no-ansi) ANSI output
-n, --no-interaction Do not ask any interactive question
--env[=ENV] The environment the command should run under
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Автоматизация задач с помощью планировщика

Команды Artisan также позволяют автоматизировать задачи в вашем приложении.

Допустим, в первый день каждого месяца вы хотите создавать PDF-отчёт по активности пользователей за предыдущий месяц и отправлять его им по электронной почте. Для автоматизации этого процесса вы можете создать команду Artisan и добавить её в "планировщик" вашего приложения. Если предположить, что на вашем сервере настроен планировщик, то эта команда будет автоматически выполняться в первый день каждого месяца и отправлять отчёт вашим пользователям.

В рамках этой статьи мы не будем рассказывать о том, как настроить планировщик на вашем сервере. Однако если вам интересно узнать больше о том, как это сделать, вы можете обратиться к документации по Laravel. В самых общих чертах планировщик — это задание cron, запускаемое на вашем сервере и вызываемое раз в минуту. Задание cron выполняет следующую команду:

php /path/to/artisan schedule:run >> /dev/null 2>&1

По сути, это просто вызов команды php artisan schedule:run для вашего проекта, которая позволяет Laravel определить, готовы ли запланированные команды к выполнению. Часть команды >> /dev/null перенаправляет вывод команды в /dev/null, который отбрасывает его, поэтому он не отображается. Часть команды 2>&1 перенаправляет вывод ошибок команды в стандартный вывод. Это используется для того, чтобы все ошибки, возникающие при выполнении команды, также отбрасывались.

Теперь давайте рассмотрим, как добавить команду в планировщик Laravel.

Представим, что мы хотим реализовать наш пример, описанный выше, и отправлять пользователям письма в первый день каждого месяца. Для этого нам нужно создать команду Artisan SendMonthlyReport:

namespace App\Console\Commands;

use Illuminate\Console\Command;

class SendMonthlyReport extends Command
{
protected $signature = 'app:send-monthly-report';

protected $description = 'Send monthly report to each user';

public function handle(): void
{
// Отправляйте ежемесячные отчёты здесь...
}
}

После создания команды мы можем добавить её в планировщик приложения. Для этого нам нужно зарегистрировать её в методе schedule файла app/Console/Kernel.php. Ваш класс Kernel может выглядеть примерно так:

namespace App\Console;

use App\Console\Commands\SendMonthlyReport;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/

protected function schedule(Schedule $schedule): void
{
$schedule->command('app:send-monthly-report')->monthly();
}

// ...

}

Если планировщик на вашем сервере настроен правильно, эта команда будет автоматически выполняться в начале каждого месяца.

Главный совет при создании подобных автоматических команд — добавьте функцию передачи аргументов и опций команде. Иногда при автоматическом выполнении команды может произойти сбой. Например, пользователь может не получить свой ежемесячный отчёт поэтому добавив параметры и аргументы, вы сможете вручную повторно выполнить команду только для одного пользователя, а не для всех в системе. Хотя может показаться, что эта функциональность не нужна, она всегда оказывалась полезной в проектах, над которыми я работал.

Тестирование команд Artisan

Как и любой другой написанный вами код, важно тестировать команды Artisan. Это особенно важно, если вы собираетесь использовать их в продакшне или для автоматизации задач через планировщик.

Утверждение результатов команд

Как правило, проще всего проверить успешность выполнения команды и вывод ожидаемого текста.

Представим, что у нас есть следующий пример команды, который мы хотим протестировать:

namespace App\Console\Commands;

use App\Services\UserService;
use Illuminate\Console\Command;

class CreateUser extends Command
{
protected $signature = 'app:create-user {email} {--role=}';

protected $description = 'Create a new user';

public function handle(UserService $userService): int
{
$userService->createUser(
email: $this->argument('email'),
role: $this->option('role'),
);

$this->info('User created successfully!');

return self::SUCCESS;
}
}

Если мы хотим протестировать успешность его запуска и вывод ожидаемого текста, то можем сделать следующее:

namespace Tests\Feature\Console\Commands;

use Tests\TestCase;

class CreateUserTest extends TestCase
{
/** @test */
public function user_can_be_created(): void
{
$this->artisan('app:create-user', [
'email' => 'hello@example.com',
'--role' => 'super-admin'
])
->expectsOutput('User created successfully!')
->assertSuccessful();

// Выполните дополнительные утверждения, проверяющие, что пользователь был создан с правильной ролью...
}
}

Утверждения задаваемых вопросов

Если ваше приложение задаёт пользователю вопрос, вам необходимо указать ответ на него в тесте. Это можно сделать с помощью метода expectsQuestion.

Например, представим, что мы хотим протестировать следующую команду:

protected $signature = 'app:create-user {email}';

protected $description = 'Create a new user';

public function handle(UserService $userService): int
{
$roles = [
'super-admin',
'admin',
'manager',
'user',
];

$role = $this->choice('What is the role of the new user?', $roles);

if (!in_array($role, $roles, true)) {
$this->error('The role is invalid!');

return self::FAILURE;
}

$userService->createUser(
email: $this->argument('email'),
role: $role,
);

$this->info('User created successfully!');

return self::SUCCESS;
}

Чтобы протестировать эту команду, мы можем сделать следующее:

/** @test */
public function user_can_be_created_with_choice_for_role(): void
{
$this->artisan('app:create-user', [
'email' => 'hello@example.com',
])
->expectsQuestion('What is the role of the new user?', 'super-admin')
->expectsOutput('User created successfully!')
->assertSuccessful();

// Выполните дополнительные утверждения, проверяющие, что пользователь был создан с правильной ролью...
}

/** @test */
public function error_is_returned_if_the_role_is_invalid(): void
{
$this->artisan('app:create-user', [
'email' => 'hello@example.com',
])
->expectsQuestion('What is the role of the new user?', 'INVALID')
->expectsOutput('The role is invalid!')
->assertFailed();

// Выполните дополнительные утверждения, чтобы проверить, что пользователь не был создан...
}

Как вы можете видеть в приведённом выше коде, у нас есть один тест для проверки успешного создания пользователя и другой тест для проверки возвращения ошибки, если роль некорректна.

Запуск процессов ОС в Laravel

До сих пор мы рассматривали, как можно взаимодействовать с приложением Laravel, выполняя команды из операционной системы. Однако могут возникнуть ситуации, когда вы захотите сделать наоборот и запускать команды в операционной системе из приложения Laravel.

Это может быть полезно, например, при запуске пользовательских сценариев оболочки, антивирусных проверок или конвертеров файлов.

Давайте рассмотрим, как добавить эту функциональность в Laravel приложение с помощью фасада Process, который был добавлен в Laravel 10. Мы рассмотрим несколько функций, которые вы, скорее всего, будете использовать в своём приложении. Однако если вы хотите ознакомиться со всеми доступными методами, вы можете заглянуть в документацию Laravel.

Запуск процессов

Чтобы запустить команду в операционной системе, можно воспользоваться методом run фасада Process. Представим, что у нас есть shell-скрипт install.sh в корневом каталоге проекта. Мы можем запустить этот скрипт из нашего кода:

use Illuminate\Support\Facades\Process;

$process = Process::path(base_path())->run('./install.sh');
$output = $process->output();

В качестве дополнительного контекста мы можем запустить этот сценарий из метода handle команды Artisan, которая выполняет несколько действий (например, чтение из базы данных или вызов API). Мы можем вызвать сценарий шелл из нашей команды:

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;

class RunInstallShellScript extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/

protected $signature = 'app:run-install-shell-script';

/**
* The console command description.
*
* @var string
*/

protected $description = 'Install the package and publish the assets';

/**
* Execute the console command.
*/

public function handle(): void
{
$this->info('Starting installation...');

$process = Process::path(base_path())->run('./install.sh');

$this->info($process->output());

// Вызывайте API здесь...

// Публикуйте ресурсы здесь...

// Добавляйте новые записи в базу данных здесь...

$this->info('Installation complete!');
}
}

Вы также можете заметить, что мы захватили вывод с помощью метода output, чтобы затем обработать его в нашем Laravel приложении. Например, если процесс выполнялся через HTTP-запрос, нам может понадобиться отобразить вывод на странице.

Команды обычно имеют два типа вывода: стандартный вывод и вывод ошибок. Если мы хотим получить от процесса вывод ошибок, нам нужно использовать метод errorOutput.

Запуск процесса из другой директории

По умолчанию фасад Process запускает команду в том же рабочем каталоге, где находится запускаемый PHP-файл. Обычно это означает, что если вы запускаете процесс с помощью команды Artisan, то процесс будет запущен в корневом каталоге проекта, поскольку именно там находится файл artisan. Однако если процесс запускается из HTTP-контроллера, он будет запущен в каталоге public проекта, поскольку это точка входа на веб-сервер.

Поэтому, если вы можете запустить процесс как из консоли, так и из веба, отсутствие явного указания рабочей директории может привести к неожиданному поведению и ошибкам. Один из способов решения этой проблемы — всегда использовать метод path, когда это возможно. Это гарантирует, что процесс всегда будет запускаться в ожидаемом каталоге.

Например, представим, что у нас есть шелл-скрипт custom-script.sh в каталоге scripts/custom проекта. Чтобы запустить этот скрипт, мы можем сделать следующее:

use Illuminate\Support\Facades\Process;

$process = Process::path(base_path('/scripts/custom'))->run('./install.sh');
$output = $process->output();

Указание тайм-аута процесса

По умолчанию фасад Process позволяет процессам выполняться не более 60 секунд, прежде чем они завершатся. Это сделано для предотвращения бесконечного выполнения процессов (например, если они застрянут в бесконечном цикле).

Однако если вы хотите запустить процесс, который может занять больше времени, чем таймаут по умолчанию, вы можете указать собственный таймаут в секундах с помощью метода timeout. Представим, что требуется запустить процесс, который может выполняться до 5 минут (300 секунд):

use Illuminate\Support\Facades\Process;

$process = Process::timeout(300)->run('./install.sh');
$output = $process->output();

Получение вывода в реальном времени

В зависимости от выполняемого процесса вы можете предпочесть выводить текст процесса по ходу его выполнения, а не дожидаться его завершения. Это может понадобиться, если у вас есть долго выполняющийся скрипт (например, скрипт установки), где вы хотите видеть шаги, которые выполняет скрипт.

Для этого можно передать замыкание в качестве второго параметра метода run, определяющего, что делать с выводом по мере его получения. Например, представим, что у нас есть шелл-скрипт install.sh, и мы хотим видеть его вывод в реальном времени, а не ждать, пока он завершится. Мы можем запустить скрипт следующим образом:

use Illuminate\Support\Facades\Process;

$process = Process::run('./install.sh', function (string $type, string $output): void {
$type === 'out' ? $this->line($output) : $this->error($output);
});

Асинхронный запуск процессов

Бывают случаи, когда нужно запустить процесс и продолжить выполнение другого кода во время его работы, а не ждать, пока он завершится. Например, вам может понадобиться запустить сценарий установки и выполнить запись в базу данных или сделать несколько вызовов API, пока продолжается установка.

Для этого можно использовать метод start. Давайте рассмотрим пример того, как это можно сделать. Представим, что у нас есть шелл-скрипт install.sh и мы хотим запустить некоторые другие шаги установки, пока ждём завершения работы шелл-скрипта:

use Illuminate\Support\Facades\Process;

public function handle(): void
{
$this->info('Starting installation...');

$extraCodeHasBeenRun = false;

$process = Process::start('./install.sh');

while ($process->running()) {
if (!$extraCodeHasBeenRun) {
$extraCodeHasBeenRun = true;

$this->runExtraCode();
}
}

$result = $process->wait();

$this->info($process->output());

$this->info('Installation complete!');
}

private function runExtraCode(): void
{
// Вызывайте API здесь...

// Публикуйте ресурсы здесь...

// Добавляйте новые записи в базу данных здесь...
}

Как вы могли заметить, в цикле while мы проверяем, не начал ли выполняться дополнительный код. Мы делаем это потому, что не хотим запускать дополнительный код несколько раз. Нам нужно выполнить его только один раз во время выполнения процесса.

После завершения процесса мы можем получить его результат (с помощью метода output) и вывести его в консоль.

Одновременный запуск процессов

Бывают случаи, когда требуется одновременный запуск нескольких команд. Например, это может понадобиться, если у вас есть сценарий, преобразующий файл из одного формата в другой. Если вы хотите массово конвертировать сразу несколько файлов (а скрипт не поддерживает обработку нескольких файлов), вы можете запустить скрипт для каждого файла одновременно.

Это удобно, потому что вам не нужно последовательно запускать скрипт для каждого файла. Например, если сценарий занимает пять секунд для каждого файла, а у вас было 3 файла для преобразования, то последовательный запуск займёт пятнадцать секунд. Однако при одновременном запуске скрипта на его выполнение уйдёт всего пять секунд.

Для этого можно использовать метод concurrently. Давайте рассмотрим пример его использования.

Представим, что нам нужно запустить шелл-скрипт convert.sh для трёх файлов в каталоге. Для целей этого примера мы жёстко закодируем эти имена файлов. Однако в реальном сценарии вы, скорее всего, получите эти имена файлов динамически из файловой системы, базы данных или запроса.

Если запускать скрипты последовательно, код может выглядеть примерно так:

// выполняется за 15 секунд...

$firstOutput = Process::path(base_path())->run('./convert.sh file-1.png');
$secondOutput = Process::path(base_path())->run('./convert.sh file-1.png');
$thirdOutput = Process::path(base_path())->run('./convert.sh file-1.png');

Однако если запускать скрипты параллельно, то код может выглядеть так:

// выполняется за 5 секунд...

$commands = Process::concurrently(function (Pool $pool) {
$pool->path(base_path())->command('./convert.sh file-1.png');
$pool->path(base_path())->command('./convert.sh file-2.png');
$pool->path(base_path())->command('./convert.sh file-3.png');
});

foreach ($commands->collect() as $command) {
$this->info($command->output());
}

Тестирование процессов ОС

Подобно командам Artisan, вы также можете проверить, выполняются ли процессы вашей ОС так, как ожидалось. Давайте рассмотрим некоторые общие моменты, которые вы можете захотеть протестировать.

Чтобы начать тестирование процессов, давайте представим, что в нашем приложении есть веб-маршрут, принимающий файл и преобразующий его в другой формат. Если передаваемый файл — изображение, мы преобразуем его с помощью скрипта convert-image.sh. Если файл — видео, мы преобразуем его с помощью convert-video.sh. Предположим, что в нашем контроллере есть метод isImage, возвращающий true, если файл является изображением, и false, если это видео.

В данном примере мы не будем заботиться о валидации или HTTP-тестировании. Мы сосредоточимся исключительно на тестировании процесса. Однако в реальном проекте вы захотите убедиться, что ваш тест охватывает все возможные сценарии.

Представим, что к нашему контроллеру можно обратиться через POST-запрос к /file/convert (с маршрутом file.convert) и что он выглядит примерно так:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class ConvertFileController extends Controller
{
/**
* Обработка входящего запроса.
*/

public function __invoke(Request $request)
{
// Временно сохраняем файл, чтобы его можно было конвертировать.
$tempFilePath = 'tmp/'.Str::random();

Storage::put(
$tempFilePath,
$request->file('uploaded_file')
);

// Определяем, какую команду следует выполнить.
$command = $this->isImage($request->file('uploaded_file'))
? 'convert-image.sh'
: 'convert-video.sh';

$command .= ' '.$tempFilePath;

// Команда конвертации.
$process = Process::timeout(30)
->path(base_path())
->run($command);

// Если процесс завершился неудачно, сообщаем об ошибке.
if ($process->failed()) {
// Сообщаем об ошибке здесь, в логах или системе отслеживания ошибок...

return response()->json([
'message' => 'Something went wrong!',
], 500);
}

return response()->json([
'message' => 'File converted successfully!',
'path' => trim($process->output()),
]);
}

// ...
}

Чтобы протестировать правильность диспетчеризации процессов, мы можем написать следующие тесты:

namespace Tests\Feature\Http\Controllers;

use Illuminate\Http\UploadedFile;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;

class ConvertFileControllerTest extends TestCase
{
public function setUp(): void
{
parent::setUp();

Storage::fake();

Process::preventStrayProcesses();

// Определяем, как должны быть построены случайные строки.
// Мы делаем это для того, чтобы можно было утверждать,
// что будет выполнена правильная команда.
Str::createRandomStringsUsing(static fn (): string => 'random');
}

/** @test */
public function image_can_be_converted(): void
{
Process::fake([
'convert-image.sh tmp/random' => Process::result(
output: 'tmp/converted.webp',
)
]);

$this->post(route('file.convert'), [
'uploaded_file' => UploadedFile::fake()->image('dummy.png'),
])
->assertOk()
->assertExactJson([
'message' => 'File converted successfully!',
'path' => 'tmp/converted.webp',
]);

Process::assertRan(function (PendingProcess $process): bool {
return $process->command === 'convert-image.sh tmp/random'
&& $process->path === base_path()
&& $process->timeout === 30;
});
}

/** @test */
public function video_can_be_converted(): void
{
Process::fake([
'convert-video.sh tmp/random' => Process::result(
output: 'tmp/converted.mp4',
)
]);

$this->post(route('file.convert'), [
'uploaded_file' => UploadedFile::fake()->create('dummy.mp4'),
])
->assertOk()
->assertExactJson([
'message' => 'File converted successfully!',
'path' => 'tmp/converted.mp4',
]);

Process::assertRan(function (PendingProcess $process): bool {
return $process->command === 'convert-video.sh tmp/random'
&& $process->path === base_path()
&& $process->timeout === 30;
});
}

/** @test */
public function error_is_returned_if_the_file_cannot_be_converted(): void
{
Process::fake([
'convert-video.sh tmp/random' => Process::result(
errorOutput: 'Something went wrong!',
exitCode: 1 // Error exit code
)
]);

$this->post(route('file.convert'), [
'uploaded_file' => UploadedFile::fake()->create('dummy.mp4'),
])
->assertStatus(500)
->assertExactJson([
'message' => 'Something went wrong!',
]);

Process::assertRan(function (PendingProcess $process): bool {
return $process->command === 'convert-video.sh tmp/random'
&& $process->path === base_path()
&& $process->timeout === 30;
});
}
}

Вышеуказанные тесты позволят убедиться в следующем:

Вы, наверное, заметили, что мы использовали метод preventStrayProcesses. Это гарантирует, что будут выполнены только те команды, которые мы указали. Если будут запущены какие-либо другие команды, тест будет провален. Это удобно если нужно убедиться, что скрипты случайно не запустят какие-либо внешние процессы в вашей системе.

Мы также явно протестировали, что команды запускаются с ожидаемым путём и таймаутом. Это необходимо для того, чтобы убедиться, что команды выполняются с правильными настройками.

Заключение

Надеемся, эта статья показала вам, как создавать и тестировать свои команды Artisan. Она также должна была показать, как запускать и тестировать процессы ОС из Laravel приложения. Теперь вы должны быть в состоянии реализовать обе эти возможности в своих проектах, чтобы добавить дополнительную функциональность своим приложениям.

Похожие статьи

Дополнительные материалы

Предыдущая Статья

Моя стратегия ветвления/тегирования пакетов

Следующая Статья

Все рекурсивные функции в PHP