ทำ API กับ Cloud Firestore ด้วย Cloud Functions for Firebase
ด้วยความที่เราอยากย้ายบล็อกใหม่ๆมาลง Firebase Hosting ของเรา เลยเอา data จาก blogspot มาลง Cloud Firestore แล้วก็ต้องสร้าง API เพื่อเอาไปใช้ต่อที่หน้าเว็บและในแอป
เกริ่นนำ
บรีฟคร่าวๆ : เราเองมีบล็อกที่ blogspot และใน medium บวกกับมีหน้าเว็บของตัวเองบน Firebase Hosting ทางนี้มองว่ามันก็มีหลายที่ไปนิดนึง เลยคิดว่าจะย้ายบล็อกที่ blogspot ไปยังตัวเว็บ Hosting ด้วยเลย จะได้มารวมในที่เดียว เราลองสำรวจแล้วคนชอบอ่านจาก medium มากกว่าด้วยนะ… เอาเป็นแนวทางปรับหน้าเว็บได้และทางเรานั้นก็ติดปัญหาบางอย่างในการใช้ API ของ blogspot ด้วย มันดันทำ lazyload ไม่ได้ซะนี่ เจ้า paging อะไรก็ไม่มี =_= เลยก็ต้อง move เพื่อเอาไปใช้เองนี่แหละจ้า
บวกกับเรื่อง Inbound Marketing ที่ website ของเราเป็น asset อะเนอะ
คิดว่าอย่างน้อยๆคิดว่าน่าจะได้เอาความรู้ที่ได้จากตอนไป vue.js workshop มาใช้ด้วย บวกกับ Cloud Firestore ด้วย ดังนั้นจึงทำ API หลังบ้านก่อน เพื่อสามารถเอาไปใช้งานต่อได้เลย
คำเตือน ตัวโค้ดอาจจะมีทั้ง Kotlin และ Node.js ควรใช้จักรยาน เอ้ยย วิจารณญาณในการรับชมจ้า เท่าที่อ่าน document มันก็ต่างที่ syntax ของภาษาจริงๆง่ะ
มาเริ่มทำ API เองกันเถอะ~
แน่นอนว่าเราต้องวางแผนในการทำเว็บ version ใหม่ของเราบน Firebase Hosting ก่อน ซึ่งเรื่องการทำ API ก็เป็นเรื่องสำคัญมากๆเลยนะ ระหว่างที่ทำไป เขียนบล็อกไป ก็ทำผิดๆถูกๆไปบ้าง แหะๆ เลยนำสิ่งที่ลองทำมาแบ่งปันกันเนอะ
Database ของ Firebase ใช้อะไรดีหล่ะ?
ใน Firebase จะมี Database 2 ตัวด้วยกัน คือ Realtime Database และ Cloud Firestore ซึ่งแน่นอนว่ามันเป็น NoSQL ทั้งคู่เลย เลยเปรียบเทียบความแตกต่างของแต่ละตัวดูจ้า
Choose a database: Cloud Firestore or Realtime Database | Firebase
Firebase offers two cloud-based, client-accessible database solutions that support realtime data syncing: Cloud…firebase.google.com
Realtime Database
- เป็น json tree ใหญ่ๆ ที่มี node ลึกสุดได้ 32 ชั้น
Cloud Firestore
- เก็บเป็น collection และ document
- global scale
- support offline บน web ด้วยนะเออ
จากการพิจารณาแล้ว เราเลือก Cloud Firestore เพราะว่า
- จัดการ data ง่ายกว่า Realtime Database ตรงที่เราสามารถดูเป็น item นั้นๆได้เลย
- เหมาะกับการ query หรือ search ที่ตรงการใช้งานของเรา
- support data type ได้หลากหลายกว่า
Supported data types | Firebase
This page describes the data types that Cloud Firestore supports. The following table lists the data types supported by…firebase.google.com
- order item ตามเวลาที่เรา publish ได้ด้วย
และเจ้า Cloud Firestore นั้น ได้ออกจาก beta ในวันที่ 31 มกราคม 2019 พร้อมเพิ่ม location ด้วยนะ
Cloud Firestore has Gone GA, Lower Pricing Tiers, New Locations, and more!
Hey there, Firebase developers! Did you hear the news? Cloud Firestore - our NoSQL database in the cloud for mobile and…firebase.googleblog.com
แล้ว collection of document อะไรเนี่ย มันคืออะไรอ่ะ?
ตอนแรกเราอ่านใน document เราก็งงๆนะ งั้นมองลองเป็นเอกสารใส่เข้าแฟ้มแล้วกันเนอะ แบบนี้ มองตัวแฟ้มเป็น Collection และ ตัวเอกสารกระดาษเป็น Document ซึ่งใน Document ก็จะมีรายละเอียดต่างๆ เรียกว่า Data ที่เก็บเป็น Field
ออกแบบ database โดยยกจากใน blogspot มาใช้
ก่อนอื่น เรามาดูกันก่อนว่า เมื่อเรา get blog จาก blogspot API แล้วได้อะไรบ้าง
อยากรู้ว่า blogspot API คืออะไร อ่านต่อได้ที่นี่จ้า
จากภาพพบว่าเราไม่ได้ใช้ทั้งหมดแน่นอน ตั้งแต่ตอนทำในแอปแล้วหล่ะ ดังนั้นเราดึงเฉพาะที่ใช้ ดังนี้
- หมวดไม่ต้อง modified ใดๆ จะมี id, published, url, title, content และ labels
- images เนื่องจากมันมีอันเดียวเลยเปลี่ยนเป็น coverUrl ที่เป็น String?
- เราเพิ่ม shortDescription โดยตัด content ให้เหลือ 140 คำแรก ให้เหมือนใน medium เพื่อนำไปแสดงในแต่ละ item ซึ่งก็ตามมีตามกรรม 555 ตัดคำไม่สวยหรอก เดี๋ยวหน้าบ้านเอาไปปรับต่อได้
เมื่อเราเอามา map กันจะได้แบบนี้
ซึ่งการออกแบบ database Cloud Firestore เราใส่ collection และ document แบบนี้
เรามีชื่อ Collection ว่า “blog” โดย Document เป็น id ของบล็อก และใน Document จะมี field ต่างๆ คือ id, published, url, title, content, coverUrl, labels และ shortDescription
ดังนั้น เราจึงเรียก API ของ blogspot ในแอพบล็อกที่เราเขียน ไปทำการ write data ลง Cloud Firestore
ผลสุดท้ายก็จะเป็นแบบนี้
เรียนรู้กระบวนท่าต่างๆ
เรามาเรียนนรู้การใช้ Cloud Firestore แบบคร่าวๆเนอะ ไม่ได้เขียนทุกกระบวนท่าเน้อ เดี๋ยวบล็อกยาวไป
แน่นอนว่าจากเมื่อสักครู่นั้น เราได้เริ่มใช้ Cloud Firestore ในการ write data ลงไปแล้วเนอะ
ต่อจากนี้เราจะแสดงตัวอย่างโค้ดที่เป็น node.js นะ
Initialize
ก่อนอื่นประกาศตัวแปรที่เป็นเจ้า Firestore ขึ้นมาก่อนนะ เพราะตัวแปรนี้เราจะเอาไปใช้ต่อ
const db = admin.firestore();
Data Model & Read Data
ก่อนอื่นเราต้องอ้างอิงถึง reference กันก่อน เรามี Collection ที่ชื่อว่า blog และมี Document เป็น id ของบล็อกนั้นๆเนอะ เราจะเรียกกันแบบนี้
let blogRef = db.collection(’blog’).doc(’1015419615744730650’);
แน่นอนถ้าเราอยากเรียกมันทั้งก้อนของ Collection ก็จะเรียกแบบนี้
db.collection('blog');
จริงๆเราสามารถเรียก document ที่เราต้องการแบบนี้ก็ได้นะ
db.document('blog/1015419615744730652');
Cloud Firestore Data model | Firebase
Cloud Firestore is a NoSQL, document-oriented database. Unlike a SQL database, there are no tables or rows. Instead…firebase.google.com
ดังนั้นการ Read Data นั้นเราต้องอ้างอิงจาก reference และใส่ get() ต่อท้าย และตามด้วย listener เพื่อนำ data ที่เรา get มาได้ไปใช้ต่อ
db.collection('blog').doc('1015419615744730652')
.get()
.then(snapshot => {
if (!snapshot.exists) {
console.log('No such document!');
} else {
console.log('Document data:', snapshot.data());
}
}.catch(err => {
console.log('Error getting document', err);
});
ซึ่งเราขอแยกส่วนการ read data ออกเป็นสองส่วน คือ เรามองว่าการอ้างอิง document หรือ collection ตามเงื่อนไขต่างๆ นับเป็น query แบบหนึ่งแล้วกัน
let query = db.collection('blog').doc('1015419615744730652');
จากนั้นเราจึง get data จาก query ที่เราต้องการ ซึ่งมีท่าประจำแบบนี้
query.get()
.then(snapshot => {
if (!snapshot.exists) {
console.log('No such document!');
} else {
console.log('Document data:', snapshot.data());
}
}.catch(err => {
console.log('Error getting document', err);
});
Write Data
ในตอนแรกเรา write data ผ่านแอพแอนดรอยด์ที่เราทำขึ้นมาแล้วเนอะ โดยเราจะเอา blog item แต่ละตัวไปอยู่ใน Collection ที่ชื่อว่า blog และ Document แต่ละ item เราจะอ้างอิงแต่ละ blog id และเราเอาแต่ละ item เข้าไปใน Document นั้นๆ แบบนี้
db.collection(“blog”).document(slug).set(blog)
การที่ set data เข้าไปใน Document นั้น เราจะต้องใส่เป็น hashmap ซึ่งจะมี key และ value
จากนั้นเราก็ใส่ listener เข้าไป เพื่อตรวจสอบว่าเขาเขียน data ได้เสร็จสมบูรณ์หรือไม่
Add data to Cloud Firestore | Firebase
Edit descriptionfirebase.google.com
Update Data
สมมุติเราจะ update ค่าต่างๆใน field เช่น เปลี่ยนหัวข้อในบล็อกใหม่ เราจะต้องเปลี่ยนค่าใน key ที่มีชื่อว่า title ใช่ม่ะ
db.collection(’blog’).doc(’1015419615744730652’)
.update({title : "ทำ API กับ Cloud Firestore ด้วย Cloud Function for Firebase"});
Query Data
แน่นอนว่าเราต้องต้องดึงข้อมูลต่างๆตามเงื่อนไข API ที่เขากำหนดไว้ ซึ่ง API blog ของเราจะมี 4 ตัวด้วยกัน คือ
- get all blogs ดึงบล็อกทั้งหมดออกมา
- get tag blog ดึงบล็อกเฉพาะที่ติดแท็กเรื่องนั้นๆมา
- get blog by id ดึงเฉพาะบล็อก id นั้นๆมาแสดง
- search content ให้คนอ่านสามารถค้นหา blog ด้วย keyword ได้
- สามารถทำ lazyload หรือ paging ได้ใน API ที่ 1–2, 4
get all blogs : เราจะเรียงลำดับบล็อกของเราจากใหม่ไปเก่า และให้คืน result มา 20 อัน แบบนี้
let query = db.collection('blog')
.orderBy("published", "desc").limit(20)
- orderBy เราต้องการให้ field ไหน เรียงข้อมูลแบบไหน สามารถเรียงได้สองแบบ คือ desc จากมากไปน้อย และ asc จากน้อยไปมา
- limit ให้แสดงผลลัพธ์เป็นจำนวนเท่าไหร่
get blog by id : เรานำ id ที่ต้องการ ที่เราได้มาจากการกด item ของ blog นั้นๆ ไป search หาบล็อกที่มี id ตรงกัน เพื่อแสดงบล็อกนั้นๆ
let query = db.collection('blog')
.where("id", "==", request.query.id)
where เป็นการ filter ข้อมูลตามที่เราต้องการ ในที่นี้คือต้องการ id ซึ่งเราใส่ parameter id เข้าไปใน API ของเรา โดย query operation จะมี >, >=, == ,<=, < และมีอีกอันคือ array-contains เอาไว้ search data ที่อยู่ใน array
get tag blog เราค้นหา tag ต่างๆใน labels ซึ่งเป็นตัวแปรแบบ array ดังนั้นเราจะ where แบบ array-contains
let query = db.collection('blog')
.where("label", "array-contains", request.query.tag)
แต่ผลที่ได้ยังไม่ถูกต้องตรงใจนัก เราต้องการเรียงข้อมูลด้วยหน่ะสิ ดังนั้นเราจึงต้องเพิ่ม Composite Indexes ก่อน เพราะว่า ก่อนหน้านี้เราทำ Single-field indexes ใช่ม่ะ ซึ่งเป็น default ของเจ้า Firestore ดังนั้นการที่เรา where label ที่เป็น array พร้อมกับการ orderBy ด้วย จึงเป็นการ query แบบ multiple field
การเปิด Composite Indexes ไปที่หน้า Firebase console ของ Cloud Firestore ไปที่แท็ป Indexes เลือก Composite และ Add Index จากนั้นเราใส่ Collection และ Field to index ตามที่เราต้องการ หลังจากนั้นก็ใช้ได้เลยจ้า เย้ๆ
Index types in Cloud Firestore | Firebase
Indexes are an important factor in the performance of a database. Much like the index of a book which maps topics in a…firebase.google.com
Better Arrays in Cloud Firestore!
Arrays haven't always been the best data structure for multi-user environments like Cloud Firestore. As Kato describes…firebase.googleblog.com
Simple Cursor
เราสามารถใช้เจ้า Simple Cursor ในการ query ค่าต่างๆได้ โดยมี 4 methods คือ
startAt(A)
คืนค่าตั้งแต่ A ลงไปstartAfter(A)
คืนค่าหลัง A คือ B-ZendAt(Z)
คืนค่าท้ายสุดที่ ZendBefore(Z)
คืนค่าท้ายสุดก่อน Z
จริงๆเราค่อนข้างติดเรื่องนี้นานมาก จนมาลองนอนกลิ้งคิดดู เรานำ param มาค่านึงเพื่อเอามาใส่ แล้วให้มันแสดงบล็อกเพิ่มก็ได้นี่เนอะ แล้วจะทำยังไงดีนะ
ในที่นี้เราให้บล็อกแสดงทีละ 20 บล็อก (ไม่รู้ว่ามันเยอะไปไหมนะ 555) พอเรา scroll ลงมา ก็จะให้โหลดมาเพิ่มอีก 20 บล็อกเนอะ
ดังนั้น เราใช้เจ้า Simple Cursor ให้เป็นประโยชน์ โดยเราให้แสดงบล็อกหลังจาก set ก่อนหน้านี้นั่นเอง เนื่องจากเราให้แสดงบล็อกเรียงลำดับใหม่ไปเก่า เราจึงต้องใช้กับเจ้า published นั่นเอง
firestore.collection(‘blog’)
.orderBy(“published”, “desc”)
.startAfter(request.query.published)
.limit(limit)
ผลที่ได้ คือเราจะได้ list ของบล็อกที่ต่อจากเดิมจ้า คือหลังจากที่เรียงใหม่ไปเก่าตอนแรกเราจะได้ 0–19 พอโหลดต่อจะได้ 20–39 จ้า
ส่วนเรื่อง Paginate Data เราอ่านแล้วแอบงงๆ และนั่นคือสาเหตุที่ไปผิดทางจ้า ฮืออ
Paginate data with query cursors | Firebase
With query cursors in Cloud Firestore, you can split data returned by a query into batches according to the parameters…firebase.google.com
ปล. ในใจอยากทำ start, length แต่ทำไม่เป็น ฮือออออออ
Access Data Offline
เราสามารถทำให้เข้าถึงเมื่อตอน offline ได้ และ set ขนาดของ cache ที่เราจะเก็บได้ด้วย
Access data offline | Firebase
Cloud Firestore supports offline data persistence. This feature caches a copy of the Cloud Firestore data that your app…firebase.google.com
ทดสอบคร่าวๆผ่าน
ใน Firebase console ของ Cloud Firestore จะเป็นแบบนี้เนอะ
กดไปที่ปุ่ม filter จะเจอแบบนี้
เราสามารถเลือก field ว่าจะให้เรียงกันแบบไหน เช่น published เรียงแบบ descending ผลคือจะเรียงบล็อกที่เขียนล่าสุดลงมา
ซึ่งการใช้ condition นั้น สามารถเลือกได้เฉพาะ query operation ที่มี >, >=, == ,<=, <
การ run Cloud Function ที่เราเขียนขึ้นมา
เราเขียน function ของ API ที่สามารถ get all blogs, get blog by tag และ get blog by id ที่ชื่อว่า “blog” ดังนั้นเราจะ run function blog เนอะ
แน่นอนว่ามีสองแบบ คือแบบ run กับ emulator
firebase emulators:start — only functions:blog
Run functions locally | Firebase
The Firebase CLI includes a Cloud Functions emulator which can emulate the following function types: HTTPS functions…firebase.google.com
กับแบบ deploy จริง
firebase deploy — only functions:blog
ทริคเล็กๆน้อยๆ โปรเจก Firebase ของเราอันนี้สร้างมานานมากแล้วหลายปี ดังนั้น Google Cloud Platform (GCP) resource location จะอยู่ที่ nam5
(us-central) ดังนั้นเวลาเรา deploy จริงแล้วเรียกใช้ มันจะช้าๆ เนื่องจากว่าเราอยู่ไทย ตัว server location อยู่ที่เมกา ดังนั้นเราจึงต้องเปลี่ยน location Cloud Function ให้อยู่ใกล้เรามากที่สุด เลยเลือก asia-east2
(Hong Kong)
ดังนั้นเราจึง handle เพิ่มไปดังนี้
const builderFunction = functions.region('asia-east2').https;
แน่นอนว่ามันเร็วขึ้นแหละ แต่แอบขัดใจที่ตัว Firestore มันดันอยู่ที่ nam5 นี่สิ
แต่แอบเห็นอันนี้
Important: If you are using HTTP functions to serve dynamic content for Firebase Hosting, you must use us-central1.
เนื่องจากเราทำเว็บใหม่ซึ่งน่าจะเป็น dynamic content ดังนั้นจึง work ต่อในส่วนที่เราทำหน้าบ้านเนอะ เดี๋ยวเล่าให้ฟังอีกที
การทำหลังบ้านที่ถูกต้อง(หรอ?)
ใน 3 API นั้น เหมือนจะดี แต่เราไม่ควรพ่นข้อมูลออกมามากเกินไป ก็คือไม่ควรพ่นออกมาทั้งหมด ดังนั้นเราจึงเลือกให้พ่นแค่บางอย่างที่เราต้องการใช้เท่านั้น
ลองนึกเป็น SQL ดูสิ เรามีตารางชื่อว่า blog มี primary key คือ id ของบล็อกใช่ม่ะ
get all blogs และ get tag blog เราต้องการแค่ id, url, title, labels, shortDescription, coverUrl ก็จะเป็น
SELECT id, url, title, labels, shortDescription, coverUrl FROM blog
ส่วน get blog by id เราต้องการแค่ id, url, title, labels, coverUrl, published และ content ก็จะเป็น
SELECT id, url, title, labels, coverUrl, published, content FROM blog
ทั้งสองกรณีเราสามารถระบุให้แสดงผลลัพธ์เฉพาะ field ที่เราต้องการได้ อย่างในกรณีของ get all blogs และ get tag blog เราจึงสร้าง object ของผลลัพธ์แต่ละก้อน และนำมาใส่ใน list ของเรา และพ่นออกมาเป็น json data และเราสามารถระบุ field ที่ต้องการนำไปใช้ต่อได้แบบนี้
var data = {};
const result = [];
snapshot.forEach(doc => {
var blogElement = {};
var blog = doc.data();
console.log(blog);
blogElement.id = blog.id;
blogElement.url = blog.url;
blogElement.title = blog.title;
blogElement.label = blog.label;
blogElement.coverUrl = blog.coverUrl;
blogElement.shortDescription = blog.shortDescription;
result.push(blogElement);
});
response.contentType('application/json');
data.items = result;
response.send(data);
ส่วนในกรณีของ get blog by id นั้น สร้าง object ของผลลัพธ์แต่ละก้อน พ่นออกมาเป็น json data
var blogElement = {};
snapshot.forEach(doc => {
var blog = doc.data();
console.log(blog);
blogElement.id = blog.id;
blogElement.url = blog.url;
blogElement.title = blog.title;
blogElement.label = blog.label;
blogElement.coverUrl = blog.coverUrl;
blogElement.published = blog.published;
blogElement.content = blog.content;
});
response.contentType('application/json');
data.data = blogElement;
response.send(data);
จากการสอบถามพี่ backend ในทีมแล้ว ในกรณีที่เราต้องการผลลัพธ์เป็น list อย่าง get all blogs และ get tag blog เราจะต้องนำ list ของผลลัพธ์ทั้งหมด มาใส่ในรูปแบบนี้
{
"items": [
//ผลลัพธ์ที่เป็น list ทั้งหมด
]
}
ผลลัพธ์ที่ได้
ส่วน get blog by id นั้น เราจะให้พ่นมาแค่ก้อนเดียว ดังนั้นเรานำ object ที่ได้มาใส่ดังนี้
{
"data": {
//ผลลัพธ์ object
}
}
และผลลัพธ์ที่ได้
พิเศษสุดๆในกรณีที่ผลลัพธ์ออกมาเป็น list นั้น เราสร้าง object String เพิ่มมาตัวนึง เพื่อนำไปใช้ตอน lazy load ต่อไปจ้า
ทั้งนี้ ทั้งนั้น ทั้งโน้น จากการสอบถามพี่ backend เขาบอกว่า จริงๆมันไม่มี guideline อะไรที่ชัดเจนที่สามารถตอบคำถามของเราว่า “พี่ค่ะ หลังบ้านมีวิธีเขียนอย่างไรให้ถูกต้องบ้างค่ะ” และพี่เขาได้ฝากบล็อกนี้มาอ่านกันจ้า
หลังบ้านอ่ะ จริงๆเขาจะแนะนำให้เราใช้ express มากกว่า งั้นขอยกเรื่องเกี่ยวกับการเขียน API แบบจริงจัง ไปในบล็อกถัดๆไปตามความสะดวกของคนเขียนจ้า
Security Rule สำคัญมากๆนะ
ที่เราใช้หลักๆ อันแรกคือตอนอัพ data จาก Blogspot ไป Cloud Firestore ขอบอกว่า ไม่ควรใช้เน้อ เพราะใครก็ได้มาอ่านมาเขียนของเราอ่ะ
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow write, read: if true;
}
}
}
ส่วนปัจจุบัน security rule ของเราเป็นแบบนี้ ซึ่งมันก็น่าจะปลอดภัยได้กว่านี้อีกมั้งนะ
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow write, read: if request.auth.uid != null;
}
}
}
Get started with Cloud Firestore Security Rules | Firebase
With Cloud Firestore Security Rules, you can focus on building a great user experience without having to manage…firebase.google.com
ทาง Firebase มี video series ของเรื่อง Security Rule จ้า ซึ่งมีของ Firestore ด้วยแหละ นอกจาก read write ยังสามารถใส่ get, update, delete และอื่นๆได้ด้วยน้า
ทดสอบการใช้งาน API
ขอรวบรัดแบบย่อๆ
จริงๆเราเองก็สามารถทดสอบได้ระหว่างเขียน API นะเออ โดยการทดลองเอาไปเปิดใน postman ก่อนเนอะ จะเป็นอย่างนี้ถ้าสำเร็จ
เราสามารถตรวจสอบ log ต่างๆได้ที่ Firebase Console ที่หน้า Functions และไปที่ Logs จ้า ถ้ามัน error หรือ crash ก็สามารถนำไปแก้ได้ทันทีจ้า
และเอาไปแปะในแอพ และในเว็บที่ยังทำไม่เสร็จ
เก็บตกอื่นๆ
1) เราลองทำ search content เช่น คนอ่านอยากอ่านบล็อกที่เกี่ยวกับ Firebase ก็ให้แสดงบล็อกที่มีเนื้อหาที่เกี่ยวข้องกัน ซึ่งก็ต้องใช้ 3rd-party ตามที่ document บอกง่ะ
Full-text search | Firebase
Most apps allow users to search app content. For example, you may want to search for posts containing a certain word or…firebase.google.com
เอาจริงๆเขียนบล็อกนี้นานมากและเหนื่อยมากๆเลยอ่ะ แฮร่ๆ ได้แต่หวังว่าคนอ่านจะอ่านรู้เรื่องและนำไปใช้งานต่อได้จ้า
2) ลองถามอากู๋ถึงวิธีการทำ lazy load ใน Firestore ว่าทำยังไงดีนะ เจออันนี้ก็น่าจะสว่างแจ่มแจ้งเน้อ
จริงๆก็แอบเจออันนี้อยู่นะ
สุดท้ายฝากร้านกันสักนิด ฝากเพจด้วยนะจ๊ะ