Введение

Всем привет! Меня зовут Герман Панов и в этой статье мы разработаем табличный редактор - аналог Google Sheets (в упрощенном виде), работающий на основе вебсокетов, чтобы познакомиться со способами применения этой технологии в браузерах.

Поскольку цель - ознакомление, код будет не очень «чистым», но для базового примера этого будет достаточно. В качестве серверной платформы будем использовать Node.js, также потребуется пакет ws, предоставляющий API для работы с вебсокетами на сервере.

Задача

Создать табличный редактор в соответствии со следующими требованиями:

Функциональные требования

  • редактирование ячеек таблицы

  • сохранение состояния таблицы

  • возможность одновременного использования несколькими пользователями в режиме реального времени

Нагрузочные требования

  • Требований по нагрузке нет

Теория. Протокол Websocket

Протокол WebSocket(«веб-сокет») предоставляет возможность организации постоянного двустороннего обмена данными между браузером и сервером. Сервер в любой момент может прислать сообщение клиенту, независимо, запрашивал ли его клиент или нет. Для этого браузеру нужно установить соединение с сервером посредством handshake («рукопожатия»)

Браузер, при помощи специальных заголовков, проверяет, поддерживает ли сервер обмен данными по Websocket, и если да, «общение» будет происходить уже по WebSocket.

На прикладной уровень текстовые сообщения приходят в кодировке UTF-8, для удобной обработки используется JSON формат. Посмотреть сообщения, получаемые по WebSocket, можно во вкладке Network -> WS в девтулзах браузера.

В качестве транспортного протокола выступает TCP. Поскольку данные фрагментируются, по Websocket можно отправлять даже сообщения большого объема. Каждое будет разбиваться на фреймы, которые по своей сути похожи на http-сообщения.

Фрейм - это небольшой заголовок + «полезная нагрузка». Заголовок содержит служебную информацию о фрейме и отправляемых данных. Полезная нагрузка - это любые данные приложения, аналогичные тексту http-сообщения.

Вид фрейма:

В браузере есть API для работы с вебсокетами, для установления соединения необходимо создать объект Websocket, передав URL в качестве параметра. По аналогии с HTTP есть два типа соединения: с шифрованием (wss) и без (ws).

Для работы с данными нужно определить колбэк-функции у созданного объекта. Есть 3 типа обработчиков для сетевых событий: onopen, onerror, onclose и один обработчик для событий отправки сообщений: onmessage. (Подробнее можно почитать тут).


Для отправки данных в сокет существует метод send.

const ws = new WebSocket('ws://localhost/echo');

ws.onopen = event => {
    alert('onopen');

    ws.send("Hello Web Socket!");
};

ws.onmessage = event => {
    alert('onmessage, ' + event.data);
};

ws.onclose = event => {
    alert('onclose');
};

ws.onerror = event => {
    alert('onerror');
};

На сервере будем использовать популярную WebSocket клиент-серверную библиотеку ws для Node.js. Предоставляемое API очень похоже на браузерное.

Полный список инструментов, поддерживающих работу с WebSocket можно посмотреть здесь

В основном WebSocket используется для сервисов, которым необходим постоянный обмен данными:

  • Игры

  • Социальные сети и чаты

  • Торговые площадки

  • Умный дом

Реализация. Написание редактора

Потребуется установить платформу Node.js. Инструкцию по установке можно найти на официальном сайте

Проект будет состоять из двух файлов:

  • HTML-файл с разметкой, стилями и скриптом для отрисовки клиентской части

  • JS-файл с серверной логикой, описывающей взаимодействие по сети

Создадим скелет проекта:

  1. Создадим отдельный каталог под проект.

  2. Инициализируем новый git-репозиторий в созданном каталоге.

  3. Инициализируем новый npm-пакет, который будет содержать код проекта.

  4. Установим необходимые для работы пакеты. Из сторонних зависимостей нам потребуется только пакет ws.

  5. Создадим файл .gitignore, в который запишем каталог "node_modules", поскольку тянуть каталог с установленными зависимостями в git не стоит.

# Создадим каталог проекта и перейдем в него
mkdir websocket-example && cd websocket-example

# Инициализируем git-репозиторий
git init

# Инициализируем npm-пакет, который будет содержать код проекта
npm init -y

# Установим библиотеку для работы с WebSocket на сервере
npm install ws

# Укажем git не учитывать каталог с установленными зависимостями
touch .gitignore && echo "node_modules" >> .gitignore

Базовое окружение настроено, можно приступать к написанию приложения.

Организуем следующую структуру проекта:

node_modules
public/index.html
src/server.js
.gitignore
package‑lock.json
package.json

Определим задачи сервера:

  • Сохранение текущего состояния истории сообщений.

  • Отправка текущего состояния при подключении нового пользователя.

  • При получении нового сообщения, разослать его всем подключенным клиентам.

Приступим к написанию серверной логики.

server.js

  1. Конфигурирование

    • Объявим необходимые зависимости

      const ws = require("ws");
      const fs = require("fs");
      const http = require("http");

    • Подготовим документ, который будет возвращать http-сервер

      const index = fs.readFileSync("public/index.html", "utf8");

    • Создадим http-сервер

      1. Задаем адрес хоста и порт, который будет прослушивать сервер.

      2. Создадаем новый экземпляр сервера, в колбэке указываем, что на все запросы сервер будет возвращать подготовленный index.html документ с 200 кодом ответа.

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

      const HOST = "127.0.0.1";
      const PORT = 8000;
      
      const server = http.createServer((req, res) => {
          res.writeHead(200);
          res.end(index);
      });
      
      server.listen(PORT, HOST, () => {
          console.log(`Server running at http://${HOST}:${PORT}/`);
      });

    • Создадим Websocket-сервер, работающий поверх http-сервера

      const wss = new ws.WebSocketServer({ server });

    • Создадим массив для хранения истории сообщений

      const messages = [];

  2. Логика сетевого взаимодействия

    • Новые подключения

      /*
        Класс WebSocketServer имеет метод on, позволяющий погрузиться
        внутрь жизненного цикла клиентского соединения и производить обработку каких-либо событий.
      
        Метод принимает первым аргументом событие, а вторым колбэк на это событие
      
        Типы событий: "connection" | "message" | "error" | "close"
      
        Вторым аргументом передается колбэк, параметрами которого будут
        текущее подключение и запрос, позволяющий получить служебную информацию
      */
      
      wss.on("connection", (websocketConnection, req) => {
          // здесь будет логика взаимодействия
      });

    • Обработка нового подключения

      При новом подключении будем выводить в логи ip-адрес подключившегося клиента и осуществлять рассылку текущей истории сообщений.

      wss.on("connection", (websocketConnection, req) => {
          const ip = req.socket.remoteAddress;
      
          console.log(`[open] Connected ${ip}`);
      
      	broadcastMessages(messages, websocketConnection);
      });

    • Обработка получения сообщения

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

      wss.on("connection", (websocketConnection, req) => {
          const ip = req.socket.remoteAddress;
          console.log(`[open] Connected ${ip}`);
      
      	broadcastMessages(messages, websocketConnection);
      
          websocketConnection.on("message", (message) => {
              console.log("[message] Received: " + message);
      
      		messages.push(message);
      
              broadcastMessage(message, websocketConnection);
          });
      });

    • Обработка отключения клиента

      При отключении клиента будем выводить в логи его ip-адрес.

      wss.on("connection", (websocketConnection, req) => {
          const ip = req.socket.remoteAddress;
          console.log(`[open] Connected ${ip}`);
      
      	broadcastMessages(messages, websocketConnection);
      
          websocketConnection.on("message", (message) => {
              console.log("[message] Received: " + message);
      
      		messages.push(message);
      
              broadcastMessage(message, websocketConnection);
          });
      
          websocketConnection.on("close", () => {
              console.log(`[close] Disconnected ${ip}`);
          });
      });

    • Вспомогательные функции

      • Функция рассылки истории сообщений

        function broadcastMessages(messages, client) {
            messages.forEach((message) => {
                if (client.readyState === ws.OPEN) {
                    client.send(message, { binary: false });
                }
            });
        }

      • Функция рассылки нового сообщения

        /*
          Доступ к списку текущих подключений осуществляется
          через свойство clients экземпляра сервера.
        
          Не забудем проверить, что клиент готов к получению и исключим клиента,
          сгенерировавшего это событие (у него оно уже есть).
        */
        
        function broadcastMessage(message, websocketConnection) {
            wss.clients.forEach((client) => {
                if (client.readyState === ws.OPEN && client !== websocketConnection) {
                    client.send(message, { binary: false });
                }
            });
        }

На этом серверная логика заканчивается.

index.html

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

В public/index.html будет все: разметка, стили, динамика. Это необходимо сделать именно в index.html, так как сервер отдает только его.

  • Определим струтуру html документа

    <!DOCTYPE html>
    <html lang="en">
        <head>
            <style>
    	         <! -- здесь будут стили -->
            </style>
        </head>
        <body>
      		<! -- таблица, которая будет динамически обновляться -->
            <table id="table"></table>
        </body>
        <script>
    	    <! -- здесь будет логика -->
        </script>
    </html>
    

  • Сконфигурируем таблицу

    const COLUMNS = ["A", "B", "C", "D", "E", "F", "G", "I", "K", "L", "M", "O"];
    const ROWS_COUNT = 30;
    
    const table = document.querySelector("#table");

  • Создадим объект для хранения значений ячеек таблицы

    const cells = {};

  • Инициируем Websocket-соединение с сервером

    const HOST = "127.0.0.1";
    const PORT = "8000";
    
    const API_URL = `ws://${HOST}:${PORT}/`;
    
    const socket = new WebSocket(API_URL);

  • Логика получения события

    socket.onmessage = function (event) {
        const data = JSON.parse(event.data);
      
        const cell = cells[data.cell];
      
        cell.value = data.value;
    };

  • Логика отправки события

    На каждой ячейке висит слушатель события нажатия клавиши (keyup). При вводе данных в ячейку, слушатель отработает и сгенерирует событие, из которого можно будет "вытащить" адрес ячейки, в которой оно произошло и введенный текст.

    Далее отравляем эти данные в сокет

    function onKeyup(event) {
        const message = {
            cell: event.target.id,
            value: event.target.value,
        };
      
        socket.send(JSON.stringify(message));
    }

  • Вспомогательные функции

    • Функция генерации таблицы

      function generateTable(table, columns) {
      	const tr = document.createElement("tr");
      
          tr.innerHTML =
      		    "<td></td>" +
      	      columns.map((column) => `<td>${column}</td>`).join("");
      
          table.appendChild(tr);
      }

    • Функция генерации строки

      function generateRow(table, rowIndex, columns) {
          const tr = document.createElement("tr");
      
          tr.innerHTML =
              `<td>${rowIndex}</td>` +
              columns
                  .map(
                      (column) =>
                          `<td><input id="${column}${rowIndex}" type="text"></td>`
                  )
                  .join("");
      
          table.appendChild(tr);
      
          columns.forEach((column) => {
              const cellId = `${column}${rowIndex}`;
      
              const input = document.getElementById(cellId);
      
              input.addEventListener("keyup", onKeyup);
      
              cells[cellId] = input;
          });
      }

    • Функция заполнения таблицы

      function fillTable(table) {
          for (let i = 1; i <= ROWS_COUNT; i++) {
          	generateRow(table, i, COLUMNS);
          }
      }

  • Отрисовка таблицы

    Для отрисовки таблицы вызовем подготовленные вспомогательные функции генерации и заполнения таблицы

    generateTable(table, COLUMNS);
    
    fillTable(table);

  • Стили

    *,
    html {
        margin: 0;
        padding: 0;
        border: 0;
        width: 100%;
        height: 100%;
    }
    
    body {
        width: 100%;
        height: 100%;
    
        position: relative;
    }
    
    input {
        margin: 2px 0;
        padding: 4px 9px;
    
        box-sizing: border-box;
    
        border: 1px solid #ccc;
    
        outline: none;
    }
    
    input:focus {
        border: 1px solid #0096ff;
    }
    
    table,
    table td {
        border: 1px solid #cccccc;
    }
    
    td {
        height: 20px;
        width: 80px;
    
        text-align: center;
        vertical-align: middle;
    }

