Текстовый редактор может изменить правила игры, если он оснащен функциями совместного редактирования. Существует так много инструментов, предоставляющих такие функции с превосходным качеством, таких как Google Docs, Microsoft Word и т. д.

Я планировал спроектировать и разработать такую ​​систему, которая могла бы выполнять базовое редактирование текста и масштабироваться по мере необходимости. Поэтому я попытался разработать POC (доказательство концепции) с функцией редактирования в реальном времени и обдумал несколько идей, чтобы сделать это решение масштабируемым.

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

Требование

Давайте определим некоторые требования к проекту для начала

  1. Базовое редактирование
  2. Сотрудничество в режиме реального времени
  3. Базовое операционное преобразование
  4. Список присоединившихся пользователей
  5. Добавлять/удалять пользователей по событию

Инструменты

  1. CKEditor 4
  2. Простой JavaScript
  3. Node.js и экспресс
  4. Socket.io
  5. Для оперативного преобразования я использовал библиотеку различий CKEditor 5, чтобы адаптировать изменения к редактору.

Начальная настройка

Во-первых, нам нужен сервер Node.js, на котором будет размещаться наш сервер приложений и который будет отвечать интерфейсу в корневой конечной точке.

// package.json
{
  "name": "<NAME>",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@socket.io/redis-adapter": "^7.0.0",
    "express": "^4.17.1",
    "http": "^0.0.1-security",
    "redis": "^3.1.2",
    "socket.io": "^4.1.2",
    "uuid": "^8.3.2"
  },
  "devDependencies": {
    "nodemon": "^2.0.9"
  }
}

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

//server.js
const express = require('express');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const app = express();
const port = process.env.PORT || 3000;
app.use('/public', express.static('public'));
app.get('/:id', (req, res) => {
    const fileDirectory = path.resolve(__dirname);
    res.sendFile('index.html', { root: fileDirectory }, (err) => {
        if (err) {
            console.error(err);
            throw (err);
        }
        res.end();
    });
});
app.get('/', (req, res) => {
    res.redirect(307, '/' + uuidv4());
});
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

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

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Collaborative Editor</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
<script src="https://cdn.ckeditor.com/4.16.1/standard/ckeditor.js"></script>
    <script src="public/writer.js"></script>
</head>
<body class="container">
    <div class="row" style="padding-top: 20px;">
        <div class="col-7">
            <h1>Collaborative Editor</h1>
        </div>
    </div>
<div id="editor-block" class="row" style="display: none;">
        <div class="form-group">
            <textarea id="textarea" class="form-control" rows="3"></textarea>
            <script>
                CKEDITOR.replace('textarea');
            </script>
        </div>
    </div>
</body>
</html>

Мы получим идентификатор документа из пути

// writer.js
const documentId = new URL(window.location.href).pathname.substring(1);
const editor = CKEDITOR.instances.textarea;
// Get data
editor.getData();
// Set data
editor.setData("Hello world");

Совместное редактирование

Чтобы добавить совместную работу в наш редактор, у нас есть разные способы реализации.

Асинхронное совместное редактирование

При таком подходе редактор синхронизирует содержимое на основе пользовательского события. Например, после того как пользователь вручную сохранит документ или инициирует событие, содержимое будет доставлено различным клиентам, связанным с этим содержимым.
Чтобы опубликовать локальный контент, необходимо выполнить ручное действие.

Редактирование в реальном времени

Содержимое редактора синхронизируется с другими клиентами в режиме реального времени.

Мы будем двигаться вперед с подходом синхронизации в реальном времени.

Разъем

На этом этапе мы собираемся добавить реализацию сокета в наш проект.

Наш сервер будет прослушивать порты сокетов.

// server.js
...
const httpServer = require("http").createServer(app);
const io = require("socket.io")(httpServer);
...
io.on("connection", socket => {
    console.log('socket connected..', socket.id);
});
httpServer.listen(port);

Управление пользователями в режиме реального времени (необязательно)

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

// server.js
...
io.on("connection", socket => {
  console.log('socket connected..', socket.id);
  socket.on('register', function (data) {
    const room = data.documentId;
    socket.nickname = data.handle;
    socket.join(room);
    let members = [];
    for (const clientId of io.sockets.adapter.rooms.get(room)) {
      members.push({
        id: clientId,
        name: io.sockets.sockets.get(clientId).nickname
      });
    }
    io.in(room).emit('members', members);
    socket.to(room).emit('register', { id: socket.id, name: data.handle });
  });
  socket.on('disconnect', function (data) {
    socket.broadcast.emit('user_left', { id: socket.id });
  });
});
...

Добавьте узел списка, чтобы показать всех подключенных пользователей.

...
<div class="row">
    <div class="col-5">
        <input type="text" class="form-control" id="handle" placeholder="User" />
        <button class="btn btn-outline-success" type="button" id="register">Register</button>
    </div>
</div>
...
<div class="row">
    <div class="col-5">
        <div class="card">
            <div class="card-header">
                Editors
            </div>
            <ul class="list-group list-group-flush" id="editors">
            </ul>
        </div>
    </div>
</div>
...

Динамически добавлять пользователей в DOM

