Руководство по использованию Dependency Injection в Symfony2

от автора

В данной статье приводится пример создания простого сайта-блога с использованием паттерна Dependency Injection. Применяется подход с внедрением зависимостей во все возможные компоненты Symfony: контроллеры, doctrine-репозитории, формы.

Для упрощения статьи сократим число страниц сайта до двух:

  • Добавление нового поста (/add)
  • Отображение списка всех постов (/list)

Финальная архитектура приложения будет выглядеть следующим образом:

Шаг 1. Создание произвольного сервиса

DI как часть Symfony уже рассматривался на хабре, а также подробно описан в документации. Поэтому мы сразу приступим к созданию собственных сервисов и зависимостей. Это можно сделать тремя способами: задание зависимостей в коде бандла, через конфигурационные файлы (YAML, XML, PHP) и используя аннотации (при помощи бандла JMSDiExtraBundle, входящего в стандартную комплектацию Symfony). Каждый способ имеет свои плюсы и минусы. Мы будем использовать аннотации для наглядности и сокращения объема кода. Начнем с класса, реализующего бизнесс-логику. Пусть это будет PostManager, обрабатывающий добавление нового поста:

/src/AppBundle/Manager/PostManager.php

<?php namespace AppBundle\Manager;  use JMS\DiExtraBundle\Annotation as DI; use AppBundle\Entity\Post; use AppBundle\Entity\User;  /**  * @DI\Service("app.manager.post", public=false)  */ class PostManager {     /**      * @DI\Inject("doctrine.orm.entity_manager")      * @var \Doctrine\ORM\EntityManager      */     public $em;      public function addPost(Post $post, User $user)     {         $post->setAuthor($user);         $this->em->persist($post);          $user->setLastPost($post);         $user->increasePostsCount();          $this->em->flush();     } } 

@DI\Service — превращает класс в сервис. В параметрах аннотации указывается название сервиса (app.manager.post) и его атрибуты.
public=false — данный атрибут указывает на то, что созданный сервис нельзя будет вызывать напрямую из DIC ($container->get(‘app.manager.post’) приведет к ошибке). Созданный сервис смогут использовать только сервисы, зависящие от него явно (далее, на примере с контроллером, станет понятнее).
@DI\Inject — указание сервисов, от которых зависит созданный сервис. Использование данной аннотации возможно только с переменными типа public. Для private/protected переменных-зависимостей можно использовать @DI\InjectParams для конструктора или другие способы создания сервисов.

Итак, мы создали сервис app.manager.post, зависящий от doctrine.orm.entity_manager:

Графическое отображение сервисов и связей доступно в удобном веб-интерфейсе с установкой JMSDebuggingBundle.

Шаг 2. Создание контроллера

По умолчанию в Symfony контроллеры не являются сервисами, но в документации есть заметка, позволяющая их сделать таковыми. Создадим PostController, использующий ранее созданный PostManager:

/src/AppBundle/Controller/PostController.php

<?php namespace AppBundle\Controller;  use JMS\DiExtraBundle\Annotation as DI; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; use JMS\SecurityExtraBundle\Annotation\Secure; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use AppBundle\Entity\Post; use AppBundle\Form\PostType;  /**  * @DI\Service("app.controller.post", scope="request")  * @Route(service="app.controller.post")  */ class PostController extends Controller {     /**      * @DI\Inject("service_container")      */     public $container;      /**      * @DI\Inject("app.manager.post")      * @var \AppBundle\Manager\PostManager      */     public $postManager;      /**      * @Route("/add", name="post_add")      * @Template      * @Secure(roles="ROLE_USER")      */     public function addAction()     {         $post = new Post();         $form = $this->createForm(new PostType(), $post);          if ($this->getRequest()->getMethod() == 'POST') {             $form->bind($this->getRequest());              if ($form->isValid()) {                 $this->postManager->addPost($post, $this->getUser());                 return $this->redirect($this->generateUrl('post_list'));             }         }         return array(             'form' => $form->createView()         );     } } 

scope=«request» — данный атрибут подробно описан в документации
@Rоute(service=«app.controller.post») — сообщает системе роутинга, что данный контроллер используется как сервис. При этом строковые значения правил переадресации изменятся с ‘AppBundle:Post:add’ на ‘app.controller.post:addAction’.
Использование зависимости @DI\Inject(«service_container») требует родительский класс-контроллер Symfony\Bundle\FrameworkBundle\Controller\Controller. В качестве контроллеров допускаются любые классы, не обязательно производные от стандартного контроллера — в этом случае зависимость от DIC можно исключить.

Таким образом, мы создали сервис app.controller.post, зависящий от service_container и app.manager.post:

Доступ сервиса к service_container означает, что он имеет доступ сразу ко всем public-сервисам проекта (через $this->container->get(‘…’)). Это облегчает использование фреймворка, но отследить связи между сервисами при таком подходе практически невозможно. Поэтому для сервисов приложения рекомендуется использовать атрибут public=false и следовать правилу:

Шаг 3. Создание формы

Данный шаг не является обязательным и служит скорее для демонстрации возможностей и закрепления материала. Но в объемных проектах, использующих большое количество форм, может быть полезным для контроля связей.
В созданном контроллере мы использовали форму PostType, попробуем определить её как сервис:

/src/AppBundle/Form/PostType.php

<?php namespace AppBundle\Form;  use JMS\DiExtraBundle\Annotation as DI;  /**  * @DI\Service("app.form.post", public=false)  */ class PostType extends AbstractType {     /* ... */ } 

Воспользуемся созданным сервисом в контроллере:

/src/AppBundle/Controller/PostController.php

/* ... */ class PostController extends Controller {     /**      * @DI\Inject("app.form.post")      * @var \AppBundle\Form\PostType      */     public $postType;      public function addAction()     {         $post = new Post();         $form = $this->createForm($this->postType, $post);         /* ... */     } } 

Теперь мы можем проследить связь формы и контроллера:

Шаг 4. Создание репозитория

Первый способ: фабричное создание

Использование Doctrine-репозиториев как сервисов осложняется тем, что они не являются частью Symfony, а входят в состав Doctrine. Но такая возможность всё же есть, через фабричное создание сервисов. К сожалению, на данный момент она не поддерживается через аннотации, поэтому придется использовать конфиги.
В файле Doctrine-сущности укажем путь к репозиторию:

/src/AppBundle/Entity/Post.php

<?php namespace AppBundle\Entity;  use Doctrine\ORM\Mapping as ORM;  /**  * @ORM\Table()  * @ORM\Entity(repositoryClass="AppBundle\Repository\PostRepository")  */ class Post {     /* ... */ } 

Создадим PostRepository для получения постраничного списка постов:

/src/AppBundle/Repository/PostRepository.php

<?php namespace AppBundle\Repository;  use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Tools\Pagination\Paginator;  class PostRepository extends EntityRepository {     public function getListPaginator($first, $max)     {         $qb = $this->createQueryBuilder('p')             ->orderBy('p.id', 'DESC')             ->setFirstResult($first)             ->setMaxResults($max);         return new Paginator($qb->getQuery());     } } 

Определяем созданный класс как сервис app.repository.post:

/src/AppBundle/Resources/config/services.yml

services:     app.repository.post:         class: AppBundle\Repository\PostRepository         factory_service: doctrine.orm.entity_manager         factory_method: getRepository         public: false         arguments: [AppBundle\Entity\Post] 

Добавим репозиторий и страницу со списком в контроллер:

/src/AppBundle/Controller/PostController.php

/* ... */ class PostController extends Controller {     /**      * @DI\Inject("app.repository.post")      * @var \AppBundle\Repository\PostRepository      */     public $postRepository;      protected $itemsPerPage = 10;      /**      * @Route("/list/{page}", requirements={"page"="\d+"}, defaults={"page"=1}, name="post_list")      * @Template      */     public function listAction($page)     {         $posts = $this->postRepository->getListPaginator(             $first = ($page-1)*$this->itemsPerPage,             $max = $this->itemsPerPage         );          return array(             'posts' => $posts,             'page' => $page,             'pagesCount' => ceil(count($posts)/$this->itemsPerPage),         );     }     /* ... */ } 

Второй способ: создание репозиториев-обёрток

Данный способ использует паттерн Adapter. Стандартный Doctrine-репозиторий включается в наш собственный сервис-репозиторий. В отличие от первого способа, здесь все реализуемо аннотациями.
В файле Doctrine-сущности убираем указание репозитория:

src/AppBundle/Entity/Post.php

<?php namespace AppBundle\Entity;  use Doctrine\ORM\Mapping as ORM;  /**  * @ORM\Table()  * @ORM\Entity()  */ class Post {     /* ... */ } 

Нам понадобится родительский класс Repository, зависимый от doctrine.orm.entity_manager и реализующий необходимые функции репозитория. Для этого воспользуемся наследованием сервисов:

/src/AppBundle/Repository/Repository.php

<?php namespace AppBundle\Repository;  use JMS\DiExtraBundle\Annotation as DI;  /**  * @DI\Service("app.repository", abstract=true)  */ class Repository {     /**      * @DI\Inject("doctrine.orm.entity_manager")      * @var \Doctrine\ORM\EntityManager      */     public $em;      protected $repositoryName;      /** @return \Doctrine\ORM\EntityRepository */     protected function getDoctrineRepository()     {         return $this->em->getRepository($this->repositoryName);     }      public function find($id)     {         return $this->getDoctrineRepository()->find($id);     }     public function findOneBy(array $criteria)     {         return $this->getDoctrineRepository()->findOneBy($criteria);     }     public function findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)     {         return $this->getDoctrineRepository()->findBy($criteria, $orderBy, $limit, $offset);     }     public function findAll()     {         return $this->getDoctrineRepository()->findAll();     }     /** @return \Doctrine\ORM\QueryBuilder */     public function createQueryBuilder($alias)     {         return $this->getDoctrineRepository()->createQueryBuilder($alias);     } } 

Определение сервиса теперь будет в самом классе репозитория-обёртки:

/src/AppBundle/Repository/PostRepository.php

<?php namespace AppBundle\Repository;  use JMS\DiExtraBundle\Annotation as DI; use Doctrine\ORM\Tools\Pagination\Paginator;  /**  * @DI\Service("app.repository.post", parent="app.repository", public=false)  */ class PostRepository extends Repository {     protected $repositoryName = 'AppBundle:Post';      public function getListPaginator($first, $max)     {         $qb = $this->createQueryBuilder('p')             ->orderBy('p.id', 'DESC')             ->setFirstResult($first)             ->setMaxResults($max);          return new Paginator($qb->getQuery());     } } 

Примечание: при использовании аннотаций, указание параметра parent=«app.repository» не является обязательным. JMSDiExtraBundle подставляет его автоматически, на основании родительского класса.

Оба способа реализуют одинаковый функционал и являются взаимозаменяемыми. Поэтому код контроллера не изменится:

/src/AppBundle/Controller/PostController.php

/* ... */ class PostController extends Controller {     /**      * @DI\Inject("app.repository.post")      * @var \AppBundle\Repository\PostRepository      */     public $postRepository;      protected $itemsPerPage = 10;      /**      * @Route("/list/{page}", requirements={"page"="\d+"}, defaults={"page"=1}, name="post_list")      * @Template      */     public function listAction($page)     {         $posts = $this->postRepository->getListPaginator(             $first = ($page-1)*$this->itemsPerPage,             $max = $this->itemsPerPage         );          return array(             'posts' => $posts,             'page' => $page,             'pagesCount' => ceil(count($posts)/$this->itemsPerPage),         );     }     /* ... */ } 

В результате граф зависимостей приложения примет следующий вид:

Для контроля связей при таком подходе необходимо следовать правилу:

Шаг 5. Оптимизация

Как Вы уже заметили, все зависимости сервиса являются его переменными и создаются вместе с созданием этого сервиса. В свою очередь, при создании зависимостей, создаются их зависимости, таким образом создаются все элементы поддерева зависимостей, в том числе неиспользуемые. На примере app.controller.post мы видим, что функция addAction использует app.manager.post и app.form.post, а listAction – app.repository.post. Но все переменные создаются при создании контроллера, поэтому, какую бы мы функцию не вызвали, часть переменных обязательно будут неиспользуемыми: в случае addAction это app.repository.post, в случае listAction — app.manager.post и app.form.post. Данный класс как бы состоит из двух независимых частей, в таком случае говорят, что он обладает низкой связностью. Чем с большим количеством переменных работает метод, тем выше связность этого метода со своим классом. Класс, в котором каждая переменная используется каждым методом, обладает максимальной связностью. Создание классов с максимальной связностью не всегда является возможным, но в нашем случае этого легко добиться, разделив app.controller.post на два независимых класса:

Заключение

Внедрение зависимостей во все классы системы повышает качество архитектуры, но увеличивает её сложность и время разработки. Поэтому данный подход оправдывает себя только в больших проектах. При создании маленьких проектов, подобных созданному в статье сайту, от него стоит отказаться, впрочем, как и от использования Symfony (в пользу легковесных фреймворков).

Рабочие исходники можно скачать/посмотреть здесь:
github.com/cerritus/demoblog

ссылка на оригинал статьи http://habrahabr.ru/post/165237/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *