Дерево розділів необмеженої вкладеності та 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}/{вкладені категорії?}', ...);

Цей маршрут вже спрацює, але якщо в URL додати ще один підрозділ, то нічого працювати не буде. І проблема в тому, що кількість таких підрозділів у нас не обмежена.

Далі, щоб витягти потрібний розділ з БД, необхідно спочатку знайти розділ з ідентифікатором
category
, потім, якщо вказано, підрозділ
вкладені категорії
і т. д. Все це доставляє незручності і сильніше навантажує сервер, кількість запитів пропорційно кількості підрозділів.

Оптимізація
Сильно скоротити кількість запитів нам допоможе розширення для 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
працює з колекцією розділів і не робить ніяких запитів до БД. Тому, щоб це працювало для безпосередніх дітей поточного розділу, потрібно додати його в колекцію. Додаємо поточний розділ, пов'язуємо всі розділи між собою і прибираємо його.

Ну і в кінці прохід по всім нащадкам і оновлення їх шляхів.

Залишилося тільки визначитися, коли викликати даний метод. Для цього чудово підходять події:

  1. Перед збереженням моделі, перевіряємо, чи змінилися атрибути
    slug
    або
    parent_id
    . Якщо змінилися, то викликаємо метод
    generatePath
    ;

  2. Після того, як модель була успішно збережено, перевіряємо, чи не змінився атрибут
    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
, а також підходи до формування посилань на розділи і товари у разі, коли глибина вкладеності розділів не обмежена.

В результаті був отриманий метод, який дозволяє генерувати посилання не здійснюючи запитів до БД, а також отримувати розділи по ссылке за один запит.

В якості альтернативи зберігання шляхів у БД можна використовувати кешування згенерованих посилань. Тоді відпадає необхідність оновлювати посилання і досить очистити кеш.

Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.