Как сократить время разработки используя ООП?

05.08.2012

Автор: Вадим Харитонов

Довольно часто, копаясь в чьих-то кодах приходилось видеть, мягко говоря, не очень красивые реализации каких-нибудь вещей. И вот за,хотелось рассказать о том, как я в одном проекте , используя паттерны проектирования сократил себе время разработки одной подзадачи. О чём пойдёт речь?


Постановка задачи
Имеется достаточно много разнообразных настроек для страниц, причём для разных страниц возможны свои настройки, т.е. допустим для главное страницы можно выбрать блок справа, а для страницы "Рубрика", помимо того, что выбрать блок, так же можно изменить название страницы. Допустим для блока "Предложения" можно изменить его название, количество на страницу предложения, длина анонса, длина названия и т.д. Надеюсь с большего описал суть.


Вариант решения
В связи с тем, что настройки у этих страниц совершенно разные, то было принято решение сохранять настройки под каждый блок в соответствующим с ним YAML-конфиге. Почему именно в YAML? Т.к. wiki-auto пишется на Symfony2 Framework'е, а в нём большинство его собственных конфигов состоят именно из YAML файлов, то ответ очевиден + к этому в самом фреймворке есть достаточно удобный и простой интерфейс для создания и чтения YAML'ов.
Как пример конфига для страницы "Добавления предложений"


addingSuppliers.yml


requirements:
'Название блока': SetBlock
'Название страницы': ChangeTitle
configuration:
jl_treebundle_setblock0: '3'
jl_treebundle_changetitle1: 'Добавление предложений'

В разделе requirements указывается как на форме будет выглядеть поле для редактирования и какой класс его будет обрабатывать, в данном случае поле "Название блока" будет обрабатывать класс SetBlock. В разделе configuration записывается результат проделанной работы. Об этом всем более подробно далее.
Итак, конфиги есть. Как их формировать и как генерировать для них формы?
Сразу, что пришло в голову, это паттерн проектирования "Chain of Responsibility".


В чем его суть. У нас будет с каждой страницей связан объект класса PageManager, который будет хранить в себе объекты тех классов, указанных в конфиге (SetBlock, ChangeTitle, etc.). Пробежавшись по списку объектов этих классов мы сформируем форму, проверим её на валидность и также вернём результат, для сохранения в конфиге. Пока это выглядит всё непонятно. Дальше будут объяснения.
Первое что сделаем, создадим интерфейс ChainOfResponsibility в котором опишем все основные методы, для работы с уникальными полями


ChainOfResponsibility.php


<?php
namespace Jl\TreeBundle\Utils;
use Symfony\Component\Form\FormBuilder;
/**
* @author Vadim
*/
interface ChainOfResponsibility
{
function getForm(FormBuilder $builder, $label, $name = '');
function validate(array $options, $name = '');
function getValue(array $options, $name = '');
function getName();
}

*В качестве namespace здесь указан Jl\TreeBundle\Utils, просто это всё часть проекта в Symfony2 и находиться в /src/Jl/TreeBundle/Utils/ChainOfResponsibility.php
Здесь описаны четыре метода:

  • getForm - принимает на вход объект FormBuilder (построитель форм на лету в Symfony2), label для отображения и часть атрибута name в вёрстке. Результатом работы будет в объект FormBuilder'а добавлены необходимые для редактирования конкретной страницы поля
  • validate - options - массив полученными с формы результатами, name - было описано выше. Результат работы - валидная ли форма?
  • getValue - получает значение с именем name из options
  • getName - получает название данного элемента формы (однозначно характеризует класс)

Метод getValue для всех классов содержит одну и туже реализацию, так что можно для этого создать абстрактный класс в котором его и реализовать


AbstractChainOfResponsibility.php



<?php
namespace Jl\TreeBundle\Utils;
/**
* @abstract
* @author Vadim
*/
abstract class AbstractChainOfResponsibility implements ChainOfResponsibility
{
/**
* get value from the options
* this method is useful for all children
*
* @param array $options
* @param string $name
* @return string
* @access public
*/
public function getValue(array $options, $name = '') {
return $options[$this->getName() . $name];
}
}


Данный класс является абстрактным, т.е. явным образом объект данного класса не может быть создан.
Теперь посмотрим на реализацию выпадающего select'а для установления длины анонсов или заголовков:


