Pull to refresh

Как использовать UrlManager для настройки роутинга и создания «дружелюбных» URL

Reading time 13 min
Views 41K

Здравствуйте, дорогие читатели! Я продолжаю цикл статей о том, как мы разрабатывали нетипичный, крупный проект с использованием Yii2 framework и AngularJS.

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

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

Самое интересное под катом.

URL Rules


Я предполагаю, что вы, скорее всего, уже использовали UrlManager ранее, по крайней мере, для того, чтобы включить ЧПУ и скрыть index.php из URL.

//...
'urlManager' => [
    'class' => 'yii\web\UrlManager',
    'enablePrettyUrl' => true,
    'showScriptName' => false,
],
/..

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

‘enableStrictParsing’ => true – очень полезное свойство, которое ​​ограничивает доступ только к правилам, которые уже настроены. В примере конфигурации маршрут www.our-site.com будет указывать на site/default/index, но www.our-site.com/site/default/index покажет страницу 404.

//...
'urlManager' => [
    'class' => 'yii\web\UrlManager',
    'enablePrettyUrl' => true,
    'showScriptName' => false,
    'enableStrictParsing' => true,
    'rules' => [
        '/' => 'site/default/index',
    ],
],
/..

Так как мы разделили приложение на модули и хотим, чтобы эти модули были максимально независимы друг от друга, URL правила могут быть добавлены динамически в URL менеджер. Это даст возможность распространять и переиспользовать модули, без необходимости донастраивать UrlManager, потому что модули будут управлять своими собственными URL правилами.

Для того чтобы динамически добавленные правила вступили в силу во время процесса маршрутизации, вы должны добавить их на стадии самонастройки. Для модулей это означает, что они должны имплементировать yii\base\BootstrapInterface и добавить правила в методе начальной загрузки bootstrap(), таким образом:

<?php
namespace modules\site;
use yii\base\BootstrapInterface;
 
class Bootstrap implements BootstrapInterface
{
    /**
     * @inheritdoc
     */
    public function bootstrap($app)
    {
        $app->getUrlManager()->addRules(
            [
                // объявление правил здесь
                '' => 'site/default/index',
                '<_a:(about|contacts)>' => 'site/default/<_a>'
            ]
        );
    }
}

Файл Bootstrap.php с этим кодом мы добавляем в папку модуля /modules/site/. И такой файл у нас будет в каждом модуле, который будет добавлять свои Url правила.

Обратите внимание, что вы должны также перечислить эти модули в yii\web\Application::bootstrap(), чтобы они могли участвовать в процессе самонастройки. Для этого в файл /frontend/config/main.php перечислить модули в массиве bootstrap:

//...
    'params' => require(__DIR__ . '/params.php'),
    'bootstrap' => [
        'modules\site\Bootstrap',
        'modules\users\Bootstrap',
        'modules\cars\Bootstrap'
        'modules\lease\Bootstrap'
        'modules\seo\Bootstrap'
        ],
];

Обратите внимание, что с момента написания первой статьи я добавил еще несколько модулей:

  • modules/users – Модуль, в котором будут обрабатываться операции с пользователем и все страницы пользователя (регистрация, логин, личный кабинет)
  • modules/cars – Модуль, в котором будут работать с базой данных брендов, марок, модификаций автомобилей.
  • modules/lease – Модуль, в котором будут обрабатываться объявления, добавленные пользователями.
  • modules/seo – Модуль для SEO. Тут будут храниться все компоненты и хелперы, которые нам будут помогать соответствовать SEO требованиям. О них я буду писать далее.

Пользовательские URL Rules


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

Например, на веб-сайте автомобильного дилера, вы можете поддерживать формат URL, как /new-lease/state/Make-Model-Location/Year, где state, Make, Model, Year и Location должны соответствовать некоторым данным, хранящимся в таблице базы данных. Дефолтный класс здесь работать не будет, так как он опирается на статически объявленные паттерны.

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

Согласно спецификации, нам необходимо было сделать следующие типы страниц с соответствующими им правилами формирования Url и meta тегов:

Страницы результатов поиска


Они же в свою очередь делятся на три типа:

Объявления от дилеров
url: /new-lease/(state)/(Make)-(Model)-(Location)
url: /new-lease/(state)/(Make)-(Model)-(Location)/(Year)

Пользовательские объявления:
url: /lease-transfer/(state)/(Make)-(Model)-(Location)
url: /lease-transfer/(state)/(Make)-(Model)-(Location)/(Year)

