Помощник Question

Дата обновления перевода 2023-01-20

Помощник Question

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

1
$helper = $this->getHelper('question');

Помощник Question имеет единственный метод ask(), которому нужен экземпляр InputInterface в качестве первого аргумента, экземпляр OutputInterface - в качестве второго, и Question в качестве последнего аргумента.

Запрос подтверждения от пользователя

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

class YourCommand extends Command
{
    // ...

    public function execute(InputInterface $input, OutputInterface $output): int
    {
        $helper = $this->getHelper('question');
        $question = new ConfirmationQuestion('Продолжить это действие?', false);

        if (!$helper->ask($input, $output, $question)) {
            return Command::SUCCESS;
        }

        // ... сделать что-то здесь

        return Command::SUCCESS;
    }
}

В этом случае, пользователя спросят "Вы хотите продолжить это действие?". Если пользователь ответит y, то вернётся true, а falseвернётся, если ответ будет n. Второй аргумент метода __construct() - это значение по умолчанию, которое стоит вернуть, если пользователь введёт невалидное значение ввода. Если второй аргумент не предоставлен, то предполагается true.

Tip

Вы можете настроить используемое регулярное выражение так, чтобы проверять, означает ли ответ "yes", в третьем аргументе консруктора. Например, чтобы разрешить всё, что начинается с y или j, вам нужно установить его так:

1
2
3
4
5
$question = new ConfirmationQuestion(
    'Continue with this action?',
    false,
    '/^(y|j)/i'
);

Регулярное выражение по умолчанию - /^y/i.

Запрос информации у пользователя

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $question = new Question('Пожалуйста, введите имя пакета', 'AcmeDemoBundle');

    $bundleName = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с bundleName
    
    return Command::SUCCESS;
}

Пользователя попросят "Пожалуйста, введите имя пакета". Он может ввести какое-то имя, которое будет возвращено методом ask(). Если он оставит поле пустым, то будет возвращено значение по умолчанию (здесь - AcmeDemoBundle).

Позвольте пользователю выбирать из списка ответов

Если у вас есть предопределённый набор ответов, из которого пользователь может выбирать, вы можете использовать ChoiceQuestion, который гарантирует, что пользователь может вводить только валидную строку из предопределённого списка:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use Symfony\Component\Console\Question\ChoiceQuestion;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');
    $question = new ChoiceQuestion(
        'Пожалуйста, выберите ваш любимый цвет (по умолчанию красный)',
        // выбор также может быть из PHP-объектов, реализующих метод __toString()
        ['red', 'blue', 'yellow'],            0
    );
    $question->setErrorMessage('Color %s is invalid.');

    $color = $helper->ask($input, $output, $question);
    $output->writeln('Вы только что выбрали: '.$color);

    // ... сделать что-то с цветом

    return Command::SUCCESS;
}

Опция, выбранная по умолчанию, предоставляется третьим аргументом конструктора. По умолчанию она null, что означает, что опции по умолчанию нет.

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

Множественный выбор

Иногда можно предоставить несколько ответов. ChoiceQuestion предлагает такую функцию, используя значения, разделённые запятыми. По умолчанию это отключено, чтобы подключить, используйте setMultiselect():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Symfony\Component\Console\Question\ChoiceQuestion;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');
    $question = new ChoiceQuestion(
        'Пожалуйста, выберите ваши любимые цвета (по умолчанию красный и синий)',
        ['red', 'blue', 'yellow'],
        '0,1'
    );
    $question->setMultiselect(true);

    $colors = $helper->ask($input, $output, $question);
    $output->writeln('Вы только что выбрали: ' . implode(', ', $colors));
    
    return Command::SUCCESS;
}

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

Если пользователь не введёт ничего, то результат будет: Вы только что выбрали: красный, синий.

Автозаполнение

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $bundles = ['AcmeDemoBundle', 'AcmeBlogBundle', 'AcmeStoreBundle'];
    $question = new Question('Пожалуйста, введите имя пакета', 'FooBundle');
    $question->setAutocompleterValues($bundles);

    $bundleName = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с bundleName
    
    return Command::SUCCESS;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    $helper = $this->getHelper('question');

    // Эта функция вызывается каждый раз, когда изменяется ввод, и необходимы
    // новые предложения.
    $callback = function (string $userInput): array {
        // Уберите все символы после последнего слеша до конца строки,
        // чтобы оставить только последний каталог, и сгенерировать предложения для него
        $inputPath = preg_replace('%(/|^)[^/]*$%', '$1', $userInput);
        $inputPath = '' === $inputPath ? '.' : $inputPath;

        // ВНИМАНИЕ - этот приер кода позволяет неограниченный доступ ко
        // всей файловой системе. В реальном приложении, ограничьте каталоги,
        // где могут находиться файлы и dir
        $foundFilesAndDirs = @scandir($inputPath) ?: [];

        return array_map(function ($dirOrFile) use ($inputPath) {
            return $inputPath.$dirOrFile;
        }, $foundFilesAndDirs);
    };

    $question = new Question('Please provide the full path of a file to parse');
    $question->setAutocompleterCallback($callback);

    $filePath = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с filePath
    
    return Command::SUCCESS;
}

Не усекать ответ

Вы также можете указать, что вы хотите не усекать ответ, установив это напрямую с помощью setTrimmable():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $question = new Question('Как зовут ребёнка?');
    $question->setTrimmable(false);
    // если пользователь вводит 'elsa ', он не будет усечен, и вы получите 'elsa ' как значение
    $name = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с именем
    
    return Command::SUCCESS;
}

Принятие ответов в несколько строк

По умолчанию, помощник question перестает читать ввод пользователя, когда он получает символ новой строки (т.е., когда пользователь разово нажимает ENTER). Однако, вы можете указать, что ответ на вопрос должен позволять ответ в несколько строк, передав true в setMultiline():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $question = new Question('Как добиться мира во всём мире?');
    $question->setMultiline(true);

    $answer = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с ответом
    
    return Command::SUCCESS;
}

Вопросы в несколько строк перестают читать ввод пользователя после получения символа контроля конца передачи (Ctrl-D на системах Unix или Ctrl-Z на Windows).

Скрытие ответов пользователя

Вы также можете задавать вопрос и скрывать ответ. Это особенно полезно для паролей:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $question = new Question('Какой пароль базы данных?');
    $question->setHidden(true);
    $question->setHiddenFallback(false);

    $password = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с паролем
    
    return Command::SUCCESS;
}

Caution

Когда вы запрашиваете скрытый ответ, Symfony будет использовать либо бинарный режим изменения stty, либо другой фокус для скрытия ответа. Если ничего недоступно, то будет использован резервный план и ответ будет видимым, кроме случаев, если выустановите это поведение, как false, используя setHiddenFallback(), как в примере выше. В этом случае, будет вызвано RuntimeException.

Note

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\ChoiceQuestion;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');
    QuestionHelper::disableStty();

    // ...
    
    return Command::SUCCESS;
}

Нормализация ответа

Перед валидацией ответа вы можете "нормализовать" его, чтобы исправить мелкие ошибки или подстроить его по необходимости. Например, в предыдущем примере вы спрашивали имя пакета. В случае, если пользователь добавляет пробелы вокруг имени по ошибке, вы можете обрезать имя перед его валидацией. Чтобы сделать это, сконфигурируйте нормализатор, используя метод setNormalizer():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $question = new Question('Пожалуйста, введите имя пакета', 'AcmeDemoBundle');
    $question->setNormalizer(function ($value) {
        // $value здесь может быть null
        return $value ? trim($value) : '';
    });

    $bundleName = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с bundleName
    
    return Command::SUCCESS;
}

Caution

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

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

Вы можете даже валидировать ответ. К прмиеру, в предыдущем примере вы спрашивали имя пакета. Следуя соглашениию именования Symfony, оно должно иметь суффикс Bundle. Вы можете валидировать это, используя метод setValidator():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $question = new Question('Пожалуйста, введите имя пакета', 'AcmeDemoBundle');
    $question->setValidator(function ($answer) {
        if (!is_string($answer) || 'Bundle' !== substr($answer, -6)) {
            throw new \RuntimeException(
                'Имя пакета должно иметь суффикс \'Bundle\''
            );
        }

        return $answer;
    });
    $question->setMaxAttempts(2);

    $bundleName = $helper->ask($input, $output, $question);
    
    // ... сделать что-то с bundleName
    
    return Command::SUCCESS;
}

$validator - это обратный вызов, который работает с валидацией. Он должен вызвать исключение, если что-то пошло не так. Сообщение об исключении отображается в консоли, поэтому хорошей практикой будет разместить в нём полезную информацию. Функция обратного вызова должна также возвращать значение ввода пользователя, если валидация была успешной.

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

Tip

Вы даже можете использовать компонент Валидатор, чтобы валидировать ввод, используя метод createCallable():

1
2
3
4
5
6
7
8
9
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Validation;

$question = new Question('Пожалуйста, введите имя пакета', 'AcmeDemoBundle');
$validation = Validation::createCallable(new Regex([
    'pattern' => '/^[a-zA-Z]+Bundle$/',
    'message' => 'Имя пакета должно иметь суффикс \'Bundle\'',
]));
$question->setValidator($validation);

Валидация скрытых ответов

Вы также можете использовать валидатор со скрытым вопросом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use Symfony\Component\Console\Question\Question;

// ...
public function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    $helper = $this->getHelper('question');

    $question = new Question('Пожалуйста, введите ваш пароль');
    $question->setNormalizer(function ($value) {
        return $value ?? '';
    });
    $question->setValidator(function ($value) {
        if ('' === trim($value)) {
            throw new \Exception('The password cannot be empty');
        }

        return $value;
    });
    $question->setHidden(true);
    $question->setMaxAttempts(20);

    $password = $helper->ask($input, $output, $question);
    
    // ... сделатьь что-то с паролем
    
    return Command::SUCCESS;
}

Тестирования команды, ожидающей ввода

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use Symfony\Component\Console\Tester\CommandTester;

// ...
public function testExecute()
{
    // ...
    $commandTester = new CommandTester($command);

    // Эквивалентно вводу пользователем "Test" и нажатию ENTER
    $commandTester->setInputs(['Test']);

    // Эквивалентно вводу пользователем  "This", "That" и нажатию ENTER
    // Может быть использовано для ответа на два разных вопроса, к примеру
    $commandTester->setInputs(['This', 'That']);

    // Для симуляции положительного ответа на вопрос подтверждения, будет работать
    // дополнительный ввод "yes"
    $commandTester->setInputs(['yes']);

    $commandTester->execute(['command' => $command->getName()]);

    // $this->assertRegExp('/.../', $commandTester->getDisplay());
}

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

Note

Класс CommandTester автоматически симулирует нажатие пользователем ENTER после каждого ввода, необходимости передавать дополнительный ввод нет.

Caution

В системах Windows Symfony использует специальную бинарность, чтобы реализовать скрытые вопросы. Это означает, что такие вопросы не используют объект консоли по умолчанию Input, и следовательно вы не можете тестировать его в Windows.