Создание «Google Sheets» через Websockets на Node.js

от автора

Введение

Всем привет! Меня зовут Герман Панов и в этой статье мы разработаем табличный редактор — аналог 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

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

Источники


ссылка на оригинал статьи https://habr.com/ru/post/718780/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *