Pull to refresh

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

Reading time9 min
Views26K
В данной статье приводится пример создания простого сайта-блога с использованием паттерна 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
Tags:
Hubs:
+13
Comments58

Articles