Спільна робота в режимі реального часу здається легкою, поки ви не зазирнете за лаштунки. Одна людина друкує. Інша видаляє рядок на три абзаци вище. Третя вставляє фрагмент зі Stack Overflow. Якимось чином документ приходить до єдиного, узгодженого стану. Створення такої плавності з нуля, без попереднього досвіду роботи з WebSockets або розподіленим станом, звучить безрозсудно. Але водночас це здається правильним способом справді навчитися.
Цей проєкт починається з нуля. Жодного запозиченого шаблонного коду. Жодних відшліфованих відеоуроків на YouTube, де складні моменти пропускаються під тридцятисекундний монтаж. Мета — створити спільний редактор коду, де кілька користувачів можуть одночасно редагувати один і той самий файл, бачачи зміни один одного — і курсори один одного — у реальному часі. Щоб досягти цього, доведеться розібратися з транспортними рівнями, моделями узгодженості та складною проблемою злиття одночасних правок без пошкодження документа.
Що насправді означає «режим реального часу»
Більшість вебдодатків працюють у звичних циклах запит-відповідь. Ви надсилаєте форму, сервер зберігає дані, ви оновлюєте сторінку. Спільна робота в режимі реального часу повністю порушує цей контракт. Кожне натискання клавіші — це подія, яка має поширитися на всі інші підключені клієнти, зазвичай за мілісекунди, і прийти в такому порядку, що зберігає зміст.
WebSockets є очевидним вибором для транспортування, оскільки вони підтримують постійне повнодуплексне з'єднання між клієнтом і сервером. На відміну від HTTP polling, який марнує пропускну здатність, запитуючи «чи є щось нове?» кожні кілька секунд, WebSocket залишається відкритим. Коли користувач А вводить крапку з комою, цей символ стає повідомленням, яке проходить через сокет до центрального сервера, а потім розповсюджується на користувачів B і C. Ця частина відносно проста.
Складна частина полягає в тому, що відбувається, коли B і C друкують у той самий момент. Якщо обидві зміни потрапляють на сервер майже одночасно, яка з них переможе? Якщо ви просто транслюєте повідомлення в порядку їх надходження, ви ризикуєте втратити символи або отримати переплутаний текст. Наївні стратегії «останній запис перемагає» (last-write-wins) не працюють, оскільки вони ігнорують намір. Якщо я введу «hello» на початку першого рядка, поки ви вводите «world» на початку того ж рядка, результатом не має бути колізія, через яку одного з нас буде стерто. Результатом має бути «helloworld» або «worldhello», обране детерміновано. Для цього потрібна стратегія синхронізації, яка розуміє структуру документа.
Чому важливо починати з нуля
Існують чудові фреймворки, які приховують цю складність. Yjs, Automerge та Socket.IO можуть абстрагувати біль і дозволити створити робочий прототип за один день. Але використовувати їх, не розуміючи базових примітивів, — це все одно що керувати літаком на автопілоті, не вміючи читати прилади. Коли виникає турбулентність — а в розподілених системах вона виникає завжди — вам потрібно знати, чи проблема у вашому мережевому рівні, у розв'язанні конфліктів чи у вашій моделі даних.
Мета полягає в тому, щоб спочатку вивчити концепції, а вже потім покладатися на бібліотеки. Це означає вручну розбиратися з тим, що відбувається, коли:
- Клієнт відключається посеред введення символу і перепідключається через десять секунд
- Два користувачі одночасно вставляють текст у ту саму позицію курсора
- Один користувач видаляє блок, який інший користувач активно редагує
- Сервер падає, і новому вузлу доводиться відновлювати стан документа з нуля
Operational Transformation (OT) та Conflict-free Replicated Data Types (CRDTs) — це дві домінуючі родини рішень для таких проблем. Відомо, що Google Docs побудував свою ранню архітектуру на OT, що вимагає центрального сервера для трансформації операцій відносно одна одної перед їх застосуванням. CRDTs, навпаки, розроблені так, щоб одночасні оновлення можна було зливати локально без координації, що робить їх привабливими для пірингових (P2P) або edge-систем. Вибір між ними — або гібридними підходами — потребує розуміння компромісів щодо використання пам'яті, гарантій збіжності та складності реалізації. Просто читати про ці компроміси недостатньо; план полягає в тому, щоб реалізувати як наївні, так і вдосконалені версії, щоб побачити, де вони ламаються.
Переробки, помилки та глухі кути
Очікування формулюються чесно. Будуть періоди, коли нічого не працює. Перша спроба може використовувати прості JSON-патчі для представлення змін тексту, лише щоб виявити, що JSON не має поняття «індекс 5 у параграфі», тому два одночасні внесення за одним і тим самим індексом перезаписують одне одного замість того, щоб об'єднатися. Друга спроба може полягати у створенні кастомного лінійного логу історії, лише щоб зрозуміти, що відтворення цього логу стає кошмаром з точки зору Big O, коли документ розростається. Третя спроба може призвести до того, що WebSockets працюватимуть локально, а потім усе розвалиться в реальній мережі, де втрата пакетів і змінна затримка змінюють правила гри.
У цьому те й полягає суть. Копіювання готового репозиторію дозволило б уникнути дослідження того, чому черга очищується саме в такому порядку або чому сервер підтримує вектор версій (version vector). Тричі перезбирати один і той самий компонент — це повільно, але це змушує зрозуміти межу між тим, що робить фреймворк, і тим, що має обробляти ваша власна логіка.
Документація цього процесу не буде збіркою найкращих моментів. Вона включатиме й помилкові шляхи. Наприклад, відстеження присутності (presence awareness) — знання того, хто онлайн і де знаходиться курсор користувача — здається лише косметичною функцією, поки ви не усвідомлюєте, що вона залежить від тієї ж моделі узгодженості (consistency model), що й сам текст. Якщо користувач А бачить курсор користувача Б на 10-й позиції, а потім користувач Б вставляє чотири символи, куди переміститься цей курсор? Без спільного розуміння топології документа дані про присутність відхиляються від реальності. Вирішення цього питання вимагає прив'язки позиції курсора до ідентифікатора базової структури даних, а не просто до його чисельного індексу. Це саме ті деталі, які туторіали пропускають побіжно, не тому, що вони неважливі, а тому, що вони нудні.
Що далі
Попередній план дій навмисно є лаконічним. Перші етапи будуть такими:
- Сирий WebSocket-сервер, що відлунює події введення символів, щоб на власному досвіді відчути затримку та життєвий цикл з'єднання
- Простий строковий буфер на стороні клієнта, щоб зрозуміти, чому наївне упорядкування вставок не працює під час паралельних операцій
- CRDT, створений з нуля для впорядкованих послідовностей, яким би неефективним він не був, щоб побачити комутативну властивість у дії
- Поступова інтеграція з реальним редактором коду, ймовірно, чимось на кшталт CodeMirror або Monaco, щоб розібратися з невідповідністю між імперативним API редактора та функціональною природою історії операцій
Кожен крок супроводжуватиметься письмовим обґрунтуванням. Чому саме такий підхід, а не інший? Які припущення були спростовані? Яка абстракція «протекла»?
Справжній висновок
Починати такий проєкт без досвіду роботи з WebSockets або CRDTs лякаюче, але експертність — це часто просто повторювана розгубленість з кращими назвами. Мета не в тому, щоб швидко закінчити. Мета — створити систему, чия поведінка є передбачуваною, тому що кожен її рівень був побудований свідомо, а не імпортований на сподіванні на краще.
Якщо ви вже створювали спільне програмне забезпечення — будь то текстовий редактор, інструмент для дизайну чи рушій синхронізації стану гри — поділіться сценаріями відмов, які застали вас зненацька. Якщо ви теж вивчаєте ці системи, стежте за процесом. Код з'являтиметься повільно, і його часто переписуватимуть. День 0 починається зараз.