Например: /new-lease/NY/volkswagen-GTI-New-York-City/2015

Результаты поиска, когда местоположение не указано в фильтре:
/(new-lease|lease-transfer)/(Make)-(Model)/(year)

Title: (Make) (Model) (Year) for Lease in (Location). (New Leases|Lease Transfers)
Например: Volkswagen GTI 2015 for Lease in New York City. Dealer Leases.
Keywords: (Make), (Model), (Year), for, Lease, in, (Location), (New, Leases|Lease, Transfers)
Description: List of (Make) (Model) (Year) in (Location) available for lease. (Dealer Leases|Lease Transfers).

Страница просмотра объявления


Они же в свою очередь делятся на два типа:

Объявление от дилера:
url: /new-lease/(state)/(make) — (model) — (year) — (color) — (fuel type) — (location) — (id)

Пользовательские объявление:
url: /lease-transfer/(state)/(make) — (model) — (year) — (color) — (fuel type) — (location) — (id)

Title: (make) — (model) — (year) — (color) — (fuel type) for lease in (location)
Keywords: (year), (make), (model), (color), (fuel type), (location), for, lease
Description: (year) (make) (model) (color) (fuel type) for lease (location)

Информационные страницы об автомобиле


url: /i/(make) — (model) — (year)
Title: (make) — (model) — (year)
Keywords: (year), (make), (model)
Description: (year), (make), (model)

Это только часть тех страниц, которые нам необходимо было реализовать. Отрывок из технического задания приведен здесь для того, что вы понимали, чего нам нужно добиться от Url менеджера и насколько это нетривиальные правила.

Yii2 позволяет определить пользовательские URL через синтаксический анализ и логику генерации URL, сделав пользовательский класс UrlRule. Если вы хотите сделать свой собственный UrlRule вы можете либо скопировать код из yii\web\UrlRule и расширить его или, в некоторых случаях, просто имплементировать yii\web\UrlRuleInterface.

Ниже приведен код, который написан нашей командой для структуры URL, которые мы обсудили. Для него мы создали файл /modules/seo/components/UrlRule.php. Не считаю этот код эталоном, но уверен, что он однозначно выполняет поставленную задачу.

<?php
namespace modules\seo\components;
use modules\seo\models\Route;
use modules\zipdata\models\Zip;
use yii\helpers\Json;
use Yii;
use yii\web\UrlRuleInterface;
 
class UrlRule implements UrlRuleInterface
{
    public function createUrl($manager, $route, $params)
    {
        /**
         * Lease module create urls
         */
        if ($route === 'lease/lease/view') {
            if (isset($params['state'], $params['node'], $params['role'])) {
                $role = ($params['role'] == 'dealer') ? 'new-lease' : 'lease-transfer';
                return $role . '/' . $params['state'] . '/' . $params['node'];
            }
        }
        if ($route === 'lease/lease/update') {
            if (isset($params['state'], $params['node'], $params['role'])) {
                $role = ($params['role'] == 'dealer') ? 'new-lease' : 'lease-transfer';
                return $role . '/' . $params['state'] . '/' . $params['node'] . '/edit/update';
            }
        }
 
        /**
         *  Information Pages create urls
         */
        if ($route === 'cars/info/view') {
            if (isset($params['node'])) {
                return 'i/' . $params['node'];
            }
        }
 
        /**
         *  Search Pages create urls
         */
        if ($route === 'lease/search/view') {
            if (!empty($params['url'])) {
                $params['url'] = str_replace(' ', '_', $params['url']);
                if($search_url = Route::findRouteByUrl($params['url'])) {
                    return '/'.$params['url'];
                } else {
                    $route = new Route();
                    $route->url = str_replace(' ', '_', substr($params['url'],1) );
                    $route->route = 'lease/search/index';
                    $route->params = json_encode(['make'=>$params['make'], 'model'=>$params['model'], 'location'=>$params['location']  ]);
                    $route->save();
                    return '/'.$params['url'];
                }
            }
            if (isset($params['type']) && in_array($params['type'], ['user','dealer'])) {
                $type = ($params['type'] == 'dealer')? 'new-lease' : 'lease-transfer';
            } else {
                return false;
            }
            if ((isset($params['zip']) && !empty($params['zip'])) || (isset($params['location']) && isset($params['state']))) {
                // make model price zip type
                if (isset($params['zip']) && !empty($params['zip'])) {
                    $zipdata = Zip::findOneByZip($params['zip']);
                } else {
                    $zipdata = Zip::findOneByLocation($params['location'], $params['state']);
                }
                // city state_code
                if (!empty($zipdata)) {
                    $url = $type . '/' . $zipdata['state_code'] . '/' . $params['make'] . '-' . $params['model'] . '-' . $zipdata['city'];
                    if (!empty($params['year'])) {
                        $url.='/'.$params['year'];
                    }
                    $url = str_replace(' ', '_', $url);
                    if($search_url = Route::findRouteByUrl($url)) {
                        return '/'.$url;
                    } else {
                        $route = new Route();
                        $route->url = str_replace(' ','_',$url);
                        $route->route = 'lease/search/index';
                        $pars = ['make'=>$params['make'], 'model'=>$params['model'], 'location'=>$zipdata['city'], 'state'=>$zipdata['state_code'] ]; //, 'zip'=>$params['zip'] ];
                        if (!empty($params['year'])) {
                            $pars['year']=$params['year'];
                        }
                        $route->params = json_encode($pars);
                        $route->save();
                        return $route->url;
                    }
                }
            }
            if (isset($params['make'], $params['model'] )) {
                $url = $type . '/' . $params['make'] . '-' . $params['model'] ;
                if (!empty($params['year'])) {
                    $url.='/'.$params['year'];
                }
                $url = str_replace(' ', '_', $url);
                if($search_url = Route::findRouteByUrl($url)) {
                    return '/'.$url;
                } else {
                    $route = new Route();
                    $route->url = str_replace(' ','_',$url);
                    $route->route = 'lease/search/index';
                    $pars = ['make'=>$params['make'], 'model'=>$params['model']  ];
                    if (!empty($params['year'])) {
                        $pars['year']=$params['year'];
                    }
                    $route->params = json_encode($pars);
                    $route->save();
                    return $route->url;
                }
            }
        }
 
        return false;
    }
 
    /**
     * Parse request
     * @param \yii\web\Request|UrlManager $manager
     * @param \yii\web\Request $request
     * @return array|boolean
     */
    public function parseRequest($manager, $request)
    {
        $pathInfo = $request->getPathInfo();
 
        /**
         * Parse request for search URLs with location and year
         */
        if (preg_match('%^(?P<role>lease-transfer|new-lease)\/(?P<state>[A-Za-z]{2})\/(?P<url>[._\sA-Za-z-0-9-]+)\/(?P<year>\d{4})?%', $pathInfo, $matches)) {
            $route = Route::findRouteByUrl($pathInfo);
            if (!$route) {
                return false;
            }
            $params = [
                'node' => $matches['url'] . '/' . $matches['year'],
                'role' => $matches['role'],
                'state' => $matches['state'],
                'year' => $matches['year']
            ];
            if (!empty($route['params'])) {
                $params = array_merge($params, json_decode($route['params'], true));
            }
            return [$route['route'], $params];
        }
 
        /**
         * Parse request for search URLs with location and with year
         */
        if (preg_match('%^(?P<role>lease-transfer|new-lease)\/(?P<url>[._\sA-Za-z-0-9-]+)\/(?P<year>\d{4})%', $pathInfo, $matches)) {
            $route = Route::findRouteByUrl($pathInfo);
            if (!$route) {
                return false;
            }
            $params = [
                'node' => $matches['url'] . '/' . $matches['year'],
                'role' => $matches['role'],
                'year' => $matches['year']
            ];
            if (!empty($route['params'])) {
                $params = array_merge($params, json_decode($route['params'], true));
            }
            return [$route['route'], $params];
        }
 
        /**
         * Parse request for leases URLs and search URLs with location
         */
        if (preg_match('%^(?P<role>lease-transfer|new-lease)\/(?P<state>[A-Za-z]{2})\/(?P<url>[_A-Za-z-0-9-]+)?%', $pathInfo, $matches)) {
            $route = Route::findRouteByUrl([$matches['url'], $pathInfo]);
            if (!$route) {
                return false;
            }
            $params = [
                'role' => $matches['role'],
                'node' => $matches['url'],
                'state' => $matches['state']
            ];
            if (!empty($route['params'])) {
                $params = array_merge($params, json_decode($route['params'], true));
            }
            return [$route['route'], $params];
        }
 
        /**
         * Parse request for search URLs without location and year
         */
        if (preg_match('%^(?P<role>lease-transfer|new-lease)\/(?P<url>[._\sA-Za-z-0-9-]+)?%', $pathInfo, $matches)) {
            $route = Route::findRouteByUrl($pathInfo);
            if (!$route) {
                return false;
            }
            $params = [
                'node' => $matches['url'],
                'role' => $matches['role'],
            ];
            if (!empty($route['params'])) {
                $params = array_merge($params, json_decode($route['params'], true));
            }
            return [$route['route'], $params];
        }
 
        /**
         * Parse request for Information pages URLs
         */
        if (preg_match('%^i\/(?P<url>[_A-Za-z-0-9-]+)?%', $pathInfo, $matches)) {
            $route = Route::findRouteByUrl($matches['url']);
            if (!$route) {
                return false;
            }
            $params = Json::decode($route['params']);
            $params['node'] = $route['url'];
            return [$route['route'], $params];
        }
 
        return false;
    }
}

Для того, чтобы его использовать, нужно только добавить этот класс в массив правил yii\web\UrlManager::$rules.

Для этого создадим файл Bootstrap.php в модуле /modules/seo (по аналогии с файлом Bootstrap.php в модуле /modules/site) и объявим в нем такое правило:
//...
    public function bootstrap($app)
    {
        $app->getUrlManager()->addRules(
            [
                [
                  'class' => 'modules\seo\components\UrlRule,
                ],
            ]
        );
    }
/..

Это специальное правило предназначено для очень конкретного случая использования. Мы не планируем повторно использовать это правило в других проектах, поэтому оно не имеет настроек.

Так как правило не настраивается, нет необходимости расширять от yii\web\UrlRule, yii\base\Object, или от чего-нибудь еще. Просто достаточно имплементировать интерфейс yii\web\UrlRuleInterface. Потому как мы не планируем повторно использовать это правило в наших переиспользуемых модулях, мы его определили в SEO модуле.

parseRequest () просматривает маршрут, и если он совпадает с регулярным выражением в условии, он разбирает дальше, чтобы извлечь параметры.

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

  • url – часть поискового запроса, по которому нашлась запись,
  • route – маршрут, которому надо передать управление,
  • params – дополнительные, параметры в формате JSON строки, которые необходимо передать в действие для дальнейшей работы.

parseRequest () возвращает массив с действием и параметрами:

[
            ‘lease/search/view’,
            [
                'node' => new-lease/NY/ volkswagen-GTI-New-York-City/2016,
                'role' => ‘new-lease’,
                'state' => ‘NY’,
                    'year' => ‘2016’
            ]
]

Иначе возвращает false, чтобы указать UrlManager, что он не может разобрать запрос.

createUrl () строит URL из предоставленных параметров, но только если URL был предложен для действий lease/lease/view, cars/info/view или lease/search/view.

Из соображений производительности


При разработке сложных веб-приложений важно оптимизировать правила URL так, чтобы синтаксический анализ запросов и создания URL занимал меньше времени.

При анализе или создании URL-адреса, URL-менеджер анализирует правила URL в том порядке, в котором они были объявлены. Таким образом, вы можете рассмотреть возможность корректировки порядка правил URL так, чтобы более определенные и/или часто используемые правила размещались перед менее использованными правилами.

Часто встречается, что ваше приложение состоит из модулей, каждый из которых имеет свой собственный набор правил URL с module ID, как и их общий префикс.

Генерация и вывод мета тегов


Для того чтобы генерировать и выводить meta теги в определенном формате для указанных типов страниц, был написан специальный хелпер, который разместился в файле modules/seo/helpers/Meta.php. В нем содержится следующий код:

<?php
namespace modules\seo\helpers;
use Yii;
use yii\helpers\Html;
 
/**
 * @package modules\seo\helpers
 */
class Meta
{
    /**
     * Генерирует meta теги title, keywords, description и возвращает строку Заголовка страницы.
     *
     * @param string $type Тип страницы, для которой генерируются meta теги
     * @param object $model
     * @return string $title Заголовок страницы
     */
    public static function all($type, $model = null)
    {
        $title = 'Carvoy | A new generation of leasing a car!'; // Заголовок страницы по-умолчанию.
 
        switch ($type) {
            case 'home':
                $title = 'Carvoy | A new generation of leasing a car!';
                Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => 'lease, car, transfer']);
                Yii::$app->view->registerMetaTag(['name' => 'description','content' => 'Carvoy - Change the way you lease! Lease your next new car online and we\'ll deliver it to your doorstep.']);
                break;
            case 'lease':
                $title = $model->make . ' - ' . $model->model . ' - ' . $model->year . ' - ' . $model->exterior_color . ' - ' . $model->engineFuelType . ' for lease in ' . $model->location;
                Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => Html::encode($model->year . ', ' . $model->make . ', ' . $model->model . ', ' . $model->exterior_color . ', ' . $model->engineFuelType . ', ' . $model->location . ', for, lease')]);
                Yii::$app->view->registerMetaTag(['name' => 'description','content' => Html::encode($model->year . ' ' . $model->make . ' ' . $model->model . ' ' . $model->exterior_color . ' ' . $model->engineFuelType . ' for lease in ' . $model->location)]);
                break;
            case 'info_page':
                $title = $model->make . ' - ' . $model->model . ' - ' . $model->year;
                Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => Html::encode($model->year . ', ' . $model->make . ', ' . $model->model)]);
                Yii::$app->view->registerMetaTag(['name' => 'description','content' => Html::encode($model->year . ' ' . $model->make . ' ' . $model->model)]);
                break;
            case 'search':
                if ($model['role'] == 'd') $role = 'Dealer Lease';
                elseif ($model['role'] == 'u') $role = 'Lease Transfers';
                else $role = 'All Leases';
                if (isset($model['make']) && isset($model['model'])) {
                    $_make = (is_array($model['make']))? (( isset($model['make']) && ( count($model['make']) == 1) )? $model['make'][0] : false ) : $model['make'];
                    $_model = (is_array($model['model']))? (( isset($model['model']) && ( count($model['model']) == 1) )? $model['model'][0] : false ) : $model['model'];
                    $_year = false;
                    $_location = false;
                    if (isset($model['year'])) {
                        $_year = (is_array($model['year']))? (( isset($model['year']) && ( count($model['year']) == 1) )? $model['year'][0] : false ) : $model['year'];
                    }
                    if (isset($model['location'])) {
                        $_location = (is_array($model['location']))? (( isset($model['location']) && ( count($model['location']) == 1) )? $model['location'][0] : false ) : $model['location'];
                    }
                    if ( ($_make || $_model) && !(isset($model['make']) && ( count($model['make']) > 1)) ) {
                        $title = $_make . (($_model)? ' ' . $_model : '') . (($_year)? ' ' . $_year : '') . ' for Lease' . (($_location)? ' in ' . $_location . '. ' : '. ') . $role . '.';
                    } else {
                        $title = 'Vehicle for Lease' . (($_location)? ' in ' . $_location . '. ' : '. ') . $role . '.';
                    }
                    Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => Html::encode( ltrim($_make . (($_model)? ', ' . $_model : '') . (($_year)? ', ' . $_year : '') . ', for, Lease' . (($_location)? ', in, ' . $_location : '') . ', ' . implode(', ', (explode(' ', $role))), ', ') ) ]);
                    Yii::$app->view->registerMetaTag(['name' => 'description','content' => Html::encode( 'List of '. ((!$_model && !$_make)? 'Vehicles' : '') . $_make . (($_model)? ' ' . $_model : '') . (($_year)? ' ' . $_year : '') . (($_location)? ' in ' . $_location : '') . ' available for lease. ' . $role . '.' )]);
                } else {
                    $title = 'Search results';
                }
                break;
        }
        return $title;
    }
}

Используем этот хелпер в view страницы, для которой необходимо установить meta теги. Например, для страницы просмотра объявления, добавляем следующую строку в файл /modules/lease/views/frontend/lease/view.php

//...
    $this->title = \modules\seo\helpers\Meta::all('lease', $model);
/..

Первым параметром в метод мы передаем тип страницы, для которой генерируются meta теги. Вторым параметром передается модель текущего объявления.

Внутри метода происходит генерация мета тегов в зависимости от типа страницы и добавление их в head с помощью метода registerMetaTag класса yii\web\View. Метод возвращает нам сгенерированную строку для тега title. Таким образом, через свойство $title класса yii\web\View, мы задаем заголовок страницы.

Спасибо за внимание!

Материал подготовлен: greebn9k (Сергей Грибняк), pavel-berezhnoy (Павел Бережной), silmarilion (Андрей Хахарев)
Tags:
Hubs:
+11
Comments 9
Comments Comments 9

Articles