Картинка для привлечения внимания
Картинка для привлечения внимания

Веб-разработка часто требует реализации механизмов обновления контента на странице в реальном времени. Существуют различные сценарии, где это необходимо, например, отображение прогресса выполнения тяжелых задач на бекенде, обновление каких-либо часто изменяющихся данных, будь то курсы валют или мониторинг какой-то активности, чаты, различные уведомления. Эти сценарии объединяет одна общая особенность: источник события необходимости обновления данных находится не на клиентской стороне, поэтому мы хотим получать события с бекенда. В данной статье мы рассмотрим четыре популярных подхода к реализации этой функциональности: 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', function() {
  var progress = 0;
  var progressBar = document.getElementById('progressBar');
  var statusText = document.getElementById('statusText');
  var interval = setInterval(function() {
    progress += Math.random() * 10;
    if (progress >= 100) {
      progress = 100;
      clearInterval(interval);
    }
    progressBar.style.width = progress + '%';
    progressBar.setAttribute('aria-valuenow', progress);
    statusText.innerText = '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:

var progressBar = document.getElementById('progressBar');
var statusText = document.getElementById('statusText');


document.getElementById('executeBtn').addEventListener('click', function () {
    fetch('http://localhost:8080/execute', {method: 'POST'})
        .then(response => {
            const reader = response.body.getReader();
            const decoder = new TextDecoder();

            //Читаем данные по мере поступления
            function readChunk() {
                return reader.read().then(({done, value}) => {
                    if (done) {
                        console.log('Stream complete');
                        return;
                    }
                    const data = decoder.decode(value, {stream: true});
                    const taskStatus = JSON.parse(data);
                    updateProgress(taskStatus);
                    return readChunk();
                });
            }

            return 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));
        pollStatus()
    }
    progressBar.style.width = data.percent + '%';
    statusText.innerText = data.percent === 100 ? 'Execution finish' : 'Execution progress: ' + data.percent?.toFixed(2) + '%';
}

Фронтенд также изменился: теперь мы отправляем только один HTTP-запрос, читаем данные по мере их поступления и обновляем прогресс-бар.

Как видим все данные приходят в одном http запросе
Как видим все данные приходят в одном 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 инструментам.

var progressBar = document.getElementById('progressBar');
var statusText = document.getElementById('statusText');

var eventSource;

document.getElementById('executeBtn').addEventListener('click', function () {
    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 = function (event) {
                const taskStatus = JSON.parse(event.data);
                updateProgress(taskStatus);
            };
        })
        .catch(error => {
            console.error('Error:', error);
        });
});

function updateProgress(data) {
    if (data.executing && data.percent < 100) {
        progressBar.style.width = data.percent + '%';
        statusText.innerText = 'Execution progress: ' + data.percent.toFixed(2) + '%';
    } else if (!data.executing && data.percent === 100) {
        progressBar.style.width = '100%';
        statusText.innerText = 'Execution finish';
        eventSource.close();
    }
}

EventSource - специальный интерфейс для работы с Server-Sent Events

Как видим браузер корректно обрабатывает EventStream
Как видим браузер корректно обрабатывает EventStream

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:

var progressBar = document.getElementById('progressBar');
var statusText = document.getElementById('statusText');

var webSocket;

document.getElementById('executeBtn').addEventListener('click', function () {
    fetch('http://localhost:8080/execute', {method: 'POST'})
        .then(response => {
            if (!response.ok) {
                throw new Error('Server returned an error');
            }
            webSocket = new WebSocket('ws://localhost:8080/events');
            webSocket.onmessage = function (event) {
                const taskStatus = JSON.parse(event.data);
                updateProgress(taskStatus);
            };
        })
        .catch(error => {
            console.error('Error:', error);
        });
});

