Пару недель назад я закончил и развернул Calendar API. Идея создания этого API возникла из-за желания создать планировщик SaaS. Естественно, мне нужен был какой-то способ отслеживать события и места для пользователей, поэтому я решил создать многоразовый сервис для этой цели, который также можно было бы использовать снова в будущем. У меня также есть планы добавить какое-то планирование в Elsewhere в будущем, что усилит мотивацию повторного использования.

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

Содержание:

Модель данных

Шаблоны доступа к данным были одними из самых важных соображений, которые я принял во внимание при разработке этого сервиса. Сам API предназначен для использования определенных шаблонов доступа, но я также не хотел отказываться от всего, что кажется интуитивно понятным. Конечно, были естественные и простые конечные точки, которые помогали создавать календари и события, а затем читать, обновлять и удалять эти объекты по их соответствующим идентификаторам. Самый большой шаблон доступа, который мне пришлось продумать, заключался в захвате нескольких событий из одного календаря ( GET /calendars/{calendarId}/events).

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

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

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

Разработка ключей DynamoDB может быть немного сложной, но мне нравится думать, что ключи имеют широкий охват и сужаются до конкретной сущности, которую мы ищем. Данными частями информации, которые мне нужно было хранить в ключах моих событий, были идентификатор календаря, к которому они принадлежали, их идентификаторы и какой-то способ сортировки по времени. Я выбрал что-то совершенно уникальное по сравнению со всем, что я видел в Интернете раньше. Мой ключ раздела для событий похож на формат {calendarId}#{date}. Этот выбор дизайна включал предостережение о том, как я создаю идентификаторы событий, о чем я расскажу в четырех абзацах.

Причина, по которой я решил использовать этот формат для своих ключей, заключалась в том, что сортировка по времени теперь стала первоклассным гражданином. Я также предположил, что чтение из календаря обычно происходит с шагом в несколько дней. Либо на один день, рабочую неделю, семидневную неделю или месяц. Теперь всякий раз, когда пользователь пытается прочитать несколько событий из календаря, я могу запустить серию Querys для ключей секции, включая идентификатор календаря (который известен из конечной точки) и даты, в которые попадает временной интервал (которые являются обязательными параметрами запроса). Например, чтение событий между 2022-01-01T00:00:01Z и 2022-01-03T00:00:01Z приводит к Queryс до {calendarId}#2022-01-01, {calendarId}#2022-01-02 и {calendarId}#2022-01-03. Затем результаты этих Query секунд фильтруются, чтобы включить только события, происходящие в этот период времени.

В сообществе DynamoDB общеизвестно ограничение количества вызовов Scan и Query, потому что это не то, для чего DynamoDB лучше всего подходит. Мой аргумент заключается в том, что мое использование Query здесь более или менее эквивалентно BatchGetItem, потому что я использую большинство (если не все) элементов, возвращаемых в Query. Так как я все равно буду искать и возвращать пакет событий по дате, Query, кажется, имеет здесь большой смысл, не заставляя меня придумывать какую-то другую доморощенную структуру данных для отслеживания событий по дням.

Одна часть функциональности, которую я хотел реализовать, заключалась в возврате событий в GET /calendars/{calendarId}/events, даже если запрошенный временной интервал начинался на полпути к событию. Например, если бы у меня была пятидневная конференция с понедельника по пятницу, я бы хотел увидеть это событие, если бы я читал события из своего календаря между средой и четвергом. Если бы я просто записал события в раздел, где они начались, я бы не смог прочитать их с Query в числах среды и четверга. Это привело к тому, что я дублировал события во всех разделах, в которых они происходят. Например, эта конференция будет иметь один идентификатор события, но сам объект события будет дублироваться пять раз, по одному разу на каждый день его проведения.

