Bootstrap

WebSocket的使用


 

WebSocket简介

WebSocket是基于TCP的一种网络通信协议,实现了浏览器、服务器之间的全双工通信,允许服务器主动推送信息给客户端。

WebSocket只需要一次HTTP握手,整个通讯过程建立在一次HTTP连接状态中。
 

WebSocket常用于服务器推送实时数据给客户端,常见的应用场景如下

  • 弹幕
  • 网页聊天系统
  • 公告
  • 实时数据监控,eg. 双11交易数据的实时监控
  • 实时数据推送,eg. 服务器给炒股软件(客户端)实时推送k线图走势
     

WebSocket中的广播分为3类

  • 单播:点对点,常用于私信、私聊​
  • 多播:也叫组播,常用于推送消息给特定人群,eg. 群聊,推送消息给该群中的所有人。常用于多人聊天、发布订阅
  • 广播:推送消息给所有人,常用于推送公告、发布订阅

 

SpringBoot整合WebScoket

依赖

Messaging -> 勾选WebSocket,也可以手动添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

websocket前后端的写法很多,此处介绍3种,第三种简单强大,推荐。

 

方式一

后台自己实现Endpoint,前端使用内置的WebSocket。
 

配置类

@Configuration
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 扫描@ServerEndpoint,将@ServerEndpoint修饰的类注册为websocket
     * 如果使用外置tomcat,则不需要此配置
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter()
    {
        return new ServerEndpointExporter();
    }

}

 

Endpoint 端点

@ServerEndpoint("/websocket")
@Component  //放到spring容器中
@Slf4j
public class WebSocketServer{

    /**
     * 所有连接的客户端
     */
    private static ConcurrentHashMap<String,Session> clients = new ConcurrentHashMap<>();


    /**
     * 建立连接时调用的方法
     */
    @OnOpen
    public void onOpen(Session session) {
        clients.put(session.getId(),session);
        //向特定用户发送消息,使用的session是接收方的session
        session.getAsyncRemote().sendText("已加入群聊");
    }


    /**
     * 连接关闭时调用的方法
     */
    @OnClose
    public void onClose(Session session) {
        clients.remove(session.getId());
        session.getAsyncRemote().sendText("已退出群聊");
    }


    /**
     * 收到客户端发送过来的消息时调用的方法
     * @param msg 客户端用户发送过来的消息,二进制可以声明为byte[]
     */
    @OnMessage
    public void onMessage(String msg) {
        //群发消息
        for (Session session : clients.values()) {
            session.getAsyncRemote().sendText(msg);
        }
    }


    /**
     * 发生错误时调用的方法
     */
    @OnError
    public void onError(Session session, Throwable e) {
        log.error("发送错误的sessionId:"+session.getId()+",错误信息:"+e.getMessage());
    }

}

接收到前端传来的消息时,会自动调用onMessage()方法。
 

前端

<script>
    let socket;

    //手动打开连接
    function openSocket() {
        if(typeof(WebSocket) == "undefined") {
            console.log("您使用的浏览器不支持WebSocket");
        }else{
            //连接到websocket的某个endpoint
            socket = new WebSocket("ws://127.0.0.1:8080/websocket");
            //以下几个方法相当于事件监听,在特定事件触发时会自动调用
            socket.onopen = () => {
                console.log("已连接到websocket");
            };
            socket.onmessage = resp => {
                console.log("接收到服务端信息:" + resp.data);
            };
            socket.onclose = () => {
                console.log("已断开websocket连接");
            };
            socket.onerror = () => {
                console.log("websocket发生错误");
            }
        }
    }

    //手动关闭连接
    function closeSocket() {
        socket.close();
    }

    //发送消息到服务器
    function sendMsg(msg) {
        //参数不一定要是字符串类型,可以是任意类型(二进制数据)
        socket.send(msg);
    }

</script>

 

说明

1、如果要同时实现单发、群发

  • 前端可以将msg写成对象,设置发送类型、接收方等属性,将对象转换为json字符串进行发送,然后在服务端的onMessage()中解析
  • 也可以多写几个endpoint,一个endpoint作为单发、一个作为群发
     

2、getBasicRemote()、getAsyncRemote()的区别

getBasicRemote()是同步的,getAsyncRemote()是异步的,getBasicRemote()会抛出异常。

eg. sendText(),sendText() 前后发送2次消息
如果是同步的,会等第一个sendText()执行完毕才执行第二个sendText();如果是异步的,第一个sendText()开始执行后就继续往下执行代码。
 

3、sendText()发送文本内容,sendBinary()发送二进制数据
 

4、websocket支持 ws、wss 协议,ws不使用ssl,wss使用了ssl。

