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

Я создаю игру.

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

  • Помогите мне узнать о фронтенд-разработке с помощью Next.js (Я всегда пытаюсь узнать что-то новое)
  • Расскажите другим о вариантах использования кэширования (речь идет не только о базах данных, его можно полностью использовать для краткосрочных данных в играх).

Для меня это интересный опыт обучения, когда я могу расширить свои навыки и применить современные бессерверные передовые практики в веселой и понятной манере. Игра называется Acorn Hunt и имеет открытый исходный код на GitHub, если вы хотите следить за тем, как я ее создаю.

На днях я работал над API и заметил несколько повторяющихся проверок с моими конечными точками. Кроме того, все конечные точки имели префикс с одинаковыми ресурсами для обеспечения правильного контекста, например POST /games/{gameId}/points и DELETE /games/{gameId}/super-abilities.

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

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

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

Имея это в виду, я смог не только упростить реализацию API, но и сделать пути API короче и удобнее для использования.

Создание сеанса пользователя

Важно отметить различие в типах данных, которые мы храним для пользователя. У вас есть долгоживущие и редко меняющиеся данные, такие как username, email и signUpDate, и часто изменяющиеся временные данные, такие как gameId, websocketConnectionId и signInTime.

Эти два типа данных извлекаются из разных областей нашего приложения и объединяются в один класс UserSession.

class UserSession{
  constructor( username, email, gameId, wsConnection, signInTime){
    Object.assign(this, { username, email, gameId, wsConnection, signInTime });
  }

  static async load (username: string): Promise<UserSession> {
    const user = await dynamodb.send(new GetItemCommand({ TableName: 'user', Key: { pk: username, sk: 'details' }}));
    const momento = await getCacheClient(['user']);
    const userCacheResponse = await momento.dictionaryGetFields('user', username, ['gameId', 'websocketConnectionId', 'signInTime']);
    const sessionCache = userCacheResponse.valueRecord();

    return new UserSession(username, user.email, sessionCache.gameId, sessionCache.websocketConnectionId, sessionCache.signInTime);
  }
}

Долгоживущие данные извлекаются из хранилища пользователей. Это может быть поставщик удостоверений (IdP), такой как Okta или Amazon Cognito, или он может поступать прямо из вашей базы данных.

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

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

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

Мы получаем преимущество не только в том, что нам нужно поддерживать меньше кода, но и в том, что конечные точки становятся проще! Мы прошли от POST /games/{gameId}/points до POST /points. Это легче читать и легче интегрировать!

Какие данные принадлежат сеансу пользователя?

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

Данные сеанса пользователя должны быть часто запрашиваемыми и регулярно обновляемыми данными. Чтобы определить кандидатов для данных сеанса, взгляните на конечные точки в вашем API. Вы видите одни и те же параметры пути в большинстве своих вызовов? Существуют ли ограничения вокруг одного «активного» параметра или ресурса? Это может быть хорошим кандидатом для вашей пользовательской сессии!

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

Возьмем несколько примеров из Охоты за желудями. Первый проход в моем API имел следующие конечные точки:

Большинство путей начинались с /games/{gameId}. У каждого из них были одинаковые проверки в реализации: существует ли игра и является ли вызывающий абонент активным игроком в ней? Это был огромный показатель того, что все можно упростить, выводя эти данные из пользовательского сеанса.

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

exports.broadcaseMessage = async (momento, gameId, connectionIdToOmit, message) => {
  await momento.listPushBack('chat', gameId, JSON.stringify(message);

  const connectionResponse = await momento.setFetch('connection', gameId);
  if(connectionResponse instanceof CacheSetFetch.Hit) {
    const connections = connectionResponse.valueArray().filter(connection => connection != connectionIdToOmit);

    await Promise.allSettled(connections.map(async (connection) => {
      await apig.send(new PostToConnectionCommand({
        ConnectionId: connection,
        Data: JSON.stringify(message)
      }));
     }));
  } else {
    console.log(connectionResponse);
  }
};

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

Будьте осторожны с авторизаторами Lambda

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

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

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

Загрузите свои пользовательские сеансы в нижестоящие службы после авторизации вызова. Это позволяет вам каждый раз извлекать данные из кеша сеанса пользователя вместо того, чтобы полагаться на кеш авторизатора. Хотя кеширование авторизатора — фантастический инструмент, он несколько раз меня укусил, когда я пытался получить данные, которые становились недействительными чаще, чем авторизатор!

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

Сеансы пользователей касаются опыта разработчиков. Ищите шаблоны в своих API, которые можно абстрагировать в пользовательский сеанс на стороне сервера. Как вы можете взять обычный опыт и сделать его волшебным?

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

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

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

Подумайте, что вы можете взять на себя в качестве разработчика API. Что можно сберечь от звонка А, что можно использовать для улучшения опыта звонка Б? Закинь в кеш и пользуйся!

Для практического примера ознакомьтесь с исходным кодом на GitHub!

Удачного кодирования!