همکاری در لحظه (Real-time collaboration) بی‌دردسر به نظر می‌رسد، تا زمانی که پرده‌ها را کنار بزنید. یک نفر تایپ می‌کند. دیگری سطری را سه پاراگراف بالاتر پاک می‌کند. نفر سوم تکه‌ای کد را از Stack Overflow کپی می‌کند. به شکلی، سند در نهایت به یک وضعیت واحد و منسجم می‌رسد. ساختن چنین روانی و پویایی از صفر، بدون تجربه قبلی در WebSockets یا وضعیت توزیع‌شده (distributed state)، بی‌پروا به نظر می‌رسد. اما در عین حال، به نظر می‌رسد بهترین راه برای یادگیری واقعی است.

این پروژه از صفر شروع می‌شود. بدون استفاده از کدهای آماده (boilerplate). بدون آموزش‌های صیقل‌خورده یوتیوب که در آن‌ها بخش‌های سخت در یک مونتاژ سی‌ثانیه‌ای نادیده گرفته می‌شوند. هدف، ساخت یک ویرایشگر کد مشارکتی است که در آن چندین کاربر بتوانند به طور همزمان یک فایل واحد را ویرایش کنند و تغییرات یکدیگر — و مکان‌نماهای (cursors) یکدیگر را — در لحظه مشاهده کنند. رسیدن به این هدف مستلزم درک لایه‌های انتقال (transport layers)، مدل‌های سازگاری (consistency models) و مسئله دشوارِ ادغام ویرایش‌های همزمان بدون خراب کردن سند است.

معنای واقعی «در لحظه» (Real-Time) چیست

اکثر اپلیکیشن‌های وب با چرخه‌های درخواست-پاسخ (request-response) راحت هستند. شما فرمی را ارسال می‌کنید، سرور آن را ذخیره می‌کند و شما صفحه را رفرش می‌کنید. همکاری در لحظه، این قرارداد را کاملاً می‌شکند. هر کلید زده شده یک رویداد است که باید به تمام کلاینت‌های متصل دیگر، معمولاً در عرض چند میلی‌ثانیه، منتشر شود و با ترتیبی برسد که معنای آن حفظ شود.

در اینجا WebSockets انتخاب بدیهی برای لایه انتقال هستند، زیرا یک اتصال پایدار و تمام‌دوطرفه (full-duplex) بین کلاینت و سرور برقرار می‌کنند. برخلاف HTTP polling که با پرسیدن «آیا چیز جدیدی هست؟» در هر چند ثانیه، پهنای باند را هدر می‌دهد، یک WebSocket باز می‌ماند. وقتی کاربر A یک نقطه-ویرگول تایپ می‌کند، آن کاراکتر به پیامی تبدیل می‌شود که از طریق سوکت به یک سرور مرکزی می‌رود و سپس به کاربران B و C پخش می‌شود. این بخش نسبتاً ساده است.

بخش سخت ماجرا زمانی است که B و C دقیقاً در یک لحظه تایپ کنند. اگر هر دو تغییر تقریباً به طور همزمان به سرور برسند، کدام یک برنده است؟ اگر پیام‌ها را صرفاً به ترتیب رسیدن پخش کنید، با خطر حذف شدن کاراکترها یا به‌هم‌ریختگی متن مواجه خواهید شد. استراتژی‌های ساده‌ی «آخرین نوشته برنده است» (last-write-wins) شکست می‌خورند، زیرا قصد و نیت کاربر را نادیده می‌گیرند. اگر من در ابتدای خط اول «hello» را تایپ کنم و شما هم در ابتدای خط اول «world» را تایپ کنید، نتیجه نباید یک تداخل باشد که در آن یکی از ما پاک شود. نتیجه باید به صورت قطعی (deterministically) «helloworld» یا «worldhello» باشد. دستیابی به این هدف مستلزم یک استراتژی همگام‌سازی است که ساختار سند را درک کند.

چرا شروع از صفر اهمیت دارد