SetLength.php



<?php
namespace Jl\TreeBundle\Utils;
use Symfony\Component\Form\FormBuilder;
/**
* @author Vadim
*/
class SetLength extends AbstractChainOfResponsibility
{
/**
* Contains values for choice form element
*
* @access protected
* @var array $values
*/
protected $values = array();
/**
* Generate values for choice
*
* @access public
*/
public function __construct()
{
for ($i = 100; $i < 260; $i += 10) {
$this->values[] = $i;
}
}
/**
* Create a choice element
*
* @param FormBuilder $builder
* @param string $name
* @access public
*/
public function getForm(FormBuilder $builder, $label, $name = '')
{
$builder->add($this->getName() . $name, 'choice', array(
'label' => $label,
'choices' => $this->values,
));
}
/**
* Get the unique name for this class. Useful for form creating to see what name of fields has been crated
*
* @return string
* @access public
*/
public function getName()
{
return 'jl_treebundle_setlength';
}
/**
* validate this choice field
*
* @param array $options
* @param string $name
* @return boolean
* @access public
*/
public function validate(array $options, $name = '')
{
return isset($options[$this->getName() . $name]) && isset($this->values[((int)$options[$this->getName() . $name])]);
> }
}

Здесь реализованы все методы из AbstractChainOfResponsibility.
На базе этого класса так же можно создать select для количества записей на странице
Pages.php


<?php
namespace Jl\TreeBundle\Utils;
/**
* @author Vadim
*/
class Pages extends SetLength
{
/**
* Generate a new values for choice
*
* @access public
*/
public function __construct()
{
for ($i = 5; $i < 30; $i += 5) {
$this->values[] = $i;
}
}
/**
* Get class name
*
* @return string
* @access public
*/
public function getName()
{
return 'jl_treebundle_page';
}
}
В данном классе происходит лишь заполнение массива value из класса SetLength другими значениями.
По этому же принципу сделаны выбор блока справа и включение\выключение блока


SetBlock.php


<?php
namespace Jl\TreeBundle\Utils;
/**
* @author Vadim
*/
class Pages extends SetLength
{
/**
* Generate a new values for choice
*
* @access public
*/
public function __construct()
{
for ($i = 5; $i < 30; $i += 5) {
$this->values[] = $i;
}
}
/**
* Get class name
*
* @return string
* @access public
*/
public function getName()
{
return 'jl_treebundle_page';
}
}


Switcher.php


<?php
namespace Jl\TreeBundle\Utils;
/**
* @author Vadim
*/
class Switcher extends SetLength
{
/**
* Change default values for choice
*
* @access public
*/
public function __construct()
{
$this->values = array("Off", "On");
}
/**
* Get class name
*
* @return string
* @access public
*/
public function getName()
{
return 'jl_treebundle_switcher';
}
}

Для задания название блока используется немного другой шаблон
ChangeTitle.php


<?php
namespace Jl\TreeBundle\Utils;
use Symfony\Component\Form\FormBuilder;
/**
* @author Vadim
*/
class ChangeTitle extends AbstractChainOfResponsibility
{
/**
* Create a text form element
*
* @param FormBuilder $builder
* @param string $name
* @access public
*/
public function getForm(FormBuilder $builder, $label, $name = '')
{
$builder->add($this->getName() . $name, 'text', array(
'label' => $label,
));
}
/**
* Get name of this class
*
* @return string
* @access public
*/
public function getName()
{
return 'jl_treebundle_changetitle';
}
/**
* Is empty inputed string?
*
* @param array $options
* @param string $name
* @return boolean
* @access public
*/
public function validate(array $options, $name = '')
{
return isset($options[$this->getName() . $name]) && $options[$this->getName() . $name] != '';
}
}


На базе этого шаблона строиться задание ссылки внизу


ChageLink.php


<?php
namespace Jl\TreeBundle\Utils;
/**
* @author Vadim
*/
class ChangeLink extends ChangeTitle
{
/**
* overriden method to get name as associated with this class
*
* @access public
* @return string
*/
public function getName()
{
return 'jl_treebundle_changelink';
}
}


* Возможно будет изменена валидация


