Расширение классов маршрутизации в Symfony

В ходе работы над очередным, весьма масштабным проектом на Symfony 1.4, у меня возникла задача сделать различные версии одних и тех же страниц для разных городов. Так, как это сделано на сайте Альфа-Банка: на каждой странице есть переключалка по городам, а первый сегмент URL — это алиас нужного города. Например, главная страница для малого бизнеса в Екб имеет URL http://alfabank.ru/ekaterinburg/sme/ и в одном из блоков в ней выводится ссылка на некий Свердловский областной фонд поддержки малого предпринимательства, а на той же странице для Петербурга (http://alfabank.ru/peterburg/sme/) — на Фонд содействия кредитованию малого бизнеса. У Москвы алиаса вообще нет: http://alfabank.ru/sme/. Там ссылка на Фонд содействия кредитованию малого бизнеса Москвы с одним из самых уе**щных логотипов в истории человечества. Вообще, так поглядишь, аж прямо душа радуется, как же у нас о малом бизнесе-то кругом заботятся! Чистая благодать!

Ну так вот, нам надо сделать так же. Про нашу студию же иногда говорят, что мы, дескать, под Лебедева косим. А сайт Альфа-Банка делали у Лебедева как раз. Ну грех же не закосить! %)

Подобную штуку мы уже делали, собственно, например, на этом сайте: http://cars.premierholiday.net/. Тут есть переключалка языков, так же использующая алиас языка в первом сегменте URL. (На самом деле такие переключалки просят сделать в 3/4 проектов, только в 90% случаев они в конце, перед наполнением, временно выпиливаются — в первую же очередь надо русскую версию сайта зарелизить — и в дальнейшем про перевод сайта на другие языке все благополучно забывают.)

На самом деле, переключалку языков таким манером сделать довольно просто. Достаточно в файле routing.yml прописывать маршруты вида:

 

news_list: url: /:sf_culture/news/year/:year requirements: { sf_culture: (?:ru|en) } param: { module: news, action: list }

Параметр sf_culture, определяющий культуру (=язык) запрашиваемой страницы, с точки зрения Symfony специальный (так же как и module иaction). В вызовах url_for()link_to()sfController::genUrl() и т. п. его указывать не обязательно, он будет подставляться автоматически соответственно текущей культуре (которая хранится в сессии, а дефолтная указывается в factories.yml). Таким образом, все работает достаточно прозрачно.

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

Как мы помним из документации Symfony, класс маршрутизации и его параметры, наряду с классами других основных компонентов, указывается в конфиге factories.yml. В Symfony 1.4 используемый по умолчанию и он же единственный класс — sfPatternRouting, реализующий хорошо знакомую нам систему маршрутов с параметрами, перечисляемых вrouting.yml. Но от него можно унаследовать свой класс и добавить в него свою функциональность.

Второй пример, который мы рассмотрим — определение своего класса маршрута. В качестве оных по умолчанию используются sfRoute, но бывают также sfRequestRoutesfPropelRoute/sfDoctrineRoute и т. п. — достаточно указать в routing.yml параметр class.

Для чего это может понадобиться? Ну, например, хорошо известно, что в Symfony нормально работать с URL, содержащие не-escape’ированные слэшивнутри параметров, довольно затруднительно. Система роутинга на это просто не рассчитана. У нас, например, в студии в довольно давно не менявшемся тестовом задании на вакансию программиста это основной подводный, можно даже сказать, надводный камень (по крайней мере для тех, кто пишет тестовое задание на Symfony, что в принципе не требуется в обязательном порядке).

Сделать универсальное решение этой проблемы будет нетривиально, но зачастую стоит достаточно простая задача — реализовать возможность использовать слэши в URL статических страниц, чтобы можно было делать не просто “/article/this-is-article” и “/article/this-is-another-article“, но и, например, “/section1/article“, “/section1/subsection2/another-article” и т. п. Делается это, как мы увидим, элементарным расширением класса sfRoute.

