Всем доброго времени суток!
Третьего дня мне понадобилось провести блиц вебинар на тему ЧПУ в 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-е на человекопонятный текст.
Разберем пример
Кому и так ясно что-такое ЧПУ — мотаем дальше.
Я зашел на страницу «Мячи для настольного тенниса» и адрес в ее оказался:
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 сегментов, уменьшающих неоднозначность пути:
- rozetka.com.ua/t_balls/donic-elit-1-6-beliy
- rozetka.com.ua/category/t_balls/donic-elit-1-6-beliy
- rozetka.com.ua/category/t_balls/product/donic-elit-1-6-beliy
Здесь:
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 StofDoctrineExtensionsBundleStofDoctrineExtensionsBundle(),
);
// ...
}
// ...
}
Из всего богатства вовлеченных нами в проект расширений 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 GedmoMappingAnnotation as Gedmo;
...
/**
* Product
*
* @ORMTable(name="product")
* @ORMEntity(repositoryClass="AppBundleRepositoryProductRepository")
*/
class Product
{
...
/**
* @var string
*
* @GedmoSlug(fields={"name"})
* @ORMColumn(name="slug", type="string", length=128, nullable=false, unique=true)
*/
private $slug;
...
}
Здесь я указал аннотацией @GedmoSlug(fields={"name"})
, что я хочу, чтобы slug генерировался на основании поля name. Можно указать несколько полей, чтобы они конкантинировались при генерации. Например, часто вместо с именем сущности указывают дату создания: @GedmoSlug(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 SymfonyComponentHttpFoundationResponse
*/
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 SymfonyComponentHttpFoundationRedirectResponse|SymfonyComponentHttpFoundationResponse
*/
public function editAction(Request $request, string $slug)
{
$product = $this->getDoctrine()
->getRepository('AppBundle:Product')
->findOneBySlug($slug);
$deleteForm = $this->createDeleteForm($product);
$editForm = $this->createForm('AppBundleFormProductType', $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 генерируется автоматически при сохранении сущности
- маршруты работают с учетом slug вместо id-шника
- Поле slug в базе данных обладает уникальным ключом, что позволяет нивелировать тормоза при выборке продуктов по этому полю
Пора идти закупаться пивом и думать как это все воплотить в большом проекте. Если я был вам полезен, значит я рад быть вам полезен.
Архив с Symfony проектом, созданным в процессе написания статьи прикладываю тут.
Кстати, 3d картинку рендерил сам специально для этой статьи. Мне она понравилась, да и сил много не отняла.
Всем хороших маршрутов!
Автор: Владимир Ческидов