Антипаттерны Symfony

Symfony framework

Автор: Александр Степанов

5 февр. 2013 г., 10:56:45  2872


Антипаттерны Symfony

Я не очень люблю слово «антипаттерны», так же как и «паттерны». По мне, это чересчур заумные слова для обозначения довольно простых по своей сути идей. И тем не менее, ниже я намереваюсь поведать о самых частых антипаттернах при использовании PHP-фреймворка Symfony (версий 1.2 и 1.3/1.4). То есть о том, как программировать по-хорошему бы не надо, но по незнанию либо лени как-то само получается.

Все ниженаписанное навеяно примерно полугодовым опытом работы в веб-студии, активно использующей Symfony для всех проектов с 2008 года.

Осторожно, многабукав. Пост про быдлокодерство, кому неинтересно, можете смело пропускать :)

Содержание:

Антипаттерн Первый: Нарушение схемы MVC

Как известно, Symfony относится к MVC (Model-View-Controller)-фреймворкам, коих в последние годы вообще развелось немало. А паттерн MVC был придуман не от хорошей жизни, а чтобы разработка больших приложений не вызывала у программистов столь сильного желания убиться об собственную клавиатуру. Без разделения «слоев» приложения с максимальной абстракцией и минимальным количеством зависимостей друг от друга приложение стремительно превращается в хаотичную кучу кода, в которой крайне нетривиально разбираться, править баги и добавлять новую функциональность. Так что при использовании Symfony, предоставляющей замечательные средства для создания MVC-приложений, стоит играть по ее правилам.

Любая нетривиальная логика в шаблонах

Это наиболее очевидное возможное нарушение MVC-паттерна. Если мы начинаем писать код сложнее echo, if и foreach в шаблонах, то с тем же успехом можем вообще выбросить Symfony и начать клепать спагетти-код на голом PHP. Ввиду очевидности непристойности такого быдлокодерства реально оно встречается достаточно редко. Ну, разве что если у программиста совсем уж ленивое настроение :)

Код уровня модели в контроллерах

Это тоже очевидное нарушение, но встречающееся не в пример чаще. Его распознать, по крайней мере при использовании Propel, очень просто: если в экшнах или компонентах упоминается класс Criteria — you're doing it wrong :). Контроллеру не должно быть абсолютно никакого дела до того, как в модели реализуется выборка объектов из БД.

Строго говоря, контроллер не должен содержать также привязки к конкретной ORM, то бишь вызывать автоматически сгенерированные методы, таких как МодельPeer::retrieveByPk(). Однако на практике выполнение этого требования лично мне кажется малореальным. Все равно, например, семантика Peer-классов в Propel и Table-объектов в Doctrine разная, и без еще одного слоя абстракции это не скрыть. Слой абстракции от слоя абстракции от БД это, на мой взгляд, уже чересчур. А менять ORM в процессе разработки в любом случае будет занятием крайне неблагодарным, так что лучше уж постараться этого избежать :)

Генерирование HTML не в шаблонах

Для генерирования HTML существуют шаблоны, и, на тот случай, если для этого нужна какая-то нетривиальная логика — хелперы. Все. Контроллер не должен брать эту функцию на себя, а модели вообще не нужно знать, что такое HTML :)

Полезная, но не универсальная конструкция-детектор:

sfContext::getInstance()->getConfiguration()->loadHelpers(...);

Она позволяет загрузить функции-хелперы, генерирующие HTML-код, в любом месте приложения. В некоторый случаях это может быть уместно (например, ajax-экшн может вместо использования шаблона экшнSuccess.php выводить партиал функцией get_partial()), но чаще все же нет, чем да.

Разумеется, HTML может генерироваться и не только хелперами, а вручную. Этих случаев тоже следует избегать, а имеющиеся рефакторить.

Использование методов sfContext

А вот это, к сожалению, как раз то, что встречается сплошь и рядом. В документации Symfony следовало бы поставить большой жирный Warning по поводу такого использования, потому что это просто роскошный пример из серии «как выстрелить себе в ногу» :)

sfContext — это класс-синглтон, экземпляр которого можно получить из любого места по sfContext::getInstance(). Он содержит ряд объектов, которые, в общем, и представляют собой текущее состояние приложения: sfRequest, sfResponse, sfController, sfUser и так далее. (Классы этих объектов и параметры их инициализации — это как раз то, что настраивается в файле factories.yml. Можно хранить в sfContext и собственные объекты, методами get() и set().) Соответственно, если нам нужно, например, получить доступ к параметрам текущего запроса, мы можем написать: sfContext::getInstance()->getRequest()->getParameter('foo'). И все будет работать, в каком бы месте мы этот код не написали.

