Momentoでリーダーボードサービスを構築する方法:ステップバイステップガイド

Yan Cui

リーダーボードは、多くのアプリケーションでよく見かけるものです。ゲームから、友達間の歩数を表示するフィットネス アプリまで、アプリケーションをゲーム化する一般的な方法です。

ただし、スケーラブルなリーダーボード サービスを構築するのは難しい場合があります。一般的なアプローチは、リーダーボードを Redis のソート済みセットとしてモデル化することです。ソート済みセットはこの問題に最適ですが、Redis を実行することとそれに伴うすべての作業が負担になります。

  • インフラストラクチャの管理
  • キャパシティプランニング
  • メンテナンスウィンドウ
  • Redisエンジンのバージョンを更新する
  • 誰もアプリを使用していないときでも稼働時間に対して料金を支払う

サーバーレスのシンプルさと従量課金制の効率性が気に入っています。そこで Momento の出番です!

Momento は、サーバーレス キャッシュおよびリアルタイム データ プラットフォームです。DynamoDB のチームによって構築されており、拡張性と堅牢性を重視して構築されています。

このガイドでは、Momento の Sorted Set コレクションを使用してリーダーボード サービスを構築する手順を説明します。

このデモのすべてのコードはこのリポジトリ[1]で入手できます。

前提条件

このガイドの前提条件は次のとおりです。

Momento のソートセットを理解する

ソート セットは、それぞれスコアに関連付けられた一意の要素を格納するデータ構造です。要素はスコアに基づいてソートされるため、ソート セットはリーダーボードに最適です。

Momento の Sorted Set コレクションを使用すると、次のことが可能になります。

  • 要素のスコアまたはランクを取得します。
  • ランク別に要素のリストを取得します。
  • スコア別に要素のリストを取得します。
  • その他(APIリファレンスはこちら[3]を参照)

これらは、リーダーボード上の一般的な操作にうまくマッピングされます。

アーキテクチャの概要

このデモでは、3 つの API ルートを作成します。

  • ユーザーのスコアを送信するには、POST /{leaderboard}/{name} をPOST /mario-kart/theburningmonk実行します。たとえば、次のようになります。

ペイロードの例:

{ 
  "score": 42
}
  • GET /{eaderboard}/{name} を実行して、ユーザーの現在のランク (0 インデックス) とスコアを取得します。たとえば、GET /mario-kart/theburningmonk.

応答例:

{ 
  "score": 42, 
  "rank": 1
}
  • GET /{leaderboard}?startRank={startRank}&endRank={endRank} を実行すると、ユーザーのページがランク別に一覧表示されます。

応答例:

{
  "leaderboard": [{ 
    "name": "v", 
    "score": 2077
  }, { 
    "name": "silverhand", 
    "score": 34
  }]
}

APIの実装にはAPI GatewayとLambda関数を使用します。ルートごとに1つのLambda関数があり、Momento SDK [4]を使用してMomentoと通信します。

アーキテクチャは次のようになります。

ステップ1: 新しいキャッシュを作成する

Momento コンソールにログインし、「キャッシュ」に移動します。

「キャッシュを作成」をクリックします。

この新しいキャッシュを「 leaderboard 」と呼び、「us-east-1」リージョンに作成します。

これはMomento CLI [5]を通じて行うこともできます。

ステップ2: APIキーを生成する

Momento には 2 種類の API キーがあります。

  • スーパーユーザーキー: キャッシュとトピックを管理し、使い捨てトークンを生成するために使用されます。
  • きめ細かいアクセス キー: これは、Moment のキャッシュ/トピックとやり取りするためのものです。

Lambda 関数はleaderboardキャッシュの読み取りと書き込みを行う必要がありますが、キャッシュの作成や削除は必要ありません。そのため、きめ細かいアクセス キーが必要です。

Momento コンソールの「API キー」に移動し、「us-east-1」リージョンに新しい「 Fine-Grained Access Key 」を生成します。新しく作成した「leaderboard 」キャッシュに「 readwrite 」ロールを追加します。

権限の追加」をクリックして、この権限を API キーに追加します。確認して「API キーの生成」をクリックします。

これにより、使用できる新しい API キーが生成されます。

ステップ3: APIキーを保護する

API キーを安全に保つために、SSM パラメータ ストアに保存します。

AWSの「AWS Systems Manager」コンソールに移動し、「Parameter Store」をクリックします。

新しいパラメータを作成し、その新しいパラメータを「/leaderboard-api/dev/momento-api-key」と呼びます。これは、SSM パラメータに通常使用する命名規則です/{service-name}/{environment}/{parameter-name}

パラメータ タイプが「SecureString」であることを確認します。これにより、API キーが保存時に暗号化されます。

ステップ4: CDKアプリを作成する

このデモでは、CDK を使用します。

CDK アプリでは、次のことを行います。

  1. サーバーレス開発において最も影響力のあるプラクティスの1つであるエフェメラル環境[6]をサポートします。
  2. 一時環境でメイン環境の1つ(例:dev)のSSMパラメータ[7]を再利用できるようにします。

stageNameしたがって、と という2 つのコンテキスト変数を取りますssmStageName

stageName名前の競合を避けるために、作成するすべての AWS リソースの名前に が含まれます。

参照するすべての SSM パラメータでは、ssmStageNameの代わりに が使用されます。stageName

これらを念頭に置いて、CDK アプリを紹介します。

#!/usr/bin/env node

const cdk = require('aws-cdk-lib');
const { LeaderboardApiStack } = require('./constructs/leaderboard-api-stack');

const app = new cdk.App();

let stageName = app.node.tryGetContext('stageName');
let ssmStageName = app.node.tryGetContext('ssmStageName');

if (!stageName) {
  console.log('Defaulting stage name to dev');
  stageName = 'dev';
}

if (!ssmStageName) {
  console.log(`Defaulting SSM stage name to "stageName": ${stageName}`);
  ssmStageName = stageName;
}

const serviceName = 'leaderboard-api';

new LeaderboardApiStack(app, `LeaderboardApiStack-${stageName}`, {
  serviceName,
  stageName,
  ssmStageName,
});

そしてこれがLeaderboardApiStackです:

const { Stack } = require('aws-cdk-lib');
const { Runtime } = require('aws-cdk-lib/aws-lambda');
const { NodejsFunction } = require('aws-cdk-lib/aws-lambda-nodejs');
const { RestApi, LambdaIntegration } = require('aws-cdk-lib/aws-apigateway');
const iam = require('aws-cdk-lib/aws-iam');

const MOMENTO_CACHE_NAME = 'leaderboard';

class LeaderboardApiStack extends Stack {
  constructor(scope, id, props) {
    super(scope, id, props);

    const api = new RestApi(this, `${props.stageName}-LeaderboardApi`, {
      deployOptions: {
        stageName: props.stageName,
        tracingEnabled: true
      }
    });

    this.momentoApiKeyParamName = `/${props.serviceName}/${props.ssmStageName}/momento-api-key`;
    this.momentoApiKeyParamArn = `arn:aws:ssm:${this.region}:${this.account}:parameter${this.momentoApiKeyParamName}`;

    const submitScoreFunction = this.createSubmitScoreFunction(props);
    const getStandingFunction = this.createGetStandingFunction(props);
    const getLeaderboardFunction = this.createGetLeaderboardFunction(props);

    this.createApiEndpoints(api, {
      submitScore: submitScoreFunction,
      getStanding: getStandingFunction,
      getLeaderboard: getLeaderboardFunction
    })
  }

  createSubmitScoreFunction(props) {
    return this.createFunction(props, 'submit-score.js', 'SubmitScoreFunction');
  }

  createGetStandingFunction(props) {
    return this.createFunction(props, 'get-standing.js', 'GetStandingFunction');
  }

  createGetLeaderboardFunction(props) {
    return this.createFunction(props, 'get-leaderboard.js', 'GetLeaderboardFunction');
  }

  createFunction(props, filename, logicalId) {
    const func = new NodejsFunction(this, logicalId, {
      runtime: Runtime.NODEJS_20_X,
      handler: 'handler',
      entry: `functions/${filename}`,
      memorySize: 1024,
      environment: {
        SERVICE_NAME: props.serviceName,
        STAGE_NAME: props.stageName,
        MOMENTO_API_KEY_PARAM_NAME: this.momentoApiKeyParamName,
        MOMENTO_CACHE_NAME,
        POWERTOOLS_LOG_LEVEL: props.stageName === 'prod' ? 'INFO' : 'DEBUG'
      }
    });

    func.role.attachInlinePolicy(new iam.Policy(this, `${logicalId}SsmPolicy`, {
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [ 'ssm:GetParameter*' ],
          resources: [ this.momentoApiKeyParamArn ]
        })
      ]
    }));

    return func;
  }

  /**
   * 
   * @param {RestApi} api
   */
  createApiEndpoints(api, functions) {    
    const leaderboardResource = api.root.addResource('{leaderboard}')
    const nameResource = leaderboardResource.addResource('{name}')

    // POST /{leaderboard}/{name}    
    nameResource.addMethod('POST', new LambdaIntegration(functions.submitScore));

    // GET /{eaderboard}/{name}
    nameResource.addMethod('GET', new LambdaIntegration(functions.getStanding));

    // GET /{leaderboard}?startRank=1&endRank=10
    leaderboardResource.addMethod('GET', new LambdaIntegration(functions.getLeaderboard));
  }
}

