เข้มข้นกับ Cloud Firestore ระบบฐานข้อมูลที่เปิดตัวใหม่ล่าสุดจาก Firebase แบบจัดเต็ม

Thanongkiat Tamtai
Firebase Thailand
Published in
20 min readOct 29, 2017

เมื่อเดือนตุลาคม ปี พ.ศ. 2560 ได้เปิดตัวบริการใหม่ชื่อว่า Firebase Cloud Firestore ซึ่งเป็นบริการในส่วนของ Database ที่ใช้ระบบฐานของข้อมูลแบบ NoSQL ที่เป็นแบบ Document Database และเป็นการนำเอาข้อดีต่างๆของบริการด้านฐานข้อมูลรุ่นพี่อย่าง Realtime Database มาปรับปรุงพัฒนาต่อและเพิ่มความสามารถขึ้นไปมากขึ้น เช่น การออกแบบโครงสร้างฐานข้อมูลที่ง่ายขึ้นและซับซ้อนน้อยลง (Flexibility) , การสอบถามข้อมูล (Query) ที่ง่ายขึ้น มีการกรองข้อมูล (Filter) มากขึ้นและมีการทำดัชนี (Index) ได้หลากหลายขึ้น , รองรับการขยายตัวของข้อมูลที่มากขึ้น (Scale) , เพิ่มการระบุชนิดของข้อมูล (Type) , การคัดลอกข้อมูลภายในฐานข้อมูลของเราไว้ในหลายภูมิภาค (Multi-region) และยังคงจุดเด่นของ Realtime Database ไว้อย่างครบถ้วน เช่น การรับรู้กระทำของข้อมูลในเวลาเดียวกัน (Real-time data synchronization) , การเข้าถึงข้อมูลโดยไม่มีอินเตอร์เน็ต (Offline support) , การป้องกันและสร้างกฏรักษาความปลอดภัยการเข้าถึงข้อมูล (Security & rule)

วิดีโอแนะนำการทำงานของ Firebase Cloud Firestore

การทำงานของ Firebase Cloud Firestore มันทำงานอย่างไร ?

อย่างที่เราทราบว่าทุกบริการของ Firebase นั้นเป็น Serverless นั้นหมายความว่าเราไม่ต้องจัดเตรียมพวกระบบ Back-end ใดๆ เองเลย แต่จัดเตรียม SDKs ของ Platform หรือ ภาษา ที่เราจะใช้ เพียงเท่านี้เราก็สามารถเข้าถึงบริการ Cloud Firestore ได้ทันที โดย SDKs ที่ทาง Firebase เตรียมไว้ให้เราก็มีอย่างครบครัน เช่น iOS, Android, Web, Node.js, Java, Python, Go, REST และ RPC APIs. โดยโครงสร้างจะเป็นแบบ NoSQL ที่เราสามารถจัดเก็บข้อมูลในรูปแบบ Document ที่จะผูก Fields กับ Values เข้าด้วยกัน ซึ่ง Document ก็จะถูกจัดเก็บใน Collections อีกทีนึง ซึ่งเราจะสามารถสร้าง Querie From ไปจัดการเอาข้อมูลที่เราต้องการได้ในแต่ละ Document โดยในบริการ Cloud Firestore สามารถระบุชนิดของข้อมูลได้ด้วย ไม่ว่าจะเป็น ข้อความ, ตัวเลขและในส่วนของข้อมูลที่มีความซับซ้อนมีการซ้อนกันของข้อมูลมากๆ เราก็สามารถสร้างเป็น Subcollections ภายใน Document และแบ่งข้อมูลเป็นลำดับชั้นเพื่อที่จะรองรับการเติบโตของข้อมูลในอนาคตได้ โดยเราสามารถออกแบบโครงสร้างได้ทุกรูปแบบที่จะสามารถทำงานได้อย่างดีที่สุดในของแอพเรา เพิ่มเติมอีกนิด ในกระบวนการ Query ข้อมูลใน Cloud Firestore มันดูแพง, มีประสิทธิภาพและทำให้เราทำงานสะดวกขึ้นด้วย เพราะ Syntax นั้นก็สั้น แถมมันยังสามารถไปเลือกเอาข้อมูลที่เราต้องการในระดับ Document ที่แตกต่างกัน โดยที่จะไม่เอาข้อมูลของระดับที่สูงกว่าหรือต่ำกว่าติดมาด้วยและยังเพิ่มการจัดเรียงข้อมูล (Sorting), การกรองข้อมูล (Filtering), การจำกัดข้อมูล (Limits), การแบ่งหน้าข้อมูล (Paginate) ที่มีความสามารถมากกว่าเดิม ซึ่งถ้าหากเราไม่อยากที่จะไปดึงข้อมูลทุกครั้งที่ข้อมูลมีการเปลี่ยนแปลง ก็ให้เราเพิ่ม Realtime listeners เอาไว้ โดยเราจะได้รับข้อมูลใหม่เฉพาะขณะที่ข้อมูลได้มีการเปลี่ยนแปลงเท่านั้นในส่วนของการป้องกันการเข้าถึงข้อมูลใน Cloud Firestore ก็สามารถผนวกกับบริการอย่าง Firebase Authentication และเรายังสามารถสร้างกฏการใช้งานของฐานข้อมูลเราได้เพียงที่เดียวก็จะสามารถใช้การในทุกๆ Platform หรือ Identity and Access Management (IAM) สำหรับภาษาฝั่ง server

การพัฒนา Firebase Cloud Firestore

การพัฒนา Cloud Firestore แบ่งออกแบบ 5 ขั้นตอน ดังนี้

  1. การสร้าง Cloud Firestore เพื่อใช้งานในโครงการ
  2. การติดตั้ง SDKs เพื่อใช้งาน Cloud Firestore
  3. การออกแบบโครงสร้างและการจัดการของข้อมูล
  4. การดึงและสอบถามข้อมูล
  5. การป้องกันและความปลอดภัยของข้อมูล

1 . การสร้าง Cloud Firestore เพื่อใช้งานในโครงการ

ในขั้นตอนแรกเราจะมาเริ่มสร้าง Database เพื่อที่จะใช้งาน Cloud Firestore กันก่อน โดยอันดับแรก ทุกคนจะต้องมี gmail กันก่อน ใครที่มีแล้วก็เข้าสู่ขั้นตอนต่อไปกันเลย ลำดับต่อมา เราจะเข้าไปที่เว็บ https://firebase.google.com จัดการเกี่ยวกับทุกๆบริการเกี่ยวกับ firebase ในอนาคต

https://firebase.google.com

Sing in ของใครของมันให้เรียบร้อย จากนั้นคลิก GO TO CONSOLE เพื่อที่จะเข้าไปสู่หน้าถัดไป

สำหรับใครที่พึ่งเข้ามาใช้งานครั้งแรก เราจะไม่มีโปรเจคใดๆ ให้กดปุ่ม เพิ่มโครงการ

จะมีหน้าต่างขึ้นมาให้เราใส่ชื่อโครงการและเลือกประเทศไทย ส่วนรหัสโครงการ ทาง Firebase จะเป็นคนสร้างให้เรา ซึ่งเราจะไปแก้ไขเองก็ได้แต่ห้ามซ้ำกับคนใช้ทั่วโลกและแก้ไขภายหลังไม่ได้ หลังจากนั้นกดปุ่ม สร้างโครงการ

หลังจากรอให้ทาง Firebase สร้างโครงการของเราสักครู่ จะพาเรามาที่หน้า https://console.firebase.google.com ซึ่งต่อไปนี้จะมีหน้าที่เราจะเห็นบ่อยๆ เพราะเป็นหน้าที่จะทำให้เราแยกไปตามบริการต่างๆ ของ Firebase ในโครงการของเรา ในส่วนของ Cloud Firestore จะอยู่ในส่วนของ Database ก็ให้เราคลิกเข้าไปที่ Database ทางด้านซ้าย

ทาง Firebase จะถามเราว่า เราจะใช้ Realtime Database ซึ่งเป็นบริการรุ่นพี่ หรืออยากจะเป็นหน่วยกล้าตายใช้ Cloud Firestore ที่เป็นรุ่นเบต้าอยู่ (ณ ขณะที่เขียนบทความ) แล้วเราจะรออะไร ? กดเลยสิครับ ลองใช้ FIRESTORE เบต้า

จากนั้นจะมีหน้าต่างขึ้นมาถามเกี่ยวกับกฏความปลอดภัยของโครงการเรา ให้เราเลือก เริ่มต้นในโหมดทดสอบ ไปก่อน แล้วกดปุ่ม เปิดใช้

ถ้าหากขึ้นหน้าตามาแบบนี้ แปลว่าเราพร้อมที่จะใช้งาน Cloud Firestore กันแล้ว

2. การติดตั้ง SDKs เพื่อใช้งาน Cloud Firestore

หลักจากที่เราเปิดใช้งาน Cloud Firestore ในโครงการกันแล้ว ขั้นตอนต่อไป เราจะมาลง SDKs ที่ทาง Firebase ได้เตรียมไว้ให้ โดยในตัวอย่างจะเป็นการลง SDKs ของ Platform Web โดยใน Platform ก็มีการลงคล้ายๆกันแล้วไม่ยากจนเกินไปสามารถดูรายละเอียดได้ที่ https://firebase.google.com/docs/firestore/quickstart

เพื่อความสะดวกและรวดเร็ว เราก็นำ Link ที่เก็บ File g-zip จาก CDN มาใช้ได้เลยโดยนำไปแปะที่ไฟล์ HTML

<script src="https://www.gstatic.com/firebasejs/4.5.1/firebase.js"></script>
<script src="https://www.gstatic.com/firebasejs/4.5.1/firebase-firestore.js"></script>

หรือใครอยากจะลงผ่าน npm ก็ใช้คำสั่ง

npm install firebase@4.5.1 --save

จากนั้นให้เราย้ายมาที่ไฟล์ javascript แล้ว Import Firebase เข้ามาใช้งาน

const firebase = require("firebase");

มาถึงขั้นตอนที่เราต้องทำการ Initialize ตัว instance ของ Cloud Firestore เพื่อที่ทาง Firebase จะได้รู้ว่าเรากำลังจะเชื่อมต่อไปที่โครงการของใครโดยใช้ Method initializeApp โดยภายในระบุเป็น Object ของ Firebase key ลงไป

firebase.initializeApp({
apiKey: '### FIREBASE API KEY ###',
authDomain: '### FIREBASE AUTH DOMAIN ###',
projectId: '### CLOUD FIRESTORE PROJECT ID ###'
});

// Initialize Cloud Firestore through Firebase
var db = firebase.firestore();

ซึ่งเราจะไปเอา Firebase key จากหน้าแรกโดยคลิกที่ Overview ทางด้านซ้าย

หลังจากนั้นคลิกที่คำว่า เพิ่ม Firebase ไปที่เว็บแอปของคุณ

เราจะได้พวก Firebase key ต่างๆ ของโครงการของเรา ให้เรากดปุ่ม คัดลอก และ นำมาวางแทนที่ Code ด้านบน หากทำครบตามขั้นตอนก็เป็นอันเสร็จสิ้น การติดตั้ง SDKs ของเราเพื่อใช้งานได้แล้ว

3. การออกแบบโครงสร้างและการจัดการของข้อมูล

ก่อนที่เราจะเริ่มการเขียนข้อมูลลงฐานข้อมูล เราต้องเข้าใจพื้นฐานของการเก็บข้อมูลบน Cloud Firestore กันสักหน่อย

https://firebase.google.com/docs/firestore/images/structure-data.png

อย่างที่เราได้เกริ่นไว้ตอนต้น ระบบฐานข้อมูลของ Cloud Firestore จะเป็น NoSQL แบบ Document ซึ่งจะไม่เหมือน ระบบฐานแบบ SQL โดยจะไม่มีตาราง ไม่มีแถว ใดๆ ทั้งสิ้น การเก็บข้อมูล ภายใน Document จะเก็บแบบ Key-value โดยแต่ละ Document จะถูกเก็บไว้ใน Collection ซึ่งใน Document สามารถมี subcollection ได้ด้วย

ลองให้เรานึกภาพว่าเรามีเอกสาร 1 แผ่น ข้างในนั้นก็จะมีเนื้อหาอยู่ภายในและเอกสารหลายๆแผ่น ก็จะเก็บอยู่ในแฟ้มอีกทีนึง

โดยที่เราไม่ต้องไปออกแบบตารางของฐานข้อมูลไว้ก่อนเหมือนอย่างระบบฐานแบบ SQL แต่เราสร้างสามารถสั่งเขียนข้อมูลลงไปได้เลย โดยระบุ Collection และ Document ถ้าหากไม่เคยมี Collection และ Document มาก่อนทาง Cloud Firestore จะสร้างให้เราเองโดยอัตโนมัติ ซึ่งทาง Cloud Firestore ก็ได้บอกไว้ว่า เหมาะสำหรับการเก็บข้อมูลโดยมี Collection จำนวนขนาดใหญ่ และ Document ขนาดเล็ก

เอาหล่ะเราลองมาดูตัวอย่างของการเก็บข้อมูลแบบต่างๆกัน

Document

Document เป็นการเรียนชื่อแทนหน่วยการเก็บของข้อมูลใน Cloud Firestore ภายในจะประกอบไปด้วย ชื่อของ Document , ชื่อของ key และ ค่าข้อมูล (value) โดยชื่อของ Document ห้ามซ้ำกัน ซึ่งใน Cloud Firestore สามารถให้เราระบุ Type ของข้อมูลได้คือ boolean, number, string, geo point, timestamp, array, object, reference และ null

เช่นดังรูป

ชื่อของ Document คือ User1

ชื่อของ key คือ Username , Class , born

ค่าข้อมูล คือ “Thanongkiat” (string) , “highschool6” (string) , 2049 (number)

Collection

Collection เป็นการเรียกชื่อแทนของการเก็บหลายๆเอกสารไว้ด้วยกัน เช่น เราจะเก็บ ข้อมูลของ user หลายๆ Document ไว้ด้วยกัน จึงตั้งชื่อ Collection ว่า Users ซึ่งใน Collection เดียวกันเราสามารถใส่ข้อมูลที่แตกต่างชนิดกันในแต่ละ Key แต่ละ Document ก็ได้ โดยในแต่ละ Key และ Document จะมีอิสระในการใส่ข้อมูลต่างๆลงไป แต่เราควรใส่ข้อมูลในแต่ละ Key ของ Document เป็นประเภทเดียวกันเพราะจะทำให้การค้นหาและการจัดเรียงลำดับของข้อมูลนั้นง่ายขึ้น

ข้อที่เราควรรู้ ของการเก็บข้อมูลแบบ Collection คือ ชื่อของ Collection ห้ามซ้ำกัน, ห้ามมีข้อมูลดิบใดๆหรือ Collection อื่นๆ อยู่ใน Collection ภายในจะเก็บได้เพียง Document เท่านั้น, เราไม่ต้องสร้างหรือลบ Collection ถ้าหากมีการเพิ่มข้อมูลลงไปโดยระบุชื่อ Collection และ Document จะทำให้ Collection นั้นถูกสร้างขึ้นโดยอัตโนมัติและถ้าหาก Collection นั้น ไม่มี Document ใดๆ เหลืออยู่เลย Collection จะถูกลบโดยอัตโนมัติเช่นกัน

Subcollection

หากว่าเราอยากทำลำดับชั้นของข้อมูลทาง Cloud Firestore ก็ตอบสนองสิ่งนั้น โดยเราสามารถสร้าง Subcollection ไว้ข้างใน Document เท่านั้น ซึ่งใน Subcollection ก็สามารถมี Document ได้อีก เราซึ่งสามารถสร้าง Subcollection ของ Subcollection ไปได้อีกเรื่อยๆ โดยทาง Cloud Firestore ได้บอกไว้ว่าสามารถซ้อนกันไปได้เยอะสุดคือ 100 ลำดับชั้น เรียกได้ว่าถ้าเปลี่ยนมาเป็นถุงทรายน้ำคงไม่ท่วมกรุงเทพแล้ว ฮ่า

References

มาถึงวิธีที่เราจะเข้าถึงข้อมูลกัน เราสามารถสร้าง Path references ไว้ได้ สมมุติว่าเราจะเข้าถึงข้อมูลใน Document User1 เราก็เพียงระบุชื่อ Collection ตามด้วย ชื่อ Document ที่เราจะเข้าถึงดังนี้

var userOneDocumentRef = db.collection('users').doc('user1');

หรือต้องการสร้าง Path ไว้เพื่อเข้าถึงเฉพาะแค่ Collection ก็สามารถทำได้

var usersCollectionRef = db.collection('users');

โดยทาง Cloud Firestore ได้บอกไว้ว่าสามารถ Path references ไว้ก่อนเยอะๆได้เพื่อความสะดวกในการใช้งานโดยไม่ใช้ทรัพยากรมากนัก (เทียบเท่ากับ Object ที่มีข้อมูลขนาดเล็กๆเท่านั้น) และ Path references ก็ไม่ได้ไปยุ่งเกี่ยวกับพวก Network operations เลยด้วย ว่ากันง่ายๆคือ แค่ประกาศ Path references ไว้เฉยๆ ไม่ได้ใช้อินเตอร์เน็ตไปเชื่อมต่อกับเซิพเวอร์เลยนั้นเอง

สมมุติว่าข้อมูลเรานั้นซ้อนกันลึกลงไปหลายๆชั้น การที่จะมา .collection() และ .doc() ทุกๆครั้งก็จะไปก็จะเป็นการเสียเวลาจนเกินไปโดยทาง Cloud Firestore ก็ได้ทำ Syntax ให้เราเข้าถึงได้ง่ายขึ้น

var userOneDocumentRef = db.doc('users/user1');

โดยจะเป็นการสลับกันระหว่างชื่อของ Collection และ ชื่อของ Document ไปเรื่อยๆโดยจะเริ่มต้นด้วยชื่อของ Collection ก่อนเสมอ

การออกแบบโครงสร้างของข้อมูล

หลังจากที่เราเข้าใจพื้นฐานของการเก็บข้อมูลบน Cloud Firestore เรียบร้อยแล้ว ในส่วนนี้จะเป็นการอธิบายประเภทของโครงสร้างหลักๆ ใน Cloud Firestore ซึ่งจะแบ่งการออกแบบโครงสร้างของฐานข้อมูลออกเป็น 3 แบบใหญ่ๆ

  1. Nested data in documents

การออกแบบโครงสร้างแบบ Nested data in documents จะเป็นการที่เราจะสร้างโครงสร้างย่อยๆลงไปใน Document เป็นชนิด Object หรือ Array

2. Subcollections

การออกแบบโครงสร้างแบบ Subcollections จะเป็นการที่เราจะสร้าง Subcollection ลงไปใน Document แต่จะมีจุดเริ่มต้นมาจาก root collection เดียวกัน

3. Root-level collections

การออกแบบโครงสร้างแบบ Root-level collections จะเป็นการที่เราจะสร้าง root collection หลายๆ ชนิด ซึ่งข้อมูลภายในแต่ละ collection อาจจะเกี่ยวข้องกันหรือไม่ก็ได้

โดยการออกแบบโครงสร้างของฐานข้อมูลนั้นเป็นเรื่องที่ละเอียดอ่อน แต่กลับมีความสำคัญมากกับตัวแอพพลิเคชั่นของเรา ถ้าหากเราออกแบบโครงสร้างของฐานข้อมูลไม่ดีจะมีผลต่อการทำงานของเราอย่างมาก อาจทำให้ประสิทธิภาพลดลงหรือการจัดการข้อมูลภายในเกิดความยากลำบากมากขึ้น โดยแต่ละโครงสร้างก็จะมีข้อดี
ข้อเสียแตกต่างกันไป การเลือกใช้โครงสร้างแต่ละชนิดควรจะมีเหตุผลประกอบเพื่อที่จะเลือกใช้ด้วย ซึ่งจะขอยกเรื่องของนี้ไปไว้เป็นบทความเฉพาะซึ่งจะมีเจาะข้อดีข้อเสีย ตัวอย่างสถานการณ์ที่ควรจะใช้งานแต่ละชนิด

การเพิ่มข้อมูล

การเพิ่มข้อมูลไปที่ Cloud Firestore จะมีหลายกรณีแตกต่างกันไปตามสถานการณ์ของเรา โดยจะมีหลายวิธีในการเพิ่มข้อมูล

  • การกำหนดข้อมูลใน Document โดยระบุชื่อของเอกสารให้ชัดเจน
  • เพิ่ม Document ใหม่ลงในคอลเล็กชัน ในกรณีนี้ Cloud Firestore จะสร้าง Index ของ Document โดยอัตโนมัติ
  • การสร้าง Document เปล่าๆ พร้อมกับการสร้าง Index โดยอัตโนมัติ ไว้เพื่อรอการกำหนดข้อมูลต่างๆในภายหลัง

โดยเราจะใช้ set() และ add() method เพื่อที่จะเขียนข้อมูลไปที่ Cloud Firestore

  1. การเขียนด้วย set()
// Add a new document in collection "cities"
db.collection("cities").doc("LA").set({
name: "Los Angeles",
state: "CA",
country: "USA"
})
.then(function() {
console.log("Document successfully written!");
})
.catch(function(error) {
console.error("Error writing document: ", error);
});

การเขียนข้อมูลด้วย Method Set() มีกฏอยู่ว่า

  • เราต้องระบุชื่อของ Collection และ Document เข้าไปเองทั้งหมด
  • ถ้าหากชื่อที่เราระบุไปยังไม่เคยมีข้อมูลมาก่อนมันจะถูกสร้างขึ้นมาใหม่
  • ถ้าหากมีข้อมูลในชื่อเดิมอยู่แล้วข้อมูลจะถูกเขียนทับ หากเราไม่ระบุ Option เพิ่มเติม
  • ถ้าไม่ต้องการให้ข้อมูลถูกเขียนทับให้ระบุ Parameter {merge: true} ใน Method set() เพิ่มเข้าไป
var cityRef = db.collection('cities').doc('BJ');

var setWithMerge = cityRef.set({
capital: true
}, { merge: true });

2. การเขียนด้วย add()

การเขียนข้อมูลด้วย Method add() ทาง Cloud Firestore จะสร้าง Index ให้เราโดยอัตโนมัติ

// Add a new document with a generated id.
db.collection("cities").add({
name: "Tokyo",
country: "Japan"
})
.then(function(docRef) {
console.log("Document written with ID: ", docRef.id);
})
.catch(function(error) {
console.error("Error adding document: ", error);
});

แต่เรื่องมันไม่จบง่ายๆ แค่นั้นหน่ะสิ๊ ใครที่ข้ามหรืออ่านผ่านๆ โปรดระวังให้ดี เพราะทาง Cloud Firestore เน้นย้ำเลยนะว่ามันไม่เหมือนกับ Push ID ใน Realtime Database ซะทีเดียวนะ การสร้าง Index ของ Cloud Firestore มันจะไม่ได้เรียงลำดับให้นะ บางทีเรา add ไปทีหลัง อาจจะไปอยู่ลำดับบนๆ ก็ได้ ถ้าอยากให้มันเรียงลำดับกันด้วย ก็ให้ระบุชื่อของ Document โดยใช้ Timestamp จะดีกว่า

3. สร้าง Document เปล่าๆ ด้วย doc()

การสร้าง Document เปล่าๆ แล้วค่อยมาเพิ่มก็ข้อมูลทีหลัง ก็ทำได้ไม่ยาก เพียงแค่ใช้ Method doc() ซึ่ง Cloud Firestore ก็จะสร้าง Index ให้เองเหมือน add() ทุกประการ

// Add a new document with a generated id.
var newCityRef = db.collection("cities").doc();

// later...
newCityRef.set(data);

เราก็สามารถเลือกใช้วิธีการเพิ่มข้อมูลทั้ง 3 วิธีได้ตามสถานการณ์จะนำพาไปโดยไม่มีความแตกต่างกันด้านประสิทธิภาพ

การแก้ไขข้อมูล

การแก้ไขข้อมูลคือการที่เราอยากจะเปลี่ยนแปลงข้อมูลบางส่วนของเอกสาร โดยที่จะไม่ไปเขียนทับทั้งหมด ซึ่งเราจะใช้ Method update()

var washingtonRef = db.collection("cities").doc("DC");

// Set the "capital" field of the city 'DC'
return washingtonRef.update({
capital: true
})
.then(function() {
console.log("Document successfully updated!");
})
.catch(function(error) {
// The document probably doesn't exist.
console.error("Error updating document: ", error);
});

ถ้าหากอยากจะ update ข้อมูลของก้อน Object ก็สามารถทำได้โดยใช้ . (เครื่องหมายจุด)

// Create an initial document to update.
var frankDocRef = db.collection("users").doc("frank");
frankDocRef.set({
name: "Frank",
favorites: { food: "Pizza", color: "Blue", subject: "recess" },
age: 12
});

// To update age and favorite color:
db.collection("users").doc("frank").update({
"age": 13,
"favorites.color": "Red"
})
.then(function() {
console.log("Document successfully updated!");
});

หรือจะเป็น update ของข้อมูล Timestamp

var docRef = db.collection('objects').doc('some-id');

// Update the timestamp field with the value from the server
var updateTimestamp = docRef.update({
timestamp: firebase.firestore.FieldValue.serverTimestamp()
});

การเขียนข้อมูลแบบ Transactions and Batched

ก่อนเราจะมาเรียนรู้การเขียนแบบ Transactions and Batched ต้องขอเท้าความไปถึงความหมายของแต่ละคำกันก่อน

  • Transactions เนี้ย หลายๆคนอาจจะเข้าใจว่า มันคือการที่เราส่ง Operator เช่น บวก ลบ คูณ หาร อะไรพวกนี้ ไปที่ฝั่ง Server แล้ว Server จะกระทำตาม Operator ส่งเข้าไปกับตัวแปรที่เรากำหนดไว้ โดยรอให้การกระทำ Operator แต่ละตัวเสร็จก่อน ถึงค่อยทำตัวต่อไป ใช่ไหมครับ ? ถ้าคุณเข้าใจแบบนี้ คุณกำลังเข้าใจถูกแค่ครึ่งเดียว จริงๆ แล้ว Transactions มันคือการรวมกันของ 3 Method เข้าด้วยกันตั้งหาก มันคือการ get() + doOperating() + update() อธิบายง่ายๆ คือจริงๆแล้วการทำ Transactions มันคือการที่เราจะไปเอาข้อมูลล่าสุดมาก่อน จากนั้นนำข้อมูลนั้นมาทำ Operating อะไรก็ว่าไปตามที่เราเขียน Logic แต่การกระทำนี้จะทำที่ฝั่ง Client นะ ไม่ใช่ฝั่ง Server หลังจากเสร็จเรียบร้อยก็จะส่งผลลัพธ์ที่ได้ไป update ข้อมูลที่ Server แต่หากว่ามี Client อื่นที่กำลังทำ Transaction พร้อมกันพอดีอาจทำให้เกิดการส่ง Transaction ไปหลายครั้งและเรารู้แล้วว่าการกระทำนี้ทำที่ฝั่ง Client ทำให้เมื่อ Client เปลี่ยนสถานะเป็น Offline ก่อนจะทำ Transaction เสร็จสิ้นจะทำให้กระบวนการทำ Transaction ล้มเหลว หรือมีกรณีที่สำคัญคือหากเกิดมีการทำกระบวนการเขียนข้อมูลก่อนกระบวนการอ่านข้อมูลก็จะทำให้กระบวนการทำ Transaction ล้มเหลวเช่นกัน ซึ่งหากเกิดกรณีใดๆ ที่ทำให้ Transaction ล้มเหลว เกิดขึ้น เราไม่ต้องที่จะดำเนินการใหม่อีกครั้ง ทาง Cloud Firestore จะดำเนินการให้เราอีกครั้งเอง
  • Batched คือการทำกระบวนหลายๆอย่างในการส่งคำสั่งเพียงชุดเดียว เช่น หากเราต้องการเขียนข้อมูลพร้อมกัน 2 ที่ ปกติเราจะส่ง คำสั่งเขียนข้อมูลไปอย่างละ 1 ที่ แต่ส่ง 2 ครั้ง แต่หากเราเขียนแบบ Batched จะทำให้เกิดโอกาสที่จะล้มเหลวของข้อมูลน้อยกว่าการส่งแบบปกติ ซึ่งจะมีประโยชน์มาก ไว้ขอติดไว้อธิบายแบบละเอียดในบทความเรื่อง Security Rules นะครับ

เอาหล่ะหลังจากเรียนรู้ความหมายของทั้งคู่ไปแล้ว เรามาดู Syntax ของการเขียนข้อมูลแบบ Transactions and Batched กัน

  1. Transactions

ขั้นตอนแรกคือเรียก Method runTransaction() จากนั้นก็ปฏิบัติตามขั้นตอนที่เคยอธิบายไว้ข้างบน คือ get() + doOperating() + update()

// Create a reference to the SF doc.
var sfDocRef = db.collection("cities").doc("SF");

// Uncomment to initialize the doc.
// sfDocRef.set({ population: 0 });

return db.runTransaction(function(transaction) {
// This code may get re-run multiple times if there are conflicts.
return transaction.get(sfDocRef).then(function(sfDoc) {
var newPopulation = sfDoc.data().population + 1;
transaction.update(sfDocRef, { population: newPopulation });
});
}).then(function() {
console.log("Transaction successfully committed!");
}).catch(function(error) {
console.log("Transaction failed: ", error);
});

หรือว่าจะนำผลลัพธ์ที่ได้จากขั้นตอน doOperating() มาทำอะไรต่อก็สามารถทำได้

// Create a reference to the SF doc.
var sfDocRef = db.collection("cities").doc("SF");

db.runTransaction(function(transaction) {
return transaction.get(sfDocRef).then(function(sfDoc) {
var newPopulation = sfDoc.data().population + 1;
if (newPopulation <= 1000000) {
transaction.update(sfDocRef, { population: newPopulation });
return newPopulation;
} else {
return Promise.reject("Sorry! Population is too big.");
}
});
}).then(function(newPopulation) {
console.log("Population increased to ", newPopulation);
}).catch(function(err) {
// This will be an "population is too big" error.
console.error(err);
});

2. Batched

ขั้นตอนแรกคือเรียก Method batch() จากนั้น ก็นำมา .set() .update() .delete() หรือตามที่เราต้องการพอเสร็จแล้วก็ .commit() เพียงเท่านี้ คำสั่งของเราจะถูกส่งไปเป็นชุดพร้อมกันทันที

// Get a new write batch
var batch = db.batch();

// Set the value of 'NYC'
var nycRef = db.collection("cities").doc("NYC");
batch.set(nycRef, {name: "New York City"});

// Update the population of 'SF'
var sfRef = db.collection("cities").doc("SF");
batch.update(sfRef, {"population": 1000000});

// Delete the city 'LA'
var laRef = db.collection("cities").doc("LA");
batch.delete(laRef);

// Commit the batch
batch.commit().then(function () {
// ...
});

การลบข้อมูล

การลบข้อมูลของ Cloud Firestore โดยใช้ Method delete() โดยก็มีการลบ 3 กรณี

  • ลบ Document
  • ลบ Field
  • ลบ Collection
  1. การลบ Document

การลบ Document ก็เพียงแต่นำ .delete() ต่อท้าย .doc() ที่เราต้องการจะลบ

db.collection("cities").doc("DC").delete().then(function() {
console.log("Document successfully deleted!");
}).catch(function(error) {
console.error("Error removing document: ", error);
});

2. การลบ Field

การลบ Field ต้องกระทำผ่าน Method update() ก่อนแล้วจึงระบุ Key ที่เราต้องการจะลบแล้วระบุค่าเป็น firebase.firestore.FieldValue.delete()

var cityRef = db.collection('cities').doc('BJ');

// Remove the 'capital' field from the document
var removeCapital = cityRef.update({
capital: firebase.firestore.FieldValue.delete()
});

3. การลบ Collection

การที่เราจะลบ Collection ถ้าหากจำกฏเดิมที่เคยบอกด้านบนของบทความได้ว่า หากไม่มี Document ใด เหลืออยู่ใน Collection นั้น Collection จะถูกลบไปเอง นั้นก็หมายความว่า ถ้าเราอยากจะลบ Collection เราจึงต้องลบ Document ออกให้หมดนั้นเอง ซึ่งกรณีนี้ยังเหมารวมไปถึง การลบ Subcollection ด้วย

/**
* Delete a collection, in batches of batchSize. Note that this does
* not recursively delete subcollections of documents in the collection
*/
function deleteCollection(db, collectionRef, batchSize) {
var query = collectionRef.orderBy('__name__').limit(batchSize);

return new Promise(function(resolve, reject) {
deleteQueryBatch(db, query, batchSize, resolve, reject);
});
}

function deleteQueryBatch(db, query, batchSize, resolve, reject) {
query.get()
.then((snapshot) => {
// When there are no documents left, we are done
if (snapshot.size == 0) {
return 0;
}

// Delete documents in a batch
var batch = db.batch();
snapshot.docs.forEach(function(doc) {
batch.delete(doc.ref);
});

return batch.commit().then(function() {
return snapshot.size;
});
}).then(function(numDeleted) {
if (numDeleted <= batchSize) {
resolve();
return;
}

// Recurse on the next process tick, to avoid
// exploding the stack.
process.nextTick(function() {
deleteQueryBatch(db, query, batchSize, resolve, reject);
});
})
.catch(reject);
}

โดยมีข้อกำหนดไว้คือการที่เราลบข้อมูลใน Document ใด แต่หากใน Document นั้นมี Subcollection จะไม่ถูกลบไปด้วย เราต้องไปไล่ลบด้วยตัวเองผ่านวิธีการที่กล่าวไว้ข้างบน

4. การรับและสอบถามข้อมูล

การรับข้อมูล

