Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Redis

สร้าง Newsletter App ที่ Scale ได้ด้วย Upstash Redis และ Workflow

ในบล็อกนี้ เราจะสร้าง newsletter app ที่ผู้ใช้จะสามารถสมัครสมัครและเลือกได้ว่าอยากได้ newsletter บ่อยแค่ไหน เราจะใช้ Upstash Redis เพื่อจัดเก็บข้อมูลการสมัคร และ Upstash Workflow เพื่อจัดการ actions ของการจัดเก็บข้อมูล ส่งอีเมลต้อนรับ และจัดตารางส่ง newsletters ตามความชอบของผู้ใช้

แรงจูงใจ (Motivation)

ก่อนอื่น serverless environments ยอดเยี่ยม! พวกมัน scalable มากและเบาใจงบประมาณ อย่างไรก็ตาม พวกมันมาพร้อมกับข้อจำกัดบางอย่าง เช่น execution time limits สิ่งนี้อาจเป็นปัญหาโดยเฉพาะเมื่อคุณต้องรัน long-running tasks

นี่คือที่ที่ Upstash Workflow เข้ามามีบทบาท ด้วย Upstash Workflow คุณสามารถสร้าง persistent workflows ที่สามารถรันนานเท่าที่ต้องการได้ ดังนั้นคุณไม่ต้องกังวลเรื่อง serverless function timeouts อีกต่อไป

นี่คือรายการฟีเจอร์ที่คุณได้เมื่อใช้ Upstash Workflow:

  • No more serverless function timeouts: Workflows ของคุณสามารถรันนานเท่าที่ต้องการ
  • Automatic recovery: หากมีบางอย่างผิดพลาดและ workflow ล้มเหลวระหว่างทาง มันจะ recover อัตโนมัติ
  • Automatic retries: หากขั้นตอนใดใน workflow ล้มเหลว มันจะ retry อัตโนมัติ
  • Real-time monitoring: คุณสามารถ monitor workflows ของคุณใน real-time จาก Upstash Console

ข้อกำหนดเบื้องต้น (Prerequisites)

  • ความเข้าใจพื้นฐานเกี่ยวกับ Next.js applications
  • บัญชี Upstash สำหรับ Redis และ QStash tokens
  • บัญชี Vercel สำหรับ deployment
  • ngrok (แนะนำ) สำหรับ local development

Project Setup

มาเริ่มต้นด้วยการ bootstrap Next.js project ใหม่โดยใช้ create-next-app:

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

ตอนนี้ มาเพิ่ม dependencies ที่จำเป็นสำหรับโต้ตอบกับ Upstash QStash และ Redis services:

npm install @upstash/qstash @upstash/redis

Directory Structure

ก่อนจะดำดิ่งสู่โค้ด มาดูโครงสร้างโปรเจกต์เราจะจัดระเบียบอย่างไร:

  • src/app/: นี่คือที่ที่ main application components และ pages ของเราจะอยู่
  • src/app/api/: เราจะวาง API routes ที่นี่ - สำหรับ subscribe, unsubscribe, และจัดการ workflows
  • src/components/: โฟลเดอร์นี้จะมี subscription และ unsubscription form components
  • src/lib/: Utility functions สำหรับ Redis และ email sending จะอยู่ที่นี่
  • src/types/: เราจะเก็บ TypeScript type definitions ในไดเรกทอรีนี้

Environment Variables

เราต้องสร้างไฟล์ .env ที่รากของโปรเจกต์และเพิ่มสิ่งต่อไปนี้:

QSTASH_TOKEN= UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= EMAIL_SERVICE_URL= NEXT_PUBLIC_BASE_URL=
  • QSTASH_TOKEN: Upstash QStash token ของเราที่เข้าถึงได้จาก Upstash Console
  • UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN: ข้อมูลประจำตัว Upstash Redis ของเราที่เข้าถึงได้จาก Upstash Console
  • EMAIL_SERVICE_URL: Endpoint ของ email sending API ของเรา
  • NEXT_PUBLIC_BASE_URL: Base URL ของแอปพลิเคชันที่ปรับใช้แล้ว (เช่น https://your-app.vercel.app)

เราสามารถตั้งค่าตัวแปร UPSTASH_WORKFLOW_URL ในไฟล์ .env ของเราสำหรับ local development ด้วย ngrok URL ได้เช่นกัน เพื่อเรียนรู้เพิ่มเติมเกี่ยวกับวิธีพัฒนา workflows ใน local ด้วย ngrok โปรดอ้างอิง Upstash Documentation

ตัวแปรสภาพแวดล้อม UPSTASH_WORKFLOW_URL จำเป็นเฉพาะสำหรับ local development ใน production baseUrl parameter จะถูกตั้งค่าอัตโนมัติและสามารถละเว้นได้

Project Implementation

Subscription Form Component

Component SubscriptionForm อนุญาตให้ผู้ใช้ป้อนอีเมลและเลือกว่าอยากได้ newsletter บ่อยแค่ไหน เมื่อส่งฟอร์ม เราส่ง POST request ไปที่ /api/subscribe พร้อมข้อมูลฟอร์ม

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> ); }

Unsubscribe Form Component

Component UnsubscribeForm อนุญาตให้ผู้ใช้ป้อนอีเมลเพื่อยกเลิกการสมัครรับ newsletter เมื่อส่งฟอร์ม เราส่ง POST request ไปที่ /api/unsubscribe พร้อมข้อมูลอีเมล มันยัง pre-fill email field หากผู้ใช้คลิก unsubscribe link ในอีเมลหนึ่งในอีเมล

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 เพื่อจัดเก็บข้อมูลการสมัครของผู้ใช้

เพื่อใช้ Upsatsh Redis เราต้องตั้งค่าฐานข้อมูล Redis บน Upstash Console และได้ REST URL และ token ของเรา สำหรับข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ คุณสามารถดู Upstash Documentation ได้

redis.ts จะมี Redis client และ helper functions ของเราสำหรับโต้ตอบกับ 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; }

Email Sending Function

เพื่อส่งอีเมล เราจะใช้ email API ของตัวเราเองที่เราพัฒนาในบล็อกโพสต์ก่อนหน้าคราวนึงเกี่ยวกับการสร้าง Email Scheduler ด้วย QStash Python SDK

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()); } }

Type Definitions

เรายังต้องการ type definition สำหรับ subscription data:

src/types/index.ts

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

Subscribe API Route

เราจะสร้าง API route ที่จัดการ subscription requests เมื่อผู้ใช้ส่ง subscription form endpoint นี้จะตรวจสอบว่าผู้ใช้สมัครแล้วหรือยัง และ enqueue workflow เพื่อจัดการส่งอีเมลตามความถี่ที่ผู้ใช้เลือก

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 } ); } };

Unsubscribe API Route

เนื่องจากเรามี subscription route เราจึงต้องมี unsubscribe route ด้วย เมื่อมี request มา เราจะตรวจสอบว่าผู้ใช้สมัครแล้วหรือยัง และลบข้อมูลของพวกเขาออกจาก 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 } ); } };

Workflow API Route

ตอนนี้ นี่คือส่วนสนุก! เราจะสร้าง API route ที่จัดการ workflow สำหรับส่ง newsletters ที่ช่วงเวลาตามความถี่ที่ระบุ

Workflow ของเราจะทำดังนี้:

  1. จัดเก็บข้อมูลการสมัครของผู้ใช้ใน Redis
  2. ส่ง welcome email
  3. เข้าสู่ loop:
    • รอระยะเวลาตามความถี่ที่ระบุ
    • ตรวจสอบว่าผู้ใช้ยังคงสมัครรับอยู่หรือไม่
    • ส่ง newsletter email
    • ทำซ้ำจนกว่าจะส่ง newsletters จำนวนที่ตั้งไว้ เนื่องจากเราไม่ต้องการ infinite loop

src/app/api/workflow/route.ts

นี่คือตัวอย่างของ workflow ที่เสร็จสมบูรณ์สำหรับผู้ใช้ที่สมัคร รับ newsletter หนึ่งฉบับ และยกเลิกการสมัคร:

สร้าง Newsletter App ที่ Scale ได้ด้วย Upstash Redis และ Workflow

คุณสามารถเข้าถึงและ monitor workflows ของคุณจาก Upstash Console

Main Page Component

มา setup หน้าหลักของแอปพลิเคชันของเรา หน้านี้จะรวม subscription form และลิงก์ไปหน้า unsubscribe

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> ); }

Unsubscribe Page Component

สุดท้าย มาสร้างหน้า unsubscription

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> ); }

สรุป

และนี่คือมัน! เราสร้าง newsletter app เรียบง่ายแล้วโดยไม่ต้องกังวลเรื่อง serverless function timeouts

คุณสามารถหา source code ครบถ้วนของโปรเจกต์นี้บน GitHub และดู live demo ได้ที่นี่

สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ Upstash Workflow คุณสามารถอ้างอิง Upstash Documentation ได้

หากคุณมีคำถาม โปรดติดต่อเราบน Discord อย่าลืมสำรวจ Upstash Blog สำหรับ tutorials และ use cases เพิ่มเติม