Дерево разделов неограниченной вложенности и URL

  • Tutorial
В данной статье мы рассмотрим один из возможных подходов к генерации полного пути на раздел, у которого может быть неограниченная вложенность в другие разделы, а также быстрое получение нужного раздела по заданному пути.

Представим, что мы программируем интернет-магазин, в котором должно быть дерево различных разделов, а также должны быть "приятные" ссылки на разделы, которые бы включали все подразделы. Пример: 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 работает с коллекцией разделов и не делает никаких запросов в БД. Поэтому, чтобы это работало для непосредственных детей текущего раздела, нужно его добавить в коллекцию. Добавляем текущий раздел, связываем все разделы между собой и убираем его.

Ну и в конце проход по всем потомкам и обновление их путей.

Осталось только определиться, когда вызывать данный метод. Для этого отлично подходят события:

  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, а также подходы к формированию ссылок на разделы и товары в случае, когда глубина вложенности разделов не ограничена.

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

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

Similar posts

Comments 11

    +2
    небольшой вопросик: а почему модель занимается генерацией урла?
      0
      Это было сделано для удобства, чтобы можно было проследить разницу до/после.
        –2
        это вряд ли. скорее признак непонимания структурирования кода + srp.
          +2
          повышайте свое ЧСВ в другом месте
            0
            я бы хотел +1 к вашим скиллам добавить в этом месте.
      0
      Непонятно зачем использовать nestedset, если в базе хранится полный url. Получается, что для генерации любого url достаточно знать parent?
        0
        Если меняется заглушка или родитель раздела, то нужно обновлять пути всех его потомков. Здесь как раз вступает nested set. Тем более что этим функционал расширения не ограничивается.
          0
          Разве нельзя получить всех потомков, независимо от уровня вложенности, по url родителя? Ведь у всех потомков хранится полный путь — это всего один запрос к базе.
            0
            Вы правы, можно, можно даже не получать всех потомков, а просто обновить их путь одним запросом. Но целью статьи было также рассказать, собственно, о моем расширении.
        0
        может кто подскажет как избавиться от
        catalog/ в
        $router->get('catalog/{path}', ...) 
        

        что-бы было что-то похожее на
        $router->get('{path}', ...) 
        

        Для категорий хочу что-бы получилось:
        {category}/{subcategory1}/.../{subcategoryN}
        а для продукта:
        {category}/{subcategory1}/.../{subcategoryN}/{proiduct}

          0
          Добавить маршрут самым последним в списке, при этом запретить использовать «рутовые» заглушки, которые могут использоваться для других маршрутов (типа там contacts и т.п.)

        Only users with full accounts can post comments. Log in, please.