Websocket - Chỉnh sửa document real-time với Golang
I. Giới thiệu
Dạo gần đây, thấy sếp mình đang viết một web app để cung cấp cho khách hàng vẽ, xem, trao đổi và trình bày ý tưởng của mình với người khác. Hệ thống theo tưởng tượng của mình là nó sẽ na ná google docs nhưng chắc chắn là sẽ không xịn bằng =))
Tối cuối tuần, mình rảnh tay thay vì lướt linh tinh thì ngồi implement thử một app simple để giải quyết basic nhất bài toán này. Ở bài viết này, mình sẽ hướng dẫn từ ý tưởng cho tới cách xử lý.
Định nghĩa chút về WebSocket
WebSocket là một giao thức truyền thông hai chiều qua một kết nối TCP duy nhất. Nó cho phép giao tiếp giữa trình duyệt và máy chủ diễn ra trong thời gian thực, mà không cần phải mở lại kết nối mỗi lần gửi dữ liệu. WebSocket rất hữu ích cho các ứng dụng cần cập nhật thông tin liên tục như chat, trò chơi trực tuyến, bảng điều khiển thời gian thực, và nhiều hơn nữa.
II. Ý tưởng
Mình vẽ nhanh nên có sai gạch đá nhẹ thôi nhé
Trên hình là ý tưởng với 3 flows. Tạo channel và trả về link join channel, khởi tạo mỗi khi người dùng join và lưu dữ liệu gửi tới những người đang xem trong channel.
Chi tiết từng bước
- Mỗi khi cần trình bày thì host sẽ tạo 1 đường link có chứa thông tin channel và gửi cho tất cả mọi người cùng join.
- Người dùng click link và join vào channel → hệ thống sẽ khởi tạo 1 connection ws tương ứng với uuid mà người dùng gửi lên ws server.
- Mỗi khi host hay bất kì 1 ai thay đổi dữ liệu → gọi 1 api lên web app server → write dữ liệu này vào các connection ws có trong channel.
Như nói ở trên, đây chỉ là ý tưởng cơ bản nhất → để sử dụng thực tế thì phải giải quyết rất nhiều bài toán nghiệp vụ nữa đặc biệt là bài toán về conflict khi nhiều người có trong channel sửa dữ liệu.
III. Implement
1. Back end
Ở BE mình dùng thư viện WebSocket của Go để xử lý. Mình sẽ tạo ra 1 biến để lưu thông tin các channel dưới dạng là 1 map và trong map đó sẽ lại là 1 map lưu lại các connections của channel đó =))
var mapWsConn = make(map[string]map[string]*websocket.Conn)
func main() {
http.HandleFunc("/index", LoadPage)
http.HandleFunc("/ws", InitWebsocket)
http.HandleFunc("/ws/close", CloseWebsocket)
http.HandleFunc("/save", SaveData)
log.Fatal(http.ListenAndServe(":3000", nil))
}
Server gồm có 4 APIs: LoadPage, InitWebsocket, CloseWebsocket, SaveData
func LoadPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
path, err := os.Getwd()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
content, err := os.ReadFile(path + "/docs-editor-using-websocket/index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, err = fmt.Fprintf(w, "%s", content)
if err != nil {
return
}
}
func InitWebsocket(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
channel := r.URL.Query().Get("channel")
uuid := r.URL.Query().Get("uuid")
if r.Header.Get("Origin") != "http://"+r.Host {
http.Error(w, "the origin is invalid", http.StatusInternalServerError)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(mapWsConn[channel]) == 0 {
mapWsConn[channel] = make(map[string]*websocket.Conn)
}
mapWsConn[channel][uuid] = conn
}
func CloseWebsocket(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
channel := r.FormValue("channel")
uuid := r.FormValue("uuid")
if _, ok := mapWsConn[channel]; !ok {
http.Error(w, "the channel is not found", http.StatusInternalServerError)
return
}
if _, ok := mapWsConn[channel][uuid]; !ok {
http.Error(w, "the uuid is not found", http.StatusInternalServerError)
return
}
err := mapWsConn[channel][uuid].Close()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
delete(mapWsConn[channel], uuid)
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte("success"))
if err != nil {
return
}
}
func SaveData(w http.ResponseWriter, r *http.Request) {
channel := r.FormValue("channel")
uuid := r.FormValue("uuid")
data := r.FormValue("data")
if _, ok := mapWsConn[channel]; !ok {
http.Error(w, "the channel is not found", http.StatusInternalServerError)
return
}
for key, ws := range mapWsConn[channel] {
if key != uuid {
err := ws.WriteJSON(map[string]interface{}{
"data": data,
})
if err != nil {
continue
}
}
}
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("success"))
if err != nil {
return
}
}
2. Front end
Về giao diện thì mình clone trên mạng cho nhanh còn phần JavaScript thì mình sẽ tự code Mình lấy html, css ở link này. Thank bro!
https://codepen.io/fajarnurwahid/pen/NWvxeXj
let ws;
if (window.WebSocket === undefined) {
console.log("Your browser does not support WebSockets")
} else {
ws = initWS();
}
function initWS() {
// close ws before init new ws
closeWs(localStorage.getItem("uuid"))
let uuid = self.crypto.randomUUID()
localStorage.setItem("uuid", uuid)
let socket = new WebSocket("ws://" + window.location.host + "/ws" + window.location.search + "&uuid=" + uuid)
socket.onopen = function () {
console.log("Socket is open")
};
// receive data from server
socket.onmessage = function (e) {
document.getElementById("content").innerHTML = JSON.parse(e.data).data
}
// close socket
socket.onclose = function () {
console.log("Socket closed")
closeWs(localStorage.getItem("uuid"))
}
return socket;
}
Khởi tạo connection ws trước khi tạo connection mình sẽ gửi lên server uuid cũ được lưu trong local storage trước để close connection cũ.
UUID sẽ tự bằng thư viện crypto của JavaScript
Đoạn code này cũng xử lý các event open, close và nhận message từ WebSocket server để hiển thị lên giao diện cho người dùng
// api close ws
async function closeWs(uuid) {
const formData = new FormData();
if (!!!uuid) {
console.log("uuid is not found")
return
}
formData.append("channel", window.location.search.split("=")[1]);
formData.append("uuid", uuid);
const requestOptions = {
method: "POST",
body: formData,
redirect: "follow"
};
await fetch("http://" + window.location.host + "/ws/close", requestOptions)
.then((response) => response.text())
.then((result) => console.log(result))
.catch((error) => console.error(error));
}
Đoạn script để close connection WebSocket cũ
// onchange text editor to save data
const div = document.getElementById('content');
let timeout;
div.addEventListener('input', function() {
clearTimeout(timeout);
timeout = setTimeout(async function () {
await saveData()
}, 500)
})
// api save data
async function saveData() {
const formData = new FormData();
formData.append("channel", window.location.search.split("=")[1]);
formData.append("uuid", localStorage.getItem("uuid"));
formData.append("data", document.getElementById("content").innerHTML);
const requestOptions = {
method: "POST",
body: formData,
redirect: "follow"
};
await fetch("http://" + window.location.host + "/save", requestOptions)
.then((response) => response.text())
.then((result) => console.log(result))
.catch((error) => console.error(error));
}
Save lại dữ liệu mỗi khi thay đổi. Ở đây, mình set timeout cứ sau 500ms người dùng dừng bấm thì sẽ save lại dữ liệu lên server và server sẽ gửi dữ liệu này cho các connection ws khác.
IV. Kết quả
Dưới đây là video mình demo nhé
V. Nguồn
- Source code HTML: https://codepen.io/fajarnurwahid/pen/NWvxeXj
- Source code: https://github.com/nguyenvantuan2391996/example-code/tree/master/docs-editor-using-websocket
- Link bài viết trên blog của mình: https://tuannguyenhust.hashnode.dev/websocket-chinh-sua-document-real-time-voi-golang
All rights reserved