Make it work

Multiroom Chat with Spring, WebSocket and Nuxt.js (Vue, Vuex)

Overview

In this article, I’ll show you how to build a multiroom-chat application with Spring, WebSocket, and Vue. If you never used WebSocket with Spring I suggest reading this post first. This time I will cover more life cases, like:

    • sending messages to a specific group of users
    • listening to WebSocket events
    • fetching data in Vuex store

It’s not a step by step tutorial. To see the whole code please visit this GitHub project.

Business logic

Business logic should be independent of the framework. Of course, it can rely on some libraries, but if it’s written in Java, it should be easy moved to any other Java projects, even if those don’t use the Spring framework.

Room.java
  1. package com.kojotdev.blog.multiroomchat.chat.domain;
  2. import com.kojotdev.blog.multiroomchat.chat.dto.SimpleRoomDto;
  3. import com.kojotdev.blog.multiroomchat.user.User;
  4. import io.vavr.collection.HashSet;
  5. import io.vavr.collection.Set;
  6. public class Room {
  7. public final String name;
  8. public final String key;
  9. public final Set<User> users;
  10. public Room(String name) {
  11. this.name = name;
  12. this.key = generateKey(name);
  13. this.users = HashSet.empty();
  14. }
  15. private Room(String name, String key, Set<User> users) {
  16. this.name = name;
  17. this.key = key;
  18. this.users = users;
  19. }
  20. public Room subscribe(User user) {
  21. final Set<User> subscribedUsers = this.users.add(user);
  22. return new Room(this.name, this.key, subscribedUsers);
  23. }
  24. public Room unsubscribe(User user) {
  25. final Set<User> subscribedUsers = this.users.remove(user);
  26. return new Room(this.name, this.key, subscribedUsers);
  27. }
  28. public SimpleRoomDto asSimpleRoomDto() {
  29. return new SimpleRoomDto(this.name, this.key);
  30. }
  31. private String generateKey(String roomName) {
  32. return roomName.toLowerCase().trim().replaceAll("\\s+", "-");
  33. }
  34. }
package com.kojotdev.blog.multiroomchat.chat.domain;

import com.kojotdev.blog.multiroomchat.chat.dto.SimpleRoomDto;
import com.kojotdev.blog.multiroomchat.user.User;
import io.vavr.collection.HashSet;
import io.vavr.collection.Set;

public class Room {

    public final String name;
    public final String key;
    public final Set<User> users;

    public Room(String name) {
        this.name = name;
        this.key = generateKey(name);
        this.users = HashSet.empty();
    }

    private Room(String name, String key, Set<User> users) {
        this.name = name;
        this.key = key;
        this.users = users;
    }

    public Room subscribe(User user) {
        final Set<User> subscribedUsers = this.users.add(user);
        return new Room(this.name, this.key, subscribedUsers);
    }

    public Room unsubscribe(User user) {
        final Set<User> subscribedUsers = this.users.remove(user);
        return new Room(this.name, this.key, subscribedUsers);
    }

    public SimpleRoomDto asSimpleRoomDto() {
        return new SimpleRoomDto(this.name, this.key);
    }

    private String generateKey(String roomName) {
        return roomName.toLowerCase().trim().replaceAll("\\s+", "-");
    }
}

The domain Room class is pretty straight, it’s got the name, key, and list of unique users. Users can join the room (subscribe) and leave it (unsubscribe), and the newly created room should be empty and have an automatically generated key. The business logic should be tested, and yes, we should’ve done this before the implementation (TDD). To make it more clear I’ve written it in Groovy with Spock Framework.

RoomTest.groovy
  1. package com.kojotdev.blog.multiroomchat.chat.domain
  2. import com.kojotdev.blog.multiroomchat.user.User
  3. import spock.lang.Specification
  4. class RoomTest extends Specification {
  5. def "new room should not have any user subscribed"() {
  6. given: "created new room"
  7. def newRoom = new Room("good name");
  8. expect: "user list should be empty"
  9. newRoom.users.isEmpty();
  10. }
  11. def "created new room should have a correct key"() {
  12. when: "creating a new room with random name"
  13. def newRoom = new Room(roomName);
  14. then: "the right key should be generated"
  15. newRoom.key == expectedKey;
  16. where:
  17. roomName | expectedKey
  18. "new room" | "new-room"
  19. "simple" | "simple"
  20. "WiTh UpperCase" | "with-uppercase"
  21. " with a lot of spaces " | "with-a-lot-of-spaces"
  22. "with ^&*- chars" | "with-^&*--chars"
  23. }
  24. def "room subscribe should be fine"() {
  25. given: "an empty room and user"
  26. def newRoom = new Room("good name");
  27. def user = new User("kojot");
  28. when: "user subscribe"
  29. def subscribedRoom = newRoom.subscribe(user);
  30. then: "user should be subscribed"
  31. !subscribedRoom.users.isEmpty();
  32. subscribedRoom.users.contains(user);
  33. }
  34. def "room unsubscribe should be fine"() {
  35. given: "a room with user"
  36. def user = new User("kojot");
  37. def newRoom = new Room("good name").subscribe(user)
  38. when: "user unsubscribe"
  39. def unsubscribedRoom = newRoom.unsubscribe(user);
  40. then: "room should be empty"
  41. unsubscribedRoom.users.isEmpty();
  42. }
  43. def "mapping to SimpleRoomDto should be correct"() {
  44. given: "create new room"
  45. def newRoom = new Room("good name");
  46. when: "map to SimpleRoomDto"
  47. def dto = newRoom.asSimpleRoomDto()
  48. then: "dto should be correct"
  49. dto.key == "good-name"
  50. dto.name == "good name"
  51. }
  52. }
package com.kojotdev.blog.multiroomchat.chat.domain

import com.kojotdev.blog.multiroomchat.user.User
import spock.lang.Specification

class RoomTest extends Specification {

    def "new room should not have any user subscribed"() {
        given: "created new room"
        def newRoom = new Room("good name");
        expect: "user list should be empty"
        newRoom.users.isEmpty();
    }

    def "created new room should have a correct key"() {
        when: "creating a new room with random name"
        def newRoom = new Room(roomName);
        then: "the right key should be generated"
        newRoom.key == expectedKey;
        where:
        roomName                         | expectedKey
        "new room"                       | "new-room"
        "simple"                         | "simple"
        "WiTh UpperCase"                 | "with-uppercase"
        "  with   a   lot of  spaces   " | "with-a-lot-of-spaces"
        "with ^&*- chars"                | "with-^&*--chars"
    }

    def "room subscribe should be fine"() {
        given: "an empty room and user"
        def newRoom = new Room("good name");
        def user = new User("kojot");
        when: "user subscribe"
        def subscribedRoom = newRoom.subscribe(user);
        then: "user should be subscribed"
        !subscribedRoom.users.isEmpty();
        subscribedRoom.users.contains(user);
    }

    def "room unsubscribe should be fine"() {
        given: "a room with user"
        def user = new User("kojot");
        def newRoom = new Room("good name").subscribe(user)
        when: "user unsubscribe"
        def unsubscribedRoom = newRoom.unsubscribe(user);
        then: "room should be empty"
        unsubscribedRoom.users.isEmpty();
    }

    def "mapping to SimpleRoomDto should be correct"() {
        given: "create new room"
        def newRoom = new Room("good name");
        when: "map to SimpleRoomDto"
        def dto = newRoom.asSimpleRoomDto()
        then: "dto should be correct"
        dto.key == "good-name"
        dto.name == "good name"
    }
}

RoomService keeps the room list and handles user’s joins/leaves to each room.

