ในบล็อกนี้ เราจะสร้างแอปจดหมายข่าวที่ผู้ใช้สามารถสมัครรับข้อมูลและเลือกความถี่ที่ต้องการรับจดหมายข่าวได้ เราจะใช้ Upstash Redis เพื่อจัดเก็บข้อมูลการสมัครสมาชิกและ Upstash Workflow เพื่อจัดการการดำเนินการจัดเก็บข้อมูล การส่งอีเมลต้อนรับ และกำหนดเวลาจดหมายข่าวตามความต้องการของผู้ใช้
แรงจูงใจ
ก่อนอื่นเลย สภาพแวดล้อมแบบไร้เซิร์ฟเวอร์นั้นยอดเยี่ยมมาก! สามารถปรับขนาดได้สูงและประหยัดงบประมาณ อย่างไรก็ตาม มีข้อจำกัดบางประการ เช่น การจำกัดเวลาดำเนินการ นี่อาจเป็นปัญหาได้โดยเฉพาะเมื่อคุณต้องการรันงานที่ต้องใช้เวลานาน
นั่นคือที่ขั้นตอนการทำงานขั้นสูง เข้ามาเล่น ด้วย Upstash Workflow คุณสามารถสร้างเวิร์กโฟลว์ถาวรที่สามารถทำงานได้นานเท่าที่ต้องการ ดังนั้นคุณจึงไม่ต้องกังวลกับการหมดเวลาของฟังก์ชันไร้เซิร์ฟเวอร์อีกต่อไป
นี่คือรายการคุณสมบัติที่คุณได้รับเมื่อใช้ Upstash Workflow:
- ไม่มีการหมดเวลาของฟังก์ชันไร้เซิร์ฟเวอร์อีกต่อไป :เวิร์กโฟลว์ของคุณสามารถทำงานได้นานเท่าที่จำเป็น
- การกู้คืนอัตโนมัติ :หากมีสิ่งผิดปกติเกิดขึ้นและเวิร์กโฟลว์ล้มเหลวกลางคัน ระบบจะกู้คืนโดยอัตโนมัติ
- ลองใหม่อัตโนมัติ :หากขั้นตอนใดๆ ในเวิร์กโฟลว์ล้มเหลว ขั้นตอนนั้นจะถูกลองใหม่โดยอัตโนมัติ
- การตรวจสอบแบบเรียลไทม์ :คุณสามารถตรวจสอบขั้นตอนการทำงานของคุณแบบเรียลไทม์ได้จาก Upstash Console
ข้อกำหนดเบื้องต้น
- ความเข้าใจพื้นฐานเกี่ยวกับแอปพลิเคชัน Next.js
- บัญชี Upstash สำหรับโทเค็น Redis และ QStash
- บัญชี Vercel สำหรับการปรับใช้
- งโกรก (แนะนำ) เพื่อการพัฒนาท้องถิ่น
การตั้งค่าโครงการ
มาเริ่มต้นด้วยการบูตโปรเจ็กต์ Next.js ใหม่โดยใช้ 08 :
npx create-next-app@latest --typescript newsletter-app
cd newsletter-app ตอนนี้ เรามาเพิ่มการขึ้นต่อกันที่จำเป็นเพื่อโต้ตอบกับบริการ Upstash QStash และ Redis:
npm install @upstash/qstash @upstash/redis โครงสร้างไดเรกทอรี
ก่อนที่จะเจาะลึกโค้ด เรามาดูกันว่าเราจะจัดระเบียบโครงการของเราอย่างไร:
11รหัส>:นี่คือที่ที่ส่วนประกอบและหน้าแอปพลิเคชันหลักของเราจะใช้งานอยู่23รหัส>:เราจะใส่เส้นทาง API ของเราไว้ที่นี่—สำหรับการสมัครสมาชิก การยกเลิกการสมัคร และการจัดการเวิร์กโฟลว์30รหัส>:โฟลเดอร์นี้จะมีส่วนประกอบของแบบฟอร์มการสมัครสมาชิกและการยกเลิกการสมัครสมาชิกของเรา43รหัส>:ฟังก์ชันยูทิลิตี้สำหรับ Redis และการส่งอีเมลจะอยู่ที่นี่51รหัส>:เราจะเก็บคำจำกัดความประเภท TypeScript ของเราไว้ในไดเร็กทอรีนี้
ตัวแปรสภาพแวดล้อม
เราจำเป็นต้องสร้าง 61 ไฟล์ที่รากของโครงการของเราและเพิ่มสิ่งต่อไปนี้:
QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
EMAIL_SERVICE_URL=
NEXT_PUBLIC_BASE_URL= - QSTASH_TOKEN :โทเค็น Upstash QStash ของเราเข้าถึงได้จากคอนโซล Upstash
- UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN :ข้อมูลประจำตัว Upstash Redis ของเราเข้าถึงได้จากคอนโซล Upstash
- EMAIL_SERVICE_URL :จุดสิ้นสุดของ API การส่งอีเมลของเรา
- NEXT_PUBLIC_BASE_URL :URL พื้นฐานของแอปพลิเคชันที่เราปรับใช้ (เช่น
76).
นอกจากนี้เรายังสามารถตั้งค่า 84 ได้อีกด้วย ตัวแปรใน 93 ของเรา ไฟล์เพื่อการพัฒนาท้องถิ่นด้วย ngrok URL ของเรา หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีพัฒนาเวิร์กโฟลว์ในเครื่องด้วย ngrok โปรดดูเอกสาร Upstash
102รหัส> ตัวแปรสภาพแวดล้อมมีความจำเป็นสำหรับการพัฒนาท้องถิ่นเท่านั้น ในการผลิต 116 พารามิเตอร์จะถูกตั้งค่าโดยอัตโนมัติและสามารถละเว้นได้
การดำเนินโครงการ
ส่วนประกอบแบบฟอร์มสมัครสมาชิก
125รหัส> ส่วนประกอบอนุญาตให้ผู้ใช้ป้อนอีเมลและเลือกความถี่ที่ต้องการรับจดหมายข่าว เมื่อส่งแบบฟอร์มแล้ว เราจะส่งคำขอ POST ไปที่ 130 ด้วยข้อมูลแบบฟอร์ม
"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>
);
} ยกเลิกการสมัครส่วนประกอบแบบฟอร์ม
144รหัส> องค์ประกอบอนุญาตให้ผู้ใช้ป้อนอีเมลเพื่อยกเลิกการสมัครรับจดหมายข่าว เมื่อส่งแบบฟอร์มแล้ว เราจะส่งคำขอ POST ไปที่ 159 ด้วยข้อมูลอีเมล นอกจากนี้ยังกรอกข้อมูลในช่องอีเมลล่วงหน้าหากผู้ใช้คลิกลิงก์ยกเลิกการสมัครในอีเมลฉบับใดฉบับหนึ่ง
"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 และโทเค็นของเรา สำหรับข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ คุณสามารถดูเอกสาร Upstash ได้
160รหัส> จะมีไคลเอนต์ Redis และฟังก์ชันตัวช่วยสำหรับการโต้ตอบกับ Redis:
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;
} ฟังก์ชันการส่งอีเมล
ในการส่งอีเมล เราจะใช้ API อีเมลของเราเองที่เราพัฒนาขึ้นในบล็อกโพสต์ก่อนหน้าเกี่ยวกับการสร้าง Email Scheduler ด้วย QStash Python SDK
src/lib/email.tsexport 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.tsexport type SubscriptionData = {
email: string;
frequency: string;
customFrequency?: string;
}; สมัครเส้นทาง API
เราจะสร้างเส้นทาง API ที่จัดการคำขอสมัครสมาชิก เมื่อผู้ใช้ส่งแบบฟอร์มการสมัคร จุดสิ้นสุดนี้จะตรวจสอบว่าผู้ใช้ได้สมัครแล้วหรือไม่ และจัดคิวเวิร์กโฟลว์เพื่อจัดการการส่งอีเมลตามความถี่ที่ผู้ใช้เลือก
src/app/api/subscribe/route.tsimport { 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.tsimport { 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 ที่จัดการขั้นตอนการทำงานสำหรับการส่งจดหมายข่าวตามช่วงความถี่ที่ระบุ
ขั้นตอนการทำงานของเราจะดำเนินการดังต่อไปนี้:
- จัดเก็บข้อมูลการสมัครสมาชิกของผู้ใช้ใน Redis
- ส่งอีเมลต้อนรับ
- ป้อนวงวน:
- รอตามระยะเวลาความถี่ที่ระบุ
- ตรวจสอบว่าผู้ใช้ยังคงสมัครรับข้อมูลอยู่หรือไม่
- ส่งอีเมลจดหมายข่าว
- ทำซ้ำจนกว่าจะมีการส่งจดหมายข่าวตามจำนวนที่กำหนด เนื่องจากเราไม่ต้องการวนซ้ำไม่สิ้นสุด
นี่คือตัวอย่างขั้นตอนการทำงานที่เสร็จสมบูรณ์สำหรับผู้ใช้ที่สมัครรับ รับจดหมายข่าวฉบับเดียว และยกเลิกการสมัคร:

คุณสามารถเข้าถึงและตรวจสอบขั้นตอนการทำงานของคุณได้จาก Upstash Console
ส่วนประกอบของหน้าหลัก
มาตั้งค่าหน้าหลักของแอปพลิเคชันของเรากันดีกว่า หน้านี้จะมีแบบฟอร์มสมัครสมาชิกและลิงก์ไปยังหน้ายกเลิกการสมัคร
src/app/page.tsximport 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.tsximport 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 โปรดดูที่เอกสารประกอบ Upstash
หากคุณมีคำถามใดๆ โปรดติดต่อเราได้ที่ Discord นอกจากนี้ อย่าลืมสำรวจ Upstash Blog เพื่อดูบทแนะนำและกรณีการใช้งานเพิ่มเติม