Я думаю, уже понятно, к чему я клоню. Использование объекта sfContext может привести к тому, что любая часть приложения может получить самую неожиданную зависимость от любой другой части. Например, модель — от класса пользователя, или форма — от параметров запроса. И получается бомба замедленного действия: с одной стороны, конечно, все работает, но с другой:

  • Можно попрощаться с возможностью сколько-нибудь адекватного юнит-тестирования кода
  • Можно попрощаться с возможностью сколько-нибудь адекватного использования этого кода в других проектах
  • Можно поздороваться с труднообнаружимыми багами-регрессиями, которые, несомненно, появятся, когда вы будете вносить изменения в одном месте, не зная или забыв о внесенной через sfContext; зависимости от этого места в абсолютно другой части проекта.

В общем, все, что лежит в sfContext — это суть те же глобальные переменные со всеми вытекающими. Поэтому его нужно по возможности избегать, если, конечно, у вас нет очень веских причин использовать именно sfContext (особенно частый и вопиющий ахтунг это использование getRequest() или getUser() в моделях). Передавайте нужные объекты в функции параметрами, на то они и существуют, в конце концов :) И кстати, помните, что в некоторых контекстах те же getRequest() и getUser() могут возвращать null — например, в консольных тасках, когда никакого HTTP-запроса попросту не существует, да и вообще по умолчанию даже конфигурация приложения не загружается.

Антипаттерн Второй: Нарушение конфигурационного каскада в плагинах

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

  1. Плагин
  2. Проект
  3. Приложение
  4. Модуль

При этом каждый из уровней может либо переопределить/расширить определенную в предыдущих уровнях функциональность, либо оставить ее как есть. Из этого правила есть некоторые исключения (например, уровень проекта не может содержать модулей с шаблонами и контроллерами), но в целом конфигурационный каскад — это очень мощный механизм. Он позволяет делать сложные и элегантные вещи, например, добавлять новые поля и таблицы в определенную в плагине модель из кода другого плагина либо самого проекта.

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

Почему конфигурационным каскадом нужно пользоваться? Потому что иначе теряется весь смысл плагинов, как унифицированных модулей, которые можно поддерживать отдельно от проектов, и применять без необходимости их подгонки под каждый конкретный проект. В момент, когда мы добавляем хоть строчку кода в плагин, мы фактически создаем его форк, намертво привязанный к конкретному проекту. Если плагин не хранится в отдельном репозитории (подтягиваясь к проекту, например, через svn:externals), то его версий существует столько же, сколько и проектов. И все сделанные в одном проекте улучшения и багфиксы никогда не попадут в другой. Это очень печально.

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

Шаблоны

С шаблонами все просто. Они поддерживают конфигурационный каскад автоматически. Поэтому когда я вижу, что и их правят прямо в каталоге плагина, я каждый раз скорблю о несовершенстве этого мира.

Шаблон в плагине должен представлять собой максимально простой прототип без всякой навороченной верстки; что-то такое, что худо-бедно работало бы в любом проекте. Чтобы переопределить этот шаблон, достаточно создать в каталоге apps/приложение/modules подкаталог с названием, соответствующим названию модуля в плагине (вручную, не через symfony generate:module), в нем — подкаталог templates, скопировать в него нужный шаблон или шаблоны из плагина, и дальше уже пилить их по вкусу, до желаемого вида.

Конфигурационные файлы

Плагин может содержать ряд своих YAML-файлов, таких, как:

  • config/settings.yml
  • config/app.yml
  • modules/модуль/config/security.yml
  • modules/модуль/config/generator.yml
  • и т. д. (config/schema.yml рассмотрим чуть ниже)

Все они могут быть переопределены в конкретном приложении, при наличии в нем файла с таким же именем:

  • apps/приложение/config/settings.yml
  • apps/приложение/config/app.yml
  • apps/приложение/modules/модуль/config/security.yml
  • apps/приложение/modules/модуль/config/generator.yml
  • и так далее :)

