Пакет для фреймворка Laravel, который позволяет организовать управление древовидными комментариями. Используется симбиоз двух методов хранения иерархических структур — «Closure Table» и «Adjacency List».

Требования

  • Фреймворк Laravel 5.7 или более новый.

  • Версия PHP минимум 7.3.

Репозиторий

https://github.com/drandin/closure-table-comments

Преимущества

Совместное применение методов «Closure Table» и «Adjacency List» позволяет:

  • Минимизировать количество запросов к базе данных. Для извлечения ветки комментариев достаточно одного запроса.

  • Обеспечить высокую производительность. Запросы на чтение к базе данных простые и скорость выборки узлов иерархии практически не снижаться при увеличении объёма данных.

  • Хранить текст комментариев и иерархическую структуру отдельно друг от друга, в двух таблицах.

  • Контролировать глубину вложенности комментариев. Уровень узлов иерархии всегда известен, нет необходимости получать информацию о предках узла, чтобы определить его.

  • Гарантировать целостность данных иерархии. Добавление новых узлов в иерархию не требует каких-либо изменений в связях созданных ранее узлов.

  • Работать с древовидными структурами без рекурсивных SQL-запросов. Не во всех СУБД есть поддержка рекурсивных запросов, кроме этого, данный механизм недостаточно эффективный и имеет ряд ограничений.

Рассмотрим известные способы хранения и работы с деревьями. Их не так уж и много.

Особенность метода «Adjacency List», заключается в том, что без поддержки рекурсивных запросов СУБД извлечь одним простым запросом произвольную часть иерархии невозможно.

Метод «Path Enumeration», или как его ещё называют «Materialized Path» неэффективен из-за низкой производительности SQL-запросов SELECT, так как предполагается использование оператора LIKE и поиск по шаблонам вида: '%/2/3/4%'. Хранение некого множества в виде строки с разделителями, едва ли уместно в мире реляционных баз данных.

Самый интересный паттерн для работы с древовидными структурами — «Nested Sets». Он вполне может быть использован для хранения иерархических структур, но его реализация сложная и он не обеспечивает гарантию целостности данных. Ошибка при вставке нового элемента в иерархию или при переносе поддерева из одного места в другое может создать большие проблемы. Необходимость пересчёта и изменения значений части левых и правых индексов элементов поддерева при добавлении нового элемента является существенным недостатком «Nested Sets».

Последний метод «Closure Table» мог бы стать лучшим выбором, если бы не одно «но» — отсутствие простого способа построить отсортированное дерево из получаемого запросом плоского списка связей.

Совместное использование «Closure Table» и «Adjacency List» позволяет объединить преимущества этим методов и избавится от недостатков. 

Связи элементов дерева «Closure Table» совмещённого с «Adjacency List»:

Связи элементов дерева «Closure Table» совмещённого с «Adjacency List»
Связи элементов дерева «Closure Table» совмещённого с «Adjacency List»

В методе «Closure Table» каждый узел иерархии хранит ссылки на всех своих предков и на самого себя. 

«Adjacency List» предполагает обязательное хранение информации о смежных узлах дерева и опционально уровень каждого узла.

Объединив специфику хранения информации этих двух методов, мы получим все необходимые данные для того, чтобы извлекать одним запросом требуемую часть иерархии и строить отсортированное дерево.

Установка

1. Перейдите в рабочий каталог вашего проекта на Laravel и выполните команду:

composer require drandin/closure-table-comments

2. Добавьте в файл конфигурации приложения config/app.php сервис-провайдер. Строку указанную ниже следует внести в массив 'providers'.

\Drandin\ClosureTableComments\ClosureTableServiceProvider::class,

3. Выполните команду, которая скопирует файл конфигурации closure-table-comments.php в каталог config вашего приложения:

php artisan vendor:publish --tag=config

После выполнения данной команды, в файле config/closure-table-comments.php вы можете изменить название базы данных и таблиц. Если это необходимо, то скорректируйте параметры конфигурации, прежде чем двигаться дальше.

4. Если ваши файлы конфигурации кешируется, то выполнить в команду:

php artisan config:cache

5. Теперь создадим таблицы в базе данных. Выполните в консоли команду, которая запустит выполнение миграций:

php artisan migrate

В результате будут созданы 2 таблицы в вашей СУБД.

Использование

Каждый комментарий может относиться к определённому предмету и иметь ссылку на автора, который его написал. 

Например, если требуется организовать обсуждение статьи на новостном сайте, то уникальный целочисленный идентификатор сущности «Статья» следует использовать в качестве указания на принадлежность комментариев к обсуждаемому предмету, а целочисленный идентификатор пользователя будет указывать на автора комментария.

Посмотрите внимательно на модели Comment и StructureTree.

subject_id — целочисленный идентификатор сущности «Статья». Может иметь значение NULL

Если subject_id равняется NULL, то комментарий не будет относиться к какому-либо предмету.

user_id — целочисленный идентификатор сущности «Автор статьи». Может иметь значение NULL

Если user_id равняется NULL, то комментарий не будет принадлежать какому-либо автору. Такие комментарии можно считать анонимными.

1. Создание корневого нового комментария. 

Предположим, у нас есть сущность «Статья» с уникальным номером 5636 и пользователь с уникальным идентификатором 7 решил оставить комментарий к статье.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());

$comment = "Отличная статья. Спасибо за полезный материал.";
    
$id = $commentator
        ->setSubjectId(5636)
        ->addCommentToRoot($comment, 7);

В базе данных будет создан комментарий с уникальным идентификатором $id, он будет принадлежать статье с кодом 5636. Автором комментария будет пользователь с идентификатором 7.

2. Ответ на комментарий, который был написан ранее. 

Предположим, у нас есть сущность «Статья» с уникальным номером 5636 и пользователь с уникальным идентификатором 43 решил ответить на ранее написанный другим пользователем комментарий к статье.

Для этого нам нужно знать идентификатор комментария (или уникальный номер узла в древовидной иерархии комментариев Node), на который пользователь хочет написать ответ. Предположим, что уникальный идентификатор комментария равен 1.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$comment = "А вот и нет, в статье есть ошибки.";
  
$id = $commentator
       ->setSubjectId(5636)
       ->replyToComment(1, $comment, 43);

В базе данных будет создан новый комментарий с уникальным идентификатором $id, он будет принадлежать статье с идентификатором 5636. Автором комментария будет пользователь с идентификатором 43.

Здесь важно то, что новый добавленный комментарий будет связан с родительским комментарием (с идентификатором равным 1). Кроме связи, уровень (level) нового комментария будет на единицу больше, чем уровень родительского комментария.

3. Редактирование текста комментария. 

Для внесения изменения в текст комментарий нужно знать его уникальный идентификатор.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$comment = "Отличная статья. Благодарю автора.";
  
$res = $commentator->editComment(1, $comment);

Если изменить комментарий удалось, то $res будет иметь значение true.

4. Проверка существования комментария. 

Вы можете узнать существует ли комментарий (узел в иерархии) по его уникальному идентификатору.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$res = $commentator->has(2);

Если комментарий с уникальным идентификатором 2 в древовидной структуре есть, то переменная $res будет равняться true.

5. Получение комментария (узла в иерархии) по уникальному идентификатору. 

Предположим, что мы хотим получить объект Node по уникальному идентификатору, который равен 2.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$node = $commentator->getNode(2);

В случае, если комментарий с уникальным идентификатором 2 существует, метод getNode вернёт объект Node. Объект Node будет содержать информацию об узле иерархии комментариев.

6. Получение отсортированной ветки комментариев.

Предположим у нас есть статья с уникальным идентификатором 5636, к этой статье разные пользователи написали комментарии. Некоторые комментаторы начали дискуссию между собой. То есть, у нас образовалась древовидная структура комментариев.

Но дискуссия возможна только, если мы сможем корректно визуализировать структуру комментариев. Иначе пользователи просто не смогут общаться между собой. Для этого необходимо сформировать список отсортированных комментариев. При этом, в отсортированном списке узлов должен быть указан уровнь каждого узла, а так же идентификатор узла родителя.

