Módulos con Slim

febrero 27 2015  PHP     PHP , Slim , Módulos

Uno de los conceptos más importantes que nos permite reusar código entre aplicaciones es el de módulo. Cada framework popular en PHP tiene su propia forma de estructurar módulos, e incluso distintas formas de nombrarlos. Por ejemplo en Zend Framework 2 son módulos, en Symfony 2 son bundles y en Laravel 4 son packages.

Slim al ser un microframework no cuenta con un concepto de módulo como tal, ya que su escencia es que puedes usar funciones anónimas como controladores y desarrollar aplicaciones de la forma más simple posible.

Aún así, si te interesa organizar el código de una aplicación mediana de forma similar a como lo harías en un framework regular, este post será de tu interés.

Te explicaré cómo puedes usar el paquete comphppuebla/slim-modules para estructurar tus aplicaciones Slim de forma similar a como lo harías con módulos. Para esto usaré el ejemplo que he venido usando en post anteriores sobre una aplicación de catálago de productos.

Estructurando el Módulo

Supongamos que tienes una estructura de directorios similar a la siguiente para tu proyecto.

src
├── ProductCatalogModule
│   ├── Controllers
│   │   ├── SearchProductsController.php
│   │   └── ProductRequest.php
│   ├── Resources
│   │   └── templates
│   │       └── search-products.html.twig
│   ├──Forms
│   │  └── ProductForm.php
├── ProductCatalog
│   ├── Catalog.php
│   └──  Product.php

Y que queremos integrar de la forma más simple posible ese código con nuestra aplicación Slim.

Instalación

Primero instalamos el paquete con Composer.

$ composer require comphppuebla/slim-modules

Puedes revisar la documentación y esta aplicación que ya usa el módulo, para más detalles.

Registrando los servicios

El paquete está pensado para integrar módulos, pero también puedes integrar librerías de terceros, similar a los services providers de Silex. Supongamos que queremos integrar Twig, podemos usar la interfaz ComPHPPuebla\Slim\ServiceProvider de la siguiente forma:

use ComPHPPuebla\Slim\ServiceProvider;

class TwigProvider implements ServiceProvider
{
    public function configure(Slim $app, array $parameters = [])
    {
        $app->container->singleton('twig.loader', function() {
            return new Twig_Loader_Filesystem($parameters['twig.paths']);
        });
        $app->container->singleton('twig.environment', function() use ($app) {
            return new Twig_Environment(
                $app->container->get('twig.loader'),
                $parameters['twig.options']
            );
        });
    }
}

Una vez definido, puedes registrar tu proveedor en index.php

$app = new Slim\Slim();

$twigProvider = new TwigProvider([
    'twig.paths' => [
        'app/templates'
        'src/ProductCatalogModule/Resources/templates',
    ],
    'twig.options' => [
        'cache' => 'var/cache/twig',
        'strict_variables' => true,
    ],
]);

$twigProvider->register($app);

$app->run();

Registrando módulos

La implementación para los servicios de un módulo es similar, solo que registraríamos controladores, repositorios, servicios de aplicación, formularios, etc. Por ejemplo:

namespace ProductCatalogModule;

use ComPHPPuebla\Slim\ServiceProvider;
use ProductCatalogModule\Controllers;
use ProductCatalogModule\Forms;
use ProductCatalog\Catalog;

class ProductCatalogServices implements ServiceProvider
{
    public function configure(Slim $app, array $parameters = [])
    {
        $app->container->singleton(
            'product_catalog.search_products_controller',
            function() use ($app) {
                return new SearchProductsController(
                    $app->container->get('twig.environment'),
                    new SearchProductsForm(),
                    $app->container->get('product_catalog.product_repository'),
                );
            }
        );
        $app->container->singleton(
            'product_catalog.product_repository',
            function() use ($app) {
                return new new Catalog($app->container->get('dbal.connection'));
            }
        );
        /* more services here... */
    }
}

Registramos los servicios del módulo igual que hicimos con el ejemplo de Twig.

$app = new Slim\Slim();

/* More providers here... */

$productCatalog = new ProductCatalogServices();
$productCatalog->register($app);

$app->run();

Registrando las rutas

Para registrar las rutas, debemos crear una clase que implemente la interfaz ComPHPPuebla\Slim\ControllerProvider

namespace ProductCatalogModule;

use ComPHPPuebla\Slim\ControllerProvider;
use ComPHPPuebla\Slim\ControllerResolver;
use Slim\Slim;

class ProductCatalogControllers implements ControllerProvider
{
    public function register(Slim $app, ControllerResolver $resolver)
    {
        $app->map('/catalog/search', $resolver->resolve(
            $app, 'product_catalog.search_products_controller:searchProducts'
        ))->via('POST', 'GET');
        /* More routes here... */
    }
}

En el ejemplo, cada que la aplicación haga match con /catalog/search se ejecutará el método searchProducts del servicio registrado con el nombre product_catalog.search_products_controller. El objeto ControllerResolver usa el patrón id_controlador:metodo para resolver qué método se ejecutará en cada ruta. El controlador no se crea hasta que Slim hace match con esa ruta, el resolvedor simplemente crea una función (similar a lo que sucede cuando ejecutas $app->container->protect) que realiza las siguientes tareas:

  • Genera un callable con el controlador y método que encontró a partir de la cadena id_controlador:metodo.
  • Recupera los argumentos que Slim genera a partir de la ruta, por ejemplo si la ruta es /products/:id, recupera el valor de $id y lo pasa al método del controlador.
  • Agrega el objeto Request como penúltimo argumento y a tu aplicación Slim como último argumento. De modo que todas las llamadas a métodos de controladores tienen por default la misma estructura:
Controller::method(/* $route_param_1, ... $route_param_n */ $request, $app)
  • Una vez que se resuelven los argumentos, se ejecuta el método del controlador

Modificando argumentos

El resolvedor puede recibir como tercer argumento una función que altere los parámetros que se le pasan a un controlador. Supongamos que tenemos un controlador que edita los datos de un producto. El método en el controlador sólo necesita el ID del producto y la instancia de la aplicación de Slim para llamar al método notFound en caso de que no encontremos el producto asociado al ID proporcionado. No nos hace falta en este caso el objeto Request.

namespace ProductCatalogModule\Controllers;

/* ... */

class ProductController
{
    /* ... */

    public function editProduct($productId, Slim $app)
    {
        if (!$product = $this->catalog->productOf($productId)) {
            $app->notFound();
        }

        // Populate your form and pass it to the view
    }

    /* ... */
}

Si no usamos un convertidor de argumentos, generaríamos un error porque el argumento que pasaríamos en segundo lugar sería de tipo Request y no de tipo Slim, ya que ese es el comportamiento default.

Para evitar este error registramos un convertidor que elimine el Request de nuestro arreglo de argumentos.

# ProductCatalogModule\ProductCatalogControllers

public function register(Slim $app, ControllerResolver $resolver)
{
    $app->get('/catalog/product/edit/:id', $resolver->resolve(
        $app,
        'product_catalog.product_controller:editProduct',
        function (array $arguments) {
            // $arguments[0] is the product ID
            unset($arguments[1]); // Remove the request
            // $arguments[2] is our Slim application

            return $arguments;
        }
    ));

    /* ... */
}

Reemplazando argumentos

Con los convertidores no solo podemos modificar los argumentos, los podemos reemplazar completamente. Supongamos que tenemos un controlador para realizar búsquedas de productos por categoría y palabras clave. Estos valores se pasan usando el query string y en la aplicación son manejados usando el siguiente objeto:

namespace ProductCatalog;

class ProductSearchCriteria
{
    protected $category;
    protected $keywords;

    public function __construct($category = null, $keywords = null)
    {
        $this->category = $category;
        $this->keywords = $keywords;
    }

    public function hasCategory()
    {
        return !is_null($this->category);
    }

    public function category()
    {
        return $this->category;
    }

    public function hasKeywords()
    {
        return !is_null($this->keywords);
    }

    public function keywords()
    {
        return $this->keyword;
    }
}

Sin un convertidor de argumentos, nuestro controlador tendría código como este:

namespace ProductCatalogModule\Controllers;

/* .. */

class SearchController
{
    /* ... */

    public function searchProducts(Request $request)
    {
        $results = $this->catalog->productsMatching(new ProductSearchCriteria(
            $request->get('category'), $request->get('keywords')
        ));

        // Pass your results to the view
    }
}

Con un convertidor podríamos pasar directamente el objeto ProductSearchCriteria al método del controlador en lugar de pasar el objeto Request

# ProductCatalogModule\ProductCatalogControllers

public function register(Slim $app, ControllerResolver $resolver)
{
    $app->get('/catalog/product/search', $resolver->resolve(
        $app,
        'product_catalog.product_search_controller:searchProducts',
        function (array $arguments) {
            // $arguments[0] is the request, our route does not have parameters

            return [new ProductSearchCriteria(
                $arguments[0]->get('category'), $arguments[0]->get('keywords')
            )];
        }
    ));

    /* ... */
}

Con este simple cambio, podemos modificar la firma del controlador.

namespace ProductCatalogModule\Controllers;

/* .. */

class SearchController
{
    /* ... */

    public function searchProducts(ProductSearchCriteria $criteria)
    {
        $results = $this->catalog->productsMatching($criteria);

        // Pass your results to the view
    }
}

Organizando todos tus servicios

En los ejemplos anteriores hemos registrado nuestros servicios por separado, sin embargo, podemos incluir todas nuestras definiciones en una sola clase si extendemos de ComPHPPuebla\Slim\Services.

Podemos registrar todos nuestros proveedores en el método init usando el método add.

namespace Application;

use ComPHPPuebla\Slim\Services;
use ProductCatalogModule\ProductCatalogServices;

class ApplicationServices extends Services
{
    /**
     * Add the providers for your modules here
     */
    protected function init()
    {
        $this
            ->add(new ProductCatalogServices())
            // Register more modules here...
            ->add(new TwigProvider())
            // Register more providers here...
        ;
    }
}

Organizando todas tus rutas

También podemos agrupar el registro de las rutas en una sola clase si extendemos de ComPHPPuebla\Slim\Controllers, también agregamos nuestros controladores en el método init el cual se llama automáticamente al registrar nuestras rutas.

namespace Application;

use ComPHPPuebla\Slim\Controllers;
use ProductCatalogModule\ProductCatalogControllers;

class ApplicationControllers extends Controllers
{
    protected function init()
    {
        $this
            ->add(new ProductCatalogControllers())
            // Register more controllers modules here...
        ;
    }
}

Una vez agrupadas las definiciones de todos tus servicios y todas tus rutas, la configuración en tu archivo index.php se reduce a algo similar a las siguientes líneas.

$app = new Slim\Slim();

$services = new Application\ApplicationServices();
$services->configure($app);

$controllers = new Application\ApplicationControllers();
$controllers->register($app);

$app->run();

Agradeceré mucho tus comentarios, dudas, quejas, sugerencias o reclamaciones.