Я долгое время очень скептически относился к так называемым базам данных NoSQL. Я считаю, что традиционная база данных SQL обеспечивала лучшие абстракции более высокого уровня для определения структур данных и работы с данными. Тем не менее, я получил несколько запросов на шаблон DynamoDB для моего конструктора проектов Goldstack, и я решил, что модуль, управляющий доступом к DynamoDB, может быть хорошим дополнением к библиотеке шаблонов.

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

Существует значительная сложность правильного моделирования данных в DynamoDB и обеспечения работы основ в приложении Node.js. Таким образом, я подумал, что составил статью, в которой рассказывается о том, что я узнал за последние несколько недель. В этой статье рассматриваются:

  • Как моделировать данные для DynamoDB
  • Как создать таблицу и запустить миграцию
  • Как создавать и запрашивать данные

tl;dr

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

Вышеупомянутый шаблон и шаблон регулярно обновляются и автоматически тестируются (установка проекта и постоянная инфраструктура). Если вы все же столкнулись с какими-либо проблемами, пожалуйста, поднимите вопрос на GitHub.

Моделирование данных

DynamoDB, по сути, представляет собой приукрашенное хранилище ключей-значений. Таким образом, его фундаментальная структура напоминает следующее:

key --> value

Например, если мы хотим определить базу данных пользователей, нам нужно определить ключ, который мы хотим использовать для идентификации пользователей. Идентификация правильного ключа обычно более важна, чем значение. Поскольку DynamoDB не имеет схемы, мы можем поместить в значение все, что угодно, без ограничений. Таким образом, мы могли бы определить пользовательские данные следующим образом:

`[email protected]` --> {name: 'Joe', dob: '31st of January 2021'}`
`[email protected]` --> {name: 'Jane', newsletterSubscribed: false}`

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

Однако эта гибкость имеет свою цену. В то время как в традиционной базе данных SQL мы обычно можем писать запросы для всех столбцов таблицы, DynamoDB допускает эффективные запросы только для ключей. Так, например, в базе данных SQL я мог бы просто создать запрос, чтобы получить всех пользователей, которые родились в определенном году, это было бы не так просто сделать в DynamoDB.

Чтобы обойти этот фундаментальный недостаток, мы можем использовать ряд стратегий в DynamoDB. Двумя наиболее важными из них являются составные ключи и глобальные вторичные индексы (GSI).

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

[newsletterSubscribed, email] -> value

Простой способ добиться этого — составить составную строку, например false#[email protected], но у DynamoDB есть специальная функция, которую мы можем использовать для этого: ключи сортировки. DynamoDB позволяет определить наш ключ как составной ключ, состоящий из двух элементов: ключа раздела и ключа сортировки. Мне не нравится имя ключа раздела, поскольку для меня оно слишком похоже на первичный ключ, и, по сути, и ключ раздела, и ключ сортировки вместе являются по существу первичным ключом нашей таблицы.

В любом случае, используя ключ раздела и ключ сортировки, мы можем определить составной ключ следующим образом:

[partitionKey: email, sortKey: newsletterSubscribed] -> value

Ключи сортировки весьма эффективны, поскольку DynamoDB позволяет нам использовать для них ряд операторов запросов: например, begins_with, between, >, <.

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

Для решения этой проблемы DynamoDB предлагает Глобальные вторичные индексы. Глобальные вторичные индексы — это, по сути, клон всех данных в вашей таблице (имеющих отношение к индексу) в другую таблицу DynamoDB. Таким образом, мы можем определить ключ раздела и ключ сортировки, отличные от тех, которые используются для нашей таблицы. Например, мы могли бы определить следующее:

Table: [partitionKey: email, sortKey: newsletterSubscribed] -> value
GSI: [partitionKey: email, sortKey: dob] -> value

(Обратите внимание, что мы также можем использовать другой ключ раздела).

Это раскрывает интересное ограничение DynamoDB. Нам нужно определить «схему» (например, какие ключи разделов, ключи сортировки и GSI мы используем) специально для запросов, которые мы хотим выполнять над нашей таблицей. Однако следует отметить, что в традиционных базах данных на основе SQL нам также необходимо учитывать то же самое, поскольку нам обычно необходимо определять индексы для ключевых атрибутов, для которых мы выполняем наши важные запросы.

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

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

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

Часть, которая немного сложна, — это ключи, которые мы будем использовать. По сути, мы составляем наши ключи как минимум из двух частей: тип объекта, на который мы ссылаемся, и соответствующий идентификатор. Например, у нас может быть такой ключ, как: user#{email}.