ในส่วนนี้จะพูดถึงการดึงข้อมูลและสอบถามข้อมูลจาก Cloud Firestore โดยจะมีการอยู่ 2 วิธี โดยทั้งคู่จะสามารถใช้ได้ทั้งการดึงข้อมูลและการสอบถามข้อมูล

  1. การรับข้อมูลเพียงครั้งเดียว

การรับข้อมูลเพียงครั้งเดียวจะเป็นการรับข้อมูลเมื่อเรามีจุดประสงค์ที่จะไม่ต้องการรับรู้การเปลี่ยนแปลงของข้อมูล เมื่อ ณ ขณะนั้นข้อมูลมีค่าเป็นอะไรก็จะได้ค่านั้นมา หากมีการเปลี่ยนแปลงข้อมูลในภายหลัง เราต้องเป็นผู้จัดการรับข้อมูลล่าสุดเอง โดยวิธีการรับข้อมูลเพียงครั้งเดียวจะใช้ Method get()

หากต้องการรับข้อมูล Document เดียว ก็เพียงนำ Path reference ที่สร้างไว้มาต่อท้ายด้วย .get() โดยนำ Callback ที่ได้รับมา .data() เพื่อแกะเอาข้อมูลข้างในออกมา

var docRef = db.collection("cities").doc("SF");

docRef.get().then(function(doc) {
if (doc.exists) {
console.log("Document data:", doc.data());
} else {
console.log("No such document!");
}
}).catch(function(error) {
console.log("Error getting document:", error);
});

หากต้องการรับข้อมูลหลายๆ Document โดยมีเงื่อนไขต่างๆ เราก็จะใช้ Method where() มาช่วยในการ Filter ระบุเงื่อนไขในการสอบถามข้อมูลของเรา จากนั้นก็ใช้ .get() เพื่อรับข้อมูลมา

db.collection("cities").where("capital", "==", true)
.get()
.then(function(querySnapshot) {
querySnapshot.forEach(function(doc) {
console.log(doc.id, " => ", doc.data());
});
})
.catch(function(error) {
console.log("Error getting documents: ", error);
});

หากต้องการรับข้อมูลทุก Document ใน Collection ก็เพียงแต่นำ Method get() ไปต่อท้ายจาก .collection() ที่เราต้องการได้เลย หรือ สามารถใช้ .where() แบบไม่ระบุ Parameter ของตัวกรองภายในก็ใช้ได้เหมือนกัน

db.collection("cities").get().then(function(querySnapshot) {
querySnapshot.forEach(function(doc) {
console.log(doc.id, " => ", doc.data());
});
});

โดย Method where() เราจะไปอธิบายกันละเอียดๆ ส่วนของการสอบถามข้อมูลด้านล่าง

2. การรับข้อมูลแบบ Realtime update

การรับข้อมูลแบบ Realtime update จะเป็นการรับข้อมูลเมื่อเรามีจุดประสงค์ที่จะต้องการรับรู้การเปลี่ยนแปลงของข้อมูล เมื่อ ณ ขณะที่ข้อมูลเกิดการเปลี่ยนแปลงจะมีการรับข้อมูลที่เกิดการเปลี่ยนแปลงโดยอัตโนมัติ โดยในครั้งแรกที่มีการรับข้อมูลจะสร้าง Initialize instance และทุกครั้งที่ข้อมูลมีการเปลี่ยนแปลงก็จะส่ง Callback ให้กับเราซึ่งเป็น Iistener โดยผ่าน Method onSnapshot()

ณ ขณะที่เขียนบทความอยู่ SDKs ที่ทาง Cloud Firestore มีให้จะยังไม่รองรับการรับข้อมูลแบบ Realtime update ในภาษา Java, Python และ Go นะครับ

หากต้องการรับข้อมูล Realtime update แบบ Document เดียว ก็เพียงนำ .onSnapshot() ไปต่อท้าย Path reference ที่เราสร้างไว้และนำ Callback ที่ได้รับมา .data() ดังเดิมเหมือนขั้นตอนรับข้อมูลครั้งเดียว

db.collection("cities").doc("SF")
.onSnapshot(function(doc) {
console.log("Current data: ", doc && doc.data());
});

หากต้องการรับข้อมูลหลายๆ Document โดยมีเงื่อนไขต่างๆ เราก็เปลี่ยนจากใช้ Method .get() เป็น .onSnapshot() เท่านั้นเองที่เหลือเหมือนเดิม

db.collection("cities").where("state", "==", "CA")
.onSnapshot(function(querySnapshot) {
var cities = [];
querySnapshot.forEach(function(doc) {
cities.push(doc.data().name);
});
console.log("Current cities in CA: ", cities.join(", "));
});

แต่หากเราอยากจะแยกกรณีของการที่ข้อมูลมีการเปลี่ยนแปลง เช่น เพิ่ม , แก้ไข หรือ ลบ ก็สามารถทำได้โดยใช้ Property .docChanges ของ Callback แล้วจึงนำมา For loop เพื่อวนหากรณีที่เราต้องการ

db.collection("cities").where("state", "==", "CA")
.onSnapshot(function(snapshot) {
snapshot.docChanges.forEach(function(change) {
if (change.type === "added") {
console.log("New city: ", change.doc.data());
}
if (change.type === "modified") {
console.log("Modified city: ", change.doc.data());
}
if (change.type === "removed") {
console.log("Removed city: ", change.doc.data());
}
});
});

โดยในบางครั้งหากเราเป็นผู้ที่เปลี่ยนแปลงข้อมูลที่เรากำลังเป็น Iistener อยู่พอดีอาจจะเกิดเหตุการณ์ที่เราจะได้รับการแจ้งเตือนข้อมูลนั้นก่อนการที่ข้อมูลจะถูกเขียนจริงๆบน Server จากเหตุที่อินเตอร์เน็ตของผู้ส่งที่ล่าช้า จึงเกิดเหตุการณ์ที่เรียกว่า “Latency compensation” ทำให้เราจะได้รับการอัพเดทข้อมูลนั้นถึงสองครั้งในช่วงเวลาที่ต่างกัน โดยทาง Cloud Firestore ก็จะมีวิธีให้เราได้ตรวจสอบว่าข้อมูลนั้นรับนั้นเป็นข้อมูลที่ส่งมาจาก Server หรือจาก Local เอง

db.collection("cities").doc("SF")
.onSnapshot(function(doc) {
var source = doc.metadata.hasPendingWrites ? "Local" : "Server";
console.log(source, " data: ", doc && doc.data());
});

แต่หากว่าเราใช้ ภาษา JavaScript เราสามารถใช้Promise เพื่อป้องกันการเกิดเหตุการณ์นี้ได้ หรือในภาษา Swift ก็จะมี Completion callback ใช้ให้ได้เหมือนกัน

เมื่อเราได้ทำการ Iistener การรับข้อมูลแบบ Realtime update แล้วนั้นหากว่าเราไม่มีความต้องการที่จะเรียกใช้การรับข้อมูลแล้ว เราควรที่จะยกเลิกจากสถานะการเป็น Iistener เพื่อหยุดการใช้งาน bandwidthในการรับการเปลี่ยนแปลงข้อมูล

var unsubscribe = db.collection("cities")
.onSnapshot(function () {});
// ...
// Stop listening to changes
unsubscribe();

และในบางครั้งการรับข้อมูลของเราก็ไม่ได้สำเร็จหรือผ่านไปด้วยดีเสมอไป เช่น เกิดจากการป้องกันความปลอดภัย หรือ เราสอบถามข้อมูลอย่างไม่ถูกวิธี เพราะฉะนั้นเราสามารถจะจัดการ Error ที่เกิดขึ้นกับเหตุการณ์ต่างๆได้

db.collection("cities")
.onSnapshot(function(snapshot) {
//...
}, function(error) {
//...
});

การสอบถามข้อมูล

มาถึงตอนพระเอกของเราแล้ว โดยสิ่งที่ทำให้ Cloud Firestore น่าใช้กว่า Firebase Realtime Database ก็เพราะตัวมันเองมีประสิทธิการสอบถามข้อมูล (Query) ที่ดีกว่าเดิม ถึงแม้จะยังไม่ดีที่สุดแต่ก็ทำให้เรามีความหวังมากขึ้นเพราะ ณ ขณะนี้ก็ยังเป็นเวอร์ชั่นเบต้า อนาคตจะต้องมีการปรับปรุงในรายละเอียดและเพิ่มฟีเจอร์ต่างๆเพิ่มมาอย่างแน่นอน

1. การสร้างการสอบถามข้อมูลอย่างง่ายและแบบผสมใน Cloud Firestore

เอาหล่ะอย่างที่เราเห็นมาแล้วข้างบนว่ามี Method where() เพื่อใช้ในการ Filter ข้อมูลต่างๆ เรามาดูกันก่อนว่า Method where() ภายในประกอบตัว Parameter อะไรบ้าง

ภายใน where() จะเป็นประกอบไปด้วย Parameter 3 ตัวคือ

  • fieldPath คือ ตัวแปรที่เราจะระบุการสอบถาม
  • opStr คือ Operation string เงื่อนไข เช่น “<”, “<=”, “==”, “>”, “>=” และตอนใช้งานอย่าลืมใส่เครื่องหมาย “ ” ลงไปด้วยเพราะมันเป็น string นะอย่าลืม
  • value คือ ค่าของ fieldPath ที่เราจะระบุการสอบถาม

สรุปแล้วรูปร่างของ where() จะมีหน้าตาเป็นแบบนี้

where(fieldPath, opStr, value)

ถ้าหากเป็น Platform หรือ ก็จะมีหน้าตาคล้ายๆ แต่ว่าเปลี่ยน Syntax เท่านั้นเอง

ต่อไปก็ลองมาดูตัวอย่างการใช้งานแบบง่ายๆ กัน สมมุติว่าข้อมูลตัวอย่างเป็นดังนี้

var citiesRef = db.collection("cities");

citiesRef.doc("SF").set({
name: "San Francisco", state: "CA", country: "USA",
capital: false, population: 860000 });
citiesRef.doc("LA").set({
name: "Los Angeles", state: "CA", country: "USA",
capital: false, population: 3900000 });
citiesRef.doc("DC").set({
name: "Washington, D.C.", state: null, country: "USA",
capital: true, population: 680000 });
citiesRef.doc("TOK").set({
name: "Tokyo", state: "null", country: "Japan",
capital: true, population: 9000000 });
citiesRef.doc("BJ").set({
name: "Beijing", state: null, country: "China",
capital: true, population: 21500000 });

หากเราต้องการค้นหา Document ของเมืองที่อยู่ในรัฐ California ย่อว่า CA

// Create a reference to the cities collection
var citiesRef = db.collection("cities");

// Create a query against the collection.
var query = citiesRef.where("state", "==", "CA");

หากต้องการค้นหา Document ของเมืองหลวงทั้งหมด

var citiesRef = db.collection("cities");

var query = citiesRef.where("capital", "==", true);

หรือตัวอย่างอื่นๆ

citiesRef.where("state", "==", "CA")
citiesRef.where("population", "<", 100000)
citiesRef.where("name", ">=", "San Francisco")

ต่อไปจะเป็นตัวอย่างของการสอบถามแบบผสมหลายเงื่อนไขดูบ้าง โดยวิธีการก็ยังใช้ Method where() เหมือนเดิมแต่ให้เดิมต่อท้าย where() ตัวแรกไปได้เลย โดยจะมีค่าเท่ากับ logical AND โดยถ้าหากเราใช้การระบุเงื่อนไขแบบเฉพาะเจาะจง (==) กับ เป็นช่วง (“<”, “<=”, “==”, “>”, “>=”) พร้อมกันเราต้องไปจัดการเรื่อง Index ก่อน ซึ่งจะกล่าวถึงวิธีการในหัวข้อต่อไป