Теперь к предостережению об идентификаторе события, упомянутом четыре абзаца назад. Обсуждение GET /calendars/{calendarId}/events завершено, но что насчет GET /calendars/{calendarId}/events/{eventId}? Поскольку события хранятся в ключе секции, включающем дату ({calendarId}#{date}), отсутствует часть информации для прямого GetItem всякий раз, когда вызывается эта конечная точка. Я не хотел заставлять пользователей моего API запоминать даты начала событий. Это привело бы к значительной нагрузке на пользователей. Вместо этого я решил закодировать дату начала события в самом идентификаторе события. Это позволяет мне декодировать дату из идентификатора события после того, как она передается через параметры пути, чтобы создать действительный ключ раздела, содержащий это событие, который позволяет мне вызывать простой GetItem для извлечения события. Обычно я предпочитаю использовать простой UUID для идентификаторов сущностей в своих API, но этот метод немного отклонялся от этой нормы. Я очень доволен результатом этой реализации, потому что я считаю, что она открывает некоторые интересные двери для будущих проектов.

Эти две конечные точки (GET /calendars/{calendarId}/events и GET /calendars/{calendarId}/events/{eventId}) были самыми сложными, но наиболее полезными для реализации из-за ненормальной модели данных, которую я выбрал для своих сущностей событий. Календари были совершенно противоположной моделью данных, потому что они были такими стандартными. Простые сущности типа "ключ-значение" на основе UUID в качестве идентификаторов календаря. Я также добавил конечную точку, чтобы пользователи могли читать все идентификаторы календаря, которые они создали, на случай, если кто-то каким-то образом забудет идентификатор. Опять же, я хотел свести к минимуму накладные расходы для пользователей моего API.

Дизайн API

Когда я приступил к проектированию конечных точек API, я хотел, чтобы он чувствовал себя как REST и интуитивно понятен. Глядя на готовый API, я считаю, что достиг своей цели. Использование параметров пути с API Gateway было для меня новым, но они отлично подходят для crow-api. Мне также нужно было глубже погрузиться в коды состояния HTTP и лучшие практики, связанные с тем, что возвращать для различных методов HTTP, и различиями в синхронных и асинхронных состояниях конечных точек.

Использовать параметры пути с API Gateway так же просто, как добавить ресурс с именем параметра пути, заключенным в фигурные скобки {}, например /calendars/{calendarId}. После правильной настройки ресурсов шлюза API параметры пути будут переданы в соответствующую функцию Lambda как event.pathParameters. pathParameters — это объект с именами ключей, являющимися именем параметра пути, поэтому, если бы параметр пути, который нам нужен, был calendarId, мы могли бы получить к нему доступ с помощью const calendarId = event.pathParameters.calendarId.

У меня был предыдущий опыт работы с различными методами HTTP, поэтому я знал, как использовать их в RESTful API. Для полноты картины я все же хотел описать, как я их использовал. Когда я думаю об объекте в программном обеспечении, я думаю о четырех операциях, которые можно выполнить с этим объектом: создать, прочитать, обновить и удалить (или CRUD). Я использую четыре основных метода HTTP, каждый из которых соответствует действию. POST соответствует созданию, GET соответствует чтению, PUT соответствует обновлению, а DELETE соответствует удалению. Для работы с календарем с помощью API можно использовать следующие методы HTTP с интуитивно понятными конечными точками. POST /calendars создает новый календарь и возвращает его ID, GET /calendars/{calendarId} читает календарь; PUT /calendars/{calendarId} обновляет календарь; и DELETE /calendars/{calendarId} удаляет календарь. Такой дизайн моего API следует лучшим практикам и ожиданиям гораздо ближе. Конечные точки почти документируют себя, и это сводит к минимуму объем проверок, который требуется разработчикам для работы с API в целом.

То, что я узнал о кодах состояния, относится к методам HTTP, поэтому я хотел, по крайней мере, рассказать о различных методах HTTP. Различные коды состояния работают лучше или лучше возвращаются для разных методов. Вот список всех различных успешных кодов состояния, которые я использовал для API календаря: 200, 201, 202 и 204. Большинство API имеют 200, но я не встречал слишком много других трех кодов состояния. 200 — это стандартный код состояния OK, но, вероятно, он используется слишком часто, чтобы просто означать, что при выполнении конечной точки ничего не пошло не так. Чего я не осознавал, так это того, что существует гораздо больше кодов 2xx, которые могут дать дополнительное представление о том, что произошло в результате вызова конечной точки. 201 означает, что объект был успешно Created. 202 означает Accepted, что чаще всего означает, что конечная точка является асинхронной, запрос прошел первоначальную проверку и был поставлен в очередь для последующего действия. 204 означает, что выполнение прошло успешно, но есть No Content и ничего не возвращает. Статусы ошибок, которые я использовал, не казались мне незнакомыми, но по какой-то причине мне кажется, что большинство разработчиков проектируют API так, чтобы просто возвращать 200 для каждого успешного выполнения вместо предоставления более конкретного кода успеха.

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

Интеграция прокси службы шлюза API

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

Я написал интеграцию сервисного прокси для двух основных типов действий: чтение из DynamoDB и размещение событий в теме SNS. Ранее я настроил интеграцию прокси-сервера службы DynamoDB, поэтому не слишком беспокоился о том, чтобы интеграция работала правильно. Конфигурация интеграции, особенно с CDK, довольно проста, и на странице документации класса apigateway.AwsIntegration есть даже пример.

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

#set($inputRoot = $input.path('$'))
#if ( "$!inputRoot.Item.optionalAttribute.S" == "" )
  #set($optionalAttribute = "defaultValue")
#else
  #set($optionalAttribute = $inputRoot.Item.optionalAttribute.S)
#end

Еще один хороший урок, который я извлек из интеграции с DynamoDB, заключался в том, как переопределить код состояния ответа интеграции, что особенно полезно при запросе элементов по их ключам. Предположим, что у меня есть модель данных с простой парой ключ-значение в DynamoDB с ключом с именем id. Если id не существует, я бы хотел вернуть 404 Not Found. Однако DynamoDB по-прежнему будет возвращать 200 вместо GetItem, даже если элемент не существует. В итоге я проверил id (как и в предыдущем примере), а затем переопределил код состояния, ничего не возвращая.

#if ( "$!inputRoot.Item.id.S" == "" )
  #set($context.responseOverride.status = 404)
#else
{
  "hello": "world"
}
#end

Настройка интеграции с SNS оказалась не такой простой, как я надеялся. Настроить интеграцию любого сервисного прокси с чем-либо, кроме DynamoDB, сложно, потому что нам нужно ссылаться на базовый API, который не так прост в навигации, как SDK и CLI. Чтобы свести к минимуму количество дублирующегося контента, который я публикую, вот пост, который я написал специально на эту тему.

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

Аутентификация

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

Конечно, у меня есть Crow Authentication, когда мне это нужно, но мне не нравилась идея аутентификации с помощью JWT для этого API, в основном потому, что RapidAPI интегрируется с ключами API. Я не верю, что RapidAPI можно настроить для получения действительного JWT. К сожалению, API-ключи не являются хорошей формой аутентификации, особенно в том виде, в каком их предлагает API Gateway. Шлюз API не сопоставляет множество ключей API с одним уникальным идентификатором, по крайней мере, насколько я знаю, и я хотел охватить вариант использования потери или ротации ключей API без потери доступа к данным, что означало, что мне нужно было обрабатывать множество - одно отображение.

В итоге я настроил на шлюзе API аутентификацию по ключу API и пользовательский авторизатор Lambda. Шлюз API сначала проверяет ключи API перед отправкой запроса авторизатору Lambda. Затем я мог бы сопоставить ключ API в моем авторизаторе Lambda с известным объектом в моей базе данных. Элементы базы данных представляют собой простые пары «ключ-значение», где ключи являются действительными ключами API, а значения представляют собой уникальный аутентифицируемый объект. Теперь я могу сопоставить любое количество ключей API с одним объектом, и авторизатор Lambda вставит этот контекст в событие.

Для себя, использующего этот сервис, мне просто нужно создать новый ключ API и сопоставить этот ключ с сущностью в моей базе данных, чтобы отделить ресурсы новой рабочей нагрузки от ресурсов других. Однако если я выполню аутентификацию для ресурсов API календаря на основе ключей API, не смогут ли все пользователи RapidAPI получить доступ ко всем ресурсам других пользователей? Именно здесь мне нужно было начать полагаться на аутентификацию RapidAPI. Для каждого запроса RapidAPI вводится несколько заголовков, которые передаются нижестоящим API. Я полагаюсь на заголовки X-RapidAPI-Proxy-Secret и X-RapidAPI-User. Секрет прокси-сервера известен только издателю API, а пользователь является аутентифицированным пользователем RapiAPI.

Эти два заголовка, специфичные для RapidAPI, являются ключом к аутентификации в моем авторизаторе Lambda. У меня есть логика разделения в зависимости от наличия этих заголовков. Если они существуют, я знаю, что запрос от RapidAPI, и я использую значение X-RapidAPI-User в качестве объекта, который я аутентифицирую. Если этих заголовков не существует, я следую описанному ранее процессу получения объекта из моей базы данных.

Я понимаю, что это не стандартный способ аутентификации. Другой вариант, о котором я подумал, — это дублировать стек и определить поток аутентификации, используя что-то вроде переменной среды для IS_RAPID_API_AUTH или что-то в этом роде. В зависимости от использования этого API я могу перейти на отдельные стеки позже, но пока эта конкретная настройка соответствует моим потребностям.

Шестиугольная архитектура

Последняя область API календаря, которую я хотел обсудить, — это структура кода. Тема создания этого API заключалась в повторении того, что я делал в прошлом, и в том, чтобы сделать его готовым к производству. Я знал, что хочу предложить его публично, и я знал, что буду использовать его повторно, поэтому имело смысл только убедиться, что API был интуитивно понятным, самодокументируемым, хорошо спроектированным и простым в обслуживании. Простота обслуживания — вот где шестиугольная архитектура вступила в игру. До этого я никогда не называл структуру кода гексагональной архитектурой. Я никогда не слышал о гексагональной архитектуре до re:Invent. Послушав этот доклад, я понял, что хочу узнать об этом больше и реализовать что-то с использованием гексагональной архитектуры. Это был мой первый реальный шанс.

Внедрив этот последний API с гексагональной архитектурой, я могу сказать, что доволен результатом и буду продолжать использовать этот стиль подхода в будущем. Весь код, который я написал, выглядит намного чище. Структура моего ранее написанного кода была недалеко от того, чтобы считаться гексагональной архитектурой, но было несколько областей, которые я просто никогда не удосужился реализовать, в основном входящий адаптер и все порты. Однако структурирование этого кода немного по-другому имело значение. У меня есть отдельный пост про технические тонкости реализации гексагональной архитектуры на случай, если будет интересно.

Заворачивать

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

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

Первоначально опубликовано на https://thomasstep.com 28 марта 2022 г.