Xây dựng ứng dụng Real-time với WebSocket trong ngôn ngữ Go

Trong các ứng dụng web hiện đại, mô hình yêu cầu - phản hồi (request-response) truyền thống của HTTP đôi khi là không đủ. Các ứng dụng như hệ thống chat trực tiếp, bảng giá chứng khoán, hoặc game多人 online yêu cầu dữ liệu phải được đẩy từ server về client ngay lập tức mà không cần client liên tục gửi yêu cầu. Đây chính là nơi giao thức WebSocket phát huy tác dụng, cung cấp kênh giao tiếp hai chiều (full-duplex) hiệu quả qua một kết nối TCP duy nhất.

Nguyên lý hoạt động của WebSocket

Khác với HTTP, nơi giao tiếp chỉ diễn ra khi client khởi tạo, WebSocket cho phép server chủ động gửi dữ liệu đến client bất cứ lúc nào sau khi quá trình "bắt tay" (handshake) hoàn tất.

Quy trình thiết lập kết nối:

  1. Client gửi một yêu cầu HTTP tiêu chuẩn với header Upgrade: websocket để yêu cầu nâng cấp giao thức.
  2. Server xác nhận yêu cầu và trả về mã trạng thái 101 Switching Protocols.
  3. Kết nối TCP được chuyển đổi từ HTTP sang WebSocket, cho phép trao đổi dữ liệu dạng tin nhắn (frames) mà không cần header phức tạp của HTTP trong các lần giao tiếp sau.

Lợi ích chính so với HTTP Polling:

  • Giảm độ trễ: Dữ liệu được truyền đi ngay lập tức mà không cần đợi request từ client.
  • Tiết kiệm băng thông: Loại bỏ việc gửi đi các header lặp lại trong mỗi request HTTP.
  • Kết nối bền vững: Duy trì trạng thái kết nối liên tục, giảm tải cho việc thiết lập lại handshake.

Triển khai WebSocket với Go

Trong hệ sinh thái Go, thư viện gorilla/websocket là tiêu chuẩn de-facto để xử lý kết nối WebSocket do tính ổn định và đầy đủ tính năng. Trước tiên, chúng ta cần cài đặt thư viện:

go get github.com/gorilla/websocket

Tạo Server cơ bản

Ví dụ dưới đây minh họa cách nâng cấp một kết nối HTTP thông thường thành WebSocket và xử lý luồng tin nhắn đơn giản.

package main

import (
	"log"
	"net/http"
	"github.com/gorilla/websocket"
)

// Cấu hình bộ nâng cấp giao thức
var wsUpgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	// Trong môi trường sản xuất, cần kiểm tra kỹOrigin để bảo mật
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func echoHandler(w http.ResponseWriter, r *http.Request) {
	// Nâng cấp kết nối HTTP thành WebSocket
	socket, err := wsUpgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Printf("Lỗi khi nâng cấp kết nối: %v", err)
		return
	}
	defer socket.Close()

	log.Printf("Đã kết nối với client: %s", socket.RemoteAddr())

	// Vòng lặp xử lý tin nhắn
	for {
		messageType, payload, err := socket.ReadMessage()
		if err != nil {
			log.Printf("Lỗi đọc tin nhắn: %v", err)
			break
		}

		log.Printf("Nhận được dữ liệu: %s", payload)

		// Echo lại dữ liệu cho client
		if err := socket.WriteMessage(messageType, payload); err != nil {
			log.Printf("Lỗi gửi tin nhắn: %v", err)
			break
		}
	}
}

func main() {
	http.HandleFunc("/ws", echoHandler)

	log.Println("Server đang chạy tại cổng :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal("Lỗi khởi động server:", err)
	}
}

Client giao diện HTML

Tệp HTML dưới đây cung cấp giao diện người dùng để kiểm tra kết nối và gửi tin nhắn.

<!DOCTYPE html>
<html lang="vi">
<head>
    <meta charset="UTF-8">
    <title>Kiểm tra WebSocket</title>
    <style>
        body { font-family: sans-serif; padding: 20px; max-width: 600px; margin: auto; }
        #chat-box { height: 300px; border: 1px solid #ddd; overflow-y: scroll; padding: 10px; margin-bottom: 10px; }
        #input-area { display: flex; gap: 10px; }
        input { flex-grow: 1; padding: 8px; }
        button { padding: 8px 16px; background: #007bff; color: white; border: none; cursor: pointer; }
    </style>
</head>
<body>
    <h2>WebSocket Demo</h2>
    <div id="chat-box"></div>
    <div id="input-area">
        <input type="text" id="msgInput" placeholder="Nhập nội dung..." />
        <button onclick="sendData()">Gửi</button>
    </div>

    <script>
        const chatBox = document.getElementById('chat-box');
        const msgInput = document.getElementById('msgInput');
        let ws;

        function initConnection() {
            ws = new WebSocket('ws://' + window.location.host + '/ws');

            ws.onopen = () => {
                appendLog('Hệ thống', 'Đã kết nối tới server');
            };

            ws.onmessage = (event) => {
                appendLog('Server', event.data);
            };

            ws.onclose = () => {
                appendLog('Hệ thống', 'Mất kết nối, đang thử kết nối lại...');
                setTimeout(initConnection, 3000);
            };
        }

        function sendData() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                const text = msgInput.value;
                if (text) {
                    ws.send(text);
                    appendLog('Tôi', text);
                    msgInput.value = '';
                }
            } else {
                alert('Chưa kết nối được server');
            }
        }

        function appendLog(sender, text) {
            const div = document.createElement('div');
            div.textContent = `${sender}: ${text}`;
            chatBox.appendChild(div);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        msgInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') sendData();
        });

        window.onload = initConnection;
    </script>
</body>
</html>

Hệ thống Broadcast (Phát sóng tin nhắn)

Để xây dựng một ứng dụng chat thực tế, server cần khả năng chuyển tiếp tin nhắn từ một người dùng đến tất cả những người dùng khác (broadcast). Chúng ta sẽ sử dụng mô hình Hub (hay còn gọi là Broker) để quản lý danh sách các kết nối đang hoạt động.

Kiến trúc hệ thống

Chúng ta sẽ định nghĩa các thành phần chính:

  • Hub/Room: Quản lý danh sách client, xử lý đăng ký, hủy đăng ký và điều phối luồng tin nhắn chung.
  • Client: Đóng gói kết nối WebSocket riêng biệt, có kênh gửi tin nhắn riêng biệt để tránh race condition.
  • Channels: Sử dụng cơ chế channel của Go để truyền tin nhắn một cách an toàn giữa các goroutine.

Mã nguồn triển khai

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"sync"
	"time"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool { return true },
}

// Cấu trúc dữ liệu tin nhắn
type Event struct {
	Type    string    `json:"type"`
	Content string    `json:"content"`
	From    string    `json:"sender"`
	At      time.Time `json:"timestamp"`
}

// Đối tượng Client đại diện cho một người dùng kết nối
type User struct {
	ID   string
	Nick string
	Conn *websocket.Conn
	Send chan []byte
	Room *ChatRoom
}

// ChatRoom quản lý tất cả các User đang online
type ChatRoom struct {
	users    map[*User]bool
	inbox    chan []byte
	join     chan *User
	leave    chan *User
	mu       sync.RWMutex
}

// Khởi tạo mới một ChatRoom
func NewChatRoom() *ChatRoom {
	return &ChatRoom{
		users: make(map[*User]bool),
		inbox: make(chan []byte),
		join:  make(chan *User),
		leave: make(chan *User),
	}
}

// Chạy vòng lặp chính của Room
func (r *ChatRoom) Run() {
	for {
		select {
		case user := <-r.join:
			r.mu.Lock()
			r.users[user] = true
			r.mu.Unlock()
			
			// Thông báo user mới tham gia
			notice := Event{Type: "sys", Content: user.Nick + " đã vào phòng", At: time.Now()}
			data, _ := json.Marshal(notice)
			r.inbox <- data

		case user := <-r.leave:
			r.mu.Lock()
			if _, ok := r.users[user]; ok {
				delete(r.users, user)
				close(user.Send)
				// Thông báo user rời đi
				notice := Event{Type: "sys", Content: user.Nick + " đã rời phòng", At: time.Now()}
				data, _ := json.Marshal(notice)
				r.inbox <- data
			}
			r.mu.Unlock()

		case msg := <-r.inbox:
			// Gửi tin nhắn đến tất cả user đang online
			r.mu.RLock()
			for user := range r.users {
				select {
				case user.Send <- msg:
				default:
					// Nếu kênh gửi bị tắc, coi như user đã mất và xóa đi
					close(user.Send)
					delete(r.users, user)
				}
			}
			r.mu.RUnlock()
		}
	}
}

// Goroutine đọc tin nhắn từ User
func (u *User) ReadLoop() {
	defer func() {
		u.Room.leave <- u
		u.Conn.Close()
	}()

	u.Conn.SetReadLimit(512)
	u.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	u.Conn.SetPongHandler(func(string) error {
		u.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
		return nil
	})

	for {
		_, data, err := u.Conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
				log.Printf("Lỗi kết nối: %v", err)
			}
			break
		}

		// Đóng gói tin nhắn và đẩy vào Room để broadcast
		msg := Event{
			Type:    "chat",
			Content: string(data),
			From:    u.Nick,
			At:      time.Now(),
		}
		payload, _ := json.Marshal(msg)
		u.Room.inbox <- payload
	}
}

// Goroutine ghi tin nhắn về cho User
func (u *User) WriteLoop() {
	ticker := time.NewTicker(50 * time.Second) // Heartbeat
	defer func() {
		ticker.Stop()
		u.Conn.Close()
	}()

	for {
		select {
		case msg, ok := <-u.Send:
			u.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if !ok {
				// Channel bị đóng nghĩa là Room đã hủy user này
				u.Conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}

			if err := u.Conn.WriteMessage(websocket.TextMessage, msg); err != nil {
				return
			}

		case <-ticker.C:
			// Gửi ping để giữ kết nối (Heartbeat)
			u.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if err := u.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

Để hoàn tất, bạn cần viết một hàm serveWS để khởi tạo đối tượng User, gán nó vào ChatRoom chung và chạy các goroutine ReadLoopWriteLoop. Cơ chế này đảm bảo tính đồng bộ cao và xử lý được lượng lớn kết nối đồng thời nhờ vào tính chất non-blocking của channels trong Go.

Thẻ: Go WebSocket real-time Gorilla WebSocket Web Development

Đăng vào ngày 26 tháng 6 lúc 09:10