Введение
Всем привет! Меня зовут Герман Панов и в этой статье мы разработаем табличный редактор - аналог 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-файл с серверной логикой, описывающей взаимодействие по сети
Создадим скелет проекта:
Создадим отдельный каталог под проект.
Инициализируем новый git-репозиторий в созданном каталоге.
Инициализируем новый npm-пакет, который будет содержать код проекта.
Установим необходимые для работы пакеты. Из сторонних зависимостей нам потребуется только пакет ws.
Создадим файл .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
-
Конфигурирование
-
Объявим необходимые зависимости
const ws = require("ws"); const fs = require("fs"); const http = require("http");
-
Подготовим документ, который будет возвращать http-сервер
const index = fs.readFileSync("public/index.html", "utf8");
-
Создадим http-сервер
Задаем адрес хоста и порт, который будет прослушивать сервер.
Создадаем новый экземпляр сервера, в колбэке указываем, что на все запросы сервер будет возвращать подготовленный index.html документ с 200 кодом ответа.
Запускаем сервер, чтобы он начал прослушивать заданный хост и порт. Выведем в логи информацию об успешном начале работы сервера.
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 = [];
-
-
Логика сетевого взаимодействия
-
Новые подключения
/* Класс 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)
Suvitruf
00.00.0000 00:00+2Про самое главное то и не написали — про одновременную работу несколькими пользователям и поддержку консистентности при одновременном редактировании одних и тех же данных =/
BigDflz
00.00.0000 00:00-1@fransua @Suvitruf
данной проблемы нет, это ж websocket! одновременно редактировать одну ячейку не возможно т.к. невозможно одновременно зайти в одну ячейку, хотя бы потому что протокол последовательный и данные придут от одного клиента раньше. как ни крути. ну а раз пришли данные на занятость ячейки - сервер остальным просто разошлёт команды и данная ячейка у остальных должна "заблокироваться" для изменения..
snedr
00.00.0000 00:00Если планируете дальше смотреть в сторону совместного редактирования то проект sharedb возможно будет вам интересен. Проект старый и уже активно не разрабатывается, хотя периодически они выпускают багфиксы.
Лично для меня Google Sheets интересен тем что видно кто и что отредактировал, есть иcтория изменений на которую можно откатиться. Аналогичную финкциональность можно разработать на основе sharedb или с аналогами.
Вам успехов с освоением новых технологий ))
fransua
Если два пользователя будут редактировать одну ячейку, то может потеряться конситсентность.
QtRoS
Вам и @Suvitruf: можно посмотреть в сторону Operational Transformation и CRDT
mdlufy Автор
Хорошее замечание. Вопрос консистентности данных это отдельная тема и не для такой статьи. (как заметил @QtRoS можно посмотреть в сторону Operational transformation)
В данном варианте, клиенты получают новые сообщения по мере их поступления в последовательном порядке. Одновременно редактировать одну и ту же ячейку по умолчанию может неограниченное число пользователей. Обработка таких случаев, когда одновременно используются одни и те же данные, когда у клиентов разная скорость связи и т.д. опущена, поскольку требует уже нетривиальных решений.
Из простых решений в голову приходит "блокировка" ячейки для остальных пользователей, пока у текущего она в фокусе. Интересно посмотреть, как это реализовано в оригинальном Google Sheets
@Suvitruf
fransua
Эта статья заинтересовала тем, что сделано все с нуля и на коленке, должно быть понятно новичкам. И я как раз ожидал освещения проблем данного подхода, почему реальный проект по созданию Google Sheets не надо так начинать.
А в других статьях берут что-то готовое вроде
yjs
илиhypercore
и концентрируются на перформансе: кастомных CRDT, ленивой отрисовке в канвас, быстрого трекингу зависимостей. Или на UX/UI, где тоже можно найти интересные проблемы.mdlufy Автор
Собственно на новичков и рассчитана статья) попробовать создать что-то интересное с нуля. Плюсы и минусы подхода освещать особо нет смысла, так как весь проект "образовательный". Какие проблемы в действительности встречаются на практике - вот эта была бы интересная тема для статьи, согласен.
Честно говоря, не в курсе какие используются коробочные имплементации CRDT, у меня в этой области экспертиза небольшая