Junio 2011

faltaba 1 año para...

Symfony 2.0 RC1

Bundle

¿Cuantos bundles deberían haber?

¡Uno para toda la App!

¡Buuuu!

¡Fueraaaaa!

¡Un Bundle por grupo!

Best Practices 2014

1 Bundle - src/AppBundle

  • Reducimos complejidad
  • Reducimos sistema de carpetas
  • Incrementamos rendimiento de trabajo
  • Reducimos separación semántica
  • Debemos ser más exigentes con el naming
  • Complicación si se quiere separar en un futuro

¿Cuantos bundles deberían haber... si hago...?

  • MVP
  • App + mantenimiento
  • Bundle FOS
  • Experimento

Nos hemos olvidado lo más importante de todo...

  • Como separamos el bundle
  • Cual es nuestra lógica de negocio
  • Cuales son las best practices

Salir de nuestra zona de confort


    namespace AppBundle;
    
    use Symfony\Component\HttpKernel\Bundle\Bundle;
    
    /**
     * This bundle will rule the world
     */
    class AppBundle extends Bundle
    {

    }

    namespace AppBundle;
    
    use Symfony\Component\HttpKernel\Bundle\Bundle;
    
    /**
     * This bundle will rule the world
     */
    class AppBundle extends Bundle
    {
    
    }
namespace AppBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
 * This bundle will rule the world
 */
class AppBundle extends Bundle
{

}
  • Clase Bundle
  • Extensión
  • CompilerPass
  • Desacoplando
  • Clase Bundle
  • Extensión
  • CompilerPass
  • Desacoplando

    namespace Symfony\Component\HttpKernel\Bundle;

    /**
     * An implementation of BundleInterface that adds a few conventions
     * for DependencyInjection extensions and Console commands.
     *
     * @author Fabien Potencier <fabien@symfony.com>
     */
    abstract class Bundle implements BundleInterface
    {

    }

definición Extensión

    
    /**
     * Returns the bundle's container extension.
     *
     * @return ExtensionInterface|null The container extension
     *
     * @throws \LogicException
     */
    public function getContainerExtension()
    {
        if (null === $this->extension) {
            $extension = $this->createContainerExtension();
            if (null !== $extension) {
                if (!$extension instanceof ExtensionInterface) {
                    throw new \LogicException(sprintf('Extension %s must implement Symfony\Component\DependencyInjection\Extension\ExtensionInterface.', get_class($extension)));
                }
                // check naming convention
                $basename = preg_replace('/Bundle$/', '', $this->getName());
                $expectedAlias = Container::underscore($basename);
                if ($expectedAlias != $extension->getAlias()) {
                    throw new \LogicException(sprintf(
                        'Users will expect the alias of the default extension of a bundle to be the underscored version of the bundle name ("%s"). You can override "Bundle::getContainerExtension()" if you want to use "%s" or another alias.',
                        $expectedAlias, $extension->getAlias()
                    ));
                }
                $this->extension = $extension;
            } else {
                $this->extension = false;
            }
        }
        if ($this->extension) {
            return $this->extension;
        }
    }
  • Te busca automáticamente una Extensión
  • Dentro de /DependencyInjection
  • {App}Extension.php
  • Ante cualquier error... ¡ni te enteras!
  • ¡Ojo con el alias! Solo acepta si es {app}. Sino... claro, los usuarios no lo entenderán

    use Symfony\Component\HttpKernel\Bundle\Bundle;
    
    /**
     * My bundle
     */
    final class MyBundle extends Bundle
    {
        /**
         * Returns the bundle's container extension.
         *
         * @return ExtensionInterface|null The container extension
         *
         * @throws \LogicException
         */
        public function getContainerExtension()
        {
            return new MyExtension();
        }
    }

Title Text


    use Symfony\Component\HttpKernel\Bundle\Bundle;
    
    /**
     * My bundle
     */
    final class MyBundle extends Bundle
    {
        /**
         * Returns the bundle's container extension.
         *
         * @return ExtensionInterface|null The container extension
         *
         * @throws \LogicException
         */
        public function getContainerExtension()
        {
            return null;
        }
    }

DRY

BaseBundle

http://github.com/mmoreram/BaseBundle

  • Bundle base donde extender tus propios bundles
  • Añade una capa de simplificación para el gran porcentaje de los casos
  • Simples métodos con simples responsabilidades
  • Minimiza un poco cierta magia oculta
  • Por defecto, un bundle no tiene Extension
  • En caso contrario, se lo defines
  • Con esto ganas algo importante en tus proyectos, y es saber qué se carga, y cuándo
  • Te olvidas de este método-que-todo-lo-hace para siempre

    namespace AppBundle;
    
    use Mmoreram\BaseBundle\BaseBundle;
    
    /**
     * Class MyBundle.
     */
    final class MyBundle extends BaseBundle
    {
    
    }

Comandos


    /**
     * Finds and registers Commands.
     *
     * Override this method if your bundle commands do not follow the conventions:
     *
     * * Commands are in the 'Command' sub-directory
     * * Commands extend Symfony\Component\Console\Command\Command
     *
     * @param Application $application An Application instance
     */
    public function registerCommands(Application $application)
    {
        if (!is_dir($dir = $this->getPath().'/Command')) {
            return;
        }
        if (!class_exists('Symfony\Component\Finder\Finder')) {
            throw new \RuntimeException('You need the symfony/finder component to register bundle commands.');
        }
        $finder = new Finder();
        $finder->files()->name('*Command.php')->in($dir);
        $prefix = $this->getNamespace().'\\Command';
        foreach ($finder as $file) {
            $ns = $prefix;
            if ($relativePath = $file->getRelativePath()) {
                $ns .= '\\'.str_replace('/', '\\', $relativePath);
            }
            $class = $ns.'\\'.$file->getBasename('.php');
            if ($this->container) {
                $alias = 'console.command.'.strtolower(str_replace('\\', '_', $class));
                if ($this->container->has($alias)) {
                    continue;
                }
            }
            $r = new \ReflectionClass($class);
            if ($r->isSubclassOf('Symfony\\Component\\Console\\Command\\Command') && !$r->isAbstract() && !$r->getConstructor()->getNumberOfRequiredParameters()) {
                $application->add($r->newInstance());
            }
        }
    }
  • Te busca en el directorio /Command todos los comandos que tengas
  • En caso que exista un servicio con el nombre `console.command.{nombre-construido-al-tun-tun}` entonces no te lo carga
  • ¿Y si quieres tener los comandos en otra carpeta? No, compañeros, tampoco podéis.

    /**
     * Register Commands.
     *
     * Disabled as commands are registered as services.
     *
     * @param Application $application An Application instance
     */
    public function registerCommands(Application $application)
    {
        return;
    }
  • Por defecto, ningún bundle tiene comandos
  • Si un bundle necesita registrar comandos sin querer declararlos como servicios, lo hace específicamente
  • De este modo, un bundle sabe, exactamente, con precisión, cuáles son los comandos que está exponiendo
  • Veréis que esto tiene mucho sentido cuando separamos bundle de componente

    /**
     * Register Commands.
     *
     * Disabled as commands are registered as services.
     *
     * @param Application $application An Application instance
     */
    public function registerCommands(Application $application)
    {
        return [
            new OneCommand(),
            new AnotherCommand()
        ]
    }

¡OJO basurita!

¿Alguien sabe exactamente qué ocurre cuando... php bin/console?


    protected function registerCommands()
    {
        if ($this->commandsRegistered) {
            return;
        }

        $this->commandsRegistered = true;
        $this->kernel->boot();
        $container = $this->kernel->getContainer();

        foreach ($this->kernel->getBundles() as $bundle) {
            if ($bundle instanceof Bundle) {
                $bundle->registerCommands($this);
            }
        }

        if ($container->hasParameter('console.command.ids')) {
            foreach ($container->getParameter('console.command.ids') as $id) {
                $this->add($container->get($id));
            }
        }
    }
  • Cada vez que listas o ejecutas cualquier comando por consola... todos y cada uno de ellos se instancian...
  • ... ¡incluidos los que se han declarado como servicios!
  • Esto implica que si tenemos un comando declarado como un servicio, y un servicio inyectado que, a su vez, tiene el entity manager inyectado...
  • ... como no haya base de datos creada, simplemente, no funciona, no establece conexión y lanza Exception
  • Solución 1 - Puedes romper la barrera de creación en cadena de servicios utilizando en algún punto un proxy.
  • Solución 2 - Puedes utilizar algún sistema de CommandBus, dado que solo levantaría el bus en sí, nada más
  • Solución 3 - Utilizas el container entero, tan hermoso y tranquilo, y a dormir.
  • Solución 4 - Tienes huevos y planteas una solución en el repositorio de Symfony (Proxy casi seguro...)

definición CompilerPass


    /**
     * Builds bundle.
     *
     * @param ContainerBuilder $container Container
     */
    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(new CompilerPass1());
        $container->addCompilerPass(new CompilerPass2());
    }
  • No sobreescribamos el método build() entero solo para añadir nuestros compilerpass
  • BaseBundle tiene un método para ello

    /**
     * Return a CompilerPass instance array.
     *
     * @return CompilerPassInterface[]
     */
    public function getCompilerPasses()
    {
        return [
            new CompilerPass1(),
            new CompilerPass2(),
        ];
    }

Dependencias

Un bundle es un paquete autosuficiente

¿Alguien se lo cree?

