webscoket方案调研及实践
一、使用场景
1、考试管理端需要给特定考试用户单独暂停考试、继续考试、加时、减时的操作,当管理端执行了上述的某个操作,需要实时的通知到正在考试的用户那里。
2、社交聊天、弹幕、多玩家游戏、协同编辑、股票基金实时报价、体育实况更新、视频会议/聊天、智能家居等需要高实时的场景
二、方案调研
1、Ajax短轮询
短轮询:客户端定时向服务器发送Ajax请求,服务器接到请求后马上返回响应信息并关闭连接。
优点:后端程序编写比较容易。
缺点:请求中有大半是无用,浪费带宽和服务器资源。
2、long-polling长轮询
长轮询:客户端向服务器发送Ajax请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求。
优点:在无消息的情况下不会频繁的请求,耗费资源小。
缺点:服务器hold连接会消耗资源,返回数据顺序无保证,难于管理维护。
3、iframe长连接
长连接:在页面里嵌入一个隐蔵iframe,将这个隐蔵iframe的src属性设为对一个长连接的请求或是采用xhr请求,服务器端就能源源不断地往客户端输入数据。
优点:消息即时到达,不发无用请求;管理起来也相对方便。
缺点:服务器维护一个长连接会增加开销,不同浏览器会有加载问题。
4、XHR-streaming
XHR流:服务端使用分块传输编码(Chunked transfer encoding)的HTTP传输机制进行响应,并且服务器端不终止HTTP响应流,让HTTP始终处于持久连接状态,当有数据需要发送给客户端时再进行写入数据。
优点:通过XHR-Streaming,可以允许服务端连续地发送消息,无需每次响应后再去建立一个连接。
缺点:XHR-streaming连接的时间越长,浏览器会占用过多内存,sockjs默认只允许每个xhr-streaming连接输出128kb数据,超过这个大小时会关闭输出流,让浏览器重新发起请求。
5、Websocket
websocket:Webscoket是Web浏览器和服务器之间的一种全双工通信协议.一旦Web客户端与服务器建立起连接,之后的全部数据通信都通过这个连接进行。通信过程中,可互相发送JSON、XML、HTML或图片等任意格式的数据。
优点:复用长连接,全双工通信,支持服务器推送消息
缺点:服务器维护一个长连接会增加开销,不同浏览器会支持程度不一
5.1 实现原理
Websocket是应用层第七层上的一个应用层协议,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。
WebSocket 交互以 HTTP 请求开始,该请求使用 HTTP“Upgrade”header头升级或在本例中切换到 WebSocket 协议
GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080
http升级成websocket请求返回的是状态码101,而不是200。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp
成功握手后,HTTP 升级请求下的 TCP 套接字保持打开状态,以便客户端和服务器继续发送和接收消息。
参考
https://segmentfault.com/a/1190000019697463
三、实现方案(Websocket)
在java层面实现Webocket主要有spring、netty两个方向,由于我们系统使用的是spring系列,所以采用spring的Websocket实现方案。
在spring的Websocket实现中,又可以细分为3种:
1、基于java原生注解:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
@Configuration
@EnableWebSocket // 开启websocket
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpoint() {
return new ServerEndpointExporter();
}
}
@ServerEndpoint("/myWs") // 声明websocket端点
@Component
public class WsServerEndpoint {
private static Map<String, Session> onlineUserCache = new HashMap<>();
/**
* 连接成功
*
* @param session
*/
@OnOpen
public void onOpen(Session session) {
System.out.println("连接成功");
}
/**
* 连接关闭
*
* @param session
*/
@OnClose
public void onClose(Session session) {
System.out.println("连接关闭");
}
/**
* 接收到消息
*
* @param text
*/
@OnMessage
public String onMsg(String text) throws IOException {
return "servet 发送:" + text;
}
}
WsServerEndpoint类下的几个注解需要注意一下,首先是他们的包都在 javax.websocket 下。并不是 spring 提供的,而 jdk 自带的,下面是他们的具体作用。
- @ServerEndpoint通过这个 spring boot 就可以知道你暴露出去的 ws 应用的路径,有点类似我们经常用的@RequestMapping。比如你的启动端口是8080,而这个注解的值是ws,那我们就可以通过 ws://127.0.0.1:8080/ws 来连接你的应用
- @OnOpen当 websocket 建立连接成功后会触发这个注解修饰的方法,注意它有一个 Session 参数
- @OnClose当 websocket 建立的连接断开后会触发这个注解修饰的方法,注意它有一个 Session 参数
- @OnMessage当客户端发送消息到服务端时,会触发这个注解修改的方法,它有一个 String 入参表明客户端传入的值
- @OnError当 websocket 建立连接时出现异常会触发这个注解修饰的方法,注意它有一个 Session 参数
另外一点就是服务端如何发送消息给客户端,服务端发送消息必须通过上面说的 Session 类,通常是在@OnOpen 方法中,当连接成功后把 session 存入 Map 的 value,key 是与 session 对应的用户标识,当要发送的时候通过 key 获得 session 再发送,这里可以通过 session.getBasicRemote().sendText()来对客户端发送消息
2、spring提供的WebSocket API
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
public class MyHandler extends TextWebSocketHandler {
// 建立连接成功事件
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Object token = session.getAttributes().get("token");
if (token != null) {
// 用户连接成功,放入在线用户缓存
WsSessionManager.add(token.toString(), session);
} else {
throw new RuntimeException("用户登录已经失效!");
}
}
// 接收消息事件
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
// ...
}
// 断开连接时
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
Object token = session.getAttributes().get("token");
if (token != null) {
// 用户退出,移除缓存
WsSessionManager.remove(token.toString());
}
}
}
MyHandler通过继承 TextWebSocketHandler 类并覆盖相应方法,可以对 websocket 的事件进行处理,这里可以同原生注解的那几个注解连起来看.
- afterConnectionEstablished 方法是在 socket 连接成功后被触发,同原生注解里的 @OnOpen 功能
- afterConnectionClosed 方法是在 socket 连接关闭后被触发,同原生注解里的 @OnClose 功能
- handleTextMessage 方法是在客户端发送信息时触发,同原生注解里的 @OnMessage 功能、
public class WsSessionManager {
/**
* 保存连接 session 的地方
*/
private static ConcurrentHashMap<String, List<WebSocketSession>> SESSION_POOL = new ConcurrentHashMap<>();
/**
* 添加 session
*
* @param key
*/
public static void add(String key, WebSocketSession session) {
// 添加 session
SESSION_POOL.put(key, session);
}
/**
* 删除 session,会返回删除的 session
*
* @param key
* @return
*/
public static WebSocketSession remove(String key) {
// 删除 session
return SESSION_POOL.remove(key);
}
/**
* 删除并同步关闭连接
*
* @param key
*/
public static void removeAndClose(String key) {
WebSocketSession session = remove(key);
if (session != null) {
try {
// 关闭连接
session.close();
} catch (IOException e) {
// todo: 关闭出现异常处理
e.printStackTrace();
}
}
}
/**
* 获得 session
*
* @param key
* @return
*/
public static WebSocketSession get(String key) {
// 获得 session
return SESSION_POOL.get(key);
}
}
这里简单通过 ConcurrentHashMap 来实现了一个 session 池,用来保存已经登录的 web socket 的 session。前面提过,服务端发送消息给客户端必须要通过这个 session。
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler").setAllowedOrigins("http://mydomain.com");
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
}
通过实现 WebSocketConfigurer 类并覆盖相应的方法进行 websocket 的配置。我们主要覆盖 registerWebSocketHandlers 这个方法。通过向 WebSocketHandlerRegistry 设置不同参数来进行配置。其中 addHandler 方法添加我们上面的写的 ws 的 handler 处理类,第二个参数是你暴露出的 ws 路径。setAllowedOrigins("*") 这个是关闭跨域校验,方便本地调试,线上推荐打开。
3、基于STOMP消息协议实现
首先需要对SockJS、StompJS以及跟WebSocket三者做简要的说明。
3.1、SockJS
SockJS是一个JavaScript库,为了应对许多浏览器不支持WebSocket协议的问题,设计了备选SockJS 。SockJS 是 WebSocket 技术的一种模拟。SockJS会尽可能对应 WebSocket API,但如果WebSocket 技术不可用的话,会自动降为轮询的方式。还提供了心跳检测的机制。
3.2、StompJS
STOMP—— Simple Text Oriented Message Protocol——面向消息的简单文本协议。
SockJS 为 WebSocket 提供了 备选方案。 STOMP协议,采用消息订阅的机制,为浏览器 和 server 间的 通信增加适当的消息语义。
3.3、WebSocket、SockJs、STOMP三者关系
WebSocket 是底层协议,SockJS 是WebSocket 的备选方案,是一种兼容实现,而 STOMP 是基于 WebSocket(SockJS)的上层协议。
3.4、代码实现
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").withSockJS(); // 1
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.setApplicationDestinationPrefixes("/app"); // 2
config.enableSimpleBroker("/topic", "/queue"); // 3
}
}
- "/portfolio"是 WebSocket(或 SockJS)客户端需要连接到 WebSocket 握手的端点的 HTTP URL
- 以“/app”开头的STOMP消息将被路由到@Controller 类中的@MessageMapping 方法中
- 使用内置的消息代理进行订阅和广播;将以“/topic”或“/queue”开头的消息路由到代理
@Controller
public class WSController {
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
@MessageMapping("/hello")
@SendTo("/topic/hello")
public ResponseMessage hello(RequestMessage requestMessage) {
System.out.println("接收消息:" + requestMessage);
return new ResponseMessage("服务端接收到你发的:" + requestMessage);
}
@GetMapping("/sendMsgByUser")
public @ResponseBody
Object sendMsgByUser(String token, String msg) {
simpMessagingTemplate.convertAndSendToUser(token, "/msg", msg);
return "success";
}
}
通过 @MessageMapping 来暴露节点路径,有点类似 @RequestMapping。注意这里虽然写的是 hello ,但是我们客户端调用的真正地址是 /app/hello。 因为我们在上面的 config 里配置了registry.setApplicationDestinationPrefixes("/app")。
@SendTo这个注解会把返回值的内容发送给订阅了 /topic/hello 的客户端,与之类似的还有一个@SendToUser 只不过他是发送给用户端一对一通信的。这两个注解一般是应答时响应的,如果服务端主动发送消息可以通过 simpMessagingTemplate类的convertAndSend方法。
3.5、 消息流向
对于客户端来说,既可以发送指定的消息请求"/app/a",又可以订阅某一个消息主题"/topic/a"。消息订阅的会被路由到消息代理SimpleBroker中,指定的消息请求会被@MessageMapper路由到指定的方法中,之后再根据特定的主题订阅到消息代理SimpleBroker中。最后再通过消息代码返回消息到对应的客户端。
4、问题
4.1 Websocket连接鉴权问题
前2种可以通过添加握手过程的拦截器,在进行握手前,通过获取url传参,进行鉴权;STOMP实现方案可以通过获取header中的参数来进行鉴权。
4.2 分布式问题
前2种方案,跟客户端的交互都需要通过Session进行,并且需要在各个JVM中维护自己的Session池,在分布式环境中,跟消息服务A连接的用户没办法发送消息给跟消息服务B连接的用户。
而Stomp可以通过外部消息中间件MessageBroker的接入解决分布式问题。
4.3 浏览器Websocket协议兼容问题
前2种方案只能通过Websocket协议进行握手,当客户端所在浏览器不支持WebSocket协议时,需要再实现一套轮询的方案来实现客户端与服务端的交互问题。
而SockJS可以实现当浏览器不支持WebSocket协议时,会自动降为轮询的方式进行交互。
5、消息推送负载均衡方案
上述不管哪种实现方案,都避不开分布式问题。解决分布式问题一般引入第三方中间件,在这里我们可以引入rocketMq、redis、rabbitMq等。消息推送服务都订阅到中间件,业务系统通过发布到中间件,进而让各个连接到消息推送服务的客户端能够收到消息。
四、方案实践
1、方案选型
这里我们选基于STOMP消息协议实现的Websocket方案,原因有如下几点:
1、前后端采用消息订阅消费机制,无须在各个JVM中维护各个SESSION池;
2、采用STOMP协议通信,更容易与中间件结合,解决分布式连接问题(浏览器A连接服务A,浏览器B连接服务B,A没法跟B通信)
3、前端实现采用的是SockJS,能够检测各个浏览器能否支持Websocket协议,不支持的话,会自己降级成XHR- streaming、iframe的实现方式
4、通过SockJS发起的Websocket连接,可以在header中添加参数,来实现鉴权,不然只能通过跟在url后的参数进行鉴权
2、具体实现
2.1 引入websocket依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2.2 添加Websocket消息代理配置
package com.learnfuture.elearning.exam.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import javax.annotation.Resource;
/**
* @author huangyizeng
* @description Websocket 消息代理配置
* @date 2021/8/22
**/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Resource
private SocketChanelInterceptor socketChanelInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry){
//客户端连接端点
registry.addEndpoint("/exam/websocket")
.setAllowedOrigins("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic","/queue/", "/exchange/");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(socketChanelInterceptor);
}
}
package com.learnfuture.elearning.exam.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* @author huangyizeng
* @description
* @date 2021/8/22
**/
@Slf4j
@Component
public class SocketChanelInterceptor implements ChannelInterceptor {
/**
* 实际消息发送到频道之前调用
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
//1、判断是否首次连接
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
List<String> nativeHeader = accessor.getNativeHeader("Authorization");
System.out.println(nativeHeader);
}
String jwtToken = accessor.getFirstNativeHeader("token");
return message;
}
}
WebSocketConfig主要是配置Websocket的开放端点,使能SockJS连接端点,并且配置Spring自带的消息代理的各个过滤器前缀/topic、/queue、/exchange 等。这里的消息代理是作为浏览器的消息代理,是浏览器跟websocket服务端的订阅及消费关系。
接着配置Socket的通道拦截器SocketChanelInterceptor,与Websocket端点建立连接以及后续通过该通道接收消息,都会经过该拦截器。在连接/发送消息的时候,可以通过添加header头,来实现websocket的鉴权。
2.3 编写Websocket的业务消费者
/**
* @author huangyizeng
* @description 考试用户消息消费者
* @date 2021/8/26
**/
@Slf4j
@Component
public class SendToExamUserConsumer{
@Resource
private SimpMessagingTemplate template;
@Value("${rocketmq.consumer.group.message.sendToExamUser}")
private String consumerGroup;
@Value("${rocketmq.name-server}")
private String nameServer;
@Value("${rocketmq.topic.message}")
private String topic;
@Value("${rocketmq.topic.message.tag.sendToExamUser}")
private String selectorExpression;
@PostConstruct
public void init() {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(consumerGroup);
consumer.setNamesrvAddr(nameServer);
consumer.setMessageModel(MessageModel.BROADCASTING);
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
try {
consumer.subscribe(topic, selectorExpression);
} catch (MQClientException e) {
e.printStackTrace();
}
//设置一个Listener,主要进行消息的逻辑处理
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
try {
for (MessageExt messageExt : msgs) {
String messageBody = new String(messageExt.getBody());
JSONObject jsonObject = JSONObject.parseObject(messageBody);
WebsocketMsgResp websocketMsgResp = jsonObject.toJavaObject(WebsocketMsgResp.class);
String destination = "/queue/examUserDetailId_" + websocketMsgResp.getExamUserDetailId();
template.convertAndSend(destination, websocketMsgResp);
log.info("考试用户消息消费成功, message={}", messageBody);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
log.error("考试用户消息消费失败, message={}", msgs, e);
throw new ServiceException(ApiCode.FAILURE, "考试用户消息消费失败", e);
}
}
});
// 调用start()方法启动consumer
try {
consumer.start();
} catch (MQClientException e) {
log.error("SendToExamUserConsumer 启动失败", e);
}
}
}
这里主要是定义了一个业务的RocketMQ消费者,消息模式设为广播模式,这样当websocket服务集群化部署时,只要有一条消息生产过来,那每一个websocket服务都能够消费到。避免了分布式下Websocket的消息推送问题。
2.4 js代码实现
<script src="/js/websocket.js"></script>
<script src="/js/jquery.min.js"></script>
<script src="/js/sockjs.min.js"></script>
<script src="/js/stomp.min.js"></script>
function connect(url) {
var host = window.location.host; // 带有端口号
userId = GetQueryString("userId");
var socket = new SockJS("http://localhost:9000/api/exam/websocket?access_token=4c819e0f-a0a0-448a-8f79-9b9a538f5837", null, {timeout : 10000});
stompClient = Stomp.over(socket);
stompClient.connect({"Authorization" : "Bearer 5807a5eb-dbf1-4ac7-8c40-8aeaa25ccf65"}, function (frame) {
writeToScreen("connected: " + frame);
stompClient.subscribe("/queue/examUserDetailId_" + userId, function (response) {
writeToScreen(response.body);
});
}, function (error) {
}
)
}
引入相关js包后,新建SockJS对象后,调用stompClient.connect发起websocket服务连接,通过stompClient.subscribe发起跟websocket服务的消息订阅,后续websocket服务发送到具体topic下的消息,都会发送到对应的浏览器那里。
3、技术难点
3.1 网关鉴权
现在系统架构中,所有需要鉴权的接口都会在gateway中进行鉴权,并且通过在header中,添加对应的Authrization属性来传参通过OAuth2的鉴权,而websocket的info接口(比如/api/exam/websocket 是端点,那么在发起websocket连接之前,会先发送一个/api/exam/websocket/info接口查看当前websocket服务是否存在)是没办法将token放在header进行调用的,只能通过/info?token=xxx 的形式来发起。
而OAuth默认是先从Header中获取token,接着再从url中的参数获取token。此时我们只能通过url传递token过去,但是OAuth默认是不允许从url进行获取的,所以需要手动设置成允许。
优先从header中获取,其次是从参数中获取。
OAuth2AuthenticationProcessingFilter.doFilter
设置成允许从参数中获取token
// token转换器
ServerBearerTokenAuthenticationConverter converter = new ServerBearerTokenAuthenticationConverter();
converter.setAllowUriQueryParameter(true); // 设置成允许从参数中获取token
3.2 前端联调问题
3.2.1 WebSocket is closed before the connection is established.
主要是由于前端本地webpack的代理没有开启ws 协议的支持。
3.2.2 Error during WebSocket handshake: Unexpected response code: 400
前端经过nginx代理,而nginx 不支持http请求的升级,而Websocket是首先发送一个http请求,然后将该请求升级成Websocket请求,所以需要添加支持配置:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";