ข้ามไปยังเนื้อหา
กลับไปหน้าบทความ
การพัฒนาซอฟต์แวร์

แอปที่ใช้งานได้แม้ไม่มีเน็ตทำงานอย่างไร

แอปแบบ offline-first อ่านและเขียนข้อมูลในเครื่องก่อน แล้วค่อย sync ผ่านคิว อธิบายทีละขั้นตั้งแต่กดบันทึก การ retry จนถึงการเลือกข้อมูลเมื่อแก้ชนกัน

ปานกลาง9 นาทีในการอ่าน

ในบทความนี้

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

แนวทางนี้เรียกว่า offline-first บทความนี้ไล่ตั้งแต่ข้อมูลควรเก็บไว้ที่ไหน ขั้นตอนตอนกดบันทึกขณะไม่มีสัญญาณ ไปจนถึงเรื่องที่มักพลาด คือข้อมูลชนกันและคำขอที่ถูกส่งซ้ำ ครับ

ระหว่างไม่มีสัญญาณ ข้อมูลสามรายการถูกบันทึกลงฐานข้อมูลในเครื่องและเข้าคิว outbox เมื่อสัญญาณกลับมา outbox ส่งไปที่ server ทีละรายการตามลำดับ

แอปแบบ offline-first คืออะไร

แอปแบบ offline-first คือแอปที่ทำงานหลักได้ทั้งหมด หรืออย่างน้อยส่วนที่สำคัญที่สุด โดยไม่ต้องต่ออินเทอร์เน็ต ตามนิยามในเอกสาร architecture ของ Android ฝั่ง Flutter ก็นิยามไว้ใกล้กันว่าเป็นแอปที่ให้ใช้ฟังก์ชันส่วนใหญ่หรือทั้งหมดได้ขณะไม่ได้ต่อเน็ต

เอกสารของ Android ยกสถานการณ์ที่เน็ตใช้ไม่ได้ไว้สามแบบ คือ bandwidth จำกัด สัญญาณหลุดชั่วคราว เช่น ในลิฟต์หรืออุโมงค์ และเครื่องที่ต่อเน็ตได้เป็นครั้งคราว เช่น แท็บเล็ตที่มีแค่ Wi-Fi (Android) บทความ local-first ของ Ink & Switch ปี พ.ศ. 2562 เพิ่มอีกกรณีที่ใกล้ตัวคนไทยหลายคน คือในพื้นที่ชนบท โครงสร้างพื้นฐานอินเทอร์เน็ตบางครั้งก็ไม่ทั่วถึง

แอปจะนับเป็น offline-first ได้ ต้องทำสามอย่างนี้ (Android):

  • ใช้งานได้แม้ network ไม่เสถียร
  • แสดงข้อมูลในเครื่องทันที ไม่รอให้ network call แรกสำเร็จหรือล้มเหลวก่อน
  • ดึงข้อมูลโดยคำนึงถึงแบตเตอรี่และค่า data เช่น ดึงตอนชาร์จหรือตอนต่อ Wi-Fi

ข้อสองคือข้อที่ทำให้วิธีออกแบบเปลี่ยนไปครับ แอปทั่วไปมักยิง request ก่อนแล้วค่อยแสดงผล ส่วนแอป offline-first แสดงสิ่งที่มีในเครื่องก่อน แล้วค่อยอัปเดตเมื่อข้อมูลใหม่มาถึง

ข้อมูลในเครื่องควรเก็บไว้ที่ไหน

ข้อมูลในเครื่องควรอยู่ในฐานข้อมูลที่เขียนลงดิสก์ และต้องเป็นแหล่งเดียวที่หน้าจออ่าน Android เรียกฐานข้อมูลนี้ว่า local data source และกำหนดให้เป็น canonical source of truth ของแอป ที่ layer ข้างบนอ่านได้จากที่นี่ที่เดียว ส่วน network data source ถูกเรียกผ่าน repository เท่านั้น UI ไม่คุยกับ network ตรง ๆ

ตัวเลือกตามแพลตฟอร์ม:

แพลตฟอร์ม ที่เก็บข้อมูล หมายเหตุ
Android Room (ฐานข้อมูล relational), DataStore หรือไฟล์ Android
Flutter ฐานข้อมูล SQL ผ่าน DatabaseService ในตัวอย่างของเอกสาร Flutter
Web IndexedDB สำหรับข้อมูลที่มีโครงสร้าง, Cache API สำหรับ request/response MDN IndexedDB, MDN Service Worker

ไฟล์ของตัวหน้าเว็บเอง เช่น HTML, CSS และ JavaScript ใช้ service worker ตอบจาก cache ได้ บทความของ Jake Archibald บน web.dev ระบุว่ารูปแบบ cache falling back to network เหมาะกับการสร้าง offline-first คือเปิดจาก cache ก่อน ไม่มีค่อยไป network แต่ request ที่ไม่ใช่ GET ถูก cache ไม่ได้ การเขียนข้อมูลจึงต้องมีคิวแยกต่างหาก ซึ่งเป็นเรื่องของหัวข้อถัดไป

ฝั่ง web มีสองเรื่องที่ควรรู้ก่อนใช้ IndexedDB เป็นที่เก็บหลัก เรื่องแรก IndexedDB เป็นฐานข้อมูลแบบ transactional ที่เก็บ object ตาม key และทำงานแบบ asynchronous และแยกตาม origin เรื่องที่สอง ข้อมูลที่เว็บเก็บไว้เป็นแบบ best-effort โดยค่าเริ่มต้น browser ลบได้เมื่อเครื่องพื้นที่ใกล้เต็ม เว็บที่เก็บข้อมูลสำคัญซึ่งไม่มีสำเนาที่อื่น ควรขอ persistent storage ด้วย navigator.storage.persist() และถ้าเขียนเกิน quota จะได้ QuotaExceededError จึงต้องครอบโค้ดที่เขียนข้อมูลด้วย try...catch

เมื่อผู้ใช้กดบันทึกตอนไม่มีเน็ต เกิดอะไรขึ้นทีละขั้น

แอปเขียนข้อมูลลงเครื่องก่อน ใส่รายการนั้นลงคิว แล้วค่อยส่งเมื่อมีเน็ต Android เรียกวิธีนี้ว่า lazy writes และบอกว่าเป็นตัวเลือกที่ถูกเมื่อข้อมูลสำคัญต่อแอป เช่น งานที่ผู้ใช้เพิ่มในแอป to-do ระหว่างออฟไลน์ ซึ่งต้องเก็บไว้ในเครื่องไม่ให้หาย ตัวอย่างต่อไปนี้คือการแก้งานหนึ่งรายการในแอป to-do

ขั้นที่ 1: เขียนลงฐานข้อมูลในเครื่อง

แอปบันทึกค่าใหม่ลงตาราง tasks ในเครื่อง พร้อมสถานะว่ายังไม่ได้ sync ใน Flutter เอกสารแนะนำให้เพิ่ม flag เช่น bool synchronized ไว้ใน data class แล้วส่งเฉพาะแถวที่ flag เป็น false หน้าจออ่านจากฐานข้อมูลนี้ จึงเห็นค่าใหม่ทันที

ขั้นที่ 2: ใส่คำขอลงคิว

แอปเก็บคำขอที่ต้องส่งไว้ในคิว (outbox) ซึ่งต้องอยู่บนดิสก์เช่นกัน เพื่อให้รอดตอนแอปถูกปิดหรือเครื่องรีสตาร์ต บน Android งานแบบนี้มักมอบให้ WorkManager ซึ่งเก็บงานที่ตั้งเวลาไว้ในฐานข้อมูล SQLite ภายใน และตั้งเวลาใหม่ให้หลังรีบูตเครื่อง

ในคิวควรเก็บข้อมูลให้พอสำหรับส่งซ้ำได้ เช่น

{"op": "PUT", "path": "/tasks/7f3c2a10", "body": {"title": "ซ่อมรั้ว", "done": true}, "baseVersion": "v3"}

7f3c2a10 คือ id ที่แอปสร้างเองตอนสร้างงาน ส่วน baseVersion คือเวอร์ชันที่เครื่องเห็นก่อนแก้ ทั้งสองค่าจะใช้ในขั้นที่ 4 และหัวข้อเรื่องข้อมูลชนกัน

ขั้นที่ 3: รอเงื่อนไขที่ส่งได้

แอปไม่ควรพยายามส่งตลอดเวลา WorkManager ให้กำหนด constraint เช่น ต้องต่อ network แบบไม่คิดตามปริมาณ ต้องมีแบตพอ แอปตัวอย่าง Now in Android ตั้ง constraint ของงาน sync เป็น NetworkType.CONNECTED ซึ่งรอจนมี network ก่อนจึงเริ่มทำงาน ฝั่ง Flutter เอกสารแนะนำ plugin workmanager สำหรับรัน sync เบื้องหลัง และ connectivity_plus กับ battery_plus สำหรับเช็กสถานะ network และแบตก่อน sync (Flutter)

ขั้นที่ 4: ส่งทีละรายการและ retry

เมื่อมีเน็ต แอปส่งรายการในคิวตามลำดับ ถ้าล้มเหลวให้รอแล้วลองใหม่โดยเว้นระยะห่างเพิ่มขึ้นเรื่อย ๆ (exponential backoff) เอกสาร Android แนะนำให้ลองใหม่เมื่อ error บอกว่าไม่มีการเชื่อมต่อ แต่ไม่ต้อง retry request ที่ไม่ผ่านการยืนยันสิทธิ์จนกว่าจะมี credential ที่ถูกต้อง ใน Now in Android ถ้า sync ไม่สำเร็จ worker คืนค่า Result.retry() แล้ว WorkManager จะ retry ให้แบบ exponential backoff

