Для клиентской части, как и в прошлом уроке, создадим файл index.html.
<html> <head> <title>Socket App</title> </head> <body> </body> </html> |
Пока этот файл пустой, но сейчас мы начнём его наполнять.
В официальной документации для клиентской части Socket.io показано несколько способов подключения библиотеки. Мы выберем самый простой способ — через CDN. Для этого в нашу html-страницу добавим строчку:
<script src="https://cdn.socket.io/3.1.1/socket.io.min.js" integrity="sha384-gDaozqUvc4HTgo8iZjwth73C6dDDeOJsAgpxBcMpZYztUfjHXpzrpdrHRdVp8ySO" crossorigin="anonymous"></script> |
Как сказано в документации, при таком подключении io (собственно клиентская библиотека) будет доступна как глобальная переменная.
Добавим в index.html следующий код:
<html> <script src="https://cdn.socket.io/3.1.1/socket.io.min.js" integrity="sha384-gDaozqUvc4HTgo8iZjwth73C6dDDeOJsAgpxBcMpZYztUfjHXpzrpdrHRdVp8ySO" crossorigin="anonymous"></script> <head> <title>Socket App</title> </head> <body> <input type="text" id="input" autofocus> <input type="submit" id="send" value="Send"> <div id="messages"></div> </body> <script type="text/javascript"> const socket = io('localhost:3000'); const addMessage = (msg) => { const msgSpan = document.createElement('span').innerHTML = msg; document.getElementById('messages').append(msgSpan); document.getElementById('messages').append(document.createElement('br')); }; socket.on('connect', function() { console.log('Successful connected to server'); }); socket.on('SERVER_MSG', function (data) { addMessage(data.msg); }); document.getElementById('send').onclick = function() { socket.emit('CLIENT_MSG', { msg: document.getElementById('input').value }); document.getElementById('input').value = ''; }; </script> </html> |
Здесь всё происходил по аналогии с серверной частью. При инициализации указывается url сервера, который ожидает сокетное соединение. В нашем случае — ‘localhost:3000’.
Затем создаётся обработчик события ‘connect’. Это событие генерируется при установлении соединения с сервером.
Далее формируем обработчики события SERVER_MSG'. Данные, которые приходят в этом событии, выведем в специально созданный div-блок ‘messages’ посредством функции addMessage.
|
Мы создали простую html-форму, чтобы отправлять данные серверу. Для этого у нас есть поле ввода — input-элемент типа text — и кнопка Send. Для кнопки мы сформировали обработчик клика, в котором будем отправлять сообщения на сервер и очищать поле ввода.
Теперь запустим нашу систему и посмотрим на результат.
Сначала запускаем сервер, затем в браузере открываем эту страницу.
Сервер возвращает html-страницу, и на ней мы видим поле ввода.
Рис. 3. Html-форма для отправки сообщений
В терминале запущенного сервера появилось успешно установленное соединение:
New connection |
В консоли браузера мы также видим успешное соединение и сообщение от сервера:
Рис. 4. Сообщение об установленном соединении с сервером в консоли браузера
Отправим сообщение Hello World!, используя созданную форму.
Рис. 5. Состояние страницы до нажатия кнопки Send
Рис. 6. Состояние страницы после нажатия кнопки Send
Как мы видим на рисунках, клиент успешно отправил сообщение, сервер его принял, инвертировал и отправил обратно. Функция addMessage, вызываемая в обработчике события, добавила сообщение на страницу.
Теперь отправим ещё одно сообщение.
Рис. 7. Состояние страницы до отправки второго сообщения
Рис. 8. Состояние страницы после отправки второго сообщения
Система сообщений работает. Теперь научимся оповещать всех клиентов о подключении нового участника. Когда новый человек войдёт в чат, это должны увидеть сразу все пользователи.
|
Рассылка сообщений
Поменяв всего одну строчку в серверной части, получится реализовать этот инструментарий. Для этого в отправку сообщения от сервера достаточно добавить вызов метода broadcast:
socket.broadcast.emit('NEW_CONN_EVENT', { msg: 'The new client connected' }); |
В клиенте добавим обработку этого сообщения по аналогии с сообщением ‘CLIENT_MSG’:
socket.on('NEW_CONN_EVENT', function (data) { addMessage(data.msg); }); |
Теперь перезапустим сервер и откроем сначала одну страницу в браузере, а потом вторую.
На первой странице появится сообщение о подключении нового клиента:
Рис. 9. Сообщение о подключении нового клиента
Здесь мы рассмотрели основной инструментарий, который даёт базовое понимание принципа работы всех чатов, мессенджеров и других приложений реального времени. Однако клиент всё ещё не получает никаких данных от других клиентов, кроме сообщения об их подключении. Реализация такого инструментария станет частью практического задания.
Далее перейдём к ещё одной важной концепции Node.js-приложений — к использованию отдельных потоков воркеров для ресурсоёмких задач.
Однопоточность
Node.js — это однопоточная среда. Как мы знаем, некоторые операции выгружаются в операционную систему, где выполняются параллельно. Это асинхронные операции ввода-вывода. Основной же JavaScript-код выполняется синхронно в один поток. И этим потоком нагружается только одно ядро процессора.
В тех случаях, когда основную часть приложения составляют асинхронные операции ввода-вывода, всё работает прекрасно.
Однако, если в коде надо выполнять какие-то тяжёлые вычисления, которые занимают много времени — это может заблокировать основной поток. То есть программа не выполнит никаких других вычислений, так как код синхронный. Например, такое вычисление займёт у веб-сервера 5 секунд. В течение этих 5 секунд сервер не примет и не обработает ни один запрос!
|
Дело в том, что изначально ни язык программирования JavaScript, ни среда Node.js не были предназначены для сложных вычислений, требующих больших ресурсов процессора. В браузере выполнение таких сложных задач означало бы «тормоза» в пользовательском интерфейсе. В Node.js мы наблюдаем ограничение пропускной способности веб-сервера, ограничение запуска новых асинхронных задач ввода-вывода и задержку выполнения коллбэков, связанных с завершением выполнения асинхронных задач.
Эту задачу призван решить модуль worker_threads.
Модуль worker_threads
Этот модуль позволяет создавать отдельные потоки воркеров для выполнения трудоёмких по вычислительной мощности задач. Однако он даёт использовать для вычисления незагруженные ядра процессора. По этой причине не рекомендуется создавать больше воркеров, чем количество ядер у процессора.
Такие потоки выполняются в изолированном контексте, обмениваясь информацией с главным процессом посредством сообщений. Это позволяет избежать «состояния гонок». В многопоточных системах такая проблема возникает, когда несколько потоков одновременно обращаются к одной и той же области памяти. Последнее, как правило, приводит к утечке памяти, порче данных, уязвимостям и т. д.
Потоки воркеров функционируют в том же процессе, что и основная программа.
Важно!
В модуле worker_threads есть возможность пользоваться разделяемой памятью. Для этого предусмотрены объекты типа SharedArrayBuffer. С ними надо быть осторожными и использовать только в тех случаях, когда требуется выполнить сложную обработку больших данных. В этом случае мы можем сэкономить ресурсы на передаче данных между воркерами и основным процессом, когда приходиться много раз сериализовывать или десериализовывать данные.
В качестве примера напишем приложение-генератор паролей.
Подготовим инструментарий для инициализации и создания отдельного потока воркера.
Для этого нам понадобится два файла. Файл index.js будет отвечать за инициализацию потока воркера, а worker.js — нести в себе его инструментарий.
index.js:
const { Worker } = require('worker_threads') const passwordSizeBytes = 4; function start(workerData) { return new Promise((resolve, reject) => { const worker = new Worker('./worker.js', { workerData }); worker.on('message', resolve); worker.on('error', reject); }) } start(passwordSizeBytes).then(result => console.log(result)).catch(err => console.error(err)); |
Здесь всё достаточно очевидно. Импортируя в файл класс Worker из модуля worker_threads, мы получаем возможность создать его экземпляр.
При создании воркера в конструктор передаётся путь к файлу, содержащий код воркера, а также объект с данными, которые для него предназначены.
С концепцией событий в этом курсе мы уже встречались, применима она и здесь.
Событие message предназначается для получения сообщения от воркера. В самом простом случае такое сообщение — это результат выполнения задачи.
Событие error предназначается для обработки ошибок, которые возникли в процессе выполнения задачи.
worker.js:
const { workerData, parentPort } = require('worker_threads'); parentPort.postMessage({ result: `You want to generate password ${workerData} bytes size` }); |
Сейчас в этом файле есть только получение данных из главного процесса (константа workerData) и объект, отвечающий за передачу данных обратно главному процессу — объект parentPort. Используя метод postMessage объекта parentPort, мы можем передать данные выполнения программы главному процессу.
Если сейчас запустить программу, где точка входа — файл index.js, то в терминале появится следующий вывод:
{ result: 'You want to generate password 4 bytes size' } |
Модуль crypto
Для создания пароля мы будем использовать ещё один стандартный модуль Node.js, который называется crypto. Это модуль шифрования, он предоставляет различные криптографические функции, например, функции хеширования, шифрования, дешифрования, подписи и прочие.
Нас интересует метод crypto.randomBytes(size[, callback]). Этот метод генерирует криптографически сильный псевдослучайный набор данных определённого размера. Переменная size — это размер данных в байтах, а callback — функция обратного вызова, которая вызывается после окончания работы метода.
Однако, если в метод crypto.randomBytes не передать параметр callback, он будет работать синхронно. Именно синхронный режим работы мы используем в примере, так как асинхронные функции отправлять в специально созданный поток воркера не имеет особого смысла. Асинхронные операции ввода-вывода будут гораздо эффективнее работать сами по себе, без «обёртки» в виде отдельного потока воркера.
Для генерации пароля достаточно добавить лишь несколько строк:
const { workerData, parentPort } = require('worker_threads'); const crypto = require('crypto'); const password = crypto.randomBytes(workerData).toString('hex'); parentPort.postMessage({ result: `Password was generated: ${password}` }); |
Теперь, если запустить приложение, то в терминале мы увидим следующее:
{ result: 'Password was generated: 72aac223' } |
Заключение
В этом курсе мы рассмотрели базовые возможности среды Node.js. Конечно, это далеко не все её возможности.
Node.js постоянно развивается и улучшается. Программисты всего мира постоянно придумывают и публикуют новые модули в npm, разрабатывают новые фреймворки и реализуют новые концепции использования.
Например, есть фреймворк Electron, который, используя Node.js в том числе, позволяет создавать графические приложения для операционных систем. На базе Electron появились следующие:
- Visual Studio Code — популярная среди программистов IDE.
- Клиентское приложение Slack — этот мессенджер активно используется в корпоративной IT-среде и не только.
- Клиентское приложение Skype.
- Мессенджер Discord.
- Прочие.
Или, например, фреймворк NestJS, который также обретает всё большую популярность в последнее время. Этот фреймворк создан, чтобы помогать программистам, использующим Node.js в качестве среды для разработки бэкенда, делать это максимально просто, используя лучшие подходы в построении архитектуры бэкенд-приложений прямо из коробки.
Однако все эти фреймворки «под капотом» используют всё те же базовые возможности среды Node.js — событийно-ориентированную архитектуру, цикл событий, потоки и т. д. Таким образом, владея этими основами, нам не составит труда разобраться и в новейших фреймворках.
Глоссарий