บทนำ
ในบทความนี้ เราจะสร้าง Serverless Mobile Application ด้วย Flutter, Serverless Framework, Upstash และ Redis สำหรับเก็บข้อมูล
Upstash คืออะไร?
Upstash เป็น Serverless Database สำหรับ Redis ด้วยราคาแบบ Per-Request หมายความว่าคุณไม่ถูกคิดค่าใช้จ่ายเมื่อ Database ไม่ได้ถูกใช้งาน
Upstash กำหนดค่าและจัดการ Database ให้คุณ เป็นทางเลือกที่แข็งแกร่งแทน DynamoDB และ Fauna พร้อมข้อดี:
- Low Latency
- ใช้งานง่าย คล้าย Redis APIs
ดู เอกสารเปรียบเทียบ Upstash กับทางเลือก Cloud อื่นๆ เพื่อเหตุผลที่ควรเลือก Upstash สำหรับโปรเจกต์ถัดไป
คุณยังสามารถดู บทความเปรียบเทียบ Serverless Databases ทั้งหมด ได้
กับ Upstash:
- เริ่มฟรี และจ่ายเฉพาะสิ่งที่ใช้
- มี Fast, Durable Storage
- เข้าถึง Database จากทั่วโลกได้ Latency ต่ำ ด้วย Global Databases และ Edge Caching
เพื่อสร้างแอปบน Upstash ได้อย่างมีประสิทธิภาพ คุณต้องเข้าใจ Redis มาเรียนรู้ Redis แบบสรุปและวิธีใช้งานใน Upstash App
หากต้องการรายละเอียดลึก แนะนำ Redis Official Website
Redis เป็น Open Source (BSD Licensed) In-Memory Data Structure Store ใช้เป็น Database, Cache, และ Message Broker รองรับ Data Structures มากมาย:
- Strings
- Hashes
- Lists
- Sets
- Sorted Sets with Range Queries
- Bitmaps
- HyperLogLogs
- Geospatial Indexes
โต้ตอบกับ Redis ผ่าน Commands และเก็บข้อมูลแบบ Key-Value โดย Key เป็น String และ Value เป็น Data Structure ใดๆ ที่ Redis รองรับ
เช่น ใช้ Command SET เก็บนามสกุล:
SET surname Rosius
surname คือ Key, Rosius คือ Value
สำคัญ: เก็บข้อมูลไว้ในลักษณะที่ Retrieve ได้ง่าย เพราะ Redis ไม่มีวิธีค้นหา Key โดย Value โดยตรง
ข้อมูลใน Redis เก็บถาวร ดึงข้อมูลที่ Key surname ด้วย:
GET surname
ผลลัพธ์: 'Rosius'
ลบค่าที่ Key surname:
DEL surname
ตัวอย่าง: เพิ่ม Likes ของโพสต์ ใช้ Command INCR (Atomic):
SET likes 10
INCR likes => 11
INCR likes => 12
INCR likes => 13
ตั้งค่า Likes เริ่มต้น 10 แล้ว Increment แบบ Atomic คุณอาจคิดว่าเพิ่มแบบนี้ก็ได้:
x = GET likes
x = x + 1
SET likes x
วิธีนี้ได้ก็ต่อเมื่อคุณเป็นผู้ใช้คนเดียว (ซึ่งไม่เคยเกิดจริง) เมื่อมี >2 คน Increment พร้อมกัน กระบวนการ (GET, Increment, SET) ไม่ใช่ Atomic อีกต่อไป:
x = GET likes (yields 10)
y = GET likes (yields 10)
x = x + 1 (x is now 11)
y = y + 1 (y is now 11)
SET likes x (likes is now 11)
SET likes y (likes is now 11)
User 1 ได้ค่า 10 เก็บใน x, User 2 ได้ค่า 10 เก็บใน y ทั้งคู่ +1 แล้ว SET ค่ากลับไป ผลคือ 11 แต่จริงควรเป็น 12 (Increment 2 ครั้ง) นั่นคือเหตุผลที่ Redis มี INCR Command แบบ Atomic แก้ปัญหานี้
Hash Data Type
Redis Hashes เทียบเท่า Hash ในภาษาโปรแกรมมิ่งอื่นๆ ประกอบด้วย Fields ที่เชื่อมโยงกับ Values
ตัวอย่าง: เก็บข้อมูลโปรไฟล์ผู้ใช้ใน Hash
HMSET userProfile:100034 "userId" 100034 "username" "Rosius Ndimofor" "firstName" "Rosius" "lastName" "Ndimofor" "profilePic" "rosius.jpeg"
Key ของ Hash คือ userProfile:100034 ตามด้วย Key-Value Pairs เช่น "userId" เป็น Key, 100034 เป็น Value ดึง First Name ด้วย HGET และ userId:
userId = 100034
HGET userProfile:{userId} firstName
หรือดึงข้อมูลโปรไฟล์ทั้งหมดด้วย HGETALL:
userId = 100034
HGETALL userProfile:{userId}
List Data Type
ก่อนหน้านี้กล่าวว่าใน Redis สำคัญมากที่จะเก็บข้อมูลตามรูปแบบที่จะ Retrieve แล้วถ้าอยากได้ Users ทั้งหมดบน Platform ล่ะ?
เราเพิ่งใช้ Hash เก็บ User Profile ตอนนี้ต้องการ Users ทั้งหมด วิธีง่ายที่สุดคือเก็บ UserIds ทุกคนใน List ด้วย LPUSH (Left Push):
LPUSH "users" userId
ดึง Users ทั้งหมดจาก List ด้วย LRANGE:
LRANGE "users" 0 -1
นี่คือบางส่วนของ Commands ที่เราจะใช้ แนะนำให้อ่าน Redis Official Site สำหรับ Commands อื่นๆ
แอป Full-Stack ของเราทำงานอย่างไร?
วันนี้เราจะสร้าง CRUD API กรณีใช้งาน (Use Case) มี 2 Entities: Users และ Posts ความสัมพันธ์ One-to-Many (User 1 คนมี Posts หลายโพสต์, Post 1 โพสต์屬於 User 1 คน)
User ได้รับอนุญาตให้:
- สร้างบัญชี (No Auth, เพิ่มได้กับ AWS Cognito หรือ Auth0)
- อัปเดตบัญชี
- ดูบัญชี
- สร้างโพสต์
- อัปเดตโพสต์
- Like โพสต์
- ดูรายการโพสต์ทั้งหมด
สร้างบัญชี Upstash
สร้างบัญชี Upstash ฟรีที่ Upstash Login แล้วสร้าง Upstash Database (Free Tier สร้างได้ 1 Database) บันทึก Redis Database Endpoint ไว้ (รูปแบบ: rediss://:2c9bb162c2444bf7ab689640bb2ead23@gusc1-smashing-bee-30249.upstash.io:00049)
สร้าง Serverless Project
Prerequisites
- AWS CLI
- NodeJS
- Serverless CLI
สร้าง Serverless Project ใหม่:
serverless
เลือก Node.js HTTP API ตั้งชื่อโปรเจกต์ กด Enter โครงสร้างเริ่มต้นจะเป็นดังภาพ
ในโฟลเดอร์นั้น เริ่ม Node Project:
npm init
ติดตั้ง Redis Client:
npm install ioredis
ติดตั้ง UUID (ใช้อย่างกว้างขวางในโปรเจกต์):
npm install uuid
เพิ่ม Redis Database Endpoint เป็น Environment Variable ใน serverless.yml:
provider:
name: aws
region: us-east-1
stage: dev
runtime: nodejs12.x
lambdaHashingVersion: "20201221"
environment:
REDIS_CLIENT: "rediss://:2c9bb162c2444bf7ab689640bb2ead23@gusc1-smashing-wasp-30249.upstash.io:30249"
สร้างโฟลเดอร์ users และ posts สำหรับ Lambda Functions ของแต่ละ Entity
สร้าง User (Create User)
User สร้างบัญชีด้วย: Username, First Name, Last Name, Profile Picture
สร้างไฟล์ create.js ในโฟลเดอร์ user Import และสร้าง Redis Client จาก Environment Variable
"use strict";
const uuid = require("uuid");
var Redis = require("ioredis");
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
Import UUID สำหรับสร้าง Unique IDs ต้องมี Data Structure 2 แบบ: Hash สำหรับ User Profile (Key: userItem:${userId}) และ List สำหรับ UserIds ทั้งหมด (users)
ใช้ HMSET สำหรับ Hash และ LPUSH สำหรับ List แต่ส่ง 2 Operations แยกกันไม่เหมาะสม Upstash รองรับ Pipelining ส่งหลาย Commands พร้อมกัน รับ Response กลับมาในลำดับเดียวกัน
client.pipeline(
await client.hmset(
`userItem:${userId}`,
"userId", userId,
"username", data.username,
"firstName", data.firstName,
"lastName", data.lastName,
"profilePic", data.profilePic,
"timestamp", timestamp
),
await client.lpush("users", userId)
);
ดึง User Profile ที่บันทึกแล้วและ Return ผ่าน API Gateway:
const userItem = await client.hgetall(`userItem:${userId}`);
อัปเดต serverless.yml สำหรับ Endpoint นี้:
functions:
createUser:
handler: user/create.createUser
events:
- http:
path: /user
method: post
Function ชื่อ createUser อยู่ใน user/create.js Handler = user/create.createUser Path = /user Method = POST
อัปเดต User (Update User)
User ต้องการอัปเดตโปรไฟล์ ใช้ Command HMSET และ userId (HMSET จะ Overwrite Fields ที่มีอยู่แล้ว)
"use strict";
const uuid = require("uuid");
var Redis = require("ioredis");
const username = "username";
const firstName = "firstName";
const lastName = "lastName";
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
module.exports.updateUser = async (event) => {
const timestamp = new Date().getTime();
const userId = event.pathParameters.id;
const data = JSON.parse(event.body);
if (userId == null) { return { statusCode: 400, body: JSON.stringify({ message: "Couldn't update the user item" }, null, 2) }; }
await client.hmset(`userItem:${userId}`, username, data.username, firstName, data.firstName, lastName, data.lastName);
const userItem = await client.hgetall(`userItem:${userId}`);
console.log(userItem);
return { statusCode: 200, body: JSON.stringify(userItem) };
};
ใน serverless.yml เพิ่ม:
updateUser:
handler: user/update.updateUser
events:
- http:
path: /user/{id}
method: put
ส่ง userId เป็น Path Parameter /user/{id}
ดู User (Get User)
ใช้ HGETALL และ Hash Key ดึงข้อมูลโปรไฟล์ User (หรือ HGET สำหรับ Field เฉพาะ เช่น FirstName)
สร้างไฟล์ get.js ในโฟลเดอร์ user:
"use strict";
var Redis = require("ioredis");
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
module.exports.getUserById = async (event) => {
const userId = event.pathParameters.id;
if (userId == null) { return { statusCode: 400, body: JSON.stringify({ message: "Couldn't get the user item" }, null, 2) }; }
console.log(`userId is ${userId}`);
const userItem = await client.hgetall(`userItem:${userId}`);
console.log(userItem);
return { statusCode: 200, body: JSON.stringify(userItem) };
};
ใน serverless.yml:
getUser:
handler: user/get.getUserById
events:
- http:
path: /user/{id}
method: get
รายการ Users (List Users)
ตอน Create User เรา LPUSH UserIds เข้า List users ตอนนี้ดึง IDs ทั้งหมดด้วย LRANGE
หมายเหตุ: LRANGE ไม่ได้มีประสิทธิภาพหาก List ใหญ่และต้องเข้าถึง Element กลาง List เนื่องจาก Redis Lists ใช้ Linked Lists หากต้องการ Deep Pagination ล้านรายการ ควรใช้ Sorted Sets แทน
หลังได้ UserIds ทั้งหมด Loop แต่ละ ID ดึง Profile ด้วย HGETALL:
"use strict";
var Redis = require("ioredis");
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
module.exports.getAllUsers = async (event) => {
let users = [];
let response = await client.lrange("users", 0, -1);
async function getAllUsers() {
let users = [];
await Promise.all(response.map(async (userId) => {
const item = await client.hgetall(`userItem:${userId}`);
users.push(item);
console.log(users);
}));
return users;
}
users = await getAllUsers();
return { statusCode: 200, body: JSON.stringify(users) };
};
ใน serverless.yml:
listUsers:
handler: user/list.getAllUsers
events:
- http:
path: /users
method: get
Deploy หากยังไม่ได้ทำ: sls deploy
Post
หลัง User สร้างบัญชีแล้ว ควรได้:
- Create a Post
- Get a Post by Id
- Get a List of their Posts
- Like/Unlike a Post (React to a Post)
สร้างโพสต์ (Create a Post)
Parameters: UserId, PostId (Auto UUID), PostText, PostImage, CreatedOn
3 Steps:
HMSETเก็บ Post Details ที่ Hash KeypostItem:${postId}LPUSHเพิ่ม PostId เข้า ListpostsLPUSHเพิ่ม PostId เข้า ListuserPost:${userId}
ใช้ Pipeline รันตามลำดับ สร้างไฟล์ create.js ในโฟลเดอร์ post:
"use strict";
const uuid = require("uuid");
var Redis = require("ioredis");
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
module.exports.createPost = async (event) => {
const timestamp = new Date().getTime();
const postId = uuid.v1();
const data = JSON.parse(event.body);
if (data == null) { return { statusCode: 400, body: JSON.stringify({ message: "Couldn't create the Post item" }, null, 2) }; }
console.log(`postId is ${postId}`);
client.pipeline(
await client.hmset(`postItem:${postId}`, "id", postId, "userId", data.userId, "postText", data.postText, "postImage", data.postImage, "createdOn", timestamp),
await client.lpush("posts", postId),
await client.lpush(`userPost:${data.userId}`, postId)
);
const postItem = await client.hgetall(`postItem:${postId}`);
console.log(postItem);
return { statusCode: 200, body: JSON.stringify(postItem) };
};
ใน serverless.yml:
createPost:
handler: post/create.createPost
events:
- http:
path: /post
method: post
ดูโพสต์ตาม ID (Get Post By Id)
ดึง Post Details และ Attach Post Admin Details + Reaction Count (Likes)
สร้างไฟล์ get.js ในโฟลเดอร์ post:
"use strict";
var Redis = require("ioredis");
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
module.exports.getPostById = async (event) => {
const postId = event.pathParameters.id;
if (postId == null) { return { statusCode: 400, body: JSON.stringify({ message: "Couldn't get the post item" }, null, 2) }; }
console.log(`postId is ${postId}`);
const postItem = await client.hgetall(`postItem:${postId}`);
const userItem = await client.hgetall(`userItem:${postItem.userId}`);
const postReactionsCount = await client.get(`postReactionsCount:${postId}`);
postItem["postAdmin"] = userItem;
postItem["reactionCount"] = postReactionsCount == null ? 0 : postReactionsCount;
console.log(postItem);
console.log(userItem);
return { statusCode: 200, body: JSON.stringify(postItem) };
};
โค้ดข้างต้น: 1) ดึง Post Details ด้วย hgetall Key postItem:${postId} 2) ดึง User Details ของ Post Admin ด้วย userItem:${postItem.userId} (Key เดียวกับ Create User) 3) ดึง Reaction Count ด้วย get Key postReactionsCount:${postId} (จะใช้ใน React Endpoint)
React to a Post (Like/Unlike)
Endpoint น่าสนใจที่สุด User Like/Unlike โพสต์ เมื่อคลิก Like ตรวจสอบว่า Like ไปแล้วหรือยัง: Like แล้ว -> Unlike, ยังไม่ Like -> Like (เหมือน Instagram/Twitter)
สร้างไฟล์ react_to_post.js ในโฟลเดอร์ post Endpoint รับ userId และ postId เป็น Path Parameters
ใช้ Sorted Set เก็บ UserId ที่ Like และ Timestamp:
zadd(`postReactions:${postId}`, timestamp, data.userId)
Key: postReactions:${postId} Sorted Sets เริ่มต้นด้วย Z zadd เพิ่ม Members พร้อม Scores ไปยัง Sorted Set
Increment postReactionsCount:${postId} ด้วย incr (Key นี้ใช้ใน Get Post By Id)
incr(`postReactionsCount:${postId}`)
บันทึก User Post Reaction Details ใน Hash:
hmset(`userPostReactions:${data.userId}`, "postId", postId, "userId", data.userId, "timestamp", timestamp)
Access Patterns ที่ได้:
- ดึง Post Reaction Count
- ดึง Users ที่ React ต่อ Post (Ascending/Descending)
- ดึง Posts ที่ User React
ตรวจสอบว่า User Like ไปแล้วหรือยังด้วย zscore:
zscore(`postReactions:${postId}`, data.userId)
ถ้า zscore เป็น Null -> ยังไม่ Like, ไม่เป็น Null -> Like แล้ว
โค้ดเต็ม:
"use strict";
const uuid = require("uuid");
var Redis = require("ioredis");
if (typeof client === "undefined") {
var client = new Redis(process.env.REDIS_CLIENT);
}
module.exports.reactToPost = async (event) => {
const timestamp = new Date().getTime();
const data = JSON.parse(event.body);
const postId = event.pathParameters.id;
if (data == null || postId == null) { return { statusCode: 400, body: JSON.stringify({ message: "Couldn't react to the Post" }, null, 2) }; }
console.log(`postId is ${postId}`);
console.log(`userId is ${data.userId}`);
const hasUserReacted = await client.zscore(`postReactions:${postId}`, data.userId);
if (hasUserReacted == null) {
// User hasn't reacted
client.pipeline(
await client.incr(`postReactionsCount:${postId}`),
await client.zadd(`postReactions:${postId}`, timestamp, data.userId),
await client.hmset(`userPostReactions:${data.userId}`, "postId", postId, "userId", data.userId, "timestamp", timestamp)
);
} else {
// User already reacted -> unreact
client.pipeline(
await client.decr(`postReactionsCount:${postId}`),
await client.zrem(`postReactions:${postId}`, data.userId),
await client.hdel(`userPostReactions:${data.userId}`, "postId", postId, "userId", data.userId, "timestamp", timestamp)
);
}
const postReactionsCount = await client.get(`postReactionsCount:${postId}`);
console.log(postReactionsCount);
return { statusCode: 200, body: JSON.stringify({ postReactionCount: parseInt(postReactionsCount) }) };
};
ใน serverless.yml:
reactToPosts:
handler: post/react_to_post.reactToPost
events:
- http:
path: /post/{id}/react
method: post
Deploy และ Test กด Send ใน API Tester (PostMan) หลายครั้ง ดู postReactionCount Toggle ระหว่าง 0 และ 1 เปลี่ยน UserId ทดสอบอีกครั้ง
ยังมี Access Patterns และ Fixes อื่นๆ มากมายที่เพิ่มได้ ลองท้าทายตัวเองขยายต่อไปนะครับ
ผมชอบ Learning Curve ของ Redis ที่ไม่ชันเกินไป และความจริงที่มันให้เก็บข้อมูลตามรูปแบบที่จะ Retrieve เรียนรู้ Access Patterns ก่อนเริ่มพัฒนาแอปเสมอ
เขียนบทความนี้สนุกมาก หวังว่าได้เรียนรู้อะไรบ้าง หากมีข้อผิดพลาด แจ้งมาได้เลย
ในบทความถัดไป เราจะสร้าง Flutter App Consume API นี้ Stay tuned!
Happy Coding Comrade✌🏿
อ้างอิง
- Upstash Docs
- Redis
- Flutter
- Fetching data from the internet