Building minicli: Autoloading Command Namespaces

erikaheidi

Erika Heidi

Posted on December 10, 2019

Building minicli: Autoloading Command Namespaces

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:

  1. Implement the new CommandNamespace class and refactor CommandRegistry accordingly.
  2. Outsource command parsing to a new CommandCall class.
  3. Update the App class to support the changes.
  4. Update the abstract CommandController class and the concrete controllers in order to support the rest of the work.
  5. 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
Enter fullscreen mode Exit fullscreen mode

Then, run Composer to set up autoload. This won't install any package, because minicli has no dependencies.

composer dump-autoload
Enter fullscreen mode Exit fullscreen mode

Run the application with:

php minicli
Enter fullscreen mode Exit fullscreen mode

or

chmod +x minicli
./minicli
Enter fullscreen mode Exit fullscreen mode

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!!!");
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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 ... ]
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Save the file when you're done.

The CommandRegistry class

Open the existing CommandRegistry class on your editor:

lib/CommandRegistry.php
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

Save the file when you're done.

8. Testing the Changes

Now you can execute the hello name command with:

./minicli hello name
Enter fullscreen mode Exit fullscreen mode

or

./minicli hello name user=erika
Enter fullscreen mode Exit fullscreen mode

To test named parameters, run:

./minicli help name=value name2=value2
Enter fullscreen mode Exit fullscreen mode

You'll get output like this:

usage: minicli hello name [ user=name ]

Array
(
    [name] => value
    [name2] => value2
)
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
erikaheidi
Erika Heidi

Posted on December 10, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related