RoomService.java
  1. package com.kojotdev.blog.multiroomchat.chat.service;
  2. import com.kojotdev.blog.multiroomchat.app.AppError;
  3. import com.kojotdev.blog.multiroomchat.chat.domain.Room;
  4. import com.kojotdev.blog.multiroomchat.chat.dto.ChatRoomUserListDto;
  5. import com.kojotdev.blog.multiroomchat.chat.dto.SimpleRoomDto;
  6. import com.kojotdev.blog.multiroomchat.chat.dto.UserRoomKeyDto;
  7. import com.kojotdev.blog.multiroomchat.user.User;
  8. import io.vavr.collection.List;
  9. import io.vavr.control.Either;
  10. import org.springframework.stereotype.Service;
  11. @Service
  12. public class RoomService {
  13. private List<Room> roomList;
  14. public RoomService() {
  15. this.roomList = List.of(defaultRoom());
  16. }
  17. public List<SimpleRoomDto> roomList() {
  18. return getRoomList()
  19. .map(room -> room.asSimpleRoomDto());
  20. }
  21. public SimpleRoomDto addRoom(String roomName) {
  22. final Room room = new Room(roomName);
  23. final List<Room> roomList = addRoom(room);
  24. return room.asSimpleRoomDto();
  25. }
  26. public Either<AppError, ChatRoomUserListDto> usersInChatRoom(String roomKey) {
  27. return getRoomList()
  28. .find(room -> room.key.equals(roomKey))
  29. .map(room -> new ChatRoomUserListDto(room.key, room.users))
  30. .toEither(AppError.INVALID_ROOM_KEY);
  31. }
  32. public Either<AppError, ChatRoomUserListDto> addUserToRoom(UserRoomKeyDto userRoomKey) {
  33. final User user = new User(userRoomKey.userName);
  34. this.roomList
  35. .find(room -> room.key.equals(userRoomKey.roomKey))
  36. .map(oldRoom -> {
  37. final Room newRoom = oldRoom.subscribe(user);
  38. updateRoom(oldRoom, newRoom);
  39. return newRoom;
  40. });
  41. return usersInChatRoom(userRoomKey.roomKey);
  42. }
  43. public Either<AppError, ChatRoomUserListDto> removeUserFromRoom(UserRoomKeyDto userRoomKey) {
  44. final User user = new User(userRoomKey.userName);
  45. this.roomList
  46. .find(room -> room.key.equals(userRoomKey.roomKey))
  47. .map(oldRoom -> {
  48. final Room newRoom = oldRoom.unsubscribe(user);
  49. updateRoom(oldRoom, newRoom);
  50. return newRoom;
  51. });
  52. return usersInChatRoom(userRoomKey.roomKey);
  53. }
  54. public List<Room> disconnectUser(User user) {
  55. final List<Room> userRooms = this.roomList
  56. .filter(room -> room.users.contains(user))
  57. .map(oldRoom -> {
  58. final Room newRoom = oldRoom.unsubscribe(user);
  59. updateRoom(oldRoom, newRoom);
  60. return newRoom;
  61. });
  62. return userRooms;
  63. }
  64. private Room defaultRoom() {
  65. return new Room("Main room");
  66. }
  67. private synchronized List<Room> getRoomList() {
  68. return this.roomList;
  69. }
  70. private synchronized List<Room> addRoom(Room room) {
  71. return this.roomList = this.roomList.append(room);
  72. }
  73. private synchronized List<Room> updateRoom(Room oldRoom, Room newRoom) {
  74. return this.roomList = this.roomList
  75. .remove(oldRoom)
  76. .append(newRoom);
  77. }
  78. }
package com.kojotdev.blog.multiroomchat.chat.service;

import com.kojotdev.blog.multiroomchat.app.AppError;
import com.kojotdev.blog.multiroomchat.chat.domain.Room;
import com.kojotdev.blog.multiroomchat.chat.dto.ChatRoomUserListDto;
import com.kojotdev.blog.multiroomchat.chat.dto.SimpleRoomDto;
import com.kojotdev.blog.multiroomchat.chat.dto.UserRoomKeyDto;
import com.kojotdev.blog.multiroomchat.user.User;
import io.vavr.collection.List;
import io.vavr.control.Either;
import org.springframework.stereotype.Service;

@Service
public class RoomService {

    private List<Room> roomList;

    public RoomService() {
        this.roomList = List.of(defaultRoom());
    }

    public List<SimpleRoomDto> roomList() {
        return getRoomList()
                .map(room -> room.asSimpleRoomDto());
    }

    public SimpleRoomDto addRoom(String roomName) {
        final Room room = new Room(roomName);
        final List<Room> roomList = addRoom(room);
        return room.asSimpleRoomDto();
    }

    public Either<AppError, ChatRoomUserListDto> usersInChatRoom(String roomKey) {
        return getRoomList()
                .find(room -> room.key.equals(roomKey))
                .map(room -> new ChatRoomUserListDto(room.key, room.users))
                .toEither(AppError.INVALID_ROOM_KEY);
    }

