Greetings |
---|
搬运自博文:https://www.cnblogs.com/tohxyblog/p/7112917.html
https://blog.csdn.net/a617137379/article/details/78765025
一、建立WebSocket连接
1.与HTTP的关系:
对HTTP的一种补充(Upgrade),两者之间有交集
2.连接过程(WebSocket
握手)
浏览器请求:
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket //告诉服务器, Connection: Upgrade //发起的是WebSocket协议,不是HTTP。 Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== //一个Base64 encode的值,浏览器随机生成,用于验证服务器是否为Websocket助理 Sec-WebSocket-Protocol: chat, superchat //一个用户定义的字符串,用来区分同URL下,不同的服务所需要的协议 Sec-WebSocket-Version: 13 //告诉服务器所使用的 WebSocket协议版本,一般为13 Origin: http://example.com
服务器响应:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade //确认协议已经升级为WebSocket协议 Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= //经过服务器确认,并且加密过后的 Sec-WebSocket-Key Sec-WebSocket-Protocol: chat //表示最终使用的协议
client通过验证server返回的Sec-WebSocket-Accept的值, 来确定两件事情:
首先,server理解websocket协议
如果server不理解, 那么server不会返回正确的Sec-WebSocket-Accept
如果server没有返回正确的Sec-WebSocket-Accept, 那么建立websocket连接失败
其次,server返回的response是对于client的此次reuqest的响应而不是之前的缓存,
主要是防止有些缓存服务器返回缓存的response
至此,WebSocket连接已经建立,HTTP协议已经升级为WebSocket协议
但是WebSocket协议没有规范payload的格式,
一般WebSocket的payload可以是文本也可以是二进制的,
比较多的情况是文本,至于这个文本是什么格式WebSocket协议本身并没有规定,由应用自己来定
比如现在要请求发送消息这个接口, 那么payload可以写成:
/send | params=我是消息
这里自定义了一个payload格式,:中坚线之前的是要调用的地址, 中竖线之后是参数.
由于格式是自定义的, 所以在服务端也需要定义解析这个payload格式的方法,这样才能最终将/send分发到相应的处理方法.
那有没有一种统一的协议呢? 这样就会有相应的已经实现的库来解析请求
这个统一的协议就是stomp协议,关于stomp协议的详情将在后文进行介绍
二、WebSocket协议作用(实现服务器端的消息推送)
1.消息推送的其他实现
(1)轮询
(2)阻塞式轮询long poll(请求在无推送数据时阻塞,一旦服务器端有了需要推送的数据就返回对该请求的响应,客户端收到请求后立即建立新的连接)
(1),(2)存在的问题:服务器不是主动将消息推送出去的(且(1)存在延迟),且轮询机制对服务器的资源消耗严重。
(3)WebSocket优势
a.解决了主动推送的问题
一旦连接建立后,服务器可以主动将消息推送给客户端,类似回调,即:S有消息了通知C,而不是C轮询查看S
客户跟接线员(服务器)建立持久连接后,客服(Handler,如PHP)在需要向客户推送信息的时候,通知接线员,然后由接线员将消息转交给客户;没有信息的时候由接线员(服务器)“接待”客户,不需要拖累本身处理速度就慢的客服(Handler)
b.相比轮询,降低服务器资源消耗:
轮询方法需要经常性地建立/关闭HTTP连接,由于HTTP是非状态性的,每次都要重新传输identity info(鉴别信息),这导致服务器为了解析这些频繁的HTTP请求,产生多余的(相比WebSocket)性能开销;
WebSocket只需要一次HTTP握手即可建立持久化的连接,即整个通讯过程建立在一次连接状态上,避免了服务器对鉴别信息的重复解析、验证过程,服务端会一直保存与客户端的连接状态,直到客户端关闭请求
二、WebSocket实例
搬运自https://www.jianshu.com/p/326290d38abe
在此特别感谢作者,教程非常靠谱,学到了很多
1.业务需求(Why WebSocket)
页面的某个组件需要动态的根据数据库中的内容的更新而即时的刷新出来,而传统的做法无论是轮询还是长连接对性能来说都不是很友好:如果前端使用轮询/长连接,而后台还需要去轮询数据库中数据的更新记录。
以上问题使用普通的HTTP协议无法解决。
有没有一种方式既可以在数据库更新数据的时候去通知后端取数据(或者直接把更新的内容推送到后端),又可以让后端把处理完毕的数据再直接推送到前端页面呢——使用WebSocket
2.SpringMVC简单整合WebSocket
Maven依赖
junit junit 3.8.1 test org.springframework spring-context ${spring.version} org.springframework spring-web ${spring.version} org.springframework spring-webmvc ${spring.version} org.springframework spring-websocket ${spring.version} org.springframework spring-messaging ${spring.version} provided javax.servlet javax.servlet-api 4.0.0 compile javax.servlet jstl 1.2 taglibs standard 1.1.2 org.java-websocket Java-WebSocket 1.3.5 log4j log4j 1.2.15 runtime com.fasterxml.jackson.core jackson-core 2.1.0 com.fasterxml.jackson.core jackson-databind 2.1.0 com.fasterxml.jackson.core jackson-annotations 2.1.0 1.8 1.8 UTF-8 UTF-8 4.2.5.RELEASE
(1)后端配置
自定义一个拦截器,在WebSocket握手过程执行的前后进行一些处理,
具体来说就是在WebSocket握手前,把浏览器请求中的session拿出来,再把session中的sesion_name属性拿到,
把sesion_name加到专门保存属性的一个map里面,key为WEBSOCKET_USERNAME,值就是session_name
继而以此session_name区分WebSocketHandler对不同用户(即浏览器)的处理
自定义拦截器SpringWebSocketHandlerInterceptor类,继承HttpSessionHandshakeInterceptor类
import java.util.Map; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.socket.WebSocketHandler; import javax.servlet.http.HttpSession; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; /* * WebSocket拦截器 */ public class SpringWebSocketHandlerInterceptor extends HttpSessionHandshakeInterceptor { //重写该类的beforeHandshake方法 @Override public boolean beforeHandshake (ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map
attributes) throws Exception { System.out.println("Before Handshake"); //如果该ServerHttpRequest请求是一个ServletServerHttpRequest请求的实例(父子关系) //ServerHttpRequest是父 if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; //拿到该请求对应的session,从session拿到SESSION_USERNAME HttpSession session = servletRequest.getServletRequest().getSession(false); if (session != null) { //使用userName(SESSION_USERNAME)区分WebSocketHandler,实现定向发送消息 String userName = (String) session.getAttribute("SESSION_USERNAME"); if (userName==null) { userName="default-system"; } //把userName放进保存属性map中,key为"WEBSOCKET_USERNAME" attributes.put("WEBSOCKET_USERNAME",userName); } } return super.beforeHandshake(request, response, wsHandler, attributes); } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) { // TODO Auto-generated method stub super.afterHandshake(request, response, wsHandler, ex); } }
握手器SpringWebSocketHandler类继承自TextWebSocketHandler类,处理WebSocket连接事项,
具体来说就是以一个名为users的ArrayList
连接成功时加入session,断开连接时移除相应的session,
另外该类可以实现方法,以接收浏览器发来的消息,
也可以实现方法,向不同的浏览器发消息(通过users的WEBSOCKET_USERNAME来区分)
代码:
import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.ArrayList; import org.apache.log4j.Logger; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; public class SpringWebSocketHandler extends TextWebSocketHandler { //使用全局的ArrayList来保存WebSocketSession对象,这个ArrayList相当于全体客户端 //可能会出现性能问题,最好用Map来存储,key用userid //在静态代码块中,在TextWebSocketHandler类加载的时候创建该ArrayList private static final ArrayList
users; //使用Log4J private static Logger logger = Logger.getLogger(SpringWebSocketHandler.class); static { users = new ArrayList (); } public SpringWebSocketHandler() { // TODO Auto-generated constructor stub } /* * 连接成功时候,会触发页面上onopen方法 */ public void afterConnectionEstablished(WebSocketSession session) throws Exception { // TODO Auto-generated method stub System.out.println("connect to the websocket success......当前量:"+users.size()); //建立WebSocket连接后往users数组中add当前客户端的session users.add(session); //实现自己业务,比如,当用户登录后,会把离线消息推送给用户 //TextMessage returnMessage = new TextMessage("你将收到的离线"); //session.sendMessage(returnMessage); } /* * 关闭连接时触发 */ public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { logger.debug("websocket connection closed......"); String username= (String) session.getAttributes().get("WEBSOCKET_USERNAME"); System.out.println("用户"+username+"已退出!"); //关闭WebSocket连接后在users数组中remove当前客户端的session users.remove(session); System.out.println("剩余在线用户"+users.size()); } /** * js调用websocket.send时候,会调用该方法,接收前端传来的消息 */ @Override protected void handleTextMessage (WebSocketSession session, TextMessage message) throws Exception { System.err.println(message.toString()); String username = (String) session.getAttributes().get("WEBSOCKET_USERNAME"); // 获取提交过来的消息详情 logger.info("收到用户 " + username + "的消息:" + message.toString()); //super.handleTextMessage(session, message); session.sendMessage(new TextMessage("reply msg:" + message.getPayload())); sendMessageToUsers(message); } public void handleTransportError (WebSocketSession session, Throwable exception) throws Exception { if(session.isOpen()){session.close();} logger.debug("websocket connection closed......"); users.remove(session); } public boolean supportsPartialMessages() { return false; } /** * 给某个用户发送消息 * * @param userName * @param message */ public void sendMessageToUser(String userName, TextMessage message) { for (WebSocketSession user : users) { if (user.getAttributes().get("WEBSOCKET_USERNAME").equals(userName)) { try { if (user.isOpen()) { user.sendMessage(message); } } catch (IOException e) { e.printStackTrace(); } break; } } } /** * 给所有在线用户发送消息 * * @param message */ public void sendMessageToUsers(TextMessage message) { for (WebSocketSession user : users) { try { if (user.isOpen()) { user.sendMessage(message); } } catch (IOException e) { e.printStackTrace(); } } } }
最后创建SpringWebSocketConfig类,用于注册WebSocketHandlers,
把刚才的拦截器和握手器都注册到WebSocketHandlerRegistry对象中,
SpringWebSocketConfig类继承WebMvcConfigurerAdapter类的同时实现WebSocketConfigurer接口:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.handler.TextWebSocketHandler; @Configuration @EnableWebMvc @EnableWebSocket public class SpringWebSocketConfig extends WebMvcConfigurerAdapter implements WebSocketConfigurer { public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { //把刚刚的握手器注册一下,给其指定拦截器SpringWebSocketHandlerInterceptor //(刚刚创建的自定义拦截器) //指明其拦截的URI请求 registry.addHandler(webSocketHandler(), "/websocket/socketServer.do") .addInterceptors(new SpringWebSocketHandlerInterceptor()); //把刚刚的握手器注册一下,给其指定拦截器SpringWebSocketHandlerInterceptor //(刚刚创建的自定义拦截器) //指明其拦截的SockJS请求 //SockJS是一个浏览器JavaScript库,它提供了一个类似于网络的对象。 //提供了一个连贯的、跨浏览器的Javascript API, //它在浏览器和web服务器之间创建了一个低延迟、全双工、跨域通信通道。 registry.addHandler(webSocketHandler(), "/sockjs/socketServer.do") .addInterceptors(new SpringWebSocketHandlerInterceptor()).withSockJS(); } //将SpringWebSocketHandler(握手器)交给Spring容器管理 @Bean public TextWebSocketHandler webSocketHandler(){ return new SpringWebSocketHandler(); } }
controller层连接到WebSocket的代码:
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.socket.TextMessage; @Controller @RequestMapping("/") public class SignlController { @Bean public SpringWebSocketHandler infoHandler() { return new SpringWebSocketHandler(); } @RequestMapping("start") public String start(HttpServletRequest request,HttpServletResponse response) { return "index"; } @RequestMapping("/websocket/login") public ModelAndView login (HttpServletRequest request, HttpServletResponse response) throws Exception { //把登录请求中的username属性取出来放到session中 String username = request.getParameter("username"); System.out.println(username+"登录"); /* *request.getSession(true):若存在会话则返回该会话,否则新建一个会话 *request.getSession(false):若存在会话则返回该会话,否则返回NULL */ HttpSession session = request.getSession(false); //用户登录的时候,往当前session中添加属性SESSION_USERNAME,值为username session.setAttribute("SESSION_USERNAME", username); //response.sendRedirect("/quicksand/jsp/websocket.jsp"); return new ModelAndView("shareMsg"); } @RequestMapping("send") public String send(HttpServletRequest request) { String username = request.getParameter("username"); System.out.println(username); //调用处理器的sendMessageToUser()方法向当前用户发送信息 infoHandler().sendMessageToUser(username, new TextMessage("你好,测试!!!!")); return "shareMsg"; } }
最后是前端jsp的hello websocket的测试代码:
登陆的jsp,用于向websocket连接内的某个用户发送消息
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
Hello World!
发送消息和接收消息的jsp代码:
SockJS 是一个浏览器上运行的 JavaScript 库,如果浏览器不支持 WebSocket,该库可以模拟对 WebSocket 的支持,实现浏览器和 Web 服务器之间低延迟、全双工、跨域的通讯通道
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
Insert title here 请输入:
3.基于STOMP协议的WebSocket
使用原始的websocket,实现后台消息的主动推送,但是这种方式过于偏向底层,
需要开发人员去手动的保存用户连接到websocket中的信息,
这个信息不仅仅是用户的id、name而已,还要保存用户的订阅信息,因为有可能所有已连接用户需要推送的消息是不一样的,
而且可能一个用户会订阅很多的推送信息,比如说:在一个新闻网页中,有的用户对军事感兴趣,有的用户对科技感兴趣,
有的对开源的代码感兴趣,而有的可能对所有的都感兴趣。
如果以上这些东西都需要根据数据库实现实时更新的话,再使用原始的websocket来管理就会变成十分麻烦。
使用STOMP的好处在于,它是一种消息队列模式,
可以使用生产者与消费者的思想来认识它,发送消息的是生产者,接收消息的是消费者。
而消费者可以通过订阅不同的destination,来获得不同的推送消息,不需要开发人员去管理这些订阅与推送目的地之前的关系,spring官网有一个简单的spring boot的stomp-demo。
stomp是一个用于client之间进行异步消息传输的简单文本协议, 全称是Simple Text Oriented Messaging Protocol.
对于stomp协议来说, client分为消费者client与生产者client两种.
server是broker, 也就是消息队列的管理者.
stomp协议并不是为WebSocket设计的,它是属于消息队列的一种协议,和amqp,jms平级.
只不过由于它的简单性,恰巧可以用于定义WebSocket的消息体格式.
stomp协议很多mq都已支持, 比如rabbitmq, activemq. 很多语言也都有stomp协议的解析client库.
可以这么理解, websocket结合stomp相当于一个面向公网对用户比较友好的一种消息队列.
stomp协议中的client分为消费者与生产者:
生产者: 通过SEND命令给某个目的地址(destination)发送消息.
消费者: 通过SUBSCRIBE命令订阅某个目的地址(destination),
当生产者发送消息到目的地址后, 订阅此目的地址的消费者会即时收到消息.
stomp协议的结构与http结构相似,由三部分组成: 命令, header, 消息体. , 结构如下:
COMMAND
header1:value1
header2:value2
Body^@
其中^@代表null结尾.
命令与header使用utf-8格式, body可以是二进制也可以是文本.
命令有SEND, SUBSCRIBE, MESSAGE, CONNECT, CONNECTED等.
header类似http有content-length, content-type等.
消息体类似http可以是二进制也可以是文本.
发送消息使用SEND这个COMMAND, 如下:
SEND
destination:/topic/a
content-type:text/plainhello world
^@
其中destination这个header的值为发送消息的目的地址.
上述SEND命令消息的意思为, 给/topic/a这个目的地址发送一条类型为text/plain, 内容是hello world的消息.
所有订阅/topic/a这个目的地址的消费者client都会收到hello world这条消息.
stomp协议并没有规定destination的格式, 这个是由使用stomp协议的应用自己来定义.
比如, /topic/a, /queue/a, queue.a, topic.a, topic-a, queue-a对于stomp协议来说都是正确的.
应用可以自己规定不同的格式以及此格式代表的含义.
比如, 应用自己可以定义以/topic打头的为发布订阅模式, 消息会被所有消费者client收到,
以/queue打头的为负载平衡模式, 只会被一个消费者client收到.
client发送SEND命令消息如何确保server收到了这条消息呢?
协议规定, 可以在SEND命令消息中加入receipt header.
receipt header的值可以唯一确定一次send.
server收到有receipt header的SEND命令消息后, 需要回复一个RECEIPT命令消息,
里面会包含receipt-id header, receipt-id的值就是SEND命令消息中receipt header的值.
这样当client收到了这条RECEIPT命令消息后, 就能确定server已收到SEND命令消息.
订阅消息用SUBSCRIBE命令, 如下:
SUBSCRIBE id:0 destination:/topic/foo ack:client ^@
上述代表client订阅/topic/foo
这个目的地址.
其中多了两个新的header: id与ack.
id能唯一确定一个订阅.
一个client对于一个server可以订阅多次, 甚至对于同一个目的地址都可以订阅多次.
为了唯一确定一次订阅, 协议规定必须包含id header, 此id要求在同一连接中唯一.
ack header告诉server, server如何确认client已经收到消息.
ack 有三个值: auto, client, client-individual
auto表示client收到消息后不会对server进行确认.
client表示client收到消息后需要对server发ack进行确认.
这个确认是累积的, 意思是说收到某条消息的ack, 那么这条消息之前的所有的消息, server都认为client已收到.
client-individual与client类似. 只不过不是累积的. 每收到一条消息都需要给server回复ack来确认.
取消订阅用UNSUBSCRIBE这个命令
UNSUBSCRIBE id:0 ^@
只需要传一个id header.
这个id header的值来自订阅时id header值. 这样server才能唯一确定到底要取消哪个订阅.
当有生产者client给目的地址发消息后, 首先server会收到消息, server收到消息后会把消息发送给所有订阅这个目的地址的client, 那么server是如何发送这个消息到消费者client的呢?
server发送消息用MESSAGE这个命令来给client发送消息, 如下
MESSAGE
subscription:0
message-id:007
destination:/queue/a
content-type:text/plainhello queue a^@
message-id这个header的值能唯一确定一条消息
subscription的值就是订阅时SUBSCRIBE命令中id header的值, 表示这条消息属于哪个订阅.
到此, 介绍了一些stomp常用的命令, 还有一些其他命令, 可以查看stomp协议文档:
https://stomp.github.io/stomp-specification-1.2.html
总结
由于http是一个单工的协议, server不能主动发送消息给client, 导致http在处理实时性要求高的应用时效率不高.
为了提高效率, 我们使用了全双工的WebSocket协议, 可以让server主动推送消息.
又由于websocket协议是个低层协议, 不是应用层协议, 未对payload的格式进行规范, 导致我们需要自己定义消息体格式,
而自己解析消息体, 成本高, 扩展性也不好,
所以我们引入了已被很多库和消息队列厂商实现的stomp协议, 将websocket协议与stomp协议结合.
再总结一下websocket与stomp的优点
websocket相对于http的优点:
全双工. 相对于http协议只能由client发送消息. 全双工的websocket协议, server与client都可以发送消息.
消息体更轻量. http的一个请求比websocket的请求大不少. 主要因为http的每次请求都要加很多的header.
stomp over websocket相对于websocket的优点:
不需要自己去规定消息的格式, 以及对消息的格式做解析.
由于stomp是一个统一的标准, 有很多库与厂商都对stomp协议进行了支持,成本低,扩展好.
spring websocket的架构
上面的图是spring WebSocket的架构图.
其中有以下几个角色:
生产者client: 发送send命令到某个目的地址(destination)的client.
消费者client: 订阅某个目的地址(destination), 并接收此目的地址所推送过来的消息的client.
request channel: 一组用来接收生产者client所推送过来的消息的线程池.
response channel: 一组用来推送消息给消费者client的线程池.
broker: 消息队列管理者. 简单讲就是记录哪些client订阅了哪个目的地址(destination).
应用目的地址(图中的”/app”): 发送到这类目的地址的消息在到达broker之前, 会先路由到由应用写的某个方法. 相当于对进入broker的消息进行一次拦截, 目的是针对消息做一些业务处理.
非应用目的地址(图中的”/topic”): 发送到这类目的地址的消息会直接转到broker. 不会被应用拦截.
SimAnnotatonMethod: 发送到应用目的地址的消息在到达broker之前, 先路由到的方法(针对消息做一些业务处理), 这部分代码是由应用控制的.
一个消息从生产者发出到消费者消费, 流程如下:
生产者通过发送一条SEND命令消息到某个目的地址(destination)
服务端request channel接受到这条SEND命令消息
如果目的地址是应用目的地址则转到相应的由应用自己写的业务方法做处理, 再转到broker.
如果目的地址是非应用目的地址则直接转到broker.
broker通过SEND命令消息来构建MESSAGE命令消息,
再通过response channel推送MESSAGE命令消息到所有订阅此目的地址的消费者.
4.实例,基于springMVC实现stomp协议的websocket
(1)注册stomp终端,并配置broker
继承AbstractWebSocketMessageBrokerConfigurer重写configureMessageBroker方法,定义发布、订阅的请求前缀
代码如下:
import java.security.Principal; import java.util.Map; import org.springframework.context.annotation.Configuration; import org.springframework.http.server.ServerHttpRequest; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.server.support.DefaultHandshakeHandler; @Configuration @EnableWebSocketMessageBroker public class WebSocketStompConfig extends AbstractWebSocketMessageBrokerConfigurer{ public void registerStompEndpoints(StompEndpointRegistry registry) { //url /stomp视为一个stomp的终端,将此终端加入到StompEndpointRegistry对象中 registry.addEndpoint("/stomp") //为此终端指定一个握手处理器DefaultHandshakeHandler() //重写其determineUser方法 .setHandshakeHandler( new DefaultHandshakeHandler() { @Override protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map
attributes) { //将客户端封装为Principal对象,将属性Map中的“name”属性作为客户端的标识 Object o = attributes.get("name"); return new FastPrincipal(o.toString()); } }) //添加socket拦截器,用于从请求中获取客户端标识参数 .addInterceptors(new HandleShakeInterceptors()).withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { //客户端发送消息的请求前缀 //有这些前缀的会被到有@SubscribeMapping与@MessageMapping的业务方法拦截 registry.setApplicationDestinationPrefixes("/app"); //客户端订阅消息的请求前缀,有这些前缀的会路由到broker //一般topic为发布订阅模式,queue负载均衡模式 registry.enableSimpleBroker("/topic", "/queue"); //服务端通知客户端的前缀,可以不设置,默认为user registry.setUserDestinationPrefix("/user"); /* 如果是用自己的消息中间件,则按照下面的去配置,删除上面的配置 * registry.enableStompBrokerRelay("/topic", "/queue") .setRelayHost("rabbit.someotherserver") .setRelayPort(62623) .setClientLogin("marcopolo") .setClientPasscode("letmein01"); registry.setApplicationDestinationPrefixes("/app", "/foo"); * */ } //定义一个权限验证类 class FastPrincipal implements Principal { private final String name; public FastPrincipal(String name) { this.name = name; } public String getName() { return name; } } }
然后是一个自定义的握手拦截器,用户验证连接是否合法,这里只是做了简单的处理,在执行握手之前,将请求中的用户名取出来作为属性map中key“name”的值
上面注册stomp终端过程中determUser方法会使用属性map中key"name"的值作为对客户端的标识
这只是一种简单的实现,具体可根据实际的业务去改写,代码:
import java.util.Map; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeInterceptor; /** * 检查握手请求和响应, 对WebSocketHandler传递属性 */ public class HandleShakeInterceptors implements HandshakeInterceptor { /** * 在握手之前执行该方法, 继续握手返回true, 中断握手返回false. * 通过attributes参数设置WebSocketSession的属性 * * @param request * @param response * @param wsHandler * @param attributes * @return * @throws Exception */ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map
attributes) throws Exception { String name= ((ServletServerHttpRequest)request) .getServletRequest().getParameter("name"); System.out.println("======================Interceptor" + name); //保存客户端标识 attributes.put("name", "8888"); return true; } /** * 在握手之后执行该方法. 无论是否握手成功都指明了响应状态码和相应头. * * @param request * @param response * @param wsHandler * @param exception */ public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } }
(2)在controller层中写具体的app/拦截到url所做出的具体业务处理:
package cn.seisys.rpf.stompController; import java.io.UnsupportedEncodingException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.MessagingException; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.RestController; @RestController public class StompController { @Autowired SimpMessagingTemplate SMT; @MessageMapping("/send") public void subscription(String str) throws MessagingException, UnsupportedEncodingException { System.err.println(str); SMT.convertAndSend("/topic/sub","开始推送消息了:"+str); } }
Tips:SimpMessagingTemplate
这个对象可以实现注解@sendto
或者@sendtoUser
的所有功能,并且可以在任意地方使用
(sendto系列注解必须要在controller中和@MassageMapping一起使用),用它就可以实现后台的主动推送消息。
当然sendto也有它的好处,比如可以直接将pojo转json字符串发到对应的消费者。
(3)前端代码
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
stomp
Greetings
这个前端的demo是直接从spring官网复制的,它的连接和发送方式和原生的websocket是完全不一样的,注意以下几点:
(1)通过sockJS绑定好服务器中配置的endpoint(/stomp),并通过stomp.over方式创建一个stompClient,完成客户端的创建
(2)再通过stompClient.subscribe订阅多个destination的消息
(3)通过stompClient.send方法发送消息
5.实例,STOMP协议的Java客户端实现
(1)应用背景
某个业务场景需要通过数据库中某个表的更新,触发后端对应的方法取出数据并推送到前端
Spring对此也提供了支持
注意在负责启动客户端连接的runStompClient()使用了ListenableFuture
ListenableFuture顾名思义就是可以监听的Future,它是对java原生Future的扩展增强。
java原生Future表示一个异步计算任务,当任务完成时可以得到计算结果。
如果我们希望一旦计算完成就拿到结果展示给用户或者做另外的计算,就必须使用另一个线程不断的查询计算状态。这样做代码复杂,而且效率低下。
使用ListenableFuture可以检测Future是否完成了,如果完成就自动调用回调函数,这样可以减少并发程序的复杂度
另外,补充一下ListenableFuture
搬运自https://www.cnblogs.com/davidwang456/p/5321413.html
StandardWebSocketClient通过标准的java websocket api编程式初始化一个连接到websocket服务器的websocket请求
它有两个私有成员:
private final WebSocketContainer webSocketContainer; private AsyncListenableTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor();其中,WebSocketContainer使用java本身提供的api获取:
public StandardWebSocketClient()
{ this.webSocketContainer = ContainerProvider.getWebSocketContainer(); }
其中,SimpleAsyncTaskExecutor为每个task触发一个新的线程来异步的执行。
总的来说就是在连接完成时,为该WebSocket请求初始化一个WebSocket容器
再为其分配一个线程来异步地执行对其请求的处理
/** * Executes the given task, within a concurrency throttle * if configured (through the superclass's settings). * @see #doExecute(Runnable) */ @Override public void execute(Runnable task) { execute(task, TIMEOUT_INDEFINITE); } /** * Executes the given task, within a concurrency throttle * if configured (through the superclass's settings). *Executes urgent tasks (with 'immediate' timeout) directly, * bypassing the concurrency throttle (if active). All other * tasks are subject to throttling. * @see #TIMEOUT_IMMEDIATE * @see #doExecute(Runnable) */ @Override public void execute(Runnable task, long startTimeout) { Assert.notNull(task, "Runnable must not be null"); if (isThrottleActive() && startTimeout > TIMEOUT_IMMEDIATE) { this.concurrencyThrottle.beforeAccess(); doExecute(new ConcurrencyThrottlingRunnable(task)); } else { doExecute(task); } } /** * Template method for the actual execution of a task. *
The default implementation creates a new Thread and starts it. * @param task the Runnable to execute * @see #setThreadFactory * @see #createThread * @see java.lang.Thread#start() */ protected void doExecute(Runnable task) { Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task)); thread.start(); }
StandardWebSocketClient的握手过程
根据uri中的端口信息,拿到端口,和远程主机的IP
根据以上信息分别创建 InetSocketAddress对象localAddress、remoteAddress
进而可以由这两个对象创建StandardWebSocketSession对象,
创建这个对象的构造器参数分别是headers,attributes, localAddress, remoteAddress
然后在 ClientEndpointConfig.Builder对象中加入此session的headers/protocols/extensions相关的配置信息
接着为此StandardWebSocketSession对象分配一个终端,
final Endpoint endpoint = new StandardWebSocketHandlerAdapter(webSocketHandler, session);
StandardWebSocketClient
该连接所要执行的连接任务为Callable
对象,其call()方法:webSocketContainer.connectToServer(endpoint, configBuilder.build(), uri); return session;
可知执行此连接任务后,可以执行webSocketContainer.connectToServer()方法真正连接到Server,
并返回此连接的session(WebSocketSession)
如果StandardWebSocketClient的taskExecutor成员为空,则为其执行上述的连接任务(执行完毕后可建立与服务器的连接并返回session)
@Override protected ListenableFuturedoHandshakeInternal(WebSocketHandler webSocketHandler, HttpHeaders headers, final URI uri, List protocols, List extensions, Map attributes) { int port = getPort(uri); InetSocketAddress localAddress = new InetSocketAddress(getLocalHost(), port); InetSocketAddress remoteAddress = new InetSocketAddress(uri.getHost(), port); final StandardWebSocketSession session = new StandardWebSocketSession (headers,attributes, localAddress, remoteAddress); final ClientEndpointConfig.Builder configBuilder = ClientEndpointConfig.Builder.create(); configBuilder.configurator(new StandardWebSocketClientConfigurator(headers)); configBuilder.preferredSubprotocols(protocols); configBuilder.extensions(adaptExtensions(extensions)); final Endpoint endpoint = new StandardWebSocketHandlerAdapter(webSocketHandler, session); Callable connectTask = new Callable () { @Override public WebSocketSession call() throws Exception { webSocketContainer.connectToServer(endpoint, configBuilder.build(), uri); return session; } }; if (this.taskExecutor != null) { return this.taskExecutor.submitListenable(connectTask); } else { ListenableFutureTask task = new ListenableFutureTask (connectTask); task.run(); return task; } }
package seisys.rpf.StompClient; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.lang.reflect.Type; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSession.Receiptable; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.util.concurrent.ListenableFuture; import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; import org.springframework.web.socket.sockjs.client.SockJsClient; import org.springframework.web.socket.sockjs.client.Transport; import org.springframework.web.socket.sockjs.client.WebSocketTransport; import org.springframework.web.socket.sockjs.frame.Jackson2SockJsMessageCodec; public class Client { //使用log4j final static Logger LOGGER=LoggerFactory.getLogger(Client.class); //请求头 private final static WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); //stomp客户端 private WebSocketStompClient client=null; //socket客户端 private SockJsClient SockJsClient=null; //连接池 private ThreadPoolTaskScheduler Ttask=null; //连接会话 private StompSession session=null; //配置参数 private static Map
WebSocketConfig; //期待的返回标志位,当收到的消息与配置中exceptionRecv相等时为true public volatile boolean RecvFlag=false; //程序入口 public static void main(String[] args) throws Exception { String sendMsg="我是java版的stomp over websocket的客户端"; //如果args[]为空发送默认的消息,否则发送args[0]表示的消息 sendMsg=(args!=null&&args.length!=0)? args[0]:sendMsg; Client myClient = new Client(); //读取配置文件 WebSocketConfig=myClient.readConfig(); //使用配置文件加载客户端 if (WebSocketConfig!=null) { //连接到客户端 myClient.runStompClient (myClient, WebSocketConfig.get("URI"), WebSocketConfig.get("subscribe"), WebSocketConfig.get("send"), sendMsg); LOGGER.info("成功使用配置文件加载客户端"); }else {//使用默认参数连接到客户端 myClient.runStompClient(myClient, //终端 "ws://localhost:8080/StompWithSSM/stomp", //订阅URI前缀 "/topic/message", //发布URI前缀 "/app/send", sendMsg); LOGGER.info("使用默认参数加载客户端"); } //持续等待返回标志位为true,每隔三秒判断一次 while (!myClient.RecvFlag) { LOGGER.info ("-------------------持续等待返回验证消息中……,当前flag:"+myClient.RecvFlag); Thread.sleep(3000); } //关闭所有连接终止程序 myClient.Ttask.destroy(); myClient.SockJsClient.stop(); myClient.client.stop(); myClient.session.disconnect(); System.exit(0); } public void runStompClient (Client client, String URI, String subscribe, String send, final String sendMsg) throws ExecutionException, InterruptedException, UnsupportedEncodingException{ //连接到对应的endpoint点上,也就是建立起websocket连接 //ListenableFuture表示一个及时通知的异步计算, //也就是一旦client.connect(URI)方法有了返回,则可以立即进行下一步的操作 //在本上下文里,一旦建立起websocket连接,就可以f.get()拿到一个tomp协议的会话 ListenableFuture f = client.connect(URI); //连接建立成功后返回一个stomp协议的会话 StompSession stompSession = f.get(); LOGGER.info("Subscribing to greeting topic using session " + stompSession); //绑定订阅的消息地址subscribe client.subscribeGreetings(subscribe, stompSession); //设置Receipt头,若不设置无法接受返回消息 stompSession.setAutoReceipt(true); //绑定发送的的地址send,注意这里使用的字节方式发送数据 Receiptable rec= stompSession.send(send,sendMsg.getBytes("UTF-8")); //添加消息发送成功的回调 rec.addReceiptLostTask(new Runnable() { public void run() { LOGGER.info("消息发送成功,发送内容为:"+sendMsg); } }); } public ListenableFuture connect(String url) { Transport webSocketTransport = new WebSocketTransport(new StandardWebSocketClient()); List transports = Collections.singletonList(webSocketTransport); SockJsClient sockJsClient = new SockJsClient(transports); //设置对应的解码器,理论支持任意的pojo自带转json格式发送, //这里只使用字节方式发送和接收数据 sockJsClient.setMessageCodec(new Jackson2SockJsMessageCodec()); WebSocketStompClient stompClient = new WebSocketStompClient(sockJsClient); stompClient.setReceiptTimeLimit(300); stompClient.setDefaultHeartbeat(new long[]{10000l,10000l}); ThreadPoolTaskScheduler task=new ThreadPoolTaskScheduler(); task.initialize(); stompClient.setTaskScheduler(task); client=stompClient; SockJsClient=sockJsClient; Ttask=task; return stompClient.connect(url, headers, new MyHandler(), "localhost", 8080); } public void subscribeGreetings(String url, StompSession stompSession) throws ExecutionException, InterruptedException { stompSession.subscribe(url, new StompFrameHandler() { public Type getPayloadType(StompHeaders stompHeaders) { return byte[].class;//设置订阅到消息用字节方式接收 } public void handleFrame(StompHeaders stompHeaders, Object o) { String recv=null; try { recv = new String((byte[]) o,"UTF-8"); } catch (UnsupportedEncodingException e) { // TODO Auto-generated catch block e.printStackTrace(); } LOGGER.info("收到返回的消息" + recv); if (WebSocketConfig!=null&&recv.equals("exceptionRecv")) { RecvFlag=true; }else if (recv.equals("success")) { RecvFlag=true; } } }); } private class MyHandler extends StompSessionHandlerAdapter { public void afterConnected(StompSession stompSession, StompHeaders stompHeaders) { session=stompSession; LOGGER.info("连接成功"); } @Override public void handleTransportError(StompSession session, Throwable exception) { LOGGER.error("连接出现异常"); exception.printStackTrace(); } @Override public void handleFrame(StompHeaders headers, Object payload) { super.handleFrame(headers, payload); LOGGER.info("=========================handleFrame"); } } private Map readConfig() { Map ConfigMap=null; String [] keys= {"URI","subscribe","send","exceptionRecv"}; //D:\\dkWorkSpace\\Java\\SocketGettingStart\\StompClient\\WebSocketConfig.properties File file =new File("src/resource/WebSocketConfig.properties"); if (file.exists()) { LOGGER.info("开始读取配置文件"); ConfigMap=new HashMap (); FileInputStream FIS=null; InputStreamReader ISReader=null; BufferedReader reader=null; try { FIS=new FileInputStream(file); ISReader=new InputStreamReader(FIS,"UTF-8"); reader=new BufferedReader(ISReader); String readline=null; LOGGER.info("开始按行读取配置文件"); while ((readline=reader.readLine())!=null) { LOGGER.info("当前行内容:"+readline); String readStr []=readline.split("="); if (readStr==null||readStr.length!=2) { LOGGER.error("配置文件格式不符合规范,必须一行一个配置,并用‘=’分割,当前行内容:"+readline); } ConfigMap.put(readStr[0], readStr[1]); } LOGGER.info("文件读取完成,最终的配置信息:"+ConfigMap); boolean notice=false; for (int i = 0; i < keys.length; i++) { if (!ConfigMap.containsKey(keys[i])) { LOGGER.error("缺少对关键参数:"+keys[i]+"的配置,配置将无法生效"); notice=true; } } ConfigMap=notice? null:ConfigMap; } catch (Exception e) { LOGGER.info("文件读取过程发生异常:"+e.getMessage()); }finally{ if (reader!=null) { try { FIS.close(); ISReader.close(); reader.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }else { LOGGER.info("不存在配置文件,请检查路径:"); LOGGER.info("开始使用默认socketConfig"); } return ConfigMap; } }