Денормализация данных - это Нормально

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