Введение
WebSockets обеспечивают двунаправленные постоянные соединения между клиентами и серверами, что критически важно для функций реального времени, таких как чат, уведомления, live-обновления и совместное редактирование. В отличие от модели HTTP «запрос—ответ» WebSockets позволяют серверу мгновенно отправлять данные клиентам. В этом руководстве рассматриваются практические паттерны реализации WebSocket.
Основы WebSocket
Как работают WebSockets
- Клиент инициирует HTTP-запрос на upgrade
- Сервер принимает запрос и выполняет upgrade соединения
- Начинается двунаправленное взаимодействие
- Любая сторона может отправлять сообщения в любое время
- Соединение сохраняется до явного закрытия
Когда использовать WebSockets
| Используйте WebSockets | Используйте HTTP/REST |
|---|
| Обновления в реальном времени | Данные по модели «запрос—ответ» |
| Чат-приложения | CRUD-операции |
| Live-уведомления | Загрузка файлов |
| Совместное редактирование | Редкие обновления |
| Игры | Статический контент |
Node.js с Socket.IO
Настройка сервера
// server.js
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
// Обработка соединения
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Обработка событий от клиента
socket.on('message', (data) => {
console.log('Message received:', data);
// Отправка всем клиентам
io.emit('message', data);
// Или отправка в конкретную room
// io.to('room1').emit('message', data);
});
// Присоединиться к room
socket.on('join_room', (room) => {
socket.join(room);
console.log(`${socket.id} joined ${room}`);
socket.to(room).emit('user_joined', { userId: socket.id });
});
// Покинуть room
socket.on('leave_room', (room) => {
socket.leave(room);
socket.to(room).emit('user_left', { userId: socket.id });
});
// Обработка отключения
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
httpServer.listen(3001, () => {
console.log('Server running on port 3001');
});
Клиент (React)
// hooks/useSocket.js
import { useEffect, useState, useCallback } from 'react';
import { io } from 'socket.io-client';
const SOCKET_URL = 'http://localhost:3001';
export function useSocket() {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = io(SOCKET_URL, {
transports: ['websocket'],
});
socketInstance.on('connect', () => {
setIsConnected(true);
console.log('Connected to server');
});
socketInstance.on('disconnect', () => {
setIsConnected(false);
console.log('Disconnected from server');
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
const sendMessage = useCallback((event, data) => {
if (socket) {
socket.emit(event, data);
}
}, [socket]);
return { socket, isConnected, sendMessage };
}
// components/Chat.jsx
import { useState, useEffect } from 'react';
import { useSocket } from '../hooks/useSocket';
export function Chat({ room }) {
const { socket, isConnected, sendMessage } = useSocket();
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
useEffect(() => {
if (!socket) return;
// Присоединиться к room
socket.emit('join_room', room);
// Прослушивание сообщений
socket.on('message', (message) => {
setMessages((prev) => [...prev, message]);
});
socket.on('user_joined', ({ userId }) => {
setMessages((prev) => [...prev, { system: true, text: `${userId} joined` }]);
});
return () => {
socket.emit('leave_room', room);
socket.off('message');
socket.off('user_joined');
};
}, [socket, room]);
const handleSend = () => {
if (input.trim()) {
sendMessage('message', { room, text: input, timestamp: Date.now() });
setInput('');
}
};
return (
<div className="chat">
<div className="status">
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
</div>
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={msg.system ? 'system' : 'message'}>
{msg.text}
</div>
))}
</div>
<div className="input">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="Type a message..."
/>
<button onClick={handleSend}>Send</button>
</div>
</div>
);
}
PHP с Ratchet
Настройка сервера
composer require cboden/ratchet
// src/ChatServer.php
<?php
namespace App;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
class ChatServer implements MessageComponentInterface
{
protected $clients;
protected $rooms = [];
public function __construct()
{
$this->clients = new \SplObjectStorage;
}
public function onOpen(ConnectionInterface $conn)
{
$this->clients->attach($conn);
echo "New connection: {$conn->resourceId}\n";
}
public function onMessage(ConnectionInterface $from, $msg)
{
$data = json_decode($msg, true);
switch ($data['type']) {
case 'join':
$this->joinRoom($from, $data['room']);
break;
case 'message':
$this->broadcastToRoom($from, $data['room'], $data);
break;
}
}
protected function joinRoom(ConnectionInterface $conn, $room)
{
if (!isset($this->rooms[$room])) {
$this->rooms[$room] = new \SplObjectStorage;
}
$this->rooms[$room]->attach($conn);
// Уведомить остальных
foreach ($this->rooms[$room] as $client) {
if ($client !== $conn) {
$client->send(json_encode([
'type' => 'user_joined',
'userId' => $conn->resourceId,
]));
}
}
}
protected function broadcastToRoom(ConnectionInterface $from, $room, $data)
{
if (!isset($this->rooms[$room])) return;
$message = json_encode($data);
foreach ($this->rooms[$room] as $client) {
$client->send($message);
}
}
public function onClose(ConnectionInterface $conn)
{
$this->clients->detach($conn);
// Удалить из всех rooms
foreach ($this->rooms as $room => $clients) {
if ($clients->contains($conn)) {
$clients->detach($conn);
}
}
echo "Connection closed: {$conn->resourceId}\n";
}
public function onError(ConnectionInterface $conn, \Exception $e)
{
echo "Error: {$e->getMessage()}\n";
$conn->close();
}
}
// bin/server.php
<?php
require __DIR__ . '/../vendor/autoload.php';
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use App\ChatServer;
$server = IoServer::factory(
new HttpServer(
new WsServer(
new ChatServer()
)
),
8080
);
echo "WebSocket server running on port 8080\n";
$server->run();
Laravel Broadcasting
Конфигурация
// config/broadcasting.php
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
],
Трансляция событий
// app/Events/MessageSent.php
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use InteractsWithSockets, SerializesModels;
public $message;
public $user;
public function __construct($message, $user)
{
$this->message = $message;
$this->user = $user;
}
public function broadcastOn()
{
return new PrivateChannel('chat.' . $this->message->room_id);
}
public function broadcastWith()
{
return [
'id' => $this->message->id,
'text' => $this->message->text,
'user' => [
'id' => $this->user->id,
'name' => $this->user->name,
],
'created_at' => $this->message->created_at->toIso8601String(),
];
}
}
Трансляция из контроллера
// app/Http/Controllers/ChatController.php
public function sendMessage(Request $request)
{
$message = Message::create([
'room_id' => $request->room_id,
'user_id' => auth()->id(),
'text' => $request->text,
]);
broadcast(new MessageSent($message, auth()->user()))->toOthers();
return response()->json($message);
}
Клиент с Laravel Echo
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
const echo = new Echo({
broadcaster: 'pusher',
key: process.env.PUSHER_APP_KEY,
cluster: process.env.PUSHER_APP_CLUSTER,
forceTLS: true,
});
// Слушать на private channel
echo.private(`chat.${roomId}`)
.listen('MessageSent', (e) => {
console.log('New message:', e);
addMessage(e);
});
// Presence channel (показывает, кто онлайн)
echo.join(`chat.${roomId}`)
.here((users) => {
console.log('Users in room:', users);
})
.joining((user) => {
console.log('User joined:', user);
})
.leaving((user) => {
console.log('User left:', user);
});
Аутентификация
Socket.IO с JWT
// Сервер
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
socket.user = user;
next();
} catch (err) {
next(new Error('Authentication failed'));
}
});
// Клиент
const socket = io(SOCKET_URL, {
auth: {
token: localStorage.getItem('token'),
},
});
Масштабирование с Redis
Socket.IO с Redis Adapter
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
Это позволяет нескольким серверам Socket.IO совместно использовать события.
Обработка ошибок и переподключение
// Клиент
const socket = io(SOCKET_URL, {
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 20000,
});
socket.on('connect_error', (error) => {
console.error('Connection error:', error);
});
socket.on('reconnect', (attemptNumber) => {
console.log('Reconnected after', attemptNumber, 'attempts');
});
socket.on('reconnect_failed', () => {
console.error('Reconnection failed');
// Показать ошибку пользователю
});
Рекомендации
- Используйте rooms для адресной отправки сообщений вместо рассылки всем
- Реализуйте heartbeats для обнаружения «зависших» соединений
- Добавьте аутентификацию для предотвращения несанкционированного доступа
- Корректно обрабатывайте переподключение на стороне клиента
- Используйте Redis adapter для горизонтального масштабирования
- Ограничивайте размер сообщений для предотвращения злоупотреблений
- Реализуйте rate limiting для отправки сообщений
Заключение
WebSockets обеспечивают коммуникацию в реальном времени, необходимую для современных приложений. Используйте Socket.IO для кроссбраузерной совместимости, Laravel Broadcasting для приложений на Laravel или Ratchet для PHP-серверов. Для использования в production реализуйте корректную аутентификацию, обработку ошибок и стратегии масштабирования.