Привет, Хабр! 

Сегодня поговорим о блокировке документов при совместной работе — задаче, которая на первый взгляд кажется простой, но таит в себе множество подводных камней. В этой статье я расскажу о нашем опыте реализации такого механизма в одном из проектов. Мы рассмотрим, с какими проблемами столкнулись, какие решения пробовали и к какому итогу пришли. 

Задача от заказчика звучала так: организовать совместную работу над документом для нескольких пользователей на разных устройствах.

Первое, что пришло в голову, — использование веб-сокетов. Однако по ряду причин этот вариант не подошел, и нам пришлось искать альтернативу. Нашим решением стало блокирование формы для всех пользователей, кроме текущего редактора. Из разряда «кто первый встал, того и тапки». Человек пришел редактировать документ, и если успел первым занять форму, то все остальные увидят сообщение: «Извините, данный документ уже редактируется другим пользователем. Вы можете связаться с ним и попросить освободить документ для редактирования».

Какие вообще были варианты такой блокировки? Первое, самое очевидное, — это поллинг, то, на чем держится весь веб с момента появления XMLHttpRequest. Но такое решение не очень хорошее по ряду, как я думаю, всем известных причин. Лонгполинг нам не подходит, так как у нас PHP-сервер на бэкенде, который через 30 секунд оборвет соединение. Мы также рассматривали Server-Sent Events — очень интересную технологию, которая появилась в HTTP/2, если не ошибаюсь. Но с точки зрения PHP-бэкенда, мы на выходе получаем тот же самый поллинг, потому что PHP отрабатывает запрос, не держит непрерывное соединение, соединение обрывается, и наш клиент непрерывно коннектился бы к серверу для получения данных. В общем, эти варианты тоже не подошли в нашем случае. 

Поэтому мы решили выбрать альтернативное решение. Фактически, это события на блокировку и разблокировку формы. В таком случае нам не нужно непрерывно отправлять запросы на бэкенд для их обработки. У нас будет всего два запроса: 

Первый запрос — когда пользователь заходит в форму, он блокирует ее, отправляя запрос на блокирование.

Второй запрос — когда пользователь выходит из формы, отправляется запрос на разблокирование.

Как это выглядит в коде?

У нас есть метод lockIt (универсальный), у которого всего один параметр — блокируем мы или разблокируем форму.

И, соответственно, у нас в нем стоит проверка: если форма не заблокирована данным пользователем на текущий момент и если ее можно блокировать (то есть она подразумевает совместную работу), то мы ее блокируем. Отправляем запрос на бэкенд с параметром — заблокировать. Что это за IssueRepository.lockIssue?

Что у нас здесь происходит? Мы передаем идентификатор документа и тип блокировки (то есть блокируем мы или разблокируем эту запись). Затем отправляем POST-запрос на определенный URL, в котором передаем идентификатор, тип блокировки и, чтобы эта штука работала даже в случае закрытия окна, обязательно передаем параметр keepalive: true.

Это нам пригодится. Чуть позже объясню, почему

Как организован сам вызов в компоненте? Конечно, с использованием useEffect. Это первое приближение.

Что мы делаем: при входе в форму мы ее блокируем, и если в ответ получаем «blocker error», то выводим эту ошибку пользователю. Это сделано вот для чего: если, пока мы заходили в форму и отправляли запрос на бэкенд, кто-то нас опередил и форма уже заблокирована, редактирование отменяется, и пользователь получает ошибку. 

Это решение довольно простое, но нам также нужно разблокировать форму при выходе. useEffect позволяет это сделать. Для этого мы в useEffect указываем, что при возврате нужно отправлять lockit: false, то есть разблокировать форму. Это то же самое событие, но с параметром блокировки — false. На бэкенде мы принимаем этот параметр и снимаем блокировку. 

Однако здесь возникает проблема обработки закрытия окна, о чем я говорил выше. Нам нужно держать соединение живым даже в том случае, когда пользователь закрывает окно. 

С чем это связано? Очень часто пользователь, зайдя в форму, вносит изменения, форма остается открытой и заблокированной для других, но затем он просто закрывает браузер. В этом случае React не отправляет событие на разблокирование, потому что оно не происходит — мы не обновляем страницу, не переходим в соседние компоненты и так далее. 

Что мы делаем? Мы добавляем событие onbeforeunload, чтобы перед закрытием страницы разблокировать форму.

Однако, как выяснилось, это событие перекрывается другим событием: если пользователь внес изменения на странице, не сохранил их и нажал «закрыть браузер», то появляется всплывающее окно с вопросом: «Вы точно хотите покинуть страницу? Все ваши изменения не будут сохранены». В этот момент событие onbeforeunload уже отработало, и форма разблокирована на бэкенде, но пользователь из формы еще не вышел. Он может отказаться закрывать форму, чтобы сохранить изменения, и остаться на ней для дальнейшего редактирования. Поэтому onbeforeunload был заменен на unload.

Такой вариант долгое время был рабочим, но в какой-то момент событие unload перестало работать в браузере Edge. Мы долго думали, как это можно решить. К сожалению, старыми методами такое больше не исправить, потому что, как бы мы ни старались, это событие мы никак не отправим, и никакие запросы на бэкенд при закрытии окна отправить не сможем — по крайней мере, через Edge. 

В итоге мы все-таки пришли к поллингу. Он отрабатывает раз в 30 секунд (тайм-аут, естественно, настраиваемый).

После блокировки формы мы устанавливаем таймер — интервал с определенным тайм-аутом. По этому интервалу мы отправляем на бэкенд события о блокировке формы.

Соответственно, другой пользователь, который в этот момент заходит в форму, видит, что она заблокирована, и получает сообщение о том, что не может с ней работать. 

При выходе мы очищаем интервал перед тем, как разблокировать форму.

Если пользователь переходит на другую страницу нашего приложения, интервал очищается, блокировки больше не отправляются, и форма разблокируется сразу же.

Если же пользователь закрывает браузер, по истечении определенного тайм-аута (примерно через 2 минуты) на бэкенде происходит проверка. Если запросы от пользователя о блокировке формы перестали поступать, форма автоматически разблокируется, и другие пользователи получают к ней доступ. 

Вот такое решение мы реализовали для блокировки форм при совместной работе. Оно оказалось достаточно простым и эффективным, хотя и не лишено своих нюансов. Однако вопрос тайминга остается открытым.  

С одной стороны, слишком короткий интервал может привести к преждевременной разблокировке, если у пользователя временно пропадает интернет. (Например, возможна такая ситуация: пользователь редактирует форму, и вдруг у него перезагружается роутер. Он теряет интернет-соединение, и через 2 минуты форма разблокируется, а его изменения еще не сохранены. В этот момент любой другой пользователь может начать редактировать форму.) С другой стороны, длительный тайм-аут увеличивает время, в течение которого форма остается недоступной для других.  

Возможно, у вас есть предложения, как улучшить этот механизм? Делитесь своими идеями! =)

Комментарии (3)


  1. oleg2tor
    31.01.2025 12:07

    Если же пользователь закрывает браузер, по истечении определенного тайм-аута (примерно через 2 минуты) на бэкенде происходит проверка. Если запросы от пользователя о блокировке формы перестали поступать, форма автоматически разблокируется, и другие пользователи получают к ней доступ. 

    Проще было бы не по шедулеру проверку вызывать а на запрос на блокировку. Тогда шедулер не нужен.


  1. chemaxa
    31.01.2025 12:07

    Привет, я может не совсем понял. А смотрели в сторону CRDT и тп. Например есть вот такая либа https://automerge.github.io/


  1. asumin
    31.01.2025 12:07

    Первое, что пришло в голову, — использование веб-сокетов. Однако по ряду причин этот вариант не подошел, и нам пришлось искать альтернативу.

    Интересно какие причины? Вебсокеты же прям рождены для этого.