Более того, можно переопределить не весь файл, а только необходимые настройки. Например, у нас есть плагин меню с модулем админки, в котором имеется поле, куда нужно ввести внутренний адрес страницы, на которую будет указывать пункт меню. Разумеется, каждый проект включает в себя свой набор модулей и, соответственно, адресов страниц. Поэтому, чтобы дать подсказку о допустимых адресах пользователю, мы создаем файл apps/cms/modules/axNavigationAdmin/config/generator.yml со следующим содержимым:

generator:
  param:
    config:
      fields:
        href:
          help: >
            Возможные варианты:

            

            
            ...

Из всего оригинального содержимого файла generator.yml мы переопределяем всего лишь один параметр (текст справки к полю href) — все остальное берется из стандартного конфига, определенного в плагине.

Экшны и компоненты

Здесь нужна небольшая хитрость. Мы можем написать для модуля из состава плагина свой файл actions.class.php/сomponents.class.php, и, как и в случае шаблонов, этот файл будет использоваться вместо файла из плагина. Но в таком случае мы не сможем унаследовать из плагина уже определенные им экшны/компоненты! Поэтому, как несложно подсмотреть в официальных плагинах (типа sfGuardPlugin), делать принято так:

plugins/плагин/modules/модуль/actions/actions.class.php:

МодульActions.class.php';
class модульActions extends PluginМодульActions
{
  // Пустой класс
}

plugins/плагин/modules/модуль/lib/PluginМодуль Actions.class.php:

МодульActions extends sfActions
{
  // Настоящие экшны
  public function executeIndex($request)
  {
    // ...
  }
}

Таким образом, настоящий файл actions.class.php — это всего лишь пустой класс-заглушка, наследующий от класса, содержащего реальный код экшнов. Если мы переопределяем экшны модуля в своем коде, то достаточно унаследовать их класс от того же промежуточного класса, и добавить/переопределить нужные нам методы. Только require_once не забудьте, автолоад не сработает :). Аналогично поступаем с components.class.php.

Модель

Здесь мы будем говорить о Propel, как о лучше знакомой мне ORM (хоть я и гораздо больше люблю Doctrine). В Doctrine можно сделать все то же самое, но, естественно, процедуры будут отличаться.

Прежде всего, нужно сделать так, чтобы автоматически генерируемые классы находились где надо (в plugins/плагин/lib/model), а не где обычно (в lib/model проекта). Для этого в файле schema.yml плагина нужно прописать:

propel:
  _attributes: { package: plugins.плагин.lib.model }

Этого достаточно. Формы и формы-фильтры тоже будут созданы в нужных местах. Существенный минус подхода заключается в том, что на каталоги plugins/плагин/lib/model/om, plugins/плагин/lib/model/map, plugins/плагин/lib/form/base и plugins/плагин/lib/filter/base нужно будет ставить свойство svn:ignore, чтобы сгенерированные файлы (которые всегда специфичны для проекта, так как могут содержать ссылки на другие содержащиеся в проекте модели) не попали в общий репозиторий плагинов. Так что вам придется учить верстальщиков команде symfony propel:build-all --classes-only :). Насколько мне известно, это ограничение неустранимо. (Также, насколько мне известно, в Doctrine оно отсутствует и сгенерированные файлы всегда находятся в lib/model проекта. Еще одна причина перейти на Doctrine :))

Классы модели плагинов тоже можно переопределять и расширять в проекте. Это делается так же, как и в случае экшнов и компонентов:

plugins/плагинМодель.php:



plugins/плагин/lib/model/plugin/PluginМодель.class.php:

Модель extends BaseМодель
{
  // Реальный код
}

То же самое делаем для Peer-классов.

Обратите внимание, что для форм такие ухищрения не нужны. Вам ничто не мешает просто унаследовать свою форму от формы из плагина, и использовать ее в своем коде (а в модуле админки переопределить generator.yml, чтобы использовал вашу форму).

Существует также возможность расширения самой схемы БД плагинов в коде проекта или других плагинов. Звучит как черная магия, но на самом деле это как раз есть в документации (сноска Customizing the plug-in schema в http://www.symfony-project.org/book/1_2/17-Extending-Symfony). Я еще не пробовал пользоваться этой фичей, так что больше пока что ничего сказать про нее не могу.

Прочий код

Остальные классы плагина, ежели таковые имеются, тоже нежелательно жестко привязывать друг к другу. Всегда оставляйте пользователю возможность унаследовать от вашего класса и использовать свою реализацию вместо вашей. Универсальных рекомендаций тут, конечно, дать трудно, но вот некоторые мысли на тему:

  • Для связи концептуально разных частей приложения между собой и для связи кода плагина с кодом проекта имеет смысл использовать предоставляемый Symfony механизм событий (http://www.symfony-project.org/reference/1_2/en/15-Events). Это куда практичней жесткой связи.
  • Не изобретайте велосипеды. Объекты-синглтоны, например, следует хранить в многострадальном sfContext — со всеми упомянытыми выше предосторожностями при доступе к ним.
  • Паттерн внедрения зависимости (http://ru.wikipedia.org/wiki/Внедрение_зависимости) — ваш друг :)
  • Презентация с умными мыслями на тему: http://fabien.potencier.org/talk/19/decouple-your-code-for-reusability-ipc-2008

Антипаттерн Третий: Неиспользование функциональности окружений

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

Спустя пару недель обнаруживается, что у нас ВНЕЗАПНО отвалилась рассылка писем. У программиста появляется непреодолимое желание посвятить остаток жизни разведению кактусов.

 

Ситуация, конечно, идиотская, но вдвойне идиотская она оттого, что в Symfony есть средства, предназначенные как раз для того, чтобы этой ситуации избежать (и которыми, естественно, никто не воспользовался). Речь идет, конечно, о системе окружений.

По умолчанию при генерации проекта и приложений создается два окружения, prod (по умолчанию) и dev. Обычно этими окружениями и ограничиваются, используя dev только для того, чтобы автоматически обновлялся кэш приложения, и чтобы видеть ошибки PHP, бэктрейсы исключений и web debug-панельку с таймерами, SQL-запросами и прочей статистикой.

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

Наверное, не нужно объяснять, как задавать различные настройки для разных окружений; это описано в документации и, в принципе, очень просто: есть конфигурационные YAML-файлы, в них есть разделы <;strong>dev, prod и all, последний из которых используется по умолчанию. Свои собственные зависимые от окружения настройки следует помещать в app.yml. Адрес для отсылки внутренних оповещений, коды Google Maps и других API, все остальные параметры, которые будут отличаться в продакшне — все это должно быть в app.yml.

Небольшая проблема заключается в том, что работу приложения в prod-окружении тоже по-хорошему нужно протестировать еще до заливки на продакшн-сервер. Документация Symfony подсказывает, что нужно в таком случае создать третье окружение, staging (имя, конечно, может быть любым). Для этого достаточно определить в конфигах секцию staging, перенеся в нее все настройки из prod, что можно, и создать для нее фронт-контроллеры в каталоге web.

В итоге, если, к примеру, у нас есть приложения index и cms, фронт-контроллеры для них могут выглядеть следующим образом:

article/one?alias=name Статья с заданным URL
contacts/index Контакты
Сервер разработки Продакшн-сервер
index.php, cms.php staging prod
index_dev.php, cms_dev.php dev

При таком подходе — с тремя окружениями вместо стандартных двух — на продакшн-сервере не нужно будет менять вообще никакие конфиги по сравнению с сервером разработки, даже databases.yml.

Идентичность кодовой базы на сервере разработки и продакшн-сервере позволит использоваться для заливки на продакшн встроенное средство Symfony, команду symfony project:deploy. project:deploy использует для передачи данных утилиту rsync, что на много порядков быстрее и удобнее передачи по FTP. Единственное требование — чтобы на продакшн-сервер был доступ по ssh (ну, и чтобы на нем была установлена rsync, но она обычно везде установлена).

Для команды project:deploy в описанной конфигурации следует поместить в файл config/rsync_exclude.txt (список игнорирующихся при rsync’е файлов) следующее:

svn
/cache/*
/log/*
/web/uploads/*
/web/index*.php
/web/cms*.php

При первом деплое последние две строчки можно временно убрать, а после него — отредактировать на сервере залитые фронт-контроллеры, удалив dev-версии и поменяв staging на prod, и вернуть обратно маски /web/index*.php и /web/cms*.php, чтобы в дальнейшем эти фронт-контроллеры больше не трогать.

Разные моветоны

Ну и напоследок поговорим о вещах, которые не являются какими-либо фатальными архитектурными недостатками, но просто здорово раздражают при работе с кодом.

Использование префикса sf

В коде самой Symfony для всех классов, модулей и плагинов используется префикс sf. Почему-то многие норовят использовать его и для собственных классов, модулей и плагинов. Это, конечно же, идиотизм. Префикс sf зарезервирован только для кода самой Symfony и ее «официальных» плагинов, таких, как sfGuardPlugin. Пруфлинк: http://www.symfony-project.org/book/1_2/02-Exploring-Symfony-s-Code#chapter_02_sub_symfony_core_classes. При использование же префикса sf для всех подряд классов его смысл (отличие официального кода Symfony от кода сторонних плагинов и собственного кода) вообще пропадает. Несложно заметить, кстати, что в названиях генерируемых автоматически классов (классы модели, экшнов и т. п.) никакого sf нету, что как бы символизирует.

propel.addTimestamp = true

В файле

config/propel.ini

, используемом Propel для генерирования классов модели, по умолчанию есть строчка:

propel.addTimeStamp = true

Эта строчка приводит к тому, что Propel вставляет дату и время генерирования классов (в каталогах

lib/model/om

и

lib/model/map

) в комментарии в начале файлов. Почему это плохо? Потому что это означает, что при каждой регенерации модели (вызове

propel:build-model

или

propel:build-all

) все файлы в каталогах

lib/model/om

и

lib/model/map

будут изменены. Даже если в них, кроме этой даты и времени, ничего не поменялось. В результате в системе контроля версий в коммите оказывается стопицот файлов, и становится абсолютно не понятно, какого масштаба изменения были вообще произведены, и (при ручной заливке на хостинг по FTP) какие из этих файлов нужно перезалить. Поэтому первое, что я делаю с попавшим мне в руки чужим проектом — переправляю эту настройку на false.

 

Использование стандартного названия сессии

По умолчанию Symfony использует для своей сессии (и ее cookie) название (сюрприз, сюрприз!) symfony. Это плохо по двум причинам. Во-первых, зачем рассказывать всему миру, что наш сайт сделан на Symfony? Это выглядит довольно непрофессионально. Во-вторых, становится очень неудобно работать с несколькими проектами на одном хосте (нужно каждый раз при переходе между проектами удалять cookie, иначе Symfony сильно плохеет). Чтобы изменить имя сессии, достаточно поправить в файле apps/приложение/config/factories.yml строчку:

session_name: symfony

Так почему бы этого не сделать?

Использование маршрута по умолчанию

По умолчанию, Symfony определяет маршрут, подходящий для любых экшнов любых модулей:

/модуль/экшн[/параметр1/значение1][/параметр2/значение2][...]

Это не значит, что его нужно использовать, по крайней мере в production-коде. Зачем делать структуру URL привязанной к внутренней архитектуре приложения? Во-первых, пользователю ее знать не обязательно; во-вторых, архитектура может со временем и поменяться; в-третьих, это выглядит особенно ужасно при использовании модулей из плагинов. URL страницы логина /sfUserModule/login это п**дец.

Для каждого варианта вызова каждого экшна каждого модуля маршрут должен быть прописан в явном виде в apps/приложение/config/routing.yml. Исключение могут составлять разве что экшны, вызываемые только AJAX'ом все равно их URL никто не видит, и поисковики не запоминают.

Неиспользование CSRF-защиты

Symfony при генерации приложения предлагает две опции для пущей безопасности: автоматический эскейпинг HTML-сущностей во всех параметров всех шаблонов, и автоматическая CSRF-защита. На практике, автоматический эскейпинг мне кажется слишком уж неудобным средством (он может мешать, даже когда мы и не пытаемся передавать в параметрах шаблона готовый HTML), а вот CSRF-защита весьма прозрачна и не использовать ее я повода не вижу. Напоминаю, CSRF-атака — это когда злоумышленник [обманом] заставляет пользователя перейти по ссылке типа:

http://www.trusted-site.com/pay?to=evildoer&how-much=1000¤cy=dollar

(В тот момент, когда пользователь залогинен на trusted-site.com, разумеется.) CSRF-защита заключается в прозрачном внедрении в каждую форму 'а со случайным неугадываемым токеном, который проверяется при сабмите формы. Для ее использования достаточно добавить в файл settings.yml приложения параметр csrf_secret; в виде случайной строки, которая будет использоваться для генерации CSRF-токена. Ну, и при ручном рендере форм не забывать вызывать

$form->renderHiddenFields()

. Все. Защита работает.

Разумеется, это не единственный возможный вид атак, и далеко не каждую форму имеет смысла атаковать таким образом, но так как защита от него практически абсолютно прозрачна — смысла не использовать его нет.