Pull to refresh

ЧПУ (SEF URLs) в Symfony 3 — автогенерация slug, настройка и маршрутизация

Reading time 10 min
Views 11K
Всем доброго времени суток!

Третьего дня мне понадобилось провести блиц вебинар на тему ЧПУ в Symfony. Вообще время вебинара у меня ограничено двумя часами, при этом я должен был рассказать еще и про автогенерацию CRUD функционала (scaffolding) в той же Symfony, и про простейший способ создать постраничность. Это создало проблему, так как я знаю как сделать ЧПУ «ручками», не прибегая к автоматизированным под эту задачу инструментам, но рассказ получился бы долгий и оказались бы затянутыми в обсуждение лишние темы. Поэтому я пошел спрашивать у Интернета как сделать все проще. И вот я оказался в той редкой ситуации, когда такая популярная платформа как Symfony не имеет банального обучающего материала на тему «ЧПУ в три клика». Смотрел так же и на английском языке, но там тоже пусто (может плохо искал — время было ограничено). В общем я справился с поиском разрозненного материала по данной теме, а так же со сбором его в единое повествование, так что почему бы не поделиться со всеми?



Терминология
Я не знаю кто будет читать мою статью, так что для начала разберемся в терминологии.

ЧПУ — аббревиатура от «Человекопонятные URL». На английский переводится как Friendly URL или Semantic URL. Однако чаще используется как аналогичная аббревиатура: SEF URLs — Search Engine Friendly URLs.

Что дает вам ЧПУ?

Самое очевидное — это то, что URL-ы вашего сайта будут понятны пользователю. Зачем, только, ему их читать? Большинство клиентов моих заказчиков даже не подозревают о наличии адресной строки браузера. Если есть сомнения, то посмотрите сколько результатов выведет запрос в Гугл «Где находится адресная строка браузера».

Однако есть неоспоримые плюс — правильно составленные ЧПУ являются одним из важных элементов SEO оптимизации, благодаря которой странички вашего сайта будут появляться в поисковике на первой странице. Для этого URL на вашем сайте должны содержать релевантную для поисковика информацию о страничках, на которые они ведут и иметь продуманную вложенность. Все это замечательно, но речь в этой статье не о SEO оптимизации. Предполагается, что вы уже решили получить ЧПУ на своем сайте и дополнительная мотивация вам уже не требуется.

ЧПУ, не ЧПУ

ЧПУ URL-ы — это адреса страниц описывающие всю необходимую информацию о запрашиваемой у сервера странице в виде сегментов пути, то есть GET параметры в таком URL-е большая редкость.

Обычно можно найти шаблоны пути подобные таким:
http(s)://Домен/slug-категории/slug-подкатегории/slug-товара-или-статьи
http(s)://Домен/Профайл/slug-владельца-профайла

Тут появляется еще один термин — Slug, который важен для дальнейшего понимания статьи:

Slug — (из Викисловаря) альтернативная дружественная к восприятию человеком — буквенно-цифровая часть универсального адреса интернет-ссылки (URL) к рубрицируемому содержимому. То есть, если по простому, то slug заменяет всяческие признаки и id-шники ресурсов нашего сайта в URL-е на человекопонятный текст.

Разберем пример

Кому и так ясно что-такое ЧПУ — мотаем дальше.

Разбор примера как могли бы выглядеть URL-ы сайта, если их доработать до ЧПУ
На примере сайта магазина rozetka.com.ua (первый сайт, который попался под руку). ЧПУ тут в зачаточном состоянии. Давайте попробуем их ссылки довести до ума вручную:

Я зашел на страницу «Мячи для настольного тенниса» и адрес в ее оказался:
rozetka.com.ua/t_balls/c81265

Явно, что «c81265» первым символом указывает на то, что запрашиваемый объект — категория товаров, а число после него — id категории в базе данных.

Переделав под ЧПУ у на получилось бы просто:
rozetka.com.ua/t_balls