โดยการสอบถามแบบผสมหลายเงื่อนไขจะมีกฏอยู่ว่า เราจะระบุเงื่อนไขแบบช่วงได้แค่ Field เดียว เช่น

แบบที่ถูกต้อง

citiesRef.where("state", "==", "CO").where("name", "==", "Denver")
citiesRef.where("state", "==", "CA").where("population", "<", 1000000)
citiesRef.where("state", ">=", "CA").where("state", "<=", "IN")
citiesRef.where("state", "==", "CA").where("population", ">", 1000000)

แบบที่ไม่ถูกต้อง

citiesRef.where("state", ">=", "CA").where("population", ">", 100000)

เพราะมีการระบุเงื่อนไขแบบช่วงถึง 2 Fields คือ “>=” ที่ state และ “>” ที่ population

2. การเรียงและจำกัดข้อมูล

หลังจากที่ดูตัวอย่างการ Filter โดยใช้ Method where() ไปเรียบร้อยแล้ว Cloud Firestore ก็ยังมีการ Query แบบการเลือกเรียงข้อมูล (OrderBy) ตาม Field ที่ระบุ หรือ การจำกัด (Limit) ผลลัพธ์ตามจำนวน

โดย Method orderBy() จะประกอบไปด้วย Parameter 2 ตัว

  • fieldPath คือ ตัวแปรที่เราจะระบุการสอบถาม
  • directionStr คือ การเรียงลำดับจากน้อยไปมากหรือมากไปน้อย

สรุปแล้วรูปร่างของ orderBy() จะมีหน้าตาเป็นแบบนี้

orderBy(fieldPath, directionStr)

และ Method limit() จะประกอบไปด้วย Parameter 1 ตัว

  • limit คือ จำนวนที่เราต้องการจะจำกัด ระบุเป็นเลขจำนวนเต็ม

สรุปแล้วรูปร่างของ limit() จะมีหน้าตาเป็นแบบนี้

limit(limit)

ทีนี้มาดูตัวอย่างการใช้งาน orderBy() และ limit()

หากเราต้องการชื่อของ 3 เมืองแรก โดยการเรียงตามตัวอักษร

citiesRef.orderBy("name").limit(3)

หรือเปลี่ยนเป็น 3 เมืองท้ายสุด

citiesRef.orderBy("name", "desc").limit(3)

หากเราต้องการเรียงผลลัพธ์Document ที่เรียงตามรัฐและแต่ละรัฐเรียงตามจำนวนประชากรจากมากไปน้อย

citiesRef.orderBy("state").orderBy("population", "desc")

โดยเราจะสามารถทำเงื่อนไขจาก Method where() มาใช้ร่วมกันได้ด้วย

หากต้องการผลลัพธ์ Document ที่ระบุเงื่อนไขของประชากรมากกว่าหนึ่งแสนโดยเรียงจากน้อยไปมากและจำกัดจำนวน 2 Document

citiesRef.where("population", ">", 100000).orderBy("population").limit(2)

อย่างไรก็ตามการใช้งาน orderBy() และ limit() ร่วมกับ where() ก็มีกฏอยู่ว่าถ้าเป็นการระบุเงื่อนไขเป็นช่วงใน where() และ orderBy() จะต้องเป็น Field เดียวกันด้วย เช่น

แบบที่ถูกต้อง

citiesRef.where("population", ">", 100000).orderBy("population")

แบบที่ไม่ถูกต้อง

citiesRef.where("population", ">", 100000).orderBy("country")

เพราะมีการระบุเงื่อนไขเป็นช่วงโดยใช้ “>” และ Field ที่ระบุใน where() คือ population ซึ่งใน orderBy() เป็น country

3. การสอบถามข้อมูลโดยตัวชี้เป้าหมายและแบ่งช่วง

เราได้เรียนรู้การสอบถามข้อมูลของ Cloud Firestore โดยใช้ Method where(), orderBy() และ limit() กันไปแล้ว ในส่วนนี้จะพูดถึงการที่เราจะ Filter ผลลัพธ์ของการสอบถามข้อมูลของเราโดยวิธีการระบุจุดเริ่มต้นหรือจุดสิ้นสุดของการค้นหาซึ่งใช้การชี้เป้าหมายและการแบ่งช่วงของผลลัพธ์ของการสอบถามออกเป็นกลุ่มย่อยๆ โดยเราจะใช้ Method startAt(), startAfter(), endAt() และ endBefore()

Method startAt() จะประกอบไปด้วย Parameter 1 ตัว

  • snapshotOrVarArgs คือ ค่าที่เราจะระบุเป็นจุดเริ่มต้นของการสอบถาม

สรุปแล้วรูปร่างของ startAt() จะมีหน้าตาเป็นแบบนี้

startAt(snapshotOrVarArgs)

Method startAfter() จะประกอบไปด้วย Parameter 1 ตัว

  • snapshotOrVarArgs คือ ค่าที่เราจะระบุเป็นจุดเริ่มต้นของการสอบถาม โดยเริ่มต้นหลังจากค่าที่ระบุเป็นต้นไป

สรุปแล้วรูปร่างของ startAfter() จะมีหน้าตาเป็นแบบนี้

startAfter(snapshotOrVarArgs)

Method endAt() จะประกอบไปด้วย Parameter 1 ตัว

  • snapshotOrVarArgs คือ ค่าที่เราจะระบุเป็นจุดสิ้นสุดของการสอบถาม

สรุปแล้วรูปร่างของ endAt() จะมีหน้าตาเป็นแบบนี้

endAt(snapshotOrVarArgs)

Method endBefore() จะประกอบไปด้วย Parameter 1 ตัว

  • snapshotOrVarArgs คือ ค่าที่เราจะระบุเป็นจุดสิ้นสุดของการสอบถาม โดยสิ้นสุดก่อนหน้าค่าที่ระบุเป็นต้นไป

สรุปแล้วรูปร่างของ endBefore() จะมีหน้าตาเป็นแบบนี้

startAfter(snapshotOrVarArgs)

4. การชี้เป้าหมายการสอบถามข้อมูล

สมมุติเรา มีตัวอักษร A-Z หากเราใช้ startAt(A) ก็จะได้ผลลัพธ์เป็น A-Z แต่หากใช้ startAfter(A) ก็จะได้ผลลัพธ์เป็น B-Z ซึ่งการใช้ endAt() และ endBefore() ก็จะให้ผลลัพธ์คล้ายกันแต่สลับเป็นการระบุจุดสิ้นสุดแทน

โดยกฏการจะใช้ Method startAt(), startAfter(), endAt() และ endBefore() จะต้องอยู่ต่อท้าย Method orderBy() เสมอ

ตัวอย่างเช่นหากต้องการผลลัพธ์ Document ที่เรียงตามจำนวนประชากรจากน้อยไปมากโดยเริ่มที่ 1 ล้านคนเป็นต้นไป

citiesRef.orderBy("population").startAt(1000000)

หรือต้องการผลลัพธ์ Document ที่เรียงตามจำนวนประชากรจากน้อยไปมากโดยสิ้นสุดที่ 1 ล้านคน

citiesRef.orderBy("population").endAt(1000000)

หรือหากเราจะใช้ผลลัพธ์จากการรับข้อมูลมาใช้ในการระบุตัวชี้เป้าหมายโดยการระบุค่าที่ได้จาก Callback ในตัวชี้เป้า เช่น ต้องการผลลัพธ์ Document ของเมืองที่มีประชากรใหญ่กว่าเมือง San Francisco

var citiesRef = db.collection("cities");

return citiesRef.doc("SF").get().then(function(doc) {
// Get all cities with a populateion bigger than San Francisco
var biggerThanSf = citiesRef
.orderBy("population")
.startAt(doc);

// ...
});

5. การแบ่งช่วงการสอบถามข้อมูล

การแบ่งช่วงการสอบถามข้อมูลจะเป็นการแบ่งการผลลัพธ์ของเราออกเป็นส่วนย่อยๆ โดยจะมีประโยชน์ในการที่เราต้องการจะแบ่งหน้าของการแสดงผลลัพธ์หรือแบ่งจำนวนการแสดงผลลัพธ์เป็นจำนวนที่กำหนดไว้ซึ่งใช้จุดเริ่มต้นหรือจุดสิ้นสุดจากครั้งก่อนมาเป็นตัวชี้เป้าหมายในครั้งต่อไปโดยเราจะใช้ Method limit() เพิ่มไปกับการชี้เป้าหมายการสอบถามข้อมูล

ตัวอย่างเช่นต้องการแสดงผลลัพธ์จากการสอบถามข้อมูลโดยเรียงจากจำนวนประชากรจากน้อยไปมากทีละ 25 และให้ผลลัพธ์ตัวสุดท้ายเป็นจุดเริ่มต้นในครั้งต่อไป

var first = db.collection("cities")
.orderBy("population")
.limit(25);

return first.get().then(function (documentSnapshots) {
// Get the last visible document
var lastVisible = documentSnapshots.docs[documentSnapshots.docs.length-1];
console.log("last", lastVisible);

// Construct a new query starting at this document,
// get the next 25 cities.
var next = db.collection("cities")
.orderBy("population")
.startAfter(lastVisible)
.limit(25);
});

6. การชี้เป้าหมายการสอบถามข้อมูลแบบหลายเป้าหมาย

หากการระบุเป้าหมายของการสอบถามข้อมูลยังไม่ได้ผลลัพธ์ที่ตรงกับจุดประสงค์ที่เราตั้งไว้เราสามารถเพิ่มการชี้เป้าหมายเพิ่มเข้าไปได้อีกเพื่อที่การระบุเป้าหมายจะทำได้ละเอียดมากขึ้นและให้ผลลัพธ์ตรงกับที่เราต้องการมากขึ้น

ตัวอย่างเช่นหากเราต้องการค้นหารัฐที่มีมหาลัยวิทยาลัย “Springfield” แต่เผอิญมหาลัยนี้เปิดในหลายรัฐ

หากเราต้องการเริ่มต้นตั้งแต่ Springfield ที่เริ่มต้นด้วย Missouri

// Will return all Springfields
db.collection("cities")
.orderBy("name")
.orderBy("state")
.startAt("Springfield")

// Will return "Springfield, Missouri" and "Springfield, Wisconsin"
db.collection("cities")
.orderBy("name")
.orderBy("state")
.startAt("Springfield", "Missouri")

การจัดการกับดัชนีของฐานข้อมูล

