ws (npm) / socket.io → github.com/gorilla/websocket
new WebSocket.Server({ port }) → websocket.Upgrade(w, r)
ws.on('message', callback) → conn.ReadMessage()
ws.send(data) → conn.WriteMessage(websocket.TextMessage, data)
ws.on('close', callback) → conn.Close() + проверка ошибок
mkdir go-websocket && cd go-websocket
go mod init go-websocket
# Устанавливаем gorilla/websocket
go get github.com/gorilla/websocket
go get github.com/google/uuid
go mod tidy
# Структура
mkdir -p internal/ws
mkdir -p cmd/server
internal/ws/client.gopackage ws
import (
"log"
"time"
"github.com/gorilla/websocket"
)
const (
// Время ожидания записи
writeWait = 10 * time.Second
// Время ожидания pong от клиента
pongWait = 60 * time.Second
// Интервал отправки ping
pingPeriod = (pongWait * 9) / 10
// Максимальный размер сообщения
maxMessageSize = 512 * 1024 // 512KB
)
// Client — представляет одного WebSocket-клиента
type Client struct {
ID string
Hub *Hub
Conn *websocket.Conn
Send chan []byte // Буфер отправляемых сообщений
UserID string
Room string
}
// readPump — читает сообщения от клиента (запускается в горутине)
func (c *Client) readPump() {
defer func() {
c.Hub.unregister <- c
c.Conn.Close()
}()
// Настройка соединения
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
// Читаем сообщение
messageType, message, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway,
websocket.CloseAbnormalClosure,
websocket.CloseNormalClosure,
) {
log.Printf("WebSocket ошибка: %v", err)
}
break
}
// Обрабатываем только текстовые сообщения
if messageType == websocket.TextMessage {
// Отправляем в hub для broadcast
c.Hub.broadcast <- BroadcastMessage{
ClientID: c.ID,
UserID: c.UserID,
Room: c.Room,
Data: message,
}
}
}
}
// writePump — отправляет сообщения клиенту (запускается в горутине)
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// Hub закрыл канал
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// Отправляем накопленные сообщения одним фреймом
n := len(c.Send)
for i := 0; i < n; i++ {
w.Write([]byte("\n"))
w.Write(<-c.Send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
// Отправляем ping
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
internal/ws/hub.gopackage ws
import (
"encoding/json"
"fmt"
"log"
"sync"
)
// BroadcastMessage — сообщение для broadcast
type BroadcastMessage struct {
ClientID string `json:"client_id,omitempty"`
UserID string `json:"user_id"`
Room string `json:"room,omitempty"`
Data []byte `json:"data"`
}
// Hub — управляет всеми WebSocket-соединениями
type Hub struct {
// Зарегистрированные клиенты по комнатам
clients map[string]map[*Client]bool // room → clients
mu sync.RWMutex
broadcast chan BroadcastMessage
register chan *Client
unregister chan *Client
}
// NewHub — создаёт новый Hub
func NewHub() *Hub {
return &Hub{
clients: make(map[string]map[*Client]bool),
broadcast: make(chan BroadcastMessage, 256),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
// Run — запускает Hub (должен работать в отдельной горутине)
func (h *Hub) Run() {
log.Println("🔌 Hub запущен")
for {
select {
case client := <-h.register:
h.mu.Lock()
if _, ok := h.clients[client.Room]; !ok {
h.clients[client.Room] = make(map[*Client]bool)
}
h.clients[client.Room][client] = true
h.mu.Unlock()
// Уведомляем всех в комнате о новом участнике
joinMsg, _ := json.Marshal(map[string]interface{}{
"type": "user_joined",
"user_id": client.UserID,
"room": client.Room,
})
h.broadcastToRoom(client.Room, joinMsg, "")
log.Printf("✅ Клиент подключился: %s (комната: %s, всего в комнате: %d)",
client.UserID, client.Room, h.roomSize(client.Room))
case client := <-h.unregister:
h.mu.Lock()
if clients, ok := h.clients[client.Room]; ok {
if _, exists := clients[client]; exists {
delete(clients, client)
close(client.Send)
// Удаляем комнату если пустая
if len(clients) == 0 {
delete(h.clients, client.Room)
}
}
}
h.mu.Unlock()
// Уведомляем об уходе
leaveMsg, _ := json.Marshal(map[string]interface{}{
"type": "user_left",
"user_id": client.UserID,
"room": client.Room,
})
h.broadcastToRoom(client.Room, leaveMsg, "")
log.Printf("👋 Клиент отключился: %s (комната: %s)", client.UserID, client.Room)
case msg := <-h.broadcast:
// Форматируем сообщение
formatted, _ := json.Marshal(map[string]interface{}{
"type": "message",
"user_id": msg.UserID,
"content": string(msg.Data),
"room": msg.Room,
})
h.broadcastToRoom(msg.Room, formatted, msg.ClientID)
}
}
}
// broadcastToRoom — отправляет сообщение всем в комнате (кроме отправителя)
func (h *Hub) broadcastToRoom(room string, message []byte, excludeClientID string) {
h.mu.RLock()
defer h.mu.RUnlock()
clients, ok := h.clients[room]
if !ok {
return
}
for client := range clients {
if client.ID != excludeClientID {
select {
case client.Send <- message:
default:
// Буфер клиента переполнен — отключаем
close(client.Send)
delete(clients, client)
}
}
}
}
// SendToUser — отправляет сообщение конкретному пользователю
func (h *Hub) SendToUser(userID, message string) error {
h.mu.RLock()
defer h.mu.RUnlock()
for _, clients := range h.clients {
for client := range clients {
if client.UserID == userID {
select {
case client.Send <- []byte(message):
return nil
default:
return fmt.Errorf("user %s buffer full", userID)
}
}
}
}
return fmt.Errorf("user %s not connected", userID)
}
// Rooms — возвращает список комнат и количество пользователей
func (h *Hub) Rooms() map[string]int {
h.mu.RLock()
defer h.mu.RUnlock()
rooms := make(map[string]int)
for room, clients := range h.clients {
rooms[room] = len(clients)
}
return rooms
}
// roomSize — количество клиентов в комнате
func (h *Hub) roomSize(room string) int {
if clients, ok := h.clients[room]; ok {
return len(clients)
}
return 0
}
// Shutdown — закрывает все соединения
func (h *Hub) Shutdown() {
h.mu.Lock()
defer h.mu.Unlock()
for _, clients := range h.clients {
for client := range clients {
close(client.Send)
client.Conn.Close()
}
}
log.Println("🔌 Hub остановлен")
}
internal/ws/upgrader.gopackage ws
import (
"net/http"
"github.com/gorilla/websocket"
)
// Upgrader — настройки для upgrade HTTP → WebSocket
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Проверка Origin (CORS для WebSocket)
CheckOrigin: func(r *http.Request) bool {
// В продакшене — проверять список разрешённых origin
origin := r.Header.Get("Origin")
return origin == "http://localhost:3000" ||
origin == "http://localhost:8080" ||
origin == ""
},
}
// UpgradeConnection — обновляет HTTP-соединение до WebSocket
func UpgradeConnection(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return nil, err
}
return conn, nil
}
cmd/server/main.gopackage main
import (
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"go-websocket/internal/ws"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Println("🔌 WebSocket сервер...")
hub := ws.NewHub()
go hub.Run()
mux := http.NewServeMux()
// Статус сервера
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// Информация о комнатах
mux.HandleFunc("GET /api/rooms", func(w http.ResponseWriter, r *http.Request) {
rooms := hub.Rooms()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rooms)
})
// WebSocket endpoint
mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
// Получаем параметры из query
room := r.URL.Query().Get("room")
if room == "" {
room = "general"
}
userID := r.URL.Query().Get("user_id")
if userID == "" {
userID = "anonymous-" + uuid.New().String()[:8]
}
// Аутентификация (опционально)
token := r.URL.Query().Get("token")
if token != "" {
// Проверить JWT токен
// claims, err := jwtService.ValidateToken(token)
// if err != nil {
// http.Error(w, "Unauthorized", http.StatusUnauthorized)
// return
// }
// userID = claims.UserID
}
// Upgrade до WebSocket
conn, err := ws.UpgradeConnection(w, r)
if err != nil {
log.Printf("❌ Ошибка upgrade: %v", err)
return
}
// Создаём клиента
client := &ws.Client{
ID: uuid.New().String(),
Hub: hub,
Conn: conn,
Send: make(chan []byte, 256),
UserID: userID,
Room: room,
}
// Регистрируем в hub
hub.Register <- client
// Запускаем горутины чтения/записи
go client.WritePump()
go client.ReadPump()
})
// HTTP-клиент для тестирования (простой HTML)
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(chatHTML))
})
server := &http.Server{Addr: ":8080", Handler: mux}
// Graceful shutdown
go func() {
log.Println("✅ Сервер на http://localhost:8080")
log.Println(" WebSocket: ws://localhost:8080/ws?room=general&user_id=Alice")
log.Println(" Чат (браузер): http://localhost:8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Сервер: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("🛑 Выключение...")
hub.Shutdown()
server.Close()
log.Println("✅ Остановлен")
}
// chatHTML — простой чат для тестирования
const chatHTML = `<!DOCTYPE html>
<html>
<head><title>WebSocket Chat</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 20px auto; }
#messages { border: 1px solid #ccc; height: 400px; overflow-y: scroll; padding: 10px; margin-bottom: 10px; }
input { width: 80%; padding: 8px; }
button { padding: 8px 15px; }
.system { color: #888; font-style: italic; }
</style>
</head>
<body>
<h2>WebSocket Chat</h2>
<div>
<input id="room" placeholder="Комната" value="general" style="width:30%">
<input id="name" placeholder="Имя" value="User" style="width:30%">
</div>
<div id="messages"></div>
<input id="input" placeholder="Сообщение..." autofocus>
<button onclick="send()">Отправить</button>
<script>
const room = document.getElementById('room');
const name = document.getElementById('name');
const input = document.getElementById('input');
const messages = document.getElementById('messages');
function connect() {
const ws = new WebSocket(
'ws://' + location.host + '/ws?room=' + room.value + '&user_id=' + name.value
);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
const div = document.createElement('div');
if (msg.type === 'message') {
div.textContent = msg.user_id + ': ' + msg.content;
} else {
div.textContent = msg.user_id + ' ' + (msg.type === 'user_joined' ? 'присоединился' : 'вышел');
div.className = 'system';
}
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
};
ws.onclose = () => setTimeout(connect, 1000);
return ws;
}
let ws = connect();
function send() {
if (input.value) {
ws.send(input.value);
input.value = '';
}
}
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') send(); });
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>
</body>
</html>`
# Запуск сервера
go run ./cmd/server/main.go
# Тестирование через wscat (если установлен)
npm install -g wscat
wscat -c "ws://localhost:8080/ws?room=general&user_id=Alice"
# Тестирование через curl (только upgrade)
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: test" \
http://localhost:8080/ws
# Просмотр комнат
curl http://localhost:8080/api/rooms
| Библиотека | Звёзды | Особенности |
|---|---|---|
| gorilla/websocket | 21k+ | Самая популярная, полный функционал |
| nhooyr.io/websocket | 3k+ | Минималистичная, контексты, современный API |
| gobwas/ws | 6k+ | Zero-allocation, очень быстрая |
💡 Best practices от сеньоров:
💡 Для Node.js разработчика:
ws в Node.js и gorilla/websocket в Go — очень похожий API.
ws.on('message', cb) → conn.ReadMessage() в цикле.ws.send(data) → conn.WriteMessage(websocket.TextMessage, data).