Обратите внимание, что ключи сортировки позволяют нам использовать такие операции, как starts_with, в наших запросах, а ключи разделов — нет. Поэтому, если нас интересуют такие запросы, как give me all user entities, нам нужно убедиться, что мы добавили идентификатор сущности user в ключ сортировки.

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

Company Entity: [partitionKey: company#{name}, sortKey: company#]
User Entity: [partitionKey: company#{name}, sortKey: user#{email}]

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

Приведенная выше схема теперь позволяет нам очень легко запрашивать всех пользователей для компании. Когда мы создаем наш запрос, мы просто предоставляем:

partitionKey equals company#{name}
sortKey starts_with user#

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

Company Entity: [partitionKey: company#{name}, sortKey: company#]
User Entity: [partitionKey: company#{name}, sortKey: user#{email}, gsi1_partitionKey: user#{email}]

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

Второй паттерн, который я хотел обсудить, — это отношения «многие ко многим». Скажем, например, что один пользователь может принадлежать нескольким компаниям. В реляционной базе данных нам нужно было бы определить дополнительную таблицу для представления отношений «многие ко многим». В DynamoDB мы также вводим новые сущности. В частности, нам нужно ввести две сущности: Отношения между компанией и пользователем и Отношения между пользователем и компанией. В результате получится следующая схема:

Company Entity: [partitionKey: company#{name}, sortKey: company#]
User Entity: [partitionKey: user#{email}, sortKey: user#]
Company-User Relationship: [partitionKey: company#{name}, sortKey: user#{email}]
User-Company Relationship: [partitionKey: user#{email}, sortKey: company#{name}]

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

Обратите внимание, что все эти сущности будут принадлежать одной таблице DynamoDB. Мы определяем только один ключ раздела и один ключ сортировки для этой таблицы: оба типа string. Ключ — это значения, которые мы предоставляем для этих ключей. Как вы можете себе представить, это быстро может стать немного запутанным беспорядком. Поэтому я рекомендую выразить эту схему (например, какие типы ключей мы накладываем на нашу базовую таблицу) в коде. Далее в этой статье я покажу, как этого можно добиться с помощью фреймворка DynamoDB Toolbox.

Нередки целые университетские курсы, посвященные моделированию реляционных данных для традиционных баз данных. Таким образом, не рассчитывайте стать мастером моделирования данных для DynamoDB после прочтения вышеизложенного. Мое намерение состоит в том, чтобы обеспечить минимальный уровень понимания, чтобы мы могли начать писать достаточно хороший код. Однако, если вы планируете создавать более масштабные системы, я настоятельно рекомендую ознакомиться с дополнительными ресурсами. Документация AWS обычно является хорошей отправной точкой для этого:

Создание таблицы и запуск миграции

Существует несколько способов создания таблицы DynamoDB, например, с помощью консоли AWS, .NET SDK или динамически через слой ORM.

На мой взгляд, вообще лучше всего определять бессерверную инфраструктуру с помощью Terraform. Определение таблицы DynamoDB в Terraform позволяет нам легко связать ее с другими ресурсами, такими как функции Lambda. Однако локально протестировать ресурсы, определенные в Terraform, непросто. Напротив, создание таблицы с помощью интерфейса командной строки или одного из SDK упрощает локальное тестирование с помощью DynamoDB Local.

Более того, хотя технически Terraform позволяет обновлять таблицу DynamoDB, на самом деле это неподходящий инструмент для работы, поскольку существует значительный риск непреднамеренных побочных эффектов при применении изменений. Вместо этого определение миграций в коде обеспечивает большую гибкость и безопасность при определении миграций.

Вы можете спросить: поскольку DynamoDB не имеет схемы, почему нам вообще нужно беспокоиться о миграции? Хотя технически DynamoDB не требует, чтобы мы определяли схему, прежде чем мы начнем вставлять и запрашивать данные, ключи разделов, ключи сортировки и глобальные вторичные индексы мы определяем тип функции как схему и должны развиваться вместе с нашим приложением. Например, новый появляющийся шаблон запроса может потребовать от нас определить новый глобальный вторичный индекс.

Подход, который позволяет нам использовать как декларативную мощь Terraform, так и преимущества определения нашей схемы в коде, заключается в создании нашей таблицы и управлении миграциями в коде с использованием атрибута данных aws_dynamodb_table. Нам нужно только указать имя нашей таблицы DynamoDB для этого ресурса, после чего мы сможем определить дополнительные ресурсы для таблицы в Terraform (например, разрешения IAM).

В прилагаемом примере проекта на таблицу DynamoDB ссылаются следующим образом из Terraform (main.tf):

data "aws_dynamodb_table" "main" {
  name = var.table_name
}

Теперь проблема заключается в том, что terraform plan и terraform apply завершатся ошибкой, если эта конкретная таблица еще не создана. Для этого я разработал простую библиотеку, которая обеспечивает создание таблицы DynamoDB до выполнения любых операций Terraform @goldstack/template-dynamodb.

Эта библиотека будет использовать AWS SDK для создания таблицы с помощью операции createTable (dynamoDBData.ts#L13):

Это создает симпатичную ванильную таблицу DynamoDB. Этого достаточно, чтобы убедиться, что Terraform может сослаться на что-то при настройке дальнейшей инфраструктуры.

Если мы хотим изменить настройки для этой таблицы (например, BillingMode) или определить дополнительные глобальные вторичные индексы, мы можем использовать миграции, когда это необходимо. В примере проекта я настроил миграции с помощью Umzug. Для этого требовалось просто определить хранилище DynamoDB для Umzug: umzugDynamoDBStorage.ts.

Затем это позволяет определить миграции Umzug, которые можно использовать как для вставки, удаления и обновления элементов, так и для обновления самой таблицы для обновления настроек таблицы или добавления/удаления индексов (migrations.ts):

Такое определение нашей таблицы позволяет нам писать сложные локальные тесты, используя DynamoDB Local.

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

Как утверждение, что таблица существует, так и запуск миграции необходимо выполнять только один раз при холодном запуске нашего приложения. Поэтому метод connect хранит кеш уже созданных экземпляров таблиц DynamoDB (templateDynamoDBTable.ts#L80):

Работа с данными

Чтобы использовать DynamoDB в нашем приложении, нам потребуется вставлять, извлекать и запрашивать данные. Проще всего это сделать с помощью DynamoDB JavaScript SDK. Для этого нам просто нужно создать экземпляр класса AWS.DynamoDB:

const dynamodb = new AWS.DynamoDB({apiVersion: '2012-08-10'});

Этот класс предоставляет доступ к методам как для изменения конфигурации нашей таблицы (например, с использованием updateTable), так и для работы с данными. Как правило, в нашем приложении мы хотим только записывать и читать данные в нашу таблицу. Для этого мы можем использовать класс AWS.DynamoDB.DocumentClient.

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

Где ./table ссылается на файл table.ts, включенный в проект. Хотя, как правило, подключиться к таблице DynamoDB не так уж сложно, эти утилиты решают одну серьезную проблему: локальное тестирование.

DynamoDB предоставляет исполняемый файл для локального запуска DynamoDB. Утилиты прозрачно загрузят нужный образ Docker, создадут нашу таблицу и запустят миграции по мере необходимости. Это делает локальное тестирование и написание модульных тестов очень простыми.

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

В первом разделе этой статьи мы говорили об определении модели данных для DynamoDB. Рекомендуемый способ сделать это — так называемый Single Table Design. Это всего лишь один из многих способов, которыми мы можем структурировать наши данные в DynamoDB, и следование строгому дизайну с одной таблицей может легко стать громоздким и трудным для реализации в нашем коде.

DynamoDB Toolbox позволяет нам легко следовать дизайну одной таблицы в нашем коде. Для этого DynamoDB Toolbox требует, чтобы мы определили наложение для Table, которое определяет используемые нами ключи секции и ключи сортировки. В примере проекта это определено в файле (entities.ts):

Обратите внимание, что это тот же ключ секции и ключ сортировки, которые мы определили при создании нашей таблицы ранее.

DynamoDB Toolbox также позволяет нам определять сущности, которые мы хотим использовать (entities.ts#L28):

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

Последние мысли

Хотя базовая структура данных DynamoDB проста, получить достойную настройку для работы с DynamoDB для бессерверного приложения довольно сложно. В этой статье я попытался охватить большинство основ, необходимых для начала работы с DynamoDB. Я также создал шаблон и шаблон, которые, надеюсь, помогут сократить некоторые сложности, связанные с первоначальной настройкой; чтобы вы могли как можно быстрее сосредоточиться на моделировании данных и написании логики приложения.

Я бы порекомендовал просмотреть код в шаблонном проекте, dynamodb-1 package, и использовать Goldstack Project Builder, чтобы запустить свой проект Node.js. Это особенно полезно, когда вы комбинируете шаблон DynamoDB с бэкендом, таким как Шаблон бессерверного API, и внешним интерфейсом, таким как Шаблон Next.js, поскольку это дает функциональный сквозной проект с полным стеком.

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

Изображение на обложке Tobias Fischer