前言
WebSocket是一种基于HTTP的长链接技术。传统的HTTP协议是一种请求-响应模型,如果浏览器不发送请求,那么服务器无法主动给浏览器推送数据。如果需要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的JavaScript定时轮询,效率很低且实时性不高。
为什么要使用WebSocket?
初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?
答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。
举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用轮询:每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。
轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。
应用场景
- 弹幕
- 媒体聊天
- 协同编辑
- 基于位置的应用
- 体育实况更新
- 股票基金报价实时更新
整合WebSocket
pom文件引入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> 复制代码
配置类WebSocketConfig,这里开启了配置之后springboot才会去扫描对应的注解
@Configuration @EnableWebSocket public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpoint() { return new ServerEndpointExporter(); } } 复制代码
处理消息类WsServerEndpoint
/** * websocket的处理类。 * 作用相当于HTTP请求 * 中的controller */ @Component @Slf4j @ServerEndpoint("/api/pushMessage/{userId}") public class WebSocketServer { /**静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。*/ private static int onlineCount = 0; /**concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象。*/ private static ConcurrentHashMap<String,WebSocketServer> webSocketMap = new ConcurrentHashMap<>(); /**与某个客户端的连接会话,需要通过它来给客户端发送数据*/ private Session session; /**接收userId*/ private String userId = ""; /** * 连接建立成 * 功调用的方法 */ @OnOpen public void onOpen(Session session,@PathParam("userId") String userId) { this.session = session; this.userId=userId; if(webSocketMap.containsKey(userId)){ webSocketMap.remove(userId); //加入set中 webSocketMap.put(userId,this); }else{ //加入set中 webSocketMap.put(userId,this); //在线数加1 addOnlineCount(); } log.info("用户连接:"+userId+",当前在线人数为:" + getOnlineCount()); sendMessage("连接成功"); } /** * 连接关闭 * 调用的方法 */ @OnClose public void onClose() { if(webSocketMap.containsKey(userId)){ webSocketMap.remove(userId); //从set中删除 subOnlineCount(); } log.info("用户退出:"+userId+",当前在线人数为:" + getOnlineCount()); } /** * 收到客户端消 * 息后调用的方法 * @param message * 客户端发送过来的消息 **/ @OnMessage public void onMessage(String message, Session session) { log.info("用户消息:"+userId+",报文:"+message); //可以群发消息 //消息保存到数据库、redis if(StringUtils.isNotBlank(message)){ try { //解析发送的报文 JSONObject jsonObject = JSON.parseObject(message); //追加发送人(防止串改) jsonObject.put("fromUserId",this.userId); String toUserId=jsonObject.getString("toUserId"); //传送给对应toUserId用户的websocket if(StringUtils.isNotBlank(toUserId)&&webSocketMap.containsKey(toUserId)){ webSocketMap.get(toUserId).sendMessage(message); }else{ //否则不在这个服务器上,发送到mysql或者redis log.error("请求的userId:"+toUserId+"不在该服务器上"); } }catch (Exception e){ e.printStackTrace(); } } } /** * @param session * @param error */ @OnError public void onError(Session session, Throwable error) { log.error("用户错误:"+this.userId+",原因:"+error.getMessage()); error.printStackTrace(); } /** * 实现服务 * 器主动推送 */ public void sendMessage(String message) { try { this.session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } /** *发送自定 *义消息 **/ public static void sendInfo(String message, String userId) { log.info("发送消息到:"+userId+",报文:"+message); if(StringUtils.isNotBlank(userId) && webSocketMap.containsKey(userId)){ webSocketMap.get(userId).sendMessage(message); }else{ log.error("用户"+userId+",不在线!"); } } /** * 获得此时的 * 在线人数 * @return */ public static synchronized int getOnlineCount() { return onlineCount; } /** * 在线人 * 数加1 */ public static synchronized void addOnlineCount() { WebSocketServer.onlineCount++; } /** * 在线人 * 数减1 */ public static synchronized void subOnlineCount() { WebSocketServer.onlineCount--; } } 复制代码
这些注解都是属于jdk自带的,并不是spring提供的,具体位置是在javax.websocket下,需要注意的是接收参数中的session,这是我们需要保存的,后面如果要对客户端发送消息的话使用session.getBasicRemote().sendText(XXX)
@ServerEndpoint
- 通过这个 spring boot 就可以知道你暴露出去的 ws 应用的路径,有点类似我们经常用的@RequestMapping。比如你的启动端口是8080,而这个注解的值是ws,那我们就可以通过 ws://127.0.0.1:8080/ws 来连接你的应用。
@OnOpen
- 当 websocket 建立连接成功后会触发这个注解修饰的方法。
@OnClose
- 当 websocket 建立的连接断开后会触发这个注解修饰的方法。
@OnMessage
- 当客户端发送消息到服务端时,会触发这个注解修改的方法,它有一个 String 入参表明客户端传入的值。
@OnError
- 当 websocket 建立连接时出现异常会触发这个注解修饰的方法。
消息推送
至于推送新信息,可以在自己的Controller写个方法调用WebSocketServer.sendInfo()即可。
程序中使用定任务不停的向客户端发送消息。
@Controller @RequestMapping("/api/test") @Api(description = "服务器向客户端推送消息接口", tags = "Test") public class TestController { @Autowired private TestServiceImpl testServiceImpl; /** * 启动页面 * @return */ @GetMapping("/start") public String start(){ return "index"; } @PostMapping("/pushToWeb") @ApiOperation(value = "服务器端向客户端推送消息", notes = "服务器端向客户端推送消息") public ResponseBean<?> pushToWeb(@RequestBody @ApiParam(value = "回收人编码和医院编码", required = true) CodesInfo info){ testServiceImpl.printTime(); return new ResponseBean<>(200, "success", "123456"); } } 复制代码
@Service @EnableScheduling public class TestServiceImpl { //打印时间 @Scheduled(fixedRate=1000) //1000毫秒执行一次 public void printTime(){ SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss"); String date = dateFormat.format(new Date()); WebSocketServer.sendInfo(date,"10"); System.out.println(date); } } 复制代码
建立websocket连接
前端页面直接使用ws协议建立连接,const socketUrl = “ws://localhost:9091/api/pushMessage/ ” + $("#userId").val();
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>websocket通讯</title> </head> <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.js"></script> <script> let socket; function openSocket() { const socketUrl = "ws://localhost:9091/api/pushMessage/" + $("#userId").val(); console.log(socketUrl); if(socket!=null){ socket.close(); socket=null; } socket = new WebSocket(socketUrl); //打开事件 socket.onopen = function() { console.log("websocket已打开"); }; //获得消息事件 socket.onmessage = function(msg) { console.log(msg.data); //发现消息进入,开始处理前端触发逻辑 }; //关闭事件 socket.onclose = function() { console.log("websocket已关闭"); }; //发生了错误事件 socket.onerror = function() { console.log("websocket发生了错误"); } } function sendMessage() { socket.send('{"toUserId":"'+$("#toUserId").val()+'","contentText":"'+$("#contentText").val()+'"}'); console.log('{"toUserId":"'+$("#toUserId").val()+'","contentText":"'+$("#contentText").val()+'"}'); } </script> <body> <p>【socket开启者的ID信息】:<div><input id="userId" name="userId" type="text" value="10"></div> <p>【客户端向服务器发送的内容】:<div><input id="toUserId" name="toUserId" type="text" value="20"> <input id="contentText" name="contentText" type="text" value="hello websocket"></div> <p>【操作】:<div><a onclick="openSocket()">开启socket</a></div> <p>【操作】:<div><a onclick="sendMessage()">发送消息</a></div> </body> </html> 复制代码
优点
Websocket协议相比http优势很明显,首先在效率上有多方面的提升。
- 传统的http请求,其并发能力都是依赖同时发起多个TCP连接访问服务器实现的(因此并发数受限于浏览器允许的并发连接数),而websocket则允许我们在一条ws连接上同时并发多个请求,即在A请求发出后A响应还未到达,就可以继续发出B请求。由于TCP的慢启动特性(新连接速度上来是需要时间的),以及连接本身的握手损耗,都使得websocket协议的这一特性有很大的效率提升。
- http协议的头部太大,且每个请求携带的几百上千字节的头部大部分是重复的,很多时候可能响应都远没有请求中的header空间大。如此多无效的内容传递是因为无法利用上一条请求内容,websocket则因为复用长连接而没有这一问题。
- websocket支持服务器推送消息,这带来了及时消息通知的更好体验,也是ajax请求无法达到的。
缺点
- 服务器长期维护长连接需要一定的成本
- 各个浏览器支持程度不一
- websocket 是长连接,受网络限制比较大,需要处理好重连,比如用户进电梯或电信用户打个电话网断了,这时候就需要重连
总结
WebSocket 是为了在 web 应用上进行双通道通信而产生的协议,相比于轮询HTTP请求的方式,WebSocket 有节省服务器资源,效率高等优点。WebSocket 中的掩码是为了防止早期版本中存在中间缓存污染攻击等问题而设置的,客户端向服务端发送数据需要掩码,服务端向客户端发送数据不需要掩码。WebSocket 中 Sec-WebSocket-Key 的生成算法是拼接服务端和客户端生成的字符串,进行SHA1哈希算法,再用base64编码。WebSocket 协议握手是依靠 HTTP 协议的,依靠于 HTTP 响应101进行协议升级转换。