function updateProgress(data) {
    if (data.executing && data.percent < 100) {
        progressBar.style.width = data.percent + '%';
        statusText.innerText = 'Execution progress: ' + data.percent.toFixed(2) + '%';
    } else if (!data.executing && data.percent === 100) {
        progressBar.style.width = '100%';
        statusText.innerText = 'Execution finish';
        webSocket.close();
    }
}
Сообщения передаются через WebSocket
Сообщения передаются через WebSocket

Итоги

Весь приведенный выше код служит исключительно для демонстрации минимально рабочих примеров. Не следует рассматривать его всерьез в качестве решения для реальных проектов. Мы рассмотрели 4 популярных способа интерактивного взаимодействия клиента с сервером для получения обновлений. Теперь давайте определим, какой из них лучше использовать?

WebSocket:

Чтобы раскрыть всю мощь этой технологии, нужно написать отдельную статью. В данном примере мы использовали WebSocket как транспорт для сообщений, что может быть излишним. WebSocket действительно полезен, когда необходимо передавать данные в обе стороны от клиента к серверу и обратно с минимальными задержками и накладными расходами. Идеально подходит для онлайн‑игр или бирж, где скорость отклика критически важна.

Server‑Sent Events:

Современный стандарт для однонаправленного потока данных от сервера к клиенту. Легко внедрить в любом фреймворке как на бекенде, так и на фронтенде. Хорошо подходит для систем мониторинга, где поток данных направлен только от сервера к клиенту. Может быть использован, например, для отображения курса валют или мониторинга трафика. Также может быть частью чата, если отправку исходящих сообщений организовать через классический REST API, а входящие сообщения — через SSE.

Long Polling:

Этот подход устарел и не имеет практического смысла в современных приложениях. Его заменил SSE.

Short Polling:

Может быть использован в случае, если более современные методы недоступны по каким‑либо причинам.

Спасибо всем, кто дочитал статью!

Комментарии (11)


  1. QuantForce
    15.05.2024 20:25

    Есть еще вариант использовать gRPC Streaming


    1. Turbomolot
      15.05.2024 20:25

      Да, кстати. Но, к сожалению gRPC не работает в браузерах, а костыль в виде web-grpc не достаточно зрелый для использования в проде.


    1. Ukrainskiy Автор
      15.05.2024 20:25

      То преимущество, которое мог бы дать полноценный gRPC в браузере, реализовано в GraphQL.


      1. QuantForce
        15.05.2024 20:25

        GraphQL подойдет только для небольших сервисов. На больших нагрузках эта фигня способна такие не оптимальные портянки в запросах построить, что придется потом все переделывать. Так что в каком-то случае grpc все же лучше GraphQL


  1. Turbomolot
    15.05.2024 20:25

    Спасибо за статью. Упомянуто было про Websocket «Самый ресурсоемкий в реализации подход», имеется в виду больше всего времени и усилий уйдет на его реализацию?


    1. Ukrainskiy Автор
      15.05.2024 20:25

      Да, особенно если рассматривать внедрение в уже рабочий проект. Как правило вебсокеты выносят в отдельный микросервис и рядом ставят очередь, например RabbitMQ. Зато в ресурсах в плане железа и скорости работы это будет самый оптимальный выбор. Опять же если задача того требует.


  1. akurilov
    15.05.2024 20:25

    Я бы добавил вебхуки. Требует публичного адреса получателя, но иногда тоже вариант


    1. alekssamos
      15.05.2024 20:25

      Кстати да, для API мне кажется это самый лучший способ


    1. Ukrainskiy Автор
      15.05.2024 20:25

      Можно поподробнее, как вы себе это представляете? В статье речь о способах организации клиент-серверного взаимодействия с получением событий от бэка в браузере. Механизм вебхуков используется для коммуникации между бэкендами.


  1. RegularScoundrel
    15.05.2024 20:25

    Я правильно понимаю, что в последнем примере можно использовать двунаправленность websocket и иметь только один endpoint на стороне сервера?


    1. Ukrainskiy Автор
      15.05.2024 20:25

      Все верно, вы можете полноценно использовать WebSocket соединение. https://pkg.go.dev/github.com/gorilla/websocket