ในการเก็บข้อมูลของ Cloud Firestore นั้นต้องการที่เราจะต้องไปกำหนดการจัดการกับ Index (ดัชนี) ของข้อมูลด้วยเพื่อให้ได้ประสิทธิภาพการค้นหาที่ดีที่สุด จริงๆ แต่ใน Realtime Database ก็มีการแนะนำให้เราไปจัดการกับ Index เหมือนกันแต่ไม่บังคับ ซึ่งพอมาเป็น Cloud Firestore ทำให้เรามีตัวเลือกของการสอบถามข้อมูลมากขึ้นเราจึงต้องจัดการกับ Index ด้วยซึ่งทาง Cloud Firestore ก็ได้กล่าวไว้ว่า หากเราต้องการสอบถามโดยเงื่อนไขเพียงครั้งเดียวหรือว่ากันง่ายๆคือใช้ Method where() ครั้งเดียว เราไม่จำเป็นต้องจัดการกับ Index ก็ได้ แต่ว่าถ้าเราต้องการสอบถามโดยเงื่อนไขแบบผสมเราจึงถูกบังคับต้องจัดการกับ Index ด้วย ไม่เช่นนั้นจะไม่สามารถสอบถามข้อมูลได้ แต่ยังไงเสียเราก็ต้องเลือกการเรียงข้อมูลจากน้อยไปมาก หรือ มากไปน้อยถึงแม้ว่าเราจะทำการสอบถามแบบใช้เงื่อนไข “==” ก็ตาม

โดยการเพิ่ม Index นั้น เราต้องเข้าไปทำใน Firebase console

  • เข้าไปที่ Firebase console เลือกเมนู Database
  • ไปที่แท็บ ดัชนี กด เพิ่มดัชนีด้วยตัวเอง
  • ระบุชื่อของ Collection และ Field ที่เราต้องนำไปใช้ในการสอบถามข้อมูลในครบถ้วน
  • จากนั้นเลือกการเรียงข้อมูลจาก น้อยไปมาก หรือ มากไปน้อย
  • เมื่อเรียบร้อยแล้ว กด สร้างดัชนี
  • หลังจากกดสร้างเรียบร้อยก็รอสักครู่ (จะนานหรือไม่ก็อยู่ขนาดข้อมูลของเรา)
    จะขึ้นเหมือนดังภาพ

ถ้าเราต้องการจะ ลบ ก็เพียงนำเม้าส์ไปแช่ไว้แถวๆด้านขวาของรายการ Index ที่เราต้องการจะลบก็จะมีเมนูแสดงขึ้นมา

5. การป้องกันและความปลอดภัยของข้อมูล

การป้องกันและความรักษาปลอดภัยของข้อมูลใน Cloud Firestore ก็ได้มีการออกแบบให้เราสามารถกำหนดกฏของความปลอดภัยต่างๆได้โดยผ่าน Firebase Console ได้ทันที ซึ่งหากเราใช้ Cloud Firestore คือเราสามารถมาทำเรื่องของการป้องกันและความรักษาปลอดภัยของข้อมูลเพียงที่เดียวก็สามารถใช้ได้ทั้งหมดโดยไม่ต้องมาทำแยกกันสำหรับ Web และ Mobile ส่วนฝั่ง Server ก็สามารถใช้ IAM ใน Google Cloud Platform มาจัดการเรื่องนี้สำหรับ Cloud Firestore ได้อีกด้วย

ในส่วนของบทความนี้เราจะกล่าวถึงในส่วนของพื้นฐานการใช้ Security & rule ของเฉพาะเว็บและมือถือก่อน ส่วนในบทความต่อไปไว้เราจะมาลงลึกของการใช้งานจริงโดยเฉพาะกันอีกที

การป้องกันและความรักษาปลอดภัยของข้อมูลใน Cloud Firestore สามารถผนวกกับ บริการอย่าง Firebase Authentication และ การเขียนกฏความปลอดภัยใน Cloud Firestore ด้วย Firebase Console ในการตรวจสอบการระบุตัวตน , สิทธิ์ในการเข้าถึงข้อมูลและการตรวจสอบความถูกต้องของข้อมูลโดยที่เราไม่ต้องมีการวางระบบ Infrastructure หรือ การเขียน Code ฝั่ง Server อะไรเองเลยด้วย โดยเราก็สามารถกำหนดได้เลยว่าจะให้ User ที่ผ่านการ Authentication มาให้คนไหนสามารถที่จะเข้าถึงข้อมูลส่วนไหนได้บ้างและข้อมูลที่เขียนลงไปที่ฐานข้อมูลต้องผ่านการตรวจสอบความถูกต้องด้วย

เริ่มต้นการเขียน Security & rule เราต้องไปเปิด Firebase Console ก่อนเพื่อเริ่มเขียนกันครับ

ให้เราไปที่คลิกที่เมนู Database ทางซ้ายมือ หลังจากนั้นเลือก Cloud Firestore เข้ามาแล้วคลิกที่เมนู กฏ จะปรากฏดังภาพ

หากใครที่ได้เลือกตามที่ผมบนไว้ข้างบนกฏของฐานข้อมูลปัจจุบันของเราจะเป็นแบบที่ไม่ว่าใครๆสามารถมาเขียนหรืออ่านข้อมูลของเราได้ทั้งหมดเลย

Syntax ที่ควรรู้ในการเขียน Security & rule

ก่อนที่เราจะมาเขียนกฏของฐานข้อมูลของเราเอง เราจะมาเข้าใจ Syntax ต่างๆ ของ Security & rule กันก่อน

  • match คือการเขียน Path ของ Collection หรือ Document ใน Cloud Firestore ของเรา โดย Path ที่เราสร้างจะเป็นการเจาะจงเฉพาะ Document ที่เราต้องการ หรือจะเป็นทุก Document ใน Collection นั้นก็สามารถทำได้ และ กฏมากกว่า 1 ข้อก็สามารถตรงกับ Path ชื่อเดียวกันก็ได้ โดยหากมีกฏมากกว่า 1 ข้อที่ตรงกับ Path นั้นๆ ทาง Cloud Firestore จะอนุญาติให้ผู้ที่มีสิทธิ์สามารถทำดำเนินการใดๆได้หากผ่านกฏข้อใดข้อหนึ่ง เช่น หากเราอนุญาติให้เข้าถึงการเขียนข้อมูลของ Document A แต่กฏอื่นๆ ที่อ้างอิงมาที่ Document A อนุญาติให้เข้าถึงการอ่านข้อมูลได้เท่านั้น จะทำให้เข้าถึงการเขียนยังสามารถทำได้เพราะผ่านกฏมาแล้วข้อก่อนหน้ามาแล้วนั้นเอง
  • allow คือการระบุการอนุญาติให้สามารถเข้าถึงการอ่านและการเขียนโดยใช้
    คำสั่ง read และ write โดยพื้นฐานจะไม่อนุญาติให้สามารถเขียนหรืออ่านได้หากเราไม่ได้กำหนดกฏไว้
  • { } คือการระบุสิ่งที่เรียกกว่า Wildcards โดยสัญลักษณ์นี้จะเป็นตัวบ่งบอกถึงหากเราต้องการอ้างอิง ID ของ Collection หรือ Document จำนวนหลายๆชุด โดยจะใส่ String ไว้ภายในเพื่อเอาไว้อ้างอิงถึง Collection หรือ Document เหล่านั้น เช่น {anyDocument}
  • =** คือการระบุต่อท้าย Wildcards เพื่อเป็นการกำหนดว่ากฏที่ใช้จะสร้างกฏที่นำไปใช้ในภายใน Document ทั้งหมดและ Subcollection ด้วย

ตัวอย่างการสร้างกฏ

ทีนี้ลองมาดูตัวอย่างการสร้างกฏแบบต่างๆกัน

การสร้างกฏแบบอ้างอิงไปที่ Path myCollection/myDocument โดยอนุญาติให้เข้าถึงการเขียนหรืออ่านหากผ่านเงื่อนไข และ อีกกฏคือการสร้างแบบอ้างอิงในทุกๆ Document ภายใน Path myCollection โดยอนุญาติให้เข้าถึงการเขียนหรืออ่านหากผ่านเงื่อนไข

service cloud.firestore {
match /databases/{database}/documents {
// Rules match specific paths, matching a particular document within a collection
match /myCollection/myDocument {
allow read, write: if <condition>;
}

// Rules can also specify a wildcard, matching any document within a collection
match /myCollection/{anyDocument} {
allow write: if <other_condition>;
}

}
}

โดยหากเราจะใช้เงื่อนไขของการ Authentication เข้ามาตรวจสอบผู้ที่มีสิทธิ์ก็สามารถทำได้โดยใช้ request.auth หรือถ้าหากเป็นเงื่อนไขเกี่ยวกับเวลาก็ใช้ request.time

service cloud.firestore {
match /databases/{database}/documents {
// Rules can specify conditions that consider the request context
// Such as user authentication or time of the request
match /myCollection/myDocument {
allow read: if request.auth != null;
}

}
}

หากเราต้องการจะตรวจสอบจากข้อมูลที่เขียนเข้ามาก็ใช้คำสั่ง resource.data.field และเรายังสามารถอ้างอิงไปยังข้อมูลที่อยู่ใน Documents อื่นได้ด้วย get(/myCollection/otherDocument).data.field

service cloud.firestore {
match /databases/{database}/documents {
// Rules can also consider fields of the resource being read or written
match /myCollection/myDocument {
allow read: if resource.data.field == value;
}

// Rules can also consider the contents of other documents stored
// This can be used to enforce schema and referential integrity
match /myCollection/myDocument {
allow read: get(/myCollection/otherDocument).data.field == value;
}

}
}

หากเราต้องการให้ผู้ใช้คนอื่นมีสิทธิ์เข้าถึงข้อมูลของเฉพาะ User ตัวเอง หรือให้ผู้ใช้ที่ผ่านการ Authentication สามารถเข้าถึงข้อความในห้องการพูดคุยได้เท่านั้น

service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth.uid == userId;
}
match /rooms/{roomId} {
match /messages/{messageId} {
allow read, write: if request.auth != null;
}
}
}
}

Match

ในส่วนต่อมาเราจะมาลงลึกกันไปอีกหน่อย ถ้าหากเราอยากจะเขียน Security & rule กับข้อมูลที่มีลำดับชั้นเราจะต้องเขียนกฏอย่างไรได้บ้าง ? ก่อนอื่นเราต้องเข้าใจในทางทฤษฎีว่า Collection และ Document ทั้งหมดที่อยู่ใน Database จะขึ้นต้นด้วย
/databases/ชื่อของDatabase/documents แต่ Path นี้จะไม่ปรากฏในฝั่งของ Client เช่น ต้องการเข้าถึง employees/stanley แต่ที่เขียนใน Security rule จริงๆ จะเป็น
databases/ชื่อของDatabase/documents/employees/stanley โดยหากเราเข้าใจ
พื้นฐานตรงส่วนนี้ก็จะทำให้เราสามารถเขียนกฏเฉพาะเจาะจงไปที่ Document ไหนในฐานข้อมูลที่เราต้องการก็ได้

service cloud.firestore {
// An exact match for the Serenity spaceship in our database
match /databases/{database}/documents/spaceships/serenity {
// Rules go here...
}
// An exact match for an employee named Stanley
match /databases/{database}/documents/employees/stanley {
// Rules go here...
}
}

มีการเขียนกฏอีกวิธีที่เราสามารถเขียนกฏซ้อนกันเข้าไปได้โดยไม่ต้องอ้างอิง Path ตั้งแต่เริ่มต้น แต่ให้เราเปลี่ยนวิธีการเขียนไปไว้ข้างในนั้นอีกทีเลย เช่น
/databases/{database}/document/spaceships/serenity/crew/jayne ซึ่งตามตัวอย่างจะเหมือนกันเราอ้างอิงไปที่ Path โดยตรง

service cloud.firestore {
match /databases/{database}/documents/spaceships/serenity {
match /crew/jayne {
// Rules go here...
}
}
}

กฏใน Cloud Firestore นั้นเป็นแบบ Rules don’t cascade โดยในกฏที่เราได้กล่าวมาทั้งหมดนั้นจะไม่ไปมีผลต่อ Subcollection หากเราไม่ได้ใช้ =** ต่อท้าย เช่นหากเราจะเขียนกฏเพื่อเข้าถึง /spaceships/serenity แต่เราจะไม่สามารถเข้าถึง /spaceships/serenity/crew/hoban ได้นั้นเอง

โดยสิ่งที่เราต้องพึงระวังอีกอย่างนึงก็คือ หากเราเขียนกฏในระดับ Collection กฏนั้นก็จะไม่ไปมีผลกับ Document ภายในเช่นกัน

service cloud.firestore {
match /databases/{database}/documents {
// This is probably a mistake
match /spaceships {
allow read;
// In spite of the above line, a user can't read any document within the
// spaceship collection.
}
}
}

เพราะฉะนั้นหากเราเขียนกฏตามตัวอย่างข้างบนก็จะไม่มีใครสามารถเข้าถึง Document ภายใน spaceships ได้นั้นเอง

wildcards

มาถึงกรณีที่เราจะได้ใช้ในการเขียนกฏบ่อยๆ นั้นก็คือการเขียนกฏแบบ wildcards เพราะในส่วนใหญ่เราต้องการที่จะให้สิทธิ์ภายใน Collection เดียวกันเหมือนกันโดยที่เราไม่ต้องมาเขียนแยกไปในแต่ละ Document ซึ่งการเขียนลักษณะแบบนี้จะไม่ได้เจาะจงไปใน Document ใด Document นึงภายใน Collection เหล่านั้น โดยหากชื่อของ Document คือ “enterprise” ค่าของ String ที่อยู่ภายใน wildcards คือ spaceship หากมีการอ้างอิงถึงก็จะมีค่าเท่ากับ enterprise

service cloud.firestore {
match /databases/{database}/documents {
match /spaceships/{spaceship} {
// Rules go here...
}
}
}

Recursive wildcards

ถ้าหากเราได้เพิ่ม =** ต่อท้าย String ที่อยู่ภายใน wildcards เข้าไปจะทำกฏจะไปมีผลต่อ ทุก Document และ Subcollection ที่อยู่ด้านล่างลงไปอีกด้วย เช่น เขียนกฏดังตัวอย่างจะทำให้ spaceships/serenity และ spaceships/enterprise/crew/troi มีผลไปด้วยนั้นเอง

service cloud.firestore {
match /databases/{database}/documents {
match /spaceships/{spaceship=**} {
// Rules go here...
// These rules would also apply to all documents in the "spaceship"
// collection, as we add documents in the "crew" subcollection.
}
}
}

แต่ถ้าหาก Client ต้องการ Update ข้อมูลใน spaceships/enterprise/crew/troi จะทำให้ spaceship มีค่าเป็น enterprise/crew/troi แต่สมมุติว่าจุดประสงค์ของเราต้องการแค่ชื่อของ spaceship เช่น enterprise เราจะต้องเขียนโครงสร้างของกฏใหม่

service cloud.firestore {
match /databases/{database}/documents {
match /spaceships/{spaceship} {
// Rules added here would apply to documents in the "spaceships"
// collection.
// The value of "spaceship" as captured by the wildcard would just be
// the name of the spaceship document
match /{allChildren=**} {
// Rules added here would apply to documents in any subcollections
}
}
}
}

หากเราเขียนกฏใหม่เป็นเหมือนตัวอย่างข้างบน จะทำให้เราสามารถได้ค่าของ spaceship เป็นเพียงแค่ชื่อของ Document นั้นเท่านั้น โดยที่เรายังใส่ =** ต่อท้ายไปใน wildcards ระดับ Collection ก็จะทำให้เราสามารถเข้าถึง ทุกๆ Document และ Subcollection ได้เหมือนเดิม

การสร้างหลายๆ กฏภายใน Document เดียวกัน

หากภายในกฏของเรามีกฏที่อ้างอิงถึง Document เดียวกันและมีกฏข้อใดอนุญาติให้เราสามารถเข้าถึงได้ เราจะสามารถเข้าถึงข้อมูลในส่วนนั้นได้ดังตัวอย่าง เช่น ผู้ใช้ที่ผ่านการ Authentication เข้ามาจะมาสามารถเข้าถึงข้อมูล Subcollection ต่างๆ ภายใน wildcards spaceship ได้ แต่หากเป็นผู้ที่ไม่ได้ทำการ Authentication มาใช้งานก็จะสามารถเข้าถึงการอ่านข้อมูลได้หากเป็น Subcollection ที่ชื่อว่า publicData เช่น spaceships/pegasus/publicData/manifest

service cloud.firestore {
match /databases/{database}/documents {
match /spaceships/{spaceship} {
// Rules would go here for the 'spaceship' documents
match /{allChildren=**} {
// Only signed in users can read this data
allow read if request.auth.uid != null;
}
match /publicData/{publicDocs} {
// Make this publicly readable
allow read;
}
}
}
}

วิธีการประเมินกฎของความปลอดภัย

การประเมินว่ากฏไหนจะสามารถให้ผู้ใช้คนไหนดำเนินการใดๆได้หรือไม่ จะเป็นประเมินจาก Boolean expression ถ้าหากผ่านเงื่อนไขจะมีค่าเป็น True ก็จะอนุญาติให้ดำเนินการได้หรือหากไม่ผ่านเงื่อนไขก็จะมีค่าเป็น False ทำให้ผู้ใช้ไม่มีสิทธิ์ได้รับอนุญาติดำเนินการตามที่เรากำหนดได้

ซึ่งถ้าเราไม่ได้กำหนดกฏใดๆไว้เลยของแต่ละ Path นั้นๆ จะมีค่ากำหนดเริ่มต้นคือ False ทำให้ทุกการดำเนินการใดๆจะไม่ได้รับการอนุญาติ

ลองมาดูดังตัวอย่าง เช่น หากเราอนุญาติแต่ Read ก็จะไม่สามารถดำเนินการเขียนได้ , หากเราอนุญาติแต่ Write การเขียนกฏ allow read: if false ลงไปด้วยก็ไม่จำเป็นต้องทำก็ได้ แต่เราสามารถเขียนกฏลงไปเพื่อเตือนความจำของเราเอง , หากเราไม่ได้เขียนกฏใดๆเลยก็จะหมายความว่าไม่อนุญาติให้ดำเนินการใดๆทั้งสิ้นใน Path นี้

service cloud.firestore {
match /databases/{database}/documents {
match /spaceships/{spaceship}/publicData/{public} {
// All data is publicly readable; nobody can write to it
allow read;
}
match /users/{user}/logs/{log} {
// All data is writable; nobody can read it
allow write;
// Technically, this line isn't necessary, but it's a useful way
// of making your intentions known.
allow read: if false;
}
match /vault/{superSecret=**} {
// Nobody can read or write anything in the vault
}
}
}

ตรรกะเงื่อนไขของการสร้างกฏ

ในการสร้างกฏของ Cloud Firestore นั้นหากเราจะต้องมีการสร้างเงื่อนไขของกฏที่ซับซ้อนมากขึ้น เราก็สามารถใช้ Function ที่ทาง Cloud Firestore ได้เตรียมไว้ให้ในการนำมาประเมินการอนุญาติการดำเนินการใดๆกับกฏของฐานข้อมูลของเราได้ โดยในบทความจะแนะนำกฏที่เป็นพื้นฐานและจะได้มีโอกาศใช้บ่อยๆ

  • ใช้ == หรือ != เพื่อตรวจสอบความเท่ากันของทั้งตัวเลขและตัวหนังสือ
  • ใช้ == null หรือ != null เพื่อตรวจสอบว่าตัวแปรนั้นมีค่าเป็น null หรือไม่
  • ใช้ is เพื่อตรวจสอบว่าตัวแปรที่เราต้องการจะมีชนิดถูกต้องตามที่เรากำหนดหรือไม่ เช่น string, int, float, bool, null, timestamp, list และ map
  • ใช้ in เพื่อตรวจสอบค่านั้นว่ามีอยู่ในตัวแปรชนิด list หรือ map หรือไม่
  • ใช้ get() เพื่อเปลี่ยนจาก document เป็นชนิด map
  • ใช้ exists() เพื่อตรวจสอบว่า document มีข้อมูลอยู่ภายในหรือไม่

การใช้งานกฏกับตัวแปรชนิด Map

หากเราต้องการเข้าถึงตัวแปรประเภท Map เช่น Array List หรือ Object ขณะสร้างกฏเราสามารถเข้าถึงค่าต่างๆในนั้นโดยผ่านสัญลักษณ์ (.) จุด หรือ เป็นเครื่องหมาย [ ] วงเล็บ แต่หากเราใช้แบบเครื่องหมายวงเล็บจะต้องระบุชื่อตัวแปรภายในเครื่องหมาย คำพูด ( “ ” )

// These two rules are the same, and are requesting the 'displayName' field of
// the 'auth' token map that's part of the 'request' variable.
allow read: if request.auth.token.displayName == "Malcolm Reynolds";
allow read: if request["auth"]["token"]["displayName"] == "Malcolm Reynolds";

Request ค่าภายในตัวแปรต่างๆ

ถ้าเราอยากจะร้องขอค่าในตัวแปรต่างๆภายในฐานข้อมูลของเรา เราสามารถทำได้ผ่านคำสั่ง request โดยปกติเราก็จะเอาไว้ใช้ตรวจสอบผู้ใช้จากการ Authentication ซึ่งจะมีกรณีต่างๆ ดังนี้

  • request.auth ตรวจสอบดูว่าผู้ใช้ผ่านการ Sign-in ว่าเป็นค่า null หรือไม่
  • request.auth.uid ต้องการค่า User ID ของผู้ใช้ เพื่อนำไปตรวจสอบกับค่าที่ได้จาก Wildcard ว่าตรงกันหรือไม่
  • request.resource.data.<key> ใช้ตรวจสอบคุณสมบัติต่างๆของตัวแปรที่เราระบุค่าลงไปใน <key> ว่าถูกต้องตามที่เราต้องการหรือไม่
service cloud.firestore {
match /databases/{database}/documents {
match /bulletinBoard/{note} {
// Anybody can read these messages, as long as they're signed in.
allow read, write: if request.auth != null;
}
match /users/{userID}/myNotes/{note} {
// Anybody can write to their own notes section
allow read, write: if request.auth.uid == userID;
}
match /spaceships/{spaceship} {
allow read;
// Spaceship documents can only contain three fields -- a name, a catchy
// slogan, and cargo capacity greater than 6500.
// Only these three fields are allowed, and this will evaluate to false
// if any of these fields are null.
allow write: if request.resource.data.size() == 3 &&
request.resource.data.name is string &&
request.resource.data.slogan is string &&
request.resource.data.cargo is int &&
request.resource.data.cargo > 6500;
}
}
}

Resource ค่าภายในตัวแปรต่างๆ

ตัวแปร resource จะคล้ายกับตัวแปร request.resource แต่ว่าจะเป็นการอ้างอิงถึง Document ที่มีอยู่แล้วภายในฐานข้อมูล ในกรณีที่เราดำเนินการอ่าน จะเป็นการอ่าน Document ขณะที่เราดำเนินการเขียน Document เก่าที่ทำการเปลี่ยนแปลงค่า

โดยทั้ง resource และ request.resource เป็น Object ชนิด Maps ทั้งคู่ ซึ่งภายในจะประกอบด้วย Property หลายๆตัวให้เราสามารถนำมาใช้ได้ ดังตัวอย่าง

