[Перевод] 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
Перед нами стоит задача создать приложение, которое облегчит управление фабрикой игрушек. Наша первая задача — создать простой дашборд, на котором будет отображаться список всех игрушек, которые мы можем производить, в таком виде:
Мы знаем, что у каждой игрушки (Toy
) будет несколько свойств, включая материал (Material
), отображаемый в виджете карточки. Следуя SQL-подобному подходу, мы создаем две коллекции: toys
и materials
, со свойствами, показанными на следующей диаграмме:
В SQL мы создаем отношения между таблицами с помощью внешнего ключа (foreign key). Однако в Firestore у нас нет внешних ключей. Вместо этого в нашем распоряжении есть DocumentReference — тип значения, который мы можем использовать в наших документах, чтобы ссылаться на другие документы.
Что будет, если мы захотим сформировать список всех игрушек?
Когда мы получим все документы из коллекции toys
, у нас не будет информации о материале, а будет только список ссылок DocumentReference
. Чтобы получить информацию о нем, нам нужно будет использовать get
для каждой ссылки, а затем использовать эту информацию для создания объекта Toy
, который нужен будет для построения пользовательского интерфейса карточки.
Если на нашей фабрике производится 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) игрушки.
Следуя такому же подходу, мы создаем новую коллекцию под названием colors
, и теперь перед нами предстает следующая диаграмма базы данных:
Когда мы будем создавать объект Toy
для построения новой карточки-виджета, нам нужно будет получить DocumentReference‘ы
из коллекций materials
и colors
и создать соответствующие ассоциативные массивы для кэширования отношений между ними.
Что касается количества чтений, то для списка из 1 000 игрушек в худшем случае нам потребуется 1 чтение для получения всех документов Toy
, 1 000 чтений для документов Material
и 1 000 чтений для документов Color
, в результате чего для создания одного списка потребуется 2 001 чтение. Если мы ограничены в пределах 50 000 бесплатных чтений в день (на момент написания статьи), и этот список будет формироваться больше 25 раз за день, то мы обязательно упремся в этот лимит, и нам придется платить за каждое обновление сверх него.
Мы можем начать отмахиваться от этого, говоря: «Нам никогда не нужно будет показывать пользователю все 1000 элементов; мы будем использовать бесконечный пагинированный список». Тем не менее, у нас остается одна проблема: фильтрация.
Если мы хотим получить все игрушки с определенным цветом, то нам нужно будет проделать вышеизложенный процесс получения объекта в обратном порядке: найти все цвета, соответствующие запросу, затем все материалы, содержащие этот цвет, и, наконец, все игрушки с этим материалом.
Более того, если мы хотим фильтровать по определенным свойствам Color
и Material
, нам придется изменить наш метод фильтрации, адаптировав его для каждого отдельного случая.
Наконец, что более важно, если в нашем приложении есть разные типы пользователей, мы можем лишить их доступа к определенным свойствам Material
и Color
, оставив им доступ только к имени и типу. В Firestore невозможно получить только часть документа, мы всегда получаем всю его информацию.
В итоге попытка применить подход SQL к NoSQL Firestore приводит к трем проблемам:
Потенциально большее число чтений;
Сложности с фильтрацией;
Раскрытие всей информации в документах, включая конфиденциальную.
Какие у нас есть альтернативы?
Денормализация данных Firestore
В нашей исходной задаче мы хотели показать пользователю карточку с именем материала и именем цвета. Это запрос, который, как мы знаем, мы будем выполнять часто, так почему бы не объединить эти данные? В этом и заключается концепция денормализации — стратегия моделирования данных путем копирования полей из разных таблиц/коллекций в один объект, чтобы упростить получение и фильтрацию информации.
Вопрос в том, какие данные из Material
и Color
мы должны добавить в наш документ Toy
? Ответ зависит от того, что мы хотим показать или какие действия мы выполняем, поэтому давайте рассмотрим наш пользовательский интерфейс.
На данном этапе нам нужно показывать пользователю разные карточки без каких-либо подробностей, отображая только имена цвета и материала. Поэтому мы можем добавить оба эти свойства в документ Toy
. Мы по-прежнему будем сохранять DocumentReference
или, если вам угодно, ID, чтобы быстро идентифицировать его имя.
Теперь, когда мы хотим представить список из 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, которая реагирует на любое изменение документа в коллекции материалов, находит все игрушки, использующие этот материал, и обновляет необходимые свойства.
В этой статье мы не будем рассматривать создание нового проекта 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:
Мы можем проверить их, отредактировав значения Firestore прямо в консоли:
Для отладки перейдите в Log Explorer в Google Cloud Console, где есть все логи нашего приложения, включая printstatements, добавленные в нашей функции.
Заключение
По мере роста нашего проекта нам понадобится создать дополнительные функции 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».