ขั้นที่ 5: ปิดรายการ

เมื่อ server ตอบรับ แอปลบรายการออกจากคิวและเปลี่ยน flag ของแถวในเครื่องเป็น synced ถ้าส่งไม่สำเร็จ แถวนั้นยังเป็น false และจะถูกส่งในรอบถัดไป (Flutter)

แอปรู้ได้อย่างไรว่ากลับมาออนไลน์แล้ว

ให้ระบบ OS หรือ browser แจ้ง แล้วยืนยันด้วยการส่ง request จริงครับ เพราะสถานะ "ออนไลน์" ที่อ่านได้เชื่อถือไม่ได้เสมอ

บน web ค่า navigator.onLine ดูจาก heuristic ของ browser และ OS MDN อธิบายว่าการต่อ LAN ได้มักนับเป็นออนไลน์ ทั้งที่ LAN นั้นอาจออกอินเทอร์เน็ตไม่ได้ จึงเขียนไว้ตรง ๆ ว่าค่านี้ไม่น่าเชื่อถือโดยธรรมชาติ ไม่ควรใช้ปิดฟีเจอร์ ใช้แค่เป็นคำใบ้ว่าผู้ใช้อาจออฟไลน์

ถ้าต้องการให้ระบบปลุกงานเมื่อมีเน็ต web มี Background Synchronization API ที่ให้เลื่อนงานไปรันใน service worker เมื่อผู้ใช้มีการเชื่อมต่อที่เสถียร แต่ MDN ระบุว่า API นี้ยังไม่ใช่ Baseline เพราะใช้ไม่ได้ใน browser หลักบางตัว แอปจึงต้องมีทางสำรอง เช่น ลองส่งคิวใหม่ทุกครั้งที่ผู้ใช้เปิดหน้าเว็บ ส่วน service worker เองใช้ได้เฉพาะใน secure context คือหน้าเว็บที่โหลดผ่าน HTTPS (ยกเว้น http://localhost สำหรับพัฒนา)

ถ้าสองเครื่องแก้ข้อมูลเดียวกันตอนออฟไลน์ ควรเลือกข้อมูลของใคร

ต้องตัดสินจากชนิดข้อมูล วิธีที่ง่ายที่สุดคือ last write wins แต่วิธีนี้ทิ้งข้อมูลอีกฝั่งไปเลย

Last write wins

Android บอกว่าในแอปมือถือ last write wins เป็นวิธีที่ใช้กันบ่อย เครื่องแนบ timestamp ไปกับข้อมูลที่เขียน แล้ว server ทิ้งข้อมูลที่เก่ากว่าสถานะปัจจุบันและรับข้อมูลที่ใหม่กว่า

ตัวอย่าง: เครื่อง A แก้ชื่องานเป็น "ซ่อมรั้วด้านเหนือ" ตอน 09:10 เครื่อง B ติ๊กงานเดียวกันว่าเสร็จตอน 09:12 ทั้งคู่ออฟไลน์ เมื่อ sync ข้อมูลทั้งแถวของ B ชนะ ชื่อที่ A แก้หายไป ผู้ใช้ทั้งสองคนไม่ได้รับแจ้งอะไร และถ้า timestamp มาจากนาฬิกาของแต่ละเครื่อง ผลลัพธ์ก็ขึ้นกับว่านาฬิกาสองเครื่องตั้งไว้ตรงกันแค่ไหน

ตรวจเวอร์ชันก่อนเขียน

ถ้าข้อมูลหายแบบนี้ไม่ได้ ให้ server ปฏิเสธการเขียนที่อิงเวอร์ชันเก่า HTTP มีกลไกนี้อยู่แล้ว header If-Match ทำให้ method ทำงานเฉพาะเมื่อ entity tag ปัจจุบันตรงกับที่ส่งมา และ RFC 9110 บอกว่าใช้กันการเขียนทับโดยไม่ตั้งใจเมื่อหลาย client แก้ resource เดียวกัน หรือปัญหา "lost update" ถ้าเวอร์ชันไม่ตรง server ตอบ 412 Precondition Failed

PUT /tasks/7f3c2a10 HTTP/1.1
If-Match: "v3"
Content-Type: application/json

{"title": "ซ่อมรั้ว", "done": true}

เมื่อได้ 412 แอปดึงข้อมูลล่าสุดมา แล้วรวมเองหรือถามผู้ใช้ เช่น "อีกเครื่องแก้ชื่องานนี้ไปแล้ว จะเก็บแบบไหน"

รวมระดับ field หรือใช้ CRDT

อีกทางคือรวมการแก้ไขทีละ field แทนการเขียนทับทั้งแถว ในตัวอย่างข้างบน ชื่อจาก A และสถานะ done จาก B อยู่ด้วยกันได้ เพราะแก้คนละ field แนวคิดนี้เป็นพื้นฐานของ CRDT ที่บทความของ Ink & Switch อธิบายไว้ว่าเมื่อสถานะถูกแก้พร้อมกันบนหลายเครื่อง CRDT จะรวมการเปลี่ยนแปลงให้ และกรณีเดียวที่ CRDT รวมให้เองไม่ได้คือเมื่อผู้ใช้หลายคนแก้ property เดียวกันของ object เดียวกันพร้อมกัน ซึ่งในกรณีนั้น CRDT จะเก็บค่าที่ขัดกันไว้ให้แอปหรือผู้ใช้ตัดสิน

ผมมองว่าแอปส่วนใหญ่ไม่จำเป็นต้องเริ่มที่ CRDT ครับ เริ่มจากตรวจเวอร์ชันด้วย If-Match และรวมระดับ field ในข้อมูลที่แก้บ่อยก็ครอบคลุมกรณีส่วนใหญ่แล้ว

ส่งคำขอซ้ำแล้วข้อมูลจะซ้ำไหม

ขึ้นกับ method ที่ใช้ แอป offline-first ส่งซ้ำเป็นเรื่องปกติ เพราะบางครั้ง request ถึง server แล้วแต่ response หายระหว่างทาง แอปจึงไม่รู้ว่าสำเร็จหรือยัง

RFC 9110 นิยาม method ที่ idempotent ว่าส่งคำขอเดียวกันหลายครั้ง ผลที่ตั้งใจบน server เท่ากับส่งครั้งเดียว และ method ที่เป็นแบบนี้คือ PUT, DELETE และ safe method คำขอแบบนี้ส่งซ้ำอัตโนมัติได้ถ้าการเชื่อมต่อขาดก่อนอ่าน response ส่วน method ที่ไม่ idempotent เช่น POST ตัว client ไม่ควร retry อัตโนมัติ เว้นแต่รู้ว่าคำขอนั้น idempotent จริงหรือรู้ว่าคำขอเดิมไม่ได้ถูกทำ

ในทางปฏิบัติจึงมีสองแบบที่ใช้ได้:

  • ให้แอปสร้าง id เอง แล้วใช้ PUT /tasks/<id> ส่งกี่ครั้งก็ได้งานเดียว ตัวอย่างในขั้นที่ 2 ใช้วิธีนี้
  • ถ้าต้องใช้ POST /tasks server ต้องมีวิธีจับว่าเป็นคำขอซ้ำ เช่น ให้ client แนบ id ของคำขอมาด้วยแล้ว server จำไว้

ถ้าไม่ทำอย่างใดอย่างหนึ่ง คิวที่ retry อาจสร้างงานซ้ำหลายแถวบน server

งานแบบไหนไม่ควรทำแบบ offline-first

งานที่ต้องเกิดบน server ทันทีและห้ามค้างอยู่ในเครื่อง ไม่ควรเขียนแบบเก็บไว้ก่อนส่งทีหลัง Android ยกตัวอย่างการโอนเงิน ว่าเป็นงานแบบ online-only write คือเขียนผ่าน network ก่อน สำเร็จแล้วจึงอัปเดตข้อมูลในเครื่อง ถ้าออฟไลน์อยู่ แอปควรปิดปุ่มนั้นหรือแจ้งผู้ใช้ว่าทำตอนนี้ไม่ได้ (Android)

ส่วนข้อมูลอย่าง analytics หรือ log ที่ไม่จำเป็นต้องถึง server ทุกรายการ ไม่เร่งเวลา และไม่ต้องแจ้งผู้ใช้เมื่อล้มเหลว Android จัดเป็น queued writes ซึ่งง่ายกว่า lazy writes เพราะไม่มีเรื่องข้อมูลชนกัน

สรุปเป็นเช็กลิสต์สั้น ๆ ก่อนเริ่มออกแบบ:

  1. แยกข้อมูลเป็นสามกลุ่ม ต้องออนไลน์เท่านั้น ส่งทีหลังได้ไม่ต้องสนผล และต้องเก็บในเครื่องห้ามหาย
  2. ให้หน้าจออ่านจากฐานข้อมูลในเครื่องที่เดียว
  3. เก็บคิวไว้บนดิสก์ ส่งตามลำดับ retry แบบ backoff
  4. ใช้ id ที่สร้างจาก client และ method ที่ idempotent
  5. เลือกวิธีจัดการข้อมูลชนกันให้แต่ละชนิดข้อมูล และบอกผู้ใช้เมื่อมีการเลือกแทนเขา

แหล่งอ้างอิง

อ่านต่อ