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

в 15:13, , рубрики: dependency injection, php, symfony, Веб-разработка, метки: , ,

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

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

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

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

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

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

/src/AppBundle/Manager/PostManager.php

<?php
namespace AppBundleManager;

use JMSDiExtraBundleAnnotation as DI;
use AppBundleEntityPost;
use AppBundleEntityUser;

/**
 * @DIService("app.manager.post", public=false)
 */
class PostManager
{
    /**
     * @DIInject("doctrine.orm.entity_manager")
     * @var DoctrineORMEntityManager
     */
    public $em;

    public function addPost(Post $post, User $user)
    {
        $post->setAuthor($user);
        $this->em->persist($post);

        $user->setLastPost($post);
        $user->increasePostsCount();

        $this->em->flush();
    }
}

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

Итак, мы создали сервис app.manager.post, зависящий от doctrine.orm.entity_manager:
Руководство по использованию Dependency Injection в Symfony2
Графическое отображение сервисов и связей доступно в удобном веб-интерфейсе с установкой JMSDebuggingBundle.

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

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

/src/AppBundle/Controller/PostController.php
<?php
namespace AppBundleController;

use JMSDiExtraBundleAnnotation as DI;
use SensioBundleFrameworkExtraBundleConfigurationRoute;
use SensioBundleFrameworkExtraBundleConfigurationTemplate;
use JMSSecurityExtraBundleAnnotationSecure;
use SymfonyBundleFrameworkBundleControllerController;
use AppBundleEntityPost;
use AppBundleFormPostType;

/**
 * @DIService("app.controller.post", scope="request")
 * @Route(service="app.controller.post")
 */
class PostController extends Controller
{
    /**
     * @DIInject("service_container")
     */
    public $container;

    /**
     * @DIInject("app.manager.post")
     * @var AppBundleManagerPostManager
     */
    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'.
Использование зависимости @DIInject(«service_container») требует родительский класс-контроллер SymfonyBundleFrameworkBundleControllerController. В качестве контроллеров допускаются любые классы, не обязательно производные от стандартного контроллера — в этом случае зависимость от DIC можно исключить.

Таким образом, мы создали сервис app.controller.post, зависящий от service_container и app.manager.post:
Руководство по использованию Dependency Injection в Symfony2
Доступ сервиса к service_container означает, что он имеет доступ сразу ко всем public-сервисам проекта (через $this->container->get('...')). Это облегчает использование фреймворка, но отследить связи между сервисами при таком подходе практически невозможно. Поэтому для сервисов приложения рекомендуется использовать атрибут public=false и следовать правилу:
Руководство по использованию Dependency Injection в Symfony2

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

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

/src/AppBundle/Form/PostType.php

<?php
namespace AppBundleForm;

use JMSDiExtraBundleAnnotation as DI;

/**
 * @DIService("app.form.post", public=false)
 */
class PostType extends AbstractType
{
    /* ... */
}

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

/src/AppBundle/Controller/PostController.php
/* ... */
class PostController extends Controller
{
    /**
     * @DIInject("app.form.post")
     * @var AppBundleFormPostType
     */
    public $postType;

    public function addAction()
    {
        $post = new Post();
        $form = $this->createForm($this->postType, $post);
        /* ... */
    }
}

Теперь мы можем проследить связь формы и контроллера:
Руководство по использованию Dependency Injection в Symfony2

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

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

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

/src/AppBundle/Entity/Post.php

<?php
namespace AppBundleEntity;

use DoctrineORMMapping as ORM;

/**
 * @ORMTable()
 * @ORMEntity(repositoryClass="AppBundleRepositoryPostRepository")
 */
class Post
{
    /* ... */
}

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

/src/AppBundle/Repository/PostRepository.php

<?php
namespace AppBundleRepository;

use DoctrineORMEntityRepository;
use DoctrineORMToolsPaginationPaginator;

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: AppBundleRepositoryPostRepository
        factory_service: doctrine.orm.entity_manager
        factory_method: getRepository
        public: false
        arguments: [AppBundleEntityPost]

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

/src/AppBundle/Controller/PostController.php

/* ... */
class PostController extends Controller
{
    /**
     * @DIInject("app.repository.post")
     * @var AppBundleRepositoryPostRepository
     */
    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 AppBundleEntity;

use DoctrineORMMapping as ORM;

/**
 * @ORMTable()
 * @ORMEntity()
 */
class Post
{
    /* ... */
}

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

/src/AppBundle/Repository/Repository.php

<?php
namespace AppBundleRepository;

use JMSDiExtraBundleAnnotation as DI;

/**
 * @DIService("app.repository", abstract=true)
 */
class Repository
{
    /**
     * @DIInject("doctrine.orm.entity_manager")
     * @var DoctrineORMEntityManager
     */
    public $em;

    protected $repositoryName;

    /** @return DoctrineORMEntityRepository */
    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 DoctrineORMQueryBuilder */
    public function createQueryBuilder($alias)
    {
        return $this->getDoctrineRepository()->createQueryBuilder($alias);
    }
}

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

/src/AppBundle/Repository/PostRepository.php

<?php
namespace AppBundleRepository;

use JMSDiExtraBundleAnnotation as DI;
use DoctrineORMToolsPaginationPaginator;

/**
 * @DIService("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
{
    /**
     * @DIInject("app.repository.post")
     * @var AppBundleRepositoryPostRepository
     */
    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),
        );
    }
    /* ... */
}

В результате граф зависимостей приложения примет следующий вид:
Руководство по использованию Dependency Injection в Symfony2
Для контроля связей при таком подходе необходимо следовать правилу:
Руководство по использованию Dependency Injection в Symfony2

Шаг 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 на два независимых класса:
Руководство по использованию Dependency Injection в Symfony2

Заключение

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

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

Автор: BoneFletcher

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js