Springboot整合WebSocket,實現向指定頁面推送信息

技術選型

Springboot + WebSocket + Mybatis + Enjoy(類似Jsper、freemarker的模板引擎) + FastJson+ SpringBoot 默認的連接池 Hikari

由於懶的寫樣式,並且不想用JQuery,直接用 Vue 加上 ElementUI 用作頁面展示。

代碼部分

先上代碼

·EvaluationServer ·類,作爲服務端類存儲Session信息

@ServerEndpoint("/im/{winNum}")
@Component
@Slf4j
public class EvaluationServer {

    /**
     *  靜態變量,用來記錄當前在線連接數。應該把它設計成線程安全的。
     * @date 2019/7/3 9:25
    */
    private static int onlineCount = 0;
    /**
     * 與某個客戶端的連接會話,需要通過它來給客戶端發送數據
     * @date 2019/7/3 9:26
    */
    private Session session;
    /**
     * 使用map對象,便於根據winNum來獲取對應的WebSocket
     * @date 2019/7/3 9:26
    */
    private static ConcurrentHashMap<String,EvaluationServer> websocketList = new ConcurrentHashMap<>();
    /**
     *  接收winNum
     * @date 2019/7/3 9:27
    */
    private String winNum="";
    /**
     * 連接建立成功調用的方法*/
    @OnOpen
    public void onOpen(Session session,@PathParam("winNum") String fromWinNum) throws IOException {
        this.session = session;
        if(StringUtils.isEmpty(fromWinNum)){
            log.error("請輸入窗口號!!!!!!!!!!!!!!!!");
            return;
        }else{
            try {
                if(websocketList.get(fromWinNum) == null){
                    this.winNum = fromWinNum;
                    websocketList.put(fromWinNum,this);
                    addOnlineCount();           //在線數加1
                    log.info("有新窗口開始監聽:{},當前窗口數爲{}",fromWinNum,getOnlineCount());
                }else{
                    session.getBasicRemote().sendText("已有相同窗口,請重新輸入不同窗口號");
                    CloseReason closeReason = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE,"相同窗口");
                    session.close(closeReason);
                }
            }catch (IOException e){
                e.printStackTrace();
            }
        }
        if(session.isOpen()){
            String jo = JSON.toJSONString(ApiReturnUtil.success());
            session.getBasicRemote().sendText(jo);
        }
    }

    /**
     * 連接關閉調用的方法
     */
    @OnClose
    public void onClose() {
        if(websocketList.get(this.winNum)!=null){
            websocketList.remove(this.winNum);
            subOnlineCount();           //在線數減1
            log.info("有一連接關閉!當前在線窗口爲:{}",getOnlineCount());
        }
    }

    /**
     * 收到客戶端消息後調用的方法
     *
     * @param message 客戶端發送過來的消息*/
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("收到來自窗口{}的信息:{},會話ID:",winNum,message,session.getId());
        if(StringUtils.isNotBlank(message)){
            //解析發送的報文
            Map<String,Object> map = JSON.parseObject(message, Map.class);
        }
    }

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

    /**
     * 服務器指定推送至某個客戶端
     * @param message
     * @author 楊逸林
     * @date 2019/7/3 10:02
     * @return void
    */
    private void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
    }


    /**
     * 發送給指定 瀏覽器
     * @ param message
     * @param winNum
     * @return void
    */
    public static void sendInfo(String message,@PathParam("winNum") String winNum) throws IOException {
        if(websocketList.get(winNum) == null){
            log.error("沒有窗口號!!!!!!!!!");
            return;
        }
        websocketList.forEach((k,v)->{
            try {
                //這裏可以設定只推送給這個winNum的,爲null則全部推送
                if(winNum==null) {
                    v.sendMessage(message);
                }else if(k.equals(winNum)){
                    log.info("推送消息到窗口:{},推送內容: {}",winNum,message);
                    v.sendMessage(message);
                }
            } catch (IOException e) {
                e.printStackTrace();
                log.info("找不到指定的 WebSocket 客戶端:{}",winNum);
            }
        });
    }

    private synchronized int getOnlineCount() {
        return onlineCount;
    }

    private synchronized void addOnlineCount() {
        onlineCount++;
    }

    private synchronized void subOnlineCount() {
        onlineCount--;
    }

    public static synchronized ConcurrentHashMap<String,EvaluationServer> getWebSocketList(){
        return websocketList;
    }
}

IndexController 用於重定向至頁面

@Controller
public class IndexController {

    @RequestMapping("/d")
    public ModelAndView index(String u){
        ModelAndView modelAndView = new ModelAndView();
        if(StringUtils.isBlank(u)){
            modelAndView.setViewName("error");
            return modelAndView;
        }
        modelAndView.addObject("winNum",u);
        modelAndView.setViewName("index");
        return modelAndView;
    }
}

GlobalConfig Springboot 配置類

@Configuration
public class GlobalConfig {

    @Value("${server.port}")
    private String port;

    /**
     * 添加Enjoy模版引擎
     * @date 2019-07-10 8:43
     * @return com.jfinal.template.ext.spring.JFinalViewResolver
    */
    @Bean(name = "jfinalViewResolver")
    public JFinalViewResolver getJFinalViewResolver() throws UnknownHostException {
        //獲取本地ip,和端口,並將信息拼接設置成context
        String ip = InetAddress.getLocalHost().getHostAddress();
        String localIp = ip+":"+port;
        JFinalViewResolver jfr = new JFinalViewResolver();
        // setDevMode 配置放在最前面
        jfr.setDevMode(true);
        // 使用 ClassPathSourceFactory 從 class path 與 jar 包中加載模板文件
        jfr.setSourceFactory(new ClassPathSourceFactory());
        // 在使用 ClassPathSourceFactory 時要使用 setBaseTemplatePath
        JFinalViewResolver.engine.setBaseTemplatePath("/templates/");
        JFinalViewResolver.engine.addSharedObject("context",localIp);
        jfr.setSuffix(".html");
        jfr.setContentType("text/html;charset=UTF-8");
        jfr.setOrder(0);
        return jfr;
    }

    /**
     * 添加 WebSocket 支持
     * @date 2019/7/3 9:20
     * @return org.springframework.web.socket.server.standard.ServerEndpointExporter
    */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    /**
     * 添加 FastJson 支持
     * @date 2019/7/3 11:16
     * @return org.springframework.boot.autoconfigure.http.HttpMessageConverters
    */
    @Bean
    public HttpMessageConverters fastJsonHttpMessageConverters(){
        //1. 需要定義一個converter轉換消息的對象
        FastJsonHttpMessageConverter fasHttpMessageConverter = new FastJsonHttpMessageConverter();

        //2. 添加fastjson的配置信息,比如:是否需要格式化返回的json的數據
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);

        //3. 在converter中添加配置信息
        fasHttpMessageConverter.setFastJsonConfig(fastJsonConfig);
        HttpMessageConverter<?> converter = fasHttpMessageConverter;
        return new HttpMessageConverters(converter);
    }
}

CallEvaluationController 調用的接口類

/**
 *  用於 API 調用
 * 調用評價器的 api 接口
 * @version 1.0
 * @date 2019/7/3 9:34
 **/
@RestController
@RequestMapping("/api")
@Slf4j
public class CallEvaluationController {

    @Autowired
    private UserService userService;

    /**
     * 開始評價接口
     * @param winNum
     * @param userId
     * @return cn.luckyray.evaluation.entity.ApiReturnObject
    */
    @RequestMapping("/startEvaluate")
    public String startEvaluate(String winNum){
        // 驗證窗口是否爲空
        ConcurrentHashMap<String, EvaluationServer> map = EvaluationServer.getWebSocketList();
        if(map.get(winNum) == null){ return "窗口不存在"}
        String message = "message";
        try {
            EvaluationServer.sendInfo(message,winNum);
        } catch (IOException e) {
            e.printStackTrace();
            log.error("{}窗口不存在,或者客戶端已斷開",winNum);
            return "窗口不存在或者已經斷開連接";
        }
        return "success";
    }
}

Maven配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.luckyray</groupId>
    <artifactId>evaluation</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>
    <name>evaluation</name>
    <description>評價功能模塊</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <!-- 添加阿里 FastJson 依賴 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.41</version>
        </dependency>
        <!-- enjoy模板引擎 begin -->
        <dependency>
            <groupId>com.jfinal</groupId>
            <artifactId>enjoy</artifactId>
            <version>3.3</version>
        </dependency>
        <!-- enjoy模板引擎 end -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- spring-boot-devtools熱啓動依賴包 start-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- spring-boot-devtools熱啓動依賴包 end-->

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <mainClass>cn.luckyray.evaluation.EvaluationApplication</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

index.html頁面,這裏採用了可重連的WebSocket,防止客戶端中途斷網導致需要刷新頁面才能重新連接。(這裏的#()裏面的內容爲Enjoy模板引擎渲染內容)

<!DOCTYPE html>
<html>

<head>
    <meta charset='utf-8'>
    <meta http-equiv='X-UA-Compatible' content='IE=edge'>
    <title>評價頁面</title>
    <meta name='viewport' content='width=device-width, initial-scale=1'>
    <!-- element-ui.css -->
    <link rel="stylesheet" href="../css/index.css">

</head>

<body>
    <div id="app">
        <el-row>
            <el-button v-on:click="click(1)" type="success" style="font-size:50px;font-family:微軟雅黑;height: 570px;width: 410px" disabled>滿意</el-button>
            <el-button v-on:click="click(2)" type="primary" style="font-size:50px;font-family:微軟雅黑;height: 570px;width: 410px" disabled>一般</el-button>
            <el-button v-on:click="click(3)" type="danger" style="font-size:50px;font-family:微軟雅黑;height: 570px;width: 410px" disabled>不滿意</el-button>
        </el-row>
    </div>
</body>

<script src="../js/reconnecting-websocket.min.js"></script>
<script src="../js/vue.js"></script>
<!-- element-ui.js -->
<script src="../js/index.js"></script>
<script>
    var socket;
    if (typeof(WebSocket) == "undefined") {
        console.log("您的瀏覽器不支持WebSocket");
    } else {
        //實現化WebSocket對象,指定要連接的服務器地址與端口  建立連接
        let socketUrl = "ws://#(context)/im/#(winNum)";
        socket = new ReconnectingWebSocket(socketUrl, null, {
            debug: false,
            reconnectInterval: 3000
        });
        console.log("創建websocket");
        //打開事件
        socket.onopen = function() {
            console.log("websocket客戶端已打開");
        };
        //獲得消息事件
        socket.onmessage = function(msg) {
            if(msg.data != undefined && msg.data.indexOf("已有相同窗口") != -1){
                alert("已有相同窗口,請重新輸入正確窗口號");
                socket.close();
                window.history.back(-1);
                return;
            }
            try{
                let data = JSON.parse(msg.data);
                console.log(data);
                if (data.code == "0" && data.data != undefined && data.data.active == "startEvaluate") {
                    userId = data.data.userId;
                    serialNum = data.data.serialNum;
                    speak();
                    app.allowClick();
                    setTimeout(app.allDisabled,10000);
                }
            }catch (e) {
                console.log(e);
            }

            //發現消息進入開始處理前端觸發邏輯
        };
        //關閉事件
        socket.onclose = function() {
            //console.log("websocket已關閉,正在嘗試重新連接");
        };
        //發生了錯誤事件
        socket.onerror = function() {
            //console.log("websocket已關閉,正在嘗試重新連接");
        }
        //監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
        window.onbeforeunload = function() {
            socket.close();
        }
    }
    //fullScreen()和exitScreen()有多種實現方式,此處只使用了其中一種
    //全屏
    function fullScreen() {
        var docElm = document.documentElement;
        docElm.webkitRequestFullScreen( Element.ALLOW_KEYBOARD_INPUT );
    }
    var app = new Vue({
        el: '#app',
        data: function() {

        },
        methods: {
            click: function(evaluation) {
                console.log(evaluation);
                let data = {
                    evaluation : evaluation,
                }
                let jsonData = JSON.stringify(data);
                console.log(jsonData);
                socket.send(jsonData);
                let childrens = app.$children[0].$children;
                for (let children of childrens) {
                    children.disabled = true;
                }
            },
            allowClick: function() {
                let childrens = app.$children[0].$children;
                for (let children of childrens) {
                    children.disabled = false;
                }
            },
            allDisabled:function () {
                let childrens = app.$children[0].$children;
                for (let children of childrens) {
                    children.disabled = true;
                }
            }
        },
    });
</script>
</html>

具體代碼

最主要的東西就是這些,尤其是index.html上的內容。《Netty實戰》中只說瞭如何建立服務端,並沒有說明客戶端如何建立。
下面代碼纔是重點,WebSocket 採用 ws 協議,其實是第一次發送 http 請求,在 http 請求頭部中 爲Connection:Upgrade,Upgrade:websocket 通知服務器將 http 請求升級爲 ws/wss 協議。下面的也可以改成 socket = new WebSocket(url,protocols)。其中 url 必填,protocols 可選參數,參數爲 string | string[] ,其中 string 爲可使用的協議,包括 SMPP,SOAP 或者自定義的協議。

有關 ws 與 wss 其實是與 http 與 https 關係類似,只是在TCP協議內,ws 協議外套了一層 TLS 協議,進行了加密處理。

let socketUrl = "ws://#(context)/im/#(winNum)";
socket = new ReconnectingWebSocket(socketUrl, null, {
            debug: false,
            reconnectInterval: 3000
        });

WebSocket的四個事件、兩個方法、兩個屬性

四個事件

open,message,error,close
下面爲對應的 ts 文件
可以看到有四個方法需要我們實現,對應着四個事件。下面詳細介紹
onclose
onerror
onmessage
onopen

interface WebSocket extends EventTarget {
    binaryType: BinaryType;
    readonly bufferedAmount: number;
    readonly extensions: string;
    onclose: ((this: WebSocket, ev: CloseEvent) => any) | null;
    onerror: ((this: WebSocket, ev: Event) => any) | null;
    onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null;
    onopen: ((this: WebSocket, ev: Event) => any) | null;
    readonly protocol: string;
    readonly readyState: number;
    readonly url: string;
    close(code?: number, reason?: string): void;
    send(data: string | ArrayBuffer | Blob | ArrayBufferView): void;
    readonly CLOSED: number;
    readonly CLOSING: number;
    readonly CONNECTING: number;
    readonly OPEN: number;
    addEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

open

一旦服務器響應了 WebSocket 連接請求,open 事件觸發並建立一個連接。open 事件對應的回調函數稱作 onopen

message

message 事件在接收到消息時觸發,對應該事件的回調函數爲 onmessage。除了文本,WebSocket還可以處理二進制數據,這種數據作爲 Blob 消息或者 ArrayBuffer 消息處理。必須在讀取數據之前決定用於客戶端二進制輸入數據的類型。其中返回的 e ,e.data 爲服務端返回的消息,其餘屬性爲 websocket 返回的附帶信息。

ws.binaryType="Blob";
ws.onmessage = function(e){
    if(e.data instanceof Blob){
        var blob = new Blob(e.data);
    }
}

error

在響應意外故障的時候觸發,最錯誤還會導致 WebSocket 關閉,一般伴隨的是 close 事件。error 事件處理程序是調用服務器重連邏輯以及處理來自 WebSocket 對象的異常的最佳場所。

close

close 事件在WebSocket 連接關閉時觸發。一旦連接關閉,雙端皆無法通信。

兩個屬性

readyState

ws.readyState === 0;就緒
ws.readyState === 1;已連接
ws.readyState === 2;正在關閉
ws.readyState === 3;已關閉

bufferAmount

該屬性的緣由是因爲 WebSocket 向服務端傳遞信息時,是有一個緩衝隊列的,該參數可以限制客戶端向服務端發送數據的速率,從而避免網絡飽和。具體代碼如下

// 10k max buffer size.
const THRESHOLD = 10240;

// Create a New WebSocket connection
let ws = new WebSocket("ws://w3mentor.com");

// Listen for the opening event
ws.onopen = function () {
   // Attempt to send update every second.
   setInterval( function() {
      // Send only if the buffer is not full
      if (ws.bufferedAmount < THRESHOLD) {
         ws.send(getApplicationState());
      }
   }, 1000);
};

兩個方法

send

必須要在 open 事件觸發之後纔可以發送消息。除了文本消息之外,還允許發送二進制數據。代碼如下。
文本

let data = "data";
if(ws.readyState == WebSocket.OPEN){
    ws.send(data);
}

二進制數據

let blob = new Blob("blob");
ws.send(blob);
let a = new Unit8Array([1,2,3,4,5,6]);
ws.send(a.buffer);

close

關閉連接用,可以加兩個參數 close(code,reason),與客戶端對應,code爲狀態碼,1000 這種,reason 爲字符串“關閉連接原因”

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章