Совместная работа в реальном времени кажется чем-то само собой разумеющимся, пока вы не заглянете за кулисы. Один человек печатает. Другой удаляет строку на три абзаца выше. Третий вставляет фрагмент кода со Stack Overflow. Каким-то образом документ приходит к единому, связному состоянию. Создание такой плавности с нуля, без предварительного опыта работы с WebSockets или распределенным состоянием, кажется безрассудством. Но в то же время это кажется единственно верным способом по-настоящему во всем разобраться.
Этот проект начинается с нуля. Никакого заимствованного шаблонного кода. Никаких отполированных туториалов на YouTube, где сложные моменты пролетают в тридцатисекундном монтаже. Цель — создать совместный редактор кода, в котором несколько пользователей могут одновременно редактировать один и тот же файл, видя изменения друг друга — и курсоры друг друга — в режиме реального времени. Чтобы достичь этого, придется разобраться с транспортными уровнями, моделями согласованности и сложной проблемой слияния одновременных правок без повреждения документа.
Что на самом деле означает «режим реального времени»
Большинство веб-приложений привыкли к циклам «запрос-ответ». Вы отправляете форму, сервер сохраняет данные, вы обновляете страницу. Совместная работа в реальном времени полностью нарушает этот контракт. Каждое нажатие клавиши — это событие, которое должно распространиться на все остальные подключенные клиенты, обычно за миллисекунды, и прибыть в таком порядке, который сохраняет смысл.
WebSockets здесь — очевидный выбор транспортного уровня, так как они поддерживают постоянное полнодуплексное соединение между клиентом и сервером. В отличие от HTTP polling, который тратит пропускную способность, каждые несколько секунд спрашивая «есть что-нибудь новое?», WebSocket остается открытым. Когда пользователь А вводит точку с запятой, этот символ становится сообщением, которое проходит через сокет на центральный сервер, а затем рассылается пользователям Б и В. Эта часть относительно проста.
Сложность заключается в том, что происходит, когда Б и В печатают в один и тот же момент. Если оба изменения достигают сервера почти одновременно, какое из них победит? Если вы будете просто рассылать сообщения в порядке их поступления, вы рискуете получить потерянные символы или перемешанный текст. Наивные стратегии типа «последняя запись побеждает» (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) — понимания того, кто находится онлайн и где находится курсор пользователя — кажется косметической функцией, пока вы не осознаете, что она зависит от той же модели согласованности, что и сам текст. Если пользователь А видит курсор пользователя Б на 10-м символе, а затем пользователь Б вставляет четыре символа, куда переместится этот курсор? Без общего понимания топологии документа данные о присутствии будут расходиться с реальностью. Решение этой проблемы требует привязки позиции курсора к идентификатору базовой структуры данных, а не просто к его числовому индексу. Это те детали, которые туториалы обходят стороной, потому что они утомительны, а не потому, что они не важны.
Что дальше
Дорожная карта на ближайшее время намеренно лаконична. Первыми вехами станут:
- Сырой WebSocket-сервер, который дублирует (echo) события символов, чтобы на собственном опыте почувствовать задержку и жизненный цикл соединения
- Простой строковый буфер на стороне клиента, чтобы понять, почему наивный порядок вставок не работает при конкурентном доступе
- CRDT, написанный с нуля для упорядоченных последовательностей, каким бы неэффективным он ни был, чтобы увидеть коммутативность в действии
- Постепенная интеграция с реальным интерфейсом текстового редактора, скорее всего, чем-то вроде CodeMirror или Monaco, чтобы справиться с несоответствием между императивным API редактора и функциональной природой истории операций
Каждый шаг будет сопровождаться письменным обоснованием. Почему именно такой подход, а не другой? Какие предположения оказались неверными? Какая абстракция «протекла»?
Главный вывод
Начинать такой проект без опыта работы с WebSockets или CRDT — пугающе, но экспертность — это зачастую просто повторяющееся замешательство, которому дали более точные названия. Цель не в том, чтобы быстро закончить. Цель — создать систему, поведение которой предсказуемо, потому что каждый уровень был построен осознанно, а не импортирован в надежде на успех.
Если вы уже создавали совместное программное обеспечение — будь то текстовый редактор, инструмент для дизайна или движок синхронизации состояния игры — поделитесь ошибками, которые застали вас врасплох. Если вы тоже изучаете эти системы, следуйте за мной. Код будет появляться медленно, и его часто придется переписывать. День 0 начинается сейчас.