¿Quién ha inyectado alguna vez el servicio @router?

  • Si instancio mi bundle, cuyos servicios necesitan inyectar @router... Necesito "si o si" instanciar FrameworkBundle
  • Al concepto "si o si" se le llama comunmente dependencia
  • Volvemos a Symfony 2.0 con su estupendo motor de dependencias... ¡el archivo deps!
  • Composer resuelve las dependencias de la mera existencia del paquete, por lo que framework-bundle DEBE estar en composer.yml
  • ¿Como solucionamos el hecho que cada bundle exponga sus dependencias a nivel de Kernel?

Adivinad qué pasó...

  • Un bundle, ahora mismo, NO es algo autosuficiente, por lo que algún mecanismo debe solucionar este problema
  • El repositorio Symfony Bundle Dependencies soluciona tanto la parte de dependencias de bundles, como la resolución por parte del Kernel
  • github.com/mmoreram/symfony-bundle-dependencies
  • Cualquier bundle que extienda BaseBundle puede definir sus dependencias (por defecto, vacío)

    /**
     * Create instance of current bundle, and return dependent bundle namespaces.
     *
     * @return array Bundle instances
     */
    public static function getBundleDependencies(KernelInterface $kernel)
    {
        return [];
    }

    /**
     * Create instance of current bundle, and return dependent bundle namespaces.
     *
     * @return array Bundle instances
     */
    public static function getBundleDependencies(KernelInterface $kernel)
    {
        return [
            'Another\Bundle',
            'Even\Another\Bundle',
        ];
    }
  • Clase Bundle
  • Extensión
  • CompilerPass
  • Desacoplando
    
    $configuration = new Configuration();
    $config = $this->processConfiguration($configuration, $config);
    
    /**
     * Setting all config elements as DI parameters to inject them
     */
    $container->setParameter(
        'my.bundle.service_provider',
        $config['service_provider']
    );

    $loader = new YamlFileLoader(
        $container,
        new FileLocator(__DIR__ . '/../Resources/config')
    );

    /**
     * Loading DI definitions
     */
    $loader->load('classes.yml');
    $loader->load('services.yml');
    $loader->load('commands.yml');
  • 1 método, mucha responsabilidad
  • Culpable de la pereza de hacer un bundle
  • No tiene buena relación complejidad/impacto
  • Muy habitualmente hacemos siempre lo mismo
    
    use Mmoreram\BaseBundle\DependencyInjection\BaseExtension;
    
    /**
     * This is the class that loads and manages your bundle configuration
     */
    class MyExtension extends BaseExtension
    {

    }
    
    /**
     * Returns the recommended alias to use in XML.
     *
     * This alias is also the mandatory prefix to use when using YAML.
     *
     * @return string The alias
     *
     * @api
     */
    public function getAlias()
    {
        return 'app';
    }
    
    /**
     * Return a new Configuration instance.
     *
     * If object returned by this method is an instance of
     * ConfigurationInterface, extension will use the Configuration to read all
     * bundle config definitions.
     *
     * Also will call getParametrizationValues method to load some config values
     * to internal parameters.
     *
     * @return ConfigurationInterface Configuration file
     */
    protected function getConfigurationInstance()
    {
        return new Configuration();
    }
    
    /**
     * Get the Config file location.
     *
     * @return string Config file location
     */
    protected function getConfigFilesLocation()
    {
        return __DIR__ . '/../Resources/config';
    }
    
    /**
     * Config files to load.
     *
     * Each array position can be a simple file name if must be loaded always,
     * or an array, with the filename in the first position, and a boolean in
     * the second one.
     *
     * As a parameter, this method receives all loaded configuration, to allow
     * setting this boolean value from a configuration value.
     *
     * return array(
     *      'file1',
     *      'file2',
     *      ['file3', $config['my_boolean'],
     *      ...
     * );
     *
     * @param array $config Config definitions
     *
     * @return array Config files
     */
    protected function getConfigFiles(array $config)
    {
        return [
            'services',
            'commands',
            'controllers',
        ];
    }
    
    /**
     * Load Parametrization definition.
     *
     * return array(
     *      'parameter1' => $config['parameter1'],
     *      'parameter2' => $config['parameter2'],
     *      ...
     * );
     *
     * @param array $config Bundles config values
     *
     * @return array Parametrization values
     */
    protected function getParametrizationValues(array $config)
    {
        return [
            'my.bundle.service_provider' => $config['service_provider'],
        ];
    }

¿Cuantos inyectáis parámetros en vuestros servicios?

¡OJO basurita!

  • En caso que compartáis el bundle, nadie os asegura que el valor del parámetro sea bueno
  • Si el bundle requiere que el proyecto final defina un parámetro, éste debe validarlo
  • Y esto, queridos amigos, se hace en el desconocido archivo Configuration
    
    /**
     * Load Parametrization definition.
     *
     * return array(
     *      'parameter1' => $config['parameter1'],
     *      'parameter2' => $config['parameter2'],
     *      ...
     * );
     *
     * @param array $config Bundles config values
     *
     * @return array Parametrization values
     */
    protected function getParametrizationValues(array $config)
    {
        return [
            'my.bundle.service_provider' => $config['service_provider'],
        ];
    }
  • Clase Bundle
  • Extensión
  • CompilerPass
  • Desacoplando
  • Un CompilerPass, entre otras cosas permite hacer búsqueda por tags
  • Hay un caso bastante habitual, cuando queremos añadir un set de servicios que implementan la misma interface a otra clase del tipo colector
  • Por ejemplo una clase que agrupa a todos los shipping providers y itera sobre ellos para determinar cual es el primero que puede actuar

    use Symfony\Component\DependencyInjection\ContainerBuilder;
    use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
    use Symfony\Component\DependencyInjection\Reference;
    
    class MailTransportPass implements CompilerPassInterface
    {
        public function process(ContainerBuilder $container)
        {
            // always first check if the primary service is defined
            if (!$container->has('app.mailer_transport_chain')) {
                return;
            }
    
            $definition = $container->findDefinition('app.mailer_transport_chain');
    
            // find all service IDs with the app.mail_transport tag
            $taggedServices = $container->findTaggedServiceIds('app.mail_transport');
    
            foreach ($taggedServices as $id => $tags) {
                // add the transport service to the ChainTransport service
                $definition->addMethodCall('addTransport', array(new Reference($id)));
            }
        }
    }
  • Siempre, exactamente siempre, lo mismo
  • Un método que hace muchas cosas
  • Podemos utilizar AbstractTagCompilerPass, pensado para que solo definas los tres valores imprescindibles para dicha acción

    use Mmoreram\BaseBundle\CompilerPass\TagCompilerPass;


    /**
     * Class FeederCompilerPass.
     */
    final class FeederCompilerPass extends TagCompilerPass
    {
    
    }

    /**
     * Get collector service name.
     *
     * @return string Collector service name
     */
    public function getCollectorServiceName()
    {
        return 'my.collector.service';
    }

    /**
     * Get collector method name.
     *
     * @return string Collector method name
     */
    public function getCollectorMethodName()
    {
        return 'addClass';
    }

    /**
     * Get tag name.
     *
     * @return string Tag name
     */
    public function getTagName()
    {
        return 'my.tag';
    }
  • Clase Bundle
  • Extensión
  • CompilerPass
  • Desacoplando
  • ¿Que pasa cuando nuestro bundle contiene una capa de servicios que queremos compartir?
  • ¿Tenemos claro exactamente qué es un bundle?

Bundle

App Symfony

¿Que pasa cuando alguien depende de nosotros?

Bundle

App Symfony

LIbrería externa PHP

  • Una simple librería PHP tendría como dependencia un Bundle y sus dependencias
  • No tiene sentido, solo quiere unas clases PHP
  • Conceptualmente un Bundle es la forma que tenemos de exponer nuestro código PHP al framework Symfony
  • Debemos convertir nuestro esquema de 2 actores a uno de 3

Librería PHP

Bundle

App Symfony

LIbrería externa PHP

Bundle externo

Librería PHP

Bundle

App Symfony

Transformación

  • En un bundle se pone todo lo necesario para que la librería PHP creada se pueda exponer al framework

  • La clase Bundle

  • Los mappings de Doctrine

  • Las clases para el Dependency Injection (Extension, CompilerPass, Configuration)

  • Los archivos yml de configuración

  • En un componente ponemos todo lo que depende de librerías PHP y no es exclusivo de Symfony Framework

  • Esto incluye clases, managers, entidades, factories, controladores, comandos...

  • La clase Bundle, aún dependiendo de una librería PHP (HttpKernel), solo es funcional en el Framework

  • Solo es útil cuando
    • Se trabaja con proyectos FOS. Se suele crear organizaciones en Github y se publican ambos
    • Se traslada la mentalidad FOS a un scope más reducido (una empresa)
    • Se espera que haya más integraciones de la librería PHP que la de Symfony (Laravel, Drupal)
  • Hay mucho que hacer aún en cuanto a Bundles se refiere
  • Cuando uno trabaja con un Framework es importante que conozca algunos de sus puntos críticos
  • Os animo a que dediquéis tiempo a comprender qué hay detrás de cierta magia y perdáis el miedo

Conclusiones

  • github.com/mmoreram
  • twitter.com/mmoreram
  • yuhu@mmoreram.com

¡Gracias!

Bundles

By Marc Morera Merino

Bundles

Buenas prácticas para crear Bundles de alta calidad

  • 1,742