post-image

Xây dựng ứng dụng chat Realtime sử dụng WebSocket

Tổng quan

Bạn có muốn làm một ứng dụng Chat giống như Messenger của Facebook, có thể có nhiều người cùng truy cập vào cuộc hội thoại để nói chuyện với nhau hay không ? Nếu bạn muốn làm một ứng dụng Chat như vậy thì bài viết này có lẽ sẽ có ích với bạn. Bài viết này mình sẽ hướng dẫn mọi người cách tạo một ứng dụng Chat Realtime bằng WebSocket trong SpringBoot cùng tham khảo nhé.

Chuẩn bị

Khái niệm WebSocket và cấu trúc của nó đã được mình giới thiệu trong bài viết trước. Mọi người có thể truy cập đường link này để xem lại. Ở đây mình sẽ giới thiệu qua với mọi người về khái niệm STOMP.

STOMP (Streaming Text Oriented Messaging Protocol): (Giao thức luồng văn bản theo hướng tin nhắn) là một giao thức truyền thông, một nhánh của WebSocket. Khi client và server liên lạc với nhau theo giao thức này chúng sẽ chỉ gửi cho nhau các dữ liệu dạng tin nhắn văn bản. Mối quan hệ giữa STOMP và WebSocket cũng gần giống mối quan hệ giữ HTTP và TCP.
Ngoài ra STOMP cũng đưa ra cách thức cụ thể để giải quyết các chức năng sau:
 
Chức năng Mô tả
Connect Đưa ra cách thức làm sao để client và server có thể kết nối với nhau.
Subscribe Đưa ra cách thức để client đăng ký (subscribe) nhận tin nhắn của một chủ đề nào đó.
Unsubscribe Đưa ra cách thức để client hủy đăng ký (unsubscribe) nhận tin nhắn của một chủ đề nào đó.
Send Làm sao client gửi nhắn gửi tới server.
Message Làm sao gửi tin nhắn gửi từ server đến client.
Transaction management Quản lý giao dịch trong quá trình truyền dữ liệu (BEGIN, COMMIT, ROLLBACK,…)

Một số khái niệm liên quan

MessageBroker

MessageBroker giống như một người trung gian nó sẽ tiếp nhận những tin nhắn trước khi chuyển các tin nhắn tới các địa chỉ khác cần thiết.

Mô tả cấu trúc của MessageBroker:

MessageBroker phơi bầy ra một endpoint (Điểm cuối) để client có thể liên lạc và hình thành một kết nối. Để liên lạc client sử dụng thư viện SockJS để làm việc này.

Đồng thời MessageBroker cũng phơi bẩy ra 2 loại điểm đến (destination)  (1) & (2)

  • Điểm đến (1) là các chủ đề (topic) mà client có thể “đăng ký theo dõi” (subscribe), khi một chủ đề có tin nhắn, các tin nhắn sẽ được gửi đến cho những client đã đăng ký chủ đề này.
  • Điểm đến (2) là các nơi mà client có thể gửi tin nhắn tới WebSocket Server.

MessageBroker phơi bầy ra một endpoint (Điểm cuối) để client có thể liên lạc và hình thành một kết nối. Để liên lạc client sử dụng thư viện SockJS để làm việc này.

Đồng thời MessageBroker cũng phơi bẩy ra 2 loại điểm đến (destination)  (1) & (2)

  • Điểm đến (1) là các chủ đề (topic) mà client có thể “đăng ký theo dõi” (subscribe), khi một chủ đề có tin nhắn, các tin nhắn sẽ được gửi đến cho những client đã đăng ký chủ đề này.
  • Điểm đến (2) là các nơi mà client có thể gửi tin nhắn tới WebSocket Server.

Cài đặt thư viện

Mọi người tạo ứng dụng SpringBoot và thêm một số thư viện như sau:

dependencies {

    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}Code language: JavaScript (javascript)

Chuẩn bị

Model

Chúng ta sẽ tạo một class có tên là Chat như sau:

Chat.java

package com.example.demo.model;

import lombok.Data;

@Data
public class Chat {
    private MessageType type;
    private String content;
    private String sender;

    public enum MessageType {
        CHAT, JOIN, LEAVE
    }

    public MessageType getType() {
        return type;
    }

    public void setType(MessageType type) {
        this.type = type;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getSender() {
        return sender;
    }

    public void setSender(String sender) {
        this.sender = sender;
    }
}
Code language: JavaScript (javascript)

Listener

Sau khi tạo xong class Chat.java chúng ta sẽ tiến hành tạo class WebSocketEventListener

WebSocketEventListener.java

package com.example.demo.listener;

import com.example.demo.model.Chat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
public class WebSocketEventListener {
    private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);
    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event){
        logger.info("Received a new web socket connection");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        String username = (String) headerAccessor.getSessionAttributes().get("username");

        if(username != null) {
            logger.info("User Disconnected : " + username);

            Chat chat = new Chat();
            chat.setType(Chat.MessageType.LEAVE);
            chat.setSender(username);

            messagingTemplate.convertAndSend("/topic/publicChatRoom", chat);
        }
    }
}
Code language: JavaScript (javascript)