Расширение класса маршрутизации: добавляем в URL город

Допустим, для городов у нас имеется отдельная Propel-модель (потому что мне лень на ходу думать, как адаптировать код под Doctrine, которой я давно не пользовался), которая выглядит примерно так (фрагмент schema.yml):

 

city: id: name: { type: varchar(255), required: true } alias: { type: varchar(40), required: true, index: true }

Алиас для URL, как несложно догадаться, хранится в поле alias. Рассмотрим, как же, собственно, работает класс sfPatternRouting.

Концептуально, подсистема маршрутизации выполняет две противоположные функции:

  • Парсинг URL, определение по нему текущего маршрута и его параметров*;
  • Генерация URL по заданному маршруту и/или его параметрам.

* Обратите внимание, что с точки зрения подсистемы маршрутизации модуль и экшн являются не более чем параметрами маршрута с особой семантикой.

Основные функции, выполняющие эти две задачи, имеют вполне самоочевидные имена:

  • public function parse($url)
  • public function generate($name, $params = array(), $absolute = false)

Напрямую мы ими не пользуемся — parse() вызывает Symfony в процессе обработки запроса, а вокруг generate() существуют более удобные обертки — но основную работу делают они. Обе они опираются на список машрутов, получаемый из заранее распарсенного routing.yml; если конкретное имя маршрута не указано, они двигаются по списку строго сверху вниз и для каждого маршрута проверяют, сооветствует ли он URL/заданным параметрам. Так как операция эта достаточно ресурсоемкая, для распарсенных маршрутов существует дополнительный, по умолчанию включенный, кэш.

Алгоритм работы parse() примерно таков:

  • Вызвать findRoute(), чтобы, собственно, получить соответствующий текстовому URL маршрут
    • findRoute() вызывает normalizeUrl() для, как ни странно, нормализации относительных URL. В нормализацию входят: удаление нескольких слэшей подряд, подстановка слэша в начало URL (если не было), отбрасывание строки GET-запроса (если есть)
    • Ищет в кэше распарсенный маршрут, и возвращает его если есть
    • Вызывает getRouteThatMatchesUrl(), чтобы осуществить непосредственно поиск. Эта функция просто проходит по списку машрутов и у каждого вызывает метод matchesUrl(). Не будем здесь вдаваться в логику парсинга/генерации URL и магию регэкспов, заключенную в классе sfRoute; нам сейчас достаточно знать, что matchesUrl() возвращает в случае успеха список распарсенных параметров.
    • Запоминает распарсенный маршрут в кэше и возвращает
  • Запомнить текущий внутренний URI
  • Подставить дефолтные параметры маршрута — moduleaction,sf_culture… (ensureDefaultParametersAreSet(),sfRoute::setDefaultParameters())

generate() же действует так:

  • Достать и сразу вернуть распарсенный маршрут из кэша, если есть
  • Если задано имя маршрута, просто извлечь его из массива маршрутов по ключу
  • Если нет, найти его функцией getRouteThatMatchesParameters(), которая проходит по массиву и для каждого маршрута вызывает методmatchesParameters()
  • Сгенерировать URL вызовом метода generate() найденного маршрута
  • Запомнить его в кэше
  • Нормализовать вызовом функции родительского абстрактного классаsfRoutingfixGeneratedUrl(), которая дописывает к URL слева префикс (папка проекта + фронт-контроллер, в случае необходимости того и/или другого), и, если нужен абсолютный URL, имя хоста

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

Итак, унаследуем наш новый класс от sfPatternRouting, обзовем егоCityPatternRouting и положим, например, вapps/frontend/lib/routing/CityPatternRouting.class.php:

 

class CityPatternRouting extends sfPatternRouting {

Очевидно, нам нужно хранить где-то текущий город, определенный по URL.

 

 protected $city = null; public function getCurrentCity() { return $this->city; }

Начнем с генерации URL, потому что это просто:

 

