[Перевод] Firestore и NoSQL — Основы структурирования данных

Инструменты Firebase уже больше десятилетия помогают разработчикам быстрее создавать приложения, начиная с push-уведомлений и аутентификации и заканчивая базой данных Firestore. В этом году на Google I/O было анонсировано, что Firestore теперь поддерживает SQL в форме Data Connect, наконец позволив разработчикам выбирать между NoSQL и SQL.

SQL базы данных имеют определенную структуру со своими правилами целостности, напоминающую электронную таблицу Excel со столбцами и строками. Каждый раз, когда пользователь добавляет новое свойство (столбец), каждые элемент (строка) будет содержать это свойство. Для работы с этим типом баз данных используется язык SQL. Он позволяет получать данные из таблиц, объединять данные из нескольких столбцов и производить другие манипуляции с базой данных, среди которых редактирование, удаление и обновление ее данных.

С другой стороны, NoSQL позволяет нам добавлять любые типы данных в формате JSON. В Firestore есть документы (document), отдельные объекты в нашей базе данных, соответствующие строкам в SQL, и коллекции (collection), которые группируют различные документы, аналогично таблицам в SQL. Однако внутри коллекции могут находиться документы с различными параметрами. Если мы хотим добавить в один документ параметр HungerMeter, а в другой — параметр FavoriteDrink, ничто не помешает нам это сделать.

Отсутствие четких правил целостности базы данных в NoSQL заставляет нас по-другому относиться к управлению данными, что приводит к важной проблеме — если у нас есть отношение между документами из разных коллекций, то как нам его организовать? Например, если у нас есть коллекция programmer и коллекция language, как нам смоделировать структуру programmer с полем favoriteLanguage? Должны ли мы ссылаться на него по ID language, как в SQL, или будет лучше скопировать значение из коллекции language?

В этом по большей части и заключается дискуссия о нормализации и денормализации наших данных в NoSQL базе данных Firestore. Именно об этом мы и поговорим сегодня.

Трудности нормализации в Firebase

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

f22dcc0e476724d527bb37734af63caf.png

Мы знаем, что у каждой игрушки (Toy) будет несколько свойств, включая материал (Material), отображаемый в виджете карточки. Следуя SQL-подобному подходу, мы создаем две коллекции: toys и materials, со свойствами, показанными на следующей диаграмме:

05fd919a526b3202ef12c2cdb81e7f0b.png

В SQL мы создаем отношения между таблицами с помощью внешнего ключа (foreign key). Однако в Firestore у нас нет внешних ключей. Вместо этого в нашем распоряжении есть DocumentReference — тип значения, который мы можем использовать в наших документах, чтобы ссылаться на другие документы.

Что будет, если мы захотим сформировать список всех игрушек?

Когда мы получим все документы из коллекции toys, у нас не будет информации о материале, а будет только список ссылок DocumentReference. Чтобы получить информацию о нем, нам нужно будет использовать get для каждой ссылки, а затем использовать эту информацию для создания объекта Toy, который нужен будет для построения пользовательского интерфейса карточки.

5655c602a1d2a7c44e77cbc4759e04af.png

Если на нашей фабрике производится 1 000 типов игрушек, нам может понадобиться 1 001 чтение из Firebase: одно для получения всех документов из коллекции toys и одно для каждой DocumentReference.

Кроме того, поскольку вся логика запросов и объединение данных находятся в клиентском приложении, мы можем использовать более сложный код на стороне клиента, как показано в следующем фрагменте:

Future> fetchToysAndMaterials() async {
  final firestore = FirebaseFirestore.instance;

  // Шаг 1: Считываем все игрушки
  final toySnapshot = await firestore.collection('toys').get();
  final toyDocs = toySnapshot.docs;

  // Шаг 2: Создаем map с ID игрушек со ссылками на документы материалов
  final toyToMaterialReference = {};
  final toyToMaterial = {};
  for (final doc in toyDocs) {
    toyToMaterialReference[doc.id] = doc["material"] as DocumentReference;
  }

  // Шаг 3: Пакетное чтение материалов
  for (final key in toyToMaterialReference.keys) {
    final materialSnapshot = await toyToMaterialReference[key]!.get();
    toyToMaterial[key] = Material.fromFirebase(materialSnapshot);
  }
  
  // Шаг 4: Создаем объекты Toy с информацией о материалах
  final toys = toyDocs.map((doc) {
    Material material = toyToMaterial[doc.id];
    return Toy.fromFirebase(doc, material);
  }).toList();

  return toys;
}

Мы можем улучшить этот код, проверяя, не имеют ли несколько игрушек одинаковый материал. Для этого мы изменим нашу Map на Map>. Мы также могли бы сформировать запрос с применением оператора where с условием whereIn, чтобы получить все документы с определенным id, если бы мы немного изменили наши документы. Однако whereIn имеет жесткое ограничение на количество документов, которые он может получить, как показано в справочной документации Firebase, что означает, что нам придется иметь дело с пагинацией.

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

Но в один прекрасный день к нам заходит менеджер и поручает вывести в карточке-виджете еще и цвет (Color) игрушки.

c445b0867b66b879cdd314acc3eeba99.png

Следуя такому же подходу, мы создаем новую коллекцию под названием colors, и теперь перед нами предстает следующая диаграмма базы данных:

c840556e201e591feee04f075c5235fd.png

Когда мы будем создавать объект Toy для построения новой карточки-виджета, нам нужно будет получить DocumentReference‘ы из коллекций materials и colors и создать соответствующие ассоциативные массивы для кэширования отношений между ними.

bd2c6ea076cc22ea79a7b496f291dde0.png

Что касается количества чтений, то для списка из 1 000 игрушек в худшем случае нам потребуется 1 чтение для получения всех документов Toy, 1 000 чтений для документов Material и 1 000 чтений для документов Color, в результате чего для создания одного списка потребуется 2 001 чтение. Если мы ограничены в пределах 50 000 бесплатных чтений в день (на момент написания статьи), и этот список будет формироваться больше 25 раз за день, то мы обязательно упремся в этот лимит, и нам придется платить за каждое обновление сверх него.

Мы можем начать отмахиваться от этого, говоря: «Нам никогда не нужно будет показывать пользователю все 1000 элементов; мы будем использовать бесконечный пагинированный список». Тем не менее, у нас остается одна проблема: фильтрация.

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

04827545594fe064d9b267206c3ce33f.png

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

Наконец, что более важно, если в нашем приложении есть разные типы пользователей, мы можем лишить их доступа к определенным свойствам Material и Color, оставив им доступ только к имени и типу. В Firestore невозможно получить только часть документа, мы всегда получаем всю его информацию.

В итоге попытка применить подход SQL к NoSQL Firestore приводит к трем проблемам:

  • Потенциально большее число чтений;

  • Сложности с фильтрацией;

  • Раскрытие всей информации в документах, включая конфиденциальную.

Какие у нас есть альтернативы?

Денормализация данных Firestore

В нашей исходной задаче мы хотели показать пользователю карточку с именем материала и именем цвета. Это запрос, который, как мы знаем, мы будем выполнять часто, так почему бы не объединить эти данные? В этом и заключается концепция денормализации — стратегия моделирования данных путем копирования полей из разных таблиц/коллекций в один объект, чтобы упростить получение и фильтрацию информации.

Вопрос в том, какие данные из Material и Color мы должны добавить в наш документ Toy? Ответ зависит от того, что мы хотим показать или какие действия мы выполняем, поэтому давайте рассмотрим наш пользовательский интерфейс.

1649ac300cd67f8cbacd77a544158392.png

На данном этапе нам нужно показывать пользователю разные карточки без каких-либо подробностей, отображая только имена цвета и материала. Поэтому мы можем добавить оба эти свойства в документ Toy. Мы по-прежнему будем сохранять DocumentReference или, если вам угодно, ID, чтобы быстро идентифицировать его имя.

05cab7e00f25c485922c5b69080fe38c.png

Теперь, когда мы хотим представить список из 1 000 элементов, нам нужно только одно чтение из Firestore, чтобы получить все необходимые данные для создания этой карточки, что значительно упрощает наш код на стороне клиента во Flutter:

Future> fetchToysAndMaterials() async {
  final firestore = FirebaseFirestore.instance;

  // Шаг 1: Считываем все игрушки
  final toySnapshot = await firestore.collection('toys').get();
  final toyDocs = toySnapshot.docs;

  // Шаг 2: Ассоциируем с классом [Toy]
  return toyDocs.map((doc) => Toy.fromFirebase(doc)).toList();
}

Есть ли другие свойства, которые нам следовало бы добавить из Color и Material? Это зависит от двух моментов:

  • Если нам понадобится реализовать возможность отобразить больше подробностей про игрушку, мы, возможно, захотим добавить все общие свойства;

  • Если фильтровать по определенным свойствам, таким как форма материала или значение цвета, мы можем рассмотреть возможность добавления их в документ Toy.

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

Это приводит нас к одной очень важной проблеме: что, если однажды имя материала изменится? Как нам обновить это свойство в Toy? Это один из существенных недостатков использования денормализованных баз данных. Но в нашем распоряжении все-таки есть решение.

Использование функций Firebase для обновления данных

Одно из решений — переложить управление всеми обновлениями базы данных на клиентское приложение. Если пользователь изменяет объект Material, он также обновляет все игрушки. Но это приводит к следующей проблеме:

  • Пользователь, редактирующий Material, может не иметь достаточных прав для доступа к коллекции Toy, поэтому нам придется создать для него специальные правила.

Для этой проблемы есть простое решение. Когда мы хотим предотвратить доступ клиентского приложения к конфиденциальной информации, такой как сторонний API-ключ или секретные данные в нашей базе данных, мы используем Firebase Cloud Functions для выполнения этих действий в облаке. Функции Firebase предоставляют нам конечные точки, похожие на API, которые можно вызывать из клиентского приложения, и возможность реагировать на изменения, например, на изменение документа в коллекции.

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

e3fe4539b6f2517e937ace4553dabbc2.png

В этой статье мы не будем рассматривать создание нового проекта Firebase Functions. Вы можете почитать об этом в руководстве Get Started. Мы перейдем непосредственно к коду на Python.

Сначала мы создадим новую функцию с аннотацией on_document_updated. Это позволяет нам указать, за изменениями в какой коллекции мы следим. Функция получает событие Event[Change[DocumentSnapshot]], что позволяет нам увидеть предыдущий и текущий статус документа.

from firebase_functions.firestore_fn import (
    on_document_updated,
    Event,
    Change,
    DocumentSnapshot,
)
from firebase_admin import initialize_app, credentials, firestore

cred = credentials.Certificate("credentials/service_account.json")
app = initialize_app(cred)
db = firestore.client()

@on_document_updated(document="/materials/{materialId}")
def on_toy_material_update(event: Event[Change[DocumentSnapshot]]) -> None:

Далее мы ищем изменения в нужных нам полях, например, в name:

@on_document_updated(document="/materials/{materialId}")
def on_toy_material_update(event: Event[Change[DocumentSnapshot]]) -> None:
    # Извлекаем обновленные данные из снапшота нового документа
    new_data = event.data.after.to_dict()
    old_data = event.data.before.to_dict()

    # Создаем словарь для хранения обновлений (только если есть изменения)
    updates = {}
    if new_data.get("name") != old_data.get("name"):
        updates["materialName"] = new_data.get("name")

Теперь мы используем ID материала, чтобы заглянуть в коллекцию игрушек, запросить из нее все игрушки, использующие этот материал, и использовать функцию update, чтобы обновить их.