Controller

Chúng ta sẽ tiến hành tạo 2 Controller lần lượt là MainController và WebSocketController. MainController sẽ chứa các mapping đến trang login và để logout tài khoản. WebSocketController sẽ chuyển người dùng tới hội thoại chat sau khi login thành công.

MainController.java

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;

@Controller
public class MainController {

    @RequestMapping("/")
    public String index(HttpServletRequest request, Model model) {
        String username = (String) request.getSession().getAttribute("username");

        if (username == null || username.isEmpty()) {
            return "redirect:/login";
        }
        model.addAttribute("username", username);

        return "chat";
    }

    @RequestMapping(path = "/login", method = RequestMethod.GET)
    public String showLoginPage() {
        return "login";
    }

    @RequestMapping(path = "/login", method = RequestMethod.POST)
    public String doLogin(HttpServletRequest request, @RequestParam(defaultValue = "") String username) {
        username = username.trim();

        if (username.isEmpty()) {
            return "login";
        }
        request.getSession().setAttribute("username", username);

        return "redirect:/";
    }

    @RequestMapping(path = "/logout")
    public String logout(HttpServletRequest request) {
        request.getSession(true).invalidate();

        return "redirect:/login";
    }

}
Code language: JavaScript (javascript)

WebSocketController.java

package com.example.demo.controller;

import com.example.demo.model.Chat;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

@Controller
public class WebSocketController {

    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/publicChatRoom")
    public Chat sendMessage(@Payload Chat chat) {
        return chat;
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/publicChatRoom")
    public Chat addUser(@Payload Chat chat, SimpMessageHeaderAccessor headerAccessor) {
        // Add username in web socket session
        headerAccessor.getSessionAttributes().put("username", chat.getSender());
        return chat;
    }

}Code language: JavaScript (javascript)

Cấu hình WebSocket 

Cấu hình MessageBroker

Chúng ta sẽ cấu hình MessageBroker bằng đoạn mã JavaScript như sau:

var socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
 
stompClient.connect({}, onConnected, onError);Code language: JavaScript (javascript)

SockJS là một thư viện của JavaScript và chúng ta chỉ cần sử dụng nó

Cấu hình HTTP Handshake

Chúng ta sẽ tạo một class có tên là HttpHandshakeInterceptor. Lớp này sẽ được sử dụng để xử lý các sự kiện trước và sau khi WebSocket bắt tay với HTTP.

HttpHandshakeInterceptor.java

package com.example.demo.intercepter;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import javax.servlet.http.HttpSession;
import java.util.Map;

@Component
public class HttpHandshakeInterceptor implements HandshakeInterceptor {
    private static final Logger logger = LoggerFactory.getLogger(HttpHandshakeInterceptor.class);

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        logger.info("Call before Handshake");
        if (request instanceof ServerHttpRequest) {
            ServletServerHttpRequest serverHttpRequest = (ServletServerHttpRequest) request;
            HttpSession session = serverHttpRequest.getServletRequest().getSession();
            attributes.put("sessionId", session.getId());
        }
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
        logger.info("Call after handshake");
    }
}
Code language: JavaScript (javascript)

Cấu hình WebSocket

Để cấu hình WebSocket chúng ta sẽ tạo một class có tên WebSocketConfiguration như sau:

WebSocketConfiguration.java

package com.example.demo.configuration;

import com.example.demo.intercepter.HttpHandshakeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
    @Autowired
    private HttpHandshakeInterceptor handshakeInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS().setInterceptors(handshakeInterceptor);
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");
    }
}
Code language: JavaScript (javascript)

Annotation @EnableWebSocketMessageBroker được sử dụng để enable WebSocket Server.

Giao diện hiển thị

HTML

Trang login

login.html

<!DOCTYPE html>
<html>
<head>
    <title>Login</title>
    <link rel="stylesheet" href="/css/main.css" />
</head>
<body>
<div id="login-container">
    <h1 class="title">Enter your username</h1>
    <form id="loginForm" name="loginForm" method="POST">
        <input type="text" name="username" />
        <button type="submit">Login</button>
    </form>
</div>
</body>
</html>Code language: HTML, XML (xml)

Phòng chat

chat.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Spring Boot WebSocket</title>
    <link rel="stylesheet" th:href="@{/css/main.css}" />

    <!-- https://cdnjs.com/libraries/sockjs-client -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
    <!-- https://cdnjs.com/libraries/stomp.js/ -->
    <script  src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>

