Diese Präsentation wurde erfolgreich gemeldet.
Wir verwenden Ihre LinkedIn Profilangaben und Informationen zu Ihren Aktivitäten, um Anzeigen zu personalisieren und Ihnen relevantere Inhalte anzuzeigen. Sie können Ihre Anzeigeneinstellungen jederzeit ändern.

High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014

11.915 Aufrufe

Veröffentlicht am

Slides for my talk "High Quality Symfony Bundles" tutorial at the Dutch PHP Conference 2014 (http://phpconference.nl).

Veröffentlicht in: Internet, Technologie
  • Loggen Sie sich ein, um Kommentare anzuzeigen.

High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014

  1. 1. The Naked Bundle Matthias Noback
  2. 2. Assuming you all have a working project https://github.com/matthiasnoback/ high-quality-bundles-project
  3. 3. Generate a bundle Use app/console generate:bundle Namespace: Dpc/Bundle/TutorialBundle Bundle name: DpcTutorialBundle Configuration: yml Whole directory structure: yes
  4. 4. The full directory structure of a bundle:
  5. 5. What's wrong? Too many comments Routing and a controller Translations Twig templates A useless test
  6. 6. You are not going to use it all, but it will be committed!
  7. 7. Before we continue, clean up your bundle Remove the following files and directories: Controller Resources/doc Resources/public Resources/translations Resources/views Tests Also remove any superfluous comments!
  8. 8. The official view on bundles
  9. 9. First-class citizens Documentation » The Quick Tour » The Architecture
  10. 10. I think your code is more important than the framework, which should be considered an implementation detail.
  11. 11. All your code lives in a bundle Documentation » The Book » Creating Pages in Symfony2
  12. 12. I don't think that's a good idea. It contradicts the promise of reuse of "pre-built feature packages".
  13. 13. Almost everything lives inside a bundle Documentation » Glossary
  14. 14. Which is not really true, because many things live inside libraries (e.g. the Symfony components), which is good.
  15. 15. Best practices Documentation » Cookbook » Bundles
  16. 16. Controllers Controllers don't need to extend anything at all. ContainerAware*should be avoided in all cases.
  17. 17. Tests What's up with the 95%?
  18. 18. Twig Why Twig? I though Symfony didn't care about this. Documentation » The Book » Creating and Using Templates
  19. 19. The old view on bundles is not sufficient anymore People are reimplementing things because existing solutions are too tightly coupled to a framework (or even a specific version). Why is it necessary to do all these things again for Symfony, Laravel, Zend, CodeIgniter, CakePHP, etc.?
  20. 20. Last year I started working on this
  21. 21. Then it became this
  22. 22. About bundles
  23. 23. A bundle is... A thin layer of Framework-specific configuration to make resources from some library available in a Symfony2 application.
  24. 24. A "Symfony application" meaning: A project that depends on the Symfony FrameworkBundle.
  25. 25. Resources are Routes (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) ...?
  26. 26. So: a bundle is mainly configuration to make these resources available, the rest is elsewhere in a library.
  27. 27. I also wrote
  28. 28. The challenge Make the bundle as clean as possible
  29. 29. Entities
  30. 30. Create an entity Use app/console doctrine:generate:entity Specs The entity shortcut name: DpcTutorialBundle:Post. Configuration format: annotation It has a title(string) field. Run app/console doctrine:schema:createor update --forceand make sure your entity has a corresponding table in your database.
  31. 31. Let's say you've modelled the Post entity very well You may want to reuse this in other projects. Yet it's only useful if that project uses Doctrine ORM too!
  32. 32. Why? Annotations couple the Postclass to Doctrine ORM. (Since annotations are classes!)
  33. 33. Also: why are my entities inside a bundle? They are not only useful inside a Symfony project.
  34. 34. Move the entity to another namespace E.g. DpcTutorialModelPost.
  35. 35. Create an XML mapping file E.g. DpcTutorialModelMappingPost.orm.xml <doctrine-mappingxmlns="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-ma http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entityname="DpcTutorialModelPost"> <idname="id"type="integer"> <generatorstrategy="AUTO"/> </id> <fieldname="title"type="string"/> </entity> </doctrine-mapping> You can copy the basic XML from /vendor/doctrine/orm/docs/en/reference/xml- mapping.rst.
  36. 36. In fact Always use XML mapping, it makes a lot of sense, and you get auto-completion in your IDE!
  37. 37. Remove all ORM things (annotations) from the Postclass
  38. 38. If you are going to try the following at home: Update DoctrineBundle Modify composer.json: { "require":{ ... "doctrine/doctrine-bundle":"~1.2@dev" } } Run composer update doctrine/doctrine-bundle
  39. 39. Add a compiler pass to your bundle It will load the XML mapping files useDoctrineBundleDoctrineBundleDependencyInjectionCompilerDoctrineOrmMappingsPass; classDpcTutorialBundle { publicfunctionbuild(ContainerBuilder$container) { $container->addCompilerPass($this->buildMappingCompilerPass()); } privatefunctionbuildMappingCompilerPass() { returnDoctrineOrmMappingsPass::createXmlMappingDriver( array( __DIR__.'/../../Test/Model/Mapping/' =>'DpcTutorialModel' ) ); } }
  40. 40. What have we won? Clean model classes They are reusable in non-Symfony projects They are reusable with different persistence libraries Documentation » The Cookbook » Doctrine » How to provide model classes for several Doctrine implementations
  41. 41. Controllers
  42. 42. Create a controller Use app/console generate:controller Specs Name: DpcTutorialBundle:Post Configuration: annotation Template: twig The route contains an idparameter. Action: showAction Route: /post/{id}/show
  43. 43. Implement the following logic Modify the action to retrieve a Postentity from the database: publicfunctionshowAction(Post$post) { returnarray('post'=>$post); }
  44. 44. Don't forget to register the route #inthebundle'srouting.ymlfile: DpcTutorialBundle_Controllers: resource:"@DpcTutorialBundle/Controller" type:"annotation"
  45. 45. By the way Consider using XML for routing too! For the same reasons
  46. 46. Does all of this really need to be inside the bundle?
  47. 47. Move the controller class to the library
  48. 48. Remove parent Controllerclass We are going to inject every dependency by hand instead of relying on the service container.
  49. 49. Create a service for the controller services: dpc_tutorial.post_controller: class:DpcTutorialControllerPostController
  50. 50. Remove @Routeannotations Instead: 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
  51. 51. Remove @Templateannotations Inject the templatingservice instead and use it to render the template. useSymfonyComponentHttpFoundationResponse; useSymfonyComponentTemplatingEngineInterface; classPostController { publicfunction__construct(EngineInterface$templating) { $this->templating=$templating; } publicfunctionshowAction(Post$post) { returnnewResponse( $this->templating->render( 'DpcTutorialBundle:Post:show.html.twig', array('post'=>$post) ) ); } }
  52. 52. services: dpc_tutorial.post_controller: class:DpcTutorialControllerPostController arguments: -@templating
  53. 53. What about the Templates
  54. 54. Move the template to the library E.g. from Dpc/Bundle/TutorialBundle/Resources/views/Post/show.html.twigto Dpc/Tutorial/View/Post/show.html.twig
  55. 55. Change the template reference $this->templating->render( '@DpcTutorial/Post/show.html.twig', array('post'=>$post) )
  56. 56. Register the new location of the templates #inconfig.yml twig: ... paths: "%kernel.root_dir%/../src/Dpc/Tutorial/View":DpcTutorial Documentation » The Cookbook » Templating » How to use and Register namespaced Twig Paths
  57. 57. Well... We don't want to ask users to modify their config.yml!
  58. 58. Let's prepend configuration useSymfonyComponentDependencyInjectionExtensionPrependExtensionInterface; classDpcTutorialExtensionextendsConfigurableExtensionimplementsPrependExtensionInter { ... publicfunctionprepend(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
  59. 59. One last step! The action's $postargument relies on something called .param converters Those convert the idfrom the route to the actual Post entity. This is actually Symfony framework-specific behavior
  60. 60. Rewrite the controller to make use of a repository useDoctrineCommonPersistenceObjectRepository; classPostController { publicfunction__construct(...,ObjectRepository$postRepository) { ... $this->postRepository=$postRepository; } publicfunctionshowAction($id) { $post=$this->postRepository->find($id); if(!($postinstanceofPost)){ thrownewNotFoundHttpException(); } ... } }
  61. 61. services: dpc_tutorial.post_controller: class:DpcTutorialControllerPostController arguments: -@templating -@dpc_tutorial.post_repository dpc_tutorial.post_repository: class:DoctrineCommonPersistenceObjectRepository factory_service:doctrine factory_method:getRepository arguments: -DpcTutorialModelPost
  62. 62. What do we have now?
  63. 63. Reusable templates
  64. 64. Reusable controllers They work with Silex too! Who would have though that was possible?
  65. 65. Console commands
  66. 66. Create a console command Use app/console generate:console-command Make it insert a new post in the database. It takes one argument: the post's title.
  67. 67. Something like this useDpcTutorialModelPost; useSymfonyBundleFrameworkBundleCommandContainerAwareCommand; useSymfonyComponentConsoleInputInputArgument; useSymfonyComponentConsoleInputInputInterface; useSymfonyComponentConsoleOutputOutputInterface; classCreatePostCommandextendsContainerAwareCommand { protectedfunctionconfigure() { $this ->setName('post:create') ->addArgument('title',InputArgument::REQUIRED); } protectedfunctionexecute(InputInterface$input,OutputInterface$output) { $manager=$this->getContainer() ->get('doctrine') ->getManagerForClass('DpcTutorialModelPost'); $post=newPost(); $post->setTitle($input->getArgument('title')); $manager->persist($post); $manager->flush(); $output->writeln('Newpostcreated:'.$post->getTitle()); } }
  68. 68. Why is it inside a bundle? Because it is automatically registered when it's in the Commanddirectory.
  69. 69. So let's move it out!
  70. 70. Move the command to the library
  71. 71. Create a service for it Give it the tag console.command. Or else it won't be recognized anymore! services: dpc_tutorial.create_post_command: class:DpcTutorialCommandCreatePostCommand tags: -{name:console.command}
  72. 72. What about ContainerAware? It couples our command to the Symfony framework. Which is not needed at all.
  73. 73. Extend from Command Then inject dependencies instead of fetching them from the container. useDoctrineCommonPersistenceManagerRegistry; classCreatePostCommandextendsCommand { private$doctrine; publicfunction__construct(ManagerRegistry$doctrine) { parent::__construct(); $this->doctrine=$doctrine; } ... protectedfunctionexecute(InputInterface$input,OutputInterface$output) { $manager=$this->doctrine->getManager(); ... } }
  74. 74. services: dpc_tutorial.create_post_command: class:DpcTutorialCommandCreatePostCommand arguments: -@doctrine tags: -{name:console.command}
  75. 75. What do we have? Explicit dependencies Reusable commands that works in all projects that use the Symfony Console Component (like ) A bit less magic (no auto-registering commands) Which means now we can put anything we want in the Commanddirectory Cilex
  76. 76. Testing a bundle Or: testing configuration
  77. 77. The Configurationclass
  78. 78. I don't get it!
  79. 79. I don't trust myself with it. And when I don't trust myself, I write tests
  80. 80. SymfonyConfigTest On GitHub: SymfonyConfigTest { "require-dev":{ "matthiasnoback/symfony-config-test":"~0.1" } }
  81. 81. Prepare a test suite for your Configurationclass Create a directory Tests/DependencyInjectioninside the bundle. In that directory create a new class: ConfigurationTest.
  82. 82. Create the test class The ConfigurationTestshould extend from AbstractConfigurationTestCase Implement the missing method getConfiguration() namespaceDpcBundleTutorialBundleTestsDependencyInjection; useDpcBundleTutorialBundleDependencyInjectionConfiguration; useMatthiasSymfonyConfigTestPhpUnitAbstractConfigurationTestCase; classConfigurationTestextendsAbstractConfigurationTestCase { protectedfunctiongetConfiguration() { returnnewConfiguration(); } }
  83. 83. Desired structure in config.yml dpc_tutorial: #hostshouldbearequiredkey host:localhost
  84. 84. A required value: host Test first /** *@test */ publicfunctionthe_host_key_is_required() { $this->assertConfigurationIsInvalid( array( array() ), 'host' ); } If we provide no values at all, we expect an exception containing "host".
  85. 85. See it fail bin/phpunit-capp
  86. 86. Make the test pass $rootNode ->children() ->scalarNode('host') ->isRequired() ->end() ->end();
  87. 87. Trial and error You're done when the test passes!
  88. 88. Repeated configuration values Desired structure in config.yml dpc_tutorial: servers: a: host:server-a.nobacksoffice.nl port:2730 b: host:server-b.nobacksoffice.nl port:2730 ... hostand portare required keys for each server configuration
  89. 89. Test first /** *@test */ publicfunctionhost_is_required_for_each_server() { $this->assertConfigurationIsInvalid( array( array( 'servers'=>array( 'a'=>array() ) ) ), 'host' ); }
  90. 90. Run the tests bin/phpunit-capp
  91. 91. Write the code $rootNode ->children() ->arrayNode('servers') ->useAttributeAsKey('name') ->prototype('array') ->children() ->scalarNode('host') ->isRequired() ->end()
  92. 92. Run the tests
  93. 93. Test first Repeat these steps for port Make sure your test first fails Then you add some code Then the test should pass
  94. 94. Merging config values $this->assertConfigurationIsInvalid( array( array( ...//e.g.valuesfromconfig.yml ), array( ...//e.g.valuesfromconfig_dev.yml ) ), 'host' );
  95. 95. Disable merging Test first /** *@test */ publicfunctionserver_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) ) ) ); }
  96. 96. Add some code $rootNode ->children() ->arrayNode('servers') ->useAttributeAsKey('name')//don'treindexthearray ->prototype('array')//means:repeatable ->children() ->scalarNode('host')->end() ->scalarNode('port')->end() ->end() ->end() ->end() ->end();
  97. 97. Run the tests bin/phpunit-capp
  98. 98. Disable deep merging Values from different configuration sources should not be merged. $rootNode ->children() ->arrayNode('servers') ->performNoDeepMerging() ... ->end() ->end();
  99. 99. Advantages of TDD for Configurationclasses We gradually approach our goal. We immediately get feedback on what's wrong. We can test different configuration values without changing config.ymlmanually. We can make sure the user gets very specific error messages about wrong configuration values. Learn more about all the options by reading the . offical documentation of the Config component
  100. 100. Testing Extension classes dpc_tutorial: servers: a: host:localhost port:2730 Should give us a dpc_tutorial.a_serverservice with hostand portas constructor arguments.
  101. 101. Create a test class for your extension Directory: Tests/DependencyInjection Class name: [NameOfTheExtension]Test Class should extend AbstractExtensionTestCase Implement getContainerExtensions(): return an instance of your extension class namespaceDpcBundleTutorialBundleTestsDependencyInjection; useDpcBundleTutorialBundleDependencyInjectionDpcTutorialExtension; useMatthiasSymfonyDependencyInjectionTestPhpUnitAbstractExtensionTestCase; classDpcTutorialExtensionTestextendsAbstractExtensionTestCase { protectedfunctiongetContainerExtensions() { returnarray( newDpcTutorialExtension() ); } }
  102. 102. Test first /** *@test */ publicfunctionit_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 ); }
  103. 103. See it fail
  104. 104. Write the code useSymfonyComponentDependencyInjectionDefinition; publicfunctionload(array$configs,ContainerBuilder$container) { $configuration=newConfiguration(); $config=$this->processConfiguration($configuration,$configs); foreach($config['servers']as$name=>$serverConfig){ $serverDefinition=newDefinition(); $serverDefinition->setArguments( array( $serverConfig['host'], $serverConfig['port'], ) ); $container->setDefinition( 'dpc_tutorial.'.$name.'_server', $serverDefinition ); } }
  105. 105. See it pass
  106. 106. Refactor! publicfunctionload(array$configs,ContainerBuilder$container) { $configuration=newConfiguration(); $config=$this->processConfiguration($configuration,$configs); $this->configureServers($container,$config['servers']); } privatefunctionconfigureServers(ContainerBuilder$container,array$servers) { foreach($serversas$name=>$server){ $this->configureServer($container,$name,$server['host'],$server['port']); } } privatefunctionconfigureServer(ContainerBuilder$container,$name,$host,$port) { $serverDefinition=newDefinition(null,array($host,$port)); $container->setDefinition( 'dpc_tutorial.'.$name.'_server', $serverDefinition ); }
  107. 107. Shortcuts versus the Real deal The base class provides some useful shortcuts To get the most out of testing your extension: Read all about classes like Definitionin the official documentation
  108. 108. Patterns of Dependency Injection
  109. 109. A Bundle called Bandle
  110. 110. I thought a bundle is just a class that implements BundleInterface...
  111. 111. Why the suffix is necessary abstractclassBundleextendsContainerAwareimplementsBundleInterface { publicfunctiongetContainerExtension() { ... $basename=preg_replace('/Bundle$/','',$this->getName()); $class=$this->getNamespace() .'DependencyInjection' .$basename .'Extension'; if(class_exists($class)){ $extension=new$class(); ... } ... } } Line 6: '/Bundle$/'
  112. 112. But: no need to guess, you already know which class it is, right?
  113. 113. Override the getContainerExtension()of your bundle class Then make it return an instance of your extension class.
  114. 114. useDpcBundleTutorialBundleDependencyInjectionDpcTutorialExtension; classDpcTutorialBundleextendsBundle { publicfunctiongetContainerExtension() { returnnewDpcTutorialExtension(); } } Now the extension doesn't need to be in the DependencyInjectiondirectory anymore!
  115. 115. It still needs to have the Extensionsuffix though...
  116. 116. Open the Extensionclass (from the HttpKernelcomponent) Take a look at the getAlias()method.
  117. 117. abstractclassExtensionimplementsExtensionInterface,ConfigurationExtensionInterface { publicfunctiongetAlias() { $className=get_class($this); if(substr($className,-9)!='Extension'){ thrownewBadMethodCallException( 'Thisextensiondoesnotfollowthenamingconvention;' .'youmustoverwritethegetAlias()method.' ); } $classBaseName=substr(strrchr($className,''),1,-9); returnContainer::underscore($classBaseName); } }
  118. 118. The alias is used to find out which configuration belongs to which bundle: #inconfig.yml dpc_tutorial: ... By convention it's the lowercase underscored bundle name.
  119. 119. But what happens when I rename the bundle? The alias changes too, which means configuration in config.ymlwon't be recognized anymore.
  120. 120. Also: The extension needs to be renamed too, because of the naming conventions... DpcTutorialBundle,DpcTutorialExtension,dpc_tutorial NobackTestBundle,NobackTestExtension,noback_test
  121. 121. So: open your extension class Override the getAlias()method. Make it return the alias of your extension (a string). E.g. DpcTutorialBundle::getAlias()returns dpc_tutorial.
  122. 122. classDpcTutorialExtensionextendsExtension { publicfunctiongetAlias() { return'dpc_tutorial'; } }
  123. 123. But now we have some duplication of information The alias is also mentioned inside the Configuration class.
  124. 124. classConfigurationimplementsConfigurationInterface { publicfunctiongetConfigTreeBuilder() { $treeBuilder=newTreeBuilder(); $rootNode=$treeBuilder->root('dpc_tutorial'); ... return$treeBuilder; } }
  125. 125. Modify extension and configuration How can we make sure that the name of the root node in the configuration class is the same as the alias returned by getAlias()?
  126. 126. classConfigurationimplementsConfigurationInterface { private$alias; publicfunction__construct($alias) { $this->alias=$alias; } publicfunctiongetConfigTreeBuilder() { $treeBuilder=newTreeBuilder(); $rootNode=$treeBuilder->root($this->alias); ... } } $configuration=newConfiguration($this->getAlias());
  127. 127. This introduces a bug Run app/console config:dump-reference [extension-alias]
  128. 128. Open the Extensionclass Take the one from the DependencyInjection component.
  129. 129. publicfunctiongetConfiguration(array$config,ContainerBuilder$container) { $reflected=newReflectionClass($this); $namespace=$reflected->getNamespaceName(); $class=$namespace.'Configuration'; if(class_exists($class)){ $r=newReflectionClass($class); $container->addResource(newFileResource($r->getFileName())); if(!method_exists($class,'__construct')){ $configuration=new$class(); return$configuration; } } } Our Configurationclass has a constructor...
  130. 130. Override getConfiguration()in your extension Also: make sure only one instance of Configurationis created in the extension class.
  131. 131. classDpcTutorialExtensionextendsExtension { publicfunctionload(array$configs,ContainerBuilder$container) { $configuration=$this->getConfiguration($configs,$container); $config=$this->processConfiguration($configuration,$configs); ... } publicfunctiongetConfiguration(array$config,ContainerBuilder$container) { returnnewConfiguration($this->getAlias()); } ... } Now we are allowed to rename Configurationor put it somewhere else entirely!
  132. 132. Some last improvement Extend from ConfigurableExtension.
  133. 133. abstractclassConfigurableExtensionextendsExtension { finalpublicfunctionload(array$configs,ContainerBuilder$container) { $this->loadInternal( $this->processConfiguration( $this->getConfiguration($configs,$container), $configs ), $container ); } abstractprotectedfunctionloadInternal(array$mergedConfig,ContainerBuilder$conta }
  134. 134. It will save you a call to processConfiguration().
  135. 135. classDpcTutorialExtensionextendsConfigurableExtension { publicfunctionloadInternal(array$mergedConfig,ContainerBuilder$container) { //$mergedConfighasalreadybeenprocessed $loader=newXmlFileLoader($container,newFileLocator(__DIR__.'/../Resources/co $loader->load('services.xml'); } ... }
  136. 136. We introduced flexibility... By hard-coding the alias And by skipping all the magic stuff Now we can Change *Bundleinto *Bandle Change *Extensioninto *Plugin Change Configurationinto Complexity If we want...
  137. 137. € 15,00
  138. 138. I’m impressed. — Robert C. Martin leanpub.com/principles-of-php-package-design/c/dpc2014
  139. 139. Feedback joind.in/10849 Twitter @matthiasnoback