基於websocket的實時通告功能,推送線上使用者,新登入使用者

蔣老溼發表於2019-02-28

背景介紹

在我們以往的軟體或者網站使用中,都有遇到過這種情況,莫名的彈出廣告或者通知!而在我們的業務系統中,有的時候也需要群發通知公告的方式去告知網站使用者一些資訊,那麼這種功能是怎麼實現的呢,本文將使用springboot+webSocket來實現這類功能,當然也有其他方式來實現 長連線/websocket/SSE等主流伺服器推送技術比較

springboot 與 webSocker整合

使用Intellij IDEA 快速建立一個springboot + webSocket專案

基於websocket的實時通告功能,推送線上使用者,新登入使用者

Maven的pom.xml內容

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
複製程式碼
  • webSocket核心是@ServerEndpoint這個註解。這個註解是Javaee標準裡的註解,tomcat7以上已經對其進行了實現,如果是用傳統方法使用tomcat釋出的專案,只要在pom檔案中引入javaee標準即可使用。
  • 但使用springboot內建tomcat時,就不需要引入javaee-api了,spring-boot已經包含了。
  • springboot的高階元件會自動引用基礎的元件,像spring-boot-starter-websocket就引入了spring-boot-starter-web和spring-boot-starter,所以不要重複引入
  • springboot已經做了深度的整合和優化,注意是否新增了不需要的依賴、配置或宣告。由於很多講解元件使用的文章是和spring整合的,會有一些配置。在使用springboot時,由於springboot已經有了自己的配置,再這些配置有可能導致各種各樣的異常。
 <dependency>
      <groupId>javax</groupId>
      <artifactId>javaee-api</artifactId>
      <version>7.0</version>
      <scope>provided</scope>
    </dependency>
複製程式碼

使用@ServerEndpoint建立websocket端點

首先要注入ServerEndpointExporter類,這個bean會自動註冊使用了@ServerEndpoint註解宣告的Websocket endpoint。要注意,如果使用獨立的servlet容器,而不是直接使用springboot的內建容器,就不要注入ServerEndpointExporter,因為 它(ServerEndpointExporter) 將由容器自己提供和管理。

WebSocketConfig.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}
複製程式碼

接下來就是寫websocket的具體實現類,很簡單,直接上程式碼:

BulletinWebSocket.java

package com.example.websocket.controller;

import com.example.websocket.service.BulletinService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Date;
import java.util.concurrent.CopyOnWriteArraySet;

/**
 * @ServerEndpoint 該註解用來指定一個URI,客戶端可以通過這個URI來連線到WebSocket。
 * 類似Servlet的註解mapping。無需在web.xml中配置。
 * configurator = SpringConfigurator.class是為了使該類可以通過Spring注入。
 * @Author jiangpeng
 */
@ServerEndpoint(value = "/webSocket/bulletin")
@Component
public class BulletinWebSocket {
    private static final Logger LOGGER = LoggerFactory.getLogger(BulletinWebSocket.class);

    private static ApplicationContext applicationContext;
    public static void setApplicationContext(ApplicationContext context) {
        applicationContext = context;
    }

    public BulletinWebSocket() {
        LOGGER.info("BulletinWebSocket init ");
    }
    // concurrent包的執行緒安全Set,用來存放每個客戶端對應的MyWebSocket物件。
    private static CopyOnWriteArraySet<BulletinWebSocket> BULLETIN_WEBSOCKETS = new CopyOnWriteArraySet<BulletinWebSocket>();
    // 與某個客戶端的連線會話,需要通過它來給客戶端傳送資料
    private Session session;

    /**
     * 連線建立成功呼叫的方法
     * */
    @OnOpen
    public void onOpen(Session session) throws IOException {
        this.session = session;
        // 加入set中
        BULLETIN_WEBSOCKETS.add(this);
        // 新登入使用者廣播通知
        this.session.getBasicRemote().sendText(applicationContext.getBean(BulletinService.class).getBulletin()+"-"+new Date());
        LOGGER.info("有新連線加入{}!當前線上人數為{}", session, getOnlineCount());
    }

    @OnClose
    public void onClose() {
        BULLETIN_WEBSOCKETS.remove(this);
        LOGGER.info("有一連線關閉!當前線上人數為{}", getOnlineCount());
    }

    /**
     * 收到客戶端訊息後呼叫的方法
     *
     * @param message 客戶端傳送過來的訊息
     * @param session 可選的引數
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        LOGGER.info("來自客戶端的資訊:{}", message);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        LOGGER.error("發生錯誤:{}", session.toString());
        error.printStackTrace();
    }

    /**
     * 這個方法與上面幾個方法不一樣。沒有用註解,是根據自己需要新增的方法。
     * 因為使用了Scheduled定時任務,所以方法不是有引數
     * @throws Exception
     */
    @Scheduled(cron = "0/2 * * * * ?")
    public void sendMessage() throws IOException {
        // 所有線上使用者廣播通知
        BULLETIN_WEBSOCKETS.forEach(socket -> {
            try {
                socket.session.getBasicRemote().sendText("定時:"+applicationContext.getBean(BulletinService.class).getBulletin()+"-"+new Date());
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }

    public static synchronized int getOnlineCount() {
        return BULLETIN_WEBSOCKETS.size();
    }
}
複製程式碼

使用springboot的唯一區別是要新增@Component註解,而使用獨立容器不用,是因為容器自己管理websocket的,但在springboot中連容器都是spring管理的。

雖然@Component預設是單例模式的,但springboot還是會為每個websocket連線初始化一個bean,所以可以用一個靜態set儲存起來private static CopyOnWriteArraySet<BulletinWebSocket> BULLETIN_WEBSOCKETS = new CopyOnWriteArraySet<BulletinWebSocket>();

html程式碼

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>static</h1>
<div id="msg" class="panel-body">
</div>
<input id="text" type="text"/>
<button onclick="send()">傳送</button>
</body>
<script src="https://cdn.bootcss.com/web-socket-js/1.0.0/web_socket.js"></script>
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
<script type="text/javascript">
    var websocket = null;
    //判斷當前瀏覽器是否支援WebSocket
    if ('WebSocket' in window) {
        websocket = new WebSocket("ws://127.0.0.1:8080/webSocket/bulletin");
    }
    else {
        alert("對不起!你的瀏覽器不支援webSocket")
    }
    //連線發生錯誤的回撥方法
    websocket.onerror = function () {
        setMessageInnerHTML("error");
    };
    //連線成功建立的回撥方法
    websocket.onopen = function (event) {
        setMessageInnerHTML("加入連線");
    };
    //接收到訊息的回撥方法
    websocket.onmessage = function (event) {
        setMessageInnerHTML(event.data);
    };
    //連線關閉的回撥方法
    websocket.onclose = function () {
        setMessageInnerHTML("斷開連線");
    };
    //監聽視窗關閉事件,當視窗關閉時,主動去關閉websocket連線,
    // 防止連線還沒斷開就關閉視窗,server端會拋異常。
    window.onbeforeunload = function () {
        var is = confirm("確定關閉視窗?");
        if (is) {
            websocket.close();
        }
    };

    //將訊息顯示在網頁上
    function setMessageInnerHTML(innerHTML) {
        $("#msg").append(innerHTML + "<br/>")
    };

    //關閉連線
    function closeWebSocket() {
        websocket.close();
    }

![](https://user-gold-cdn.xitu.io/2019/2/21/1690f655083376d7?w=721&h=457&f=gif&s=31053)
    //傳送訊息
    function send() {
        var message = $("#text").val();
        websocket.send(message);
        $("#text").val("");
    }
</script>
</html>
複製程式碼

GITHUB原始碼地址《===

基於websocket的實時通告功能,推送線上使用者,新登入使用者

效果展示

基於websocket的實時通告功能,推送線上使用者,新登入使用者

通告表設計

通告表Bulletin

CREATE TABLE `bulletin` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '編號id',
  `title` varchar(50) COLLATE utf8_bin NOT NULL COMMENT '標題',
  `content` varchar(1000) COLLATE utf8_bin NOT NULL COMMENT '內容',
  `user_type` tinyint(1) NOT NULL COMMENT '通告物件型別 1:單個使用者  2:多個使用者  3:全部使用者',
  `user_roles` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '通告物件角色',
  `user_depts` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '通告物件部門',
  `type` tinyint(1) DEFAULT NULL COMMENT '通告型別 1:系統升級',
  `publish_time` datetime DEFAULT NULL COMMENT '釋出時間',
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '狀態 0:待發布  1:已釋出 2:撤銷 ',
  `created_at` datetime NOT NULL COMMENT '建立時間',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
  `created_by` int(11) NOT NULL COMMENT '建立人',
  `updated_by` int(11) NOT NULL COMMENT '修改人',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='通告表';


複製程式碼

使用者標記表BulletinUser

CREATE TABLE `bulletin_user` (
  `bulletin_id` int(11) NOT NULL COMMENT '通告編號id',
  `user_id` int(11) NOT NULL COMMENT '使用者id',
  `is_read` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否閱讀 0否 1是',
  `created_at` datetime NOT NULL COMMENT '建立時間',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
  PRIMARY KEY (`bulletin_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='使用者通告標記表';
複製程式碼

業務規則

新增通告

  • 單個使用者:通告表新增一條記錄,使用者標記表新增一條記錄
  • 多個使用者:通告表新增一條記錄,使用者標記表新增多條記錄
  • 全部使用者:通告表新增一條記錄

閱讀公告

  • 單個使用者:修改使用者標記表中的記錄
  • 多個使用者:修改使用者標記表中的記錄
  • 全部使用者:使用者標記表新增閱讀記錄

發現新通告的規則

  • 單個使用者:通告表中有,並且通告物件型別是“單個使用者”,並且使用者標記表中的未讀標記是“0”
  • 多個使用者:通告表中有,並且通告物件型別是“多個使用者”,並且使用者標記表中的未讀標記是“0”
  • 全部使用者:通告表中有,並且通告物件型別是“全部使用者”,並且使用者標記表中沒有使用者的資訊

通告彈窗提示

  1. 線上使用者可以收到並彈窗顯示,看過的就不用再顯示了 (websocket 服務查詢當前使用者是否有未讀的公告,也就是所有全部使用者型別通告編號 not in 已讀通告編號,多出來的結果就是需要彈窗的通告, 可以時間篩選,免得新員工彈所有公告 )
  2. 沒看過的一登入也會彈窗顯示或者實時
  3. 前端任何頁面都可以接受到最新通告並彈窗(公共parent.js做websocket監聽)

以上的功能實現居然可以參考上面 BulletinWebSocket.java 中的這幾塊程式碼

 /**
     * 連線建立成功呼叫的方法
     * */
    @OnOpen
    public void onOpen(Session session) throws IOException {
        this.session = session;
        // 加入set中
        BULLETIN_WEBSOCKETS.add(this);
        // 新登入使用者廣播通知
        this.session.getBasicRemote().sendText(applicationContext.getBean(BulletinService.class).getBulletin()+"-"+new Date());
        LOGGER.info("有新連線加入{}!當前線上人數為{}", session, getOnlineCount());
    }
複製程式碼
public void sendMessage() throws IOException {
        // 所有線上使用者廣播通知
        BULLETIN_WEBSOCKETS.forEach(socket -> {
            try {
                socket.session.getBasicRemote().sendText("定時:"+applicationContext.getBean(BulletinService.class).getBulletin()+"-"+new Date());
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }
複製程式碼

總結

SpringBoot 部署與Spring部署都有一些差別,但現在用Srpingboot的公司多,SpringBoot建立專案快,所以使用該方式來講解,有一個問題就是開發WebSocket時發現無法通過@Autowired注入bean,一直為空。怎麼解決呢?

其實不是不能注入,是已經注入了,但是客戶端每建立一個連結就會建立一個物件,這個物件沒有任何的bean注入操作,下面貼下實踐

基於websocket的實時通告功能,推送線上使用者,新登入使用者
接下來
基於websocket的實時通告功能,推送線上使用者,新登入使用者
解決辦法就是springboot的啟動類注入一個static的物件
基於websocket的實時通告功能,推送線上使用者,新登入使用者
最後在WebSocket endpoint類新增相應的靜態物件,並新增set方法
基於websocket的實時通告功能,推送線上使用者,新登入使用者
接著如果那裡要使用Spring管理在Bean的話,就可以使用這種方式使用 applicationContext.getBean(BulletinService.class)

相關文章