    public Either<AppError, ChatRoomUserListDto> addUserToRoom(UserRoomKeyDto userRoomKey) {
        final User user = new User(userRoomKey.userName);
        this.roomList
                .find(room -> room.key.equals(userRoomKey.roomKey))
                .map(oldRoom -> {
                    final Room newRoom = oldRoom.subscribe(user);
                    updateRoom(oldRoom, newRoom);
                    return newRoom;
                });
        return usersInChatRoom(userRoomKey.roomKey);
    }

    public Either<AppError, ChatRoomUserListDto> removeUserFromRoom(UserRoomKeyDto userRoomKey) {
        final User user = new User(userRoomKey.userName);
        this.roomList
                .find(room -> room.key.equals(userRoomKey.roomKey))
                .map(oldRoom -> {
                    final Room newRoom = oldRoom.unsubscribe(user);
                    updateRoom(oldRoom, newRoom);
                    return newRoom;
                });
        return usersInChatRoom(userRoomKey.roomKey);
    }

    public List<Room> disconnectUser(User user) {
        final List<Room> userRooms = this.roomList
                .filter(room -> room.users.contains(user))
                .map(oldRoom -> {
                    final Room newRoom = oldRoom.unsubscribe(user);
                    updateRoom(oldRoom, newRoom);
                    return newRoom;
                });

        return userRooms;
    }

    private Room defaultRoom() {
        return new Room("Main room");
    }

    private synchronized List<Room> getRoomList() {
        return this.roomList;
    }

    private synchronized List<Room> addRoom(Room room) {
        return this.roomList = this.roomList.append(room);
    }

    private synchronized List<Room> updateRoom(Room oldRoom, Room newRoom) {
        return this.roomList = this.roomList
                .remove(oldRoom)
                .append(newRoom);
    }
}

The other classes like User, Message, AppError and DTO’s are simple POJOs, so I don’t paste their code here.

Handling WebSocket messages

Enabling WebSocket in Spring with built-it Message Broker is pretty easy and you probably are familiar with it. We just need to add one config class:

WebSocketConfig.java
  1. package com.kojotdev.blog.multiroomchat.app.websocket;
  2. import org.springframework.context.annotation.Configuration;
  3. import org.springframework.messaging.simp.config.MessageBrokerRegistry;
  4. import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
  5. import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
  6. import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
  7. @Configuration
  8. @EnableWebSocketMessageBroker
  9. public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
  10. @Override
  11. public void configureMessageBroker(MessageBrokerRegistry config) {
  12. config.enableSimpleBroker("/chat");
  13. config.setApplicationDestinationPrefixes("/app");
  14. }
  15. @Override
  16. public void registerStompEndpoints(StompEndpointRegistry registry) {
  17. registry.addEndpoint("/ws")
  18. .setAllowedOrigins("http://localhost:3000",
  19. "chrome-extension://ggnhohnkfcpcanfekomdkjffnfcjnjam")
  20. .withSockJS();
  21. }
  22. }
package com.kojotdev.blog.multiroomchat.app.websocket;

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 {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/chat");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("http://localhost:3000",
                        "chrome-extension://ggnhohnkfcpcanfekomdkjffnfcjnjam")
                .withSockJS();
    }
}

The most important things go in ChatController, where we are handling WebSocket messages.

ChatController.java
  1. package com.kojotdev.blog.multiroomchat.chat.controller;
  2. import com.kojotdev.blog.multiroomchat.chat.domain.Room;
  3. import com.kojotdev.blog.multiroomchat.chat.dto.ChatRoomUserListDto;
  4. import com.kojotdev.blog.multiroomchat.chat.dto.NewRoomDto;
  5. import com.kojotdev.blog.multiroomchat.chat.dto.SimpleRoomDto;
  6. import com.kojotdev.blog.multiroomchat.chat.dto.UserRoomKeyDto;
  7. import com.kojotdev.blog.multiroomchat.chat.service.RoomService;
  8. import com.kojotdev.blog.multiroomchat.message.Message;
  9. import com.kojotdev.blog.multiroomchat.message.MessageTypes;
  10. import com.kojotdev.blog.multiroomchat.user.User;
  11. import io.vavr.collection.HashSet;
  12. import io.vavr.collection.List;
  13. import org.slf4j.Logger;
  14. import org.slf4j.LoggerFactory;
  15. import org.springframework.messaging.handler.annotation.DestinationVariable;
  16. import org.springframework.messaging.handler.annotation.MessageMapping;
  17. import org.springframework.messaging.handler.annotation.SendTo;
  18. import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
  19. import org.springframework.messaging.simp.SimpMessageSendingOperations;
  20. import org.springframework.messaging.simp.annotation.SubscribeMapping;
  21. import org.springframework.stereotype.Controller;
  22. import static java.lang.String.format;
  23. @Controller
  24. public class ChatController {
  25. private static final Logger log = LoggerFactory.getLogger(ChatController.class);
  26. private final RoomService roomService;
  27. private final SimpMessageSendingOperations messagingTemplate;
  28. public ChatController(RoomService roomService, SimpMessageSendingOperations messagingTemplate) {
  29. this.roomService = roomService;
  30. this.messagingTemplate = messagingTemplate;
  31. }
  32. @SubscribeMapping("/chat/roomList")
  33. public List<SimpleRoomDto> roomList() {
  34. return roomService.roomList();
  35. }
  36. @MessageMapping("/chat/addRoom")
  37. @SendTo("/chat/newRoom")
  38. public SimpleRoomDto addRoom(NewRoomDto newRoom) {
  39. return roomService.addRoom(newRoom.roomName);
  40. }
  41. @MessageMapping("/chat/{roomId}/join")
  42. public ChatRoomUserListDto userJoinRoom(UserRoomKeyDto userRoomKey, SimpMessageHeaderAccessor headerAccessor) {
  43. // with enabled spring security
  44. // final String securityUser = headerAccessor.getUser().getName();
  45. final String username = (String) headerAccessor.getSessionAttributes().put("username", userRoomKey.userName);
  46. final Message joinMessage = new Message(MessageTypes.JOIN, userRoomKey.userName, "");
  47. return roomService.addUserToRoom(userRoomKey)
  48. .map(userList -> {
  49. messagingTemplate.convertAndSend(format("/chat/%s/userList", userList.roomKey), userList);
  50. sendMessage(userRoomKey.roomKey, joinMessage);
  51. return userList;
  52. })
  53. .getOrElseGet(appError -> {
  54. log.error("invalid room id...");
  55. return new ChatRoomUserListDto(userRoomKey.roomKey, HashSet.empty());
  56. });
  57. }
  58. @MessageMapping("/chat/{roomId}/leave")
  59. public ChatRoomUserListDto userLeaveRoom(UserRoomKeyDto userRoomKey, SimpMessageHeaderAccessor headerAccessor) {
  60. final Message leaveMessage = new Message(MessageTypes.LEAVE, userRoomKey.userName, "");
  61. return roomService.removeUserFromRoom(userRoomKey)
  62. .map(userList -> {
  63. messagingTemplate.convertAndSend(format("/chat/%s/userList", userList.roomKey), userList);
  64. sendMessage(userRoomKey.roomKey, leaveMessage);
  65. return userList;
  66. })
  67. .getOrElseGet(appError -> {
  68. log.error("invalid room id...");
  69. return new ChatRoomUserListDto(userRoomKey.roomKey, HashSet.empty());
  70. });
  71. }
  72. @MessageMapping("chat/{roomId}/sendMessage")
  73. public Message sendMessage(@DestinationVariable String roomId, Message message) {
  74. messagingTemplate.convertAndSend(format("/chat/%s/messages", roomId), message);
  75. return message;
  76. }
  77. public void handleUserDisconnection(String userName) {
  78. final User user = new User(userName);
  79. final Message leaveMessage = new Message(MessageTypes.LEAVE, userName, "");
  80. List<Room> userRooms = roomService.disconnectUser(user);
  81. userRooms
  82. .map(room -> new ChatRoomUserListDto(room.key, room.users))
  83. .forEach(roomUserList -> {
  84. messagingTemplate.convertAndSend(format("/chat/%s/userList", roomUserList.roomKey), roomUserList);
  85. sendMessage(roomUserList.roomKey, leaveMessage);
  86. });
  87. }
  88. }
package com.kojotdev.blog.multiroomchat.chat.controller;

import com.kojotdev.blog.multiroomchat.chat.domain.Room;
import com.kojotdev.blog.multiroomchat.chat.dto.ChatRoomUserListDto;
import com.kojotdev.blog.multiroomchat.chat.dto.NewRoomDto;
import com.kojotdev.blog.multiroomchat.chat.dto.SimpleRoomDto;
import com.kojotdev.blog.multiroomchat.chat.dto.UserRoomKeyDto;
import com.kojotdev.blog.multiroomchat.chat.service.RoomService;
import com.kojotdev.blog.multiroomchat.message.Message;
import com.kojotdev.blog.multiroomchat.message.MessageTypes;
import com.kojotdev.blog.multiroomchat.user.User;
import io.vavr.collection.HashSet;
import io.vavr.collection.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;

import static java.lang.String.format;

@Controller
public class ChatController {

    private static final Logger log = LoggerFactory.getLogger(ChatController.class);

    private final RoomService roomService;
    private final SimpMessageSendingOperations messagingTemplate;

    public ChatController(RoomService roomService, SimpMessageSendingOperations messagingTemplate) {
        this.roomService = roomService;
        this.messagingTemplate = messagingTemplate;
    }

    @SubscribeMapping("/chat/roomList")
    public List<SimpleRoomDto> roomList() {
        return roomService.roomList();
    }

    @MessageMapping("/chat/addRoom")
    @SendTo("/chat/newRoom")
    public SimpleRoomDto addRoom(NewRoomDto newRoom) {
        return roomService.addRoom(newRoom.roomName);
    }

    @MessageMapping("/chat/{roomId}/join")
    public ChatRoomUserListDto userJoinRoom(UserRoomKeyDto userRoomKey, SimpMessageHeaderAccessor headerAccessor) {
//        with enabled spring security
//        final String securityUser = headerAccessor.getUser().getName();
        final String username = (String) headerAccessor.getSessionAttributes().put("username", userRoomKey.userName);
        final Message joinMessage = new Message(MessageTypes.JOIN, userRoomKey.userName, "");
        return roomService.addUserToRoom(userRoomKey)
                .map(userList -> {
                    messagingTemplate.convertAndSend(format("/chat/%s/userList", userList.roomKey), userList);
                    sendMessage(userRoomKey.roomKey, joinMessage);
                    return userList;
                })
                .getOrElseGet(appError -> {
                    log.error("invalid room id...");
                    return new ChatRoomUserListDto(userRoomKey.roomKey, HashSet.empty());
                });
    }

    @MessageMapping("/chat/{roomId}/leave")
    public ChatRoomUserListDto userLeaveRoom(UserRoomKeyDto userRoomKey, SimpMessageHeaderAccessor headerAccessor) {
        final Message leaveMessage = new Message(MessageTypes.LEAVE, userRoomKey.userName, "");
        return roomService.removeUserFromRoom(userRoomKey)
                .map(userList -> {
                    messagingTemplate.convertAndSend(format("/chat/%s/userList", userList.roomKey), userList);
                    sendMessage(userRoomKey.roomKey, leaveMessage);
                    return userList;
                })
                .getOrElseGet(appError -> {
                    log.error("invalid room id...");
                    return new ChatRoomUserListDto(userRoomKey.roomKey, HashSet.empty());
                });
    }

    @MessageMapping("chat/{roomId}/sendMessage")
    public Message sendMessage(@DestinationVariable String roomId, Message message) {
        messagingTemplate.convertAndSend(format("/chat/%s/messages", roomId), message);
        return message;
    }

    public void handleUserDisconnection(String userName) {
        final User user = new User(userName);
        final Message leaveMessage = new Message(MessageTypes.LEAVE, userName, "");
        List<Room> userRooms = roomService.disconnectUser(user);
        userRooms
                .map(room -> new ChatRoomUserListDto(room.key, room.users))
                .forEach(roomUserList -> {
                    messagingTemplate.convertAndSend(format("/chat/%s/userList", roomUserList.roomKey), roomUserList);
                    sendMessage(roomUserList.roomKey, leaveMessage);
                });
    }

}

We are injecting two beans in the constructor: the RoomService and SimpMessageSendingOperations. The second one is very helpful to push the messages to the subscribers.

The first method roomList is annotated with @SubscribeMapping("/chat/roomList"). It will return the list of rooms immediately after subscribing to "/app/chat/roomList". Note that we use the "/app" prefix here, which we set in the WebSocketConfig class.

The second method addRoom uses two annotations: @MessageMapping and @SendTo. So, if any user send a message with NewRoomDto to "/app/chat/addRoom", it runs the roomService.addRoom() with room name and returns the SimpleRoomDto to users, who subscribed to "/chat/newRoom".

The join/leave room methods are very similar. They both use {roomId} in @MessageMapping annotation to give an opportunity for sending the messages to each room. These methods take SimpMessageHeaderAccessor as an argument, which we use to store the username in the user’s WebSocket session attribute. We need it when the user closes the connection. After user joins/leaves the room we are updating the room list and sending the message (with the help of the SimpMessageSendingOperations) to anyone, who subscribed this room.

The sendMessage method is simple, and the handleUserDisconnection is called when users close WebSocket connection (like closing the browser). It sends the message and updates the user list in every room that the user was subscribed.

WebSocketEventListener.java
  1. package com.kojotdev.blog.multiroomchat.app.websocket;
  2. import com.kojotdev.blog.multiroomchat.chat.controller.ChatController;
  3. import org.slf4j.Logger;
  4. import org.slf4j.LoggerFactory;
  5. import org.springframework.context.event.EventListener;
  6. import org.springframework.messaging.simp.SimpMessageSendingOperations;
  7. import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
  8. import org.springframework.stereotype.Component;
  9. import org.springframework.web.socket.messaging.SessionConnectedEvent;
  10. import org.springframework.web.socket.messaging.SessionDisconnectEvent;
  11. @Component
  12. public class WebSocketEventListener {
  13. private static final Logger log = LoggerFactory.getLogger(WebSocketEventListener.class);
  14. private final SimpMessageSendingOperations messagingTemplate;
  15. private final ChatController chatController;
  16. public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate, ChatController chatController) {
  17. this.messagingTemplate = messagingTemplate;
  18. this.chatController = chatController;
  19. }
  20. @EventListener
  21. public void handleWebSocketConnectListener(SessionConnectedEvent event) {
  22. log.info("Received a new web socket connection.");
  23. }
  24. @EventListener
  25. public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
  26. StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
  27. String username = (String) headerAccessor.getSessionAttributes().get("username");
  28. chatController.handleUserDisconnection(username);
  29. }
  30. }
package com.kojotdev.blog.multiroomchat.app.websocket;

import com.kojotdev.blog.multiroomchat.chat.controller.ChatController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 log = LoggerFactory.getLogger(WebSocketEventListener.class);

    private final SimpMessageSendingOperations messagingTemplate;
    private final ChatController chatController;

    public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate, ChatController chatController) {
        this.messagingTemplate = messagingTemplate;
        this.chatController = chatController;
    }

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        log.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");
        chatController.handleUserDisconnection(username);
    }

}

WebSocketEventListener shows how to catch an event when the user connects and disconnects.

Frontend - Vue and Vuex

Our frontend is simple Vue application. It uses Vuex, which is very helpful to share the data across the components. To supply it by the WebSocket, we need to call the right mutation method after subscription, for example:

mian.js
  1. this.stompClient.subscribe(
  2. "/chat/" + roomId + "/messages",
  3. tick => {
  4. const message = JSON.parse(tick.body);
  5. const roomMessage = { roomKey: roomId, message: message };
  6. commit("sendMessage", roomMessage);
  7. },
  8. { id: roomId + "_messages" }
  9. );
  this.stompClient.subscribe(
	"/chat/" + roomId + "/messages",
	tick => {
	  const message = JSON.parse(tick.body);
	  const roomMessage = { roomKey: roomId, message: message };
	  commit("sendMessage", roomMessage);
	},
	{ id: roomId + "_messages" }
  );

The second argument is used to override the default subscribe id and we need it to unsubscribe the specific room later.

Summary

Writing application like this gives a great user experience. The local store is populated by the data even if the user is on another page. When he switching back to the chat room, no extra calls to the backend are performed. Managing the Vuex data with WebSocket is much easier than with REST API to me also.
I hope this post helped you to understand how to write the application with Vuex and Spring WebSocket. Remember that the full source code is available in GitHub project, and if you have any question, please don’t hesitate to comment below!

Leave a Reply

Your email address will not be published.