การทำงานร่วมกันแบบ Real-time ดูเหมือนจะเป็นเรื่องง่ายจนกว่าคุณจะได้เปิดม่านดูความจริงเบื้องหลัง คนหนึ่งกำลังพิมพ์ อีกคนลบข้อความที่อยู่สูงขึ้นไปสามย่อหน้า และคนที่สามกำลังวางโค้ดที่ก๊อปปี้มาจาก Stack Overflow แต่ไม่รู้ทำไม เอกสารนั้นกลับสามารถรวมกันเป็นสถานะเดียวที่สอดคล้องกันได้อย่างลงตัว การสร้างความลื่นไหลเช่นนั้นขึ้นมาใหม่ตั้งแต่ต้น โดยไม่มีประสบการณ์ด้าน WebSockets หรือ distributed state มาก่อน ฟังดูเหมือนเป็นเรื่องที่บ้าบิ่น แต่มันก็ฟังดูเหมือนเป็นวิธีที่ถูกต้องในการเรียนรู้อย่างแท้จริงเช่นกัน

โปรเจกต์นี้เริ่มจากศูนย์ ไม่มีการใช้โค้ดสำเร็จรูป (boilerplate) ที่หยิบยืมมา ไม่มีการดูวิดีโอสอนใน YouTube ที่ดูเนียนกริบซึ่งมักจะข้ามส่วนที่ยากที่สุดไปในวิดีโอตัดต่อสั้นๆ 30 วินาที เป้าหมายคือการสร้าง Code Editor แบบทำงานร่วมกันที่ผู้ใช้หลายคนสามารถแก้ไขไฟล์เดียวกันได้พร้อมกัน โดยเห็นการเปลี่ยนแปลงของกันและกัน—รวมถึงเห็นเคอร์เซอร์ของกันและกัน—ในขณะที่มันเกิดขึ้น การจะไปถึงจุดนั้นจำเป็นต้องทำความเข้าใจเรื่อง transport layers, consistency models และปัญหาที่ยุ่งยากในการรวมการแก้ไขที่เกิดขึ้นพร้อมกัน (merging concurrent edits) โดยไม่ทำให้เอกสารเสียหาย

ความหมายที่แท้จริงของคำว่า “Real-Time”

แอปพลิเคชันเว็บส่วนใหญ่คุ้นเคยกับวงจรแบบ request-response คุณส่งฟอร์ม เซิร์ฟเวอร์บันทึกข้อมูล แล้วคุณก็รีเฟรชหน้าเว็บ แต่การทำงานร่วมกันแบบ Real-time นั้นทำลายข้อตกลงนั้นไปโดยสิ้นเชิง ทุกการกดแป้นพิมพ์คือเหตุการณ์ (event) ที่ต้องแพร่กระจายไปยังไคลเอนต์ที่เชื่อมต่ออยู่รายอื่นๆ โดยปกติจะต้องเกิดขึ้นภายในเสี้ยววินาที และต้องมาถึงในลำดับที่ยังคงรักษาความหมายของข้อความไว้ได้

WebSockets เป็นตัวเลือกในการรับส่งข้อมูลที่ชัดเจนที่สุดในที่นี้ เพราะมันรักษาการเชื่อมต่อแบบ full-duplex ที่คงอยู่ตลอดเวลาระหว่างไคลเอนต์และเซิร์ฟเวอร์ ซึ่งต่างจากการทำ HTTP polling ที่สิ้นเปลืองแบนด์วิดท์ด้วยการคอยถามว่า “มีอะไรใหม่ไหม?” ทุกๆ ไม่กี่วินาที แต่ WebSocket จะเปิดค้างไว้ เมื่อผู้ใช้ A พิมพ์เครื่องหมายอัฒภาค (semicolon) ตัวอักษรนั้นจะกลายเป็นข้อความที่เดินทางผ่าน socket ไปยังเซิร์ฟเวอร์กลาง จากนั้นจึงกระจายไปยังผู้ใช้ B และ C ส่วนนั้นถือว่าค่อนข้างตรงไปตรงมา

