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

Upstash Redis と Upstash Workflow で作るスケーラブルなニュースレターアプリ

この記事では、ユーザーが購読登録を行い、ニュースレターの受信頻度を選択できるニュースレターアプリを構築します。購読データの保存には Upstash Redis を使用し、データの保存・ウェルカムメールの送信・ユーザーの設定に基づいたニュースレターの定期配信といった一連の処理の管理には Upstash Workflow を活用します。

開発の動機

まず前提として、サーバーレス環境は非常に優れています。高いスケーラビリティを備え、コスト面でも有利です。しかし一方で、実行時間の制限など、いくつかの制約も存在します。特に、長時間の実行が必要なタスクを扱う場合、この制限が大きな障害になることがあります。

そこで役立つのが Upstash Workflow です。Upstash Workflow を使えば、必要なだけ長く実行し続けられる永続的なワークフローを作成できます。もうサーバーレス関数のタイムアウトを心配する必要はありません。

Upstash Workflow を利用することで得られる主な機能は以下の通りです。

  • サーバーレス関数のタイムアウトを気にしない:ワークフローは必要なだけ長く実行できます。
  • 自動リカバリー:何らかの問題でワークフローが途中で失敗しても、自動的に復旧します。
  • 自動リトライ:ワークフロー内のステップが失敗した場合、自動的に再試行されます。
  • リアルタイム監視:Upstash Console からワークフローをリアルタイムで監視できます。

前提条件

  • Next.js アプリケーションに関する基礎知識
  • Redis および QStash のトークンを取得するための Upstash アカウント
  • デプロイ用の Vercel アカウント
  • ローカル開発用の ngrok(推奨)

プロジェクトのセットアップ

まずは create-next-app を使って、新しい Next.js プロジェクトを作成しましょう。

npx create-next-app@latest --typescript newsletter-app
cd newsletter-app

次に、Upstash QStash および Redis サービスと連携するために必要な依存パッケージを追加します。

npm install @upstash/qstash @upstash/redis

ディレクトリ構成

コードに入る前に、プロジェクト全体の構成を簡単に確認しておきましょう。

  • src/app/:アプリケーションの主要なコンポーネントとページを配置します。
  • src/app/api/:購読・購読解除・ワークフロー処理のための API ルートを配置します。
  • src/components/:購読フォームおよび購読解除フォームのコンポーネントを格納します。
  • src/lib/:Redis 操作やメール送信のためのユーティリティ関数を配置します。
  • src/types/:TypeScript の型定義を管理します。

環境変数

プロジェクトのルートに .env ファイルを作成し、以下の環境変数を追加してください。

QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
EMAIL_SERVICE_URL=
NEXT_PUBLIC_BASE_URL=
  • QSTASH_TOKEN:Upstash Console から取得できる QStash のトークンです。
  • UPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKEN:Upstash Console から取得できる Upstash Redis の認証情報です。
  • EMAIL_SERVICE_URL:メール送信 API のエンドポイントです。
  • NEXT_PUBLIC_BASE_URL:デプロイしたアプリケーションのベース URL(例:https://your-app.vercel.app)です。

また、ngrok の URL を使ったローカル開発のために、.env ファイルに UPSTASH_WORKFLOW_URL 変数を設定することもできます。ngrok を使ったワークフローのローカル開発方法について詳しくは、Upstash の公式ドキュメントをご参照ください。

なお、UPSTASH_WORKFLOW_URL 環境変数はローカル開発時にのみ必要です。本番環境では baseUrl パラメータが自動的に設定されるため、省略可能です。

プロジェクトの実装

購読フォームコンポーネント

SubscriptionForm コンポーネントでは、ユーザーがメールアドレスを入力し、ニュースレターの受信頻度を選択できます。フォームが送信されると、フォームデータとともに /api/subscribe へ POST リクエストが送信されます。

src/components/SubscriptionForm.tsx

"use client";
 
import React, { useState } from "react";
 
export default function SubscriptionForm() {
 const [frequency, setFrequency] = useState("daily");
 const [showCustomFrequency, setShowCustomFrequency] = useState(false);
 const [message, setMessage] = useState("");
 const [isError, setIsError] = useState(false);
 
 // Handle frequency selection
 const handleFrequencyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
 const value = e.target.value;
 setFrequency(value);
 setShowCustomFrequency(value === "custom");
 };
 
 // Handle form submission
 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
 e.preventDefault();
 setMessage("");
 setIsError(false);
 
 const formData = new FormData(e.currentTarget);
 
 try {
 const response = await fetch("/api/subscribe", {
 method: "POST",
 headers: {
 "Content-Type": "application/json",
 },
 body: JSON.stringify(Object.fromEntries(formData.entries())),
 });
 
 const result = await response.json();
 
 if (!response.ok) {
 setIsError(true);
 setMessage(result.error || "An error occurred during subscription.");
 } else {
 setIsError(false);
 setMessage(result.message || "Subscription successful!");
 }
 } catch (error) {
 console.error("An unexpected error occurred:", error);
 setIsError(true);
 setMessage("An unexpected error occurred.");
 }
 };
 
 // Render the form
 return (
 <form className="flex flex-col gap-4 text-gray-700" onSubmit={handleSubmit}>
 <input
 type="email"
 name="email"
 placeholder="Your Email"
 required
 className="border p-2 rounded"
 />
 <select
 name="frequency"
 value={frequency}
 onChange={handleFrequencyChange}
 required
 className="border p-2 rounded text-gray-700"
 >
 <option value="daily">Daily</option>
 <option value="weekly">Weekly</option>
 <option value="monthly">Monthly</option>
 <option value="custom">Custom Amount of Days</option>
 </select>
 {showCustomFrequency && (
 <input
 type="number"
 name="customFrequency"
 placeholder="Enter number of days"
 min="1"
 className="border p-2 rounded text-gray-700"
 required
 />
 )}
 <button type="submit" className="bg-blue-500 text-white p-2 rounded">
 Subscribe
 </button>
 
 {message && (
 <p className={`mt-2 ${isError ? "text-red-500" : "text-green-500"}`}>
 {message}
 </p>
 )}
 </form>
 );
}

購読解除フォームコンポーネント

UnsubscribeForm コンポーネントでは、ユーザーがメールアドレスを入力してニュースレターの購読を解除できます。フォーム送信時には、メールアドレスのデータとともに /api/unsubscribe へ POST リクエストが送信されます。また、メール内の購読解除リンクをクリックした場合には、メールアドレス欄が自動的に入力される仕組みになっています。

src/components/UnsubscribeForm.tsx

"use client";
 
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
 
const UnsubscribeForm = () => {
 const searchParams = useSearchParams();
 const [email, setEmail] = useState("");
 const [message, setMessage] = useState("");
 const [isError, setIsError] = useState(false);
 
 // Pre-fill email from query parameter
 useEffect(() => {
 const emailParam = searchParams.get("email");
 if (emailParam) {
 setEmail(emailParam);
 }
 }, [searchParams]);
 
 // Handle form submission
 const handleSubmit = async (e: React.FormEvent) => {
 e.preventDefault();
 setMessage("");
 setIsError(false);
 
 try {
 const response = await fetch("/api/unsubscribe", {
 method: "POST",
 headers: {
 "Content-Type": "application/json",
 },
 body: JSON.stringify({ email }),
 });
 
 const data = await response.json();
 
 if (response.ok) {
 setIsError(false);
 setMessage("You have been unsubscribed successfully.");
 } else {
 setIsError(true);
 setMessage(data.error || "Something went wrong. Please try again.");
 }
 } catch (error) {
 console.error("Error unsubscribing:", error);
 setIsError(true);
 setMessage("An unexpected error occurred. Please try again.");
 }
 };
 
 // Render the form
 return (
 <form className="flex flex-col gap-4 text-gray-700" onSubmit={handleSubmit}>
 <input
 type="email"
 name="email"
 value={email}
 onChange={(e) => setEmail(e.target.value)}
 placeholder="Your Email"
 required
 className="border p-2 rounded"
 />
 <button
 type="submit"
 className="bg-red-500 hover:bg-red-700 text-white p-2 rounded"
 >
 Unsubscribe
 </button>
 
 {message && (
 <p className={`mt-2 ${isError ? "text-red-500" : "text-green-500"}`}>
 {message}
 </p>
 )}
 </form>
 );
};
 
export default function UnsubscribePage() {
 return (
 <Suspense fallback={<div>Loading...</div>}>
 <UnsubscribeForm />
 </Suspense>
 );
}

Redis へのデータ保存

ユーザーの購読データの保存には、Upstash Redis を使用します。

Upstash Redis を利用するには、まず Upstash Console 上で Redis データベースを作成し、REST URL とトークンを取得する必要があります。詳細については、Upstash の公式ドキュメントをご確認ください。

redis.ts には、Redis クライアントおよび Redis とやり取りするためのヘルパー関数を定義します。

src/lib/redis.ts

import { Redis } from "@upstash/redis";
 
export const redis = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL!,
 token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
 
export async function getUserFrequency(email: string): Promise<number | null> {
 const data = await redis.get(`user:${email}`);
 console.log("User data:", data);
 if (!data) return null;
 
 const parsed = JSON.parse(JSON.stringify(data));
 return parsed.frequency;
}
 
export async function removeUser(email: string): Promise<void> {
 await redis.del(`user:${email}`);
}
 
export async function checkSubscription(email: string): Promise<boolean> {
 return (await getUserFrequency(email)) !== null;
}

メール送信関数

メールの送信には、以前の記事「QStash Python SDK による Email Scheduler の作成」で開発した独自のメール API を使用します。

src/lib/email.ts

export async function sendEmail(message: string, email: string) {
 console.log(`Sending email to ${email}`);
 const url = process.env.EMAIL_SERVICE_URL;
 const payload = {
 to_email: email,
 subject: "Upstash Newsletter",
 content: message,
 };
 
 if (!url) {
 console.error("EMAIL_SERVICE_URL is not defined.");
 return;
 }
 
 const response = await fetch(url, {
 method: "POST",
 headers: {
 "Content-Type": "application/json",
 },
 body: JSON.stringify(payload),
 });
 
 if (!response.ok) {
 console.error("Failed to send email:", await response.text());
 }
}

型定義

さらに、購読データ用の型定義も用意します。

src/types/index.ts

export type SubscriptionData = {
 email: string;
 frequency: string;
 customFrequency?: string;
};

購読 API ルート

次に、購読リクエストを処理する API ルートを作成します。ユーザーが購読フォームを送信すると、このエンドポイントが既に購読済みかどうかをチェックし、ユーザーが選択した頻度に応じてメール送信を担当するワークフローをキューに追加します。

src/app/api/subscribe/route.ts

import { NextRequest, NextResponse } from "next/server";
import { checkSubscription } from "@/lib/redis";
 
export const POST = async (request: NextRequest) => {
 try {
 const { email, frequency: freq, customFrequency } = await request.json();
 
 console.log("Email:", email);
 console.log("Frequency:", freq);
 console.log("Custom Frequency:", customFrequency);
 
 if (!email || !freq) {
 console.error("Email and frequency are required.");
 return NextResponse.json(
 { error: "Email and frequency are required." },
 { status: 400 }
 );
 }
 
 let frequency = freq;
 if (frequency === "custom") {
 if (!customFrequency) {
 console.error("Custom frequency days are required.");
 return NextResponse.json(
 { error: "Custom frequency days are required." },
 { status: 400 }
 );
 }
 frequency = customFrequency;
 }
 
 if (frequency === "daily") {
 frequency = "1";
 } else if (frequency === "weekly") {
 frequency = "7";
 } else if (frequency === "monthly") {
 frequency = "30";
 }
 
 const frequencyNumber = Number(frequency);
 
 if (isNaN(frequencyNumber) || frequencyNumber <= 0) {
 console.error("Invalid frequency value.");
 return NextResponse.json(
 { error: "Invalid frequency value." },
 { status: 400 }
 );
 }
 
 const exists = await checkSubscription(email);
 
 if (exists) {
 console.error("Email is already subscribed.");
 return NextResponse.json(
 { error: "Email is already subscribed." },
 { status: 400 }
 );
 }
 
 console.log("Subscription successful!");
 
 console.log("Enqueue the workflow");
 // Enqueue the workflow
 await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/workflow`, {
 method: "POST",
 headers: {
 Authorization: `Bearer ${process.env.QSTASH_TOKEN}`,
 "Content-Type": "application/json",
 },
 body: JSON.stringify({
 email: email,
 frequency: frequencyNumber,
 }),
 })
 .then((response) => {
 if (!response.ok) {
 console.error("Failed to enqueue workflow:", response.statusText);
 return NextResponse.json(
 { error: "Failed to enqueue workflow." },
 { status: 500 }
 );
 } else {
 console.log("Workflow enqueued successfully");
 }
 })
 .catch((error) => {
 console.error("Error enqueuing workflow:", error);
 return NextResponse.json(
 { error: "Error enqueuing workflow." },
 { status: 500 }
 );
 });
 
 return NextResponse.json({ message: "Subscription successful!" });
 } catch (error) {
 console.error("Error occurred:", error);
 return NextResponse.json(
 { error: "An error occurred during subscription." },
 { status: 500 }
 );
 }
};

購読解除 API ルート

購読ルートがある以上、購読解除ルートも必要です。リクエストを受け取ると、ユーザーが購読中かどうかを確認し、Redis から該当データを削除します。同時に、購読解除の確認メールも送信します。

src/app/api/unsubscribe/route.ts

import { NextRequest, NextResponse } from "next/server";
import { redis } from "@/lib/redis";
import { sendEmail } from "@/lib/email";
 
export const POST = async (request: NextRequest) => {
 try {
 const { email } = await request.json();
 
 if (!email) {
 return NextResponse.json(
 { error: "Email is required." },
 { status: 400 }
 );
 }
 
 const userExists = await redis.exists(`user:${email}`);
 if (!userExists) {
 return NextResponse.json(
 { error: "Email is not subscribed." },
 { status: 400 }
 );
 }
 
 // Remove the user from Redis
 await redis.del(`user:${email}`);
 
 // Send an email to confirm unsubscription
 await sendEmail(
 "You have been unsubscribed from Upstash Newsletter.",
 email
 );
 
 return NextResponse.json({ message: "You have been unsubscribed." });
 } catch (error) {
 console.error("Unsubscribe error:", error);
 return NextResponse.json(
 { error: "An error occurred. Please try again." },
 { status: 500 }
 );
 }
};

ワークフロー API ルート

さて、ここからが本題です。指定された間隔でニュースレターを配信するワークフローを処理する API ルートを作成します。

このワークフローは以下の手順を実行します。

  1. ユーザーの購読データを Redis に保存する。
  2. ウェルカムメールを送信する。
  3. ループ処理に入る。
    • 指定された頻度の期間だけ待機する。
    • ユーザーがまだ購読中かどうかを確認する。
    • ニュースレターのメールを送信する。
    • 無限ループを避けるため、あらかじめ設定した回数分のニュースレター送信が完了するまで繰り返す。

src/app/api/workflow/route.ts

以下は、あるユーザーが購読登録を行い、ニュースレターを 1 回受信した後、購読を解除するまでの一連のワークフロー完了例です。

Upstash Redis と Upstash Workflow で作るスケーラブルなニュースレターアプリ

ワークフローには、Upstash Console からアクセスして監視することができます。

メインページコンポーネント

続いて、アプリケーションのメインページを設定しましょう。このページには購読フォームと、購読解除ページへのリンクを含めます。

src/app/page.tsx

import SubscriptionForm from "@/components/SubscriptionForm";
import Link from "next/link";
 
export default function Home() {
 return (
 <main className="flex flex-col items-center justify-center min-h-screen p-4">
 <h1 className="text-3xl font-bold mb-6">
 Subscribe to Upstash Newsletter
 </h1>
 
 {/* Subscription Form */}
 <SubscriptionForm />
 
 {/* Unsubscribe Link */}
 <div className="mt-8">
 <p className="text-gray-600">
 Already subscribed and want to unsubscribe?
 <Link
 href="/unsubscribe"
 className="text-red-500 hover:text-red-700 font-bold ml-2"
 >
 Click here to unsubscribe
 </Link>
 </p>
 </div>
 </main>
 );
}

購読解除ページコンポーネント

最後に、購読解除ページを作成します。

src/app/unsubscribe/page.tsx

import UnsubscribePage from "@/components/UnsubscribeForm";
 
export default function UnsubscribeHome() {
 return (
 <main className="flex flex-col items-center justify-center min-h-screen p-4">
 <h1 className="text-3xl font-bold mb-6">
 Unsubscribe from Upstash Newsletter
 </h1>
 
 {/* Unsubscribe Form */}
 <UnsubscribePage />
 </main>
 );
}

まとめ

以上で完成です!サーバーレス関数のタイムアウトを一切気にすることなく、シンプルなニュースレターアプリを構築できました。

このプロジェクトの完全なソースコードは GitHub で公開されており、実際のデモもご覧いただけます。

Upstash Workflow について詳しく知りたい方は、Upstash の公式ドキュメントをご参照ください。

ご質問がある場合は、Discord でお気軽にお問い合わせください。また、Upstash Blog では他にも多数のチュートリアルやユースケースを紹介していますので、ぜひチェックしてみてください。

  1. Vercel EdgeとUpstash RedisでNext.jsにレート制限を実装する方法

    Vercel Edge Middlewareと@upstash/ratelimitライブラリを活用して、Webアプリケーションにレート制限(レートリミット)を実装する方法を解説します。@upstash/ratelimitは、バックエンドでRedisを使用してレート制限データの保存・管理を行うライブラリです。 Vercel Edgeを使うメリット Vercel Edgeは、ユーザーに最も近いロケーションで計算処理を実行するコンピューティングプラットフォームです。本記事では、リクエストがバックエンドに到達する前に処理を割り込ませるVercel Edge Middlewareを使用します。レート制限

  2. React Server Componentsで実装するリアルタイム閲覧数カウンターの作り方

    このチュートリアルでは、React Server Components(RSC)の仕組みを理解した上で、その知識を活かしてアプリにシンプルな閲覧数カウンターを実装していきます。すぐに実装に取り掛かりたい方は、「プロジェクトのセットアップ」までスキップしてください。 React Server Componentsを理解する React Server Componentsがどのように動作するのかを理解するために、まずクライアントサイドレンダリング(CSR)とサーバーサイドレンダリング(SSR)について簡単におさらいしましょう。 クライアントサイドレンダリング(CSR) CSRでは、レンダリング処理