Немного дело обстоит по-другому с тем, что у нас в задании настроек под конкретную страницу они могут задаваться табами. Причем для каждой из таб должны быть одинаковые настройки, но значения должны быть сохранены для каждой свои. Здесь приходит на помощь паттерн Adapter, который попытается работу с нашими табами свести в работу как с обычным инстансом ChainOfResponsibility.


Tabs.php


<?php
namespace Jl\TreeBundle\Utils;
use Symfony\Component\Form\FormBuilder;
/**
* @author Vadim
*/
class Tabs extends AbstractChainOfResponsibility
{
/**
* Contains names of tabs
*
* @access private
* @var array
*/
private $tabs = array();
/**
* Contains all fields
*
* @var private
* @access array
*/
private $fields = array();
/**
* Initialize
*
* @param array $tabs
* @param array $fields
* @access public
*/
public function __construct(array $tabs, array $fields)
{
$this->tabs = $tabs;
$this->fields = $fields;
}
/**
* Form generating
*
* @param FormBuilder $builder
* @param string $label
* @param string $name
* @access private
*/
public function getForm(FormBuilder $builder, $label, $name = '')
{
foreach ($this->tabs as $index => $tabName) {
$counter = 0;
foreach ($this->fields as $fieldLabel => $field) {
$class = 'Jl\\TreeBundle\Utils\\' . $field;
$handler = new $class();
if ($handler instanceof ChainOfResponsibility) {
$handler->getForm($builder, $fieldLabel, '_' . $label . $index . '_' . ($counter++) . '_' . $name);
}
}
$builder->add('_' . $label . $index, 'hidden', array(
'attr' => array(
'class' => $tabName,
),
));
}
}
/**
* Get unique name
*
* @return string
* @access public
*/
public function getName()
{
return 'jl_treebundle_tabs';
}
/**
* Validate form elements
*
* @param array $options
* @param string $name
* @return boolean
* @access public
*/
public function validate(array $options, $name = '')
{
$rc = true;
foreach (array_keys($this->tabs) as $index) {
$counter = 0;
foreach ($this->fields as $field) {
$class = 'Jl\\TreeBundle\Utils\\' . $field;
$handler = new $class();
if ($handler instanceof ChainOfResponsibility) {
$rc = $rc && $handler->validate($options, '_' . 'tabs' . $index . '_' . ($counter++) . '_' . $name);
}
}
}
return $rc;
}
}


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


pageCompaniesList.yml


requirements :
'Название блока' : SetBlock
'Название страницы' : ChangeTitle
tabs : { names: [Покупателю, Продавцу], fields: { 'Заголовок вкладки блока': ChangeTitle, 'Кол-во записей на вкладке': Pages, 'Длина заголовка записи': SetLength, 'Длина анонса записи': SetLength, 'Отображение вкладки на сайте': Switcher } }
configuration :
jl_treebundle_setblock0 : '3'
jl_treebundle_changetitle1 : 'Список компаний'
jl_treebundle_changetitle_tabs0_0_2 : Покупателю
jl_treebundle_page_tabs0_1_2 : '0'
jl_treebundle_setlength_tabs0_2_2 : '1'
jl_treebundle_setlength_tabs0_3_2 : '2'
jl_treebundle_switcher_tabs0_4_2 : '1'
jl_treebundle_changetitle_tabs1_0_2 : Продавцу
jl_treebundle_page_tabs1_1_2 : '2'
jl_treebundle_setlength_tabs1_2_2 : '5'
jl_treebundle_setlength_tabs1_3_2 : '5'
jl_treebundle_switcher_tabs1_4_2 : '0'



Здесь заданы две табы (Покупателю и продавцу), для каждой есть свои поля. Эти данные попадают в конструктор класса Tabs. И дальнейшая обработка идёт как и написано в ChainOfresponsibility.
Теперь же необходимо написать того, кто будет за всем этим следить и всем раздавать эти задачи. Так называемого менеджера.


PageManager.php


<?php
namespace Jl\TreeBundle\Utils;
use \ArrayObject;
use Symfony\Component\Form\FormBuilder;
/**
* @author Vadim
*/
class PageManager
{
/**
* Contains ChainOfResponsibility realizations
*
* @access private
* @var ArrayObject $container
*/
private $container;
/**
* populate container only of ChainOfResponsibility objects
*
* @access public
> * @param array $handlers
*/
public function __construct(array $handlers)
{
> $this->container = new ArrayObject();
foreach ($handlers as $label => $handlerName) {
if ($label === 'tabs') {
$handler = new Tabs($handlerName['names'], $handlerName['fields']);
} else {
$class = 'Jl\\TreeBundle\\Utils\\' . $handlerName;
$handler = new $class ();
}
if ($handler instanceof ChainOfResponsibility) {
$this->container[$label] = $handler;
}
}
}
/**
* Adding a elements to form
*
* @access public
* @param FormBuilder $builder
> */
public function createForm(FormBuilder $builder)
{
> $index = 0;
foreach ($this->container as $label => $handler) {
$handler->getForm($builder, $label, $index++);
}
}
/**
* Validate a form
*
* @access public
* @param array $options
* @return boolean
*/
public function validate(array $options)
{
$index = 0;
foreach ($this->container as $handler) {
if (!$handler->validate($options, $index++)) {
return false ;
}
}
return true ;
}
}


В конструктор поступает информация из конфига и создаются экземпляры соответствующих инстансов ChainOfResponsibility
Метод createForm создаёт форму, вызываю для каждого инстанса метод getForm
Метод validate вызывает метод validate для каждого инстанса и если хоть из результатов был false, значит он вернёт тоже false.
А теперь осталось лишь рассмотреть реализацию самого контроллера


SettingsController.php


<?php
namespace Jl\TreeBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\Yaml\Yaml;
use Jl\TreeBundle\Utils\PageManager;
/**
* @author Vadim
* @Route("/settings")
*/
class SettingsController extends Controller
{
/**
* contains path to .yml configurations
*
* @access private
* @var string $confDirectory
*/
private $confDirectory;
/**
* contains information about management pages and block
*
* @access private
* @var array $files
*/
private $files = array();
/**
* setting up default modules
*
* @access public
*/
public function __construct()
{
$this->confDirectory = realpath(__DIR__ . '/../Resources/config/settings') . '/';
$this->files = array(
'Главная страница' => 'mainPage',
'Страница "Рубрика"' => 'pageRubric',
'Страница "Раздел"' => 'pageSection',
'Страница "Модель"' => 'pageModel',
'Страница "Марка"' => 'pageMake',
'Страница "Статья"' => 'pageArticle',
'Страница "Инструкции"' => 'pageInstruction',
'Страница "Список компаний"' => 'pageCompaniesList',
'Страница "Предложение - лента"' => 'pageSupplyTape',
'Страница "Предложение - подробно"' => 'pageSupplyInDetail',
'Страница "Новости - лента"' => 'pageNewsTape',
'Страница "Новости - подробно"' => 'pageNewsInDetail',
'Страница "Статьи - лента"' => 'pageArticlesTape',
'Страница "Статьи - подробно"' => 'pageArticlesInDetail',
'Страница "Регистрация компании"' => 'pageRegisterCompany',
'Добавление предложений' => 'addingSupplies',
'Добавление тизеров' => 'addingTiesers',
'FeedBack' => 'feedBack',
'Блок "Тизеры"' => 'blockTiesers',
'Блок "СТО"' => 'blockServiceStation',
'Блок "Запачасти и ремонт"' => 'blockPartsAndRepair',
'Блок "Топ ответов"' => 'blockTopAnswers',
#'Блок "Консультанты"' => 'blockConsultants',
'Блок "Инструкции"' => 'blockInstructions',
'Блок "Комментарии"' => 'blockComments',
'Блок "Сервис и ремонт"' => 'blockServiceAndRepair',
'Блок "Продавцы"' => 'blockSellers',
);
}
/**
* @access public
* @return Reponse
* @Route("/", name="settings_getallroutes")
* @Template()
*/
public function getAllRoutesAction()
{
return array ('routes' => $this->files);
}
/**
* @Route("/{name}", name="settings")
* @Template()
* @param string $name
* @access public
* @return Response
*/
public function viewAction($name)
{
$this->validate($name);
$settings = $this->getSettings($name);
$pm = new PageManager($settings['requirements']);
$fb = $this->createFormBuilder($settings['configuration']);
$pm->createForm($fb);
return array ('form' => $fb->getForm()->createView());
}
/**
* @Route("/{name}/save", name="settings_save")
* @Method("post")
* @param string $name
* @access public
* @return Response
*/
public function saveAction($name)
{
$settings = $this->getSettings($name);
$request = $this->getRequest();
$post = $request->request->get('form');
$files = $request->files->get('form');
if (is_array($files)) {
$post = array_merge($post, $files);
}
$pm = new PageManager($settings['requirements']);
if ($pm->validate($post)) {
unset($post['_token']);
$settings['configuration'] = $post;
$this->setSettings($name, $settings);
}
return $this->redirect($this->generateUrl('settings', array('name' => $name)));
}
/**
* Validate .yml files
*
* @param string $name
* @access private
* @throws NotFoundException
*/
private function validate($name) {
if (!in_array($name, $this->files)) {
throw $this->createNotFoundException("This page cann't be found");
}
}
/**
* Create full path to conf file
*
* @access private
* @param string $fileName
*/
private function createFileName($fileName)
{
return $this->confDirectory . $fileName . '.yml';
}
/**
* is file exists and is it writable?
*
* @access private
* @param string $path
* @throws FileNotFoundException
> */
private function checkFile($path)
{
if (!file_exists($path) || !is_writeable($path)) {
throw new FileNotFoundException("Cann't get settings from {$path}");
}
}
/**
* Get settings from .yml-conf file
*
* @access private
* @param string $file
* @return array
*/
private function getSettings($file)
{
$fullName = $this->createFileName($file);
$this->checkFile($fullName);
return Yaml::parse($fullName);
}
/**
* Set settings to .yml-conf file
*
* @access private
* @param string $file
* @param array $settings
*/
private function setSettings($file, array $settings)
{
$fullName = $this->createFileName($file);
$this->checkFile($fullName);
> $yaml = Yaml::dump($settings);
file_put_contents($fullName, $yaml);
}
}

  • В конструкторе в массив files записываются названия страниц и блоков, для каких нужны настройки,и имя файла настроек.
  • confDirectory - путь к папке с конфигами.
  • Метод getAllRoutesAction - выводит все линки на страницу
  • Метод viewAction - выводит форму для соответствующей страницы
  • Метод saveAction - проверяет на валидность данную форму
  • Метод validate - существует ли данный файл
  • Метод createFileName - генерирует полный путь к файлу с настройками
  • Метод checkFile - проверяет есть ли данный файл по указанному пути и можно ли в него писать
  • Метод getSettings - получает настройки из YAML файлов
  • Метод setSettings - устанавливает настройки


Итог
Результатом является то, что при дальнейшем расширении (добавлении новых полей в формы) не составит труда ни для одной из форм. Для этого необходимо лишь реализовать методы из класса AbstractChainOfResponsibility и дописать одну строчку в конфиге для соответствующей страницы.
Согласно технологии DRY (Don't Repeat Yourself) это реализация делает код модульным


P.S.
Надеюсь в результате этого кто-нибудь прочитает и задумается об использовании в своём коде объектно-ориентированных подходов и документировании своего кода.


Приложение
getAllRoutes.html.twig


{% for name, route in routes %}
<p><a href="{{ path('settings', { 'name' : route }) }}">{{ name }}</a></p>
{% endfor %}
view.html.twig



{% extends '::base.html.twig' %}
{% block body %}
<form method="post" action="{{ path('settings_save', { 'name' : app.request.attributes.get('name') }) }}" {{ form_enctype(form) }}>
{{ form_widget(form) }}
<input type="submit" value="send" />
</form>
<a href="{{ path('settings_getallroutes') }}">К списку</a>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript" language="javascript" src="{{ asset('bundles/jltree/javascript/settings.js') }}"></script>
{% endblock %}

settings.js



jQuery(document).ready(function($){
$(':hidden[id*="_tabs"]').each(function(i, o) {
var id = $(o).attr('id').replace('form_', '') + '_', container = $('<div/>').html('<h1>' + $(o).attr('class') + '</h1>');
$(':input[type!="hidden"][id*="' + id + '"]').each(function(i, o){
$(o).parent().appendTo(container);
});
$(o).remove();
container.appendTo($('form'));
});
});

 
Позвоните нам +375 (29) 334 21 22
или Отправьте запрос