Страница PHP с системой тегов: варианты реализации

Раздел: Веб-программирование на PHP -> Метки

Основные подходы к работе с тегами

Эффективное решение с реляционной базой данных (MySQL)

Как организовать связь многие-ко-многим между постами и тегами?

Самым надёжным и гибким способом является использование трёх таблиц: posts, tags и post_tags (промежуточная). Это позволяет избежать дублирования данных, обеспечивает целостность ссылок и быстрый поиск.


-- Таблица постов
CREATE TABLE posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Таблица тегов
CREATE TABLE tags (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL UNIQUE
);

-- Связующая таблица
CREATE TABLE post_tags (
    post_id INT NOT NULL,
    tag_id INT NOT NULL,
    PRIMARY KEY (post_id, tag_id),
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
  

Page php tag (страница php с тегами)

Получение постов по тегу (например, тег 'php'):


SELECT p.* FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
WHERE t.name = 'php';
  

При добавлении тегов к посту следует сначала вставлять тег в таблицу tags (если его ещё нет), затем создавать связь. Для защиты от SQL-инъекций обязательно использовать подготовленные выражения (PDO).

Типичные ошибки:

  • Забывают про уникальность тегов – дублируют записи. Решение: добавить UNIQUE на поле name.
  • Не используют транзакции при массовом добавлении тегов – может возникнуть частичное обновление. Рекомендуется BEGIN/COMMIT.
  • Прямая конкатенация в SQL – уязвимость для инъекций. Всегда использовать PDO с параметрами.

Вариант: хранение тегов в JSON-поле одной таблицы

Как упростить структуру базы данных, сохраняя список тегов прямо в записи поста?

В MySQL (начиная с версии 5.7) можно использовать тип JSON. Это сокращает количество таблиц, но усложняет поиск и индексацию.


CREATE TABLE posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    tags JSON,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Вставка поста с тегами
INSERT INTO posts (title, tags) VALUES (
    'Основы PHP',
    '["php", "программирование", "веб"]'
);
  

Поиск постов, содержащих тег 'php':


SELECT * FROM posts WHERE JSON_CONTAINS(tags, '"php"');
  

Или с использованием JSON_TABLE для более сложных операций.

Проблемы и ограничения:

  • Отсутствие возможности простого индексирования – запросы с JSON_CONTAINS медленнее, чем JOIN по внешнему ключу.
  • Сложность обновления отдельного тега (например, переименование «php» в «PHP» потребует прохода по всем записям).
  • Невозможность наложения ограничений на уровне БД (например, уникальность тега).

Этот вариант подходит для небольших проектов, где скорость записи важнее скорости поиска.

Вариант: хранение тегов в файле (JSON или XML)

Как реализовать систему тегов без использования базы данных?

Например, все посты можно хранить в одном JSON-файле, а теги внутри каждого объекта. Или завести отдельный файл tags.json со списком идентификаторов постов для каждого тега.

Пример структуры posts.json:


[
  {
    "id": 1,
    "title": "Основы PHP",
    "tags": ["php", "программирование"]
  },
  {
    "id": 2,
    "title": "Работа с MySQL",
    "tags": ["mysql", "базы данных"]
  }
]
  

Поиск постов по тегу сводится к фильтрации массива в PHP:


$data = json_decode(file_get_contents('posts.json'), true);
$tag = 'php';
$filtered = array_filter($data, function($post) use ($tag) {
    return in_array($tag, $post['tags']);
});
  

Типичные проблемы:

  • Производительность – при большом количестве постов чтение всего файла в память становится узким местом.
  • Конкурентный доступ – при одновременной записи возможна потеря данных. Требуется блокировка файла (flock).
  • Отсутствие индексов – каждый запрос выполняется полным перебором.

Такой подход применим для прототипов или проектов с очень малым объёмом данных.

Вариант: использование NoSQL (MongoDB)

Как работать с тегами в документоориентированной базе данных?

В MongoDB каждый пост – документ, поле tags – массив строк. Поиск по тегу выполняется через специализированный индекс.


// Подключение (MongoDB PHP Library)
$manager = new MongoDB\Driver\Manager('mongodb://localhost:27017');

// Создание коллекции и вставка
$bulk = new MongoDB\Driver\BulkWrite;
$bulk->insert([
    'title' => 'Основы PHP',
    'tags' => ['php', 'программирование'],
    'created_at' => new MongoDB\BSON\UTCDateTime()
]);
$manager->executeBulkWrite('blog.posts', $bulk);

// Поиск по тегу 'php'
$filter = ['tags' => 'php'];
$query = new MongoDB\Driver\Query($filter);
$cursor = $manager->executeQuery('blog.posts', $query);
  

MongoDB автоматически создаёт индекс на массив, что обеспечивает высокую скорость поиска.

Возможные сложности:

  • Необходимость установки и настройки отдельного сервера.
  • Отсутствие транзакций (в версиях до 4.0) – может быть критично для сложных операций.
  • Отсутствие строгой схемы – требует внимательной валидации на уровне приложения.

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

Расширенные примеры работы с тегами

Ниже приведены сценарии, которые можно адаптировать под конкретный проект.

Пример 1: Постраничная навигация по тегам (MySQL)

Показ 10 постов на странице для заданного тега.

Пример

// Параметры
$tagName = $_GET['tag'] ?? '';
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 10;
$offset = ($page - 1) * $perPage;

$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// Получение постов
$stmt = $pdo->prepare('
    SELECT p.id, p.title, p.created_at
    FROM posts p
    JOIN post_tags pt ON p.id = pt.post_id
    JOIN tags t ON pt.tag_id = t.id
    WHERE t.name = :tag
    ORDER BY p.created_at DESC
    LIMIT :limit OFFSET :offset
');
$stmt->bindValue(':tag', $tagName, PDO::PARAM_STR);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);

// Подсчёт общего количества постов для этого тега
$countStmt = $pdo->prepare('
    SELECT COUNT(*)
    FROM posts p
    JOIN post_tags pt ON p.id = pt.post_id
    JOIN tags t ON pt.tag_id = t.id
    WHERE t.name = :tag
');
$countStmt->execute([':tag' => $tagName]);
$totalPosts = $countStmt->fetchColumn();
$totalPages = ceil($totalPosts / $perPage);

Результат: переменная $posts содержит массив постов для текущей страницы, а $totalPages позволяет построить пагинацию.

Пример 2: Облако тегов с частотой использования

Вывод списка тегов с указанием количества постов и размером шрифта пропорционально частоте.

Пример

$pdo = new PDO(/* ... */);

// Получаем теги с количеством постов
$stmt = $pdo->query('
    SELECT t.name, COUNT(pt.post_id) AS cnt
    FROM tags t
    JOIN post_tags pt ON t.id = pt.tag_id
    GROUP BY t.id
    ORDER BY cnt DESC
');
$tags = $stmt->fetchAll(PDO::FETCH_ASSOC);

// Определяем min и max для масштабирования
$min = min(array_column($tags, 'cnt'));
$max = max(array_column($tags, 'cnt'));

// Генерация HTML
$html = '
'; foreach ($tags as $tag) { $size = $min == $max ? 1 : ($tag['cnt'] - $min) / ($max - $min); // от 0 до 1 $fontSize = 0.8 + $size * 1.5; // от 0.8em до 2.3em $html .= sprintf( '%s (%d)', htmlspecialchars($tag['name']), $fontSize, htmlspecialchars($tag['name']), $tag['cnt'] ); } $html .= '
'; echo $html;

Пример 3: Редактор тегов с автодополнением (AJAX)

При вводе в поле input отправляется запрос на PHP-скрипт, который возвращает подсказки.

PHP-скрипт (autocomplete.php):

Пример

header('Content-Type: application/json');
$pdo = new PDO(/* ... */);
$query = $_GET['q'] ?? '';
$stmt = $pdo->prepare('SELECT name FROM tags WHERE name LIKE :q LIMIT 10');
$stmt->execute([':q' => '%' . $query . '%']);
$tags = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode($tags);

Результат (JSON):

["php", "php7", "phpmailer"]

На клиенте этот массив используется для отображения выпадающего списка.

Пример 4: Переименование тега с обновлением всех связей (MySQL с транзакцией)

Пример

$oldName = 'php';
$newName = 'PHP';

try {
    $pdo->beginTransaction();

    // Обновление названия в таблице tags
    $stmt = $pdo->prepare('UPDATE tags SET name = :new WHERE name = :old');
    $stmt->execute([':new' => $newName, ':old' => $oldName]);

    // Если тег с новым именем уже существовал, то UPDATE не сработает (UNIQUE).
    // В этом случае нужно удалить старый тег и переназначить связи.
    if ($stmt->rowCount() == 0) {
        // Получаем ID нового тега
        $newTagId = $pdo->query("SELECT id FROM tags WHERE name = '$newName'")->fetchColumn();
        $oldTagId = $pdo->query("SELECT id FROM tags WHERE name = '$oldName'")->fetchColumn();
        // Обновляем связи
        $pdo->prepare('UPDATE IGNORE post_tags SET tag_id = :new WHERE tag_id = :old')->execute([':new' => $newTagId, ':old' => $oldTagId]);
        // Удаляем дубликаты, которые могли появиться из-за IGNORE
        // (можно использовать DELETE с подзапросом)
        $pdo->exec("DELETE t1 FROM post_tags t1, post_tags t2 WHERE t1.post_id = t2.post_id AND t1.tag_id = t2.tag_id AND t1.post_id > t2.post_id");
        // Удаляем старый тег
        $pdo->prepare('DELETE FROM tags WHERE id = :old')->execute([':old' => $oldTagId]);
    }

    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    echo 'Ошибка: ' . $e->getMessage();
}

Пояснение: Транзакция гарантирует атомарность. При конфликте уникальности выполняется слияние тегов.

Страница PHP с тегами - comments

En
Page php tag (php)