Денормализация данных - это Нормально
Один из часто возникающих вопросов при работе с NoSQL - "Как запросить те или иные данные, также как в SQL?" Это естественный вопрос при переходе из мира реляционных баз данных.
Рассмотрим вопрос на примере Firebase. В Firebase есть два основных способа запросить данные: по пути (by path) и по приоритету (by priority).
Это достаточно ограниченные возможности по сравнению с классическим SQL. Firebase API спроектирован так, чтобы запросы выполнялись гарантированно быстро. Firebase - это масштабируемая real-time база данных и в первую очередь рассчитана на обработку миллионов соединений пользователей без задержек.
Важно понимать это и серьезно отнестись к проектированию структуры данных приложения, то каким образом приложение будет получать доступ к этим данным при реальной работе.
Примитивы
В Firebase есть два мощных и простых способа получения данных:
-
Мы можем запросить данные по адресу. Можно представить себе это как запрос по первичному индексу (
primary index
в терминах SQL,ref.child('/users/{id}')
), грубо это эквивалентноSELECT * FROM users WHERE user_id={id}
. Firebase автоматически сортирует данные по их адресам (location
), и мы можем фильтровать результаты применяяstartAt
,endAt
иlimit
. -
Мы можем запрашивать данные по приоритету (
priority
). Каждой части данных в Firebase может быть присвоен произвольный приоритет, который можно использовать как угодно. Это можно представить как вторичный индекс. Например можно добавлять метки времени (timestamp), а затем запрашивать данные за определенный период:ref.child('users').startAt(new Date('1/1/2000').getTime())
.
Усвоив и поняв эти два похода, можно приступать к проектированию данных приложения.
Структура данных - это важно
Прежде чем писать код приложения, лучше задуматься над структурой данных. Итак два аспекта в проектировании имеют важное значение. Первый - как сделать простыми правила безопасности (Firebase Security Rules), и второй - какие запросы потребуются приложению. Правильно спроектированная структура данных критически важна для элегантного приложения.
Лучше всего понять, как это сделать - изучить на примере. Попробуем сделать приложение похожее на Reddit или Hacker News. Сайт на котором можно размещать ссылки и комментировать их.
Начнем с примера решения данной задачи в SQL-мире, а затем реализуем это с помощью Firebase.
В мире SQL
Если в приложении используется SQL база данных, то вероятно таблица пользователей может выглядеть так:
CREATE TABLE users (
uid int auto_increment, name varchar, bio varchar, PRIMARY KEY (uid)
);
таблица с постами:
CREATE TABLE links (
id int auto_increment, title varchar, href varchar, submitted int,
PRIMARY KEY (id), FOREIGN KEY (submitted) REFERENCES users(uid)
);
и наконец таблица с комментариями:
CREATE TABLE comments (
id int auto_increment, author int, body varchar, link int,
PRIMARY KEY (id), FOREIGN KEY (author) REFERENCES users(uid),
FOREIGN KEY (link) REFERENCES links(id)
);
для вывода информации на главной странице нужно сделать запрос:
SELECT * FROM links ORDER BY id DESC LIMIT 20
для просмотра комментариев:
SELECT * FROM comments WHERE link = {link_id} ORDER BY id DESC
Для просмотра комментариев, которые сделал определенный пользователь, например на странице профиля пользователя:
SELECT * FROM comments WHERE author = {user_id}
обратите внимание, что мы можем получить данные о комментариях двумя разными способами (через
link_id
илиauthor
). То, что в Firebase это так, как в SQL мы заметим сразу.
Конечно вы должны иметь набор запросов INSERT
для добавления данных и кучу кода для валидации поступающей в приложение информации.
В мире Firebase
Если мы попытаемся воспроизвести похожее приложение в Firebase, можно просто повторить структуру SQL версии. На верхнем уровне три ключа users
,links
,comments
.
{
users: {
user1: {
name: "Alice"
},
user2: {
name: "Bob"
}
},
links: {
link1: {
title: "Example",
href: "http://example.org",
submitted: "user1"
}
},
comments: {
comment1: {
link: "link1",
body: "This is awesome!",
author: "user2"
}
}
}
Получение данных для главной страницы достаточно просто. Получаем 20 последних опубликованных ссылок используя limitToLast()
запрос:
var ref = new Firebase("https://awesome.firebaseio-demo.com/links");
ref.limitToLast(20).on("child_added", function(snapshot) {
// Add link to home page.
});
ref.limitToLast(20).on("child_removed", function(snapshot) {
// Remove link from home page.
});
здесь мы уже видим преимущества Firebase. При обработке событий
child_added
иchild_removed
, содержание страницы будет обновляться автоматически в реальном времени, без участия пользователя.
Что будет если нам нужно получить все комментарии связанные с определенной ссылкой? В SQL версии каждый комментарий имел связь со ссылкой и мы могли использовать фильтр WHERE link={link_id}
. Но Firebase не имеет WHERE
. Исходя из нашей структуры мы можем получить доступ к комментарию, только если мы знаем его идентификатор.
В этом и заключается суть "дружелюбной-firebase" структуры данных: иногда нам нужна денормализация наших данных. В данном примере, для возможности возврата списка комментариев к определенной ссылке, мы можем хранить это список непосредственно со ссылкой.
{
links: {
link1: {
title: "Example",
href: "http://example.org",
submitted: "user1",
comments: {
comment1: true
}
}
}
}
Теперь мы можем просто получить список комментариев для любой ссылки и отобразить его:
var commentsRef =
new Firebase("https://awesome.firebaseio-demo.com/comments");
var linkRef =
new Firebase("https://awesome.firebaseio-demo.com/links");
var linkCommentsRef = linkRef.child(LINK_ID).child("comments");
linkCommentsRef.on("child_added", function(snap) {
commentsRef.child(snap.key()).once("value", function() {
// Render the comment on the link page.
));
});
Мы также хотим отображать список комментариев каждого пользователя в его профиле.
Сделаем нечто подобное:
{
users: {
user2: {
name: "Bob",
comments: {
comment1: true
}
}
}
}
Для многих разработчиков дублирование данных может казаться нелогичным. Тем не менее, чтобы построить действительно масштабируемое приложение, денормализация по сути - требование к структуре данных. Мы оптимизируем чтение данных на этапе записи, добавляя некоторые избыточные данные. Дисковое пространство достаточно дешево, в отличие от времени пользователя.
Некоторые соображения
Итак последствия денормализации очевидны. Каждый раз когда создаются некоторые данные, которые нужно связать между собой (как в нашем примере комментарий), нужно гарантировать размещение данных одновременно в нескольких местах:
functon onCommentSubmitted(comment) {
var root = new Firebase("https://awesome.firebaseio-demo.com");
var id = root.child("/comments").push();
id.set(comment, function(err) {
if (!err) {
var name = id.key();
root.child("/links/" + comment.link + "/comments/" + name).set(true);
root.child("/users/" + comment.author + "/comments/" + name).set(true);
}
});
}
для управления асинхронными потоками можно использовать
async.js
илиTameJS
.
Мы также должны подумать о том, как обрабатывать команды удаления и изменения комментариев. Изменение комментария проходит без вопросов: просто установить новое значение для комментария. Для удаления, просто удалить комментарий из comments
. Теперь всякий раз, когда мы будем сталкиваться в приложении с ID несуществующих комментариев, можно сделать предположение, что они были удалены.
function deleteComment(id) {
var url = "https://awesome.firebaseio-demo.com/comments/";
new Firebase(url + id).remove();
}
function editComment(id, comment) {
var url = "https://awesome.firebaseio-demo.com/comments/";
new Firebase(url + id).set(comment);
}
Firebase делает всё возможное, чтобы сделать операции с данными эффективными. Для примера, если мы уже получили контент для комментария для страницы со ссылками, и переходим на страницу профиля пользователя, который оставил этот комментарий, то данные будут повторно запрошены из локального кеша.