Привет, Хабр!
Сегодня поговорим о блокировке документов при совместной работе — задаче, которая на первый взгляд кажется простой, но таит в себе множество подводных камней. В этой статье я расскажу о нашем опыте реализации такого механизма в одном из проектов. Мы рассмотрим, с какими проблемами столкнулись, какие решения пробовали и к какому итогу пришли.
Задача от заказчика звучала так: организовать совместную работу над документом для нескольких пользователей на разных устройствах.
Первое, что пришло в голову, — использование веб-сокетов. Однако по ряду причин этот вариант не подошел, и нам пришлось искать альтернативу. Нашим решением стало блокирование формы для всех пользователей, кроме текущего редактора. Из разряда «кто первый встал, того и тапки». Человек пришел редактировать документ, и если успел первым занять форму, то все остальные увидят сообщение: «Извините, данный документ уже редактируется другим пользователем. Вы можете связаться с ним и попросить освободить документ для редактирования».
Какие вообще были варианты такой блокировки? Первое, самое очевидное, — это поллинг, то, на чем держится весь веб с момента появления XMLHttpRequest. Но такое решение не очень хорошее по ряду, как я думаю, всем известных причин. Лонгполинг нам не подходит, так как у нас PHP-сервер на бэкенде, который через 30 секунд оборвет соединение. Мы также рассматривали Server-Sent Events — очень интересную технологию, которая появилась в HTTP/2, если не ошибаюсь. Но с точки зрения PHP-бэкенда, мы на выходе получаем тот же самый поллинг, потому что PHP отрабатывает запрос, не держит непрерывное соединение, соединение обрывается, и наш клиент непрерывно коннектился бы к серверу для получения данных. В общем, эти варианты тоже не подошли в нашем случае.
Поэтому мы решили выбрать альтернативное решение. Фактически, это события на блокировку и разблокировку формы. В таком случае нам не нужно непрерывно отправлять запросы на бэкенд для их обработки. У нас будет всего два запроса:
Первый запрос — когда пользователь заходит в форму, он блокирует ее, отправляя запрос на блокирование.
Второй запрос — когда пользователь выходит из формы, отправляется запрос на разблокирование.
Как это выглядит в коде?
У нас есть метод lockIt (универсальный), у которого всего один параметр — блокируем мы или разблокируем форму.
![](https://habrastorage.org/getpro/habr/upload_files/d8d/0d0/cee/d8d0d0ceef05df9074411c1c94be7dca.png)
И, соответственно, у нас в нем стоит проверка: если форма не заблокирована данным пользователем на текущий момент и если ее можно блокировать (то есть она подразумевает совместную работу), то мы ее блокируем. Отправляем запрос на бэкенд с параметром — заблокировать. Что это за IssueRepository.lockIssue?
![](https://habrastorage.org/getpro/habr/upload_files/0be/06a/455/0be06a4550839493f16659c3d380a39d.png)
Что у нас здесь происходит? Мы передаем идентификатор документа и тип блокировки (то есть блокируем мы или разблокируем эту запись). Затем отправляем POST-запрос на определенный URL, в котором передаем идентификатор, тип блокировки и, чтобы эта штука работала даже в случае закрытия окна, обязательно передаем параметр keepalive: true.
Это нам пригодится. Чуть позже объясню, почему.
Как организован сам вызов в компоненте? Конечно, с использованием useEffect. Это первое приближение.
![](https://habrastorage.org/getpro/habr/upload_files/ae5/c19/e8c/ae5c19e8c183933349c2586f0d1aa7e7.png)
Что мы делаем: при входе в форму мы ее блокируем, и если в ответ получаем «blocker error», то выводим эту ошибку пользователю. Это сделано вот для чего: если, пока мы заходили в форму и отправляли запрос на бэкенд, кто-то нас опередил и форма уже заблокирована, редактирование отменяется, и пользователь получает ошибку.
Это решение довольно простое, но нам также нужно разблокировать форму при выходе. useEffect позволяет это сделать. Для этого мы в useEffect указываем, что при возврате нужно отправлять lockit: false, то есть разблокировать форму. Это то же самое событие, но с параметром блокировки — false. На бэкенде мы принимаем этот параметр и снимаем блокировку.
Однако здесь возникает проблема обработки закрытия окна, о чем я говорил выше. Нам нужно держать соединение живым даже в том случае, когда пользователь закрывает окно.
С чем это связано? Очень часто пользователь, зайдя в форму, вносит изменения, форма остается открытой и заблокированной для других, но затем он просто закрывает браузер. В этом случае React не отправляет событие на разблокирование, потому что оно не происходит — мы не обновляем страницу, не переходим в соседние компоненты и так далее.
Что мы делаем? Мы добавляем событие onbeforeunload, чтобы перед закрытием страницы разблокировать форму.
![](https://habrastorage.org/getpro/habr/upload_files/59b/a6f/750/59ba6f750c4079bebf298163c9bfd7d4.png)
Однако, как выяснилось, это событие перекрывается другим событием: если пользователь внес изменения на странице, не сохранил их и нажал «закрыть браузер», то появляется всплывающее окно с вопросом: «Вы точно хотите покинуть страницу? Все ваши изменения не будут сохранены». В этот момент событие onbeforeunload уже отработало, и форма разблокирована на бэкенде, но пользователь из формы еще не вышел. Он может отказаться закрывать форму, чтобы сохранить изменения, и остаться на ней для дальнейшего редактирования. Поэтому onbeforeunload был заменен на unload.
![](https://habrastorage.org/getpro/habr/upload_files/b9e/c84/626/b9ec84626fbc38f99281331c2c7c4776.png)
Такой вариант долгое время был рабочим, но в какой-то момент событие unload перестало работать в браузере Edge. Мы долго думали, как это можно решить. К сожалению, старыми методами такое больше не исправить, потому что, как бы мы ни старались, это событие мы никак не отправим, и никакие запросы на бэкенд при закрытии окна отправить не сможем — по крайней мере, через Edge.
В итоге мы все-таки пришли к поллингу. Он отрабатывает раз в 30 секунд (тайм-аут, естественно, настраиваемый).
![](https://habrastorage.org/getpro/habr/upload_files/1c8/841/661/1c8841661c4689ec72e13cc2f9192e91.png)
После блокировки формы мы устанавливаем таймер — интервал с определенным тайм-аутом. По этому интервалу мы отправляем на бэкенд события о блокировке формы.
Соответственно, другой пользователь, который в этот момент заходит в форму, видит, что она заблокирована, и получает сообщение о том, что не может с ней работать.
При выходе мы очищаем интервал перед тем, как разблокировать форму.
Если пользователь переходит на другую страницу нашего приложения, интервал очищается, блокировки больше не отправляются, и форма разблокируется сразу же.
Если же пользователь закрывает браузер, по истечении определенного тайм-аута (примерно через 2 минуты) на бэкенде происходит проверка. Если запросы от пользователя о блокировке формы перестали поступать, форма автоматически разблокируется, и другие пользователи получают к ней доступ.
Вот такое решение мы реализовали для блокировки форм при совместной работе. Оно оказалось достаточно простым и эффективным, хотя и не лишено своих нюансов. Однако вопрос тайминга остается открытым.
С одной стороны, слишком короткий интервал может привести к преждевременной разблокировке, если у пользователя временно пропадает интернет. (Например, возможна такая ситуация: пользователь редактирует форму, и вдруг у него перезагружается роутер. Он теряет интернет-соединение, и через 2 минуты форма разблокируется, а его изменения еще не сохранены. В этот момент любой другой пользователь может начать редактировать форму.) С другой стороны, длительный тайм-аут увеличивает время, в течение которого форма остается недоступной для других.
Возможно, у вас есть предложения, как улучшить этот механизм? Делитесь своими идеями! =)
Комментарии (3)
chemaxa
31.01.2025 12:07Привет, я может не совсем понял. А смотрели в сторону CRDT и тп. Например есть вот такая либа https://automerge.github.io/
asumin
31.01.2025 12:07Первое, что пришло в голову, — использование веб-сокетов. Однако по ряду причин этот вариант не подошел, и нам пришлось искать альтернативу.
Интересно какие причины? Вебсокеты же прям рождены для этого.
oleg2tor
Проще было бы не по шедулеру проверку вызывать а на запрос на блокировку. Тогда шедулер не нужен.