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 为字符串“关闭连接原因”

发布了54 篇原创文章 · 获赞 69 · 访问量 25万+

猜你喜欢

转载自blog.csdn.net/seanxwq/article/details/103799137