可根据http通信协议判断 window.location.protocol,如果是http则使用ws,如果是https则使用wss。
 

5、方式一简单,但功能单一、能接收的数据类型有限,适合只群发、不单发,消息内容简单的场景。

 

方式二

后端使用@MessageMapping、@SendTo指定接受、推送地址,前端使用sockjs+stomp。

sockjs封装了websocket,stomp是消息队列模式。
 

配置类

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 注册端点
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 注册endpoint(服务端点),用于接收客户端连接,客户端的SockJS通过端点连接到websocket
        // setAllowedOrigins是配置跨域,withSockJS是启用SockJS支持
        // 可注册多个端点
        registry.addEndpoint("/socket").setAllowedOrigins("*").withSockJS();
    }


    /**
     * 配置服务器接收消息、推送消息的地址前缀
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 客户端订阅地址前缀,用于客户端订阅服务端的某个
        // registry.enableSimpleBroker("/topic");

        // 服务端接收地址前缀,用于服务端接收客户端的消息
        // registry.setApplicationDestinationPrefixes("/app");
    }

}

 

实体类

@Getter
@Setter
@ToString
@AllArgsConstructor
public class Msg implements Serializable {

    private String msgContent;

    private String fromUserId;

    @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone="GMT+8")
    private Date sendTime;

}

 

处理消息的controller

@Controller
public class MsgController {
    
    @MessageMapping("/app/serverReceive")  //@MessageMapping指定服务器接收消息的地址,此方法只处理发给该地址的消息
    @SendTo("/topic/serverPush")  //@SendTo指定消息的推送地址,会把return返回的数据推送到指定的地址
    public Msg msgHandler(Msg message){
        return message;
    }

}

 

前端

要引入2个核心的js文件:sockjs.js、stomp.js

<noscript>您使用的浏览器不支持websocket</noscript>

<script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

<script type="text/javascript">
    let stompClient = null;

    //连接到websocket
    function connect() {
        //SockJS连接的是端点endpoint
        let socket = new SockJS('/socket');
        stompClient = Stomp.over(socket);

        //参数:请求头设置,连接成功的回调函数,连接失败的回调函数
        stompClient.connect({},frame => {
            console.log("连接成功");
            //stompClient订阅的是推送地址。参数:订阅地址、回调函数
            stompClient.subscribe('/topic/serverPush',resp => {
                // console.log(resp.body)
            });
        },error => {
            console.log("连接失败")
        });
    }


    //断开连接
    function disconnect() {
        if (stompClient != null) {
            stompClient.disconnect();
        }
        console.log("已断开连接");
    }


    //发送消息
    function sendMsg() {
        //消息可以是对象
        let msg={ 'fromUserId': $('#fromUserId').val(),'msgContent': $('#msgContent').val(),'sendTime':new Date()};
        //参数:服务器接收地址,请求头设置,消息内容
        stompClient.send("/app/serverReceive", {}, JSON.stringify(msg));
    }

</script>

 

说明

1、不管在配置类中配不配置订阅地址的前缀,@SendTo()中都要写全全路径,不能省略前缀。

如果在配置中配置了接受地址的前缀,@MessageMapping中要省略前缀;如果没配置接受地址的前缀,@MessageMapping中必须要写全路径。
 

2、fromUserId给其它用户推送消息,推送时fromUserId本身也会收到自己发出的消息,但这个消息是本地的msg,其它人收到的msg是服务端推送的msg,可能在原msg的基础上做了修改。

消息最好在本地就配置好,尽量不要在服务端修改消息,以保证每个用户收到的消息是一致的(主要是发送方和接受方收到的消息一致)。
 

3、此种方式可以接受多种类型的消息内容,主要是可以接受对象形式的消息内容,但@SendTo适合群发,实现单点发送有点麻烦。此种方式与方式一相比,优点是可以接收对象形式的消息。

 

方式三(推荐)

在方式二的基础上改进,使用SimpMessagingTemplate代替@SendTo注解。
 

配置类

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 注册端点
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/socket").setAllowedOrigins("*").withSockJS();
    }


    /**
     * 配置服务器接收消息、推送消息的地址前缀
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 客户端订阅地址前缀,用于客户端订阅服务端的某个
        // 需要加上点对点的前缀
        registry.enableSimpleBroker("/topic","/user");

        // 服务端接收地址前缀,用于服务端接收客户端的消息
        registry.setApplicationDestinationPrefixes("/app");

        //点对点使用的订阅前缀
        registry.setUserDestinationPrefix("/user");
    }

}

 

实体类

@Getter
@Setter
@ToString
@AllArgsConstructor
public class Msg implements Serializable {

    private String msgContent;

    private String fromUserId;

    @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone="GMT+8")
    private Date sendTime;

}

 

处理消息的controller

@Controller
public class MsgController {

    @Autowired
    private SimpMessagingTemplate template;

    /**
     * 群发
     */
    @MessageMapping("/toAll")
    public void toAll(Msg msg) {
        //convertAndSend代替@SendTo指定目标地址
        template.convertAndSend("/topic/toAll", msg);
    }


    /**
     * 点对点
     */
    @MessageMapping("/toOne")
    // @Scheduled(fixedDelay = 1000L)  //可以使用定时器实现定时推送
    public void toOne(Msg msg) {
        //参数:当前用户的标识,目标地址,消息内容。会将消息推送到当前用户的频道中,所有订阅了当前用户的客户端都会收到消息
        template.convertAndSendToUser(msg.getFromUserId(), "/toOne", msg);
    }


}

 

