tva
← บทความเชิงลึก

สร้างแอปเตรียมสอบที่มีคำถามนับพัน: การตัดสินใจด้านสถาปัตยกรรม

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

เราสร้างแอปเตรียมสอบที่รองรับการรับรองวิชาชีพหลายประเภทในหลายภูมิภาค การตัดสินใจด้านสถาปัตยกรรม โดยเฉพาะแบบจำลองข้อมูล การแบ่งโมดูลด้วย Swift Package Manager ระบบ backend ของ Supabase และการทำงานออฟไลน์ ไม่ได้ชัดเจนตั้งแต่แรก บางส่วนเปลี่ยนไปมากระหว่างต้นแบบแรกกับเวอร์ชันที่เผยแพร่

ปัญหาการออกแบบแบบจำลองข้อมูล

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

สคีมาที่เราเลือกเก็บคำถามพร้อมฟิลด์ type (single-choice, multi-choice, ordering, code-snippet) และคอลัมน์ JSONB ชื่อ options สำหรับชุดคำตอบ ทำให้คำถามแต่ละประเภทเก็บข้อมูลที่ต้องใช้ได้โดยไม่ต้องแยกตารางตามประเภท คำตอบที่ถูกต้องเก็บเป็นอาร์เรย์ดัชนีแบบ JSONB แทนจำนวนเต็มตัวเดียว จึงรองรับคำตอบถูกหลายข้อได้โดยไม่เปลี่ยนสคีมา

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

ระดับความยากเก็บเป็นจำนวนเต็ม (1–5) แทน enum เพราะการเพิ่มระดับใน enum ต้องย้ายสคีมา แต่จำนวนเต็มไม่ต้อง frontend ทำหน้าที่แปลงจำนวนเต็มเป็นป้ายชื่อ

ใช้ Swift Package Manager เพื่อแบ่งสถาปัตยกรรมเป็นโมดูล

การใช้ Xcode target เดียวสำหรับโค้ดขนาดนี้ทำให้เวลาสร้างระบบเพิ่มขึ้น และบังคับขอบเขตระหว่างชั้นได้ยาก โมดูลเครือข่ายไม่ควรเข้าถึงคอมโพเนนต์ UI ส่วนกลไกคำถามไม่ควรต้องรู้ว่าตัวเองทำงานในแอป iOS หรือชุดทดสอบ Swift Package Manager ทำให้ขอบเขตเหล่านี้ชัดเจนและให้คอมไพเลอร์บังคับใช้

โครงสร้างการพึ่งพาแพ็กเกจที่เราเลือกมีแพ็กเกจหลักสี่ตัว:

  • QuestionEngine — ตรรกะล้วน ได้แก่ การเลือกคำถาม ประเมินคำตอบ คำนวณคะแนนแต่ละรอบ และกำหนดเวลาทบทวนแบบเว้นระยะ ไม่พึ่ง UIKit, SwiftUI หรือ Supabase ทดสอบแยกได้ทั้งหมด
  • ExamData — แบบจำลองข้อมูลและตัวห่อหุ้มไคลเอนต์ Supabase กำหนดชนิด Question, Exam, UserProgress และ LeaderboardEntry นำเข้าไคลเอนต์ Supabase สำหรับ Swift
  • OfflineStore — ชุดองค์ประกอบ Core Data และตรรกะการซิงค์ พึ่ง ExamData สำหรับนิยามชนิดข้อมูล จัดการแคชในเครื่องและติดตามการเปลี่ยนแปลงเพื่อซิงค์
  • AppUI — มุมมองและแบบจำลองมุมมองของ SwiftUI นำเข้า QuestionEngine และ ExamData โดยไม่รู้จัก OfflineStore โดยตรง

Target หลักของแอปรวมแพ็กเกจทั้งสี่เข้าด้วยกัน จึงรันชุดทดสอบ QuestionEngine ทั้งหมดได้โดยไม่ต้องเปิดตัวจำลอง เพราะแพ็กเกจไม่พึ่งแพลตฟอร์ม เมื่อไคลเอนต์ Supabase สำหรับ Swift มีการเปลี่ยนแปลงที่ทำให้โค้ดเดิมใช้ไม่ได้ในเวอร์ชันย่อย ผลกระทบก็จำกัดอยู่ใน ExamData เราอัปเดตเพียงแพ็กเกจเดียว ส่วนที่เหลือคอมไพล์ได้โดยไม่แก้ไข

ข้อเสียคือการแก้การพึ่งพาแพ็กเกจและแคชของ SPM ใน Xcode อาจทำงานไม่แน่นอน โดยเฉพาะหลังสร้างระบบใหม่โดยล้างผลลัพธ์เดิม การมี target ใน Makefile ที่รัน xcodebuild -resolvePackageDependencies ก่อนสร้างระบบใน CI ช่วยขจัดปัญหาไม่คาดคิดส่วนใหญ่ได้

ระบบ backend ของ Supabase

Supabase เหมาะกับ backend นี้ด้วยเหตุผลสองข้อ: Row Level Security จัดการการเข้าถึงข้อมูลของผู้เช่าหลายรายได้โดยไม่ต้องเพิ่มตัวตรวจในชั้นแอปพลิเคชัน และสคีมาทั้งหมดอยู่ใน PostgreSQL จึงใช้ความสามารถสืบค้นได้เต็มรูปแบบเมื่อสร้างแบบจำลองสำหรับอ่านข้อมูลตารางอันดับและชั้นรายงาน

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

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

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

ออกแบบให้ใช้งานออฟไลน์เป็นหลัก

แอปเตรียมสอบที่ต้องเชื่อมต่อเครือข่ายใช้ไม่ได้สำหรับผู้เข้าสอบที่กำลังอ่านหนังสือบนเครื่องบิน การใช้งานออฟไลน์เป็นหลักจึงเป็นกรณีใช้งานหลักที่ขาดไม่ได้

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

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

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

ตารางอันดับเมื่อมีการใช้งานพร้อมกัน

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

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

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

สิ่งที่เราจะทำต่างออกไป

คอลัมน์ JSONB ชื่อ options สำหรับคำตอบเป็นทางเลือกที่ถูกต้อง แต่ช่วงแรกเราระบุสคีมาของออบเจ็กต์ตัวเลือกไม่ละเอียดพอ นักพัฒนาแต่ละคนเพิ่มฟิลด์ต่างกันเล็กน้อยตามเวลา บางตัวเลือกมี explanation บางตัวมี imageUrl บางตัวไม่มีทั้งคู่ เมื่อไม่มีการตรวจและบังคับรูปแบบตอนเพิ่มข้อมูล เนื้อหาจึงไม่สอดคล้องกัน การเพิ่มสคีมา Zod ฝั่ง TypeScript ในเครื่องมือผู้ดูแล และข้อจำกัด CHECK บนคอลัมน์ Postgres จะช่วยตรวจพบได้เร็วกว่า ควรบังคับรูปแบบข้อมูล JSONB ที่จุดรับข้อมูลเข้าระบบ

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

บทความที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง