В данной статье мы рассмотрим один из возможных подходов к генерации полного пути на раздел, у которого может быть неограниченная вложенность в другие разделы, а также быстрое получение нужного раздела по заданному пути.
Представим, что мы программируем интернет-магазин, в котором должно быть дерево различных разделов, а также должны быть "приятные" ссылки на разделы, которые бы включали все подразделы. Пример:
Самый очевидный вариант — это создать связь на родителя через атрибут
Также, у нашей модели имеется атрибут
Чтобы получить ссылку на раздел, нужно вытащить всех его родителей последовательно. Функция генерации URL выглядит примерно так:
Чем больше раздел имеет предков, тем больше выполнится запросов в базу. Но это только часть проблемы. Как сформировать маршрут до раздела? Попробуем так:
Скормим браузеру ссылку
Этот маршрут уже сработает, но если в URL добавить еще один подраздел, то ничего работать не будет. И проблема в том, что количество таких подразделов у нас не ограничено.
Далее, чтобы вытащить нужный раздел из БД, необходимо сначала найти раздел с идентификатором
Сильно сократить количество запросов нам поможет расширение для laravel kalnoy/nestedset. Оно призвано упростить работу с деревьями.
Установка очень проста. Для начала нужно установить расширение через composer:
Модели понадобится два дополнительных атрибута, которые необходимо добавить в новой миграции:
Теперь только нужно удалить старые отношения
Однако, значения
Данный код "починит" дерево на основе атрибута
Процесс генерации URL выглядит так:
Намного проще, правда? Не важно сколько потомков у данного раздела, они все будут получены за один запрос. А вот с маршрутами не все так просто. По-прежнему не получится получить цепочку разделов за один запрос.
Задача №1. Как задать маршрут до раздела с указанием всех его предков в ссылке?
Задача №2. Как получить весь путь до нужного раздела за один запрос?
Ответ на первую задачу: использовать весь путь как параметр маршрута.
Мы просто указываем, что параметр
Теперь в контроллере на входе получаем только один параметр, но можем его разбить на все подразделы:
Однако, это не упростило задачу с получением указанного в ссылке раздела.
Итак, как оптимизировать этот процесс? Хранить полный путь для каждого раздела в БД.
Допустим, имеется такое простое дерево:
Данным разделам будут соответствовать следующие пути:
Тогда нужную категорию можно получить очень просто:
Теперь сохраняем в БД то, что до этого генерировали для ссылки, а генерация ссылки теперь значительно упрощается:
Если присмотреться к списку путей в примере, то можно заметить, что путь для каждой модели это
Остается следующая проблема. Когда обновляется заглушка одного раздела, либо раздел меняет родителя, должны обновиться ссылки всех его потомков. Алгоритм простой: получить всех потомков и сгенерировать для них новый путь. Ниже приведен метод обновления потомков:
Рассмотрим более подробно.
В первой строчке получаем всех потомков (за один запрос).
Вторая строчка выглядит немного странно. Смысл ее в том, что она заполняет отношение
Ну и в конце проход по всем потомкам и обновление их путей.
Осталось только определиться, когда вызывать данный метод. Для этого отлично подходят события:
Преимущества такого подхода:
Недостатки:
По сути, преимущества очень сильно перевешивают недостатки в виду того, что генерировать ссылки и получать разделы нужно гораздо чаще, чем обновлять заглушки; а перерасход пространства путями мизерный.
Рассмотрим подходы к генерации ссылок на товары, которые включали бы в себя путь к разделу. Например:
Товар, как и раздел, имеет заглушку, которая может быть указана вручную, либо сгенерирована на основе названия. Важно, что эта заглушка должна быть уникальна внутри раздела, чтобы не возникало конфликтов. Лучше всего в БД создать уникальный индекс
Попробуем самый простой вариант и рассмотрим следующие маршруты:
Первый маршрут должен быть уже знаком — это маршрут вывода раздела. Второй маршрут — это практически то же самое, только в конец добавлен еще один параметр, который должен указывать на конкретный товар в данном разделе. Если попробовать ввести в строку браузера выше приведенный пример, то получим следующее:
Сработал первый маршрут; не совсем то, что ожидалось получить. Все потому, что первый маршрут будет срабатывать для любой строки, которая начинается с ключевого слова
Отлично! Это уже лучше, но это не все. Попробуем такой URL:
Теперь срабатывает только маршрут до товара. Необходимо однозначно отделить заглушку товара от заглушки раздела. Для этого можно использовать какой-нибудь префикс/постфикс. Например, добавлять в конец или в начало заглушки товара его числовой идентификатор:
Осталось только добавить ограничение на параметр
В этом случае генерация заглушки товара выглядит так:
Генерация ссылки:
Получение товара в контроллере:
Здесь, правда, возникает условие: заглушки разделов не должны начинаться с числа. Иначе будет срабатывать маршрут до товара, вместо маршрута до раздела.
Можно использовать какой-либо статический префикс, к пример
Код контроллера остается как в предыдущем случае.
Последний вариант — самый сложный. Суть его заключается в том, чтобы хранить ссылки на разделы и товары в отдельной таблице.
Модель выглядит примерно так:
С таким подходом достаточно только одного маршрута:
Модель
Этот подход описан весьма условно, как пища к размышлению. Возможно это даже потянет на отдельное расширение.
В данной статье мы рассмотрели основные возможности расширения
В результате был получен метод, который позволяет генерировать ссылки не совершая запросов в БД, а также получать разделы по ссылке за один запрос.
В качестве альтернативы хранению путей в БД можно использовать кэширование сгенерированных ссылок. Тогда отпадает необходимость обновлять ссылки и достаточно обнулить кэш.
Представим, что мы программируем интернет-магазин, в котором должно быть дерево различных разделов, а также должны быть "приятные" ссылки на разделы, которые бы включали все подразделы. Пример:
http://example.com/catalog/category/sub-category
.Разделы
Самый очевидный вариант — это создать связь на родителя через атрибут
parent_id
и отношение parent
.class Category extends Model
{
public function parent()
{
return $this->belongsTo(self::class);
}
}
Также, у нашей модели имеется атрибут
slug
— заглушка, которая отражает раздел в URL. Она может быть сгенерирована из названия, либо указана пользователем вручную. Самое главное, заглушка должна проходить правило валидации alphadash
(то есть состоять из букв, цифр и знаков -
, _
), а также быть уникальной внутри родительского раздела. Для последнего достаточно создать уникальный индекс в БД (parent_id, slug)
.Чтобы получить ссылку на раздел, нужно вытащить всех его родителей последовательно. Функция генерации URL выглядит примерно так:
public function getUrl()
{
$url = $this->slug;
$category = $this;
while ($category = $category->parent) {
$url = $category->slug.'/'.$url;
}
return 'catalog/'.$url;
}
Чем больше раздел имеет предков, тем больше выполнится запросов в базу. Но это только часть проблемы. Как сформировать маршрут до раздела? Попробуем так:
$router->get('catalog/{category}', ...);
Скормим браузеру ссылку
http://example.com/catalog/category
. Маршрут сработает. Теперь такую ссылку: http://example.com/catalog/category/sub-category
. Маршрут уже не сработает, т.к. обратный слэш является разделителем параметров. Хм, значит добавим еще один параметр и сделаем его необязательным:$router->get('catalog/{category}/{subcategory?}', ...);
Этот маршрут уже сработает, но если в URL добавить еще один подраздел, то ничего работать не будет. И проблема в том, что количество таких подразделов у нас не ограничено.
Далее, чтобы вытащить нужный раздел из БД, необходимо сначала найти раздел с идентификатором
category
, затем, если указан, подраздел subcategory
и т.д. Все это доставляет неудобства и сильнее нагружает сервер, количество запросов пропорционально количеству подразделов.Оптимизация
Сильно сократить количество запросов нам поможет расширение для laravel kalnoy/nestedset. Оно призвано упростить работу с деревьями.
Установка
Установка очень проста. Для начала нужно установить расширение через composer:
composer require kalnoy/nestedset
Модели понадобится два дополнительных атрибута, которые необходимо добавить в новой миграции:
Schema::table('categories', function (Blueprint $table) {
$table->unsignedInteger('_lft');
$table->unsignedInteger('_rgt');
});
Теперь только нужно удалить старые отношения
parent
и children
, если они были заданы, а также добавить trait Kalnoy\Nestedset\NodeTrait
. После обновления наша модель выглядит так:class Category extends Model
{
use Kalnoy\Nestedset\NodeTrait;
}
Однако, значения
_lft
и _rgt
не заполнены, чтобы все заработало, остался последний штрих:Category::fixTree();
Данный код "починит" дерево на основе атрибута
parent_id
.Упрощенная генерация
Процесс генерации URL выглядит так:
public function getUrl()
{
// Получаем заглушки всех предков
$slugs = $this->ancestors()->lists('slug');
// Добавляем заглушку самого раздела
$slugs[] = $this->slug;
// И склеиваем это все
return 'catalog/'.implode('/', $slugs);
}
Намного проще, правда? Не важно сколько потомков у данного раздела, они все будут получены за один запрос. А вот с маршрутами не все так просто. По-прежнему не получится получить цепочку разделов за один запрос.
Маршруты
Задача №1. Как задать маршрут до раздела с указанием всех его предков в ссылке?
Задача №2. Как получить весь путь до нужного раздела за один запрос?
Описание маршрута
Ответ на первую задачу: использовать весь путь как параметр маршрута.
$router->get('catalog/{path}', 'CategoriesController@show')
->where('path', '[a-zA-Z0-9/_-]+');
Мы просто указываем, что параметр
{path}
может содержать не только привычную строку, но и обратный слэш. Таким образом, этот параметр захватывает сразу весь путь, который следует за контрольным словом catalog
.Теперь в контроллере на входе получаем только один параметр, но можем его разбить на все подразделы:
public function show($path)
{
$path = explode('/', $path);
}
Однако, это не упростило задачу с получением указанного в ссылке раздела.
Связка пути с разделом
Итак, как оптимизировать этот процесс? Хранить полный путь для каждого раздела в БД.
Допустим, имеется такое простое дерево:
- Category
-- Sub category
--- Sub sub category
Данным разделам будут соответствовать следующие пути:
- category
-- category/sub-category
--- category/sub-category/sub-sub-category
Тогда нужную категорию можно получить очень просто:
public function show($path)
{
$category = Category::where('path', '=', $path)->firstOrFail();
}
Теперь сохраняем в БД то, что до этого генерировали для ссылки, а генерация ссылки теперь значительно упрощается:
// Генерация пути
public function generatePath()
{
$slugs = $this->ancestors()->lists('slug');
$slugs[] = $this->slug;
$this->path = implode('/', $slugs);
return $this;
}
// Получение ссылки
public function getUrl()
{
return 'catalog/'.$this->path;
}
Если присмотреться к списку путей в примере, то можно заметить, что путь для каждой модели это
путь-родителя/заглушка-модели
. Поэтому генерацию пути можно еще немного оптимизировать:public function generatePath()
{
$slug = $this->slug;
$this->path = $this->isRoot() ? $slug : $this->parent->path.'/'.$slug;
return $this;
}
Остается следующая проблема. Когда обновляется заглушка одного раздела, либо раздел меняет родителя, должны обновиться ссылки всех его потомков. Алгоритм простой: получить всех потомков и сгенерировать для них новый путь. Ниже приведен метод обновления потомков:
public function updateDescendantsPaths()
{
// Получаем всех потомков в древовидном порядке
$descendants = $this->descendants()->defaultOrder()->get();
// Данный метод заполняет отношения parent и children
$descendants->push($this)->linkNodes()->pop();
foreach ($descendants as $model) {
$model->generatePath()->save();
}
}
Рассмотрим более подробно.
В первой строчке получаем всех потомков (за один запрос).
defaultOrder
здесь применяет древовидную сортировку. Смысл ее в том, что в списке каждый раздел будет стоять после своего предка. Алгоритм построения пути использует родителя, поэтому необходимо, чтобы родитель обновил свой путь до того, как будет обновлен путь любого из его потомков.Вторая строчка выглядит немного странно. Смысл ее в том, что она заполняет отношение
parent
, которое используется в алгоритме генерации пути. Если не воспользоваться данной оптимизацией, то каждый вызов generatePath
будет выполнять запрос для получения значения отношения parent
. При этом linkNodes
работает с коллекцией разделов и не делает никаких запросов в БД. Поэтому, чтобы это работало для непосредственных детей текущего раздела, нужно его добавить в коллекцию. Добавляем текущий раздел, связываем все разделы между собой и убираем его.Ну и в конце проход по всем потомкам и обновление их путей.
Осталось только определиться, когда вызывать данный метод. Для этого отлично подходят события:
- Перед сохранением модели, проверяем, изменились ли атрибуты
slug
илиparent_id
. Если изменились, то вызываем методgeneratePath
;
- После того, как модель была успешно сохранена, проверяем, не изменился ли атрибут
path
, и, если изменился, вызываем методupdateDescendantsPaths
.
protected static function boot()
{
static::saving(function (self $model) {
if ($model->isDirty('slug', 'parent_id')) {
$model->generatePath();
}
});
static::saved(function (self $model) {
// Данная переменная нужна для того, чтобы потомки не начали вызывать
// метод, т.к. для них путь также изменится
static $updating = false;
if ( ! $updating && $model->isDirty('path')) {
$updating = true;
$model->updateDescendantsPaths();
$updating = false;
}
});
}
Результаты
Преимущества такого подхода:
- Мгновенная генерация ссылки на раздел
- Быстрое получение раздела по пути
Недостатки:
- Пути хранятся в БД, что несколько увеличивает размер таблицы
- Смена заглушки одного раздела влечет за собой обновление путей всех потомков
По сути, преимущества очень сильно перевешивают недостатки в виду того, что генерировать ссылки и получать разделы нужно гораздо чаще, чем обновлять заглушки; а перерасход пространства путями мизерный.
Товары
Рассмотрим подходы к генерации ссылок на товары, которые включали бы в себя путь к разделу. Например:
http://example.com/catalog/category/sub-catagory/product
. Основная проблема здесь в том, чтобы сформировать правильный маршрут.Товар, как и раздел, имеет заглушку, которая может быть указана вручную, либо сгенерирована на основе названия. Важно, что эта заглушка должна быть уникальна внутри раздела, чтобы не возникало конфликтов. Лучше всего в БД создать уникальный индекс
(category_id, slug)
.Попробуем самый простой вариант и рассмотрим следующие маршруты:
// Маршрут до раздела
$router->get('catalog/{path}', function ($path) {
return 'category = '.$path;
})->where('path', '[a-zA-Z0-9\-/_]+');
// Маршрут до товара
$router->get('catalog/{category}/{product}', function ($category, $product) {
return 'category = '.$category.'<br>product = '.$product;
})->where('category', '[a-zA-Z0-9\-/_]+');
Первый маршрут должен быть уже знаком — это маршрут вывода раздела. Второй маршрут — это практически то же самое, только в конец добавлен еще один параметр, который должен указывать на конкретный товар в данном разделе. Если попробовать ввести в строку браузера выше приведенный пример, то получим следующее:
category = category/sub-category/product
Сработал первый маршрут; не совсем то, что ожидалось получить. Все потому, что первый маршрут будет срабатывать для любой строки, которая начинается с ключевого слова
catalog
. Нужно поменять местами маршруты. Тогда получаем:category = category/sub-category
product = product
Отлично! Это уже лучше, но это не все. Попробуем такой URL:
http://example.com/catalog/category/sub-category
. Получим следующее:category = category
product = sub-category
Теперь срабатывает только маршрут до товара. Необходимо однозначно отделить заглушку товара от заглушки раздела. Для этого можно использовать какой-нибудь префикс/постфикс. Например, добавлять в конец или в начало заглушки товара его числовой идентификатор:
http://example.com/catalog/category/sub-category/123-product
Осталось только добавить ограничение на параметр
{product}
:$router->get(...)->where('product', '[0-9]+-[a-zA-Z0-9_-]+');
В этом случае генерация заглушки товара выглядит так:
$product->slug = $product->id.'-'.str_slug($product->name);
Генерация ссылки:
$url = 'catalog/'.$product->category->path.'/'.$product->slug;
Получение товара в контроллере:
public function show($categoryPath, $productSlug)
{
// Сначала находим раздел по пути
$category = Category::where('path', '=', $categoryPath)->firstOrFail();
// Затем в этом разделе ищем товар с указанной заглушкой
$product = $category->products()
->where('slug', '=', $productSlug)
->firstOrFail();
}
Здесь, правда, возникает условие: заглушки разделов не должны начинаться с числа. Иначе будет срабатывать маршрут до товара, вместо маршрута до раздела.
Можно использовать какой-либо статический префикс, к пример
p-
:http://example.com/catalog/category/sub-category/p-product
$router->get('catalog/{category}/p-{product}', ...);
$product->slug = str_slug($product->name);
$url = 'catalog/'.$product->category->path.'/p-'.$product->slug;
Код контроллера остается как в предыдущем случае.
Последний вариант — самый сложный. Суть его заключается в том, чтобы хранить ссылки на разделы и товары в отдельной таблице.
Модель выглядит примерно так:
class Url extends Model
{
// Полиморфное отношение
public function model()
{
return $this->morphTo();
}
}
С таким подходом достаточно только одного маршрута:
$router->get('catalog/{path}', function ($path) {
$url = Url::findOrFail($path);
// Извлекаем модель используя отношение
$model = $url->model;
if ($model instanceof Product) {
return $this->renderProduct($model);
}
return $this->renderCategory($model);
})
->where('path', '[a-zA-Z0-9\-/_]+');
Модель
Url
имеет полиморфное отношение с другими моделями и хранит полные пути на них. Что это дает:- Не нужно никаких префиксов/постфиксов для товара
- Можно хранить предыдущие версии URL и перенаправлять на новые, т.е. SEO не страдает при смене адреса страницы
- Не обязательно ограничиваться только разделами/товарами, можно хранить любой другой ресурс
Этот подход описан весьма условно, как пища к размышлению. Возможно это даже потянет на отдельное расширение.
Выводы
В данной статье мы рассмотрели основные возможности расширения
kalnoy/nestedset
, а также подходы к формированию ссылок на разделы и товары в случае, когда глубина вложенности разделов не ограничена.В результате был получен метод, который позволяет генерировать ссылки не совершая запросов в БД, а также получать разделы по ссылке за один запрос.
В качестве альтернативы хранению путей в БД можно использовать кэширование сгенерированных ссылок. Тогда отпадает необходимость обновлять ссылки и достаточно обнулить кэш.
Комментарии (9)
shot131
15.03.2016 20:22Непонятно зачем использовать nestedset, если в базе хранится полный url. Получается, что для генерации любого url достаточно знать parent?
lazychaser
16.03.2016 06:42Если меняется заглушка или родитель раздела, то нужно обновлять пути всех его потомков. Здесь как раз вступает nested set. Тем более что этим функционал расширения не ограничивается.
shot131
16.03.2016 09:13Разве нельзя получить всех потомков, независимо от уровня вложенности, по url родителя? Ведь у всех потомков хранится полный путь — это всего один запрос к базе.
lazychaser
16.03.2016 15:04Вы правы, можно, можно даже не получать всех потомков, а просто обновить их путь одним запросом. Но целью статьи было также рассказать, собственно, о моем расширении.
zelenin
небольшой вопросик: а почему модель занимается генерацией урла?
lazychaser
Это было сделано для удобства, чтобы можно было проследить разницу до/после.
zelenin
это вряд ли. скорее признак непонимания структурирования кода + srp.
lazychaser
повышайте свое ЧСВ в другом месте
zelenin
я бы хотел +1 к вашим скиллам добавить в этом месте.