Если вы выполнили все шаги правильно, то при запуске сервера вы увидите сообщение об успешном старте

websocket-example git:(main) ✗ node src/server.js
Server running at <http://127.0.0.1:8000/>

Теперь, если зайти на http://127.0.0.1:8000/ откроется редактируемая таблица.

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

Посмотрев консоль запущенного сервера, можно увидеть логи о происходящих событиях. Также все отправляемые/получаемые сообщения можно посмотреть на вкладке Network -> WS в девтулзах браузера

server.js
const ws = require("ws");
const fs = require("fs");
const http = require("http");

const index = fs.readFileSync("public/index.html", "utf8");


const HOST = "127.0.0.1";
const PORT = 8000;

const server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end(index);
});

server.listen(PORT, HOST, () => {
    console.log(`Server running at http://${HOST}:${PORT}/`);
});

const wss = new ws.WebSocketServer({ server });

const messages = [];

wss.on("connection", (websocketConnection, req) => {
    const ip = req.socket.remoteAddress;
    console.log(`[open] Connected ${ip}`);

    broadcastMessages(messages, websocketConnection);

    websocketConnection.on("message", (message) => {
        console.log("[message] Received: " + message);

        messages.push(message);

        broadcastMessage(message, websocketConnection);
    });

    websocketConnection.on("close", () => {
        console.log(`[close] Disconnected ${ip}`);
    });
});


function broadcastMessages(messages, client) {
    messages.forEach((message) => {
        if (client.readyState === ws.OPEN) {
            client.send(message, { binary: false });
        }
    });
}

function broadcastMessage(message, websocketConnection) {
    wss.clients.forEach((client) => {
        if (client.readyState === ws.OPEN && client !== websocketConnection) {
            client.send(message, { binary: false });
        }
    });
}

index.html
<!DOCTYPE html>
<html lang="en">
    <head>
        <style>
            *,
            html {
                margin: 0;
                padding: 0;
                border: 0;

                width: 100%;
                height: 100%;
            }

            body {
                width: 100%;
                height: 100%;

                position: relative;
            }

            input {
                margin: 2px 0;
                padding: 4px 9px;

                box-sizing: border-box;

                border: 1px solid #ccc;

                outline: none;
            }

            input:focus {
                border: 1px solid #0096ff;
            }

            table,
            table td {
                border: 1px solid #cccccc;
            }

            td {
                height: 20px;
                width: 80px;

                text-align: center;
                vertical-align: middle;
            }
        </style>
    </head>
    <body>
        <table id="table"></table>
    </body>
    <script>
        const COLUMNS = ["A", "B", "C", "D", "E", "F", "G", "I", "K", "L", "M", "O"];
        const ROWS_COUNT = 30;

        const table = document.querySelector("#table");

        const cells = {};

        const HOST = "127.0.0.1";
        const PORT = "8000";

        const API_URL = `ws://${HOST}:${PORT}/`;

        const socket = new WebSocket(API_URL);

        socket.onmessage = function (event) {
            const data = JSON.parse(event.data);

            const cell = cells[data.cell];
            cell.value = data.value;
        };

        function onKeyup(event) {
            const message = {
                cell: event.target.id,
                value: event.target.value,
            };

            socket.send(JSON.stringify(message));
        }

        function generateTable(table, columns) {
            const tr = document.createElement("tr");

            tr.innerHTML =
                "<td></td>" +
                columns.map((column) => `<td>${column}</td>`).join("");

            table.appendChild(tr);
        }

        function generateRow(table, rowIndex, columns) {
            const tr = document.createElement("tr");

            tr.innerHTML =
                `<td>${rowIndex}</td>` +
                columns
                    .map(
                        (column) =>
                            `<td><input id="${column}${rowIndex}" type="text"></td>`
                    )
                    .join("");

            table.appendChild(tr);

            columns.forEach((column) => {
                const cellId = `${column}${rowIndex}`;

                const input = document.getElementById(cellId);

                input.addEventListener("keyup", onKeyup);

                cells[cellId] = input;
            });
        }

        function fillTable(table) {
            for (let i = 1; i <= ROWS_COUNT; i++) {
                generateRow(table, i, COLUMNS);
            }
        }

        generateTable(table, COLUMNS);

        fillTable(table);
    </script>
</html>

Код проекта целиком можно посмотреть на Github

На этом все. Надеюсь материал был кому-то полезен. Если обнаружите ошибку или неточность, напишите об этом в комментариях :)

Источники

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


  1. fransua
    00.00.0000 00:00
    +2

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


    1. QtRoS
      00.00.0000 00:00
      +3

      Вам и @Suvitruf: можно посмотреть в сторону Operational Transformation и CRDT


    1. mdlufy Автор
      00.00.0000 00:00
      +3

      Хорошее замечание. Вопрос консистентности данных это отдельная тема и не для такой статьи. (как заметил @QtRoS можно посмотреть в сторону Operational transformation)

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

      Из простых решений в голову приходит "блокировка" ячейки для остальных пользователей, пока у текущего она в фокусе. Интересно посмотреть, как это реализовано в оригинальном Google Sheets

      @Suvitruf


      1. fransua
        00.00.0000 00:00
        +2

        Эта статья заинтересовала тем, что сделано все с нуля и на коленке, должно быть понятно новичкам. И я как раз ожидал освещения проблем данного подхода, почему реальный проект по созданию Google Sheets не надо так начинать.
        А в других статьях берут что-то готовое вроде yjs или hypercore и концентрируются на перформансе: кастомных CRDT, ленивой отрисовке в канвас, быстрого трекингу зависимостей. Или на UX/UI, где тоже можно найти интересные проблемы.


        1. mdlufy Автор
          00.00.0000 00:00

          Собственно на новичков и рассчитана статья) попробовать создать что-то интересное с нуля. Плюсы и минусы подхода освещать особо нет смысла, так как весь проект "образовательный". Какие проблемы в действительности встречаются на практике - вот эта была бы интересная тема для статьи, согласен.

          Честно говоря, не в курсе какие используются коробочные имплементации CRDT, у меня в этой области экспертиза небольшая


  1. Suvitruf
    00.00.0000 00:00
    +2

    Про самое главное то и не написали — про одновременную работу несколькими пользователям и поддержку консистентности при одновременном редактировании одних и тех же данных =/


  1. BigDflz
    00.00.0000 00:00
    -1

    @fransua @Suvitruf
    данной проблемы нет, это ж websocket! одновременно редактировать одну ячейку не возможно т.к. невозможно одновременно зайти в одну ячейку, хотя бы потому что протокол последовательный и данные придут от одного клиента раньше. как ни крути. ну а раз пришли данные на занятость ячейки - сервер остальным просто разошлёт команды и данная ячейка у остальных должна "заблокироваться" для изменения..


  1. snedr
    00.00.0000 00:00

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

    Лично для меня Google Sheets интересен тем что видно кто и что отредактировал, есть иcтория изменений на которую можно откатиться. Аналогичную финкциональность можно разработать на основе sharedb или с аналогами.

    Вам успехов с освоением новых технологий ))


    1. mdlufy Автор
      00.00.0000 00:00

      Посмотрю что за зверь такой sharedb, спасибо)