Для решения данной задачи следует использовать метод getTreeBranch.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$nodes = $commentator
             ->setSubjectId(5636)
             ->getTreeBranch();

В результате мы получим коллекцию объектов Node.

Если нужно получить узлы иерархии комментариев начиная с узла с определённым идентификатором, то в метод getTreeBranch следует передать значение этого идентификатора.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$nodes = $commentator
             ->setSubjectId(5636)
             ->getTreeBranch(2);

Мы получим коллекцию объектов всех узлов иерархии начиная с узла 2.

7. Получение массива древовидной иерархии. 

Получить массив дерева комментариев можно при помощи метода getTreeBranchArray.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$tree = $commentator
          ->setSubjectId(5636)
          ->getTreeBranchArray();

Если необходимо получить ветку комментариев, которая должна начинаться с определённого узла, то в метод getTreeBranchArray следует передать идентификатор этого узла иерархии.

8. Получение идентификаторов ветки комментариев. 

Предположим, мы хотим получить массив уникальных идентификаторов всех узлов ветки комментариев начиная с узла с идентификатором 23.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$ids = $commentator->getBranchIds(23);

В результате мы получим массив $ids содержащий идентификаторы узлов ветки комментариев.

9.Получение уровня узла по уникальному идентификатору. 

Предположим, нам нужно узнать уровень узла 23 в иерархии, но извлекать объект Node методом getNode мы не хотим, так как нам нужна лишь информация об уровне. Чтобы это сделать, следует воспользоваться методом getLevel.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$level = $commentator->getLevel(23);

10. Удаление узла иерархии (листа) или ветки древовидной иерархии комментариев. 

Если нам нужно удалить ветку комментариев или только один последний комментарий в иерархии (лист в дереве комментариев), то это можно сделать при помощи метода delete.

Метод delete получает уникальный идентификатор узла и удаляет все комментарии, начиная с узла, идентификатор которого был передан этому методу.

В примере ниже мы удаляем узел 64 и всех его потомков, если они есть.

use Drandin\ClosureTableComments\ClosureTableService;
use Drandin\ClosureTableComments\Commentator;

$commentator = new Commentator(new ClosureTableService());
  
$res = $commentator->delete(64);

Будьте осторожны, удалённые комментарии восстановить невозможно.

Критика «Closure Table» 

Шаблон «Closure Table» часто критикуют за то, что необходимо хранить в базе данных связи каждого элемента дерева со всеми его предками, а так же ссылку каждого элемента на самого себя. Чем глубже в иерархии располагается элемент, тем больше записей в таблице необходимо сделать. Очевидно, что добавление новых элементов в конец глубокой древовидной иерархии будет менее эффективным, чем вставка элементов вблизи корня дерева.

Кроме этого, стоит отметить, что для хранения деревьев метод «Closure Table» требует больше места в базе данных, чем любой другой метод.

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

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

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

Объём дискового пространства, который может потребовать структура дерева едва ли стоит учитывать, так как другие потребители этого ресурса намного более значимые, например логирование посетителей сайта.

Другие публикации и полезные ссылки на данную тему

  1. Первая реализация совместного использования методов «Closure Table» и «Adjacency List» https://habr.com/ru/post/263629/. 2015 год.

  2. Обсуждение вопросов сортировки деревьев на «Stack Overflow». MySQL Closure Table hierarchical как вытащить информацию в правильном порядке? http://stackoverflow.com/questions/8252323/mysql-closure-table-hierarchical-database-how-to-pull-information-out-in-the-c, Какой наиболее эффективный способ преобразовать плоскую таблицы на дерево? http://stackoverflow.com/questions/192220/what-is-the-most-efficient-elegant-way-to-parse-a-flat-table-into-a-tree.

  3. Презентация Билла Карвина (Bill Karwin). http://www.slideshare.net/billkarwin/models-for-hierarchical-data.

  4. Хранение деревьев в базе данных. Часть первая, теоретическая https://habr.com/ru/post/193166/. 2013 год.