in Профессиональное

The symfony 2009 Advent Calendar: день 2 – продвинутая маршрутизация (часть 1)

Symfony 2009 advent calendar – это 24 урока продвинутого уровня о symfony. Все уроки представлены на 5 языках: английском, французском, испанском, итальянском, японском. Русского нет, это досадное упущение постараюсь исправить.

Перевод статьи Symfony 2009 Advent Calendar: Advanced Routing (part 1).

by Ryan Weaver

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

Эта глава иллюстрирует процесс создания простого веб-приложения, в котором каждый клиент использует отдельный субдомен (например client1.mydomain.com и client2.mydomain.com). Это будет совсем несложно, когда мы расширим возможности маршрутизатора ))

Для примера из этой главы мы будем использовать Doctrine в качестве ORM

Настройка проекта: CMS для многих клиентов

Для начала представим некую вымышленную компанию, Sympal Builder, которая желает создать CMS, с помощью которой ее клиенты смогут создавать вебсайты на поддоменах сайта sympalbuilder.com. Т.е. клиент XXX может просмотреть свой сайт по адресу xxx.sympalbuilder.com и использовать админку по адресу xxx.sympalbuilder.com/backend.php.

Наименование Sympal мы позаимствовали у одноименного проекта Jonathan Wage – Sympal, CMF, созданная на symfony.

Проект имеет два основных требования:

  • Пользователь должен иметь возможность создавать страницы и указывать для них title, content и URL.
  • Приложение должно быть создано внутри одного проекта symfony, который контролирует фронтэнд и бэкэнд всех клентских сайтов, определяя клиента и загружая корректные данные для поддомена.

Для создания нашего приложения, вебсервер должен быть настроен направлять все запросы на *.sympalbuilder.com на тот же document root – web директорию нашего проекта на symfony.

Схема и данные

База данных для проекта будет состоять из объектов Client и Page. Каждый Client представлен на своем поддомене и может содержать много объектов Page.

# config/doctrine/schema.yml
Client:
  columns:
    name:       string(255)
    subdomain:  string(50)
  indexes:
    subdomain_index:
      fields:   [subdomain]
      type:     unique

Page:
  columns:
    title:      string(255)
    slug:       string(255)
    content:    clob
    client_id:  integer
  relations:
    Client:
      alias:        Client
      foreignAlias: Pages
      onDelete:     CASCADE
  indexes:
    slug_index:
      fields:   [slug, client_id]
      type:     unique

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

Для того чтобы наш проект заработал, необходимо разместить следующие тестовые данные в файле data/fixtures/fixtures.yml:

# data/fixtures/fixtures.yml
Client:
  client_pete:
    name:      Pete's Pet Shop
    subdomain: pete
  client_pub:
    name:      City Pub and Grill
    subdomain: citypub

Page:
  page_pete_location_hours:
    title:     Location and Hours | Pete's Pet Shop
    content:   We're open Mon - Sat, 8 am - 7pm
    slug:      location
    Client:    client_pete
  page_pub_menu:
    title:     City Pub And Grill | Menu
    content:   Our menu consists of fish, Steak, salads, and more.
    slug:      menu
    Client:    client_pub

Это тестовые данные для двух вебсайтов, каждый из них имеет по одной странице. Полный URL каждой страницы определяется колонкой subdomain в таблице Client и колонкой slug в таблице Page.

http://pete.sympalbuilder.com/location
http://citypub.sympalbuilder.com/menu

Маршрутизация

Каждая страница вебсайта Sympal Builder напрямую соответствует объекту Page, который определяет ее title и content. Для того чтобы связать каждый URL с его страницей, создадим маршрут типа sfDoctrineRoute, который использует поле slug:

# apps/frontend/config/routing.yml
page_show:
  url:        /:slug
  class:      sfDoctrineRoute
  options:
    model:    Page
    type:     object
  params:
    module:   page
    action:   show

Этот маршрут связывает страницу http://pete.sympalbuilder.com/location с соответствующим объектом Page. К несчастью, этот маршрут также соответствует URL’у http://pete.sympalbuilder.com/menu, т.е. меню ресторана (это другая страница, которая относится к клиенту citypub) также будет отбражаться и на сайте Пита!  На этом шаге маршрут не знает о важности клиентских поддоменов.

Для того чтобы проект заработал, маршрут должен стать более умным. Он должен находить корректную страницу основываясь на полях slug и client_id одновременно, что позволит определять соответствующий хост (например pete.sympalbuilder.com) по колонке subdomain в модели Client. Для того чтобы достичь этого, мы воспользуемся каркасом маршрутизации и создадим свой собственный класс маршрута.

Но, для начала, нам необходимо немного узнать о том, как работает система маршрутизации.

Как работает система маршрутизации

