Upload
matthiasnoback
View
2.042
Download
0
Tags:
Embed Size (px)
DESCRIPTION
Slides for my talk "High Quality Symfony Bundles" tutorial at the Dutch PHP Conference 2014 (http://phpconference.nl).
Citation preview
The Naked BundleMatthias Noback
Assuming you all have aworking project
https://github.com/matthiasnoback/high-quality-bundles-project
Generate a bundleUse app/console generate:bundle
Namespace: Dpc/Bundle/TutorialBundleBundle name: DpcTutorialBundleConfiguration: ymlWhole directory structure: yes
The full directory structure of a bundle:
What's wrong?Too many commentsRouting and a controllerTranslationsTwig templatesA useless test
You are not going to use it all,but it will be committed!
Before we continue, clean up yourbundle
Remove the following files and directories:ControllerResources/docResources/publicResources/translationsResources/viewsTests
Also remove any superfluous comments!
The officialview onbundles
First-class citizens
Documentation » The Quick Tour » The Architecture
I think your code is more important than the framework,which should be considered an implementation detail.
All your code lives in abundle
Documentation » The Book » Creating Pages in Symfony2
I don't think that's a good idea.It contradicts the promise of reuse of "pre-built feature
packages".
Almost everything livesinside a bundle
Documentation » Glossary
Which is not really true, because many things live insidelibraries (e.g. the Symfony components), which is good.
Best practicesDocumentation » Cookbook » Bundles
Controllers
Controllers don't need to extend anything at all.ContainerAware* should be avoided in all cases.
Tests
What's up with the 95%?
Twig
Why Twig? I though Symfony didn't care about this.
Documentation » The Book » Creating and Using Templates
The old view on bundles isnot sufficient anymorePeople are reimplementing things because existing
solutions are too tightly coupled to a framework (or even aspecific version).
Why is it necessary to do all these things again for Symfony,Laravel, Zend, CodeIgniter, CakePHP, etc.?
Last year I started workingon this
Then it became this
About bundles
A bundle is...A thin layer of Framework-specific
configuration to make resources from somelibrary available in a Symfony2 application.
A "Symfony application"meaning:
A project that depends on the Symfony FrameworkBundle.
Resources areRoutes (Symfony Routing Component)Services (Symfony DependencyInjection Component)Templates (Twig)Form types (Symfony Form Component)Mapping metadata (Doctrine ORM, MongoDB ODM, etc.)Translations (Symfony Translation Component)Commands (Symfony Console Component)...?
So: a bundle is mainly configuration to make these resourcesavailable, the rest is elsewhere in a library.
I also wrote
The challengeMake the bundle as clean as possible
Entities
Create an entityUse app/console doctrine:generate:entity
Specs
The entity shortcut name: DpcTutorialBundle:Post.Configuration format: annotationIt has a title (string) field.Run app/console doctrine:schema:create orupdate --force and make sure your entity has acorresponding table in your database.
Let's say you've modelled the Postentity very well
You may want to reuse this in other projects.Yet it's only useful if that project uses Doctrine ORM too!
Why?Annotations couple the Post class to Doctrine ORM.
(Since annotations are classes!)
Also: why are my entities inside abundle?
They are not only useful inside a Symfony project.
Move the entity to anothernamespace
E.g. Dpc\Tutorial\Model\Post.
Create an XML mapping fileE.g. Dpc\Tutorial\Model\Mapping\Post.orm.xml<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="Dpc\Tutorial\Model\Post"> <id name="id" type="integer"> <generator strategy="AUTO"/> </id> <field name="title" type="string"/> </entity></doctrine-mapping>
You can copy the basic XML from/vendor/doctrine/orm/docs/en/reference/xml-
mapping.rst.
In factAlways use XML mapping, it makes a lot of sense, and you
get auto-completion in your IDE!
Remove all ORM things (annotations) from the Post class
If you are going to try the following at home:
Update DoctrineBundleModify composer.json:
{ "require": { ... "doctrine/doctrine-bundle": "~1.2@dev" }}
Run composer update doctrine/doctrine-bundle
Add a compiler pass to your bundleIt will load the XML mapping files
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass;
class DpcTutorialBundle{ public function build(ContainerBuilder $container) { $container->addCompilerPass($this->buildMappingCompilerPass()); }
private function buildMappingCompilerPass() { return DoctrineOrmMappingsPass::createXmlMappingDriver( array( __DIR__ . '/../../Test/Model/Mapping/' => 'Dpc\Tutorial\Model' ) ); }}
What have we won?Clean model classesThey are reusable in non-Symfony projectsThey are reusable with different persistence libraries
Documentation » The Cookbook » Doctrine » How to provide model classes for several Doctrineimplementations
Controllers
Create a controllerUse app/console generate:controller
Specs
Name: DpcTutorialBundle:PostConfiguration: annotationTemplate: twigThe route contains an id parameter.Action: showActionRoute: /post/{id}/show
Implement the following logicModify the action to retrieve a Post entity from the
database:public function showAction(Post $post){ return array('post' => $post);}
Don't forget to register the route# in the bundle's routing.yml file:DpcTutorialBundle_Controllers: resource: "@DpcTutorialBundle/Controller" type: "annotation"
By the wayConsider using XML for routing too!
For the same reasons
Does all of this really needto be inside the bundle?
Move the controller class to thelibrary
Remove parent Controller classWe are going to inject every dependency by hand instead of
relying on the service container.
Create a service for the controllerservices: dpc_tutorial.post_controller: class: Dpc\Tutorial\Controller\PostController
Remove @Route annotationsInstead: define actual routes in the bundle's routing.yml
file.Use the service id of the controller instead of its class name.
dpc_tutorial.post_controller.show: path: /post/{id}/show defaults: _controller: dpc_tutorial.post_controller:showAction
Remove @Template annotationsInject the templating service instead and use it to render
the template.use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Templating\EngineInterface;
class PostController{ public function __construct(EngineInterface $templating) { $this->templating = $templating; }
public function showAction(Post $post) { return new Response( $this->templating->render( 'DpcTutorialBundle:Post:show.html.twig', array('post' => $post) ) ); }}
services: dpc_tutorial.post_controller: class: Dpc\Tutorial\Controller\PostController arguments: - @templating
What about the
Templates
Move the template to the libraryE.g. from Dpc/Bundle/TutorialBundle/Resources/views/Post/show.html.twig to
Dpc/Tutorial/View/Post/show.html.twig
Change the template reference$this->templating->render( '@DpcTutorial/Post/show.html.twig', array('post' => $post))
Register the new location of thetemplates
# in config.ymltwig: ... paths: "%kernel.root_dir%/../src/Dpc/Tutorial/View": DpcTutorial
Documentation » The Cookbook » Templating » How to use and Register namespaced Twig Paths
Well...We don't want to ask users to modify their config.yml!
Let's prependconfiguration
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
class DpcTutorialExtension extends ConfigurableExtension implements PrependExtensionInterface{ ... public function prepend(ContainerBuilder $container) { $bundles = $container->getParameter('kernel.bundles'); if (!isset($bundles['TwigBundle'])) { return; }
$container->prependExtensionConfig( 'twig', array( 'paths' => array( "%kernel.root_dir%/../src/Dpc/Tutorial/View" => 'DpcTutorial' ) ) ); }}
Documentation » The Cookbook » Bundles » How to simplify configuration of multiple Bundles
One last step!The action's $post argument relies on something called
.param convertersThose convert the id from the route to the actual Post
entity.This is actually Symfony framework-specific behavior
Rewrite the controller to make useof a repository
use Doctrine\Common\Persistence\ObjectRepository;
class PostController{ public function __construct(..., ObjectRepository $postRepository) { ... $this->postRepository = $postRepository; }
public function showAction($id) { $post = $this->postRepository->find($id); if (!($post instanceof Post)) { throw new NotFoundHttpException(); } ... }}
services: dpc_tutorial.post_controller: class: Dpc\Tutorial\Controller\PostController arguments: - @templating - @dpc_tutorial.post_repository
dpc_tutorial.post_repository: class: Doctrine\Common\Persistence\ObjectRepository factory_service: doctrine factory_method: getRepository arguments: - Dpc\Tutorial\Model\Post
What do we have now?
Reusable templates
Reusable controllersThey work with Silex too!
Who would have though that was possible?
Console commands
Create a console commandUse app/console generate:console-command
Make it insert a new post in the database.It takes one argument: the post's title.
Something like thisuse Dpc\Tutorial\Model\Post;use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;use Symfony\Component\Console\Input\InputArgument;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;
class CreatePostCommand extends ContainerAwareCommand{ protected function configure() { $this ->setName('post:create') ->addArgument('title', InputArgument::REQUIRED); }
protected function execute(InputInterface $input, OutputInterface $output) { $manager = $this->getContainer() ->get('doctrine') ->getManagerForClass('Dpc\Tutorial\Model\Post');
$post = new Post(); $post->setTitle($input->getArgument('title')); $manager->persist($post); $manager->flush();
$output->writeln('New post created: '.$post->getTitle()); }}
Why is it inside a bundle?Because it is automatically registered when it's in the
Command directory.
So let's move it out!
Move the command to the library
Create a service for itGive it the tag console.command.
Or else it won't be recognized anymore!
services: dpc_tutorial.create_post_command: class: Dpc\Tutorial\Command\CreatePostCommand tags: - { name: console.command }
What about ContainerAware?It couples our command to the Symfony framework.
Which is not needed at all.
Extend from CommandThen inject dependencies instead of fetching them from the
container.use Doctrine\Common\Persistence\ManagerRegistry;
class CreatePostCommand extends Command{ private $doctrine;
public function __construct(ManagerRegistry $doctrine) { parent::__construct();
$this->doctrine = $doctrine; } ... protected function execute(InputInterface $input, OutputInterface $output) { $manager = $this->doctrine->getManager(); ... }}
services: dpc_tutorial.create_post_command: class: Dpc\Tutorial\Command\CreatePostCommand arguments: - @doctrine tags: - { name: console.command }
What do we have?Explicit dependenciesReusable commands that works in all projects that use theSymfony Console Component (like )A bit less magic (no auto-registering commands)Which means now we can put anything we want in theCommand directory
Cilex
Testing abundle
Or: testing configuration
The Configuration class
I don't get it!
I don't trust myself with it.And when I don't trust myself, I write tests
SymfonyConfigTestOn GitHub: SymfonyConfigTest
{ "require-dev": { "matthiasnoback/symfony-config-test": "~0.1" }}
Prepare a test suite for yourConfiguration class
Create a directory Tests/DependencyInjection insidethe bundle.In that directory create a new class:ConfigurationTest.
Create the test classThe ConfigurationTest should extend fromAbstractConfigurationTestCaseImplement the missing method getConfiguration()namespace Dpc\Bundle\TutorialBundle\Tests\DependencyInjection;
use Dpc\Bundle\TutorialBundle\DependencyInjection\Configuration;use Matthias\SymfonyConfigTest\PhpUnit\AbstractConfigurationTestCase;
class ConfigurationTest extends AbstractConfigurationTestCase{ protected function getConfiguration() { return new Configuration(); }}
Desired structure in config.ymldpc_tutorial: # host should be a required key host: localhost
A required value: hostTest first
/** * @test */public function the_host_key_is_required(){ $this->assertConfigurationIsInvalid( array( array() ), 'host' );}
If we provide no values at all, we expect an exceptioncontaining "host".
See it failbin/phpunit -c app
Make the test pass$rootNode ->children() ->scalarNode('host') ->isRequired() ->end() ->end();
Trial and errorYou're done when the test passes!
Repeated configuration valuesDesired structure in config.yml
dpc_tutorial: servers: a: host: server-a.nobacksoffice.nl port: 2730 b: host: server-b.nobacksoffice.nl port: 2730 ...
host and port are required keys for each serverconfiguration
Test first/** * @test */public function host_is_required_for_each_server(){ $this->assertConfigurationIsInvalid( array( array( 'servers' => array( 'a' => array() ) ) ), 'host' );}
Run the testsbin/phpunit -c app
Write the code$rootNode ->children() ->arrayNode('servers') ->useAttributeAsKey('name') ->prototype('array') ->children() ->scalarNode('host') ->isRequired() ->end()
Run the tests
Test firstRepeat these steps for port
Make sure your test first failsThen you add some codeThen the test should pass
Merging config values$this->assertConfigurationIsInvalid( array( array( ... // e.g. values from config.yml ), array( ... // e.g. values from config_dev.yml ) ), 'host');
Disable mergingTest first
/** * @test */public function server_configurations_are_not_merged(){ $this->assertProcessedConfigurationEquals( array( array( 'servers' => array( 'a' => array('host' => 'host-a', 'port' => 1) ) ), array( 'servers' => array( 'b' => array('host' => 'host-b', 'port' => 2) ) ) ), array( 'servers' => array( 'b' => array('host' => 'host-b', 'port' => 2) ) ) );}
Add some code$rootNode ->children() ->arrayNode('servers') ->useAttributeAsKey('name') // don't reindex the array ->prototype('array') // means: repeatable ->children() ->scalarNode('host')->end() ->scalarNode('port')->end() ->end() ->end() ->end() ->end();
Run the testsbin/phpunit -c app
Disable deep mergingValues from different configuration sources should not be
merged.$rootNode ->children() ->arrayNode('servers') ->performNoDeepMerging() ... ->end() ->end();
Advantages of TDD forConfiguration classes
We gradually approach our goal.We immediately get feedback on what's wrong.We can test different configuration values withoutchanging config.yml manually.We can make sure the user gets very specific errormessages about wrong configuration values.Learn more about all the options by reading the
.offical
documentation of the Config component
Testing Extensionclasses
dpc_tutorial: servers: a: host: localhost port: 2730
Should give us a dpc_tutorial.a_server service withhost and port as constructor arguments.
Create a test class for your extensionDirectory: Tests/DependencyInjectionClass name: [NameOfTheExtension]TestClass should extend AbstractExtensionTestCaseImplement getContainerExtensions(): return aninstance of your extension classnamespace Dpc\Bundle\TutorialBundle\Tests\DependencyInjection;
use Dpc\Bundle\TutorialBundle\DependencyInjection\DpcTutorialExtension;use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase;
class DpcTutorialExtensionTest extends AbstractExtensionTestCase{ protected function getContainerExtensions() { return array( new DpcTutorialExtension() ); }}
Test first/** * @test */public function it_creates_service_definitions_for_each_server(){ $this->load( array( 'servers' => array( 'a' => array('host' => 'host-a', 'port' => 123), 'b' => array('host' => 'host-b', 'port' => 234) ) ) );
$this->assertContainerBuilderHasServiceDefinitionWithArgument( 'dpc_tutorial.a_server', 0, 'host-a' ); $this->assertContainerBuilderHasServiceDefinitionWithArgument( 'dpc_tutorial.a_server', 1, 123 );
$this->assertContainerBuilderHasServiceDefinitionWithArgument( 'dpc_tutorial.b_server', 0, 'host-b' ); $this->assertContainerBuilderHasServiceDefinitionWithArgument( 'dpc_tutorial.b_server', 1, 234 );}
See it fail
Write the codeuse Symfony\Component\DependencyInjection\Definition;
public function load(array $configs, ContainerBuilder $container){ $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs);
foreach ($config['servers'] as $name => $serverConfig) { $serverDefinition = new Definition(); $serverDefinition->setArguments( array( $serverConfig['host'], $serverConfig['port'], ) );
$container->setDefinition( 'dpc_tutorial.' . $name . '_server', $serverDefinition ); }}
See it pass
Refactor!public function load(array $configs, ContainerBuilder $container){ $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs);
$this->configureServers($container, $config['servers']);}
private function configureServers(ContainerBuilder $container, array $servers){ foreach ($servers as $name => $server) { $this->configureServer($container, $name, $server['host'], $server['port']); }}
private function configureServer(ContainerBuilder $container, $name, $host, $port){ $serverDefinition = new Definition(null, array($host, $port)); $container->setDefinition( 'dpc_tutorial.' . $name . '_server', $serverDefinition );}
Shortcuts versus the Real dealThe base class provides some useful shortcutsTo get the most out of testing your extension:Read all about classes like Definition in the officialdocumentation
Patterns of Dependency Injection
A Bundlecalled Bandle
I thought a bundle is just a class thatimplements BundleInterface...
Why the suffix isnecessary
abstract class Bundle extends ContainerAware implements BundleInterface{ public function getContainerExtension() { ... $basename = preg_replace('/Bundle$/', '', $this->getName());
$class = $this->getNamespace() . '\\DependencyInjection\\' . $basename . 'Extension';
if (class_exists($class)) { $extension = new $class(); ... } ... }} Line 6: '/Bundle$/'
But: no need to guess, youalready know which class
it is, right?
Override thegetContainerExtension() of your
bundle classThen make it return an instance of your extension class.
use Dpc\Bundle\TutorialBundle\DependencyInjection\DpcTutorialExtension;
class DpcTutorialBundle extends Bundle{ public function getContainerExtension() { return new DpcTutorialExtension(); }}
Now the extension doesn't need to be in theDependencyInjection directory anymore!
It still needs to have the Extension suffix though...
Open the Extension class (from theHttpKernel component)Take a look at the getAlias() method.
abstract class Extension implements ExtensionInterface, ConfigurationExtensionInterface{ public function getAlias() { $className = get_class($this); if (substr($className, -9) != 'Extension') { throw new BadMethodCallException( 'This extension does not follow the naming convention;' . 'you must overwrite the getAlias() method.' ); } $classBaseName = substr(strrchr($className, '\\'), 1, -9);
return Container::underscore($classBaseName); }}
The alias is used to find out which configuration belongs towhich bundle:
# in config.ymldpc_tutorial: ...
By convention it's the lowercase underscored bundle name.
But what happens when Irename the bundle?
The alias changes too, which means configuration inconfig.yml won't be recognized anymore.
Also:The extension needs to be renamed too, because of the
naming conventions...DpcTutorialBundle, DpcTutorialExtension, dpc_tutorial
NobackTestBundle, NobackTestExtension, noback_test
So: open your extension classOverride the getAlias() method.
Make it return the alias of your extension (a string).E.g. DpcTutorialBundle::getAlias() returns
dpc_tutorial.
class DpcTutorialExtension extends Extension{ public function getAlias() { return 'dpc_tutorial'; }}
But now we have someduplication of information
The alias is also mentioned inside the Configurationclass.
class Configuration implements ConfigurationInterface{ public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('dpc_tutorial'); ...
return $treeBuilder; }}
Modify extension and configurationHow can we make sure that the name of the root node inthe configuration class is the same as the alias returned by
getAlias()?
class Configuration implements ConfigurationInterface{ private $alias;
public function __construct($alias) { $this->alias = $alias; }
public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root($this->alias); ... }}
$configuration = new Configuration($this->getAlias());
This introduces a bugRun app/console config:dump-reference
[extension-alias]
Open the Extension classTake the one from the DependencyInjection
component.
public function getConfiguration(array $config, ContainerBuilder $container){ $reflected = new \ReflectionClass($this); $namespace = $reflected->getNamespaceName(); $class = $namespace.'\\Configuration'; if (class_exists($class)) { $r = new \ReflectionClass($class); $container->addResource(new FileResource($r->getFileName()));
if (!method_exists($class, '__construct')) { $configuration = new $class();
return $configuration; } }}
Our Configuration class has a constructor...
Override getConfiguration() inyour extension
Also: make sure only one instance of Configuration iscreated in the extension class.
class DpcTutorialExtension extends Extension{ public function load(array $configs, ContainerBuilder $container) { $configuration = $this->getConfiguration($configs, $container); $config = $this->processConfiguration($configuration, $configs); ... }
public function getConfiguration(array $config, ContainerBuilder $container) { return new Configuration($this->getAlias()); } ...}
Now we are allowed to rename Configuration or put itsomewhere else entirely!
Some last improvementExtend from ConfigurableExtension.
abstract class ConfigurableExtension extends Extension{ final public function load(array $configs, ContainerBuilder $container) { $this->loadInternal( $this->processConfiguration( $this->getConfiguration($configs, $container), $configs ), $container ); }
abstract protected function loadInternal(array $mergedConfig, ContainerBuilder $container}
It will save you a call to processConfiguration().
class DpcTutorialExtension extends ConfigurableExtension{ public function loadInternal(array $mergedConfig, ContainerBuilder $container) { // $mergedConfig has already been processed
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config' $loader->load('services.xml'); } ...}
We introduced flexibility...By hard-coding the alias
And by skipping all the magic stuff
Now we canChange *Bundle into *Bandle
Change *Extension into *PluginChange Configuration into Complexity
If we want...
€ 15,00
I’m impressed. — Robert C. Martin
leanpub.com/principles-of-php-package-design/c/dpc2014
Feedbackjoind.in/10849
Twitter@matthiasnoback