</head>
<body>
<div id="chat-container">
    <div class="chat-header">
        <div class="user-container">
            <span id="username" th:utext="${username}"></span>
            <a th:href="@{/logout}">Logout</a>
        </div>
        <h3>Spring WebSocket Chat Demo</h3>
    </div>

    <hr/>

    <div id="connecting">Connecting...</div>
    <ul id="messageArea">
    </ul>
    <form id="messageForm" name="messageForm">
        <div class="input-message">
            <input type="text" id="message" autocomplete="off"
                   placeholder="Type a message..."/>
            <button type="submit">Send</button>
        </div>
    </form>
</div>

<script th:src="@{/js/main.js}"></script>

</body>
</html>Code language: HTML, XML (xml)

CSS + JS

main.css

* {
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
}

html, body {
    height: 100%;
    overflow: hidden;
}

#login-page {
    text-align: center;
}

.nickname  {
    color:blue;margin-right:20px;
}
.hidden {
    display: none;
}

.user-container {
    float: right;
    margin-right:5px;
}

#login-container {
    background: #f4f6f6 ;
    border: 2px solid #ccc;
    width: 100%;
    max-width: 500px;
    display: inline-block;
    margin-top: 42px;
    vertical-align: middle;
    position: relative;
    padding: 35px 55px 35px;
    min-height: 250px;
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    margin: 0 auto;
    margin-top: -160px;
}


#chat-container {
    position: relative;
    height: 100%;
}

#chat-container #messageForm {
    padding: 20px;
}

#chat-container {
    border: 2px solid #d5dbdb;
    background-color:  #d5dbdb ;
    max-width: 500px;
    margin-left: auto;
    margin-right: auto;

    margin-top: 30px;
    height: calc(100% - 60px);
    max-height: 600px;
    position: relative;

}

#chat-container ul {
    list-style-type: none;
    background-color: #fff;
    margin: 0;
    overflow: auto;
    overflow-y: scroll;
    padding: 0 20px 0px 20px;
    height: calc(100% - 150px);
}

#chat-container #messageForm {
    padding: 20px;
}

#chat-container ul li {
    line-height: 1.5rem;
    padding: 10px 20px;
    margin: 0;
    border-bottom: 1px solid #f4f4f4;
}

#chat-container ul li p {
    margin: 0;
}

#chat-container .event-message {
    width: 100%;
    text-align: center;
    clear: both;
}

#chat-container .event-message p {
    color: #777;
    font-size: 14px;
    word-wrap: break-word;
}

#chat-container .chat-message {
    position: relative;
}

#messageForm .input-message  {
    float: left;
    width: calc(100% - 85px);
}

.connecting {
    text-align: center;
    color: #777;
    width: 100%;
}Code language: CSS (css)

main.js

'use strict';


var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('#connecting');

var stompClient = null;
var username = null;


function connect() {
    username = document.querySelector('#username').innerText.trim();

    var socket = new SockJS('/ws');
    stompClient = Stomp.over(socket);

    stompClient.connect({}, onConnected, onError);
}

// Connect to WebSocket Server.
connect();

function onConnected() {
    // Subscribe to the Public Topic
    stompClient.subscribe('/topic/publicChatRoom', onMessageReceived);

    // Tell your username to the server
    stompClient.send("/app/chat.addUser",
        {},
        JSON.stringify({sender: username, type: 'JOIN'})
    )

    connectingElement.classList.add('hidden');
}


function onError(error) {
    connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
    connectingElement.style.color = 'red';
}


function sendMessage(event) {
    var messageContent = messageInput.value.trim();
    if(messageContent && stompClient) {
        var chatMessage = {
            sender: username,
            content: messageInput.value,
            type: 'CHAT'
        };
        stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
        messageInput.value = '';
    }
    event.preventDefault();
}


function onMessageReceived(payload) {
    var message = JSON.parse(payload.body);

    var messageElement = document.createElement('li');

    if(message.type === 'JOIN') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' joined!';
    } else if (message.type === 'LEAVE') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' left!';
    } else {
        messageElement.classList.add('chat-message');
        var usernameElement = document.createElement('strong');
        usernameElement.classList.add('nickname');
        var usernameText = document.createTextNode(message.sender);
        var usernameText = document.createTextNode(message.sender);
        usernameElement.appendChild(usernameText);
        messageElement.appendChild(usernameElement);
    }

    var textElement = document.createElement('span');
    var messageText = document.createTextNode(message.content);
    textElement.appendChild(messageText);

    messageElement.appendChild(textElement);

    messageArea.appendChild(messageElement);
    messageArea.scrollTop = messageArea.scrollHeight;
}


messageForm.addEventListener('submit', sendMessage, true);Code language: JavaScript (javascript)

Leave a Reply

Your email address will not be published. Required fields are marked *