В данной статье приводится пример создания простого сайта-блога с использованием паттерна Dependency Injection. Применяется подход с внедрением зависимостей во все возможные компоненты Symfony: контроллеры, doctrine-репозитории, формы.
Для упрощения статьи сократим число страниц сайта до двух:
- Добавление нового поста (/add)
- Отображение списка всех постов (/list)
Финальная архитектура приложения будет выглядеть следующим образом:
Шаг 1. Создание произвольного сервиса
DI как часть Symfony уже рассматривался на хабре, а также подробно описан в документации. Поэтому мы сразу приступим к созданию собственных сервисов и зависимостей. Это можно сделать тремя способами: задание зависимостей в коде бандла, через конфигурационные файлы (YAML, XML, PHP) и используя аннотации (при помощи бандла JMSDiExtraBundle, входящего в стандартную комплектацию Symfony). Каждый способ имеет свои плюсы и минусы. Мы будем использовать аннотации для наглядности и сокращения объема кода. Начнем с класса, реализующего бизнес-логику. Пусть это будет PostManager, обрабатывающий добавление нового поста:
<?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:
Графическое отображение сервисов и связей доступно в удобном веб-интерфейсе с установкой JMSDebuggingBundle.
Шаг 2. Создание контроллера
По умолчанию в Symfony контроллеры не являются сервисами, но в документации есть заметка, позволяющая их сделать таковыми. Создадим PostController, использующий ранее созданный PostManager:
<?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:
Доступ сервиса к service_container означает, что он имеет доступ сразу ко всем public-сервисам проекта (через $this->container->get('...')). Это облегчает использование фреймворка, но отследить связи между сервисами при таком подходе практически невозможно. Поэтому для сервисов приложения рекомендуется использовать атрибут public=false и следовать правилу:
Шаг 3. Создание формы
Данный шаг не является обязательным и служит скорее для демонстрации возможностей и закрепления материала. Но в объемных проектах, использующих большое количество форм, может быть полезным для контроля связей.
В созданном контроллере мы использовали форму PostType, попробуем определить её как сервис:
<?php
namespace AppBundleForm;
use JMSDiExtraBundleAnnotation as DI;
/**
* @DIService("app.form.post", public=false)
*/
class PostType extends AbstractType
{
/* ... */
}
Воспользуемся созданным сервисом в контроллере:
/* ... */
class PostController extends Controller
{
/**
* @DIInject("app.form.post")
* @var AppBundleFormPostType
*/
public $postType;
public function addAction()
{
$post = new Post();
$form = $this->createForm($this->postType, $post);
/* ... */
}
}
Теперь мы можем проследить связь формы и контроллера:
Шаг 4. Создание репозитория
Первый способ: фабричное создание
Использование Doctrine-репозиториев как сервисов осложняется тем, что они не являются частью Symfony, а входят в состав Doctrine. Но такая возможность всё же есть, через фабричное создание сервисов. К сожалению, на данный момент она не поддерживается через аннотации, поэтому придется использовать конфиги.
В файле Doctrine-сущности укажем путь к репозиторию:
<?php
namespace AppBundleEntity;
use DoctrineORMMapping as ORM;
/**
* @ORMTable()
* @ORMEntity(repositoryClass="AppBundleRepositoryPostRepository")
*/
class Post
{
/* ... */
}
Создадим PostRepository для получения постраничного списка постов:
<?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:
services:
app.repository.post:
class: AppBundleRepositoryPostRepository
factory_service: doctrine.orm.entity_manager
factory_method: getRepository
public: false
arguments: [AppBundleEntityPost]
Добавим репозиторий и страницу со списком в контроллер:
/* ... */
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-сущности убираем указание репозитория:
<?php
namespace AppBundleEntity;
use DoctrineORMMapping as ORM;
/**
* @ORMTable()
* @ORMEntity()
*/
class Post
{
/* ... */
}
Нам понадобится родительский класс Repository, зависимый от doctrine.orm.entity_manager и реализующий необходимые функции репозитория. Для этого воспользуемся наследованием сервисов:
<?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);
}
}
Определение сервиса теперь будет в самом классе репозитория-обёртки:
<?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 подставляет его автоматически, на основании родительского класса.
Оба способа реализуют одинаковый функционал и являются взаимозаменяемыми. Поэтому код контроллера не изменится:
/* ... */
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),
);
}
/* ... */
}
В результате граф зависимостей приложения примет следующий вид:
Для контроля связей при таком подходе необходимо следовать правилу:
Шаг 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
Автор: BoneFletcher