Server-Sent Events: пример использования

от автора

Доброго времени суток, друзья!

В этом туториале мы рассмотрим Server Sent Events: встроенный класс EventSource, который позволяет поддерживать соединение с сервером и получать от него события.

О том, что такое SSE и для чего он используется можно почитать здесь.

Что конкретно мы будем делать?

Мы напишем простой сервер, который будет по запросу клиента отправлять ему данные 10 случайных пользователей, а клиент с помощью этих данных будет формировать карточки пользователей.

Сервер будет реализован на Node.js, клиент — на JavaScript. Для стилизации будет использоваться Bootstrap, в качестве API — Random User Generator.

Код проекта находится здесь.

Поиграть с кодом можно здесь.

Если вам это интересно, прошу следовать за мной.

Подготовка

Создаем директорию sse-tut:

mkdir sse-tut 

Заходим в нее и инициализируем проект:

cd sse-tut  yarn init -y // или npm init -y 

Устанавливаем axios:

yarn add axios // или npm i axios 

axios будет использоваться для получения данных пользователей.

Редактируем package.json:

"main": "server.js", "scripts": {     "start": "node server.js" }, 

Структура проекта:

sse-tut     --node_modules     --client.js     --index.html     --package.json     --server.js     --yarn.lock 

Содержание index.html:

<head>     <!-- Bootstrap CSS -->     <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">     <style>         .card {             margin: 0 auto;             max-width: 512px;         }         img {             margin: 1rem;             max-width: 320px;         }         p {             margin: 1rem;         }     </style> </head>  <body>     <main class="container text-center">         <h1>Server-Sent Events Tutorial</h1>         <button class="btn btn-primary" data-type="start-btn">Start</button>         <button class="btn btn-danger" data-type="stop-btn" disabled>Stop</button>         <p data-type="event-log"></p>     </main>      <script src="client.js"></script> </body> 

Сервер

Приступаем к реализации сервера.

Открываем server.js.

Подключаем http и axios, определяем порт:

const http = require('http') const axios = require('axios') const PORT = process.env.PORT || 3000 

Создаем функцию получения данных пользователя:

const getUserData = async () => {     const response = await axios.get('https://randomuser.me/api')     // проверяем полученные данные     console.log(response)     return response.data.results[0] } 

Создаем счетчик количества отправленных пользователей:

let i = 1 

Пишем функцию отправки данных клиенту:

const sendUserData = (req, res) => {     // статус ответа - 200 ок     // соединение должно оставаться открытым     // тип содержимого - поток событий     // не кэшировать     res.writeHead(200, {         Connection: 'keep-alive',         'Content-Type': 'text/event-stream',         'Cache-Control': 'no-cache'     })      // данные будут отправляться каждые 2 секунды     const timer = setInterval(async () => {         // если отправлено 10 пользователей         if (i > 10) {             // останавливаем таймер             clearInterval(timer)             // сообщаем о том, что было отправлено 10 пользователей             console.log('10 users has been sent.')             // отправляем клиенту идентификатор со значением -1             // для того, чтобы клиент закрыл соединение             res.write('id: -1\ndata:\n\n')             // закрываем соединение             res.end()             return         }          // получаем данные         const data = await getUserData()          // записываем данные в ответ         // event - название события         // id - идентификатор события; используется при повторном подключении         // retry - время повторного подключения         // data - данные         res.write(`event: randomUser\nid: ${i}\nretry: 5000\ndata: ${JSON.stringify(data)}\n\n`)          // сообщаем о том, что данные отправлены         console.log('User data has been sent.')          // увеличиваем значение счетчика         i++     }, 2000)      // обрабатываем закрытие соединения клиентом     req.on('close', () => {         clearInterval(timer)         res.end()         console.log('Client closed the connection.')       }) } 

Создаем и запускаем сервер:

http.createServer((req, res) => {     // обязательный заголовок для преодоления CORS     res.setHeader('Access-Control-Allow-Origin', '*')      // если адрес запроса - getUser     if (req.url === '/getUsers') {         // отправляем данные         sendUserData(req, res)     } else {         // иначе, сообщаем о том, что запрашиваемая страница не найдена,         // и закрываем соединение         res.writeHead(404)         res.end()     }  }).listen(PORT, () => console.log('Server ready.')) 

Полный код сервера:

const http = require('http') const axios = require('axios') const PORT = process.env.PORT || 3000  const getUserData = async () => {     const response = await axios.get('https://randomuser.me/api')     return response.data.results[0] }  let i = 1  const sendUserData = (req, res) => {     res.writeHead(200, {     Connection: 'keep-alive',     'Content-Type': 'text/event-stream',     'Cache-Control': 'no-cache'     })      const timer = setInterval(async () => {     if (i > 10) {         clearInterval(timer)         console.log('10 users has been sent.')         res.write('id: -1\ndata:\n\n')         res.end()         return     }      const data = await getUserData()      res.write(`event: randomUser\nid: ${i}\nretry: 5000\ndata: ${JSON.stringify(data)}\n\n`)      console.log('User data has been sent.')      i++     }, 2000)      req.on('close', () => {     clearInterval(timer)     res.end()     console.log('Client closed the connection.')     }) }  http.createServer((req, res) => {     res.setHeader('Access-Control-Allow-Origin', '*')      if (req.url === '/getUsers') {     sendUserData(req, res)     } else {     res.writeHead(404)     res.end()     }  }).listen(PORT, () => console.log('Server ready.')) 

Выполняем команду yarn start или npm start. В терминале появляется сообщение «Server ready.». Открываем http://localhost:3000:

С сервером закончили, переходим к клиентской части приложения.

Клиент

Открываем файл client.js.

Создаем функцию генерации шаблона пользовательской карточки:

const getTemplate = user => ` <div class="card">     <div class="row">         <div class="col-md-4">             <img src="${user.img}" class="card-img" alt="user-photo">         </div>         <div class="col-md-8">             <div class="card-body">                 <h5 class="card-title">${user.id !== null ? `Id: ${user.id}` : `User hasn't id`}</h5>                 <p class="card-text">Name: ${user.name}</p>                 <p class="card-text">Username: ${user.username}</p>                 <p class="card-text">Email: ${user.email}</p>                 <p class="card-text">Age: ${user.age}</p>             </div>         </div>     </div> </div> ` 

Шаблон генерируется с использованием следующих данных: идентификатор пользователя (если имеется), имя, логин, адрес электронной почты и возраст пользователя.

Начинаем реализовывать основной функционал:

class App {     constructor(selector) {         // основной элемент - контейнер         this.$ = document.querySelector(selector)         // запускаем частный метод         this.#init()     }      #init() {         this.startBtn = this.$.querySelector('[data-type="start-btn"]')         this.stopBtn = this.$.querySelector('[data-type="stop-btn"]')         // контейнер для сообщений о событиях         this.eventLog = this.$.querySelector('[data-type="event-log"]')         // устанавливаем контекст для обработчика         this.clickHandler = this.clickHandler.bind(this)         // делегируем обработку события         this.$.addEventListener('click', this.clickHandler)     }      clickHandler(e) {         // если кликнули по кнопке         if (e.target.tagName === 'BUTTON') {             // получаем тип кнопки             // и либо начинаем получать события от сервера,             // либо закрываем соединение             const {                 type             } = e.target.dataset              if (type === 'start-btn') {                 this.startEvents()             } else if (type === 'stop-btn') {                 this.stopEvents()             }              // управление состоянием кнопок             this.changeDisabled()         }     }      changeDisabled() {         if (this.stopBtn.disabled) {             this.stopBtn.disabled = false             this.startBtn.disabled = true         } else {             this.stopBtn.disabled = true             this.startBtn.disabled = false         }     } //... 

Сначала реализуем закрытие соединения:

stopEvents() {     this.eventSource.close()     // сообщаем о том, что соединение закрыто пользователем     this.eventLog.textContent = 'Event stream closed by client.' } 

Переходим к открытию потока событий:

startEvents() {     // создаем экземпляр для получения данных по запросу на указанный адрес     this.eventSource = new EventSource('http://localhost:3000/getUsers')      // сообщаем о том, что соединение открыто     this.eventLog.textContent = 'Accepting data from the server.'      // обрабатываем получение от сервера идентификатора со значением -1     this.eventSource.addEventListener('message', e => {         if (e.lastEventId === '-1') {             // закрываем соединение             this.eventSource.close()             // сообщаем об этом             this.eventLog.textContent = 'End of stream from the server.'              this.changeDisabled()         }         // мы можем получить такой идентификатор лишь раз     }, {once: true}) } 

Обрабатываем кастомное событие «randomUser»:

this.eventSource.addEventListener('randomUser', e => {     // парсим полученные данные     const userData = JSON.parse(e.data)     // проверяем их     console.log(userData)      // извлекаем данные с помощью деструктуризации     const {         id,         name,         login,         email,         dob,         picture     } = userData      // продолжаем формировать данные, необходимые для генерации пользовательской карточки     const i = id.value     const fullName = `${name.first} ${name.last}`     const username = login.username     const age = dob.age     const img = picture.large      const user = {         id: i,         name: fullName,         username,         email,         age,         img     }      // генерируем шаблон     const template = getTemplate(user)      // рендерим карточку на странице     this.$.insertAdjacentHTML('beforeend', template) }) 

Не забываем реализовать обработку ошибок:

this.eventSource.addEventListener('error', e => {     this.eventSource.close()      this.eventLog.textContent = `Got an error: ${e}`      this.changeDisabled() }, {once: true}) 

Наконец, инициализируем приложение:

const app = new App('main') 

Полный код клиента:

const getTemplate = user => ` <div class="card">     <div class="row">         <div class="col-md-4">             <img src="${user.img}" class="card-img" alt="user-photo">         </div>         <div class="col-md-8">             <div class="card-body">                 <h5 class="card-title">${user.id !== null ? `Id: ${user.id}` : `User hasn't id`}</h5>                 <p class="card-text">Name: ${user.name}</p>                 <p class="card-text">Username: ${user.username}</p>                 <p class="card-text">Email: ${user.email}</p>                 <p class="card-text">Age: ${user.age}</p>             </div>         </div>     </div> </div> `  class App {     constructor(selector) {         this.$ = document.querySelector(selector)         this.#init()     }      #init() {         this.startBtn = this.$.querySelector('[data-type="start-btn"]')         this.stopBtn = this.$.querySelector('[data-type="stop-btn"]')         this.eventLog = this.$.querySelector('[data-type="event-log"]')         this.clickHandler = this.clickHandler.bind(this)         this.$.addEventListener('click', this.clickHandler)     }      clickHandler(e) {         if (e.target.tagName === 'BUTTON') {             const {                 type             } = e.target.dataset              if (type === 'start-btn') {                 this.startEvents()             } else if (type === 'stop-btn') {                 this.stopEvents()             }              this.changeDisabled()         }     }      changeDisabled() {         if (this.stopBtn.disabled) {             this.stopBtn.disabled = false             this.startBtn.disabled = true         } else {             this.stopBtn.disabled = true             this.startBtn.disabled = false         }     }      startEvents() {         this.eventSource = new EventSource('http://localhost:3000/getUsers')          this.eventLog.textContent = 'Accepting data from the server.'          this.eventSource.addEventListener('message', e => {             if (e.lastEventId === '-1') {                 this.eventSource.close()                 this.eventLog.textContent = 'End of stream from the server.'                  this.changeDisabled()             }         }, {once: true})          this.eventSource.addEventListener('randomUser', e => {             const userData = JSON.parse(e.data)             console.log(userData)              const {                 id,                 name,                 login,                 email,                 dob,                 picture             } = userData              const i = id.value             const fullName = `${name.first} ${name.last}`             const username = login.username             const age = dob.age             const img = picture.large              const user = {                 id: i,                 name: fullName,                 username,                 email,                 age,                 img             }              const template = getTemplate(user)              this.$.insertAdjacentHTML('beforeend', template)         })          this.eventSource.addEventListener('error', e => {             this.eventSource.close()              this.eventLog.textContent = `Got an error: ${e}`              this.changeDisabled()         }, {once: true})     }      stopEvents() {         this.eventSource.close()         this.eventLog.textContent = 'Event stream closed by client.'     } }  const app = new App('main') 

На всякий случай перезапускаем сервер. Открываем http://localhost:3000. Нажимаем на кнопку «Start»:

Начинаем получать данные от сервера и рендерить карточки пользователей.

Если нажать на кнопку «Stop», отправка данных будет приостановлена:

Снова нажимаем «Start», отправка данных продолжается.

При достижении лимита (10 пользователей) сервер отправляет идентификатор со значением -1 и закрывает соединение. Клиент, в свою очередь, также закрывает поток событий:

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

Поддержка данной технологии на сегодняшний день составляет 95%:

Надеюсь, статья вам понравилась. Благодарю за внимание.

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


Комментарии

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

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