iT邦幫忙

2022 iThome 鐵人賽

DAY 20
0

今日目標,使用 WebSocket 傳遞房間列表資料,並即時的將其顯示在頁面上。

Controller

我們依舊需要在 Controller 負責對定義的 endpoint、broker url 做處理,類似於前面的 Controller 提到的請求分發。

  1. 再來要定義一個新的 Controller 負責處理 WebSocket 相關的操作,也可以在原先的 RoomController 去做處理,只是小弟單純覺得 HTTP 相關與 WebSocket 相關的分開比較容易管理
  2. 在定義 Controller 之前,我們要先定義回傳的資料格式,所以在 room package 底下建立一個 java class,名稱為 RoomListResponse,內容為:
    package com.example.room;
    
    import lombok.Getter;
    import lombok.Setter;
    
    import java.util.Set;
    
    public class RoomListResponse {
        @Getter @Setter
        private Set<Room> rooms;
    }
    
    • 資料格式很簡單,就是所有的 Room,並且是一個 set,轉成 json 之後會變成一個 array
  3. 再來就可以定義我們的 Controller,在 room package 底下建立一個 java class,名稱為 RoomWsController,內容為:
    package com.example.room;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.messaging.handler.annotation.MessageMapping;
    import org.springframework.messaging.handler.annotation.SendTo;
    import org.springframework.stereotype.Controller;
    
    @Controller
    public class RoomWsController {
        @Autowired
        private RoomList roomList;
    
        @Autowired
        private UserStatus userStatus;
    
        @MessageMapping("/room-list")
        @SendTo("/topic/room-list")
        public RoomListResponse getAllRooms() {
            RoomListResponse roomListResponse = new RoomListResponse();
            roomListResponse.setRooms(roomList.getRooms());
            return roomListResponse;
        }
    }
    
    • @MessageMapping("/room-list"):定義接收資料的 url
    • @SendTo("/topic/room-list"):定義 Server 將傳遞訊息至哪個代理

SockJS

再來我們要在頁面上透過 JavaScript 操作 WebScoket,先建立連線再訂閱訊息代理,藉此來接收 Server 傳遞過來的資訊。

  1. resource/static/js 底下建立 WebSockets.js,內容為:
    class WebSocket {
        constructor() {
            this.stompClient = null;
        }
    
        connect(connect, callback) {
            let socket = new SockJS(connect);
            this.stompClient = Stomp.over(socket);
            this.stompClient.connect({}, function (frame) {
                callback();
            });
        }
    
        disconnect() {
            if (this.isConnected()) {
                this.stompClient.disconnect();
            }
        }
    
        send(controller, json) {
            setTimeout(() => {
                this.stompClient.send(controller, {}, JSON.stringify(json));
            }, 1000);
        }
    
        subscribe(topic, onMessage) {
            setTimeout(() => {
                this.stompClient.subscribe(topic, onMessage);
            }, 1000);
        }
    
        isConnected() {
            return this.stompClient !== null;
        }
    }
    
    • 我們重新包裝 SockJS 的操作,.send().subscribe() 必須要在已經連線的時候才可以觸發,他應該放在 .connect() 的那個函數內,因為這牽涉到同步與非同步問題,避免文章過於冗長且失焦,小弟這邊用最簡單但不太靠普的方式處理 send()subscribe(),在還沒連線時,就先延遲一秒(setTimeout()),等待 connect 執行完畢才執行
    • 之後操作時,先生成一個 WebSocket 的物件,然後使用連線函數(connect()),並傳入對應函數來處理連線後的動作
  2. 再來修改 rooms.js,這裡要做 WebSocket 連線,以及接收到訊息後顯示房間列表,完整內容為:
    var websocket = new WebSocket();
    websocket.connect('/connect', () => {
        getRoomList()
    });
    
    function getRoomList() {
        // 訂閱,並定義收到訊息該做什麼操作
        websocket.subscribe("/topic/room-list", (response) => {
            // 處理接收到的資料
            response = JSON.parse(response.body);
            let rooms = response["rooms"];
    
            // 顯示房間列表
            $("#rooms tbody").empty();
            let roomList = $("#rooms tbody").html();
            for (let room of rooms) {
                roomList += `
                <tr>
                    <th scope="row">${room.roomId}</th>
                    <td>${room.owner}</td>
                    <td>${room.info.number} / 4</td>
                    <td>
                        <button type="button" class="btn btn-outline-primary" onclick="joinRoom('${room.roomId}')">
                            加入
                        </button>
                    </td>
                </tr>
                `
            }
            $("#rooms tbody").html(roomList);
        })
    
        // 發送訊息給 Server 表示要取得房間列表的資料
        websocket.send(`/room-list`, {});
    }
    
    function createRoom() {
        let data = {
            action : "create",
        }
        jq.post(
            "/api/room/join",
            data,
            (response) => {
                window.location.href = `/room/${response.roomId}`;
            }
        )
        .fail(function(e) {
            $("#alert-toast-title").text(e.responseJSON.message);
            $("#alert-toast").toast('show');
        })
    }
    
    function joinRoom(roomId) {
        let data = {
            action : "join",
            roomId : roomId,
        }
        jq.post(
            "/api/room/join",
            data,
            (response) => {
                window.location.href = `/room/${response.roomId}`;
            }
        )
        .fail(function(e) {
            $("#alert-toast-title").text(e.responseJSON.message);
            $("#alert-toast").toast('show');
        })
    }
    
    $(document).ready(() => {
        $("#create").click(function() {
            createRoom();
        })
    })
    
    • 產生資料時,我們同時也產生「加入」的按鈕,並綁定我們已經先定義好的 joinRoom() 函數

頁面

  1. 在那之前,我們先稍微美化一下,在 resource/static/css 底下建立 rooms.css,內容為:
    .room-list {
        height: 450px;
        width: 1000px;
        margin: -20px auto 0 auto;
        padding: 35px;
        text-align: center;
        z-index: 1;
    }
    
  2. rooms.html 引入相關的 CDN 以及剛才定義的 WebSocket.js,僅需要修改 layout:fragment="js-and-css"的區塊:
    <div layout:fragment="js-and-css">
        <!-- websockets -->
        <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.js"
                integrity="sha512-tL4PIUsPy+Rks1go4kQG8M8/ItpRMvKnbBjQm4d2DQnFwgcBYRRN00QdyQnWSCwNMsoY/MfJY8nHp2CzlNdtZA=="
                crossorigin="anonymous"
                referrerpolicy="no-referrer"></script>
    
        <!-- custom -->
        <script type="text/javascript" th:src="@{/js/WebSockets.js}"></script>
        <script type="text/javascript" th:src="@{/js/rooms.js}"></script>
        <link th:href="@{/css/rooms.css}" rel="stylesheet">
    </div>
    
  3. 再來就是 demo 時間,到 http://localhost:8080/rooms ,先登入後點選 create 按鈕建立房間,這時候會被跳轉到 room 的頁面(但我們還沒定義)
  4. 這時,開啟另一個瀏覽器,或是用無痕模式開啟(如果本來就是無痕,就要開一般的),一樣到 http://localhost:8080/rooms ,就能看到建立的房間了
  5. 當你再開一個瀏覽器去到 rooms 的頁面,然後剛剛第 4. 的那個地方登入(用別的帳號)並創建(或是加入),會發現這邊的房間列表並沒有更新,必須要重新刷新頁面,這是因為我們只定義當 Client 送資料過去,Server 才會有訊息
  6. 解決方法是,在加入房間的 API 加入「Server 主動發訊息到代理」的功能,先修改 RoomService,加入 broadcastRoomList() 的功能,完整內容為:
    package com.example.room;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.messaging.simp.SimpMessagingTemplate;
    import org.springframework.stereotype.Service;
    
    
    @Service
    public class RoomService {
        @Autowired
        private RoomList roomList;
    
        @Autowired
        private UserStatus userStatus;
    
        @Autowired
        private SimpMessagingTemplate simpMessagingTemplate;
    
        public boolean createRoom(String username) {
            if (this.userStatus.containsUser(username) && this.userStatus.isUserInRoom(username)) {
                return false;
            }
    
            String roomId = roomList.create(username);
            this.userStatus.setUserInRoom(username, true);
            this.userStatus.setUserRoomId(username, roomId);
            return true;
        }
    
        public boolean joinInRoom(String username, String roomId) {
            if (this.userStatus.containsUser(username) && this.userStatus.isUserInRoom(username)) {
                return false;
            }
            if (roomList.getRoomById(roomId).count() == 4) {
                return false;
            }
    
            Room room = roomList.getRoomById(roomId);
            if (room.addGuest(username)) {
                if (!this.userStatus.containsUser(username)) {
                    this.userStatus.initialize(username);
                }
                this.userStatus.setUserInRoom(username, true);
                this.userStatus.setUserRoomId(username, roomId);
                return true;
            }
            return false;
        }
    
        public void broadcastRoomList() {
            RoomListResponse roomListResponse = new RoomListResponse();
            roomListResponse.setRooms(roomList.getRooms());
            this.simpMessagingTemplate.convertAndSend("/topic/room-list", roomListResponse);
        }
    }
    
    • 記得要注入 SimpMessagingTemplate
    • SimpMessagingTemplate:用於發送訊息的方法,可以設定目的代理,將資料傳送過去
    • simpMessagingTemplate.convertAndSend():第一個參數是目的代理,第二個參數是資料
  7. 再來修改 RoomController 的 joinRoomProcess(),添加 broadcastRoomList() 方法,用於在有任何一個使用者建立或加入房間就傳送最新的房間列表給所有訂閱的 Client,完整內容為:
    package com.example.room;
    
    import com.example.user.UserService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    import java.util.HashMap;
    import java.util.Map;
    
    @Controller
    public class RoomController {
        @Autowired
        private UserService userService;
    
        @Autowired
        private UserStatus userStatus;
    
        @Autowired
        private RoomService roomService;
    
        @GetMapping("/rooms")
        public String viewAllRoomsPage(Model model) {
            if (userService.isLogin()) {
                String username = userService.getUsername();
                if (this.userStatus.containsUser(username) && this.userStatus.isUserInRoom(username)) {
                    return "redirect:/room/" + this.userStatus.getUserRoomId(username);
                }
                this.userStatus.initialize(username);
            }
            model.addAttribute("disableJoinRoomButton", !userService.isLogin());
            return "rooms";
        }
    
        @PostMapping("/api/room/join")
        @ResponseBody
        public ResponseEntity<Map<String, Object>> joinRoomProcess(UserJoinRoomMessage userJoinRoomMessage) {
            Map<String, Object> response = new HashMap<>();
            HttpStatus httpStatus;
            String message;
            if (!userService.isLogin()) {
                httpStatus = HttpStatus.FORBIDDEN;
                message = "未登入";
                response.put("message", message);
                return ResponseEntity.status(httpStatus).body(response);
            }
    
            String username = userService.getUsername();
            String action = userJoinRoomMessage.getAction();
            String roomId = userJoinRoomMessage.getRoomId();
            if (action.equals("create") && roomService.createRoom(username)) {
                httpStatus = HttpStatus.OK;
                message = "建立成功";
            }
            else if (action.equals("join") && roomService.joinInRoom(username, roomId)) {
                httpStatus = HttpStatus.OK;
                message = "加入成功";
            }
            else {
                httpStatus = HttpStatus.BAD_REQUEST;
                message = "Error";
            }
    
            if (httpStatus == HttpStatus.OK) {
                // 發送最新的房間列表資訊
                this.roomService.broadcastRoomList();
                roomId = this.userStatus.getUserRoomId(username);
            }
            else {
                roomId = "";
            }
            response.put("message", message);
            response.put("roomId", roomId);
            return ResponseEntity.status(httpStatus).body(response);
        }
    }
    
  8. 這時候再操作一次第3~5. 就會發現能即時更新了~~~ /images/emoticon/emoticon07.gif

上一篇
Day 18 - 房間列表
下一篇
Day 20 - 房間頁面
系列文
Spring Boot... 深不可測31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言