@on_document_updated(document="/materials/{materialId}")
def on_toy_material_update(event: Event[Change[DocumentSnapshot]]) -> None:
    """
    Updates toys that use a specific material when the material is updated.

    Args:
        event (Event[Change[DocumentSnapshot]]): The event triggered by the
        material update.

    Returns:
        None
    """

    # Извлекаем обновленные данные из снапшота нового документа
    new_data = event.data.after.to_dict()
    old_data = event.data.before.to_dict()

    # Создаем словарь для хранения обновлений (только если есть изменения)
    updates = {}
    if new_data.get("name") != old_data.get("name"):
        updates["materialName"] = new_data.get("name")

    material_id = event.data.after.id
    
    # Проверяем, есть ли поля, которые нуждаются в обновлении
    if updates:
        # Формируем запрос в коллекцию игрушек, чтобы найти любую игрушку, которая использует этот материал
        toys_ref = db.collection("toy").where("materialId", "==", material_id)
        toys = toys_ref.get()

        # При необходимости обновляем каждый документ игрушки
        for toy in toys:
            toy_ref = db.collection("toy").document(toy.id)
            toy_ref.update(updates)

        print(f"Updated toys with new material data: {updates}")
    else:
        print("No material attributes were changed; no updates performed.")

Использование словаря для переменной updates позволяет нам проверять дальнейшие изменения свойств, если мы хотим обновить больше полей.

Теперь мы можем загрузить наши функции в Firebase с помощью команды:

firebase deploy --only functions

После загрузки мы увидим их в Firebase Console:

402092b123def530b48800272cd5e41f.png

Мы можем проверить их, отредактировав значения Firestore прямо в консоли:

6edacf3e5fa6004adff8e312f01dc653.png

Для отладки перейдите в Log Explorer в Google Cloud Console, где есть все логи нашего приложения, включая printstatements, добавленные в нашей функции.

7fb54f4a7a6d65629ff43ad22cad3a41.png

Заключение

По мере роста нашего проекта нам понадобится создать дополнительные функции Firebase для охвата возникающих побочных эффектов. Если мы изменим коллекцию с цветами, нам нужно будет распространить изменения на материал и игрушку. Если мы работаем с заказами в коллекции заказов, мы можем проверить запасы материала и игрушки перед оформлением заказа и вычесть необходимые товары из запасов.

Эти побочные эффекты являются следствием использования денормализованной базы данных. Нам нужны способы синхронизации данных базы данных, если мы моделируем ее с учетом особенностей приложения, жертвую целостностью, присущей SQL-структурам. Используя Firebase Functions, мы изолируем нашу базу данных от клиентского приложения, защищая конфиденциальные данные и предотвращая нежелательное редактирование.

В этой статье мы рассмотрели нормализацию и денормализацию данных в нашей базе данных Firestore:

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

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

В некоторых случаях эти побочные эффекты представляют из себя не полное изменение данных (например, изменение имени), а увеличивают или уменьшают значение (например, изменение количество товара на складе). В этом случае мы используем транзакции, позволяющие нам читать значение документа и обрабатывать изменения в другом документе. Если Firebase обнаруживает изменения в документах, которые мы читаем, он повторяет транзакцию, чтобы гарантировать, что мы используем самые актуальные данные.

Кроме того, нам не следует забывать о валидации данных в наших Firebase Function. Это гарантирует, что пользователи не смогут изменить тип значения, например, со String на bool, или заставит его следовать определенному правилу, например, все имена имеют префикс material_. Если функция Firebase обнаруживает ошибку, она прекращает распространение изменений и может вернуть документ в его исходное состояние.

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

И если уж мы хотим добавить больше функций в наши Firebase-приложения, почему бы не использовать Firebase Extensions? Ознакомьтесь с этой статьей, чтобы узнать, как легко добавить AI-сводку с помощью Firebase Extensions.

26 ноября пройдет открытый урок «Макросы и другие помощники Dart/Flutter». На нём мы научимся создавать и использовать макросы, поймём принципы генерации кода через source_gen и build_runner, упростим себе жизнь с помощью mason bricks.

Регистрация открыта на странице курса «Flutter Mobile Developer».

© Habrahabr.ru