module.exports = { LeaderboardApiStack }

ここでは、API Gateway に API を作成し、前述のルートを実装するための 3 つの Lambda 関数を作成しました。

  • スコア送信関数
  • 立ち位置関数を取得
  • リーダーボード関数を取得する

SSMパラメータを安全に読み込む

関数には環境変数として Momento API キーが含まれていないことに注意してください。代わりに、パラメータの名前を渡します。

environment: {
  SERVICE_NAME: props.serviceName,
  STAGE_NAME: props.stageName,
  MOMENTO_API_KEY_PARAM_NAME: this.momentoApiKeyParamName,
  MOMENTO_CACHE_NAME,
  POWERTOOLS_LOG_LEVEL: props.stageName === 'prod' ? 'INFO' : 'DEBUG'
}

そして、実行時にパラメータを取得するための IAM 権限を関数に付与します。

func.role.attachInlinePolicy(new iam.Policy(this, `${logicalId}SsmPolicy`, {
  statements: [
    new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: [ 'ssm:GetParameter*' ],
      resources: [ this.momentoApiKeyParamArn ]
    })
  ]
}));

これは次の理由によるものです:

  • 環境変数から情報を盗む、侵害された依存関係から身を守ります。
  • API キーの有効期限を短く設定し、アプリケーションを再デプロイせずにキーをローテーションすることができます。

コールド スタート中、関数は SSM パラメータを取得し、それを復号化して、その値を数分間キャッシュします。キャッシュの有効期限が切れると、次の呼び出しで SSM パラメータ ストアから更新された値を取得しようとします。

この方法により、呼び出しごとに SSM を呼び出す必要がなくなります。API キーをバックグラウンドで (cron ジョブを使用して) ローテーションすると、関数はキャッシュの有効期限が切れた後に新しいキーを自動的に取得します。

幸いなことに、Middyのssmミドルウェア[8]は、このフローをすぐにサポートしています。このミドルウェアに重い処理を任せますが、これについては後で詳しく説明します。

ワークフローの例

JIRA チケット「ABP-1734」の作業を開始するときは、次のことを行います。

  1. 機能ブランチを作成しますABP-1734
  2. を実行して一時的な環境を作成しますcdk deploy --context stageName=FEAT-ABP-1734 --context ssmStageName=dev。これにより、リーダーボード サービスの新しいインスタンスが作成され、変更を個別に処理できるようになります。この新しい環境ではdevSSM パラメータが使用されますが、そのすべてのリソースにはサフィックスが付きますFEAT-ABP-1734
  3. 変更を加えてテストし、PR を作成します。
  4. を実行して一時的な環境を削除しますcdk destroy --context stageName=FEAT-ABP-1734 --context ssmStageName=dev

これらの短命な環境は、機能開発や CI/CD パイプラインでのテスト実行に役立ちます。従量課金制のおかげで、追加コストをかけずに、必要な数の環境を持つことができます。

理想的には、環境ごとに 1 つの Momento キャッシュを用意します。その場合、キャッシュの名前の先頭または末尾に を付ける必要がありますstageName

ステップ5: submitScore関数を実装する

submitScoreルートの背後にある関数のコードは次のとおりですPOST /{leaderboard}/{name}

const { initClient, submitScore } = require('../lib/momento');
const middy = require('@middy/core');
const ssm = require('@middy/ssm');

const handler = async (event, context) => {
  const body = JSON.parse(event.body);
  const leaderboardName = event.pathParameters['leaderboard'];
  const name = event.pathParameters['name'];

  await initClient(context.MOMENTO_API_KEY);
  await submitScore(leaderboardName, name, body.score);

  return {
    statusCode: 202,
  };
};

module.exports.handler = middy(handler)
.use(ssm({
  cache: true,
  cacheExpiry: 5 * 60 * 1000,
  setToContext: true,
  fetchData: {
    MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
  }
}));

ここでは、Middyのssmミドルウェア[8]を使用して、SSMパラメータストアからMomento APIキーを取得してキャッシュします。

.use(ssm({
  cache: true,
  cacheExpiry: 5 * 60 * 1000,
  setToContext: true,
  fetchData: {
    MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
  }
}));

デフォルトでは、ミドルウェアは取得したデータを環境変数に挿入します。ただし、前述のように、暗号化されていない API キーを Lambda 関数の環境変数に配置することは避けてください。攻撃者は多くの場合、環境変数をスキャンして機密情報を探します。

そこで、取得したデータを Lambda の呼び出しオブジェクトに設定するようにミドルウェアに依頼しますcontext。そのため、Momento クライアントを初期化するときに、 から Momento API キーを取得する必要がありますcontext.MOMENTO_API_KEY

await initClient(context.MOMENTO_API_KEY);
await submitScore(leaderboardName, name, body.score);

共有ロジックのカプセル化

前のスニペットに従って、すべての Momento 関連の操作を共有momento.jsモジュールにカプセル化しました。

これには、Momento キャッシュ クライアントの初期化などの共有ロジックが含まれます。

const { CacheClient, Configurations, CredentialProvider } = require('@gomomento/sdk');
const { 
  CacheSortedSetPutElementResponse,
  CacheSortedSetGetScoreResponse,
  CacheSortedSetGetRankResponse,
  CacheSortedSetFetchResponse,
  SortedSetOrder
} = require('@gomomento/sdk');

const { Logger } = require('@aws-lambda-powertools/logger');
const logger = new Logger({ serviceName: 'leaderboard-api' });

const { MOMENTO_CACHE_NAME } = global.process.env;

let cacheClient;

async function initClient(apiKey) {
  if (!cacheClient) {
    logger.info('Initializing Momento cache client');
    
    cacheClient = await CacheClient.create({
      configuration: Configurations.Lambda.latest(),
      credentialProvider: CredentialProvider.fromString(apiKey),
      defaultTtlSeconds: 7 * 24 * 60 * 60, // 7 days
    });

    logger.info('Initialized Momento cache client');
  }
};

async function submitScore(leaderboardName, name, score) {
  const result = await cacheClient.sortedSetPutElement(
    MOMENTO_CACHE_NAME, leaderboardName, name, score);

  if (result.type === CacheSortedSetPutElementResponse.Error) {    
    logger.error('Failed to submit score', {
      error: result.innerException(),
      errorMessage: result.message()
    });

    throw result.innerException();
  }
}

ここでは、Lambda 実行環境が再利用されるという事実を活用しています。

新しい実行環境が作成されると (コールド スタート中)、cacheClient変数が設定されます。同じ実行環境での後続の呼び出しでは、initClient関数は短絡してすぐに戻ります。

ステップ6: getStanding関数を実装する

getStandingルートの背後にある関数のコードは次のとおりですGET /{leaderboard}/{name}

const { initClient, getScoreAndRank } = require('../lib/momento');
const middy = require('@middy/core');
const ssm = require('@middy/ssm');

const handler = async (event, context) => {
  const leaderboardName = event.pathParameters['leaderboard'];
  const name = event.pathParameters['name'];

  await initClient(context.MOMENTO_API_KEY);
  const result = await getScoreAndRank(leaderboardName, name);
  return {
    statusCode: 200,
    body: JSON.stringify({
      score: result.score,
      rank: result.rank
    })
  };  
};

module.exports.handler = middy(handler)
.use(ssm({
  cache: true,
  cacheExpiry: 5 * 60 * 1000,
  setToContext: true,
  fetchData: {
    MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
  }
}));

getScoreAndRank共有モジュール内の関数は次のとおりですmomento.js

async function getScore(leaderboardName, name) {
  const result = await cacheClient.sortedSetGetScore(
    MOMENTO_CACHE_NAME, leaderboardName, name);

  if (result.type === CacheSortedSetGetScoreResponse.Hit) {
    return result.score();
  } else if (result.type === CacheSortedSetGetScoreResponse.Miss) {
    return 0;
  } else {
    logger.error('Failed to get score', {
      error: result.innerException(),
      errorMessage: result.message()
    });

    throw result.innerException();
  }
}

async function getRank(leaderboardName, name) {
  const result = await cacheClient.sortedSetGetRank(
    MOMENTO_CACHE_NAME, leaderboardName, name, {
      order: SortedSetOrder.Descending
    });

  if (result.type === CacheSortedSetGetRankResponse.Hit) {
    return result.rank();
  } else if (result.type === CacheSortedSetGetRankResponse.Miss) {
    return null;
  } else {
    logger.error('Failed to get rank', {
      error: result.innerException(),
      errorMessage: result.message()
    });

    throw result.innerException();
  }
}