// writer.js
...
let socket;
const handle = document.getElementById('handle');
const register = document.getElementById('register');
function addEditor(writer) {
    var ul = document.getElementById("editors");
    var li = document.createElement("li");
    li.appendChild(document.createTextNode(writer.name));
    li.className = "list-group-item";
    li.id = writer.id;
    ul.appendChild(li);
}
function removeEditor(id) {
    var elem = document.getElementById(id);
    return elem.parentNode.removeChild(elem);
}
function registerUserListener() {
    handle.style.display = 'none';
    register.style.display = 'none';
const editorBlock = document.getElementById('editor-block');
    editorBlock.style.display = 'block';
socket = io();
    socket.emit('register', {
        handle: handle.value,
        documentId: documentId
    });
    socket.on('register', (data) => {
        addEditor(data);
    });
    socket.on('user_left', (data) => {
        removeEditor(data.id);
    });
    socket.on('members', (members) => {
        members.forEach(member => {
            addEditor(member);
        });
        socket.off('members');
    });
}
register.addEventListener('click', registerUserListener);
...

Совместное редактирование

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

Оперативное преобразование

По данным Википедии

Операционная трансформация (OT) – это технология поддержки ряда функций совместной работы в современных программных системах для совместной работы. Первоначально OT был разработан для обеспечения согласованности и управления параллелизмом при совместном редактировании простых текстовых документов.

Внедрение ОТ — это сложный процесс, требующий много времени для реализации и хорошего мастерства. Есть известная цитата Джозефа Джентла, одного из первых, кто внедрил ОТ.

К сожалению, внедрение OT — отстой. Существует миллион алгоритмов с различными компромиссами, в основном застрявшими в академических статьях. Алгоритмы действительно сложны и требуют много времени для правильной реализации. […] На написание Wave ушло 2 года, и если бы мы переписали его сегодня, на второй раз ушло бы почти столько же времени.

Давайте рассмотрим пример операционного преобразования. Допустим, у нас есть текст CA и два разных пользователя выполняют операции
String -> CA
User 1 -> CAT (операция O1)
USER 2 -> HAT (операция O2)

O1 = [вставьте T в позицию 2]

O2 = [вставить T в позиции 2, удалить C в позиции 0, вставить H в позиции 0]

Для пользователя 1 локальная операция — O1, а операция входящего изменения — O2
Для пользователя 2 локальная операция — O2, а операция входящего изменения — O1.

Чтобы добавить преобразование, мы должны применить оба этих изменения к строке. Преображение будет,

For User 1, String --> O2 --> O1 
    String --> CA 
    O2     --> HAT 
    O1     --> HAT (Result) 
For User 2, String --> O1 --> O2 
    String --> CA 
    O1     --> CAT 
    O2     --> HAT (Result)

Состояния синхронизируются для обоих пользователей.

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

Для этого проекта я использовал утилиту CKEditor 5 diffToChanges для проверки изменений (операций) из состояния синхронизации, а затем применил их в редакторе.

Самая сложная часть OT — это не код, а сложность доказательства правильности вашей системы. Поэтому поддерживать код OT сложно. Либо вам нужно неоднократно подтверждать правильность своего кода (и исторически сложилось так, что люди допускают ошибки), либо вам нужна мощная инфраструктура тестирования для параллельных/распределенных систем (которую также сложно написать)

реализация ОТ

// server.js
...
socket.on('content_change', (data) => {
    const room = data.documentId;
    socket.to(room).emit('content_change', data.changes);
});
...

Теперь нам нужно применить преобразование к содержимому редактора. В коде я публикую изменения в сокете после того, как пользователь перестал печатать. В этом случае пользователь сравнит изменения с состоянием синхронизации, а затем применит изменения к состоянию и опубликует изменения в сокете.

При получении изменения он сначала сверится с состоянием синхронизации, а затем применит его. После этого он применит локальные изменения к состоянию, применит и установит значение редактора. Для локальных дополнительных изменений он будет снова опубликован в сокете.

// writer.js
...
// Sync state store
let syncValue = Array();
let keypressed = false;
function getChanges(input, output) {
    return diffToChanges(diff(input, output), output);
}
function applyChanges(input, changes) {
    changes.forEach(change => {
        if (change.type == 'insert') {
            input.splice(change.index, 0, ...change.values);
        } else if (change.type == 'delete') {
            input.splice(change.index, change.howMany);
        }
    });
}
function applyLocalChanges() {
    if (keypressed && editor.checkDirty()) {
        let currentData = editor.getData();
        let input = Array.from(syncValue);
        let output = Array.from(currentData);
        let changes = getChanges(input, output);
        applyChanges(input, changes);
        if (output.join('') == input.join('')) {
            socket.emit('content_change', {
                documentId: documentId,
                changes: changes
            });
            editor.resetDirty();
            syncValue = input;
        }
        keypressed = false;
    }
}
socket.on('content_change', (incomingChanges) => {
    let input = Array.from(syncValue);
    applyChanges(input, incomingChanges);
    syncValue = input;
    applyLocalChanges();
let ranges = editor.getSelection().getRanges();
    editor.setData(syncValue.join(''));
    editor.getSelection().selectRanges(ranges);
    editor.resetDirty();
});
var timeout = setTimeout(null, 0);
editor.on('key', () => {
    clearTimeout(timeout);
    keypressed = true;
    timeout = setTimeout(applyLocalChanges, 1000);
});
...

Контейнеризация

FROM node:16-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY ["package.json", "package-lock.json*", "./"]
RUN npm install --production
COPY . .
EXPOSE 3000
CMD [ "node", "server.js" ]

В следующей части я поделюсь идеями и реализацией для масштабирования этого приложения.

Полный исходный код размещен на Github и краткая демо

Первоначально опубликовано на https://www.mahfuz.info 4 июля 2021 г.