 protected function fixGeneratedUrl($url, $absolute = false) { $url = '/' . $this->city->getAlias() . $url; return parent::fixGeneratedUrl($url, $absolute); }

Вот и все. Теперь сегмент с городом (текущим) будет подставляться в правильное место во все URL, каким бы образом они ни были сгенерированы — url_for()sfController::genUrl(),sfAction::redirect()… Ничего менять в routing.yml также не потребуется, для остального кода наше добавление абсолютно прозрачно.

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

Теперь разберемся с normalizeUrl():

 

 protected function normalizeUrl($url) { $url = parent::normalizeUrl($url); // Вычленяем алиас города if (preg_match('#^/([^/]+)/?#', $url, $matches)) { // Пробуем распознать город по алиасу $this->city = CityPeer::retrieveByAlias($matches[1]); if ($this->city) { // Убираем префикс: /ekb/some_url => /some_url $url = preg_replace('#^/([^/]+)#', '', $url); // Особый случай: /ekb => / if ($url == '') $url= '/'; } } return $url; } }

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

Мы, однако, должны предусмотреть случай, когда город опознать не удалось. This is where things begin to get interesting. Что мы должны делать в таком случае? Есть следующие стратегии:

  1. Ничего не делать и не вырезать из URL, и показать страницу для дефолтного города (по сути дефолтный город = город без алиаса)
  2. Ничего не делать и не вырезать из URL и показать страницу для последнего посещенного города
  3. Редиректить пользователя на страницу дефолтного города
  4. Редиректить пользователя на страницу последнего посещенного города
  5. Показать 404. В этом случае следует реализовать редирект хотя бы для главной страницы (/ → /ekb/)

На сайте Альфа-Банка, который я приводил в пример в начале статьи, реализован первый вариант. Мы попробуем реализовать третий, как несколько более хитрый. Хитрость заключается в том, что непосредственно в классе роутинга редиректить мы ничего никуда не можем и не должны, это привилегия уровня контроллера.

Значит, мы заводим для осуществления редиректа специальный экшн и прописываем его в routing.yml:

 

bad_city_redirect: url: /__bad_city_redirect__/:orig_url param: { module: navigation, action: badCityRedirect }

И добавляем в normalizeUrl() код, меняющий настоящий URL на URL этого экшна:

 

 protected function normalizeUrl($url) { // ... if (!$this->city) $url = '/__bad_city_redirect__/' . urlencode($url); return $url; }

Мы кодируем оригинальный URL urlencode(), так что здесь нам пофиг на слэши в нем и все остальное.

А это код нашего экшна:

 

class navigationActions extends sfActions { public function executeBadCityRedirect($request) { // Мы строим URL для редиректа вручную, и нам нужно получить // от класса роутинга префикс, который может включать в себя // путь к проекту и/или фронт-контроллер. Префикс хранится // в опциях роутинга, его нет необходимости конструировать самому $options = $this->getContext()->getRouting()->getOptions(); $prefix = $options['context']['prefix']; // Алиас города по умолчанию указан опцией в app.yml $this->redirect($prefix . '/' . sfConfig::get('app_default_city_alias') . $request->getParameter('orig_url')); } }

Вот и все. Чтобы подключить новый класс роутинга, достаточно прописать его в factories.yml:

 

all: routing: class: CityPatternRouting param: generate_shortest_url: true extra_parameters_as_query_string: true

Все, что осталось — cделать возможной генерацию ссылок на города, отличные от текущего, чтобы сделать переключалку городов. Не будем рассматривать всю переключалку — ее тривиально написать, используя для генерации таких ссылок специальный хелпер. Поместим его вapps/index/lib/helper/CityUrlHelper.php:

 

function url_for_city($uri, $city) { // Действуем достаточно прямолинейно: генерируем обычную // ссылку и меняем в префиксе алиас на нужный нам/ // вырезаем префикс $url = url_for($uri); // Получаем класс роутинга, а из него -- текущий город и префикс $routing = sfContext::getInstance()->getRouting(); $current_city = $routing->getCurrentCity(); $routing_options = $routing->getOptions(); $prefix = @$routing_options['context']['prefix']; // Либо подставляем алиас заданного города вместо текущего, // либо вообще убираем алиас, если вместо города передано null if ($city) return preg_replace('#^' . $prefix . '/' . $current_city->getAlias() . '#', $prefix . '/' . $city->getAlias(), $url); else return preg_replace('#^' . $prefix . '/' . $current_city->getAlias() . '#', $prefix, $url); }

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

Расширение класса машрута: универсальный маршрут для статических страниц (со слэшами в URL)

Django использует для реализации статических страниц с произвольными URL перехват 404 ошибки, т. е. соответствующий модуль срабатывает последним, если ни один другой не сумел обработать URL. Когда-то я попытался реализовать подобную схему в Symfony, но ничего хорошего из этого не вышло. Концептуально правильный, с точки зрения Symfony, способ также включает в себя расширение подсистемы маршрутизации.

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

Снова начнем с описания тривиальной модели, Article:

 

article: id: title: { type: varchar(255), required: true } body: { type: longvarchar, required: true } url: { type: varchar(255), required: true, index: true }

Определим класс нашего маршрута в файлеapps/frontend/lib/routing/ArticleRoute.class.php, и сразу добавим в него переменную для искомой статической страницы:

 

class ArticleRoute extends sfRoute { protected $article = null; public function getArticle() { return $this->article; }

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

 

 // Генерация ссылки. У нашего маршрута будет параметр, url -- // который собственно и будет URL искомой статической страницы (с обрезанными // слэшами по краям и префиксом). public function generate($params, $context = array(), $absolute = false) { // ВНЕЗАПНО! return '/' . $params['url']; } // Проверка соответствия URL маршруту public function matchesUrl($url, $context = array()) { // Обрезаем крайние слэши и ищем // статическую страницу по получившемуся URL $url = trim($url, '/'); $this->article = ArticlePeer::retrieveByUrl($url); if (!$this->article) return false; // В случае успеха, возвращаем параметры -- заданные в // routing.yml модуль/экшн, и URL return array('module' => $this->defaults['module'], 'action' => $this->defaults['action'], 'url' => $url); } }

Вот и все, этого достаточно. Получающийся маршрут подключаем вrouting.yml. Вставлять его следует в конце, после всех остальных маршрутов (чтобы нельзя было, создав статическую страницу, замаскировать ею другой маршрут, и чтобы делать запрос к БД для поиска страницы по URL, только если никакие другие маршруты не подошли), но до универсальных маршрутов /:module и /:module/:action/* (если, конечно, вы их используете), которые подходят к любому URL:

 

article: class: ArticleRoute url: /:url # это поле мы на самом деле не используем param: { module: article, action: show }

Реализация экшна отображения статьи будет тривиальна.apps/frontend/modules/article/actions/actions.class.php:

 

class articleActions extends sfActions { public function executeShow($request) { $this->article = $this->getRoute()->getArticle(); } }

И шаблон, apps/frontend/modules/article/templates/showSuccess.php, в простейшем виде:

 

<h1><?php echo $article->getTitle() ?></h1> <?php echo $article->getBody() ?>

На этом реализация статических страниц будет полностью закончена.

 

Вот и статье конец, а кто читал — молодец. Это, конечно, далеко не все, что можно сделать с маршрутизацией в Symfony — при большом желании можно вообще выкинуть sfPatternRouting и написать свой потомок sfRouting, но для чего могут понадобиться такие меры, мне трудно предположить. Так или иначе, наслаждайтесь 🙂

оригинал

Advertisements

2 Responses to Расширение классов маршрутизации в Symfony

  1. Sergey says:

    Если можно поправить плагин что-ли вордпреса представления кода!!! а то все в одну строчку код. это капец.

  2. eddifisher says:

    как обычно была ночь и лень что-то делать, внизу статьи есть ссылка на ригинал

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: