Веб-разработка часто требует реализации механизмов обновления контента на странице в реальном времени. Существуют различные сценарии, где это необходимо, например, отображение прогресса выполнения тяжелых задач на бекенде, обновление каких-либо часто изменяющихся данных, будь то курсы валют или мониторинг какой-то активности, чаты, различные уведомления. Эти сценарии объединяет одна общая особенность: источник события необходимости обновления данных находится не на клиентской стороне, поэтому мы хотим получать события с бекенда. В данной статье мы рассмотрим четыре популярных подхода к реализации этой функциональности: WebSocket, Server-Sent Events (SSE), Long Polling и Short Polling. Мы проанализируем каждый метод, выявим их плюсы, минусы и сложность реализации.
Обзор технологий и подходов к реализации асинхронного взаимодействия
WebSocket
WebSocket был стандартизирован в 2011 году как способ обеспечения полнодуплексного двустороннего взаимодействия между клиентом и сервером через одно TCP-соединение. Это позволяет устанавливать постоянное соединение между браузером и сервером, обеспечивая мгновенную передачу данных в обе стороны без необходимости постоянного обновления страницы. После установки соединения через стандартный HTTP/HTTPS запрос, браузер и сервер могут обмениваться данными напрямую, без необходимости посылать новые HTTP запросы для каждого сообщения. WebSocket использует специальный заголовок Upgrade в HTTP запросе для переключения на бинарный протокол передачи данных.
Преимущества: Протокол WebSocket обеспечивает полноценную двустороннюю, асинхронную связь между клиентом и сервером в реальном времени. Наиболее гибкий из всех возможных вариантов, с минимумом ограничений, позволяющий реализовать любые сценарии взаимодействия.
Недостатки: Самый ресурсоемкий в реализации подход, в большинстве случаев требует реализации отдельного серверного приложения.
Server-Sent Events (SSE)
Server-Sent Events (SSE) представляют собой технологию, позволяющую серверу отправлять поток событий клиенту по одностороннему соединению. Для поддержания соединения открытым, сервер может отправлять пустые события с определенной периодичностью, чтобы предотвратить закрытие соединения браузером из-за таймаута. Это делает их идеальным выбором для ситуаций, когда сервер должен регулярно обновлять информацию на веб-странице, например, для отображения изменений в ленте новостей или прогресса загрузки. Стандарт SSE был представлен в спецификации HTML5 и хорошо поддерживается современными браузерами.
Преимущества: Простая реализация на стороне сервера и клиента, автоматическое восстановление соединения при обрыве обеспечивает надежную передачу данных. Широкая поддержка как браузерами, так и веб-фреймворками.
Недостатки: Только односторонняя передача данных от сервера к клиенту, может не поддерживаться старыми браузерами.
Long Poling
Long Polling был одним из первых методов для обновления контента на странице в реальном времени до появления более современных технологий, таких как WebSocket и Server-Sent Events. Его использование стало популярным в середине 2000-х годов как способ обхода ограничений традиционного веб-протокола HTTP, который не поддерживает двустороннюю связь. Принцип работы следующий: клиент отправляет на сервер HTTP запрос, сервер выполняет запрос и может отправлять несколько порций данных перед отправкой финального результата и закрытием соединения.
Преимущества: Простота в понимании и реализации, асинхронная передача данных со стороны сервера.
Недостатки: Задержки в передаче данных из-за ожиданий и таймаутов, может приводить к высокой нагрузке на сервер из-за большого количества открытых соединений, может потребоваться настройка сервера. Подход неэффективен при постоянном потоке данных.
Short Polling
Short Polling - не является общепринятым термином для данного подхода, как впрочем и сам подход не является общепринятым и нормальным для такого рода задач, однако я ни раз в своей практике сталкивался с его использованием в различных проектах разной степени сложности. Суть его заключается в простом периодическом опрашивании веб-сервера посредством отправки классических HTTP запросов.
Преимущества: Самый простой в реализации подход, может быть реализован практически на любой платформе с любым языком программирования.
Недостатки: Самый неэффективный подход во всех отношениях, однако не требующий никаких дополнительных действий для реализации. Создает ненужные запросы в случае отсутствия обновлений данных. Высокая нагрузка на сервер, таймауты при передаче данных.
От теории к практике
Для демонстрации различных подходов представим абстрактную задачу:
На странице расположены прогресс-бар, текст статуса и кнопка. При нажатии на кнопку отправляется HTTP-запрос на сервер, и начинается выполнение задачи. Необходимо отслеживать выполнение задачи, обновлять прогресс-бар и текст статуса.
Создадим простую страницу index.html
, для UI можно использовать Bootstrap.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Update Example</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
.container {
margin-top: 20vh;
}
.progress-container {
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>Task execution</h1>
<div class="progress-container">
<div class="progress">
<div id="progressBar" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<p id="statusText" class="mt-2">Waiting for execution...</p>
</div>
<button id="executeBtn" type="button" class="btn btn-primary mt-3">Execute</button>
</div>
<script src="main.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js"></script>
</body>
</html>
И main.js
с заглушкой, которая увеличивает заполнение прогресс-бара случайными значениями раз в секунду:
document.getElementById('executeBtn').addEventListener('click', () => {
let progress = 0;
const progressBar = document.getElementById('progressBar');
const statusText = document.getElementById('statusText');
const interval = setInterval(() => {
progress += Math.random() * 10;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
}
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', Math.round(progress));
statusText.textContent = `Execution progress: ${progress.toFixed(2)}%`;
}, 1000);
});
Periodic Polling
Начнем с демонстрации подхода Periodic Polling, как самого простого. Для этого напишем серверное приложение на GoLang, реализующее два метода: POST /execute
и GET /status
.
package main
import (
"encoding/json"
"log"
"math/rand"
"net/http"
"time"
)
// Структура для хранения статуса выполнения задачи
type TaskStatus struct {
Executing bool `json:"executing"`
Percent int `json:"percent"`
}
var status TaskStatus
func main() {
rand.New(rand.NewSource(99))
http.HandleFunc("/execute", executeHandler)
http.HandleFunc("/status", statusHandler)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Print(err)
}
}
func executeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
// Начинаем выполнение задачи, устанавливаем статус "выполняется"
status.Executing = true
status.Percent = 0
go execute()
err := json.NewEncoder(w).Encode(map[string]interface{}{
"status": status.Executing,
"percent": status.Percent,
})
if err != nil {
log.Print(err)
}
}
func statusHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
// Отдаем текущий статус выполнения задачи
err := json.NewEncoder(w).Encode(map[string]interface{}{
"status": status.Executing,
"percent": status.Percent,
})
if err != nil {
log.Print(err)
}
}
func execute() {
for {
time.Sleep(time.Second)
if status.Executing {
// Увеличиваем процент выполнения задачи на случайное значение от 1 до 10
status.Percent += rand.Intn(10) + 1
if status.Percent >= 100 {
// Если выполнение задачи завершено, устанавливаем статус "завершено"
status.Executing = false
status.Percent = 100
}
}
}
}
Также внесем изменения в main.js
для работы с API:
var progressBar = document.getElementById('progressBar');
var statusText = document.getElementById('statusText');
function pollStatus() {
fetch('http://localhost:8080/status')
.then(response => response.json())
.then(data => {
updateProgress(data)
})
.catch(error => {
console.error('Error:', error);
})
}
document.getElementById('executeBtn').addEventListener('click', function () {
fetch('http://localhost:8080/execute', {method: 'POST'})
.then(response => response.json())
.then(data => {
updateProgress(data); // Начинаем опрос статуса после отправки запроса на выполнение задачи
})
.catch(error => {
console.error('Error:', error);
});
});
async function updateProgress(data) {
if (data.status === true && data.percent < 100) {
await new Promise(r => setTimeout(r, 1000));
pollStatus()
}
progressBar.style.width = data.percent + '%';
statusText.innerText = data.percent === 100 ? 'Execution finish' : 'Execution progress: ' + data.percent?.toFixed(2) + '%';
}
После нажатия на кнопку 'Execute' мы отправляем запрос /execute
, и задача начинает "выполняться". Далее мы с интервалом в секунду опрашиваем метод /status
до тех пор, пока задача не будет выполнена. Браузер будет отправлять запросы, пока не будет достигнуто условие остановки цикла. Здесь легко допустить ошибку, поэтому стоит уделять таким участкам логики особое внимание, иначе можно получить неконтролируемый поток запросов в API.
Как видим, браузер каждый раз отправляет новый HTTP-запрос для проверки статуса выполнения задачи, что не является эффективным подходом.
Long Poling
Самый простой пример применения Long Polling можно рассмотреть в таком сценарии: Клиент отправляет запрос к методу /messages
, который отдает новые сообщения клиенту. В момент обращения к серверу сообщений может не быть, в таком случае сервер не закрывает соединение сразу, а ждет, когда появится сообщение для отправки, после чего отправляет его и закрывает соединение. Именно этот механизм и дал название данному методу.
В нашей задаче мы применим этот подход по максимуму, будем в рамках одного HTTP-соединения отправлять множество блоков данных до тех пор, пока задача не будет выполнена. Теперь код на Go выглядит следующим образом:
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"time"
)
// Структура для хранения статуса выполнения задачи
type TaskStatus struct {
Executing bool `json:"executing"`
Percent int `json:"percent"`
}
func main() {
rand.New(rand.NewSource(99))
http.HandleFunc("/execute", executeHandler)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Print(err)
}
}
func executeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
taskStatus := TaskStatus{Executing: true}
for taskStatus.Percent < 100 {
taskStatus.Percent += rand.Intn(10) + 1
if taskStatus.Percent > 100 {
taskStatus.Executing = false
taskStatus.Percent = 100
}
sendData(w, taskStatus)
time.Sleep(time.Second)
}
}
func sendData(w http.ResponseWriter, data TaskStatus) {
w.Header().Set("Content-Type", "application/json")
jsonData, err := json.Marshal(data)
if err != nil {
fmt.Println("Error marshalling JSON:", err)
return
}
// принудительно отправляем данные
w.Write(jsonData)
w.(http.Flusher).Flush()
}
Мы немного поменяли подход: теперь у нас есть только один метод /execute
, который выполняет задачу и отправляет статусы выполнения.
Изменения в main.js
:
const progressBar = document.getElementById('progressBar');
const statusText = document.getElementById('statusText');
document.getElementById('executeBtn').addEventListener('click', async () => {
try {
const response = await fetch('http://localhost:8080/execute', { method: 'POST' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Читаем данные по мере поступления
async function readChunk() {
const { done, value } = await reader.read();
if (done) {
console.log('Stream complete');
return;
}
const data = decoder.decode(value, { stream: true });
const taskStatus = JSON.parse(data);
await updateProgress(taskStatus);
return readChunk();
}
await readChunk();
} catch (error) {
console.error('Error:', error);
}
});
async function updateProgress(data) {
if (data.status === true && data.percent < 100) {
await new Promise(r => setTimeout(r, 1000));
}
progressBar.style.width = `${data.percent}%`;
statusText.textContent = data.percent === 100 ? 'Execution finish' : `Execution progress: ${data.percent.toFixed(2)}%`;
}
Фронтенд также изменился: теперь мы отправляем только один HTTP-запрос, читаем данные по мере их поступления и обновляем прогресс-бар.
Данный подход лучше предыдущего, так как не создает лишних запросов. Однако здесь также есть потенциальное узкое место: все передаваемые данные на клиенте и сервере будут храниться в буфере и занимать место в оперативной памяти до конца времени жизни HTTP-запроса. Также важно учитывать максимальное время жизни запроса в браузере и на сервере, а также обрабатывать прерывание запросов. И всё же данный подход, хоть и рабочий, устарел, и я не рекомендую его использовать. На смену ему пришел SSE, который мы сейчас рассмотрим.
Server-Sent Events (SSE)
С применением подхода SSE наш серверный код выглядит следующим образом:
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"time"
)
// Структура для хранения статуса выполнения задачи
type TaskStatus struct {
Executing bool `json:"executing"`
Percent int `json:"percent"`
}
// Очередь эвентов
var eventsQueue chan TaskStatus
func task() {
taskStatus := TaskStatus{Executing: true}
for taskStatus.Percent < 100 {
taskStatus.Percent += rand.Intn(10) + 1
if taskStatus.Percent > 100 {
taskStatus.Executing = false
taskStatus.Percent = 100
}
eventsQueue <- taskStatus
time.Sleep(time.Second)
}
}
func main() {
rand.New(rand.NewSource(99))
eventsQueue = make(chan TaskStatus, 100)
http.HandleFunc("/execute", executeHandler)
http.HandleFunc("/events", eventsHandler)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Print(err)
}
}
func executeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
go task()
w.WriteHeader(http.StatusOK)
}
func eventsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
//Отправляем эвенты из очереди
for {
select {
case taskStatus := <-eventsQueue:
jsonData, err := json.Marshal(taskStatus)
if err != nil {
fmt.Println("Error marshalling JSON:", err)
continue
}
fmt.Fprintf(w, "data: %s\n\n", jsonData)
w.(http.Flusher).Flush()
case <-r.Context().Done():
return
}
}
}
Подход к решению задачи был изменен. Теперь в методе /execute
мы запускаем горутину, которая выполняет задачу и складывает события в очередь. Метод /events
читает очередь и отправляет события клиенту. Обратите внимание на заголовок text/event-stream
- именно он сообщает браузеру о том, что мы работаем с SSE. Работать с таким сервером очень просто благодаря встроенным в среду JavaScript инструментам.
const progressBar = document.getElementById('progressBar');
const statusText = document.getElementById('statusText');
let eventSource;
document.getElementById('executeBtn').addEventListener('click', () => {
fetch('http://localhost:8080/execute', { method: 'POST' })
.then(response => {
if (!response.ok) {
throw new Error('Server returned an error');
}
// Создаем экземпляр EventSource и добавляем обработчик входящих сообщений
eventSource = new EventSource('http://localhost:8080/events');
eventSource.onmessage = event => {
const taskStatus = JSON.parse(event.data);
updateProgress(taskStatus);
};
eventSource.onerror = error => {
console.error('EventSource failed:', error);
eventSource.close();
};
})
.catch(error => {
console.error('Error:', error);
});
});
function updateProgress(data) {
if (data.executing && data.percent < 100) {
progressBar.style.width = `${data.percent}%`;
statusText.textContent = `Execution progress: ${data.percent.toFixed(2)}%`;
} else if (!data.executing && data.percent === 100) {
progressBar.style.width = '100%';
statusText.textContent = 'Execution finish';
eventSource.close();
}
}
EventSource - специальный интерфейс для работы с Server-Sent Events
WebSocket
Самый мощный инструмент я оставил напоследок. Реализуем сервер аналогичный SSE, но сообщения будем отправлять через WebSocket:
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"time"
"github.com/gorilla/websocket"
)
// Структура для хранения статуса выполнения задачи
type TaskStatus struct {
Executing bool `json:"executing"`
Percent int `json:"percent"`
}
// Очередь эвентов
var eventsQueue chan TaskStatus
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func task() {
taskStatus := TaskStatus{Executing: true}
for taskStatus.Percent < 100 {
taskStatus.Percent += rand.Intn(10) + 1
if taskStatus.Percent > 100 {
taskStatus.Executing = false
taskStatus.Percent = 100
}
eventsQueue <- taskStatus
time.Sleep(time.Second)
}
}
func main() {
rand.New(rand.NewSource(99))
eventsQueue = make(chan TaskStatus, 100)
http.HandleFunc("/execute", executeHandler)
http.HandleFunc("/events", eventsHandler)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Print(err)
}
}
func executeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
go task()
w.WriteHeader(http.StatusOK)
}
func eventsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
// Переключаемся на протокол WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
defer conn.Close()
// Отправляем эвенты из очереди
for {
select {
case taskStatus := <-eventsQueue:
jsonData, err := json.Marshal(taskStatus)
if err != nil {
fmt.Println("Error marshalling JSON:", err)
continue
}
if err := conn.WriteMessage(websocket.TextMessage, jsonData); err != nil {
fmt.Println("Error writing message:", err)
return
}
case <-r.Context().Done():
return
}
}
}
Клиентский код остается практически идентичным, вместо EventSource мы используем WebSocket:
const progressBar = document.getElementById('progressBar');
const statusText = document.getElementById('statusText');
let webSocket;
document.getElementById('executeBtn').addEventListener('click', async () => {
try {
const response = await fetch('http://localhost:8080/execute', { method: 'POST' });
if (!response.ok) {
throw new Error('Server returned an error');
}
webSocket = new WebSocket('ws://localhost:8080/events');
webSocket.onmessage = event => {
const taskStatus = JSON.parse(event.data);
updateProgress(taskStatus);
};
webSocket.onerror = error => {
console.error('WebSocket error:', error);
webSocket.close();
};
} catch (error) {
console.error('Error:', error);
}
});
function updateProgress(data) {
progressBar.style.width = `${data.percent}%`;
statusText.textContent = data.percent === 100
? 'Execution finish'
: `Execution progress: ${data.percent.toFixed(2)}%`;
if (data.percent === 100) {
webSocket.close();
}
}
Итоги
Весь приведенный выше код служит исключительно для демонстрации минимально рабочих примеров. Не следует рассматривать его всерьез в качестве решения для реальных проектов. Мы рассмотрели 4 популярных способа интерактивного взаимодействия клиента с сервером для получения обновлений. Теперь давайте определим, какой из них лучше использовать?
WebSocket:
Чтобы раскрыть всю мощь этой технологии, нужно написать отдельную статью. В данном примере мы использовали WebSocket как транспорт для сообщений, что может быть излишним. WebSocket действительно полезен, когда необходимо передавать данные в обе стороны от клиента к серверу и обратно с минимальными задержками и накладными расходами. Идеально подходит для онлайн‑игр или бирж, где скорость отклика критически важна.
Server‑Sent Events:
Современный стандарт для однонаправленного потока данных от сервера к клиенту. Легко внедрить в любом фреймворке как на бекенде, так и на фронтенде. Хорошо подходит для систем мониторинга, где поток данных направлен только от сервера к клиенту. Может быть использован, например, для отображения курса валют или мониторинга трафика. Также может быть частью чата, если отправку исходящих сообщений организовать через классический REST API, а входящие сообщения — через SSE.
Long Polling:
Этот подход устарел и не имеет практического смысла в современных приложениях. Его заменил SSE.
Short Polling:
Может быть использован в случае, если более современные методы недоступны по каким‑либо причинам.
Спасибо всем, кто дочитал статью!