Не смотря на то, что маршруты в основном определены в YAML-файле, они трансформируются в объект в момент вызова при помощи специального класса, который называется cache config handler. В итоге, PHP код представляет каждый маршрут в приложении. Поскольку специфика данного процесса выходит за рамки данной статьи, давайте сразу перейдем к его окончанию, т.е. к компилированной версии маршрута page_show. Компилированный файл находится в файле cache/yourappname/envname/config/config_routing.yml.php для конкретного приложения (app) и окружения (env). Ниже приведена сокращенная версия того как выглядит в итоге маршрут page_show:

new sfDoctrineRoute('/:slug', array (
  'module' => 'page',
  'action' => 'show',
), array (
  'slug' => '[^/\.]+',
), array (
  'model' => 'Page',
  'type' => 'object',
));

Наименование класса каждого маршрута определяется ключом class внутри файла routing.yml. Если этот ключ не указан, маршрут по умолчанию будет являться классом sfRoute. Другой стандартный класс маршрута – это sfRequestRoute, который позволяет разработчику создавать RESTful маршруты. Полный список классов маршрутов и доступных для них опций вы можете найти в с правочнике symfony.

Сверка входящего запроса с конкретным маршрутом

Одно из основных занятий маршрутизатора – сверять каждый входящий URL с корректным объектом маршрута. Класс sfPatternRouting это ядро движка маршрутизации и занимается только этой задачей. Не смотря на его важность, разработчики редко взаимодействуют напрямую с sfPatternRouting.

Для определения корректного маршрута, sfPatternRouting циклически просматривает каждый sfRoute и “спрашивает”, подходит ли ему входящий URL. Изнутри это выглядит следующим образом: sfPatternRouting вызывает метод sfRoute::matchesUrl() для каждого объекта маршрута. Этот метод просто возвращает false, если маршрут не соответствует входящему URL.

А вот если маршрут соответствует входящему URL, sfRoute::matchesUrl() делает несколько больше, нежели просто возвращает true. Вместо этого, маршрут возвращает массив параметров, которые объединяются с объектом запроса. Например, URL http://pete.sympalbuilder.com/location соответствует маршруту page_show, чей метод matchesUrl() вернет следующий массив:

array('slug' => 'location')

Эти данные будут включены в объект запроса, именно поэтому можно получить доступ к переменным маршрута (например, slug) из экшена или из других мест где это требуется.

$this->slug = $request->getParameter('slug');

Как вы могли предположить, переопределение метода sfRoute::matchesUrl() это отличный способ настроить маршрут и сделать почти все что угодно.

Создание собственного класса маршрута

Для того чтобы расширить возможности маршрута page_show для того чтобы он осуществлял проверку, основываясь на поддомене из объекта Client, мы создадим свой собственный класс маршрута. Создадим файл acClientObjectRoute.class.php и разместим его в директории lib/routing нашего проекта (вам нужно будет создать эту директорию):

// lib/routing/acClientObjectRoute.class.php
class acClientObjectRoute extends sfDoctrineRoute
{
  public function matchesUrl($url, $context = array())
  {
    if (false === $parameters = parent::matchesUrl($url, $context))
    {
      return false;
    }

    return $parameters;
  }
}

Следующим шагом проинструктируем маршрут page_show чтобы он использовал наш класс маршрута. В routing.yml нужно обновить ключ class в маршруте:

# apps/fo/config/routing.yml
page_show:
  url:        /:slug
  class:      acClientObjectRoute
  options:
    model:    Page
    type:     object
  params:
    module:   page
    action:   show

Пока что acClientObjectRoute не добавляет функциональности, но тем не менее все части на своих местах. Метод matchesUrl имеет две специфичные функции.

Добавляем логику в наш маршрут

Для того чтобы добавить в маршрут необходимую функциональность, заменим содержимое файла acClientObjectRoute.class.php на следующее:

class acClientObjectRoute extends sfDoctrineRoute
{
  protected $baseHost = '.sympalbuilder.com';

  public function matchesUrl($url, $context = array())
  {
    if (false === $parameters = parent::matchesUrl($url, $context))
    {
      return false;
    }

    // return false if the baseHost isn't found
    if (strpos($context['host'], $this->baseHost) === false)
    {
      return false;
    }

    $subdomain = str_replace($this->baseHost, '', $context['host']);

    $client = Doctrine_Core::getTable('Client')
      ->findOneBySubdomain($subdomain)
    ;

    if (!$client)
    {
      return false;
    }

    return array_merge(array('client_id' => $client->id), $parameters);
  }
}

Вызов parent::matchesUrl() необходим чтобы запустить обычный процесс проверки соответствия маршрутов. В нашем примере, так как URL /location соответствует маршруту page_show, метод parent::matchesUrl() должен вернуть соответствующий параметр slug:

array('slug' => 'location')

Другими словами, всю тяжелую работу по сравнению маршрутов сделали за нас ))) это позволяет нам сфокусироваться на проверке соответствия, основываясь на поддомене клиента.

public function matchesUrl($url, $context = array())
{
  // ...

  $subdomain = str_replace($this->baseHost, '', $context['host']);

  $client = Doctrine_Core::getTable('Client')
    ->findOneBySubdomain($subdomain)
  ;

  if (!$client)
  {
    return false;
  }

  return array_merge(array('client_id' => $client->id), $parameters);
}

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

Массив $context, передаваемый в matchesUrl заполняется кучей полезной информации о текущем запросе, включая host, is_secure, request_uri, HTTP метод и так далее…

И чего же мы в конце концов добились? Класс acClientObjectRoute теперь делает следующее:

  • Входящий $url будет соответствовать только если host содержит поддомен, соответствующий одному их клиентских объектов.
  • Если маршрут соответствует, метод возвращает дополнительный параметр – client_id соответствующего клиента, объединяемый с параметрами запроса.

Используем новый маршрут

Теперь, когда acClientObjectRoute возвращает корректный параметр client_id, мы можем получить его значение через объект запроса. Например, экшен page/show может использовать client_id для определения корректного объекта Page:

public function executeShow(sfWebRequest $request)
{
  $this->page = Doctrine_Core::getTable('Page')->findOneBySlugAndClientId(
    $request->getParameter('slug'),
    $request->getParameter('client_id')
  );

  $this->forward404Unless($this->page);
}

Метод findOneBySlugAndClientId() это один из магических поисковых методов(magic finder) – нововведение Doctrine 1.2, которое позволяет искать объекты, основываясь на нескольких полях.

Маршрутизатор может предложить даже еще более элегантное решение. Для начала добавим следующий метод в класс acClientObjectRoute:

protected function getRealVariables()
{
  return array_merge(array('client_id'), parent::getRealVariables());
}

Теперь, экшен может полностью полагаться на маршрут, который вернет правильный объект. Теперь экшен page/show может уместиться в одну строку:

public function executeShow(sfWebRequest $request)
{
  $this->page = $this->getRoute()->getObject();
}

Без лишних телодвижений, этот код будет запрашивать объект Page, основываясь на обеих колонках: slug и client_id. Кроме того, как и все другие объекты маршрутов, экшен будет автоматически перенаправлен на страницу 404, если соответствующий объект не найден.

Как же это работает? Объекты маршрутов, например sfDoctrineRoute, который расширяет наш класс acClientObjectRoute, автоматически запрашивает соответствующий объект, основываясь на переменных в ключе url маршрута. Например, маршрут page_show, который содержит переменную :slug в его url, будет запрашивать объект Page, основываясь на колонке slug.

В этом приложении, маршрут page_show должен также запрашивать объекты Page, основываясь на колонке client_id. Для того чтобы добиться этого, мы переопределили метод sfObjectRoute::getRealVariables(), который вызывается внутри для определения какие колонки нужно использовать для запроса объекта. Т.о., добавив в этот массив client_id, мы дали возможность acClientObjectRoute сформировать запрос используя и slug и client_id.

Объекты маршрутов автоматически игнорируют любые переменные, которые не соответствуют реальным столбцам в базе данных. Например, если URL содержит переменную :page, но соответствующая таблица такой колонки не имеет, эта переменная будет проигнорирована.

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

Генерируем корректный URL

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

<?php echo link_to('Locations', 'page_show', $page) ?>

Генерируется url: /location?client_id=1

Как вы видите, client_id автоматически добавляется к url. Это происходит потому что маршрут пытается использовать все его переменные для генерации URL. Так как маршрут теперь осведомлен о двух параметрах – slug и client_id, он использует их оба для создания маршрута.

Для того чтобы поправить этот баг, добавим следующий метод к классу acClientObjectRoute :

protected function doConvertObjectToArray($object)
{
  $parameters = parent::doConvertObjectToArray($object);

  unset($parameters['client_id']);

  return $parameters;
}

Когда объект маршрута создается, он пытается получить всю необходимую информацию, вызывая doConvertObjectToArray(). По умолчанию, client_id возвращается в массиве $parameters. Когда мы его удаляем (unset), мы предотвращаем включение его в сгенерированный URL. Помните, мы позволили себе подобную роскошь, поскольку информация о клиенте содержится в его поддомене.

Вы можете переопределить doConvertObjectToArray процесс целиком и обрабатывать его самостоятельно, путем добавления метода toParams в класс модели. Этот метод должен возвращать массив параметров, которые вы хотите использовать в процессе генерации маршрута.

Продолжение следует! ;–)

Write a Comment

Comment

*