Erika Heidi
Posted on December 10, 2019
Introduction
In the previous episode of the Building Minicli series, we have refactored the initial version of minicli
to support commands defined in classes, with an architecture that uses Command Controllers.
In this new guide, we are going to implement Command Namespaces to organize Controllers and create a standard directory structure and naming conventions that can be leveraged for autoloading commands during application boot. This is a common approach used in web PHP frameworks to facilitate app development and reduce the amount of code necessary when bootstrapping a new application.
Our refactoring will go over the following steps:
- Implement the new
CommandNamespace
class and refactorCommandRegistry
accordingly. - Outsource command parsing to a new
CommandCall
class. - Update the
App
class to support the changes. - Update the abstract
CommandController
class and the concrete controllers in order to support the rest of the work. - Update and run the
minicli
script.
This is Part 3 of the Building Minicli series.
Before Getting Started
You'll need php-cli
and Composer to follow this tutorial. You are strongly encouraged to start with the first tutorial in this series and then move through the second part before following this guide.
In case you want a clean base copy of minicli
to follow this tutorial, download version 0.1.2
of erikaheidi/minicli to bootstrap your setup:
wget https://github.com/erikaheidi/minicli/archive/0.1.2.zip
unzip 0.1.2.zip
cd minicli
Then, run Composer to set up autoload. This won't install any package, because minicli
has no dependencies.
composer dump-autoload
Run the application with:
php minicli
or
chmod +x minicli
./minicli
1. Implementing Command Namespaces
In the current application design, each command is an individual Controller. This is a nice way to keep commands organized and under a "contract", instead of having multiple commands all mixed together in a single Controller. This is how the demo controller HelloController
looks like:
<?php
namespace App\Command;
use Minicli\CommandController;
class HelloController extends CommandController
{
public function run($argv)
{
$name = isset ($argv[2]) ? $argv[2] : "World";
$this->getApp()->getPrinter()->display("Hello $name!!!");
}
}
Our current CommandRegistry
keeps a record of Command Controllers that are manually registered when bootstrapping the application. The getCallable
method is responsible for figuring out what callable needs to be executed by the application:
public function getCallable($command_name)
{
$controller = $this->getController($command_name);
if ($controller instanceof CommandController) {
return [ $controller, 'run' ];
}
$command = $this->getCommand($command_name);
if ($command === null) {
throw new \Exception("Command \"$command_name\" not found.");
}
return $command;
}
The problem with this approach is that it can get quite messy and confusing for users if you have many commands that are related to each other but with completely different names.
We want to implement common command entry points to keep related commands organized. Take the example of docker
:
docker image [ import | build | history | ls | pull | prune ... ]
docker container [ build | info | kill | pause | rename | rm ... ]
The image
command serves as a common namespace for all commands that deal with Docker images. The same is valid for container
and other Docker commands.
We'll create a new CommandNamespace
class that will keep a registry of application Controllers under a common name. We'll then modify the CommandRegistry
class to work directly with Command Namespaces, and leave the work of registering and loading Controllers to these new entities. To expand the new design even further while simplifying application bootstrap, we will implement a standard directory structure that will facilitate autoloading Command Namespaces and Controllers into the application.
This is how our new architecture will look like:
app/Command
└── Command1
├── DefaultController.php
├── OtherController.php
└── AnyController.php
└── Command2
└── AnotherController.php
└── Command3
└── RandomController.php
...
This is an expressive way of organizing commands while also facilitating automatic loading, which reduces the amount of code you have to write in order to include new commands into the application. Each Controller is a new subcommand under the designated Namespace. The name of each subcommand is obtained from the Controller class name, and the DefaultController is automatically used when no subcommand is provided in the command call. A directory structure like that would yield the following command "map":
./minicli command1 [ other | any ]
./minicli command2 another
./minicli command3 random
Let's start by creating the new CommandNamespace
class.
The CommandNamespace
Class
Open a new file at minicli/lib/CommandNamespace
using your code editor of choice.
lib/CommandNamespace.php
The CommandNamespace
class will have a name and an array containing Controllers mapped into subcommands.
The loadControllers
method will leverage the standard directory structure and naming conventions we defined to create a map of all Controllers under that namespace.
Copy the following code to your CommandNamespace
class:
<?php
namespace Minicli;
class CommandNamespace
{
protected $name;
protected $controllers = [];
public function __construct($name)
{
$this->name = $name;
}
public function getName()
{
return $this->name;
}
public function loadControllers($commands_path)
{
foreach (glob($commands_path . '/' . $this->getName() . '/*Controller.php') as $controller_file) {
$this->loadCommandMap($controller_file);
}
return $this->getControllers();
}
public function getControllers()
{
return $this->controllers;
}
public function getController($command_name)
{
return isset($this->controllers[$command_name]) ? $this->controllers[$command_name] : null;
}
protected function loadCommandMap($controller_file)
{
$filename = basename($controller_file);
$controller_class = str_replace('.php', '', $filename);
$command_name = strtolower(str_replace('Controller', '', $controller_class));
$full_class_name = sprintf("App\\Command\\%s\\%s", $this->getName(), $controller_class);
/** @var CommandController $controller */
$controller = new $full_class_name();
$this->controllers[$command_name] = $controller;
}
}
Save the file when you're done.
The CommandRegistry
class
Open the existing CommandRegistry
class on your editor:
lib/CommandRegistry.php
The CommandRegistry
class will now outsource to Command Namespaces the work of registering and locating Controllers. Because the application implements a standard directory structure and naming conventions, we can locate all Command Namespaces currently defined - this is done in the autoloadNamespaces
method.
To keep compatibility with single commands registered via anonymous functions, which can be very handy and facilitate single-command apps, we will keep a default_registry
array to register commands that way, too. Another important change is that we now have a getCallableController
in addition to getCallable
. The Application will decide which one to use, and when.
This is how the updated CommandRegistry
class looks like:
<?php
namespace Minicli;
class CommandRegistry
{
protected $commands_path;
protected $namespaces = [];
protected $default_registry = [];
public function __construct($commands_path)
{
$this->commands_path = $commands_path;
$this->autoloadNamespaces();
}
public function autoloadNamespaces()
{
foreach (glob($this->getCommandsPath() . '/*', GLOB_ONLYDIR) as $namespace_path) {
$this->registerNamespace(basename($namespace_path));
}
}
public function registerNamespace($command_namespace)
{
$namespace = new CommandNamespace($command_namespace);
$namespace->loadControllers($this->getCommandsPath());
$this->namespaces[strtolower($command_namespace)] = $namespace;
}
public function getNamespace($command)
{
return isset($this->namespaces[$command]) ? $this->namespaces[$command] : null;
}
public function getCommandsPath()
{
return $this->commands_path;
}
public function registerCommand($name, $callable)
{
$this->default_registry[$name] = $callable;
}
public function getCommand($command)
{
return isset($this->default_registry[$command]) ? $this->default_registry[$command] : null;
}
public function getCallableController($command, $subcommand = null)
{
$namespace = $this->getNamespace($command);
if ($namespace !== null) {
return $namespace->getController($subcommand);
}
return null;
}
public function getCallable($command)
{
$single_command = $this->getCommand($command);
if ($single_command === null) {
throw new \Exception(sprintf("Command \"%s\" not found.", $command));
}
return $single_command;
}
}
Save the file when you're done updating its content.
2. Outsourcing Command Parsing to the CommandCall
Class
To facilitate parsing commands, subcommands and other parameters, we'll create a new class named CommandCall
.
Open a new file:
lib/CommandCall.php
The CommandCall
class works as a simple abstraction to the command call and provides a way to parse named parameters, such as user=name
.
It is handy because it keeps these values in a typed object that gives us more control over what is forwarded to the commands controllers. It can be expanded in the future for more complex parsing.
The CommandCall
class
Copy the following contents to your new CommandCall
class:
<?php
namespace Minicli;
class CommandCall
{
public $command;
public $subcommand;
public $args = [];
public $params = [];
public function __construct(array $argv)
{
$this->args = $argv;
$this->command = isset($argv[1]) ? $argv[1] : null;
$this->subcommand = isset($argv[2]) ? $argv[2] : 'default';
$this->loadParams($argv);
}
protected function loadParams(array $args)
{
foreach ($args as $arg) {
$pair = explode('=', $arg);
if (count($pair) == 2) {
$this->params[$pair[0]] = $pair[1];
}
}
}
public function hasParam($param)
{
return isset($this->params[$param]);
}
public function getParam($param)
{
return $this->hasParam($param) ? $this->params[$param] : null;
}
}
Save the file when you're done.
3. Updating the App
Class
To accommodate the changes in the CommandRegistry
, we'll need to also update the App
class. Open the file with:
lib/App.php
The runCommand
method now will first call the getCallableController
method in the CommandRegistry
class; if a controller is found, it will execute three distinct methods in this order: boot
, run
, and teardown
. If a controller can't be found, it probably means that namespace doesn't exist, and it's actually a single command. We'll try to find a single command and run its respective callable, otherwise the app will exit with an error.
There's also a new app_signature
property that lets us customize a one-liner to tell people how to use the app.
The App
Class
Following, the contents of the updated App
class:
<?php
namespace Minicli;
class App
{
protected $printer;
protected $command_registry;
protected $app_signature;
public function __construct()
{
$this->printer = new CliPrinter();
$this->command_registry = new CommandRegistry(__DIR__ . '/../app/Command');
}
public function getPrinter()
{
return $this->printer;
}
public function getSignature()
{
return $this->app_signature;
}
public function printSignature()
{
$this->getPrinter()->display(sprintf("usage: %s", $this->getSignature()));
}
public function setSignature($app_signature)
{
$this->app_signature = $app_signature;
}
public function registerCommand($name, $callable)
{
$this->command_registry->registerCommand($name, $callable);
}
public function runCommand(array $argv = [])
{
$input = new CommandCall($argv);
if (count($input->args) < 2) {
$this->printSignature();
exit;
}
$controller = $this->command_registry->getCallableController($input->command, $input->subcommand);
if ($controller instanceof CommandController) {
$controller->boot($this);
$controller->run($input);
$controller->teardown();
exit;
}
$this->runSingle($input);
}
protected function runSingle(CommandCall $input)
{
try {
$callable = $this->command_registry->getCallable($input->command);
call_user_func($callable, $input);
} catch (\Exception $e) {
$this->getPrinter()->display("ERROR: " . $e->getMessage());
$this->printSignature();
exit;
}
}
}
Save the file when you're done updating its content.
4. Refactoring Abstract and Concrete Command Controllers
Now it's time to update the abstract class that is inherited by our Controllers, in order to include a few handy methods to retrieve parameters and to work as shortcut for accessing Application components such as the Printer.
Open the CommandController
class:
lib/CommandController.php
Under the new "contract" , Controllers will have to implement a method named handle
. Externally, nothing will change: run
is still the public method that will be executed from the App
class. The change is to enable intercepting the CommandCall
data and make it available for all protected controller methods.
The teardown
method is optional and for that reason is empty, so it can be overwritten in children controllers.
The CommandController
Class
Following, the contents of the updated CommandController
abstract class:
<?php
namespace Minicli;
abstract class CommandController
{
protected $app;
protected $input;
abstract public function handle();
public function boot(App $app)
{
$this->app = $app;
}
public function run(CommandCall $input)
{
$this->input = $input;
$this->handle();
}
public function teardown()
{
//
}
protected function getArgs()
{
return $this->input->args;
}
protected function getParams()
{
return $this->input->params;
}
protected function hasParam($param)
{
return $this->input->hasParam($param);
}
protected function getParam($param)
{
return $this->input->getParam($param);
}
protected function getApp()
{
return $this->app;
}
protected function getPrinter()
{
return $this->getApp()->getPrinter();
}
}
Save the file when you're done updating its content.
We'll need to move our current hello
command to follow the designated directory structure:
cd minicli
mkdir app/Command/Hello
Because we now use a command subcommand
nomenclature, we'll have to create a subcommand inside the hello
namespace. To create a subcommand named name
, you should use NameController
as class name.
Let's copy the HelloController
to the hello
namespace and rename it to NameController.php
.
mv app/Command/HelloController.php app/Command/Hello/NameController.php
Now we need to update this file to rename the class and implement the handle
method, removing the old run
implementation. Open file with:
app/Hello/NameController.php
The NameController
Class
Folowing, the contents of the updated NameController
class, former HelloController
.
<?php
namespace App\Command\Hello;
use Minicli\CommandController;
class NameController extends CommandController
{
public function handle()
{
$name = $this->hasParam('user') ? $this->getParam('user') : 'World';
$this->getPrinter()->display(sprintf("Hello, %s!", $name));
}
}
Save the file when you're done updating its content.
5. Updating and running minicli
The last thing we need to do is update the minicli
script to reflect all the changes. We'll set a signature and register a single help
command to test out our named parameters feature.
Open the file with:
cd minicli
nano minicli
The minicli
Script
Replace the current contents of your minicli
script with the following code:
#!/usr/bin/php
<?php
if (php_sapi_name() !== 'cli') {
exit;
}
require __DIR__ . '/vendor/autoload.php';
use Minicli\App;
use Minicli\CommandCall;
$app = new App();
$app->setSignature("minicli hello name [ user=name ]");
$app->registerCommand("help", function(CommandCall $call) use ($app) {
$app->printSignature();
print_r($call->params);
});
$app->runCommand($argv);
Save the file when you're done.
8. Testing the Changes
Now you can execute the hello name
command with:
./minicli hello name
or
./minicli hello name user=erika
To test named parameters, run:
./minicli help name=value name2=value2
You'll get output like this:
usage: minicli hello name [ user=name ]
Array
(
[name] => value
[name2] => value2
)
Conclusion
In this guide, we refactored our minicli
micro framework to support a better organizational command structure and to enable autoloading command controllers.
You can find the full refactored code in the 0.1.3 release of minicli
: https://github.com/erikaheidi/minicli/releases/tag/0.1.3.
In the next and final part of this series, we'll wrap up everything to release minicli 1.0
.
Posted on December 10, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.