Просто удалили id-шник? Как же так? А как же контентные страницы (http://rozetka.com.ua/contacts/)?
Да никаких проблем. Просто поставьте все контентные страницы, так чтобы текущий путь в запросе сверялся в первую очередь с ними. В Symfony это делается всего лишь тем, что маршруты для этих путей объявлены первыми.

Если и так не получается или у вас на сайте есть еще что-то важное кроме контентных страниц и категорий товаров, то делаем более однозначный путь:
rozetka.com.ua/category/t_balls

Далее я перешел на сам продукт «Мячи для настольного тенниса Donic Elit 1* 6 шт белый (618016): rozetka.com.ua/198578/p198578

Вот тут просто беда. ЧПУ даже перестало пахнуть.

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


Здесь:

t_balls — slug категории
donic-elit-1-6-beliy — slug продукта

Думаю с наглядностью мы закончили.

Как получить ЧПУ в Symfony

Объяснять буду на примере свежей установки Symfony. На момент написания была взята версия Symfony 3.3.0. Предполагается, что вы установили Symfony и сконфигурировали доступ к базе данных.

Прежде чем начнется суть да дело нужно подружить нашу Symfony 3.3.0 с phpunit, чтобы она не валилась после автогенерации контроллеров. Дополните composer.json проекта двумя строчками:

composer.json
...
    "require-dev": {
        ...
        "phpunit/phpunit": "^6.2.1"
        ...
    },
...
    "config": {
        "platform": {
            "php": "7.0.15"
        },
        ...
    },
...

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

composer update

Или так, если у вас композер архивом лежит в проекте:

php composer.phar update

Генерируем внутри бандла AppBundle сущность продукта консольной командой:

php bin/console doctrine:generate:entity --entity=AppBundle:Product --fields="name:string description:text seller:string publishDate:datetime slug:string(length=128 nullable=false unique=true)" -q

Наверняка вы заметили, что помимо остальных полей имеется интересное поле slug. Я сделал его уникальным, и без возможности быть равным null. Дело в том, что в нашем новом проекте мы должны будем иметь возможность выбирать товары из базы данных как по id-шникам, так и по slug-ам. Slug теперь — наш второй после id уникальный идентификатор записи.

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

php bin/console doctrine:database:create #создаем базу данных
php bin/console doctrine:schema:create #создаем структуру данных в базе данных

php bin/console doctrine:generate:crud --entity="AppBundle:Product" --route-prefix=products --with-write -n #генерируем CRUD контроллер

Теперь после запуска сервера

php bin/console server:run localhost:2020

Мы можем посетить страницу http://localhost:2020/products/ и увидеть пустой список продуктов да ссылку на страницу создания нового продукта:



Повременим с созданием новых продуктов. Ведь нас ждет подключение расширений Doctrine.

Подключение поведенческих расширений Doctrine

Почему нам нужны расширения Doctrine? Разве мы сами не можем генерировать slug для продукта? В целом да. Все это можно сделать собственными руками: генерировать slug на основе поля или набора полей, заботиться об уникальности slug-а, иметь всегда в виду необходимость его заполнения, иначе сайт рухнет. Но мы не ради этого здесь собрались. Так что читаем официальную документацию по тому как пользоваться расширениями Doctrine:

How to use Doctrine Extensions

Тут нам советуют использовать бандл StofDoctrineExtensionsBundle, который обеспечит корректное подключение расширений Doctrine. Читаем документацию по нему:

StofDoctrineExtensionsBundle

Устанавливаем бандл StofDoctrineExtensionsBundle:

composer require stof/doctrine-extensions-bundle

Подключаем скачанный бандл:

app/AppKernel.php
class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = array(
            // ...
            new Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle(),
        );
        // ...
    }
    // ...
}

Из всего богатства вовлеченных нами в проект расширений Doctrine нам нужно всего одно — Sluggable behavior extension. Так что сконфигурируем StofDoctrineExtensionsBundle таким образом, чтобы это расширение было включено:

app/config/config.yml
...
stof_doctrine_extensions:
    default_locale: en_US
    orm:
        default:
            sluggable : true
...

Расширение Sluggable behavior extension подключено. Надо теперь указать ему, что именно от него требуется. Для этого почитаем по нему документацию:

Sluggable behavior extension for Doctrine 2

Оказывается, нам не так уж и много нужно сделать. Всего-то нужно в сущности продукта подключить класс аннотаций, которые предоставляет нам расширение, да указать этими аннотациями полю Product:slug, что оно должно автоматически заполняться как slug на основе полей, которые мы выберем:

src/AppBundle/Entity/Product.php
...
use Gedmo\Mapping\Annotation as Gedmo;
...

/**
 * Product
 *
 * @ORM\Table(name="product")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\ProductRepository")
 */
class Product
{
...
     /**
     * @var string
     *
     * @Gedmo\Slug(fields={"name"})
     * @ORM\Column(name="slug", type="string", length=128, nullable=false, unique=true)
     */
    private $slug;
...
}

Здесь я указал аннотацией @Gedmo\Slug(fields={"name"}), что я хочу, чтобы slug генерировался на основании поля name. Можно указать несколько полей, чтобы они конкантинировались при генерации. Например, часто вместо с именем сущности указывают дату создания: @Gedmo\Slug(fields={"publishDate", "name"}).

Пора создавать продукты. Но перед этим нужно убрать лишнее поле из формы, ведь поле slug Doctrine будет заполнять самостоятельно:

src/AppBundle/Form/ProductType.php
...
class ProductType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name')->add('description')->add('seller')->add('publishDate'); //Удалили ->add('slug')
    }
    ...
}

Заходим на форму создания продукта (http://localhost:2020/products/new)


Сохраняем и видим, что slug сгенерирован. Он годен для использования в маршрутах вашего приложения:



Остается проверить ЧПУ на деле.

Первый ЧПУ маршрут

Сделаем все по простому. А именно — переделаем маршруты products_show и products_edit:



таким образом, чтобы они показывали нам продукт не по id-нику, а по slug-у. Маршрут products_delete менять не будем, так как он не виден ни пользователю, ни поисковику.

src/AppBundle/Controller/ProductController.php
...
class ProductController extends Controller
{
     ...
     /**
     * Finds and displays a product entity.
     *
     * @Route("/{slug}", name="products_show")
     * @Method("GET")
     * @param string $slug
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function showAction(string $slug)
    {
        $product = $this->getDoctrine()
            ->getRepository('AppBundle:Product')
            ->findOneBySlug($slug);
        $deleteForm = $this->createDeleteForm($product);

        return $this->render('product/show.html.twig', array(
            'product' => $product,
            'delete_form' => $deleteForm->createView(),
        ));
    }

    /**
     * Displays a form to edit an existing product entity.
     *
     * @Route("/{slug}/edit", name="products_edit")
     * @Method({"GET", "POST"})
     * @param Request $request
     * @param string $slug
     * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
     */
    public function editAction(Request $request, string $slug)
    {
        $product = $this->getDoctrine()
            ->getRepository('AppBundle:Product')
            ->findOneBySlug($slug);

        $deleteForm = $this->createDeleteForm($product);
        $editForm = $this->createForm('AppBundle\Form\ProductType', $product);
        $editForm->handleRequest($request);

        if ($editForm->isSubmitted() && $editForm->isValid()) {
            $this->getDoctrine()->getManager()->flush();

            return $this->redirectToRoute('products_edit', array('slug' => $product->getSlug()));
        }

        return $this->render('product/edit.html.twig', array(
            'product' => $product,
            'edit_form' => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        ));
    }
    ...
}

app/Resources/views/product/index.html.twig
{% extends 'base.html.twig' %}

{% block body %}
    <h1>Products list</h1>

    <table>
        <thead>
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th>Description</th>
                <th>Seller</th>
                <th>Publishdate</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
        {% for product in products %}
            <tr>
                <td><a href="{{ path('products_show', { 'slug': product.slug }) }}">{{ product.id }}</a></td>
                <td>{{ product.name }}</td>
                <td>{{ product.description }}</td>
                <td>{{ product.seller }}</td>
                <td>{% if product.publishDate %}{{ product.publishDate|date('Y-m-d H:i:s') }}{% endif %}</td>
                <td>
                    <ul>
                        <li>
                            <a href="{{ path('products_show', { 'slug': product.slug }) }}">show</a>
                        </li>
                        <li>
                            <a href="{{ path('products_edit', { 'slug': product.slug }) }}">edit</a>
                        </li>
                    </ul>
                </td>
            </tr>
        {% endfor %}
        </tbody>
    </table>

    <ul>
        <li>
            <a href="{{ path('products_new') }}">Create a new product</a>
        </li>
    </ul>
{% endblock %}


app/Resources/views/product/show.html.twig
{% extends 'base.html.twig' %}

{% block body %}
    <h1>Product</h1>

    <table>
        <tbody>
            <tr>
                <th>Id</th>
                <td>{{ product.id }}</td>
            </tr>
            <tr>
                <th>Name</th>
                <td>{{ product.name }}</td>
            </tr>
            <tr>
                <th>Description</th>
                <td>{{ product.description }}</td>
            </tr>
            <tr>
                <th>Seller</th>
                <td>{{ product.seller }}</td>
            </tr>
            <tr>
                <th>Publishdate</th>
                <td>{% if product.publishDate %}{{ product.publishDate|date('Y-m-d H:i:s') }}{% endif %}</td>
            </tr>
            <tr>
                <th>Slug</th>
                <td>{{ product.slug }}</td>
            </tr>
        </tbody>
    </table>

    <ul>
        <li>
            <a href="{{ path('products_index') }}">Back to the list</a>
        </li>
        <li>
            <a href="{{ path('products_edit', { 'slug': product.slug }) }}">Edit</a>
        </li>
        <li>
            {{ form_start(delete_form) }}
                <input type="submit" value="Delete">
            {{ form_end(delete_form) }}
        </li>
    </ul>
{% endblock %}

Получилось так:



Теперь маршрут на детальный просмотр продукта выглядит так: @Route("/{slug}", name="products_show")

Маршрут на редактирование продукта: @Route("/{slug}/edit", name="products_edit")

Уникальность slug-ов
Вопрос заданный мне в комментариях пользователем psycho-coder сподвиг меня дополнить статью. А что, если я захочу создать несколько продуктов с одинаковым наименованием? Ведь Symfony позволяет это сделать. Что будет тогда со slug-ами, которые пишутся в поле с уникальным ключом в базе данных?

Как я говорил выше, Doctrine Sluggable behavior extension берет на себя ответственность по построению уникальных slug-ов.

Для примера я создал три раза подряд продукт с одним и тем же именем: „Что-то осмысленное“. Автоматически сгенерированные slug-и получились такими:

  • chto-to-osmyslennoe
  • chto-to-osmyslennoe-1
  • chto-to-osmyslennoe-2


Если этот вариант не нравится, то можно для поля slug указать генерацию на основе не одного поля, а двух. Пример подобной аннотации для поля slug:

@Gedmo\Slug(fields={"name", "publishDate"})

Трижды создаем продукт с промежутком в одну минуту и получаем slug-и:

  • chto-to-osmyslennoe-2015-05-05-04-04
  • chto-to-osmyslennoe-2015-05-05-04-05
  • chto-to-osmyslennoe-2015-05-05-04-06


Если и это не нравится, то придумываем свой вариант и делимся в комментариях

Напоследок

Мы добились нашей цели:

  • slug генерируется автоматически при сохранении сущности
  • маршруты работают с учетом slug вместо id-шника
  • Поле slug в базе данных обладает уникальным ключом, что позволяет нивелировать тормоза при выборке продуктов по этому полю

Пора идти закупаться пивом и думать как это все воплотить в большом проекте. Если я был вам полезен, значит я рад быть вам полезен.

Архив с Symfony проектом, созданным в процессе написания статьи прикладываю тут.

Кстати, 3d картинку рендерил сам специально для этой статьи. Мне она понравилась, да и сил много не отняла.

Всем хороших маршрутов!
Tags:
Hubs:
+6
Comments 19
Comments Comments 19

Articles