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:
- 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. - Server xác nhận yêu cầu và trả về mã trạng thái
101 Switching Protocols. - 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 ReadLoop và WriteLoop. 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.