Вещь очень крутая, даже если не используется явно, кмк, в любой движок сайта должна закладываться такая фича. Чтобы любой элемент сайта имел историю изменений, как на ЛОРе: любой пост можно редактировать и всё сохраняется. Да чего там, взгляните на опеннет: даже там любой анонимус может исправить новость! Ящитаю это «маст хэв» фичей.
Мне захотелось поиграться и вместо банальных post_title, post_content реализовать более динамичное содержимое SQL-таблиц, в которых хранится вся история изменений, будь то комментариев, постов и т.д.
Хочу поделиться идеей реализации и спросить ваших советов, о мудрецы всея ЛОРа! Спасибо заранее. :)
Значит, ключевая табличка blog_posts — тут храним посты в ЖЖ, всё как обычно. Однако, как вы могли заметить, здесь отсутствуют привычные нам post_title, post_content и прочие «как у всех» поля, потому что в blog_posts будут хранится лишь мета-данные о постах, вроде, «доступен ли пост для чтения» и всё в таком роде. в post_object можно хранить serialized-массив с такими мета-данными. Лишние поля не нужны.
CREATE TABLE 'blog_posts'
(
'post_id' INTEGER PRIMARY KEY,
'post_object' TEXT
);
В табличке revision_posts хранится история изменений всех постов в ЖЖ. Привязка по post_id, затем id самой ревизии, далее отсылка на text_id — сам текст тоже храним в отдельной табличке, комментарий для описания коммита, и информация об авторе, конечно же, который создал этот «коммит», внеся изменения.
CREATE TABLE 'revision_posts'
(
'post_id' INTEGER,
'id' INTEGER PRIMARY KEY,
'date' DATETIME DEFAULT CURRENT_TIMESTAMP,
'text_id' INTEGER,
'text_comment' NVARCHAR(255),
'text_length' INTEGER DEFAULT 0,
'author_id' INTEGER,
'author_object' NVARCHAR(255),
'author_ip' NVARCHAR(45),
'author_agent' NVARCHAR(255)
);
Отдельно от всех мета-данных хранится уже сам текст поста в ЖЖ, каждая новая ревизия ссылается на новый изменённый текст, и старый текст никуда не девается. В поле text хранится сырой текст, который пользователь создал, text_filtered предназначен для хранения HTML-варианта, образованного из обработки сырого text, а text_flags это какие-нибудь опции, например, можно хранить сжатый gzip-текст, и указывать в text_flags что он был пожат.
CREATE TABLE 'text_posts'
(
'text_id' INTEGER PRIMARY KEY,
'text' TEXT,
'text_filtered' TEXT,
'text_flags' NVARCHAR(255)
);
Как вы знаете, пост в ЖЖ это не только сам текст, это ещё и заголовок, а ещё у поста есть тэги и прочая-прочая-прочая. Где это?
Решение простое — храним вместе с текстом, здесь же.
Как? В формате HTTP-заголовков, лол. :)
Date: дата создания
Tags: тэг.
ещё один тэг, на новой строке.
HTTP разрешает переносы строк,
если ставить таб в начале.
Title: Заголовок поста
После пустой строки идёт содержание поста.
Храним целиком все дополнительные данные о посте вместе с его текстом, преобразуя эти данные в формат HTTP-заголовков!
Почему мне так захотелось? А прост. Для простоты измерения изменений.
1) HTTP-заголовки имеют простой формат, понятный человеку, любой дурак сможет отредактировать сырой текст поста, не говоря уже о том, что будет веб-интерфейс.
2) Когда мы захотим сравнить две ревизии, два коммита, — мы сравним не только текст поста, а ещё и все мета-данные! Вдруг, изменился заголовок, были добавлены/удалены тэги, и т.п.
Все данные в одном месте — при сравнении ревизий все изменения будут выявлены.
Опционально, разумеется, можно создать поле post_title в табличке blog_posts и дублировать всегда актуальный заголовок там, просто для удобства обращения к информации, потому что он нам будет часто нужен, например.
Тэги тоже дублировать в другое место, чтобы сделать более эффективную выборку по тэгам. Это понятно.
Но суть в том, что храня все данные разом — их история изменений будет очень наглядной! Вот в чём фишка предлагаемого мною формата данных.
Ну так вот, значит, для записи у нас есть данные $_POST['title'], $_POST['content'], $_POST['tags'], угу?
Нужно часть данных сохранить в заголовках, а текст оставить «как есть».
$headers = array(
'Date' => date(DATE_RFC2822),
'Tags' => http_header_tabbed($_POST['tags']),
'Title' => http_header_tabbed($_POST['title'])
);
$content = $_POST['content'];
$text = http_headers_from_array($headers) . $content;
Готовый $text сохраняем в БД, всё просто.
Я вам своего кода принёс, братишки!
Функция http_header_tabbed ($multiline_text)
исправляет переносы строк для HTTP-заголовков, расставляя в начале каждой новой строки TAB.
Функция http_headers_to_array ($raw_headers)
преобразует сырой текст HTTP-заголовков в массив данных.
Функция http_headers_from_array ($php_array)
соответственно наоборот (не тестировал, юзайте на страх и риск).
<?php
function http_headers_to_array($raw_headers) {
if ($pos = strpos($raw_headers, "\r\n\r\n")) {
$raw_headers = substr($raw_headers, 0, $pos + 4);
}
$headers = array();
$header = '';
foreach(explode("\r\n", $raw_headers) as $i => $line) {
$key = strstr($line, ':', true);
$value = substr(strstr($line, ':'), 2);
if (isset($header) && substr($line, 0, 1) == "\t") {
$headers[$header] .= "\r\n\t" . trim($line);
}
elseif (isset($value)) {
$header = $key;
if (isset($headers[$key])) {
if (is_array($headers[$key])) {
$headers[$key] = array_merge($headers[$key], array($value));
}
else {
$headers[$key] = array_merge(array($headers[$key]), array($value));
}
}
else {
$headers[$key] = $value;
}
}
else {
$headers[0] = $key;
}
}
return $headers;
}
function http_headers_from_array($php_array, $force_header = null) {
$headers = '';
foreach ($php_array as $key => $value) {
if (isset($force_header)) {
$key = $force_header;
}
if (is_array($value)) {
$headers .= substr(http_headers_from_array($value, $key), -2);
}
else {
$headers .= ucwords(trim($key, ':'), '-') . ': ';
$headers .= http_header_tabbed($value) . "\r\n";
}
}
return $headers . "\r\n";
}
function http_header_tabbed($multiline_text) {
return str_replace(array("\r\n", "\r", "\n"), "\r\n\t", $multiline_text);
}
?>
Таким образом, мы преобразовали текст поста и все мета-данные к нему, типа заголовка, тэгов, даты — в удобный читаемый формат, который очень наглядно сравнивать!
Теперь самое интересное.
Вот имеем мы все эти данные, разбросанные по трём разным таблицам, надо это дело как-то склеить, да?
В SQL я лох, чего скрывать, все это знают, поэтому написал такой стрёмный SQL-запрос, чтобы данные склеивались, и если вы поможете его оптимизировать — буду благодарен!
-- нам нужен пост
SELECT * FROM blog_posts
-- берём самую первую ревизию поста
INNER JOIN revision_posts AS r_init ON
(
r_init.post_id = blog_posts.post_id AND r_init.id =
(
SELECT id FROM revision_posts WHERE post_id = blog_posts.post_id ORDER BY id ASC LIMIT 1
)
)
-- и берём самую последнюю ревизию поста
INNER JOIN revision_posts AS r_last ON
(
r_last.post_id = blog_posts.post_id AND r_last.id =
(
SELECT id FROM revision_posts WHERE post_id = blog_posts.post_id ORDER BY id DESC LIMIT 1
)
)
-- берём самый последний текст поста, это последняя ревизия
INNER JOIN text_posts ON text_posts.text_id = r_last.text_id
-- автор поста тот, кто создал первую ревизию
LEFT JOIN user ON user.user_id = r_init.author_id
-- ищем такой-то пост
WHERE post_id = :post_id
Такие дела.
Дабы не быть многословным... Я уже.
Я уже переделал свой ЖЖ под новый формат данных, который описал.
blog_posts, revision_posts, text_posts, text_posts изнутри
Блог, все комментарии в нём уже хранятся, добавляются и выводятся с учётом истории изменений! Дело тривиальное теперь, прикрутить функционал, нарисовать дополнительный интерфейс для редактирования всего и вся любыми анонимусам. И будет совсем как на ЛОРе! Так вот.
Очень хочется услышать ваших рекомендаций. Вдоль не предлагать.