+3

[Spring][Websocket] Simple send notifications

Overview

Trong bài viết sẽ tạo mộ ứng dụng web thực thi gửi tin nhắn sử dụng tính năng Websocket với Spring Framework 5.0. Websocket là kết nối 2 chiều, song công, liên tục giữa máy chủ và trình duyệt. Sau khi Websocket được thiết lập kết nối, kết nối vẫn mở cho đến khi client hoặc máy chủ quyết định đóng kết nối này.

Các case sử dụng có thể khi một ứng dụng liên quan nhiều user liên lạc với nhau, từ server đến client chẳng như notification, chat. Chúng ta sẽ build một ứng dụng đơn giản nhận notification sử dụng STOMP messaging với Spring để tạo tương tác qua web khi có một notification được tạo sẽ push message cho các client đã subscribed.

Coding

Dependencies

Trước tiên cần thêm các dependency vào build.gradle.kts như sau

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.5.1"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    kotlin("jvm") version "1.5.10"
    kotlin("plugin.spring") version "1.5.10"
}

group = "dev.hlk"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib")

   // Swagger OpenAPI
    implementation("org.springdoc:springdoc-openapi-ui:1.5.9")
    implementation("org.springdoc:springdoc-openapi-kotlin:1.5.9")

    // Websocket
    implementation("org.springframework:spring-websocket:5.3.8")
    implementation("org.springframework:spring-messaging:5.3.8")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "11"
    }
}

Sau khi thêm cần phải build lại bằng command ./gradlew build hoặc qua nút reload của IDEA Intellij

WebSocket Configuration

Tạo một file config cho Websocket:

// config/WebSocketConfig.kt
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.apply {
            enableSimpleBroker("/topic")
        }
    }

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.apply {
            addEndpoint("/notification").withSockJS()
        }
    }
}

WebSocketConfig được thêm annotation @Configuration để chỉ rằng nó là một configuration class, @EnableWebSocketMessageBroker bật tính năng xử lý Websocket message bởi một message broker.

Method configureMessageBroker() thực thi method mặc định trong WebSocketMessageBrokerConfigurer để config message broker. Nó bắt đầu gọi enableSimpleBroker() để bật một message broker đơn giản trong bộ nhớ chứa tin nhắn cho client với đích có prefix /topic.

Method registerStompEndpoints() đăng ký Websocket endpoint mở cho SockJS client kết nối qua /notification.

Web Controller

Tạo controller NotificationWebController trong /controller/web/NotificationWebController.kt dành cho view.

@Controller
class NotificationWebController {
    @GetMapping("/notifications")
    fun index() = ModelAndView("notifications/index")
}

API Controller

Tạo controller NotificationController trong /controller/api/NotificationController.kt dành cho việc push notification.

@RestController
class NotificationController(
    private val notificationService: NotificationService
) {
    @PostMapping("/pushNotification")
    fun pushNotification(@RequestBody payload: NotificationDao): Boolean {
        return notificationService.send(payload)
    }
}

Client

Với server-side đã tạo thì bên client cần có phần nhận notification từ server.

Để có thể dùng views cần phải config thêm trong application.yml

spring:
 mvc:
   view:
     prefix: /views/
     suffix: .html

Tạo một index.html trong resources/static/views/notifications/index.html.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Websocket</title>

    <!-- CSS -->
    <link rel="stylesheet" href="../../css/app.css">
    <link rel="stylesheet" href="../../css/bootstrap/bootstrap.min.css">
    <link rel="stylesheet" href="../../css/bootstrap/bootstrap-grid.min.css">
    <link rel="stylesheet" href="../../css/bootstrap/bootstrap-reboot.min.css">

    <!-- JS -->
    <script src="../../js/jquery.min.js"></script>
    <script src="../../js/bootstrap/popper.min.js"></script>
    <script src="../../js/bootstrap/bootstrap.bundle.min.js"></script>
    <script src="../../js/bootstrap/bootstrap.min.js"></script>
    <script src="../../js/sockjs.js"></script>
    <script src="../../js/stomp.js"></script>
    <script src="../../js/timeago.full.min.js"></script>
    <script src="../../js/app.js"></script>
</head>
<body>
<h2>Notifications</h2>
<ul id="notifications"></ul>

<div class="toast" id="js-toast" role="alert" aria-live="assertive" aria-atomic="true">
    <div class="toast-header">
        <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
            <path style="fill:#FF8A1E;" d="M174.74,430.93c0,44.878,36.382,81.07,81.26,81.07l22.261-103.331L174.74,430.93z"/>
            <path style="fill:#FF562B;" d="M256,512c44.878,0,81.26-36.192,81.26-81.07L256,408.669V512L256,512z"/>
            <polygon style="fill:#FFA418;" points="34.846,364.142 34.846,430.93 256,430.93 278.261,341.881 "/>
            <path style="fill:#FFBE11;" d="M256,0C176.159,0,111.837,59.674,99.965,136.479c-5.683,36.763-25.84,146.535-25.84,146.535  l204.137,22.261L256,0z"/>
            <g><path style="fill:#FF8A1E;" d="M437.877,283.016c0,0-20.158-109.774-25.842-146.537C400.162,59.674,335.841,0,256,0v305.277   L437.877,283.016z"/>
                <polygon style="fill:#FF8A1E;" points="256,341.881 256,430.93 477.154,430.93 477.154,364.142  "/>
            </g>
            <path style="fill:#FFD460;" d="M256,283.016H74.123c-23.905,18.925-39.277,48.417-39.277,81.126H256l22.261-40.563L256,283.016z"/>
            <path style="fill:#FFA418;" d="M437.877,283.016H256v81.126h221.154C477.154,331.433,461.781,301.941,437.877,283.016z"/>
            <g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g><g></g>
        </svg>

        <strong class="js-toast-title mr-auto"></strong>
        <small class="js-toast-timestamp"></small>
        <button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>
    </div>
    <div class="toast-body js-toast-content"></div>
</div>
</body>
</html>

Tạo app.js trong resources/static/js/app.js

let stompClient = null;

function connect() {
    let socket = new SockJS("/notification");
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        console.log('Connected: ' + frame);
        stompClient.subscribe("/topic/notifications", function (notification) {
            showNotification(JSON.parse(notification.body));
        });
    });

    // Reconnect socket
    socket.onclose = function (e) {
        console.log('Socket is closed. Reconnect will be attempted in 1 second.', e.reason);
        setTimeout(function() {
            connect();
        }, 1000);
    }
}

function disconnect() {
    if (stompClient != null) {
        stompClient.disconnect();
    }
    console.log("Disconnected");
}

function showNotification(notification) {
    let elem = document.createElement("li");
    let data = "Title: " + notification.title
        + " - Timestamp: " + notification.timestamp
        + " - Content: " + notification.content;
    elem.appendChild(document.createTextNode(data));
    $("#notifications").append(elem);
    $('#js-toast').on('show.bs.toast', function () {
        let that = $(this);
        let header = that.children(".toast-header");
        header.children(".js-toast-timestamp").text(timeago.format(Date.parse(notification.timestamp)))
        header.children(".js-toast-title").text(notification.title);
        that.children(".js-toast-content").text(notification.content);
    })
    $(".toast").toast("show");
}

$(function () {
    disconnect();
    connect();
    $(".toast").toast({
        delay: 2000 // ms
    });
});

// Disconnect on close tab
window.onbeforeunload = function () {
    disconnect();
};
  • Phần chính trong đoạn JS là connect()showNotification()
  • connect() function sử dụng SockJSstomp.js mở kết nối đến /notifications, là endpoint SockJS server chờ các kết nối. Khi kết nối thành công, client sẽ subscribe đến đích /topic/notifications phần mà server sẽ publish. Khi nhận notification DOM sẽ chền vào list và có sử dụng Toasts của bootstrap để làm popup.
  • showNotification() function nhận nội dung message để hiển thị.
  • socket.onclose(function(){}) phụ vụ cho việc kết nối lại với server khi server fail/restart.

Demo

Trong demo này cho thấy các trình duyệt nhận notification sau khi thao tác qua swagger gọi 1 API endpoint /notifications và nội dụng gồm title, content, timestamp.

Cảm ơn các bạn đã đọc bài viết. 😉

References


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.