Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

サーバーレスRedisを使ってSvelteKitアプリケーションを構築する方法

SvelteKitは、コンパイル時にアプリをビルドしてより小さく・高速なJavaScriptを生成するUIフレームワーク「Svelte」向けのフルスタックアプリケーションフレームワークです。SvelteKitではエンドポイントを使ってサーバーサイドのロジックを記述できますが、アプリケーションのデータをどのように永続化するかは開発者自身に委ねられています。

この記事では、SvelteKitアプリケーションでRedisを使ってデータを保存する方法を解説します。The Movie Database(TMDB) APIから取得した映画データをRedisでキャッシュし、さらにランダムな映画を表示する機能まで実装していきます。

デモを実際に動かすには、Redis接続文字列が必要です。お持ちでない場合はローカル環境でRedisを起動しても構いませんが、特におすすめしたいのがUpstashです。SvelteKitと同じくサーバーレスアプリケーションに最適化されており、リクエスト数が少なければ無料枠(執筆時点で1日10,000リクエストまで)で利用できます。どのRedisインスタンスを利用する場合でも、レイテンシを抑えるために、アプリケーションのデプロイ先に近いリージョンを選ぶようにしましょう。

前提条件

  • SvelteKitの基礎知識(ページとエンドポイントの違い、load関数、クエリパラメータやparamsの取得方法など)
  • デモを実行またはデプロイする場合は、TMDB APIキーとRedisインスタンス(例:Upstash

スタータープロジェクトの概要

まずスターターリポジトリをクローンしてください。mainブランチには完成版のコードがあるため、この記事に沿って進める場合はinitialブランチをチェックアウトしてください。変更内容をプッシュしたい場合は、先にリポジトリをフォークしておきましょう。

git clone https://github.com/geoffrich/movie-search-redis.git
cd movie-search-redis
git checkout initial

これはTMDB APIを使って映画を検索し、詳細を表示できる小さなSvelteKitアプリケーションです。APIレスポンスとのやり取りを簡単にするためにTypeScriptを採用していますが、RedisやSvelteKitの利用にTypeScriptは必須ではありません。

src/routes配下には、以下のルートがすでに用意されています。

  • /:ホームページを表示
  • /search:検索結果の一覧を表示。querypageをクエリパラメータとして受け取ります(例:?query=star wars&page=3で「Star Wars」の3ページ目を表示)
  • /search.json:TMDB APIに問い合わせて検索結果を返すサーバーエンドポイント。/searchと同じクエリパラメータを受け付けます
  • /movie/[id]:指定されたIDの映画詳細ページを表示(例:/movie/11
  • /movie/[id].json:TMDB APIから映画詳細を返すサーバーエンドポイント。TMDBのレスポンスはページで使う量より多いため、必要なデータだけに絞って返しています

動作中のデモはNetlifyで確認できます。

なお、TMDB APIへの呼び出しはすべてサーバーエンドポイント内でのみ行われています。これは、APIキーがクライアント側に露出しないようにするためです。

ローカル環境での実行方法

プロジェクトのルートに.envファイルを作成し、TMDB APIキーとRedis接続文字列を以下のように記述します。その後、npm installで依存関係をインストールし、npm run devでアプリを起動してください。

TMDB_API_KEY=KEY_GOES_HERE
REDIS_CONNECTION=CONNECTION_GOES_HERE

実行時にはprocess.env['TMDB_API_KEY']process.env['REDIS_CONNECTION']としてこれらの値にアクセスできます。.envファイルの値はdotenvを使ってhooks.ts内で読み込まれます。

RedisでAPIレスポンスをキャッシュする

既存プロジェクトに加えられる改善のひとつが、個別の映画データに対するAPIレスポンスをRedisにキャッシュすることです。現状、映画ページが読み込まれるたびにTMDB APIへリクエストが発生しています。初回リクエスト時にAPIレスポンスをRedisへ保存しておけば、以降はTMDBに再度問い合わせる必要がなくなります。

TMDB APIは高速なので必ずしもキャッシュが必要というわけではありませんが、応答に時間のかかるAPIやリクエスト制限のあるAPIを扱う際には非常に有効な手法です。また、万が一APIがダウンした場合の耐障害性向上にもつながります。

Redisクライアントライブラリのioredisはすでに依存関係に追加済みです。これはNode.js向けのRedisクライアントで、Redisデータベースとのやり取りを簡単に行えます。

まずsrc/lib/redis.tsファイルを作成しましょう。このファイルではRedisクライアントを初期化してエクスポートし、他の関数から利用できるようにします。あわせてキー生成用のヘルパー関数も定義します。

import Redis from "ioredis";

const connectionString = process.env["REDIS_CONNECTION"];

export const MOVIE_IDS_KEY = "movie_ids";

/** 指定されたIDの映画詳細をRedisに保存するためのキーを返す */
export function getMovieKey(id): string {
  return `movie:${id}`;
}

export default connectionString ? new Redis(connectionString) : new Redis();

この実装では、サーバーレスインスタンスごとに1つのRedisクライアントを作成しています。もうひとつの選択肢として、リクエストごとに接続を作成・切断する方法もあります。また、UpstashにはRedis接続の初期化が不要なREST APIも用意されています。どれが最適かは、アプリのレイテンシやメモリ要件によって変わってきます。

次に/src/routes/movie/[id].json.tsを開き、RedisクライアントとgetMovieKey関数をredis.tsからインポートします。

import redis, { getMovieKey } from "$lib/redis";

getMovieDetailsFromApi関数は、TMDB APIから映画の詳細情報とクレジット情報を取得して返す関数です。データを返す前に、その内容をRedisキャッシュに保存しておきましょう。次回以降はAPIではなくキャッシュからデータを取得できるようになります。そこで、キャッシュ処理を行う新しいcacheMovieResponse関数を追加します。

async function cacheMovieResponse(id: number, movie, credits) {
  try {
    const cache: MovieDetails = {
      movie,
      credits,
    };
    // 映画のレスポンスを24時間保存する
    await redis.set(getMovieKey(id), JSON.stringify(cache), "EX", 24 * 60 * 60);
  } catch (e) {
    console.log("Unable to cache", id, e);
  }
}

映画ごとに異なるキーでデータを保存します。たとえばID 11の映画情報なら、キーはmovie:11になります。redis.setの最後の2つの引数は、データを24時間(86,400秒)だけキャッシュすることを意味します。データを無期限にキャッシュするのは利用規約違反になりますし、時間が経てばデータは古くなるため避けるべきです。

JavaScriptオブジェクトはそのまま保存できないため、JSON.stringifyで文字列化する必要がある点にも注意してください。また、この処理で発生した例外はすべてキャッチし、エンドポイントの堅牢性を保っています。キャッシュできなかった場合でも、未処理の例外を投げるのではなくAPIからのデータをそのまま返せるようにしているのです。

続いて、新しく作成したcacheMovieResponse関数をgetMovieDetailsFromApi内で呼び出し、APIレスポンスをキャッシュに保存します。

async function getMovieDetailsFromApi(id: number) {
  const [movieResponse, creditsResponse] = await Promise.all([
    getMovieDetails(id),
    getCredits(id),
  ]);
  if (movieResponse.ok) {
    const movie = await movieResponse.json();
    const credits = await creditsResponse.json();

    // この行を追加
    await cacheMovieResponse(id, movie, credits);

    return {
      movie,
      credits,
    };
  }

  return {
    status: movieResponse.status,
  };
}

これでキャッシュへの保存はできるようになりましたが、まだキャッシュからデータを読み取る処理がありません。映画詳細をキャッシュから取得する関数を追加しましょう。

async function getMovieDetailsFromCache(
  id: number
): Promise<MovieDetails | Record<string, never>> {
  try {
    const cached: string = await redis.get(getMovieKey(id));
    if (cached) {
      const parsed: MovieDetails = JSON.parse(cached);
      console.log(`Found ${id} in cache`);
      return parsed;
    }
  } catch (e) {
    console.log("Unable to retrieve from cache", id, e);
  }
  return {};
}

データは文字列として保存されているため、利用可能なオブジェクトに戻すにはパースが必要です。前述の関数と同様に、例外はログに記録するだけでキャッシュ関数の外へ漏らしません。キャッシュから取得できない場合でも、常にAPIからフォールバックできるからです。

最後に、メインのリクエストハンドラでキャッシュ関数を呼び出します。キャッシュにデータが見つかれば即座にそれを返し、見つからなければ従来どおりAPIから取得します。

export const get: RequestHandler = async function ({ params }) {
	const { id: rawId } = params;
	// 入力値のバリデーションとサニタイズ
	const id = parseInt(rawId);
	if (isNaN(id)) {
		return {
			status: 400
		};
	}

	// 以下の行を追加
	const { movie, credits } = await getMovieDetailsFromCache(id);
	if (movie && credits) {
		return {
			body: adaptResponse(movie, credits)
		};
	}

	// キャッシュになければAPIへフォールバック
	const result = await getMovieDetailsFromApi(id);

このエンドポイントの最終的なコードはデモリポジトリで確認できます。

ここまでの変更を加えたら、任意の映画ページにアクセスしてみてください。ページを更新すると、コンソールに「Found id in cache」というログが表示されるはずです。これは、映画データの保存とキャッシュからの取得が正常に行われていることを示しています。

ランダムな映画を取得する

Redisの活用法はAPIレスポンスのキャッシュだけにとどまりません。ここでは、ユーザーをランダムな映画ページへリダイレクトするルートの作り方を見ていきます。

単純に1〜300,000の間で乱数を生成して映画IDとして使う、という方法はうまくいきません。この範囲のすべての数値が映画に対応しているわけではないからです(例えばID 1や1000の映画は存在しません)。しかも、新しい映画が追加されるたびに最大IDは変わり続けるため、追跡するのも困難です。

そこで、次の2段階のプロセスでランダムな映画を選択します。

  1. 検索クエリが実行されたとき、返却されたすべての映画IDをRedis Setに追加する。
  2. /movie/randomルートへのリクエスト時、そのSetからランダムなメンバーを1件取得し、対応する映画詳細ページへリダイレクトする。

最初は候補となる映画は少ないですが、検索が繰り返されるほど選択肢は増えていきます。

ランダム用のSetを登録するため、/src/routes/search.json.tsを以下のように更新します。

import type { RequestHandler } from "@sveltejs/kit";
import type { SearchResponse } from "$lib/types/tmdb";
import redis, { MOVIE_IDS_KEY } from "$lib/redis";

const VOTE_THRESHOLD = 20;

export const get: RequestHandler = async function ({ query }) {
  const searchQuery = query.get("query");
  const page = query.get("page") ?? 1;
  const response = await fetch(
    `https://api.themoviedb.org/3/search/movie?api_key=${process.env["TMDB_API_KEY"]}&page=${page}&include_adult=false&query=${searchQuery}`
  );
  const parsed: SearchResponse = await response.json();

  // 以下の行を追加
  const filteredMovies = parsed.results.filter(
    (movie) => movie.vote_count >= VOTE_THRESHOLD
  );
  if (filteredMovies.length > 0) {
    try {
      await redis.sadd(MOVIE_IDS_KEY, ...filteredMovies.map((r) => r.id));
    } catch (e) {
      console.log(e);
    }
  }

  return {
    body: parsed,
  };
};

注目すべきは、検索結果のすべての映画をSetに追加しているわけではない点です。投票数の少ない映画はユーザーに評価されている可能性が低いため、フィルタリングで除外しています。VOTE_THRESHOLDの値は好みに応じて調整してください。

この変更により、映画を検索するたびにSetにIDが蓄積されていきます。「Star Wars」「Lion King」「Spider Man」など、いくつか検索を実行してIDを追加してみましょう。

検索を何度か行うと、ランダム用のIDセットがRedisに保存されているはずです。次に、/movie/random用のエンドポイントとページを作成します。

src/routes/movie/random.json.ts

import type { RequestHandler } from "@sveltejs/kit";
import redis, { MOVIE_IDS_KEY } from "$lib/redis";

export const get: RequestHandler = async function () {
  const randomId = await redis.srandmember(MOVIE_IDS_KEY);
  return {
    body: randomId,
  };
};

このサーバーエンドポイントでは、SRANDMEMBERコマンドを使って映画IDセットからランダムに1件のIDを取得しています。

src/routes/movie/random.svelte

<script context="module" lang="ts">
  import type { Load } from "@sveltejs/kit";

  export const load: Load = async function ({ fetch }) {
    const result = await fetch(`/movie/random.json`);
    if (result.ok) {
      const id = await result.json();
      return {
        redirect: `/movie/${id}`,
        status: 303,
      };
    }

    return {
      status: result.status,
      error: new Error("Could not retrieve random id"),
    };
  };
</script>

対応するSvelteページはUIを描画しないため、load関数だけあれば十分です。この関数は直前に作成したサーバーエンドポイントを呼び出し、該当する映画ページへリダイレクトします。

これで完成です!https://localhost:3000/movie/randomにアクセスすると、先ほどの検索で登録した映画の中からランダムに1本選ばれたページへ自動的にリダイレクトされます。このルートへアクセスしやすくするために、/src/routes/__layout.svelteのナビゲーションにリンクを追加しておきましょう。

<header>
  <nav>
    <a href="/">Search</a>
    <a href="/movie/random">Random</a>
  </nav>
</header>

実際の動作はライブデモで確認できます。

まとめ

Redisの使い方はまだまだたくさんありますが、この記事を通じて、SvelteKitアプリにRedisを統合する基礎をしっかりと理解できたのではないでしょうか。最終的なコードはGitHubで、ライブデモはNetlifyで公開されています。

ご質問がある方はTwitterでお気軽にお声がけください。Svelteに関するその他の記事は著者のブログでも読むことができます。

  1. UpstashのPipeline APIでサーバーレスRedisを高速化:複数コマンドを1リクエストで実行

    Upstashは、ネイティブなRedis APIに加えてREST APIもサポートしており、開発者はサーバーレス関数やエッジ関数から接続の問題を気にせずRedisへアクセスできます。しかし、同じ関数内で複数のRedisコマンドを実行する場合、その回数だけデータベースへの呼び出しが発生してしまうという課題がありました。 そこで、コミュニティメンバーの一人である@MasterGates氏が、Discordチャンネルで素晴らしい提案をしてくれました。それが「Pipeline API」です。 Pipeline APIとは Pipeline APIは、RedisのPIPELINEコマンドをREST A

  2. Cloudflare WorkersとRedisで実現するエッジコンピューティング活用術

    エッジコンピューティングは、近年もっとも注目されている技術のひとつです。CDNがファイルをユーザーの近くに配置できるようにしたのと同じように、エッジコンピューティングはアプリケーションそのものをユーザーの近くで実行できるようにします。これにより、開発者はグローバルに分散され、高いパフォーマンスを発揮するアプリケーションを構築できるようになります。 Cloudflare Workersとステートレス性の課題 現在この分野をリードしている製品がCloudflare Workersです。コールドスタートのないサーバーレス実行環境を提供し、Cloudflareのグローバルネットワークを活かすことで、ア