فریم‌ورک‌های عالی وجود دارند که این پیچیدگی را پنهان می‌کنند. Yjs، Automerge و Socket.IO می‌توانند این سختی‌ها را انتزاع (abstract) کرده و در یک بعدازظهر یک نمونه اولیه کارآمد ارائه دهند. اما استفاده از آن‌ها بدون درک مفاهیم پایه (primitives)، مانند پرواز با هواپیما در حالت خلبان خودکار (autopilot) بدون دانستن نحوه خواندن ابزارهای کنترلی است. وقتی تلاطم (turbulence) رخ می‌دهد — و در سیستم‌های توزیع‌شده، همیشه رخ می‌دهد — باید بدانید که مشکل در لایه شبکه شماست، در حل تعارض (conflict resolution) یا در مدل داده‌ای شما.

تعهد در اینجا این است که مفاهیم را قبل از تکیه بر کتابخانه‌ها یاد بگیریم. این یعنی تحلیل دستیِ آنچه رخ می‌دهد در شرایط زیر:

  • یک کلاینت در میانه‌ی فشردن یک کلید قطع شود و ده ثانیه بعد دوباره متصل شود
  • دو کاربر به طور همزمان در یک موقعیت مکان‌نما متن وارد کنند
  • یک کاربر بلوکی را پاک کند که کاربر دیگری در حال ویرایش فعال آن است
  • سرور از کار بیفتد و یک گره (node) جدید مجبور شود وضعیت سند را از صفر بازسازی کند

Operational Transformation (OT) و Conflict-free Replicated Data Types (CRDTs) دو خانواده اصلی از راهکارها برای این مشکلات هستند. Google Docs به طور مشهوری معماری اولیه خود را بر پایه OT بنا کرد، که مستلزم یک سرور مرکزی است تا عملیات‌ها را پیش از اعمال، نسبت به یکدیگر تغییر دهد (transform). در مقابل، CRDTs به گونه‌ای طراحی شده‌اند که به‌روزرسانی‌های همزمان را می‌توان بدون هماهنگی به صورت محلی ادغام کرد، که آن‌ها را برای ساختارهای همتا-به-همتا (peer-to-peer) یا مبتنی بر لبه (edge-based) جذاب می‌کند. انتخاب بین آن‌ها — یا رویکردهای ترکیبی — مستلزم درک سبک‌سنگین کردن‌ها (trade-offs) در میزان مصرف حافظه، تضمین‌های همگرایی (convergence guarantees) و پیچیدگی پیاده‌سازی است. مطالعه درباره این سبک‌سنگین‌ها کافی نیست؛ برنامه این است که هر دو نسخه ساده (naive) و اصلاح‌شده (refined) را پیاده‌سازی کنیم تا ببینیم در کجا دچار خطا می‌شوند.

بازسازی‌ها، اشتباهات و بن‌بست‌ها

انتظارات صادقانه تنظیم شده‌اند. دوره‌هایی وجود خواهد داشت که هیچ‌چیز کار نمی‌کند. اولین تلاش ممکن است از JSON patchهای ساده برای نمایش تغییرات متن استفاده کند، اما بعداً متوجه شود که JSON مفهومی برای «اندیس ۵ در یک پاراگراف» ندارد، بنابراین دو درج همزمان در یک اندیس مشابه، به جای ادغام شدن، یکدیگر را بازنویسی می‌کنند. تلاش دوم ممکن است یک لاگ تاریخچه خطی سفارشی بسازد، اما بعداً متوجه شود که بازپخش (replaying) آن لاگ با بزرگ شدن سند، به یک کابوس Big O تبدیل می‌شود. تلاش سوم ممکن است WebSockets را به صورت محلی راه‌اندازی کند، اما در یک شبکه واقعی که در آن از دست رفتن بسته‌ها (packet loss) و تأخیر متغیر، قوانین را تغییر می‌دهند، از هم بپاشد.

