$30 off During Our Annual Pro Sale. View Details »

Better Console Applications

Better Console Applications

Console applications - whether part of a larger (Symfony-)application or standalone-tool - usually are the bash-script of PHP developers. Thereby one often leaves the path of clean code and hacks a very pragmatic solution. Despite the fact that a lot of these fast solutions remain in project and need to be maintained for longer. What to reason about while developing a console application and which simple tricks help to clean up your code, is shown by examples in this talk. How do I decouple my code from CLI runtime, how do I optimize long running processes and so on.

Conference: SymfonyLive Phantasialand 2018
Example Application: https://github.com/chr-hertel/console-example

Christopher Hertel

May 04, 2018
Tweet

More Decks by Christopher Hertel

Other Decks in Programming

Transcript

  1. SensioLabs
    Better Console
    Applications

    View Slide

  2. Christopher Hertel
    Software Developer at SensioLabs
    Symfony User Group Berlin
    @el_stoffel

    View Slide

  3. Console
    Applications

    View Slide

  4. Console
    Commands

    View Slide

  5. View Slide

  6. CLI SAPI

    View Slide

  7. View Slide

  8. View Slide

  9. SensioLabs
    Console
    Component

    View Slide

  10. symfony/console
    ~ 85.000.000 Downloads

    View Slide

  11. CLI Application
    Framework

    View Slide

  12. $ composer req symfony/console
    Installation

    View Slide

  13. Application

    View Slide

  14. app

    View Slide

  15. chmod +x ./app

    View Slide

  16. View Slide

  17. Command

    View Slide

  18. View Slide

  19. More Dependencies
    Use Symfony Flex

    View Slide

  20. $ composer create-project \
    symfony/skeleton my-cli-app
    Installation

    View Slide

  21. bin/console

    View Slide

  22. php bin/console hello

    View Slide

  23. Tool-Tip
    Collision
    $ composer req nunomaduro/collision

    View Slide

  24. Tool-Tip
    Collision

    View Slide

  25. SensioLabs
    Application
    Types

    View Slide

  26. Jobs

    View Slide

  27. • running in background
    • no interaction
    • controlled by server
    • e.g. queue workers

    View Slide

  28. Helper

    View Slide

  29. • running in foreground
    • interaction possible
    • controlled by user
    • e.g. generator or debugging CMDs

    View Slide

  30. Tools

    View Slide

  31. • combining both
    • w/ or w/o interaction
    • used while development & CI
    • e.g. Composer, PHPUnit

    View Slide

  32. We rarely build tools

    View Slide

  33. Simple Question:
    Who executes this
    command?

    View Slide

  34. SensioLabs
    Example

    View Slide

  35. Billing Run

    View Slide

  36. • Generate Invoices
    • Charge payment
    • Generate PDF
    • Mail to customer
    • Export postal data

    View Slide

  37. • Console Command
    • Executed monthly
    • Executed by developer

    View Slide

  38. use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\Console\Question\Question;
    use Symfony\Component\Stopwatch\Stopwatch;
    /**
    * Monthly Billing run for all active subscribers of our magazines.
    *
    * - Invoice gets generated
    * - Payment is executed
    * - Email is sent
    * - Magazine export is generated
    *
    * @author C■■■■■■■■■■ H■■■■■
    */
    class BillingRunCommand extends ContainerAwareCommand
    {
    protected function configure(): void
    {
    $this->setName('app:billing:run');
    $this->addArgument('period', InputArgument::OPTIONAL, 'Billing Period', '');
    }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $stopwatch = new Stopwatch();
    $stopwatch->start('billing-run');

    View Slide

  39. }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $stopwatch = new Stopwatch();
    $stopwatch->start('billing-run');
    }
    $period = \DateTimeImmutable::createFromFormat('m-Y', $input->getArgument('per
    if (false === $period) {
    $questionHelper = $this->getHelper('question');
    $question = new Question('Which period do you want? (format: mm-yyyy)');
    $question->setNormalizer(function ($period) {
    return \DateTimeImmutable::createFromFormat('m-Y', (string) $period);
    });
    $question->setValidator(function ($period) {
    if (false === $period) {
    throw new \InvalidArgumentException('The given value was not a val
    }
    return $period;
    });
    $period = $questionHelper->ask($input, $output, $question);
    }
    $output->writeln(sprintf('Start billing run for %s', $period->for

    View Slide

  40. $period = $questionHelper->ask($input, $output, $question);
    }
    $output->writeln(sprintf('Start billing run for %s', $period->for
    $output->writeln('============================='.PHP_EOL);
    $customers = $this->fetchActiveCustomer();
    $output->writeln(sprintf('Loaded %d customers to process'.PHP_EOL
    $invoices = $this->generateInvoice($output, $customers, $period);
    $this->payInvoices($output, $invoices);
    $this->sendInvoices($output, $invoices);
    $this->exportMagazines($output, $period, $invoices);
    $output->writeln(['', 'Done.', '']);
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $output->writeln((string) $stopwatch->stop('billing-run'));
    }
    return 0;
    }
    /**
    * @return Customer[]
    */
    private function fetchActiveCustomer(): array
    {

    View Slide

  41. /**
    * @return Invoice[]
    */
    private function generateInvoice(OutputInterface $output, array $customers, \DateT
    {
    $entityManager = $this->getContainer()->get('doctrine.orm.default_entity_manag
    $output->writeln('Generate Invoices:');
    $invoices = [];
    $progressBar = new ProgressBar($output, count($customers));
    $progressBar->start();
    foreach ($customers as $i => $customer) {
    $invoice = Invoice::forCustomer($customer, $period);
    $invoices[] = $invoice;
    $entityManager->persist($invoice);
    $progressBar->advance();
    }
    $progressBar->finish();
    $output->writeln('');
    $output->writeln(sprintf('Generated %d invoices to pay'.PHP
    $entityManager->flush();
    return $invoices;
    }

    View Slide

  42. View Slide

  43. It's working

    View Slide

  44. But … umm

    View Slide

  45. Let's refactor

    View Slide

  46. Testing!

    View Slide

  47. ApplicationTester
    CommandTester

    View Slide

  48. Helper to execute an
    Application or Command

    View Slide

  49. Easily combined with
    KernelTestCase

    View Slide

  50. View Slide

  51. View Slide

  52. SensioLabs
    Input
    Interaction

    View Slide

  53. use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\Console\Question\Question;
    use Symfony\Component\Stopwatch\Stopwatch;
    /**
    * Monthly Billing run for all active subscribers of our magazines.
    *
    * - Invoice gets generated
    * - Payment is executed
    * - Email is sent
    * - Magazine export is generated
    *
    * @author C■■■■■■■■■■ H■■■■■
    */
    class BillingRunCommand extends ContainerAwareCommand
    {
    protected function configure(): void
    {
    $this->setName('app:billing:run');
    $this->addArgument('period', InputArgument::OPTIONAL, 'Billing Period', '');
    }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $stopwatch = new Stopwatch();
    $stopwatch->start('billing-run');

    View Slide

  54. }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $stopwatch = new Stopwatch();
    $stopwatch->start('billing-run');
    }
    $period = \DateTimeImmutable::createFromFormat('m-Y', $input->getArgument('per
    if (false === $period) {
    $questionHelper = $this->getHelper('question');
    $question = new Question('Which period do you want? (format: mm-yyyy)');
    $question->setNormalizer(function ($period) {
    return \DateTimeImmutable::createFromFormat('m-Y', (string) $period);
    });
    $question->setValidator(function ($period) {
    if (false === $period) {
    throw new \InvalidArgumentException('The given value was not a val
    }
    return $period;
    });
    $period = $questionHelper->ask($input, $output, $question);
    }
    $output->writeln(sprintf('Start billing run for %s', $period->for

    View Slide

  55. class ExampleCommand extends Command
    {
    protected function configure(): void
    {
    // TODO: IMPLEMENT
    }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    // TODO: IMPLEMENT
    }
    }

    View Slide

  56. class ExampleCommand extends Command
    {
    protected function configure(): void
    {
    // TODO: IMPLEMENT
    }
    protected function initialize(InputInterface $input, OutputInterface $output): void
    {
    // TODO: IMPLEMENT
    }
    protected function interact(InputInterface $input, OutputInterface $output): void
    {
    // TODO: IMPLEMENT
    }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    // TODO: IMPLEMENT
    }
    }
    Lazy Commands

    View Slide

  57. protected function interact(InputInterface $input, OutputInterface $output)
    {
    $period = \DateTimeImmutable::createFromFormat('m-Y', (string) $input->getArgument('period'));
    if (false === $period) {
    $questionHelper = $this->getHelper('question');
    $question = new Question('Which period do you want? (format: mm-yyyy)');
    $question->setNormalizer(function ($period) {
    return \DateTimeImmutable::createFromFormat('m-Y', (string) $period);
    });
    $question->setValidator(function ($period) {
    if (false === $period) {
    throw new \InvalidArgumentException('The given value was not a valid period, use mm-y
    }
    return $period;
    });
    $period = $questionHelper->ask($input, $output, $question);
    }
    $input->setArgument('period', $period);
    }

    View Slide

  58. protected function execute(InputInterface $input, OutputInterface $output): int
    {
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $stopwatch = new Stopwatch();
    $stopwatch->start('billing-run');
    }
    $period = $input->getArgument('period');
    $output->writeln(sprintf('Start billing run for %s', $period->format('
    $output->writeln('============================='.PHP_EOL);
    // ...
    protected function configure(): void
    {
    $this->setName('app:billing:run');
    $this->addArgument('period', InputArgument::REQUIRED, 'Billing Period');
    }

    View Slide

  59. SensioLabs
    Console
    Events

    View Slide

  60. •console.command
    •console.error
    •console.terminate

    View Slide

  61. console.
    command
    console.
    terminate
    console.
    error
    Command Execution
    Command Lifecycle
    on error

    View Slide

  62. protected function execute(InputInterface $input, OutputInterface $output): int
    {
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $stopwatch = new Stopwatch();
    $stopwatch->start('billing-run');
    }
    $period = $input->getArgument('period');
    $output->writeln(sprintf('Start billing run for %s', $period->format(
    $output->writeln('============================='.PHP_EOL);
    // ...
    $output->writeln(['', 'Done.', '']);
    if ('dev' === $this->getContainer()->getParameter('kernel.environment')) {
    $output->writeln((string) $stopwatch->stop('billing-run'));
    }
    return 0;
    }

    View Slide

  63. class StopwatchListener implements EventSubscriberInterface
    {
    private $stopwatch;
    public function __construct(Stopwatch $stopwatch)
    {
    $this->stopwatch = $stopwatch;
    }
    public static function getSubscribedEvents()
    {
    return [
    ConsoleEvents::COMMAND => 'startStopwatch',
    ConsoleEvents::TERMINATE => 'stopStopwatch',
    ];
    }
    public function startStopwatch(ConsoleCommandEvent $event): void
    {
    $this->stopwatch->start($event->getCommand()->getName());
    }
    public function stopStopwatch(ConsoleTerminateEvent $event): void
    {
    $name = $event->getCommand()->getName();

    View Slide

  64. }
    public static function getSubscribedEvents()
    {
    return [
    ConsoleEvents::COMMAND => 'startStopwatch',
    ConsoleEvents::TERMINATE => 'stopStopwatch',
    ];
    }
    public function startStopwatch(ConsoleCommandEvent $event): void
    {
    $this->stopwatch->start($event->getCommand()->getName());
    }
    public function stopStopwatch(ConsoleTerminateEvent $event): void
    {
    $name = $event->getCommand()->getName();
    if (!$this->stopwatch->isStarted($name)) {
    return;
    }
    $event->getOutput()->writeln((string) $this->stopwatch->stop($name));
    }
    }

    View Slide

  65. protected function execute(InputInterface $input, OutputInterface $output): int
    {
    $period = $input->getArgument('period');
    $output->writeln(sprintf('Start billing run for %s', $period->format('m-Y')));
    $output->writeln('============================='.PHP_EOL);
    $customers = $this->fetchActiveCustomer();
    $output->writeln(sprintf('Loaded %d customers to process'.PHP_EOL, count($cust
    $invoices = $this->generateInvoice($output, $customers, $period);
    $this->payInvoices($output, $invoices);
    $this->sendInvoices($output, $invoices);
    $this->exportMagazines($output, $period, $invoices);
    $output->writeln(['', 'Done.', '']);
    return 0;
    }

    View Slide

  66. SensioLabs
    Command
    ==
    Glue Code

    View Slide

  67. No business logic
    in a command

    View Slide

  68. protected function execute(InputInterface $input, OutputInterface $output): int
    {
    $period = $input->getArgument('period');
    $output->writeln(sprintf('Start billing run for %s', $period->format('m-Y')));
    $output->writeln('============================='.PHP_EOL);
    $customers = $this->fetchActiveCustomer();
    $output->writeln(sprintf('Loaded %d customers to process'.PHP_EOL, count($cust
    $invoices = $this->generateInvoice($output, $customers, $period);
    $this->payInvoices($output, $invoices);
    $this->sendInvoices($output, $invoices);
    $this->exportMagazines($output, $period, $invoices);
    $output->writeln(['', 'Done.', '']);
    return 0;
    }

    View Slide

  69. private function generateInvoice(OutputInterface $output, array $customers, \DateTimeImmutable
    {
    $entityManager = $this->getContainer()->get('doctrine.orm.default_entity_manager');
    $output->writeln('Generate Invoices:');
    $invoices = [];
    $progressBar = new ProgressBar($output, count($customers));
    $progressBar->start();
    foreach ($customers as $i => $customer) {
    $invoice = Invoice::forCustomer($customer, $period);
    $invoices[] = $invoice;
    $entityManager->persist($invoice);
    $progressBar->advance();
    }
    $progressBar->finish();
    $output->writeln('');
    $output->writeln(sprintf('Generated %d invoices to pay'.PHP_EOL, count($i
    $entityManager->flush();
    return $invoices;
    }

    View Slide

  70. Move business logic
    to service layer

    View Slide

  71. class BillingRunCommand extends Command
    {
    private $billingRun;
    public function __construct(BillingRun $billingRun)
    {
    parent::__construct('app:billing:run');
    $this->billingRun = $billingRun;
    }
    protected function configure(): void
    {
    $this->addArgument('period', InputArgument::REQUIRED, 'Billing Period');
    }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    $period = $input->getArgument('period');
    $output->writeln(sprintf('Start billing run for %s', $period->format('m-Y'
    $output->writeln('============================='.PHP_EOL);
    $this->billingRun->start($period, $output);
    $output->writeln(['', 'Done.', '']);
    return 0;
    }

    View Slide

  72. class BillingRun
    {
    private $entityManager;
    private $paymentProvider;
    private $mailer;
    private $exporter;
    public function __construct(
    EntityManagerInterface $entityManager,
    PaymentProvider $paymentProvider,
    Mailer $mailer,
    Exporter $exporter
    ) {
    $this->entityManager = $entityManager;
    $this->paymentProvider = $paymentProvider;
    $this->mailer = $mailer;
    $this->exporter = $exporter;
    }
    public function start(\DateTimeImmutable $period, OutputInterface $output): void
    {
    $customers = $this->fetchActiveCustomer();
    $output->writeln(sprintf('Loaded %d customers to process'.PHP_EOL, count($c
    $invoices = $this->generateInvoice($output, $customers, $period);
    $this->payInvoices($output, $invoices);
    $this->sendInvoices($output, $invoices);
    $this->exportMagazines($output, $period, $invoices);
    }

    View Slide

  73. Cleaner
    Dependencies

    View Slide

  74. Easier Testing

    View Slide

  75. Tool-Tip
    PHPBench
    $ composer req phpbench/phpbench

    View Slide

  76. Tool-Tip
    PHPBench
    class TimeConsumerBench
    {
    /**
    * @Revs(1000)
    * @Iterations(5)
    */
    public function benchConsume()
    {
    // ...
    }
    }

    View Slide

  77. SensioLabs
    Output

    View Slide

  78. declare(strict_types = 1);
    namespace App;
    use App\Entity\Customer;
    use App\Entity\Invoice;
    use App\Invoice\Exporter;
    use App\Invoice\Mailer;
    use App\Payment\Exception as PaymentException;
    use App\Payment\Provider as PaymentProvider;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Console\Helper\ProgressBar;
    use Symfony\Component\Console\Output\OutputInterface;
    class BillingRun
    {
    private $entityManager;
    private $paymentProvider;
    private $mailer;
    private $exporter;
    public function __construct(
    EntityManagerInterface $entityManager,
    PaymentProvider $paymentProvider,
    Mailer $mailer,
    Exporter $exporter

    View Slide

  79. BillingRun depends on
    Symfony\Component\Console

    View Slide

  80. Really?

    View Slide

  81. NOPE!

    View Slide

  82. Decouple business logic
    from Framework

    View Slide

  83. Tool-Tip
    Deptrac
    $ composer req sensiolabs-de/deptrac

    View Slide

  84. Logging

    View Slide

  85. Perfect for
    background jobs

    View Slide

  86. class BillingRun
    {
    private $entityManager;
    private $paymentProvider;
    private $mailer;
    private $exporter;
    private $logger;
    public function __construct(
    EntityManagerInterface $entityManager,
    PaymentProvider $paymentProvider,
    Mailer $mailer,
    Exporter $exporter,
    LoggerInterface $logger
    ) {
    $this->entityManager = $entityManager;
    $this->paymentProvider = $paymentProvider;
    $this->mailer = $mailer;
    $this->exporter = $exporter;
    $this->logger = $logger;
    }
    public function start(\DateTimeImmutable $period): void
    {
    $customers = $this->fetchActiveCustomer();
    $this->logger->info(sprintf('Loaded %d customers to process', count($customers)));
    $invoices = $this->generateInvoice($customers, $period);
    $this->payInvoices($invoices);
    $this->sendInvoices($invoices);

    View Slide

  87. View Slide

  88. Verbosity Level

    View Slide

  89. Quiet
    •-q or --quiet
    •no output
    •logging >= error

    View Slide

  90. Normal
    •all output
    •logging >= warning

    View Slide

  91. Verbose
    •-v
    •all output
    •logging >= notice

    View Slide

  92. Very Verbose
    •-vv
    •all output
    •logging >= info

    View Slide

  93. Debug
    •-vvv
    •all output
    •all logs + extended context

    View Slide

  94. Output

    View Slide

  95. ProgressBar?

    View Slide

  96. easy way out
    callable

    View Slide

  97. public function start(\DateTimeImmutable $period, callable $progress, callable $error):
    {
    $customers = $this->entityManager->getRepository(Customer::class)->findByActive(tru
    $customerNum = count($customers);
    $invoices = [];
    foreach ($customers as $i => $customer) {
    $invoice = Invoice::forCustomer($customer, $period);
    try {
    $this->paymentProvider->authorize($invoice);
    } catch (PaymentException $exception) {
    $error($exception);
    }
    $this->mailer->sendInvoice($invoice);
    $this->entityManager->persist($invoice);
    $invoices[] = $invoice;
    $progress(++$i, $customerNum);
    }
    $this->exporter->export($period, $invoices);
    $this->entityManager->flush();
    }

    View Slide

  98. protected function execute(InputInterface $input, OutputInterface $output): int
    {
    $period = $input->getArgument('period');
    $output->writeln(sprintf('Start billing run for %s', $period->format('m-Y')));
    $output->writeln('============================='.PHP_EOL);
    $progress = new ProgressBar($output);
    $onProgress = function (int $count, int $max) use ($output, $progress) {
    $this->onProgress($output, $progress, $count, $max);
    };
    $onError = function (PaymentException $exception) use ($output) {
    $output->writeln(''.$exception->getMessage().'');
    };
    $this->billingRun->start($period, $onProgress, $onError);
    $output->writeln(['', 'Done.', '']);
    return 0;
    }

    View Slide

  99. Alternative
    BillingRun Events

    View Slide

  100. Alternative
    BillingRun Observer

    View Slide

  101. View Slide

  102. SensioLabs
    Features

    View Slide

  103. SymfonyStyle

    View Slide

  104. Helper Class on top
    of Input & Output

    View Slide

  105. View Slide

  106. Sections
    NEW IN SYMFONY 4.1

    View Slide

  107. $ bin/console app:billing:run 05-2018
    Section Progress
    Section Errors

    View Slide

  108. View Slide

  109. View Slide

  110. SensioLabs
    Thank You!

    View Slide

  111. Feedback
    //joind.in/talk/36239

    View Slide

  112. Questions?

    View Slide