前端

<noscript>您使用的浏览器不支持websocket</noscript>

<script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

<script type="text/javascript">
    let stompClient = null;

    //连接到websocket
    function connect() {
        //SockJS连接的是端点endpoint
        let socket = new SockJS('/socket');
        stompClient = Stomp.over(socket);

        //参数:请求头设置,连接成功的回调函数,连接失败的回调函数
        stompClient.connect({},frame => {
            console.log("连接成功");
            //订阅全体消息
            stompClient.subscribe('/topic/toAll',resp => {
                // console.log(resp.body)
            });
            //订阅点对点消息,/user/toUserId/toOne 此处订阅的是目标用户的消息
            stompClient.subscribe('/user/' + $("#toUserId").val() + '/toOne', resp => {
                // console.log("ok:"+resp.body);
            });
        },error => {
            console.log("连接失败")
        });
    }


    //断开连接
    function disconnect() {
        if (stompClient != null) {
            stompClient.disconnect();
        }
        console.log("已断开连接");
    }


    //群发消息
    function sendMsgToAll() {
        let msg={ 'fromUserId': $('#fromUserId').val(),'msgContent': $('#msgContent').val(),'sendDate':new Date()};
        //参数:服务器接收地址,请求头设置,消息内容
        stompClient.send("/app/toAll", {}, JSON.stringify(msg));
    }

    //点对点
    function sendMsgToOne() {
        let msg={ 'fromUserId': $('#fromUserId').val(),'msgContent': $('#msgContent').val(),'sendDate':new Date()};
        //服务器接收地址天@MessageMapping映射的即可
        stompClient.send("/app/toOne", {}, JSON.stringify(msg));
    }
    
</script>

 

说明
SimpMessagingTemplate比@SendTo更加灵活,支持多种发送方式,即可以实现群发,又可以实现点对点,且可以接收对象类型的消息,十分强大。

 

WebSocket的监听器

WebSocket的监听器可以监听以下事件

  • SessionSubscribeEvent 订阅
  • SessionUnsubscribeEvent 取消订阅
  • SessionDisconnectEvent 断开连接
  • SessionConnectEvent 建立连接
     

使用示例

新建类listener.ConnectEventListener

@Component  //放到spring容器中
public class ConnectEventListener implements ApplicationListener<SessionConnectEvent> {

    @Override
    public void onApplicationEvent(SessionConnectEvent event) {
        StompHeaderAccessor headerAccessor =  StompHeaderAccessor.wrap(event.getMessage());
        System.out.println("事件类型:"+headerAccessor.getCommand().getMessageType());
    }

}

需要实现ApplicationListener接口,泛型指定要监听的事件类型。一个类只能监听一个事件,如果要监听多个事件,需要写多个类。

 

WebSocket的拦截器

WebSocket的一次通信只需要1次握手,HandshakeInterceptor 握手拦截器可以在握手前后进行拦截,做一些处理。
 
新建类intecepter.MyHandShakeIntecepter

public class MyHandShakeIntecepter implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request,ServerHttpResponse response,WebSocketHandler wsHandler,Map<String, Object> attributes) {
        ServletServerHttpRequest req = (ServletServerHttpRequest) request;
        HttpSession session = req.getServletRequest().getSession();
        // 返回的boolean标识是否继续往下执行
        return true;
    }


    @Override
    public void afterHandshake(ServerHttpRequest request,ServerHttpResponse response, WebSocketHandler wsHandler,Exception exception) {
        ServletServerHttpRequest req = (ServletServerHttpRequest) request;
        HttpSession session = req.getServletRequest().getSession();
    }


}

 
需要在websocket的配置中添加要使用的拦截器

/**
 * 注册端点
 */
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/socket")
            .addInterceptors(new MyHandShakeIntecepter())  //拦截器
            .setAllowedOrigins("*")   
            .withSockJS();
}
;