Tạo ứng dụng Chat đơn giản với Spring Boot và Websocket
1. WebSocket là gì?
WebSocket là một giao thức truyền thông (communication protocol), nó giúp thành lập một kênh liên lạc 2 chiều (two-way communication channel) giữa client và server. Một giao thức truyền thông mà bạn quen thuộc đó là HTTP, và bây giờ chúng ta sẽ so sánh đặc điểm của 2 giao thức này:
HTTP (Hypertext Transfer Protocol): Là một giao thức request-response (Yêu cầu - Đáp ứng). Client (Trình duyệt) muốn một cái gì đó, nó gửi yêu cầu tới Server, và Server đáp ứng yêu cầu đó. HTTP là một giao thức truyền thông một chiều, mục đích ở đây là để giải quyết "Làm thế nào để tạo một yêu cầu tại client, và làm thế nào để đáp ứng yêu cầu của client", và đây là lý do để HTTP tỏa sáng.
WebSocket: Không phải là giao thức request-response (Yêu cầu - Đáp ứng), nơi mà chỉ Client có mới thể gửi yêu cầu tới Server. Một khi kết nối với giao thức WebSocket được thiết lập, client & server có thể gửi dữ liệu tới cho nhau, cho tới khi kết nối ở tầng dưới là TCP được đóng lại. WebSocket về cơ bản rất giống với khái niệm TCP Socket, sự khác biệt là WebSocket được tạo ra để sử dụng cho các ứng dụng Web.
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,...) |
2. Mục tiêu của bài học
Trong bài học này tôi sẽ hướng dẫn bạn tạo một ứng dụng Chat đơn giản, sử dụng Spring Boot và WebSocket. Ứng dụng Chat có lẽ là ứng dụng kinh điển và dễ hiểu nhất đề tìm hiểu về WebSocket.
Đây là hình ảnh xem trước của ứng dụng này:
3. Tạo dự án Spring Boot
Trên Eclipse tạo một dự án Spring Boot:
Nội dung đầy đủ của tập tin pom.xml:
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.o7planning</groupId>
<artifactId>SpringBootWebSocket</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>SpringBootWebSocket</name>
<description>Spring Boot + WebSocket Example</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.2.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
SpringBootWebSocketApplication.java
package org.o7planning.springbootwebsocket;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringBootWebSocketApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootWebSocketApplication.class, args);
}
}
4. Cấu hình WebSocket
Có một vài khái niệm liên quan tới WebSocket mà bạn cần hiểu về chúng.
MessageBroker
MessageBroker là một chương trình trung gian, nó tiếp nhận các tin nhắn được gửi đến trước khi phân phát tới các địa chỉ cần thiết. Vì vậy bạn cần nói với Spring bật (enable) chương trình này cho nó hoạt động.
Hình dưới đây 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.
** javascript code **
var socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, onConnected, onError);
// See more in main.js file.
Đồ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.
SockJS
Không phải tất cả các trình duyệt đều hỗ trợ giao thức WebSocket. Vì vậy SockJS là một tùy chọn dự phòng (fallback option), nó sẽ được kích hoạt cho các trình duyệt không hỗ trợ WebSocket. SockJS chỉ đơn giản là một thư viện JavaScript.
WebSocketConfig.java
package org.o7planning.springbootwebsocket.config;
import org.o7planning.springbootwebsocket.interceptor.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 WebSocketConfig 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");
}
}
@EnableWebSocketMessageBroker
Annotation này nói với Spring rằng hãy bật (enable) WebSocket Server.
HTTP Handshake
Cơ sở hạ tầng hiện có gây ra các hạn chế cho việc triển khai WebSocket, thông thường HTTP đã sử dụng cổng 80 & 443, vì vậy WebSocket phải sử dụng các cổng khác, trong khi đó hầu hết các Firewall (Tường lửa) chặn các cổng khác 80 & 443, sử dụng các Proxy (Ủy quyền) cũng có nhiều vấn đề xẩy ra. Vì vậy để có thể dễ dàng triển khai, WebSocket sử dụng HTTP Handshake (Cái bắt tay với HTTP) để nâng cấp. Điều đó có nghĩa là trong lần đầu tiên client gửi một yêu cầu dựa trên HTTP tới server, nói với server rằng đó không phải là HTTP, hãy nâng cấp lên WebSocket, và như vậy chúng hình thành một kết nối.
Lớp HttpHandshakeInterceptor được sử dụng để xử lý các sự kiện ngay trước và sau khi WebSocket bắt tay với HTTP. Bạn có thể làm gì đó trong lớp này.
HttpHandshakeInterceptor.java
package org.o7planning.springbootwebsocket.interceptor;
import java.util.Map;
import javax.servlet.http.HttpSession;
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;
@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 beforeHandshake");
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
HttpSession session = servletRequest.getServletRequest().getSession();
attributes.put("sessionId", session.getId());
}
return true;
}
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception ex) {
logger.info("Call afterHandshake");
}
}
5. Listener, Model, Controller
ChatMessage.java
package org.o7planning.springbootwebsocket.model;
public class ChatMessage {
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;
}
}
WebSocketEventListener.java
package org.o7planning.springbootwebsocket.listener;
import org.o7planning.springbootwebsocket.model.ChatMessage;
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);
ChatMessage chatMessage = new ChatMessage();
chatMessage.setType(ChatMessage.MessageType.LEAVE);
chatMessage.setSender(username);
messagingTemplate.convertAndSend("/topic/publicChatRoom", chatMessage);
}
}
}
MainController.java
package org.o7planning.springbootwebsocket.controller;
import javax.servlet.http.HttpServletRequest;
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;
@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";
}
}
WebSocketController.java
package org.o7planning.springbootwebsocket.controller;
import org.o7planning.springbootwebsocket.model.ChatMessage;
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 ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
return chatMessage;
}
@MessageMapping("/chat.addUser")
@SendTo("/topic/publicChatRoom")
public ChatMessage addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
// Add username in web socket session
headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
return chatMessage;
}
}
6. Html, Javascript, Css
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>
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>
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%;
}
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);
Các hướng dẫn Spring Boot
- Cài đặt Spring Tool Suite cho Eclipse
- Hướng dẫn lập trình Spring cho người mới bắt đầu
- Hướng dẫn lập trình Spring Boot cho người mới bắt đầu
- Các thuộc tính thông dụng của Spring Boot
- Hướng dẫn sử dụng Spring Boot và Thymeleaf
- Hướng dẫn sử dụng Spring Boot và FreeMarker
- Hướng dẫn sử dụng Spring Boot và Groovy
- Hướng dẫn sử dụng Spring Boot và Mustache
- Hướng dẫn sử dụng Spring Boot và JSP
- Hướng dẫn sử dụng Spring Boot, Apache Tiles, JSP
- Sử dụng Logging trong Spring Boot
- Giám sát ứng dụng với Spring Boot Actuator
- Tạo ứng dụng web đa ngôn ngữ với Spring Boot
- Sử dụng nhiều ViewResolver trong Spring Boot
- Sử dụng Twitter Bootstrap trong Spring Boot
- Hướng dẫn và ví dụ Spring Boot Interceptor
- Hướng dẫn sử dụng Spring Boot, Spring JDBC và Spring Transaction
- Hướng dẫn và ví dụ Spring JDBC
- Hướng dẫn sử dụng Spring Boot, JPA và Spring Transaction
- Hướng dẫn sử dụng Spring Boot và Spring Data JPA
- Hướng dẫn sử dụng Spring Boot, Hibernate và Spring Transaction
- Tương tác Spring Boot, JPA và cơ sở dữ liệu H2
- Hướng dẫn sử dụng Spring Boot và MongoDB
- Sử dụng nhiều DataSource với Spring Boot và JPA
- Sử dụng nhiều DataSource với Spring Boot và RoutingDataSource
- Tạo ứng dụng Login với Spring Boot, Spring Security, Spring JDBC
- Tạo ứng dụng Login với Spring Boot, Spring Security, JPA
- Tạo ứng dụng đăng ký tài khoản với Spring Boot, Spring Form Validation
- Ví dụ OAuth2 Social Login trong Spring Boot
- Chạy các nhiệm vụ nền theo lịch trình trong Spring
- Ví dụ CRUD Restful Web Service với Spring Boot
- Ví dụ Spring Boot Restful Client với RestTemplate
- Ví dụ CRUD với Spring Boot, REST và AngularJS
- Bảo mật Spring Boot RESTful Service sử dụng Basic Authentication
- Bảo mật Spring Boot RESTful Service sử dụng Auth0 JWT
- Ví dụ Upload file với Spring Boot
- Ví dụ Download file với Spring Boot
- Ví dụ Upload file với Spring Boot và jQuery Ajax
- Ví dụ Upload file với Spring Boot và AngularJS
- Tạo ứng dụng Web bán hàng với Spring Boot, Hibernate
- Hướng dẫn và ví dụ Spring Email
- Tạo ứng dụng Chat đơn giản với Spring Boot và Websocket
- Triển khai ứng dụng Spring Boot trên Tomcat Server
- Triển khai ứng dụng Spring Boot trên Oracle WebLogic Server
- Cài đặt chứng chỉ SSL miễn phí Let's Encrypt cho Spring Boot
- Cấu hình Spring Boot chuyển hướng HTTP sang HTTPS
- Tìm nạp dữ liệu với Spring Data JPA DTO Projections
Show More