ส่วนที่ยากคือจะเกิดอะไรขึ้นเมื่อ B และ C พิมพ์ข้อความในจังหวะเดียวกันเป๊ะ หากการเปลี่ยนแปลงทั้งสองส่งถึงเซิร์ฟเวอร์เกือบจะพร้อมกัน อันไหนจะเป็นฝ่ายชนะ? หากคุณเพียงแค่กระจายข้อความตามลำดับที่มาถึง คุณก็เสี่ยงที่จะทำให้ตัวอักษรตกหล่นหรือข้อความสลับที่กัน กลยุทธ์แบบ last-write-wins แบบพื้นๆ มักจะล้มเหลวเพราะมันละเลยเจตนาของผู้ใช้ หากฉันพิมพ์ “hello” ที่ต้นบรรทัดที่หนึ่ง ในขณะที่คุณพิมพ์ “world” ที่ต้นบรรทัดที่หนึ่งเช่นกัน ผลลัพธ์ไม่ควรเป็นการชนกันที่ทำให้คนใดคนหนึ่งถูกลบหายไป แต่มันควรจะเป็น “helloworld” หรือ “worldhello” ซึ่งถูกเลือกอย่างเป็นระบบ (deterministically) การจะบรรลุสิ่งนั้นได้ต้องใช้กลยุทธ์การซิงโครไนซ์ที่เข้าใจโครงสร้างของเอกสาร

ทำไมการเริ่มจากศูนย์ถึงสำคัญ

มีเฟรมเวิร์กชั้นยอดมากมายที่ซ่อนความซับซ้อนเหล่านี้ไว้ Yjs, Automerge และ Socket.IO สามารถช่วยลดความยุ่งยากและสร้างตัวต้นแบบที่ใช้งานได้ภายในเวลาเพียงบ่ายวันเดียว แต่การใช้สิ่งเหล่านี้โดยไม่เข้าใจพื้นฐานที่อยู่เบื้องล่าง ก็เหมือนกับการขับเครื่องบินด้วยระบบ autopilot โดยที่ไม่รู้วิธีอ่านเครื่องวัดประกอบการบิน เมื่อเกิดความปั่นป่วน (turbulence) ขึ้น—ซึ่งในระบบแบบกระจาย (distributed systems) มันเกิดขึ้นเสมอ—คุณจำเป็นต้องรู้ว่าปัญหาอยู่ที่เลเยอร์เครือข่าย, การแก้ปัญหาความขัดแย้ง (conflict resolution) หรือที่โมเดลข้อมูลของคุณกันแน่

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

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

Operational Transformation (OT) และ Conflict-free Replicated Data Types (CRDTs) คือสองแนวทางหลักในการแก้ปัญหาเหล่านี้ Google Docs มีชื่อเสียงจากการสร้างสถาปัตยกรรมยุคแรกด้วย OT ซึ่งต้องใช้เซิร์ฟเวอร์กลางในการแปลงการทำงาน (operations) เพื่อให้สอดคล้องกันก่อนที่จะนำไปใช้ ในทางตรงกันข้าม CRDTs ถูกออกแบบมาเพื่อให้การอัปเดตที่เกิดขึ้นพร้อมกันสามารถรวมกันได้ในระดับท้องถิ่น (locally) โดยไม่ต้องมีการประสานงานกัน ทำให้เหมาะสำหรับการตั้งค่าแบบ peer-to-peer หรือ edge-based การเลือกระหว่างสองสิ่งนี้—หรือแนวทางแบบผสม—จำเป็นต้องเข้าใจข้อดีข้อเสีย (trade-offs) ในเรื่องการใช้หน่วยความจำ, การรับประกันความสอดคล้อง (convergence guarantees) และความซับซ้อนในการเขียนโปรแกรม การอ่านเกี่ยวกับข้อดีข้อเสียเหล่านี้ยังไม่เพียงพอ แผนการคือการลงมือทำทั้งเวอร์ชันแบบพื้นๆ และเวอร์ชันที่ปรับปรุงแล้ว เพื่อดูว่าพวกมันจะพังลงที่ตรงไหน

การสร้างใหม่, ความผิดพลาด, และทางตัน

ความคาดหวังถูกตั้งไว้บนพื้นฐานของความเป็นจริง จะมีช่วงเวลาที่ไม่มีอะไรทำงานได้เลย ความพยายามครั้งแรกอาจใช้ JSON patches แบบง่ายเพื่อแทนการเปลี่ยนแปลงข้อความ เพียงเพื่อจะพบว่า JSON ไม่มีแนวคิดเรื่อง "ดัชนีที่ 5 ในย่อหน้า" ดังนั้นการแทรกข้อมูลพร้อมกันสองจุดที่ดัชนีเดียวกันจึงเป็นการเขียนทับกันแทนที่จะเป็นการรวมข้อมูล ความพยายามครั้งที่สองอาจสร้างบันทึกประวัติแบบเส้นตรง (linear history log) ขึ้นมาเอง เพียงเพื่อจะตระหนักว่าการเล่นซ้ำ (replaying) บันทึกนั้นคือฝันร้ายทางด้าน Big O เมื่อเอกสารมีขนาดใหญ่ขึ้น ความพยายามครั้งที่สามอาจทำให้ WebSockets ทำงานได้ในเครื่องตัวเอง แต่กลับพังทลายเมื่อใช้งานบนเครือข่ายจริงที่การสูญเสียแพ็กเก็ต (packet loss) และความหน่วงที่แปรผัน (variable latency) เข้ามาเปลี่ยนกฎเกณฑ์ทั้งหมด

