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

0bee6a2886272e60be8888ae48baf42d?s=128

Christopher Hertel

May 04, 2018
Tweet

Transcript

  1. SensioLabs Better Console Applications

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

    @el_stoffel
  3. Console Applications

  4. Console Commands

  5. None
  6. CLI SAPI

  7. None
  8. None
  9. SensioLabs Console Component

  10. symfony/console ~ 85.000.000 Downloads

  11. CLI Application Framework

  12. $ composer req symfony/console Installation

  13. Application

  14. app

  15. chmod +x ./app

  16. None
  17. Command

  18. None
  19. More Dependencies Use Symfony Flex

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

  21. bin/console

  22. php bin/console hello

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

  24. Tool-Tip Collision

  25. SensioLabs Application Types

  26. Jobs

  27. • running in background • no interaction • controlled by

    server • e.g. queue workers
  28. Helper

  29. • running in foreground • interaction possible • controlled by

    user • e.g. generator or debugging CMDs
  30. Tools

  31. • combining both • w/ or w/o interaction • used

    while development & CI • e.g. Composer, PHPUnit
  32. We rarely build tools

  33. Simple Question: Who executes this command?

  34. SensioLabs Example

  35. Billing Run

  36. • Generate Invoices • Charge payment • Generate PDF •

    Mail to customer • Export postal data
  37. • Console Command • Executed monthly • Executed by developer

  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▪▪▪▪▪ <c▪▪▪▪▪▪▪▪▪▪.h▪▪▪▪▪@▪▪▪▪▪▪▪▪▪▪.de> */ 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');
  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('<info>Start billing run for %s</info>', $period->for
  40. $period = $questionHelper->ask($input, $output, $question); } $output->writeln(sprintf('<info>Start billing run for

    %s</info>', $period->for $output->writeln('============================='.PHP_EOL); $customers = $this->fetchActiveCustomer(); $output->writeln(sprintf('<info>Loaded %d customers to process</info>'.PHP_EOL $invoices = $this->generateInvoice($output, $customers, $period); $this->payInvoices($output, $invoices); $this->sendInvoices($output, $invoices); $this->exportMagazines($output, $period, $invoices); $output->writeln(['', '<info>Done.</info>', '']); if ('dev' === $this->getContainer()->getParameter('kernel.environment')) { $output->writeln((string) $stopwatch->stop('billing-run')); } return 0; } /** * @return Customer[] */ private function fetchActiveCustomer(): array {
  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('<comment>Generated %d invoices to pay</comment>'.PHP $entityManager->flush(); return $invoices; }
  42. None
  43. It's working

  44. But … umm

  45. Let's refactor

  46. Testing!

  47. ApplicationTester CommandTester

  48. Helper to execute an Application or Command

  49. Easily combined with KernelTestCase

  50. None
  51. None
  52. SensioLabs Input Interaction

  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▪▪▪▪▪ <c▪▪▪▪▪▪▪▪▪▪.h▪▪▪▪▪@▪▪▪▪▪▪▪▪▪▪.de> */ 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');
  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('<info>Start billing run for %s</info>', $period->for
  55. class ExampleCommand extends Command { protected function configure(): void {

    // TODO: IMPLEMENT } protected function execute(InputInterface $input, OutputInterface $output): int { // TODO: IMPLEMENT } }
  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
  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); }
  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('<info>Start billing run for %s</info>', $period->format(' $output->writeln('============================='.PHP_EOL); // ... protected function configure(): void { $this->setName('app:billing:run'); $this->addArgument('period', InputArgument::REQUIRED, 'Billing Period'); }
  59. SensioLabs Console Events

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

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

    on error
  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('<info>Start billing run for %s</info>', $period->format( $output->writeln('============================='.PHP_EOL); // ... $output->writeln(['', '<info>Done.</info>', '']); if ('dev' === $this->getContainer()->getParameter('kernel.environment')) { $output->writeln((string) $stopwatch->stop('billing-run')); } return 0; }
  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();
  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)); } }
  65. protected function execute(InputInterface $input, OutputInterface $output): int { $period =

    $input->getArgument('period'); $output->writeln(sprintf('<info>Start billing run for %s</info>', $period->format('m-Y'))); $output->writeln('============================='.PHP_EOL); $customers = $this->fetchActiveCustomer(); $output->writeln(sprintf('<info>Loaded %d customers to process</info>'.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(['', '<info>Done.</info>', '']); return 0; }
  66. SensioLabs Command == Glue Code

  67. No business logic in a command

  68. protected function execute(InputInterface $input, OutputInterface $output): int { $period =

    $input->getArgument('period'); $output->writeln(sprintf('<info>Start billing run for %s</info>', $period->format('m-Y'))); $output->writeln('============================='.PHP_EOL); $customers = $this->fetchActiveCustomer(); $output->writeln(sprintf('<info>Loaded %d customers to process</info>'.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(['', '<info>Done.</info>', '']); return 0; }
  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('<comment>Generated %d invoices to pay</comment>'.PHP_EOL, count($i $entityManager->flush(); return $invoices; }
  70. Move business logic to service layer

  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('<info>Start billing run for %s</info>', $period->format('m-Y' $output->writeln('============================='.PHP_EOL); $this->billingRun->start($period, $output); $output->writeln(['', '<info>Done.</info>', '']); return 0; }
  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('<info>Loaded %d customers to process</info>'.PHP_EOL, count($c $invoices = $this->generateInvoice($output, $customers, $period); $this->payInvoices($output, $invoices); $this->sendInvoices($output, $invoices); $this->exportMagazines($output, $period, $invoices); }
  73. Cleaner Dependencies

  74. Easier Testing

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

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

    */ public function benchConsume() { // ... } }
  77. SensioLabs Output

  78. <?php 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
  79. BillingRun depends on Symfony\Component\Console

  80. Really?

  81. NOPE!

  82. Decouple business logic from Framework

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

  84. Logging

  85. Perfect for background jobs

  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);
  87. None
  88. Verbosity Level

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

  90. Normal •all output •logging >= warning

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

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

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

  94. Output

  95. ProgressBar?

  96. easy way out callable

  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(); }
  98. protected function execute(InputInterface $input, OutputInterface $output): int { $period =

    $input->getArgument('period'); $output->writeln(sprintf('<info>Start billing run for %s</info>', $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('<error>'.$exception->getMessage().'</error>'); }; $this->billingRun->start($period, $onProgress, $onError); $output->writeln(['', '<info>Done.</info>', '']); return 0; }
  99. Alternative BillingRun Events

  100. Alternative BillingRun Observer

  101. None
  102. SensioLabs Features

  103. SymfonyStyle

  104. Helper Class on top of Input & Output

  105. None
  106. Sections NEW IN SYMFONY 4.1

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

  108. None
  109. None
  110. SensioLabs Thank You!

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

  112. Questions?