Gửi STOMP message tới một user cụ thể với Spring WebSocket

Bài viết được sự cho phép của tác giả Nguyễn Hữu Khanh

Trong bài viết trước, mình đã hướng dẫn các bạn cách hiện thực WebSocket sử dụng Spring WebSocket. Trong ví dụ của bài viết đó, tất cả các user subscribe vào “/topic/messages” sẽ nhận được các message được publish vào topic này. Trong trường hợp các bạn muốn chỉ gửi đến một user cụ thế nào đó thì làm thế nào? Trong bài viết này, mình sẽ hướng dẫn các bạn cách gửi STOMP message tới một user cụ thể với Spring WebSocket các bạn nhé!

  Discord đã lưu trữ hàng tỉ messages mỗi ngày như thế nào
  Giới thiệu JMS – Java Message Services

Mình cũng tạo một Spring Boot project với Web và WebSocket dependency như bài viết trước.

Kết quả như sau:

Gửi STOMP message tới một user cụ thể với Spring WebSocket

Mình cũng khai báo để sử dụng WebJars với JQuerySocketJS client và Stomp WebSocket dependencies như sau:

<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>jquery</artifactId>
<version>3.6.0</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>

Tương tự như bài viết trước, mình cũng tạo mới một class để cấu hình WebSocket như sau:

package com.huongdanjava.springboot.websocket;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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 {

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

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/users");
}
}

Điểm khác biệt ở đây:

  • Thứ nhất là mình để giá trị của tham số trong phương thức enableSimpleBroker() là “queue”, mục đích là để thể hiện mục đích của ví dụ thôi, point-to-point, chứ tên gì cũng được nha các bạn!
  • Cái chính mà các bạn cần biết là để send một message tới user cụ thể nào đó, chúng ta sẽ send message tới 1 endpoint mặc định bắt đầu với “/user”, ví dụ như “/user/queue/messages” chẳng hạn. Chúng ta có thể thay đổi prefix mặc định này bằng cách sử dụng phương thức setUserDestinationPrefix() của đối tượng MessageBrokerRegistry như mình làm ở trên. Trong ví dụ của mình thì bây giờ, chúng ta cần message tới endpoint “/users/queue/messages” chẳng hạn.

Để handle message từ client gửi tới STOMP endpoint của WebSocket server, mình cũng tạo mới một class MessageController với nội dung như sau:

package com.huongdanjava.springboot.websocket;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

@Controller
public class MessageController {

@Autowired
private SimpMessagingTemplate simpMessagingTemplate;

@MessageMapping("/hello")
public void send(SimpMessageHeaderAccessor sha, @Payload String username) {
String message = "Hello from " + sha.getUser().getName();

simpMessagingTemplate.convertAndSendToUser(username, "/queue/messages", message);
}
}

Mình cũng sử dụng @MessageMapping để định nghĩa mapping method handle cho request “/hello” tới WebSocket server.

Điểm khác biệt ở đây là chúng ta không sử dụng annotation @SendTo như bài viết trước nữa mà sẽ sử dụng class SimpMessagingTemplate với phương thức convertAndSendToUser() để send message tới một user cụ thể.

Có nhiều phương thức overload cho phương thức convertAndSendToUser() này:

Gửi STOMP message tới một user cụ thể với Spring WebSocket

Mình chỉ đang sử dụng một phương thức đơn giản với 3 tham số: user, destination và payload.

Tham số thứ 2, destination, là endpoint mà client cần subscribe, tất nhiên là cần phải thêm prefix mà chúng ta đã cấu hình trong class WebSocketConfiguration nữa nha các bạn. Trong ví dụ của mình thì các client cần subscribe endpoint “/users/queue/messages”.

Tham số thứ 3, payload, là nôi dung message mà chúng ta cần gửi tới user.

Tham số thứ 1, user là tham số quan trọng nhất mà mình đã mất rất nhiều thời gian để xác định nó, vì hiện tại không có một official document của Spring nói về vấn đề này. Thật ra thì nó là username gắn liền với một session id khi một client connect tới WebSocket server. Mặc định thì chỉ có session id được generate và đi kèm với mỗi WebSocket connection còn user name thì nếu chúng ta không thêm code để xác định username thì username này sẽ là null. Thông tin username này nếu có, sẽ được chứa trong implementation của interface java.security.Principal và class SimpMessageHeaderAccessor chứa thông tin về WebSocket connection header và sẽ chứa thông tin của class implementation này. Đó là lý do mà trong phương thức send() của class MessageController mình có sử dụng class SimpMessageHeaderAccessor là một tham số của phương thức này.

Trong ví dụ của bài viết này, mình sẽ truyền thông tin username vào header của WebSocket connection khi client connect tới WebSocket server và phía WebSocket server, mình sẽ thêm code để Spring xem nó là thông tin username của WebSocket connection.

Code phần front-end của mình như sau:

index.html:

<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<script src="/webjars/jquery/dist/jquery.min.js"></script>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<script src="/app.js"></script>
</head>
<body>
<div id="main-content">
<div>
<div>
<label>What is your username?</label> 
<input type="text" id="username" placeholder="Your username here...">
</div>
<button id="connect" type="submit">Connect</button>
<form>
<div>
<label>User will received message</label> 
<input type="text" id="name" placeholder="User will receive the message...">
</div>
<button id="send" type="submit">Send</button>
</form>
</div>
</div>
<div>
<label>Message from someone: </label><span id="message"></span>
</div>
</body>
</html>

app.js:

var stompClient = null;

function connect(username) {
var socket = new SockJS('/hello');
stompClient = Stomp.over(socket);
stompClient.connect({ username: username, }, function() {
console.log('Web Socket is connected');
stompClient.subscribe('/users/queue/messages', function(message) {
$("#message").text(message.body);
});
});
}

$(function() {
$("form").on('submit', function(e) {
e.preventDefault();
});
$("#connect").click(function() {
connect($("#username").val());
});
$("#send").click(function() {
stompClient.send("/app/hello", {}, $("#name").val());
});
});

Kết quả khi chạy ví dụ của mình như sau:

Gửi STOMP message tới một user cụ thể với Spring WebSocket

Chúng ta sẽ nhập tên username sẽ gắn liền với WebSocket connection sau đó thì nhấn nút Connect để tạo WebSocket connection. Để đơn giản thì mình chỉ cho phép người dùng nhập tên username mà họ muốn gửi message, nội dung message sẽ tự động generate từ phía WebSocket server. Khi một user nhận được message từ ai đó, nội dung của message này sẽ hiển thị phía sau dòng text “Message from someone”.

Trong tập tin app.js, như các bạn thấy, client đang subscribe vào endpoint “/users/queue/messages” để nhận các message từ các user khác.

Bây giờ, mình sẽ code phần quan trọng nhất là thêm thông tin username đi kèm với mỗi connection tới WebSocket server.

Đầu tiên, mình sẽ tạo mới một class User implement interface java.security.Principal:

package com.huongdanjava.springboot.websocket;

import java.security.Principal;

public class User implements Principal {

private String name;

public User(String name) {
this.name = name;
}

@Override
public String getName() {
return name;
}

}

Tiếp theo, mình sẽ update class WebSocketConfiguration để thêm một interceptor cho mỗi WebSocket connection tới WebSocket server, chúng ta sẽ thêm code để set thông tin user cho connection đó, như sau:

package com.huongdanjava.springboot.websocket;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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 {

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

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/users");
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new UserInterceptor());
}
}

Nội dung của class UserInterceptor như sau:

package com.huongdanjava.springboot.websocket;

import java.util.ArrayList;
import java.util.Map;

import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;

public class UserInterceptor implements ChannelInterceptor {

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
Object raw = message.getHeaders().get(SimpMessageHeaderAccessor.NATIVE_HEADERS);

if (raw instanceof Map) {
Object name = ((Map) raw).get("username");

if (name instanceof ArrayList) {
accessor.setUser(new User(((ArrayList<String>) name).get(0).toString()));
}
}
}
return message;
}
}

Nói nôm na thì cho mỗi WebSocket connection, mình sẽ lấy thông tin username mà mình đã truyền ở phía client lên để gán thông tin user cho connection đó. Phương thức setUser() của class SimpMessageHeaderAccessor với tham số là interface java.security.Principal sẽ giúp chúng ta làm điều này.

Bây giờ thì chúng ta có thể chạy ứng dụng để xem kết quả như thế nào các bạn nhé!

Mình sẽ mở 2 cửa sổ trình duyệt, nhập thông tin user “khanh” và “test” cho mỗi cửa sổ trình duyệt và nhấn nút Connect. Sau đó, ở cửa sổ của user “test”, mình sẽ nhập tên user “khanh” và nhấn nút Send. Ở cửa sổ của user “khanh”, các bạn sẽ thấy kết quả như sau:

Gửi STOMP message tới một user cụ thể với Spring WebSocket