Hướng dẫn push message qua socket với EzyChat
Back to ezychatTrong EzyChat, gửi tin nhắn qua socket là một quy trình hai chiều: client đẩy yêu cầu lên socket server, server kiểm tra và lưu tin nhắn, sau đó phát dữ liệu đã chuẩn hóa tới tất cả thành viên của kênh.
Socket chỉ đảm nhiệm giao tiếp thời gian thực. Cơ sở dữ liệu vẫn là nguồn dữ liệu chính của cuộc hội thoại.
Chuẩn bị kết nối
Client cần có các thông tin sau:
- WebSocket URL, chẳng hạn
wss://chat.example.com:2812/ws. - Zone name:
chat. - App name:
ezychat. - Access token dùng để đăng nhập socket.
- ID của kênh mà người dùng đang tham gia.
Access token nên được lấy từ backend sau khi người dùng đăng nhập. Không nên nhúng token cố định vào mã JavaScript.
Quá trình kết nối gồm bốn bước:
sequenceDiagram
actor Client
participant API as Web API
participant Socket as Socket server
participant App as EzyChat socket app
Client->>API: Yêu cầu socket access token
API-->>Client: Access token
Client->>Socket: Kết nối WebSocket
Client->>Socket: Đăng nhập bằng access token
Socket-->>Client: Đăng nhập thành công
Client->>App: Truy cập app ezychat
App-->>Client: App sẵn sàng
Chỉ nên gửi message sau khi client đã truy cập thành công socket app. Việc kết nối WebSocket thành công chưa có nghĩa là quá trình đăng nhập và truy cập app đã hoàn tất.
Ví dụ khởi tạo client bằng EzyFox JavaScript SDK:
import Ezy from 'ezyfox-es6-client'; const config = new Ezy.ClientConfig(); config.zoneName = 'chat'; config.reconnect.maxReconnectCount = 32768; const clients = Ezy.Clients.getInstance(); const client = clients.newClient(config); const { setup } = client; let chatApp = null;
Khi nhận handshake, client lấy access token và gửi lệnh đăng nhập:
const handshakeHandler = new Ezy.HandshakeHandler(); handshakeHandler.handleLogin = async () => { const { accessToken } = await fetch('/api/socket-access-token') .then((response) => response.json()); client.sendRequest(Ezy.Command.LOGIN, [ 'chat', 'useAccessToken', 'useAccessToken', { accessToken }, ]); }; setup.addDataHandler(Ezy.Command.HANDSHAKE, handshakeHandler);
Sau khi đăng nhập thành công, truy cập ứng dụng
ezychat:const loginSuccessHandler = new Ezy.LoginSuccessHandler(); loginSuccessHandler.handleLoginSuccess = () => { client.sendRequest(Ezy.Command.APP_ACCESS, ['ezychat']); }; setup.addDataHandler(Ezy.Command.LOGIN, loginSuccessHandler);
Lưu lại app khi quá trình truy cập hoàn tất:
const appAccessHandler = new Ezy.AppAccessHandler(); appAccessHandler.postHandle = (app) => { chatApp = app; console.log('EzyChat socket app is ready'); }; setup.addDataHandler(Ezy.Command.APP_ACCESS, appAccessHandler);
Cuối cùng, mở kết nối:
client.connect('wss://chat.example.com/ws');
Gửi tin nhắn
EzyChat sử dụng command
message cho cả yêu cầu gửi từ client và sự kiện được server phát về.Payload tối thiểu gồm nội dung và ID kênh:
const payload = { channelId: 123, message: 'Xin chào!', repliedMessageId: 0, }; chatApp.send('message', payload);
Tùy phiên bản của EzyFox JavaScript SDK, lời gọi có thể mang tên
send hoặc sendRequest:
chatApp.sendRequest('message', payload);
Client nên kiểm tra app đã sẵn sàng và kênh hợp lệ trước khi gửi:
function sendMessage(channelId, message) { const content = (message || '').trim(); if (!chatApp || channelId <= 0 || !content) { return false; } chatApp.send('message', { channelId, message: content, repliedMessageId: 0, }); return true; }
Không cần gửi thông tin người gửi trong payload. Server lấy danh tính người gửi từ phiên socket đã được xác thực. Điều này ngăn client giả mạo
senderId hoặc loại tài khoản.Cấu trúc payload
Một yêu cầu đầy đủ có thể chứa các trường sau:
| Trường | Kiểu | Ý nghĩa |
|---|---|---|
channelId | Number | Kênh nhận tin nhắn |
message | String | Nội dung tin nhắn |
contentType | String | TEXT hoặc JSON; mặc định là TEXT |
repliedMessageId | Number | ID tin nhắn đang được trả lời |
mediaId | Number | Một tài nguyên media chính |
mediaIds | Number[] | Danh sách tài nguyên đính kèm |
links | String[] | Các đường dẫn xuất hiện trong nội dung |
threadId | Number | Thread chứa tin nhắn |
parentId | Number | Tin nhắn cha |
title | String | Tiêu đề tùy chọn |
type | String | Loại tin nhắn nghiệp vụ |
templateId | Number | ID mẫu tin nhắn |
Ví dụ gửi tin nhắn trả lời kèm tệp:
chatApp.send('message', { channelId: 123, message: 'Bạn xem tài liệu này nhé.', contentType: 'TEXT', repliedMessageId: 456, mediaIds: [901, 902], links: ['https://example.com/guide'], });
Với nội dung JSON, giá trị
message vẫn là chuỗi nhưng phải chứa JSON hợp lệ:chatApp.send('message', { channelId: 123, contentType: 'JSON', message: JSON.stringify({ action: 'open-order', orderId: 789, }), repliedMessageId: 0, });
Server từ chối yêu cầu nếu
contentType không được hỗ trợ, JSON không hợp lệ hoặc nội dung vượt quá độ dài cấu hình.Luồng xử lý phía server
Sau khi nhận command
message, server thực hiện tuần tự các bước sau:
sequenceDiagram
actor Sender as Người gửi
participant Server as Socket server
participant Guard as Kiểm tra dữ liệu
participant DB as Cơ sở dữ liệu
participant Push as Bộ phát socket
participant Bot as Chatbot
Sender->>Server: message + payload
Server->>Guard: Kiểm tra nội dung
Guard->>Guard: Kiểm tra thành viên và trạng thái khóa
Guard-->>Server: Hợp lệ
Server->>DB: Lưu tin nhắn và tài nguyên
Server->>DB: Cập nhật tin nhắn cuối của kênh
Server->>DB: Tăng số tin chưa đọc
Server->>DB: Tạo trạng thái SENT
Server->>Push: Phát message tới thành viên
Push-->>Sender: Tin nhắn đã lưu
Push-->>Sender: Cùng command message
Server->>Bot: Yêu cầu trả lời nếu cần
Server xác minh người gửi thực sự là thành viên của
channelId. Nếu không, yêu cầu bị từ chối như một kênh không tồn tại đối với người dùng đó. Server cũng kiểm tra kênh có bị khóa với người gửi hay không.Sau khi hợp lệ, server:
- Lưu tin nhắn và các tài nguyên đi kèm.
- Cập nhật tin nhắn gần nhất của kênh.
- Tăng số lượng chưa đọc của các thành viên khác.
- Tạo trạng thái
SENTcho các thành viên. - Lấy danh sách người nhận từ dữ liệu thành viên của kênh.
- Phát message đã chuẩn hóa tới các phiên socket tương ứng.
- Gửi notification và kích hoạt chatbot nếu kênh có cấu hình phù hợp.
Danh sách người nhận do server tạo từ thành viên trong kênh. Client không được tự chỉ định danh sách username nhận message.
Nhận message được server push
Đăng ký handler cho command
message trước hoặc ngay trong quá trình thiết lập socket app:const chatSetup = setup.setupApp('ezychat'); chatSetup.addDataHandler('message', (app, data) => { handleIncomingMessage(data); });
Một handler cơ bản có thể cập nhật cuộc hội thoại đang mở và số tin chưa đọc:
function handleIncomingMessage(message) { if (message.channelId === activeChannelId) { appendMessage(message); scrollToLatestMessage(); return; } increaseUnreadCount(message.channelId); moveChannelToTop(message.channelId); }
Server cũng phát message trở lại cho chính người gửi. Vì vậy, client có thể đợi phản hồi này rồi mới thêm tin nhắn chính thức vào giao diện.
Cách này có một số lợi ích:
- Giao diện sử dụng đúng ID do server sinh.
- Dữ liệu người gửi đã được server chuẩn hóa.
- Tệp đính kèm và tin nhắn được trả lời có đủ metadata.
- Tin nhắn trên các thiết bị của cùng tài khoản được đồng bộ.
- Client tránh phải ghép dữ liệu tạm với dữ liệu thật.
Nếu giao diện dùng optimistic update, nên gắn một client-generated ID để đối chiếu và thay thế tin nhắn tạm khi phản hồi từ server đến. Nếu không có cơ chế đối chiếu, tin nhắn có thể xuất hiện hai lần.
Gửi trạng thái đang nhập
Trạng thái đang nhập sử dụng command
typing:
chatApp.send('typing', {
channelId: 123,
typing: true,
});
Khi gửi message, client nên tắt trạng thái này:
function sendChatMessage(channelId, message) { chatApp.send('typing', { channelId, typing: false, }); chatApp.send('message', { channelId, message, repliedMessageId: 0, }); }
Phía nhận đăng ký handler tương ứng:
chatSetup.addDataHandler('typing', (app, data) => { if (data.senderUuid === currentUserUuid) { return; } updateTypingStatus(data.channelId, data.typing); });
Sự kiện
typing là dữ liệu tạm thời và không được lưu như một tin nhắn. Giao diện nên tự xóa trạng thái này sau một khoảng timeout để tránh hiển thị mãi khi client mất kết nối.Xử lý lỗi và mất kết nối
Client nên theo dõi ít nhất ba trạng thái:
-
connecting: WebSocket đã mở nhưng chưa truy cập thành công app. -
online: Đã đăng nhập và truy cập app. -
offline: Kết nối thất bại hoặc bị ngắt.
Không nên xóa nội dung người dùng đang nhập khi socket chưa sẵn sàng. Hàm gửi có thể trả về
false để giao diện giữ lại nội dung:
function safeSendMessage(channelId, message) { if (!chatApp) { return false; } chatApp.send('message', { channelId, message, repliedMessageId: 0, }); return true; } const sent = safeSendMessage(activeChannelId, inputValue); if (sent) { clearInput(); } else { showOfflineStatus(); }
Khi reconnect, client cần thực hiện lại toàn bộ handshake, đăng nhập và app access. Sau đó, nên tải lại lịch sử gần nhất qua API để bù những message có thể đã phát trong thời gian mất kết nối. Socket push không thay thế cơ chế truy vấn lịch sử.
Các lỗi thường gặp
Kết nối thành công nhưng không gửi được message
Client có thể mới mở WebSocket nhưng chưa đăng nhập hoặc chưa truy cập app
ezychat. Hãy chỉ bật nút gửi sau sự kiện app access thành công.Server trả lỗi kênh không tồn tại
Người dùng hiện tại không phải thành viên của kênh, hoặc client đang giữ một
channelId cũ.Server trả lỗi kênh bị khóa
Thiết lập của người dùng trong kênh không cho phép gửi thêm tin nhắn.
Tin nhắn JSON bị từ chối
Khi
contentType là JSON, trường message phải là một chuỗi JSON hợp lệ.Người gửi không thấy tin nhắn vừa gửi
Client cần đăng ký handler cho command
message. Phản hồi chính thức cũng được server phát qua command này.Tin nhắn xuất hiện hai lần
Giao diện vừa thêm message ngay khi bấm gửi, vừa thêm lần nữa khi nhận socket event. Hãy đợi dữ liệu từ server hoặc triển khai cơ chế đối chiếu tin nhắn tạm.
Mất message sau khi reconnect
Socket không tự phát lại toàn bộ sự kiện cũ. Sau reconnect, hãy đồng bộ lịch sử gần nhất từ API.
Mẫu tích hợp hoàn chỉnh
import Ezy from 'ezyfox-es6-client'; export function createChatSocket({ websocketUrl, getAccessToken, onReady, onMessage, onDisconnected, }) { const config = new Ezy.ClientConfig(); config.zoneName = 'chat'; config.reconnect.maxReconnectCount = 32768; const client = Ezy.Clients.getInstance().newClient(config); const { setup } = client; let app = null; const handshakeHandler = new Ezy.HandshakeHandler(); handshakeHandler.handleLogin = async () => { const accessToken = await getAccessToken(); client.sendRequest(Ezy.Command.LOGIN, [ 'chat', 'useAccessToken', 'useAccessToken', { accessToken }, ]); }; const loginHandler = new Ezy.LoginSuccessHandler(); loginHandler.handleLoginSuccess = () => { client.sendRequest(Ezy.Command.APP_ACCESS, ['ezychat']); }; const appAccessHandler = new Ezy.AppAccessHandler(); appAccessHandler.postHandle = (accessedApp) => { app = accessedApp; onReady?.(); }; const disconnectionHandler = new Ezy.DisconnectionHandler(); disconnectionHandler.preHandle = () => { app = null; onDisconnected?.(); }; setup.addDataHandler(Ezy.Command.HANDSHAKE, handshakeHandler); setup.addDataHandler(Ezy.Command.LOGIN, loginHandler); setup.addDataHandler(Ezy.Command.APP_ACCESS, appAccessHandler); setup.addEventHandler( Ezy.EventType.DISCONNECTION, disconnectionHandler, ); const chatSetup = setup.setupApp('ezychat'); chatSetup.addDataHandler('message', (socketApp, data) => { onMessage?.(data); }); return { connect() { client.connect(websocketUrl); }, sendMessage({ channelId, message, repliedMessageId = 0, mediaIds = [], links = [], }) { if (!app || channelId <= 0) { return false; } app.send('message', { channelId, message, repliedMessageId, ...(mediaIds.length > 0 && { mediaIds }), ...(links.length > 0 && { links }), }); return true; }, }; }
Cách sử dụng:
const socket = createChatSocket({ websocketUrl: 'wss://chat.example.com/ws', getAccessToken: async () => { const response = await fetch('/api/socket-access-token'); const data = await response.json(); return data.accessToken; }, onReady: () => { console.log('Socket is ready'); }, onMessage: (message) => { console.log('Received message:', message); }, onDisconnected: () => { console.log('Socket disconnected'); }, }); socket.connect(); socket.sendMessage({ channelId: 123, message: 'Xin chào!', });
Lưu ý khi triển khai production
Dùng
wss:// khi website chạy qua HTTPS. Access token cần có thời hạn phù hợp và phải được cấp từ backend đã xác thực.Không dùng socket event làm nguồn dữ liệu duy nhất. Client vẫn cần API lấy danh sách kênh và lịch sử message để khởi tạo giao diện cũng như đồng bộ lại sau reconnect.
Server chỉ push tới các phiên socket mà nó có thể định tuyến. Khi triển khai nhiều socket node, hạ tầng cần có cơ chế chia sẻ session hoặc phân phối sự kiện liên node; logic gửi message ở tầng nghiệp vụ không tự biến các kết nối cục bộ thành message bus cho toàn cluster.