Blog Archives

Building a small microframework with PHP (Part 2). Command line interface

In my last post we spoke about building a small microframework with PHP. The main goal of this kind of framework was to be able to map urls to plain PHP classes and become those classes easily testeable with PHPUnit. Now we’re going to take a step forward. Sometimes we need to execute the script from command line, for example if we need to use them in a crontab file. OK. We can use curl and perform a simple http call to the webserver. But it’s pretty straightforward to create a command line interface (CLI) for our microframework. Zend Framework and Symfony has a great console tools. I’ve used Zend_Console_Getopt in a project and is really easy to use and well documented. But now we’re going to build a command line interface from scratch.

We are going to reuse a lot of code from the earlier post, because of that we are going to encapsulate the code in a class (DRY).

We will use the getopt function from PHP’s function set.

$sortOptions = "";
$sortOptions .= "c:";
$sortOptions .= "f:";
$sortOptions .= "v::";
$sortOptions .= "h::";

$options = getopt($sortOptions);

Then we need to take the paramaters from $GLOBALS[‘argv’] superglobal according with the options. I use the following hack to prepare $GLOBALS[‘argv’]:

foreach( $options_array as $o => $a ) {
    while($k=array_search("-". $o. $a, $GLOBALS['argv'])) {
        if($k) {
            unset($GLOBALS['argv'][$k]);
        }
    }
    while($k=array_search("-" . $o, $GLOBALS['argv'])) {
        if($k) {
            unset($GLOBALS['argv'][$k]);
            unset($GLOBALS['argv'][$k+1]);
        }
    }
}
$GLOBALS['argv'] = array_merge($GLOBALS['argv']);

And now we get the params into $param_arr array.

$param_arr = array();
$lenght = count((array)$GLOBALS['argv']);
if ($lenght > 0) {
    for ($i = 1; $i < $lenght; $i++) {
        if (isset($GLOBALS['argv'][$i])) {
            list($paramName, $paramValue) = explode("=", $GLOBALS['argv'][$i], 2);
            $param_arr[$paramName] = $paramValue;
        }
    }
}

Now we can get className and functionName:

$className    = !array_key_exists('c', $options) ?: $options['c'];
$functionName = !array_key_exists('f', $options) ?: $options['f'];

We add a “usage” parameter:

if (array_key_exists('h', $options)) {
     $usage = <<<USAGE
Usage: cli [options] [-c] <class> [-f] <function> [args...]

Options:
-h Print this help
-v verbose mode
\n
USAGE;
    echo $usage;
    exit;
}

Now we can invoke the function with call_user_func_array

return call_user_func_array(array($className, $functionName), $this->realParams);

As you can see, instead of using $param_arr as parameters array, We need to create an extra $realParams array. The aim of this realParams arrays is to use call_user_func_array with named parameters. In getRealParams function we use reflection to see what are the real parameters of our function and use only those parameters form in the correct order instead. With this trick we will allow to the user to use the parameters in the his desired order, and without forcing to use the real order of our function.

    private function getRealParams($params)
    {
        $realParams = array();
        $class = new ReflectionClass(new $this->className);
        $reflect = $class->getMethod($this->functionName);

        foreach ($reflect->getParameters() as $i => $param) {
            $pname = $param->getName();
            if ($param->isPassedByReference()) {
                /// @todo shall we raise some warning?
            }
            if (array_key_exists($pname, $params)) {
                $realParams[] = $params[$pname];
            } else if ($param->isDefaultValueAvailable()) {
                $realParams[] = $param->getDefaultValue();
            } else {
                throw new Exception("{$this->className}::{$this->functionName}() param: missing param: {$pname}");
            }
        }
        return $realParams;
    }

And now we can use our microframework from the command line:

./cli -c 'Demo\Foo' -f hello
Hello

./cli -c Demo\\Foo -f getUsers
["Gonzalo","Peter"]

./cli -c Demo\\Foo -f helloName name=Gonzalo surname=Ayuso
Hello Gonzalo Ayuso

Full code on github

Building a small microframework with PHP

Nowadays microframewors are very popular. Since Blake Mizerany created Sinatra (Ruby), we have a lot of Sinatra clones in PHP world. Probably the most famous (and a really good one) is Silex. But we also have several ones, such as Limonade, GluePHP and Slim. Those frameworks are similar. We define routes and we connect those routes to callbacks:

For example:

‘/hello/{name}’ => function ($name) {return “Hello $name”;}

Those microframeworks use the new PHP 5.3 callback features. It’s easy to build prototypes with those frameworks. I’ve used silex for a small prototype and I’m really happy with it. But I have a little problem. Each time I need to create a new route I need to create the route and create the callback. The business logic is inside the callback, but I need to tell to the framework where is it with the router. That’s means code de callback and write the router. This way of work has advantages. You can change the routes without changing the business logic. I feel comfortable with it in small projects, but when it scales it’s difficult to manage (at least for me). Symfony2 has a great way to create routes, with inheritance, catching and things like that, but sometimes I dont’t feel confortable with it. Because of that I have done this small microframework experiment. The idea is drop the router and create it depending on the filesystem. The first idea was inside this postit:

Yes I now. If I want to change the class inside the filesystem, I need to change the urls. As an experiment, I’ve created a small microframework. The idea the following one:

  • .htaccess to redirect every request to index.php
  • a set of classes (plain php classes)
  • index.php will invoke the required class’ function
  • after invoking the required function index.php will convert the output to the format selected

Basically index.php will follow the following script:

setUpAutoload();
list($className, $functionName, $format, $params) = decodeUri(getUri());
$realParams = getRealParams($className, $functionName, $params);
echo format($format, call_user_func_array(array($className, $functionName), $realParams));

Here you have the full index.php code:

function call_user_func_named($className, $obj, $function, $params)
{
    $class = new ReflectionClass($obj);
    $reflect = $class->getMethod($function);

    $realParams = array();
    foreach ($reflect->getParameters() as $i => $param) {
        $pname = $param->getName();
        if ($param->isPassedByReference()) {
            /// @todo shall we raise some warning?
        }
        if (array_key_exists($pname, $params)) {
            $realParams[] = $params[$pname];
        } else if ($param->isDefaultValueAvailable()) {
            $realParams[] = $param->getDefaultValue();
        } else {
            throw new Exception("{$className}::{$function}() param: missing param: {$pname}");
        }
    }

    return call_user_func_array(array(new $obj, $function), $realParams);
}

function decodeUri($uri)
{
    $conf = $params = array();
    $functionName = $format = null;

    $parsedUrl = parse_url($uri);
    $path = $parsedUrl['path'];
    if (isset($parsedUrl['query'])) {
        $query = $parsedUrl['query'];
        $params = array();
        $pairs = explode('&', $query);
        foreach ($pairs as $pair) {
            if (trim($pair) == '') {
                continue;
            }
            list($key, $value) = explode('=', $pair);
            $params[$key] = urldecode($value);
        }
    }
    $arr = explode('/', $path);

    for ($i = 0; $i < count($arr); $i++) {
        $elem = $arr[$i];
        if (strpos($elem, '.') !== false) {
            list($functionName, $format) = explode(".", $elem);
            continue;
        } else {
            if ($elem != '') $conf[] = ucfirst($elem);
        }
    }

    $className = implode('\\', $conf);
    return array($className, $functionName, $format, $params);
}

function format($format, $out)
{
    switch ($format) {
        case 'json':
            header('Cache-Control: no-cache, must-revalidate');
            header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
            header('Content-type: application/json');
            return json_encode($out);
        case 'html':
        case 'htm':
            header('Cache-Control: no-cache, must-revalidate');
            header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
            header('Content-type: Content-Type: text/html');
            return (string)$out;
        case 'txt':
        case 'ajax':
            header('Content-type: text/plain; charset=utf-8');
            return (string)$out;
        case 'css':
            header('Content-type: text/css');
            return (string)$out;
        case 'js':
            header('Content-type: application/javascript');
            return (string)$out;
        case 'jsonp':
            $cbk = filter_input(INPUT_GET, '_cbk', FILTER_SANITIZE_STRING);
            if ($cbk == '') {
                $cbk = 'cbk';
            }

            header('Content-type: text/javascript; charset=utf-8');
            return "{$cbk}(" . json_encode($out) . ");";
        default:
            throw new Exception("Undefined format");
    }
}

function getRealParams($className, $functionName, $params)
{
    $realParams = array();
    $class = new \ReflectionClass(new $className);
    $reflect = $class->getMethod($functionName);

    foreach ($reflect->getParameters() as $i => $param) {
        $pname = $param->getName();
        if ($param->isPassedByReference()) {
            /// @todo shall we raise some warning?
        }
        if (array_key_exists($pname, $params)) {
            $realParams[] = $params[$pname];
        } else if ($param->isDefaultValueAvailable()) {
            $realParams[] = $param->getDefaultValue();
        } else {
            throw new Exception("{$className}::{$functionName}() param: missing param: {$pname}");
        }
    }
    return $realParams;
}

function setUpAutoload()
{
    spl_autoload_register(function ($class)
        {
            $class = str_replace('\\', '/', $class) . '.php';
            if (is_file($class)) {
                require_once($class);
            } else {
                throw new Exception("{$class} does not exists");
            }
        }
    );
}

function getUri()
{
    $requestUri = $_SERVER['REQUEST_URI'];
    $scriptName = $_SERVER['SCRIPT_NAME'];

    if (dirname($scriptName) == '/') {
        $uri = $requestUri;
        return $uri;
    } else {
        $uri = str_replace(dirname($scriptName), null, $requestUri);
        return $uri;
    }
}

An example of the plain php classes with the business logic:

namespace Demo;

class Foo
{
    public function hello()
    {
        return "Hello";
    }

    public function helloName($name)
    {
        return "Hello " . $name;
    }

    public function getUsers()
    {
        return array('Gonzalo', 'Peter', );
    }
}

This class is easily testable with PHPUnit.

Normally I use this kind of framework to get json and jsonp. It’s pretty straightforward to get json:

http://localhost/demo/foo/getUsers.json
[“Gonzalo”,”Peter”]

http://localhost/demo/foo/getUsers.jsonp

cbk([“Gonzalo”,”Peter”]);

This is a very small approach of what I have in mind, coded with a few lines to show the concept. I’m working in some more advanced features such as twig integration, security, and things like that. The idea is combine the plain PHP classes with annotations (with Addendum). Imagine a class like that:

Here @Render() annotation tells to the framework to use a twig template.

// http://localhost/tdl/index.html

class Tdl
{
    /**
     * @Render()
     * @return array
     */
    public function index()
    {
        return array(
            'title'       => 'Simple ToDo List',
            'description' => 'Simple ToDo List',
            'author'      => 'Gonzalo',
        );
    }
}

And here will output a javascript file using assetic library

// http://localhost/tdl/js/js.js

namespace Tdl;

use Assetic\Asset\AssetCollection;
use Assetic\Asset\FileAsset;
use Assetic\Filter\FilterInterface;

class Js
{
    public function js()
    {
        $js = new AssetCollection(array(
            new FileAsset(MVC_PATH . '/V/Tdl/Js/nf.js'),
            new FileAsset(MVC_PATH . '/V/Tdl/Js/main.js'),
        ));

        return  $js->dump();
    }
}

Sometimes I think Symfony2 has all this features and developing this framework is a waste of time. Probably, but it’s cool to code it. Also it’s very easy and fast for me build prototypes. What do you think?

Initial commit here (the code used in this post).
Full code on github

Follow

Get every new post delivered to your Inbox.

Join 1,003 other followers