Illustration of a bird flying.

Zend Framework Console


Hoje venho falar sobre um elemento muito bacana do Zend Framework, o Console. Com ele você pode criar poderosas aplicações de linha de comando de forma organizada e tirando proveito do melhor que o ZF pode lhe oferecer.

Iniciaremos um projeto com o Zend Skeleton Application para que você veja como é simples começar com o Console.

Instalando o Zend Skeleton Application

Por mais que esta etapa seja muito simples, a replicarei aqui para caso você tenha chego a este post sem ler nenhum dos meus anteriores sobre o ZF.

$ composer create-project -sdev zendframework/skeleton-application path/to/install

Algumas perguntas serão feitas, pode seguir com as respostas da mesma forma que apresento aqui.

Do you want a minimal install (no optional packages)? Y/n – responda n.

Would you like to install the developer toolbar? y/N – pode ser N, somente utilizaremos a linha de comando.

Would you like to install caching support? y/N – pode responder N.

Would you like to install database support (installs zend-db)? y/N – pode ser N.

Would you like to install forms support? y/N – indiferente, eu respondi N.

Would you like to install JSON de/serialization support? y/N – mais uma vez N.

Would you like to install logging support? y/N – não precisaremos para este exemplo, N.

Would you like to install MVC-based console support? (We recommend migrating to zf-console, symfony/console, or Aura.CLI) y/N – responda y.

Would you like to install the official MVC plugins, including PRG support, identity, and flash messages? y/N – pode ser N também.

Would you like to use the PSR-7 middleware dispatcher? y/N – de momento responda N.

Would you like to install sessions support? y/N – N de novo.

Would you like to install MVC testing support? y/N – N, não serão abordados testes aqui.

Would you like to install the zend-di integration for zend-servicemanager? y/N – pode responder y.

Please select which config file you wish to inject ‘Zend\Mvc\Console’ into:
[0] Do not inject
[1] config/modules.config.php
[2] config/development.config.php.dist
Make your selection (default is 0): – responda 1.

Remember this option for other packages of the same type? (y/N) – responda y para que outros módulos semelhantes sejam injetados no mesmo arquivo.

Do you want to remove the existing VCS (.git, .svn..) history? [Y,n]? – responda Y e pronto! O Zend já está instalado e pronto para uso.

Hello World

Agora que estamos com o Skeleton Application instalado, vamos configurar nossa primeira rota para o Console. Neste momento faremos apenas um “hello world”. Iniciamos tudo criando uma rota.

No arquivo module/Application/config/module.config.php adicione a seguinte configuração, pode ser bem ao final, depois de view_manager.

'view_manager' => [/* ... */],
'console' => [
    'router' => [
        'routes' => [
            'hello-world' => [
                'options' => [
                    'route'    => 'hello-world',
                    'defaults' => [
                        'controller' => HelloWorldConsole::class,
                        'action'     => 'index'
                    ]
                ]
            ]
        ]
    ]
]

Note que temos em controller, uma classe que 1) ainda não foi mapeada nos controllers e, 2) sequer existe. Vamos resolver isso.

Nas configurações de controllers, ainda no arquivomodule/Application/config/module.config.php, mapeie o HelloWorldConsole.

'controllers' => [
    'factories' => [
        Controller\IndexController::class => InvokableFactory::class,
        HelloWorldConsole::class => InvokableFactory::class
    ]
],

E nos imports, isso:

use Application\Console\HelloWorldConsole;

Nosso controller

Crie um diretório chamado Console em module/Application/src. Ou seja, module/Application/src/Console. Nela, crie a classe HelloWorldConsole conforme abaixo.

<?php

namespace Application\Console;

use Zend\Mvc\Console\Controller\AbstractConsoleController;

/**
 * Class HelloWorldConsole
 * @package Application\Console
 */
class HelloWorldConsole extends AbstractConsoleController
{
    public function indexAction()
    {
        return 'hello world!' . PHP_EOL;
    }
}

Simples não?! Se você rodar pelo terminal o comando php public/index.php hello-world, verá o resultado do que acabamos de criar.

Notas da primeira parte

Perceba que ao invés de utilzar como um controller, defini um namespace específico para o Console. Isso fiz para organizar o que é utilizado em linha de comando e o que vem a ser lógica de aplicação web. Fica a seu critério separar os namespaces, prefiro assim por ser mais fácil de dar manutenção e por estar com as responsabilidades separadas.

Na próxima etapa deixaremos as coisas mais interessantes ao ponto de que, até o final deste post, você tenha conhecimento para criar aplicações poderosas via linha de comando.

Garantindo somente cli requests

Da forma como definimos a rota não é possível acessar via web, veja o exemplo.

Isso porque a rota foi definida desta forma e já é seguro garantir que a mesma funcione somente via linha de comando. No entanto podemos deixar as coisas mais seguras ainda.

<?php

namespace Application\Console;

// importando o Request de Console e o apelidando de ConsoleRequest
use Zend\Console\Request as ConsoleRequest;
use Zend\Mvc\Console\Controller\AbstractConsoleController;

/**
 * Class HelloWorldConsole
 * @package Application\Console
 */
class HelloWorldConsole extends AbstractConsoleController
{
    public function indexAction()
    {
        // obtendo o objeto da requisição
        $request = $this->getRequest();

        // e garantindo que o acesso será somente via linha de comando
        if (! $request instanceof ConsoleRequest) {
            throw new \RuntimeException('Rota válida somente para linha de comando');
        }

        return 'hello world!' . PHP_EOL;
    }
}

Isso não modifica em nada o que já temos funcionando, mas impede que por engano este controller seja utilizado em rotas web.

Parâmetros

Em um cenário real pode ser necessário utilizar parâmetros para realizar ações com determinadas condições e é bom saber que existem algumas possibilidades.

  • Parâmetros obrigatórios
  • Parâmetros opcionais
  • Parâmetros de flag

Os parâmetros obrigatórios são essenciais para o correto funcionamento do comando, já os opcionais podem ser deixados de lado sem que o comando acuse erros. Já os comandos de flag pode também ser obrigatórios e opcionais.

Parâmetro obrigatório

Consiste em definir o nome do parâmetro preferencialmente sem espaços em branco entre os sinais de menor/maior. Podem ser utilizados caracteres como hífen e underline como separador: <primeiro_nome> ou <primeiro-nome>.

'console' => [
    'router' => [
        'routes' => [
            'hello-world' => [
                'options' => [
                    'route'    => 'hello-world <nome>',
                    'defaults' => [
                        'controller' => HelloWorldConsole::class,
                        'action'     => 'index'
                    ]
                ]
            ]
        ]
    ]
]

Parâmetro opcional

Para que um parâmetro seja considerado opcional, basta o informar entre colchetes [ ]. Veja o exemplo com sobrenome.

'console' => [
    'router' => [
        'routes' => [
            'hello-world' => [
                'options' => [
                    'route'    => 'hello-world <nome> [<sobrenome>]',
                    'defaults' => [
                        'controller' => HelloWorldConsole::class,
                        'action'     => 'index'
                    ]
                ]
            ]
        ]
    ]
]

Parâmetro de flag

Por diversas vezes precisaremos de uma estrutura de condicional, como por exemplo, exibir log ou mesmo a data e hora em que um determinado processo se iniciou e finalizou. Podemos fazer isso utilizando flags, e elas também podem ser obrigatórias ou opcionais, seguindo o mesmo conceito dos demais parâmetros já apresentados.

Para definir uma flag utilizamos o prefixo — (dois hífens) seguido do nome do parâmetro, exemplo: –show_date é uma flag obrigatória enquanto que [–show_date] é opcional. Em ambos os casos o valor recuperado no controller é um booleano (true ou false).

Complementando nossa rota.

'console' => [ 
    'router' => [ 
        'routes' => [ 
            'hello-world' => [ 
                'options' => [ 
                    'route' => 'hello-world <nome> [<sobrenome>] [--show_date]', 
                    'defaults' => [ 
                        'controller' => HelloWorldConsole::class, 
                        'action' => 'index' 
                    ] 
                ] 
            ] 
        ] 
    ] 
]

 

No controller

Agora que nossa rota já foi definida com os parâmetros necessários, podemos os recuperar em nosso controller.

public function indexAction()
{
    // obtendo o objeto da requisição
    $request = $this->getRequest();

    // e garantindo que o acesso será somente via linha de comando
    if (!$request instanceof ConsoleRequest) {
        throw new \RuntimeException('Rota válida somente para linha de comando');
    }

    $nome = $request->getParam('nome');
    $sobrenome = $request->getParam('sobrenome');
    $showDate = (bool)$request->getParam('show_date', false);

    $outputDate = '';
    if ($showDate) {
        $outputDate = ' ' . date('Y-m-d H:i:s');
    }

    return 'hello ' . $nome . ' ' . $sobrenome . $outputDate . PHP_EOL;
}

E o resultado

 

Shortcut

