- PVSM.RU - https://www.pvsm.ru -
Часто возникает необходимость использовать одинаковый код в разных проектах. Чтобы не было повторения кода, такой код обычно помещают в библиотеку. В фреймворке Symfony2 весь код должен быть помещён в так называемые бандлы (bundle). Уже сейчас существует огромное количество бандлов, решающих совершенно разные задачи, но всё-таки часто возникает необходимость создания своего бандла, решающего рутинную задачу.
Это может быть обычный бандл, находящийся в папке src, и тогда при необходимости использовать его в новом проекте нужно скопировать его в новый проект. Но в таком случае возникает проблема с обновлением кода, ведь, когда код доступен для изменения, то он будет изменён (особые извращенцы изменяют даже код в папке vendor). Для удобства процедуры использования своего кода в других проектах можно оформить бандл как внешний, вендорный бандл, и управлять им через composer наравне с остальными сторонними бандлами.
Эта статья пошагово показывает, как можно с нуля создать бандл, доступный к установке через composer.
Содержание:
Будет рассмотрено создание бандла для управления статичными страницами сайта. Можно найти несколько готовых подобных бандлов, но они либо слишком простые, либо слишком сложные (типа SonataPageBundle). Уровень статьи — продвинутый новичок. Подразумевается, что читатель уже умеет создавать бандлы в проекте, а также пользоваться контроллерами и шаблонами.
Создание вендорного бандла проще всего начать с обычного бандла. Поэтому воспользуемся командой generate:bundle для его создания. Тут нужно внимательно отнестись к правильному именованию, ведь именно под этим названием ваш бандл станет публично доступен. Обычно бандлы называются по имени разработчика или компании и по имени самого бандла. Поэтому при создании бандла я указал пространство имён Lexxpavlov/PageBundle — моё имя и простое понятное название. На основании пространства имён автоматически предлагается имя самого бандла, в моём случае LexxpavlovPageBundle. Это имя можно изменить, но меня оно устраивает, поэтому можно оставить так. Подробнее про именование бандла можно почитать тут [1].
При создании бандла особое внимание нужно уделить одному параметру — выбору типа конфигурации. Симфония предлагает четыре разных варианта — yml, xml, php, или annotation. Но реальный выбор происходит между yml и annotation, то есть между выбором конфигурации в отдельных файлах формата YAML, либо в формате аннотаций, размещаемых прямо в комментариях в самом коде контроллеров и сущностей. На этот счёт было сломано много копий в этом топике [2], есть аргументы в обоих вариантах. Мой выбор в данном случае — аннотации, потому что проект очень маленький, и преимущества отдельных файлов конфигурации нивелируются (по сути, только один файл будет иметь конфигурацию — доктриновская сущность). На быстродействие самого бандла в production тип конфигурации не влияет — в любом случае весь конфиг кэшируется.
Далее следует подтвердить готовность генерации нового бандла и после этого согласиться с автоматическим обновлением файла AppKernel.php и конфигурации роутов app/config/routes.yml.
Пришло время создать сущность, в которой будут находиться будущие страницы. Очевидно, что требуются поля id, title и content. Также будет полезным булевое поле published, для возможности временно отключить показ страницы, а также поля createdAt и updatedAt с датой создания и последнего изменения страницы. В целях SEO полезно добавить поле для хранения названия страницы в «урлифицированном» виде, обычно такое поле называется slug, а также поля keywords и description. Создаём папку Entity в папке бандла, и в ней создаём файл Page.php:
<?php
namespace LexxpavlovPageBundleEntity;
use DoctrineORMMapping as ORM;
use GedmoMappingAnnotation as Gedmo;
/**
* @ORMEntity
*/
class Page
{
/**
* @var integer
* @ORMColumn(type="integer")
* @ORMId
* @ORMGeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* @var string
* @GedmoSlug(fields={"title"}, updatable=false)
* @ORMColumn(type="string", length=100, unique=true)
*/
protected $slug;
/**
* @var string
*
* @ORMColumn(type="string", length=255)
*/
protected $title;
/**
* @var string
* @ORMColumn(type="text")
*/
protected $content;
/**
* @var string
* @ORMColumn(type="text", name="keywords", nullable=true)
*/
protected $keywords;
/**
* @var string
* @ORMColumn(type="text", name="description", nullable=true)
*/
protected $description;
/**
* @var boolean
* @ORMColumn(type="boolean", options={"default":false})
*/
protected $published = false;
/**
* @var Datetime
* @GedmoTimestampable(on="create")
* @ORMColumn(type="datetime", name="created_at")
*/
protected $createdAt;
/**
* @var Datetime
* @GedmoTimestampable(on="update")
* @ORMColumn(type="datetime", name="updated_at")
*/
protected $updatedAt;
public function __toString() {
return $this->title ?: 'n/a';
}
/**
* Get id
* @return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set slug
* @param string $slug
* @return Page
*/
public function setSlug($slug)
{
$this->slug = $slug;
return $this;
}
/**
* Get slug
* @return string
*/
public function getSlug()
{
return $this->slug;
}
/**
* Set title
* @param string $title
* @return Page
*/
public function setTitle($title)
{
$this->title = $title;
return $this;
}
/**
* Get title
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* Set content
* @param string $content
* @return Page
*/
public function setContent($content)
{
$this->content = $content;
return $this;
}
/**
* Get content
* @return string
*/
public function getContent()
{
return $this->content;
}
/**
* Set meta keywords
* @param string $keywords
* @return Page
*/
public function setKeywords($mkeywords)
{
$this->keywords = $keywords;
return $this;
}
/**
* Get meta keywords
* @return string
*/
public function getKeywords()
{
return $this->keywords;
}
/**
* Set meta description
* @param string $description
* @return Page
*/
public function setDescription($description)
{
$this->description = $description;
return $this;
}
/**
* Set meta description
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* Set published
* @param boolean $published
* @return Page
*/
public function setPublished($published)
{
$this->published = $published;
return $this;
}
/**
* Toggle published
* @return Page
*/
public function togglePublished()
{
$this->published = !$this->published;
return $this;
}
/**
* Get published
* @return boolean
*/
public function getPublished()
{
return $this->published;
}
/**
* Sets created at
* @param DateTime $createdAt
* @return Page
*/
public function setCreatedAt(DateTime $createdAt)
{
$this->createdAt = $createdAt;
return $this;
}
/**
* Returns created at
* @return DateTime
*/
public function getCreatedAt()
{
return $this->createdAt;
}
/**
* Sets updated at
* @param DateTime $updatedAt
* @return Page
*/
public function setUpdatedAt(DateTime $updatedAt)
{
$this->updatedAt = $updatedAt;
return $this;
}
/**
* Returns updated at
* @return DateTime
*/
public function getUpdatedAt()
{
return $this->updatedAt;
}
}
Помимо известных аннотаций @ORM, поддерживаемых доктриной, здесь использованы аннотации @Gedmo, предоставленные бандлом StofDoctrineExtensionsBundle [3]. Чтобы эти аннотации работали, нужно добавить этот бандл в систему. Для этого воспользуемся инструкцией в его документации, установив пакет изменением composer.json нашего проекта. Для работы используемых аннотаций достаточно такой конфигурации:
stof_doctrine_extensions:
orm:
default:
sluggable: true
timestampable: true
Создание таблицы в базу данных выполняется командой 'app/console doctrine:schema:update --force' (если сама БД ещё не создана, то нужно вначале выполнить команду 'app/console doctrine:database:create'). Эта команда создаст таблицу с именем Page или page, в зависимости от настройки БД. Насколько я встречал в других бандлах, чаще всего сторонние бандлы создают таблицы без заглавных букв, потому что иначе в некоторых случаях могут возникнуть проблемы. Но в данном случае это не важно, позже этот скользкий момент будет устранён.
Следующий шаг — написание типа формы для создания страниц. Для этого требуется создать папку Form/Type в бандле и в ней создать файл PageType.php. Содержимое этого файла достаточно тривиальное — создаём класс-наследник AbstractType и указываем в методе buildForm() все поля, требуемые для заполнения:
<?php
namespace LexxpavlovPageBundleFormType;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
class PageType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', 'text')
->add('slug', 'text', array('required' => false))
->add('content', 'textarea')
->add('published', 'checkbox', array('required' => false))
->add('keywords', 'text', array('required' => false))
->add('description', 'text', array('required' => false))
->add('save', 'submit')
;
}
public function getName()
{
return 'lexxpavlov_page';
}
}
Не следует забывать указать имя формы, имеющее в префиксе название бандла — `lexxpavlov_page`. Именно по этому имени нужно из типа формы создать сервис, и указание вендорного префикса позволит избежать конфликтов в проекте. Для этого создадим файл конфигурации бандла Resources/config/services.yml и добавим в него такой код:
services:
lexxpavlov_page.form.type.page:
class: LexxpavlovPageBundleFormTypePageType
tags:
- { name: form.type, alias: lexxpavlov_page }
В папке Resources/config уже находится файл services.xml, предлагающий создавать описание сервисов в многословном формате XML. Так как мы написали описание сервиса на YAML, то нужно удалить файл services.xml и настроить подключение файла services.yml. Для этого откроем настройку инъектора зависимостей бандла — файл DependencyInjector/LexxpavlovPageExtension.php и исправим чтение файла XML на YAML:
// ...
$loader = new LoaderYamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
Добавим в созданный генератором контроллер DefaultController действия для просмотра списка страниц, для просмотра одной страницы и для добавления новой страницы, не забудем добавить также и шаблоны для них:
<?php
namespace AppBundleController;
use SymfonyBundleFrameworkBundleControllerController;
use SensioBundleFrameworkExtraBundleConfigurationRoute;
use SensioBundleFrameworkExtraBundleConfigurationTemplate;
use SymfonyComponentHttpFoundationRequest;
use LexxpavlovPageBundleEntityPage;
/**
* @Route("/page")
*/
class DefaultController extends Controller
{
/**
* @Route("/", name="page")
* @Template()
*/
public function indexAction()
{
$pages = $this->getDoctrine()->getManager()
->getRepository('LexxpavlovPageBundle:Page')
->findAll();
return array(
'pages' => $pages,
);
}
/**
* @Route("/show/{slug}", name="page_show")
* @Template()
*/
public function showAction(Page $page)
{
}
/**
* @Route("/new", name="page_new")
* @Template()
*/
public function newAction(Request $request)
{
$page = new Page();
$form = $this->createForm('lexxpavlov_page', $page);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->persist($page);
$em->flush();
return $this->redirect($this->generateUrl('page'));
}
}
return array(
'form' => $form->createView(),
);
}
}
{# src/Lexxpavlov/PageBundle/Resources/views/Default/index.html.twig #}
<ul>
{% for page in pages %}
<li><a href="{{ path('page_show', {slug: page.slug}) }}">{{ page.title }}</a></li>
{% endfor %}
</ul>
<a href="{{ path('page_new') }}">Добавить новую страницу</a>
{# src/Lexxpavlov/PageBundle/Resources/views/Default/show.html.twig #}
<article>
<h1>{{ page.title }}</h1>
<div>Дата публикации: <time datetime="{{ page.createdAt|date('Y-m-d') }}" pubdate>{{ page.createdAt|date('d.m.Y') }}</time></div>
{{ page.content|raw }}
</article>
{# src/Lexxpavlov/PageBundle/Resources/views/Default/new.html.twig #}
<h1>Добавление страницы</h1>
{{ form(form) }}
Также создадим класс для админки SonataAdminBundle [5] — популярной административной панели проектов на Symfony2. Для этого, во-первых, нужно добавить в проект саму админку SonataAdminBundle (существует старая статья [6] про установку SonataAdminBundle, я планирую написать новую по этой теме). Далее требуется создать файл Admin/Page.php:
<?php
namespace LexxpavlovPageBundleAdmin;
use SonataAdminBundleAdminAdmin;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleShowShowMapper;
class PageAdmin extends Admin
{
public function configureListFields(ListMapper $listMapper)
{
$listMapper
->addIdentifier('title')
->add('slug')
->add('published', null, array('editable' => true))
->add('createdAt', 'datetime')
->add('updatedAt', 'datetime')
;
}
public function configureFormFields(FormMapper $formMapper)
{
$formMapper
->with('General')
->add('slug', null, array('required' => false))
->add('title')
->add('content')
->add('published', null, array('required' => false))
->end()
->with('SEO')
->add('keywords', null, array('required' => false))
->add('description', null, array('required' => false))
->end()
;
$formMapper->setHelps(array(
'slug' => 'Leave blank for automatic filling from title field',
));
}
public function configureDatagridFilters(DatagridMapper $datagridMapper)
{
$datagridMapper
->add('slug')
->add('title')
->add('published')
;
}
public function configureShowFields(ShowMapper $showMapper)
{
$showMapper
->add('slug')
->add('title')
->add('content')
->add('published')
->add('publishedAt', 'datetime')
->add('createdAt', 'datetime')
->add('updatedAt', 'datetime')
->add('keywords')
->add('description')
;
}
}
Теперь, для того, чтобы Соната его увидела, нужно объявить сервис для него. Сервисы для Сонаты я обычно описываю в отдельном файле конфигурации — Resources/config/admin.yml:
services:
sonata.admin.lexxpavlov_page:
class: LexxpavlovPageBundleAdminPage
tags:
- { name: sonata.admin, manager_type: orm, group: "Content", label: "Pages", label_catalogue: "messages" }
arguments:
- ~
- LexxpavlovPageBundleEntityPage
- ~
calls:
- [ setTranslationDomain, [messages]]
И теперь нужно добавить загрузку этого файла в конфигураторе DependencyInjector/LexxpavlovPageExtension.php:
$loader = new LoaderYamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
$loader->load('admin.yml');
Пора проверять проделанную работу — зайти на страницу сайта /page/new, добавить на ней страницу с заголовком «Тест», увидеть эту страницу в списке на /page/ и просмотреть её на /page/show/tiest, заодно проверив работу @Gedmo/Slug. На первой странице нужно добавить добавление новой страницы, на второй — увидеть вновь созданную страницу в списке, и на третьей — открыть и посмотреть на неё.
<?php
namespace LexxpavlovPageBundleListener;
use GedmoSluggableSluggableListener as BaseSluggableListener;
use GedmoSluggableUtilUrlizer;
class SluggableListener extends BaseSluggableListener
{
public function __construct(){
$this->setTransliterator(array($this, 'transliterate'));
}
public function transliterate($text, $separator = '-')
{
$convertTable = array(
'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd',
'е' => 'e', 'ё' => 'e', 'ж' => 'zh', 'з' => 'z', 'и' => 'i',
'й' => 'j', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n',
'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't',
'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'ts', 'ч' => 'ch',
'ш' => 'sh', 'щ' => 'sch', 'ь' => '', 'ы' => 'y', 'ъ' => '',
'э' => 'e', 'ю' => 'yu', 'я' => 'ya'
);
$text = strtr(trim(mb_strtolower($text, 'UTF-8')), $convertTable);
return Urlizer::urlize($text, $separator);
}
}
stof_doctrine_extensions:
orm:
default:
sluggable: true
timestampable: true
class:
sluggable: LexxpavlovPageBundleListenerSluggableListener
На этом предварительная подготовка завершена, бандл готов для использования, в том числе, готов к копированию в другие проекты. Если бы это был обычный бандл для работы, можно было бы на этом и остановиться, но нам нужно теперь придать ему присущую вендорным бандлам гибкость в настройке и подготовить его для публикации.
Важным отличием вендорных бандлов является их способность подстраиваться под нужды использующего его. Например, требуется, помимо указанных в бандле полей страницы, добавить новое поле с именем автора страницы и поле, хранящее автора последних правок на странице. Как это сделать? Написать новый класс сущности? Долго и нудно. Унаследовать его от уже готового? Уже лучше, к тому же не зря в классе сущности поля отмечены как protected, а не private.
Хорошее решение реализовано в популярном бандле FOSUserBundle — в самом бандле нет сущностей, то есть, классов, отмеченных аннотацией @ORMEntity. Сделаем этот же приём и в нашем бандле. Нужно удалить аннотацию @ORMEntity перед классом Lexxpavlov/PageBundle/Entity/Page.php. А для создания сущности нужно создать класс-наследник этого класса, отметить его как @ORMEntity, и в него же можно добавлять требуемые кастомные дополнительные поля. Этот класс будет хорошим местом для того, чтобы пользователь мог выбрать свой собственный стиль именования таблицы — либо разрешить доктрине автоматически выбрать имя таблицы для новой сущности, либо указать своё имя в аннотации @ORMTable.
Для создания новой сущности лучше создать новый бандл. Воспользуемся генератором и разместим бандл в пространство имён AppBundle (если вы установили новый проект Symfony2, то у вас уже должен быть бандл с таким названием). Перенесём контроллер DefaultController из нашего бандла (в нём вообще больше не нужен контроллер) во вновь созданный, перенесём шаблоны (Resources/views), удалим лишнюю ссылку на бандл в файле app/config/routing.yml, а также не забудьте изменить новый класс в контроллере DefaultController (в конструкции use и в вызове метода getRepository()). Создадим новую сущность для страниц:
<?php
namespace AppBundleEntity;
use DoctrineORMMapping as ORM;
use LexxpavlovPageBundleEntityPage as BasePage;
/**
* @ORMEntity
*/
class Page extends BasePage
{
}
Так как новый класс имеет то же имя, что и предыдущий, то пересоздавать таблицу в базе данных не требуется, и в этот момент уже созданные страницы должны работать как прежде.
Но у нас была задача расширить сущность новыми полями — полями для хранения автора и последнего редактора. Для этого хорошо подойдёт поведение Blameable из уже используемого бандла StofDoctrineExtensionsBundle. Добавим их во вновь созданный класс:
<?php
namespace AppBundleEntity;
use DoctrineORMMapping as ORM;
use GedmoMappingAnnotation as Gedmo;
use LexxpavlovPageBundleEntityPage as BasePage;
use AppBundleEntityUser;
/**
* @ORMEntity
*/
class Page extends BasePage
{
/**
* @var User
* @ORMManyToOne(targetEntity="User")
* @GedmoBlameable(on="create")
*/
protected $createdBy;
/**
* @var User
* @ORMManyToOne(targetEntity="User")
* @GedmoBlameable(on="update")
*/
protected $updatedBy;
/**
* Set user, that updated entity
* @param User $updatedBy
* @return Page
*/
public function setUpdatedBy($updatedBy)
{
$this->updatedBy = $updatedBy;
return $this;
}
/**
* Get user, that updated entity
* @return User
*/
public function getUpdatedBy()
{
return $this->updatedBy;
}
/**
* Set user, that created entity
* @param User $createdBy
* @return Page
*/
public function setCreatedBy($createdBy)
{
$this->createdby = $createdBy;
return $this;
}
/**
* Get user, that created entity
* @return User
*/
public function getCreatedBy()
{
return $this->createdBy;
}
}
Также нам понадобится новый класс User — для хранения пользователей. Лучше всего взять его из бандла FOSUserBundle [8]. Устанавливаем этот бандл и расширяем предложенный в нём класс для пользователей:
<?php
namespace AppBundleEntity;
use FOSUserBundleModelUser as BaseUser;
use DoctrineORMMapping as ORM;
/**
* @ORMEntity
* @ORMTable(name="users")
*/
class User extends BaseUser
{
/**
* @ORMId
* @ORMColumn(type="integer")
* @ORMGeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* Get id
* @return integer
*/
public function getId()
{
return $this->id;
}
}
Теперь нужно включить поведение Blameable в конфиг StofDoctrineExtensionsBundle:
stof_doctrine_extensions:
orm:
default:
sluggable: true
timestampable: true
blameable: true
и обновить таблицы в базе данных консольной командой 'app/console doctrine:schema:update --force'.
security:
# ...
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/page/new, role: ROLE_ADMIN }
Теперь требуется добавить пользователя в базу данных. Проще всего это сделать консольной командой 'app/console fos:user:create', добавлением ему прав администратора (ROLE_ADMIN) командой 'app/console fos:user:promote', а затем активировать его командой 'app/console fos:user:activate'.
После внесённых изменений и переносе создания сущности в отдельный бандл, перестал работать класс для Сонаты, ведь в конфиге его сервиса был явно прописан класс LexxpavlovPageBundleEntityPage. Но теперь нельзя точно сказать, в каком месте и под каким названием будет создан класс сущности. Для этого лучше всего использовать стандартный способ создания настроек в Симфонии — описание сервисов указать в настройках бандла, а саму конфигурацию, относящуюся к конкретному проекту, поместить в app/config/config.yml.
Первым делом требуется создать описание конфигурации нашего бандла. Для этого служит стандартный компонент Config [10]. Всё описание конфига заключается в последовательном вызове предопределённых методов, описывающих доступные параметры конфига. Повторять документацию здесь будет излишне, только приведу ссылку на её перевод на русский [11].
<?php
namespace LexxpavlovPageBundleDependencyInjection;
use SymfonyComponentConfigDefinitionBuilderTreeBuilder;
use SymfonyComponentConfigDefinitionConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder();
$rootNode = $treeBuilder->root('lexxpavlov_page');
$rootNode
->children()
->scalarNode('entity_class')
->cannotBeEmpty()
->end()
->end()
;
return $treeBuilder;
}
}
Теперь нужно добавить настройку в конфиг:
lexxpavlov_page:
entity_class: AppBundleEntityPage
и настроить использование этого параметра в тех местах, где он нужен — в сервисе админки сонаты и типе формы. Чтобы можно было использовать значение имени класса, требуется сохранить его в параметр DI-контейнера, это нужно сделать в конфигураторе DependencyInjection/LexxpavlovPageExtension.php, добавив следующую строчку в конец метода load():
$container->setParameter('lexxpavlov_page.entity_class', $config['entity_class']);
Теперь сохранённый параметр можно использовать в объявлениях сервисов. Сервис админки требует указать имя класса вторым параметром конструктора:
services:
sonata.admin.lexxpavlov_page:
class: LexxpavlovPageBundleAdminPage
tags:
- { name: sonata.admin, manager_type: orm, group: "Content", label: "Pages", label_catalogue: "messages" }
arguments:
- ~
- %lexxpavlov_page.entity_class%
- ~
calls:
- [ setTranslationDomain, [messages]]
В сервисе формы нужно не только передать параметр, но и создать конструктор в классе формы:
services:
lexxpavlov_page.form.type.page:
class: LexxpavlovPageBundleFormTypePageType
arguments: [ %lexxpavlov_page.entity_class% ]
tags:
- { name: form.type, alias: lexxpavlov_page }
<?php
namespace LexxpavlovPageBundleFormType;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolverInterface;
class PageType extends AbstractType
{
private $dataClass;
public function __construct($dataClass)
{
$this->dataClass = $dataClass;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', 'text')
->add('slug', 'text', array('required' => false))
->add('content', 'textarea')
->add('published', 'checkbox')
->add('keywords', 'text')
->add('description', 'text')
->add('save', 'submit')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => $this->dataClass,
));
}
public function getName()
{
return 'lexxpavlov_page';
}
}
Следующим шагом будет возможность отключить регистрацию сервиса для сонаты, для тех, кому этот сервис не нужен, или для тех, кто захочет изменить этот сервис (например, добавить в админку свои поля сущности). Для этого нужно добавить новый параметр в конфигурацию бандла. Заодно добавим ещё одну настройку — использование в формах добавления страницы wysiwyg-редактора CKEditor вместо обычного поля textarea (для использования этого типа поля формы потребуется бандл IvoryCKEditorBundle, позже мы отметим его рекомендуемым для установки).
Добавим два новых элемента в конфигурацию бандла (DependencyInjection/Configuration.php):
$rootNode
->children()
->scalarNode('entity_class')
->cannotBeEmpty()
->end()
->scalarNode('admin_class')
->defaultValue('LexxpavlovPageBundleAdminPageAdmin')
->end()
->scalarNode('content_type')
->defaultValue('ckeditor')
->end()
->end()
;
Эти параметры отмечены необязательными, и если они не указаны в конфиге, то они примут указанные значения по умолчанию. Теперь сохраним значения настроек в параметры контейнера (в файле DependencyInjection/LexxpavlovPageExtension.php):
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = new LoaderYamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
$container->setParameter('lexxpavlov_page.entity_class', $config['entity_class']);
$container->setParameter('lexxpavlov_page.content_type', $config['content_type']);
if ($config['admin_class'] && $config['admin_class'] != 'false') {
$loader->load('admin.yml');
$container->setParameter('lexxpavlov_page.admin_class', $config['admin_class']);
}
}
Тут указано условное добавление файла с объявлением сервиса для админки. А если написан свой расширяющий класс для админки (который вполне можно наследовать от уже готового класса), то в параметре lexxpavlov_page.admin_class можно указать его имя.
Теперь добавим использование новых параметров в класс админки. Изменим объявление сервиса и расширим сам класс:
services:
sonata.admin.lexxpavlov_page:
class: %lexxpavlov_page.admin_class%
tags:
- { name: sonata.admin, manager_type: orm, group: "Content", label: "Pages", label_catalogue: "messages" }
arguments:
- ~
- %lexxpavlov_page.entity_class%
- ~
calls:
- [ setTranslationDomain, [messages]]
- [ setContentType, [ %lexxpavlov_page.content_type% ] ]
<?php
namespace LexxpavlovPageBundleAdmin;
use SonataAdminBundleAdminAdmin;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleShowShowMapper;
class PageAdmin extends Admin
{
protected $contentType = 'ckeditor'; // or null for simple textarea field
public function setContentType($contentType)
{
$this->contentType = $contentType;
}
public function configureListFields(ListMapper $listMapper)
{
$listMapper
->addIdentifier('title')
->add('slug')
->add('published', null, array('editable' => true))
->add('createdAt', 'datetime')
->add('updatedAt', 'datetime')
;
}
public function configureFormFields(FormMapper $formMapper)
{
$formMapper
->with('General')
->add('slug', null, array('required' => false))
->add('title')
->add('content', $this->contentType)
->add('published', null, array('required' => false))
->end()
->with('SEO')
->add('keywords', null, array('required' => false))
->add('description', null, array('required' => false))
->end()
;
$formMapper->setHelps(array(
'slug' => 'Leave blank for automatic filling from title field',
));
}
public function configureDatagridFilters(DatagridMapper $datagridMapper)
{
$datagridMapper
->add('slug')
->add('title')
->add('published')
;
}
public function configureShowFields(ShowMapper $showMapper)
{
$showMapper
->add('slug')
->add('title')
->add('content')
->add('published')
->add('publishedAt', 'datetime')
->add('createdAt', 'datetime')
->add('updatedAt', 'datetime')
->add('keywords')
->add('description')
;
}
}
Для разнообразия я не стал добавлять параметр типа поля в класс формы, включив использование CKEditor-а по умолчанию и предоставив возможность его отключения через настройки формы при её создании.
<?php
namespace LexxpavlovPageBundleFormType;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolverInterface;
class PageType extends AbstractType
{
private $dataClass;
public function __construct($dataClass)
{
$this->dataClass = $dataClass;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('slug', 'text')
->add('title', 'text')
->add('content', $options['contentType'])
->add('published', 'checkbox')
->add('keywords', 'text')
->add('description', 'text')
->add('save', 'submit')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => $this->dataClass,
'contentType' => 'ckeditor',
));
}
public function getName()
{
return 'lexxpavlov_page';
}
}
Бандл почти закончен. Осталось выкинуть лишние файлы, которые были созданы бандлогенератором и которые не используются. Это папка Controller (если вы раньше не удалили её), содержимое папки Resources (кроме Resources/config), а также папка тестов (тестировать в бандле нечего, контроллеров нет, а всё остальное тривиально).
Когда бандл готов к публикации, пришло время готовить его к публикации. Бандлы composer-а находятся на сайте packagist.org, а туда они попадают из какой-либо публичной системы контроля версий. Мы будем использовать Github. Но первым делом нужно создать файл composer.json — файл в формате JSON, содержащий метаинформацию о пакете, которую в будущем будет использовать как сам composer, так и packagist. Файл composer.json располагается в корне бандла и выглядит примерно так:
{
"name" : "lexxpavlov/pagebundle",
"description" : "Symfony2 Page bundle with meta data, predefined form type and Sonata Admin service",
"version" : "1.0.0",
"type" : "symfony-bundle",
"homepage": "https://github.com/lexxpavlov/PageBundle",
"license" : "MIT",
"keywords" : ["page", "page bundle"],
"authors" : [{
"name" : "Alexey Pavlov",
"email" : "lexx.pavlov@gmail.com"
}],
"require" : {
"php": ">=5.3.2",
"symfony/symfony": ">=2.1",
"stof/doctrine-extensions-bundle": ">=1.1"
},
"suggest": {
"egeloen/ckeditor-bundle": "Allow use ckeditor field"
},
"autoload" : {
"psr-4" : { "Lexxpavlov\PageBundle\" : "" }
},
"extra" : {
"branch-alias" : {
"dev-master" : "1.0.x-dev"
}
}
}
Рассмотрим, какие поля файла за что отвечают.
name — название бандла. Формируется из неймспейса проекта переводом в нижний регистр. Именно под таким именем бандл будет размещён в папку vendor.
description — описание бандла. Короткое предложение, которое даёт исчерпывающее описание бандла, чтобы пользователи Packagist-а могли понять, что предлагает данный бандл.
version — текущая версия бандла. В дальнейшем, при развитии бандла, этот номер будет увеличиваться.
type — тип пакета. Так как у нас пакет содержит бандл Symfony, то нужно указать «symfony-bundle».
homepage — адрес страницы с описанием бандла. Может быть ссылка на ваш сайт, но вполне допускается и ссылка на страницу проекта на гитхабе.
license — лицензия, под которой доступен этот пакет. Почти всегда указывают лицензию MIT — это одна из самых свободных лицензий. (Подробнее о выборе лицензии [12])
keywords — список ключевых слов, описывающих пакет, размещённый в массив.
authors — список авторов пакета.
require — список ограничений, под которыми работает пакет. Сюда следует указать версию php, версию симфонии, а также добавить те бандлы, которые вы сами используете в своём коде. Composer сам проверит версии указанных ограничений, а также установит пакеты, которые требуются для работы пакета, но не были установлены ранее.
suggest — список предложений к установке (опционально). Сюда обычно размещают бандлы, использование которых расширит работу с вашим бандлом, но которые не являютя обязательными. У нас бандл умеет использовать тип формы 'ckeditor', который предоставляет бандл IvoryCKEditorBundle, поэтому добавим его в список suggests, указав поясняющий текст, показывающий, для чего используется этот бандл. Список предложений будет выведен на экран после установки пакета composer-ом.
autoload — способ автозагрузки классов пакета. Не вижу смысла не использовать PSR-4, поэтому его и укажем. Отличие от PSR-0 в том, что не будут создаваться лишние уровни вложенности в папках, в которых расположены файлы пакета — файлы будут располагаться прямо в папке vendor/lexxpavlov/pagebundle.
extra — дополнительные параметры пакета. В данном случае используется параметр branch-alias, создающий в Packagist новую версию пакета с именем dev-master, которая обычно показывает на master-ветку кода.
Также требуется создать файл readme.md с документацией бандла. Это текстовый файл в формате Markdown позволяет легко и быстро написать документацию с разметкой текста и фрагментами кода. Описание формата Markdown можно почитать здесь [13], или посмотреть разметку описания [14] нашего бандла.
Теперь нужно создать репозиторий Git, и поместить все файлы бандла в этот репозиторий. Для создания репозитория для проекта можно воспользоваться каким-нибудь визуальным клиентом, но здесь будет приведён консольный вариант:
$ cd /path/to/project/src/Lexxpavlov/PageBundle
$ git init
$ git add . -A
$ git commit -m "Init commit"
По хорошему, следовало бы скопировать пакет из папки проекта в отдельную папку, и уже там создавать репозиторий, чтобы этот репозиторий не конфликтовал с репозиторием самого проекта сайта, и чтобы потом можно было легче вносить изменения в код. Хорошим выбором будет вынесение папки с репозиторием в отдельную папку, и дальнейшее добавление его обратно в проект с помощью symlink.
Идём на Github на страницу создания нового репозитория [15] и даём имя новому репозиторию (PageBundle). Можно задать краткое описание пакета, которое будет выводиться наверху главной страницы репозитория сразу под названием. Важно! Убедитесь, что не стоит галочка «Initialize this repository with a README», а также отключено создание .gitignore и файла лицензии (стоит слово NONE в соответствующих выпадающих списках). Далее заходим во вновь созданный (пока пустой) репозиторий и копируем путь к нему в буфер (кнопка Copy to clipboard в разделе HTTPS clone URL внизу правого меню). Выполняем строчки в консоли:
$ git remote add origin remote https://github.com/yourusername/YourBundle.git
$ git push origin master
Готово! Зайдите на страницу пакета и полюбуйтесь на ваши файлы!
Уже сейчас можно использовать пакет через composer, только нужно подсказать ему, где искать файлы пакета. Для этого нужно добавить в composer.json проекта, в который требуется добавить этот пакет, следующие строчки:
[...]
"require" : {
[...]
"lexxpavlov/pagebundle" : "dev-master"
},
"repositories" : [{
"type" : "vcs",
"url" : "https://github.com/lexxpavlov/PageBundle.git"
}],
[...]
Таким способом можно подключать к проекту свои пакеты, например, свой форк другого проекта, без его регистрации на Packagist. Но получение пакета из его репозитория имеет одно неудобство — всегда будет получена master-версия кода. Для поддержки разных версий пакета требуется добавить его на Packagist.
Архив пакетов Packagist поддерживает множество версий пакета, определяя версии по тегам коммитов и названиям веток кода. Он просматривает все теги и ветки репозитория, и если находит названия, похожие на название версии, то применяет их как версии кода. Поэтому нужно добавить хотя бы один тег версии в репозиторий проекта. Версии задаются названиями, подходящими под шаблон 1.0.0 или v1.0.0. (Подробнее про именование версий можно почитать тут [1].) Если ваш бандл готов к использованию и вы не собираетесь его в ближайшее время изменять, то можно выбрать версию 1.0.0. Если вы выкладываете версию бандла, но собираетесь его дорабатывать, то лучше дать версию меньше единицы.
Создадим тег, название которого совпадает с версией, указанной в composer.json:
$ git tag 1.0.0
$ git push origin --tags
Пора выкладывать наш бандл и заканчивать этот затянувшийся туториал. Регистрируемся [16] на Packagist.org (или проще войти через аккаунт Github) и нажимаем на зелёную кнопку Submit package. В появившееся поле вводим адрес репозитория и нажимаем кнопку Check. Если всё в порядке, то появится кнопка Submit. Смело нажимайте её!
Пакет будет размещён в архиве пакетов Packagist.org. Вы сможете увидеть страницу пакета с полями, которые были извлечены из файла composer.json, и кнопками управления пакетом. Также вы увидите предупреждение, что пакет не является автообновляемым. Автообновление пакета — это настройка гитхаба, которая автоматически сообщает Packagist-у об обновлении репозитория, и тот заново просмотрит репозиторий в поисках новых версий кода или обновления информации в файле composer.json. Для установки автообновления нужно пройти на страницу профиля на Packagist [17] и выполнить инструкции, которые там приведены.
Теперь для использования бандла достаточно добавить название пакета в файл composer.json вашего проекта:
[...]
"require" : {
[...]
"lexxpavlov/pagebundle" : "1.0.0"
},
[...]
или выполнить команду для добавления пакета в консоли (в папке проекта):
$ php composer.phar require lexxpavlov/pagebundle
Мы создали пакет для пакетного менеджера composer, который содерит написанный нами бандл для фреймвока Symfony2. Если мы будем использовать его в нескольких проектах, то при изменении пакета получить новую версию кода во все проекты можно простой командой «composer update». Также другие программисты могут использовать ваш пакет для своих проектов, и отплатить вам багрепортами и пулреквестами.
Репозиторий бандла [18] (в статье предложена незначительно изменённая версия бандла, чтобы не увеличивать статью ещё больше)
Страница бандла на Packagist [19]
Использованные материалы и полезные ссылки:
Автор: lexxpavlov
Источник [22]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/php-2/79836
Ссылки в тексте:
[1] тут: https://packagist.org/about
[2] этом топике: http://habrahabr.ru/post/240187/
[3] StofDoctrineExtensionsBundle: https://github.com/stof/StofDoctrineExtensionsBundle
[4] Fesor: http://habrahabr.ru/users/fesor/
[5] SonataAdminBundle: http://sonata-project.org/bundles/admin
[6] старая статья: http://habrahabr.ru/post/136659/
[7] MappedSuperclass: http://doctrine-orm.readthedocs.org/en/latest/reference/inheritance-mapping.html
[8] FOSUserBundle: https://github.com/FriendsOfSymfony/FOSUserBundle
[9] документацией: https://github.com/FriendsOfSymfony/FOSUserBundle/blob/master/Resources/doc/index.md
[10] Config: http://symfony.com/doc/current/components/config/definition.html
[11] перевод на русский: http://devacademy.ru/posts/opredelenie-i-proverka-parametrov-konfiguratsii-dlya-bandla-v-symfony-2/
[12] Подробнее о выборе лицензии: http://choosealicense.com/
[13] здесь: https://guides.github.com/features/mastering-markdown/
[14] разметку описания: https://raw.githubusercontent.com/lexxpavlov/PageBundle/master/README.md
[15] создания нового репозитория: https://github.com/new
[16] Регистрируемся: https://packagist.org/register/
[17] профиля на Packagist: https://packagist.org/profile/
[18] Репозиторий бандла: https://github.com/lexxpavlov/PageBundle
[19] Страница бандла на Packagist: https://packagist.org/packages/lexxpavlov/pagebundle
[20] Вопрос на StackOverflow о создании своего бандла: http://stackoverflow.com/questions/21523481/symfony2-creating-own-vendor-bundle-project-and-git-strategy
[21] Создание страниц в Symfony2: http://symfony-gu.ru/documentation/ru/html/book/page_creation.html
[22] Источник: http://habrahabr.ru/post/248055/
Нажмите здесь для печати.