WEBSOCKET集成指南
目录
1. websocket对于spring有要求,至少4.0以上。... 11
3. 部署websocket的容器也有要求,Tomcat从7.0.27开始支持,我使用tomcat 8.5版本可以。 11
4. 前端页面需要定时发送心跳请求,不然会有超时,断了,那么就无法再推送了。 11
五、 Websocket实现session共享方法... 11
一、 为何使用websocket
随着网络时代的发展,传统的响应式网站越来越不满足项目的需求,比如一些特殊的项目需求是服务器通知客户端,而不是服务器响应式的应答客户端。在这个大的前提下面,websocket未出来之前解决办法:
1. 轮询
客户端和服务器之间会一直进行连接,每隔一段时间就询问一次。客户端会轮询,有没有新消息。这种方式连接数会很多,一个接受,一个发送。而且每次发送请求都会有Http的Header,会很耗流量,也会消耗CPU的利用率。
2. 长轮询
长轮询是对轮询的改进版,客户端发送HTTP给服务器之后,有没有新消息,如果没有新消息,就一直等待。当有新消息的时候,才会返回给客户端。在某种程度上减小了网络带宽和CPU利用率等问题。但是这种方式还是有一种弊端:例如假设服务器端的数据更新速度很快,服务器在传送一个数据包给客户端后必须等待客户端的下一个Get请求到来,才能传递第二个更新的数据包给客户端,那么这样的话,客户端显示实时数据最快的时间为2×RTT(往返时间),而且如果在网络拥塞的情况下,这个时间用户是不能接受的,比如在股市的的报价上。另外,由于http数据包的头部数据量往往很大(通常有400多个字节),但是真正被服务器需要的数据却很少(有时只有10个字节左右),这样的数据包在网络上周期性的传输,难免对网络带宽是一种浪费。
随着H5技术的推进,新的技术websocket完美的解决了上面的难题,真正的实现了WEB的实时通讯。使B/S模式具有了C/S模式的实时通信能力。
二、 Websocket原理
Websocket是应用层第七层上的一个应用层协议,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。Websocket的数据传输是frame形式传输的,比如会将一条消息分为几个frame,按照先后顺序传输出去。这样做会有几个好处:
1) 大数据的传输可以分片传输,不用考虑到数据大小导致的长度标志位不足够的情况。
2) 和http的chunk一样,可以边生成数据边传递消息,即提高传输效率。
三、 Websocket与java集成
1. 前置条件
maven、Spring、SpringMVC、redis
2. 后端集成操作
1) 在项目pom文件加入如下依赖
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>${spring.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
2) 配置websocket请求规则
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer{ @Bean public WebSocketHandler myHandler() { return new MyWebSocketHandler(); } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myHandler(), "/ws").addInterceptors(new HandShake()).setAllowedOrigins("*"); //底下的是防止不支持websocket的替代方案 registry.addHandler(myHandler(), "/ws/sockjs").setAllowedOrigins("*").addInterceptors(new HandShake()).withSockJS(); } }
3) 配置握手之前如何获取会话主键获取
新建类实现接口HandshakeInterceptor,重写里面的方法:beforeHandshake,如如下案例,获取userID
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { if (request instanceof ServletServerHttpRequest) { // 标记用户 String userId = request.getURI().getQuery(); if (StringUtils.isBlank(userId)){ return false; }else { if (userId.indexOf("=")>=-1){ userId = userId.split("=")[1]; }else { return false; } } if (userId != null) { attributes.put("userId", userId); } else { return false; } } return true; }
4) 连接建立后会话处理类
会话处理类需要实现接口WebSocketHandler,实现接口里面的方法即可。具体可看如下代码:
public class MyWebSocketHandler implements WebSocketHandler { /*生成logger*/ private static final Logger logger = LoggerFactory.getLogger(MyWebSocketHandler.class); //存放会话 private static ConcurrentHashMap<String,Set<WebSocketSession>> userSessionMap = new ConcurrentHashMap<>(); //计数 private static AtomicLong onlineCount = new AtomicLong(0); private String getUserId(WebSocketSession session){ return (String) session.getAttributes().get("userId"); } /** * 连接建立之后,执行方法 * @param webSocketSession * @throws Exception */ @Override public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception { String id = getUserId(webSocketSession); //如果包含在表里面,那么直接添加 if (userSessionMap.contains(id)){ userSessionMap.get(id).add(webSocketSession); }else { Set<WebSocketSession> sessions = new HashSet<>(); sessions.add(webSocketSession); userSessionMap.put(id,sessions); } onlineCount.incrementAndGet(); logger.info("新建立一个连接,建立的连接的主键是:\t"+id+"\t当前在线人数 :\t"+onlineCount); sendMessageToUser(id,new TextMessage("连接建立成功")); } /** * 消息处理,在客户端通过Websocket API发送的消息会经过这里,然后进行相应的处理 * @param webSocketSession * @param webSocketMessage * @throws Exception */ @Override public void handleMessage(WebSocketSession webSocketSession, WebSocketMessage<?> webSocketMessage) throws Exception { if (webSocketMessage.getPayloadLength()==0){ return; } logger.info("服务器接受到"+getUserId(webSocketSession)+"\t的消息:\t"+webSocketMessage.getPayload().toString()); sendMessage(webSocketSession,new TextMessage(webSocketMessage.getPayload().toString())); } /** *消息传输错误处理 * @param webSocketSession * @param throwable * @throws Exception */ @Override public void handleTransportError(WebSocketSession webSocketSession, Throwable throwable) throws Exception { if (webSocketSession.isOpen()){ webSocketSession.close(); } deleteSessionFromCache(webSocketSession); } /** * 关闭连接后 * @param webSocketSession * @param closeStatus * @throws Exception */ @Override public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus closeStatus) throws Exception { logger.info("Session {} disconnected. Because of {}", webSocketSession.getId(), closeStatus); deleteSessionFromCache(webSocketSession); } @Override public boolean supportsPartialMessages() { return false; } /** * 向指定的客户端发送消息 * @param id * @param message */ public void sendMessageToUser(String id,TextMessage message){ try { if (StringUtils.isNotBlank(id)){ Set<WebSocketSession> sessions = userSessionMap.get(id); sendMessage(sessions,message); }else { logger.info("传入的id为空,所以不发送消息"); } }catch (Exception e){ logger.error("发送消息失败,失败原因是:\t",e); } } /** * 向指定的客户端发送消息 * @param session * @param message */ private void sendMessage(WebSocketSession session, TextMessage message){ try { session.sendMessage(message); } catch (IOException e) { logger.error("发送消息异常",e); } } /** * 发送消息 * @param sessions * @param message */ private void sendMessage(Set<WebSocketSession> sessions, TextMessage message){ if (sessions==null||sessions.size()==0){ return; } for (WebSocketSession session: sessions ) { try { session.sendMessage(message); } catch (IOException e) { logger.error("发送消息异常",e); } } } /** * 从缓存里面删除次会话 * @param webSocketSession */ private void deleteSessionFromCache(WebSocketSession webSocketSession){ String id = getUserId(webSocketSession); logger.info("关闭的连接主键:\t"+id); Set<WebSocketSession> sessions = userSessionMap.get(id); sessions.remove(webSocketSession); //如果客户端的连结数0,直接移除 if (sessions.size()==0){ userSessionMap.remove(id); } onlineCount.decrementAndGet(); logger.info("当前在线用户数: {}"+onlineCount); } }
3. 前端集成操作
案例如下,前端js主要做的事情,判断是否支持websocket,支持,那么建立连接,定义weosocket的函数onmessage、onopen、onclose、onerror
var socket; if(typeof(WebSocket) == "undefined") { console.log("您的浏览器不支持WebSocket"); }else { console.log("您的浏览器支持WebSocket"); }
var contextPath = '${pageContext.request.contextPath}'; var ip = window.location.host; var url ; if (contextPath){ url = "ws://"+ip+"/"+contextPath+"/ws?userId="+parseInt(Math.random()*100); }else { url = "ws://"+ip+"/ws?userId="+parseInt(Math.random()*100); } //前面是协议,后面是项目名称/监听的url,加入uid是为了区分不同的页面 console.log(url); initSocket(url);
function initSocket(url) { //初始化一个socket客户端 socket = new WebSocket(url); //打开事件 socket.onopen = function() { console.log("Socket 已打开"); //socket.send("这是来自客户端的消息" + location.href + new Date()); };
//获得消息事件 socket.onmessage = function(msg) { console.log(msg.data); //发现消息进入 调后台获取 // getCallingList(); }; //关闭事件 socket.onclose = function() { console.log("Socket已关闭"); }; //发生了错误事件 socket.onerror = function() { alert("Socket发生了错误"); } } /** * 当用户离开了页面,关闭连接 * 点击某个离开页面的链接 在地址栏中键入了新的 URL 使用前进或后退按钮 关闭浏览器 重新加载页面 * */ $(window).unload(function(){ socket.close(); }); //定时发送心跳,防止客户端与服务器因为超时时间发生断开 window.setInterval(function(){ socket.send('发送心跳'); },40000);
到此java与websocket集成完成。
四、 Websocket集成遇到的难题
1. websocket对于spring有要求,至少4.0以上。
2. 在部署nginx的时候,需要加入特殊的配置,nginx 的版本至少1.3以上才行,
proxy_set_header Upgrade$http_upgrade;
proxy_set_header Connection"upgrade";
3. 部署websocket的容器也有要求,Tomcat从7.0.27开始支持,我使用tomcat 8.5版本可以。
4. 前端页面需要定时发送心跳请求,不然会有超时,断了,那么就无法再推送了。
五、 Websocket实现session共享方法
1. 前提摘要
因为单例的服务器到后期无法满足越来越大的客户量请求,所以开始部署多台服务器,组成集群环境,缓解高并发请求压力。但是这样会造成会话请求的session无法进行共享。所以以前很容易碰到我明明登录了系统,但是刷新了下页面,又需要重新登录的情况。这就是session没有共享出现的典型情况。而websocket不会出现这种情况,其链接一但建立,那么就会切换到tcp通道。每一次刷新页面都是断开重新建立连接操作。标题说的session共享是服务器向客户端推送的共享。
这里我采用的是使用redis消息订阅与发布功能,可能还有其他更好的办法,暂不讨论。
2. 集成步骤
1) 配置redis
案例如下
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"> <property name="maxIdle" value="100" /> <property name="maxTotal" value="600" /> </bean> <bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" > <constructor-arg ref="jedisPoolConfig"/> <!--<constructor-arg ref="redisClusterConfiguration"/>--> <property name="hostName" value="${redis.name}"/> <property name="port" value="${redis.port}"/> <property name="password" value="${redis.password}"/> </bean> <bean id="stringRedisSerializer" class="org.springframework.data.redis.serializer.StringRedisSerializer"/> <bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate"> <property name="connectionFactory" ref="jedisConnectionFactory"/> <property name="enableTransactionSupport" value="true"/> <property name="keySerializer" ref="stringRedisSerializer"/> <property name="hashKeySerializer" ref="stringRedisSerializer"/> <property name="valueSerializer" ref="stringRedisSerializer"/> <property name="hashValueSerializer" ref="stringRedisSerializer"/> </bean>
2) 书写redis的订阅者与发布者类
订阅者:需要实现接口M e s sa ge Listener,实现里面的方法onMessage
案例如下:
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); private InitService initService; //当生产者发送一条数据的时候,监听事件 @Override public void onMessage(Message message, byte[] pattern) { byte[] body = message.getBody(); byte[] channel = message.getChannel(); String msg = (String)stringRedisSerializer.deserialize(body); String topic = (String)stringRedisSerializer.deserialize(channel); logger.info("我是sub,监听"+topic+",我收到消息:"+msg); //接受到消息后,需要处理 initService.addQuene(msg);
发布者:将需要推送的消息,发布到redis中,供订阅者订阅
案例如下:
/** * 发布者发布消息 * @param message 发布的消息 */ public void sendMessage(String message){ redisTemplate.convertAndSend(config.getTopicName(),message); }
3) 配置消息订阅与发布的关系
这里主要是配置redis订阅者订阅的主题
<bean id="initService" class="com.iflytek.sgy.websocket.service.impl.InitServiceImpl"/> <bean id="topicMessageListener" class="com.iflytek.sgy.websocket.pubAndSub.Subscription"> <property name="initService" ref="initService"/> </bean> <bean id="channelTopic" class="org.springframework.data.redis.listener.ChannelTopic"> <constructor-arg value="${topic.name}" /> </bean> <!-- SDR Pub/Sub配置 --> <bean id="topicContainer" class="org.springframework.data.redis.listener.RedisMessageListenerContainer" destroy-method="destroy"> <property name="connectionFactory" ref="jedisConnectionFactory" /> <property name="messageListeners"> <map> <entry key-ref="topicMessageListener"> <ref bean="channelTopic" /> </entry> </map> </property> </bean>
至此,配置完成。当集群中项目运行起来后,订阅同一个redis中主题的订阅者,在监听到发布者发布消息后,会进行操作,session共享解决办法完成。