service cloud.firestore {
match /databases/{database}/documents {
match /bulletinBoard/{note} {
// You can read any document that has a custom field named "visibility"
// set to the string "public" or "read-only"
allow read: if resource.data.visibility in ["public", "read-only"];
}
match /products/{productID}/reviews/{review} {
allow read;
// A user can update a product reviews, but they can't change
// the headline.
// Also, they should only be able up update their own product review,
// and they still have to list themselves as an author
allow update: if request.resource.data.headline == resource.data.headline
&& resource.data.authorID == request.auth.userID
&& request.resource.data.authorID == request.auth.userID;
}
}

หรือเราจะได้ค่า Metadata ของข้อมูลต่างๆก็สามารถเรียกได้โดยผ่าน สัญลักษณ์ _ _ เช่น __name__, __created_at__, and __updated_at__

// Allow a read if the document being read contains certain fields
allow read: if resource.data.keys().hasAll(['name', 'age'])
&& resource.data.size() == 2
&& resource.data.name is string
&& resource.data.age is int
&& resource.__name__ is path; // projects/projectId/databases/(default)/...

ค่าที่ได้จากตัวแปร Wildcards

หากว่าเราใช้ Wildcards เพื่อกำหนดกฏของชุดของ Document จะทำให้ค่าในตัวแปร Wildcards เปลี่ยนไปตามชื่อของ Document โดยเราสามารถนำค่าที่ได้ไปใช้อ้างอิงกับเงื่อนไขของกฏได้ เช่น หากเราต้องการนำค่าจาก userIDFromWildcard ไปตรวจสอบดูว่าตรงกับ request.auth.uid หรือไม่ หากถูกต้องจะทำการอนุญาติการเขียนและการอ่านได้

service cloud.firestore {
match /databases/{database}/documents {
match /users/{userIDFromWildcard}/ {
// Users can only edit documents in the database if the documentID is
// equal to their userID
allow read, write: if request.auth.uid == userIDFromWildcard;
}
}
}

การสร้างเงื่อนไขโดนอ้างอิงข้อมูลจาก Document อื่น

ถ้าหากเราใช้ resource เราก็จะสามารถตรวจสอบข้อมูลที่จะเขียนเข้ามาใน Path ที่เราอ้างอิง แต่อาจจะมีบางกรณีที่เราจะต้องไปตรวจสอบโดยการอ้างอิงข้อมูลใน Document อื่นที่อยู่ในฐานข้อมูลเดียวกัน โดยเราจะใช้ Method get() และ exists() เข้ามาช่วย

ใน Method get() ภายในก็จะระบุ Path เป็น Parameter ลงไปและวิธีการเข้าถึงข้อมูลภายในก็ต้องทำการ .data เข้าไปเหมือนกับ resource

Method exists() ภายในก็จะระบุ Path เป็น Parameter ลงไปเช่นกัน แต่จะเป็นการตรวจสอบข้อมูลว่ามีอยู่หรือไม่ใน Path นั้น

ทั้งสอง Method หากเราต้องการใช้ Wildcards ใน Path เราต้องเปลี่ยนจาก { } เปลี่ยนเป็น $ เพื่อเข้าถึงตัวแปร Wildcards นั้นๆแทน

service cloud.firestore {
match /databases/{database}/documents {
match /games/{game}/playerProfiles/{playerID} {
// Every "game" document has the userID of a referee, who is allowed to
// alter player profiles
allow write: if get(/databases/$(database)/documents/games/$(game)).data.referee == request.auth.uid;

// All players in a game are allowed to view the player profiles of any
// other player.
allow read: if exists(/databases/$(database)/documents/games/$(game)/playerProfiles/$(request.auth.uid));
}
match /guilds/{guildID}/bulletinBoard/{post} {
// Assume our guild document includes a "users" field, which itself is
// a map consisting of a player ID and their role. For example:
// {"user_123": "Member", "user_456": "Probation", "user_789": "Admin"}
//
// A player can write to the bulletin board if they're listed in the
// guild's "users" map field as a "Member" or "Admin"
allow write: if get(/databases/$(database)/documents/guilds/$(guildID)).data.users[(request.auth.uid)] in ["Admin", "Member"];
}
}
}

การเขียนฟังก์ชั่นของกฏขึ้นมาใช้เอง

หากว่าเราต้องมีการเขียนกฏคล้ายๆกันเพื่อใช้ในหลายๆที่ เราสามารถลดขั้นตอนการทำงานของเราโดยการสร้างฟังก์ชั่นของกฏขึ้นมาใช้งานเองได้ โดยฟังก์ชั่นที่สร้างมาจะสามารถนำไปใช้ได้ภายใน match block เดียวกัน ซึ่งฟังก์ชั่นที่เราเขียนขึ้นมาก็สามารถใช้ requestและ resource หรือ การเข้าถึง Wildcards ต่างได้อีกด้วย

ตัวอย่างของกฏหากเราไม่ได้เขียนเป็นฟังก์ชั่น

service cloud.firestore {
match /databases/{database}/documents {
match /projects/{projectID} {
// A user can update a project if they're listed in the project's
// "members" array. Or if the project is a "all-access" project.
allow update: if request.auth.uid in get(/databases/$(database)/documents/projects/$(projectID)).data.members ||
get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
match /{allChildren=**} {
// The same thing holds true for subcollections of this project
allow update: if request.auth.uid in get(/databases/$(database)/documents/projects/$(projectID)).data.members ||
get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
}
}
}
}

หากเปลี่ยนวิธีโดยการเขียนแบบสร้างฟังก์ชั่น

service cloud.firestore {
match /databases/{database}/documents {
match /projects/{projectID} {

function canUserUpdateProject() {
return request.auth.uid in get(/databases/$(database)/documents/project/$(projectID)).data.members ||
get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
}

allow update: if canUserUpdateProject();
match /{allChildren=**} {
allow update: if canUserUpdateProject();
}
}
}
}

หรือมีอีกวิธี

service cloud.firestore {
match /databases/{database}/documents {
match /projects/{projectID} {

function isProjectAllAccess() {
return get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
}
function isUserOfficialMember() {
return request.auth.uid in get(/databases/$(database)/documents/project/$(projectID)).data.members;
}
function canUserUpdateProject() {
return isUserOfficialMember() || isProjectAllAccess();
}

allow update: if canUserUpdateProject();
match /{allChildren=**} {
allow update: if canUserUpdateProject();
}
}
}
}

การป้องกันการเขียน

ในการป้องกันการเขียนจากฝั่ง Client จะใช้ Method create, update และ delete ในการสร้างกฏ เพื่อที่จะไปจับคู่กับ set(), add(), update(), remove() และ transaction() ที่ทางฝั่ง Client ได้ส่งคำสั่งเข้ามา โดยจะทำให้เราสามารถแยกในการสร้างกฏออกเป็นกรณีต่างๆได้ละเอียดมากขึ้น

service cloud.firestore {
match /databases/{database}/documents {
// Writes are divided into create, update, and delete operations
match /myCollection/myDocument {
allow create, update, delete: if <condition>;
}

// This is equivalent to using the write operation
match /myCollection/myDocument {
allow write: if <condition>;
}

}
}

ซึ่งหากเราไม่ต้องการที่จะแยกออกเป็นกรณีๆก็สามารถใช้ write ได้เหมือนเดิม

การป้องกันการอ่าน

ในการป้องกันการอ่านจากฝั่ง Client จะใช้ Method get และ list ในการสร้างกฏ เพื่อที่จะไปจับคู่กับ get() และ where().get() ที่ทางฝั่ง Client ได้ส่งคำสั่งเข้ามา โดยจะทำให้เราสามารถแยกในการสร้างกฏออกเป็นกรณีต่างๆได้ละเอียดมากขึ้น

service cloud.firestore {
match /databases/{database}/documents {
// Reads are divided into get and list operations
match /myCollection/myDocument {
allow get, list: if <condition>;
}

// This is equivalent to using the read operation
match /myCollection/myDocument {
allow read: if <condition>;
}

}
}

ซึ่งหากเราไม่ต้องการที่จะแยกออกเป็นกรณีๆก็สามารถใช้ read ได้เหมือนเดิม

โดยมีข้อควรระวังคือหากเราจะมีการสอบถามข้อมูลภายใน field นั้นๆ จะต้องการสิทธิ์ในการเข้าถึงการอ่านภายใน Document นั้นๆ ก่อน เช่น

service cloud.firestore {
match /databases/{database}/documents {
match /rooms/{roomId} {
match /messages/{messageId} {
// Allow reads if the name is "Inara Serra"
allow read: if resource.data.name == "Inara Serra";
}
}
}

โดยใช้คำสั่งเข้าไปสอบถามข้อมูลดังนี้

// Read all messages by Inara
messagesRef.where("name", "==", "Inara Serra").get().then(function(documentSet) {
documentSet.forEach(function(document) {
// Query succeeds, message should contain "name" and "text" properties
var message = document.data();
});
});

สรุป

ก็จบลงไปแล้วกับพื้นฐานการพัฒนาระบบฐานข้อมูล Cloud Firestore อย่างไรก็ตามก็อยากแนะนำให้ผู้อ่านได้ศึกษารายละเอียดต่างๆทั้ง 5 ขั้นตอนก่อนนำไปประยุกต์ใช้งานจริงกันนะครับเพราะทุกขั้นตอนก็มีความสำคัญทั้งสิ้นไม่ว่าจะเป็นการสร้าง Cloud Firestore เพื่อใช้งานในโครงการเพื่อเริ่มดำเนินโครงการ, การติดตั้ง SDKs เพื่อใช้งาน Cloud Firestore เพื่อติดต่อกับ Server , การออกแบบโครงสร้างและการจัดการของข้อมูลที่หากออกแบบโครงสร้างเป็นไปอย่างดีก็จะทำให้เราทำงานได้ราบรื่นและมีประสิทธิภาพมากขึ้น , การดึงและสอบถามข้อมูลก็จะทำให้เราผลลัพธ์ของข้อมูลอย่างถูกต้องตามที่เราต้องการ และส่วนสุดท้ายเป็นการป้องกันและความปลอดภัยของข้อมูลที่จะทำให้เราสามารถป้องกันผู้ที่ไม่ประสงค์ดีมากระทำการใดๆที่เราไม่ต้องการภายในฐานข้อมูลของเราได้ โดยหากเราทำเข้าใจในทุกๆขั้นตอนเป็นอย่างดีแล้วก็จะสามารถนำไปต่อยอดในการสร้างแอพพลิเคชั่นของเราได้อย่างสมบรูณ์

ซึ่งหากใครที่ต้องการศึกษาด้วยตนเองก็สามารถเข้าไปอ่านรายละเอียดจาก Document ของ Firebase เองได้ที่ https://firebase.google.com/docs/firestore/ หรือ https://firebase.google.com/docs/reference/js/firebase.firestore และหากใครอยากติดตามบทความของผมต่อก็สามารถรออ่านต่อกันได้ในเร็วๆ นี้ จะเป็นการออกแบบโครงสร้างของฐานข้อมูลอย่างละเอียดฉบับใช้งานจริง ยังไงฝากติดตามกันต่อและช่วยกดปรบมือเป็นกำลังใจให้ด้วยนะครับ

--

--

Thanongkiat Tamtai
Firebase Thailand

CTO @ Flagfrog # Full-stack Developer # Everything i can do , but it maybe not cool