แรงเสียดทานเหล่านั้นคือหัวใจสำคัญ การคัดลอก repository ที่ใช้งานได้อยู่แล้วจะทำให้คุณข้ามขั้นตอนการสืบเสาะว่าทำไมคิวถึงถูกล้าง (flush) ในลำดับนั้นๆ หรือทำไมเซิร์ฟเวอร์ถึงต้องรักษา version vector ไว้ การสร้างคอมโพเนนต์เดิมซ้ำถึงสามครั้งอาจจะช้า แต่มันบังคับให้คุณเข้าใจถึงขอบเขตระหว่างสิ่งที่เฟรมเวิร์กจัดการ กับสิ่งที่ตรรกะของคุณต้องเป็นคนดูแลเอง

เอกสารประกอบกระบวนการนี้จะไม่ใช่แค่การรวบรวมเฉพาะความสำเร็จ แต่มันจะรวมถึงการเดินหลงทางด้วย ตัวอย่างเช่น การสร้างการรับรู้สถานะการใช้งาน (presence awareness)—การรู้ว่าใครออนไลน์อยู่และเคอร์เซอร์ของพวกเขาอยู่ที่ไหน—ดูเหมือนจะเป็นเพียงฟีเจอร์เพื่อความสวยงาม จนกว่าคุณจะตระหนักว่ามันต้องพึ่งพาโมเดลความสอดคล้อง (consistency model) แบบเดียวกับตัวข้อความเอง หากผู้ใช้ A เห็นเคอร์เซอร์ของผู้ใช้ B ที่คอลัมน์ 10 แล้วผู้ใช้ B แทรกตัวอักษรเพิ่มสี่ตัว เคอร์เซอร์นั้นควรจะเคลื่อนไปที่ไหน? หากปราศจากความเข้าใจร่วมกันเกี่ยวกับโครงสร้างเชิงพื้นที่ของเอกสาร (document topology) ข้อมูลสถานะ (presence data) ก็จะคลาดเคลื่อนไปจากความเป็นจริง การแก้ปัญหานี้จำเป็นต้องผูกตำแหน่งเคอร์เซอร์เข้ากับอัตลักษณ์ของโครงสร้างข้อมูลพื้นฐาน ไม่ใช่แค่ดัชนีตัวเลข สิ่งเหล่านี้คือรายละเอียดที่บทช่วยสอนมักจะข้ามไปเพราะมันน่าเบื่อ ไม่ใช่เพราะมันไม่สำคัญ

สิ่งที่จะเกิดขึ้นต่อไป

แผนงานในระยะสั้นถูกออกแบบมาให้เรียบง่าย โดยหมุดหมายแรกจะมีดังนี้:

  • เซิร์ฟเวอร์ WebSocket แบบดิบที่ส่งสัญญาณ (echo) เหตุการณ์การพิมพ์ตัวอักษร เพื่อให้สัมผัสถึงความหน่วง (latency) และวงจรชีวิตของการเชื่อมต่อ (connection lifecycle) ด้วยตัวเอง
  • บัฟเฟอร์สตริง (string buffer) แบบง่ายบนไคลเอนต์ เพื่อทำความเข้าใจว่าทำไมลำดับการแทรกแบบธรรมดาถึงล้มเหลวภายใต้สภาวะการทำงานพร้อมกัน (concurrency)
  • CRDT ที่สร้างขึ้นใหม่ตั้งแต่ต้นสำหรับลำดับข้อมูล (ordered sequences) แม้ว่าจะไม่มีประสิทธิภาพนัก เพื่อดูคุณสมบัติการสลับที่ (commutative property) ในการทำงานจริง
  • การค่อยๆ ผสานรวมเข้ากับหน้าจอแก้ไขโค้ดจริง เช่น CodeMirror หรือ Monaco เพื่อรับมือกับความไม่สอดคล้องกันระหว่าง API แบบ imperative ของตัวแก้ไข กับธรรมชาติแบบ functional ของประวัติการทำงาน (operational history)

แต่ละขั้นตอนจะมาพร้อมกับเหตุผลประกอบที่เป็นลายลักษณ์อักษร ทำไมถึงใช้วิธีนี้แทนที่จะเป็นวิธีนั้น? สมมติฐานใดที่ถูกพิสูจน์ว่าผิด? มีการรั่วไหลของระดับการทำงาน (abstraction) ตรงไหนบ้าง?

บทเรียนที่แท้จริง

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

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