async function getScoreAndRank(leaderboardName, name) {  
  const [score, rank] = await Promise.all([
    getScore(leaderboardName, name),
    getRank(leaderboardName, name),
  ]);

  return { score, rank };
}

ここで、ユーザーのスコアとランクを同時に取得するために、小さな最適化を行ったことに注意してください。

const [score, rank] = await Promise.all([
  getScore(leaderboardName, name),
  getRank(leaderboardName, name),
]);

これはシンプルですが効果的な最適化です。

また、順位を取得するときに、スコアをどの順序でソートするかを指定できます。この場合、スコアが高いほど良いというリーダーボードが必要です。スコアが低いほど良いというリーダーボードにすることも可能です (例: レーシング ゲームのラップ タイム)。

const result = await cacheClient.sortedSetGetRank(
  MOMENTO_CACHE_NAME, leaderboardName, name, {
    order: SortedSetOrder.Descending
  });

ステップ7: getLeaderboard関数を実装する

getLeaderboardルートの背後にある関数のコードは次のとおりですGET /{leaderboard}

const { initClient, getLeaderboard } = require('../lib/momento');
const middy = require('@middy/core');
const ssm = require('@middy/ssm');

const handler = async (event, context) => {
  const leaderboardName = event.pathParameters['leaderboard'];
  const startRank = parseInt(event.queryStringParameters['startRank']) || undefined;
  const endRank = parseInt(event.queryStringParameters['endRank']) || undefined;

  await initClient(context.MOMENTO_API_KEY);
  const leaderboard = await getLeaderboard(leaderboardName, startRank, endRank);
  return {
    statusCode: 200,
    body: JSON.stringify({
      leaderboard: leaderboard
    })
  };
};

module.exports.handler = middy(handler)
.use(ssm({
  cache: true,
  cacheExpiry: 5 * 60 * 1000,
  setToContext: true,
  fetchData: {
    MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
  }
}));

getLeaderboard共有モジュール内の対応する関数は次のとおりですmomento.js

async function getLeaderboard(leaderboardName, startRank, endRank) {
  const result = await cacheClient.sortedSetFetchByRank(
    MOMENTO_CACHE_NAME, leaderboardName, {
      startRank: startRank || 0, 
      endRank,
      order: SortedSetOrder.Descending
    });

  if (result.type === CacheSortedSetFetchResponse.Hit) {
    return result.value().map((item) => ({
      name: item.value,
      score: item.score
    }));
  } else if (result.type === CacheSortedSetFetchResponse.Miss) {
    return [];
  } else {
    logger.error('Failed to get leaderboard', {
      error: result.innerException(),
      errorMessage: result.message()
    });

    throw result.innerException();
  }
}

上記で、 の場合、Momento はリーダーボード上のすべてのユーザーを返すことに注意してくださいendRankundefinedこれはおそらく私たちが望んでいることではありません。

したがって、実際には、呼び出し元が を指定しない場合には、ページ サイズの制限を適用する必要がありますendRank。たとえば、endRankの場合undefined、 は と等しくなります(startRank || 0) + 20

ステップ8: デプロイとテスト

最後に、実行しcdk deployてテストしてください。

実際にどのように動作するかを確認したい場合は、こちらで全コースをご覧ください。

まとめ

Momento キャッシュの使いやすさを少しでもご理解いただけたかと思います。完全にサーバーレスで、使用した分だけ料金が発生します。インフラストラクチャを管理する必要がなく、拡張性を考慮して設計されています。

Momentoは、Daniele Frascaによるこの記事[9]で説明されているように、1秒あたり数万リクエスト(RPS)まで簡単に拡張できます。

しかし、これは単なるキャッシュではありません。すぐに使用できる WebSocket をサポートするトピックも備えています。トピックは、リアルタイム アプリケーションを構築するための非常に興味深い可能性を提供します。

次の記事では、Momento のトピックについて詳しく説明します。

リンク

[1]デモリポジトリ

[2]モメントホームページ

[3] MomentoソートセットAPIリファレンス

[4]モメントSDK

[5]モメントCLI

[6]サーバーレスエフェメラル環境の説明

[7]一時環境:SSMパラメータの再利用方法

[8] MiddyのSSMミドルウェア

[9]サーバーレスチャレンジ:Momentoをキャッシュアサイドパターンとして使用してスケーリングは実現可能か?

[10] [フルコース] Momentoを使ったリアルタイムアプリの構築