Note que até agora utilizamos o comando chamando o arquivo public/index.php e se você quiser deixar sua aplicação mais com cara de uma aplicação CLI, basta criar um atalho. Crie uma pasta chamada bin na raiz de sua aplicação e dentro dela um arquivo chamado app. Torne-o executável.

mkdir bin
touch bin/app
chmod +x bin/app

No arquivo app, adicione o seguinte conteúdo:

#!/usr/bin/env php
<?php

include __DIR__ . '/../public/index.php';

A partir de agora, ao invés de rodar o comando php public/index.php, basta rodar ./bin/app. Deixa muito mais intuitivo para uma aplicação via linha de comando.

Console usage

Até o momento criamos somente uma rota para uso na linha de comando e, mesmo com esta simples rota já é fácil de encontrar complexidade. Pense no seguinte: Você criou esta rota hoje, tudo está fresco em sua memória e é fácil de você a utilizar quantas vezes forem necessário. Algumas semanas se passaram e você precisa novamente usar o comando criado. Você lembra quais foram os parâmetros definidos? Eram obrigatórios? Quantos eram? Arrisco a dizer que você teve de retornar no conteúdo do controller ou da rota pra ver quais eram 😉

Isso é muito ruim. Somos seres humanos, esquecemos das coisas! Antes de iniciarmos este assunto, rode o comando ./bin/app sem parâmetro algum. O resultado será esse:

Note que não existe nada, agora pense em uma configuração de rotas como esta da imagem a seguir. É uma pequena parte das dezenas de rotas de uma aplicação real que desenvolvi recentemente.

Pra nossa sorte que o ZF pensou em tudo e nos disponibilizou um meio de indicar a forma de utilização de rotas do Console, a ConsoleUsageProviderInterface. No arquivo module/Application/src/Module.php implemente a interface.

<?php

namespace Application;

use Zend\Console\Adapter\AdapterInterface;
use Zend\ModuleManager\Feature\ConsoleUsageProviderInterface;

class Module implements ConsoleUsageProviderInterface
{
    const VERSION = '3.0.3-dev';

    public function getConfig()
    {
        return include __DIR__ . '/../config/module.config.php';
    }

    public function getConsoleUsage(AdapterInterface $console)
    {
        return [
            'hello-world <nome> [<sobrenome>] [--show_date]' => 'Apresenta uma saudacao ao usuario'
        ];
    }
}

Agora se rodar novamente o comando ./bin/app, temos novidades.

Melhorou né? Mas sabia que podemos ir além?

Simplificando/dinamizando o Console Usage

Por mais que o ZF facilite nossa vida disponibilizando a interface do Console Usage, ainda temos um problema: temos dois lugares pra cuidar, um é para a definição da rota e o outro para os detalhes de uso. Ao longo do tempo isso pode causar falta de informações ou informações desatualizadas.

Pensando nisso, tenho a seguinte abordagem em meus projetos: as informações de uso são definidas no mesmo local das rotas. Assim elas são carregadas dinamicamente e, a cada inclusão, atualização ou remoção de rota, tenho de tratar em um ponto centralizado. Acompanhe o racioncínio.

Em uma imagem anterior mostrei as rotas de uma aplicação real que desenvolvi, você talvez tenha percebido que existia uma chave chamada usage_info. Ela faz parte do mecanismo que criei. Com isso em mente, nossa rota atual ficaria assim:

'console' => [
    'router' => [
        'routes' => [
            'hello-world' => [
                'options' => [
                    'route'    => 'hello-world <nome> [<sobrenome>] [--show_date]',
                    'defaults' => [
                        'controller' => HelloWorldConsole::class,
                        'action'     => 'index'
                    ]
                ],
                'usage_info' => 'Apresenta uma saudacao ao usuario. Obrigatorio: <nome>. Opcionais: [<sobrenome>] e [--show_date]'
            ]
        ]
    ]
]

E pra que nossa aplicação reconheça automaticamente a rota e os detalhes de uso, criei uma lógica no Module.php.

public function getConsoleUsage(AdapterInterface $console)
{
    // carrega as configurações do módulo
    $config = $this->getConfig();

    // obtém as rotas do console
    $consoleRoutes = $config['console']['router']['routes'];

    $usages = [];
    foreach ($consoleRoutes as $route) {
        // adiciona a rota juntamente com sua forma de uso
        $usages[$route['options']['route']] = $route['usage_info'];
    }

    // retorna todas as configurações adicionadas dinamicamente
    return $usages;
}

E o resultado é esse:

Agora podemos adicionar mais rotas para testar o carregamento dinâmico.

'console' => [
    'router' => [
        'routes' => [
            'hello-world' => [
                'options' => [
                    'route'    => 'hello-world <nome> [<sobrenome>] [--show_date]',
                    'defaults' => [
                        'controller' => HelloWorldConsole::class,
                        'action'     => 'index'
                    ]
                ],
                'usage_info' => 'Apresenta uma saudacao ao usuario. Obrigatorio: <nome>. Opcionais: [<sobrenome>] e [--show_date]'
            ],
            'status' => [
                'options' => [
                    'route'    => 'status',
                    'defaults' => [
                        'controller' => HelloWorldConsole::class,
                        'action'     => 'status'
                    ]
                ],
                'usage_info' => 'Apresenta um status atualizado da aplicacao'
            ]
        ]
    ]
]

Pode parecer algo dispensável, mas num cenário real, pode ser extremamente útil, veja só.

O que vem depois?

Recapitulando o que vimos neste post:

  • Zend Console
  • Definição de rotas de console
  • Garantindo que os controllers serão utilizados somente via linha de comando
  • Criação de parâmetros (obrigatórios, opcionais e flags)
  • Recuperação de parâmetros no controller
  • Criação de atalho para ./bin/app
  • Console Usage – Forma padrão de informar os detalhes de uso dos comandos
  • Console Usage – Tornando os detalhes de uso dinâmicos e evitando informações desatualizadas

Com tudo isso você já sabe o essencial para desenvolver poderosas aplicações em linha de comando utilizando o Zend Framework. O restante da lógica nos controllers segue exatamente à risca o que você já faz nos controllers para as rotas web. Você pode buscar serviços, repositórios, banco de dados, etc.

Um case real

Recentemente criei uma aplicação mista com PHP puro e ZF. Toda a parte web permaneceu com o PHP puro que já rodava na versão anterior do projeto e apenas foi melhorado, já tudo o que foi desenvolvido para rodar em background foi baseado no Zend Console. A aplicação possui 6 clientes (multi tenant) e cada qual possui mais de 10 comandos para sincronizar dados entre a base dos clientes, sistemas internos do cliente, bases locais e ferramentas de marketing.

Documentação (ou falta dela) e redundância

O que eu tinha quando assumi o projeto eram 4 arquivos distribuídos entre PHP, python e node js espalhados em 5 instâncias amazon. Nenhum possuía documentação, com isso nunca soube como os executar sem erros. Alguns deles (node e python) sequer possuíam documentação sobre o ambiente, simplesmente na instância rodavam bem mas não consegui replicar a execução em uma maquina de desenvolvimento sem perder uma semana só resolvendo instalações de libs para que funcionassem do começo ao fim.

Um dos arquivos em PHP possuía as lógicas de buscar dados na base do cliente, gravar na local, atualizar no sistema do cliente e ainda sincronizar a ferramenta de marketing. Se em um destes passos houvesse erro, todo o restante não funcionava. E acredite,  vendo os logs assim que assumi o projeto, vi que há meses o arquivo não executava até o fim.

Por fim, eram 4 arquivos que realizavam todas as ações do sistema, em 5 instâncias da amazon, rodando com 3 usuários distintos. Dá pra imaginar como foi difícil saber o que era necessário ali né?! Ah, e tinha também as versões do ano anterior que ainda estavam rodando por algum motivo.

Um recomeço

Devido à complexidade do ambiente descrito anteriormente (4 arquivos em 5 instâncias com 3 usuários e multiplicados por 2, referente ao ano anterior e o atual), decidi jogar tudo fora. Mal utilizei o know how dos devs anteriores. Permaneci apenas com a camada web, que tratava somente da visualização dos dados.

Todas as ações em background foram satisfatoriamente desenvolvidas utilizando todos os conceitos que apresentei ao longo deste post e, como feedback, digo que o resultado foi sensacional. O projeto agora está estruturado com um framework, o que garante documentação e padronização, diferente do que encontrei. Também todos os comandos possuem explicações e os cron jobs foram todos mapeados e adicionados no repositório. Assim que outro dev prosseguir, basta ler sobre a estrutura do ZF, rodar o comando ./bin/app e tudo já está explicado, é só seguir o desenvolvimento ou manutenção.

Sempre pense no próximo 😉 documente! Quando assumi o projeto levei quase 2 meses pra entender o que os 3 devs anteriores pensaram no momento em que escreveram e a única coisa que ficou clara foi que um deles viajava demais, hora em Curitiba, hora em São Paulo, depois em Santos… mas escrever o que veio à sua cabeça nestas viagens todas parece que não era uma preocupação.