هدف اصلی دقیقاً همین اصطکاک است. کپی کردن یک مخزن (repository) آماده، باعث می‌شود از بررسی علت تخلیه صف (queue flush) با آن ترتیب خاص، یا علت استفاده سرور از یک بردار نسخه (version vector) چشم‌پوشی کنید. بازسازی یک کامپوننت یکسان به مدت سه بار، کند است، اما شما را مجبور به درک مرز بین کاری که فریم‌ورک انجام می‌دهد و آنچه منطق خودتان باید مدیریت کند، می‌کند.

مستندات این فرآیند، مجموعه‌ای از موفقیت‌های درخشان نخواهد بود؛ بلکه شامل مسیرهای اشتباه نیز خواهد بود. برای مثال، ساخت قابلیت «آگاهی از حضور» (presence awareness) — یعنی دانستن اینکه چه کسی آنلاین است و نشانگر (cursor) او کجاست — تا زمانی که متوجه نشوید این قابلیت به همان مدل سازگاری (consistency model) متن وابسته است، یک ویژگی ظاهری به نظر می‌رسد. اگر کاربر A نشانگر کاربر B را در ستون ۱۰ ببیند، و سپس کاربر B چهار کاراکتر درج کند، آن نشانگر به کجا منتقل می‌شود؟ بدون درک مشترک از توپولوژی سند، داده‌های مربوط به حضور از واقعیت فاصله می‌گیرند. حل این مسئله مستلزم پیوند دادن موقعیت نشانگر به هویت ساختار داده‌ای زیرین است، نه فقط اندیس عددی آن. این‌ها از آن دسته جزئیاتی هستند که آموزش‌ها از روی آن‌ها سریع رد می‌شوند، نه به این دلیل که بی‌اهمیت هستند، بلکه چون خسته‌کننده می‌باشند.

آنچه در ادامه می‌آید

نقشه راه فوری، از ابتدا با طراحیِ مینیمال ارائه شده است. اولین نقاط عطف عبارتند از:

  • یک سرور WebSocket خام که رویدادهای کاراکتر را بازتاب (echo) می‌دهد، تا تأخیر و چرخه حیات اتصال را مستقیماً تجربه کنید
  • یک بافر رشته‌ای ساده در کلاینت تا درک کنید چرا ترتیب درج ساده‌لوحانه در شرایط همزمانی (concurrency) شکست می‌خورد
  • یک CRDT از صفر برای توالی‌های مرتب‌شده، هرچند ناکارآمد، تا خاصیت جابه‌جایی‌پذیری (commutative property) را در عمل ببینید
  • ادغام تدریجی با یک محیط ویرایشگر کد واقعی، احتمالاً چیزی مانند CodeMirror یا Monaco، تا با عدم تطابق بین API امری (imperative) ویرایشگر و ماهیت تابعی (functional) تاریخچه عملیاتی دست‌وپنجه نرم کنید

هر مرحله با یک استدلال مکتوب همراه خواهد بود. چرا این رویکرد و نه آن یکی؟ چه فرضیاتی رد شدند؟ کدام انتزاع (abstraction) نشت کرد؟

یک نتیجه‌گیری واقعی

شروع پروژه‌ای از این دست بدون تجربه در WebSockets یا CRDTs دلهره‌آور است، اما تخصص اغلب چیزی نیست جز تکرار سردرگمی‌ها با برچسب‌های بهتر. هدف، پایان دادن سریع نیست. هدف ساخت سیستمی است که رفتارش قابل پیش‌بینی باشد، زیرا هر لایه با هدف و قصد ساخته شده است، نه با امید و انتظار.

اگر قبلاً نرم‌افزار مشارکتی ساخته‌اید — خواه یک ویرایشگر متن باشد، یا یک ابزار طراحی، یا یک موتور همگام‌سازی وضعیت بازی — حالت‌های شکست (failure modes) که شما را غافلگیر کردند، به اشتراک بگذارید. اگر شما هم در حال یادگیری این سیستم‌ها هستید، با ما همراه باشید. کدها به آرامی از راه می‌رسند و اغلب بازنویسی خواهند شد. روز صفر، از همین حالا شروع می‌شود.