websocket 概述
一、是什么
WebSocket,是一种网络传输协议,位于OSI模型的应用层。可在单个TCP连接上进行全双工通信,能更好的节省服务器资源和带宽并达到实时通迅
客户端和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输
从上图可见,websocket服务器与客户端通过握手连接,连接成功后,两者都能主动的向对方发送或接受数据
而在websocket出现之前,开发实时web应用的方式为轮询
不停地向服务器发送 HTTP 请求,问有没有数据,有数据的话服务器就用响应报文回应。如果轮询的频率比较高,那么就可以近似地实现“实时通信”的效果
轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU资源
二、特点
全双工
通信允许数据在两个方向上同时传输,它在能力上相当于两个单工通信方式的结合
例如指 A→B 的同时 B→A ,是瞬时同步的
二进制帧
采用了二进制帧结构,语法、语义与 HTTP 完全不兼容,相比http/2,WebSocket更侧重于“实时通信”,而HTTP/2 更侧重于提高传输效率,所以两者的帧结构也有很大的区别
不像 HTTP/2 那样定义流,也就不存在多路复用、优先级等特性
自身就是全双工,也不需要服务器推送
协议名
引入ws和wss分别代表明文和密文的websocket协议,且默认端口使用80或443,几乎与http一致
ws://www.chrono.comws://www.chrono.com:8080/srvwss://www.chrono.com:445/im?user_id=xxx握手
WebSocket也要有一个握手过程,然后才能正式收发数据
客户端发送数据格式如下:
GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Origin: http://example.comSec-WebSocket-Protocol: chat, superchatSec-WebSocket-Version: 13
Connection:必须设置Upgrade,表示客户端希望连接升级(ws)
Upgrade:必须设置Websocket,表示希望升级到Websocket协议
Sec-WebSocket-Key:客户端发送的一个 base64 编码的密文,用于简单的认证秘钥。要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept应答,否则客户端会抛出错误,并关闭连接
Sec-WebSocket-Version :表示支持的Websocket版本
服务端返回的数据格式:
HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Sec-WebSocket-Protocol: chat
HTTP/1.1 101 Switching Protocols:表示服务端接受 WebSocket 协议的客户端连接(切换协议)
Sec-WebSocket-Accep:验证客户端请求报文,同样也是为了防止误连接。具体做法是把请求头里“Sec-WebSocket-Key”的值,加上一个专用的 UUID,再计算摘要
优点
较少的控制开销:数据包头部协议较小,不同于http每次请求需要携带完整的头部
更强的实时性:相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少
保持创连接状态:创建通信后,可省略状态信息,不同于HTTP每次请求需要携带身份验证
更好的二进制支持:定义了二进制帧,更好处理二进制内容
支持扩展:用户可以扩展websocket协议、实现部分自定义的子协议
更好的压缩效果:Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率
三、应用场景
基于websocket的实时通信的特点,经常能看到其广泛的应用场景,下方列举如:
- 提示推送
- 弹幕
- 媒体聊天
- 协同编辑
- 基于位置的应用
案列一 基于netty实现
一、需求
实现一个简单的服务端主动向客户端发送数据,同时客户端也可以主动发送数据给服务端
1)Http协议是无状态的,浏览器和服务器间的请求响应一次,下一次会重新创建连接.
2)要求:实现基于webSocket的长连接的全双工的交互
3)改变Http协议多次请求的约束,实现长连接了,服务器可以发送消息给浏览器
4)客户端浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知,同样浏览器关闭了,服务器会感知
二、流程图分析
三、代码实现
事先初始化好一个springboot的项目
①、引入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.st.websocket</groupId>
<artifactId>st-websocket</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>st-websocket</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.43.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
②、springboot启动完成的监听类AppInitializer
用于项目启动后紧接着启动我们的netty服务端
package com.st.websocket.netty;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* @创建人: 放生
* @创建时间: 2022/4/12
* @描述:
*/
@Component
@Order(2) // 控制类执行的顺序越小越靠前
public class AppInitializer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println("----------------启动完成------------------");
//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); //8个NioEventLoop
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.handler(new LoggingHandler(LogLevel.INFO));
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//因为基于http协议,使用http的编码和解码器
pipeline.addLast(new HttpServerCodec());
//是以块方式写,添加ChunkedWriteHandler处理器
pipeline.addLast(new ChunkedWriteHandler());
/*
说明
1. http数据在传输过程中是分段, HttpObjectAggregator ,就是可以将多个段聚合
2. 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
*/
pipeline.addLast(new HttpObjectAggregator(8192));
/*
说明
1. 对应websocket ,它的数据是以 帧(frame) 形式传递
2. 可以看到WebSocketFrame 下面有六个子类
3. 浏览器请求时 ws://localhost:7000/hello 表示请求的uri
4. WebSocketServerProtocolHandler 核心功能是将 http协议升级为 ws协议 , 保持长连接
5. 是通过一个 状态码 101
*/
pipeline.addLast(new WebSocketServerProtocolHandler("/hello2"));
//自定义的handler ,处理业务逻辑
pipeline.addLast(new MyTextWebSocketFrameHandler());
}
});
//启动服务器
ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();
}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
③、MyTextWebSocketFrameHandler
对应处理的Handler
package com.st.websocket.netty;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.ServletContext;
import java.time.LocalDateTime;
//这里 TextWebSocketFrame 类型,表示一个文本帧(frame)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
System.out.println("服务器收到消息 " + msg.text());
//回复消息
ctx.channel().writeAndFlush(new TextWebSocketFrame("服务器时间" + LocalDateTime.now() + " " + msg.text()));
}
//当web客户端连接后, 触发方法
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
//id 表示唯一的值,LongText 是唯一的 ShortText 不是唯一
System.out.println("handlerAdded 被调用" + ctx.channel().id().asLongText());
System.out.println("handlerAdded 被调用" + ctx.channel().id().asShortText());
ServletContext servletContext = MyServletUtil.servletContext;
System.out.println("存进去==》"+ctx);
servletContext.setAttribute("key",ctx);//实际中我们不能写死的,而应该是一个动态的业务唯一标识
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
System.out.println("handlerRemoved 被调用" + ctx.channel().id().asLongText());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常发生 " + cause.getMessage());
ctx.close(); //关闭连接
}
}
④、ServletUtil
用于获取ServletContext的工具类供其他地方使用
package com.st.websocket.netty;
import lombok.Data;
import org.springframework.stereotype.Component;
import org.springframework.web.context.support.WebApplicationObjectSupport;
import javax.servlet.ServletContext;
/**
* @创建人: 放生
* @创建时间: 2022/4/12
* @描述:
*/
@Data
@Component
public class MyServletUtil extends WebApplicationObjectSupport {
public static ServletContext servletContext;
public MyServletUtil() {
}
@Override
protected void initServletContext(ServletContext servletContext) {
super.initServletContext(servletContext);
MyServletUtil.servletContext=servletContext;
System.out.println("servletContext====>"+servletContext);
}
}
⑤、客户端页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
var socket;
//判断当前浏览器是否支持websocket
if(window.WebSocket) {
//go on
socket = new WebSocket("ws://localhost:7000/hello2");
//相当于channelReado, ev 收到服务器端回送的消息
socket.onmessage = function (ev) {
var rt = document.getElementById("responseText");
rt.value = rt.value + "\n" + ev.data;
}
//相当于连接开启(感知到连接开启)
socket.onopen = function (ev) {
var rt = document.getElementById("responseText");
rt.value = "连接开启了.."
}
socket.onmessage= function (ev) {
var rt = document.getElementById("responseText");
rt.value = rt.value + "\n" + ev.data;
}
//相当于连接关闭(感知到连接关闭)
socket.onclose = function (ev) {
var rt = document.getElementById("responseText");
rt.value = rt.value + "\n" + "连接关闭了.."
}
} else {
alert("当前浏览器不支持websocket")
}
//发送消息到服务器
function send(message) {
if(!window.socket) { //先判断socket是否创建好
return;
}
if(socket.readyState == WebSocket.OPEN) {
//通过socket 发送消息
socket.send(message)
} else {
alert("连接没有开启");
}
}
</script>
<form onsubmit="return false">
<textarea name="message" style="height: 300px; width: 300px"></textarea>
<input type="button" value="发生消息" onclick="send(this.form.message.value)">
<textarea id="responseText" style="height: 300px; width: 300px"></textarea>
<input type="button" value="清空内容" onclick="document.getElementById('responseText').value=''">
</form>
</body>
</html>
四、测试
一、启动主程序
二、打开客户端
三、服务端向客户端发送数据
四、客户端向服务端发送数据
案列二 基于java websocket 实现
这里的需求,流程图不在赘述,直接开打 也是基于上一个的工程添加一个包继续编写的
一、代码实现
①、引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
②、编写配置类WebSocketConfig
package com.st.websocket.websocket.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* @创建人: 放生
* @创建时间: 2022/4/13
* @描述: 开启WebSocket支持
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
③、端点配置类
package com.st.websocket.websocket;
import org.springframework.stereotype.Component;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* @创建人: 放生
* @创建时间: 2022/4/13
* @描述: 订单通知
*/
@Component
@ServerEndpoint("/web_socket/order_notification/{merchantId}")
public class OrderNotificationWebSocket {
// static final ConcurrentHashMap<String, List<WebSocketClient>> webSocketClientMap= new ConcurrentHashMap<>();
static final ConcurrentHashMap<String, WebSocketClient> webSocketClientMap= new ConcurrentHashMap<>();
/**
* 连接建立成功时触发,绑定参数
* @param session 与某个客户端的连接会话,需要通过它来给客户端发送数据
* @param merchantId 商户ID
*/
@OnOpen
public void onOpen(Session session, @PathParam("merchantId") String merchantId){
WebSocketClient client = new WebSocketClient();
client.setSession(session);
client.setUri(session.getRequestURI().toString());
webSocketClientMap.put(merchantId, client);
System.out.println("已经建立了连接==>"+merchantId);
buildConnectOk(client);
}
public void buildConnectOk(WebSocketClient client){
try {
client.getSession().getBasicRemote().sendText("已连接,请放马过来吧!");
} catch (IOException e) {
e.printStackTrace();
System.err.println("马儿跑了。。。┭┮﹏┭┮");
}
}
/**
* 连接关闭时触发,注意不能向客户端发送消息了
* @param merchantId
*/
@OnClose
public void onClose(@PathParam("merchantId") String merchantId){
webSocketClientMap.remove(merchantId);
System.out.println("已经关闭了连接==>"+merchantId);
}
/**
* 通信发生错误时触发
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
System.out.println("发生错误");
error.printStackTrace();
}
/**
* 向客户端发送消息
* @param merchantId
* @param message
*/
public static void sendMessage(String merchantId,String message){
try {
WebSocketClient webSocketClient = webSocketClientMap.get(merchantId);
if(webSocketClient!=null){
webSocketClient.getSession().getBasicRemote().sendText(message);
}
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException(e.getMessage());
}
}
}
④、客户端类
package com.st.websocket.websocket;
import lombok.Data;
import javax.websocket.Session;
/**
* @创建人: 放生
* @创建时间: 2022/4/13
* @描述:
*/
@Data
public class WebSocketClient {
// 与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
//连接的uri
private String uri;
}
⑤、controller
package com.st.websocket.websocket.controller;
import com.st.websocket.websocket.OrderNotificationWebSocket;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* @创建人: 放生
* @创建时间: 2022/4/13
* @描述:
*/
@RestController
public class OrderNotificationWebSocketController {
@GetMapping("/sendOrderNum")
public void test(@RequestParam(value = "merchantId") String merchantId){
OrderNotificationWebSocket.sendMessage(merchantId,"有新订单啦");
}
}
⑥、客户端页面
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket</title>
</head>
<body>
<input id="url" type="text" size="60" value="ws://localhost:8080/web_socket/order_notification/M666666" />
<button onclick="openWebSocket()">打开WebSocket连接</button>
<button onclick="closeWebSocket()">关闭WebSocket连接</button>
<div id="message"></div>
</body>
<script type="text/javascript">
var websocket = null;
function openWebSocket() {
var url = document.getElementById('url').value.trim();
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
websocket = new WebSocket(url);
} else {
alert('当前浏览器 Not support websocket')
}
//连接发生错误的回调方法
websocket.onerror = function() {
setMessageInnerHTML("WebSocket连接发生错误");
};
//连接成功建立的回调方法
websocket.onopen = function() {
setMessageInnerHTML("WebSocket连接成功");
}
//接收到消息的回调方法
websocket.onmessage = function(event) {
setMessageInnerHTML(event.data);
}
//连接关闭的回调方法
websocket.onclose = function() {
setMessageInnerHTML("WebSocket连接关闭");
}
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function() {
closeWebSocket();
}
//将消息显示在网页上
function setMessageInnerHTML(innerHTML) {
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
//关闭WebSocket连接
function closeWebSocket() {
websocket.close();
}
</script>
</html>
⑦、包结构
二、测试
- 启动主程序
- 开启客户端
待优化
-
java websocket 的实现,也可以实现鉴权,并不是所有的客户端过来我都要连接,可以鉴权通过则连接,不通过则无权限,网上有很多实现的案列。
-
以上两个案列都是Channel都是基于session存储的,只不过一个是基于上下文,一个是基于map,但是如果有这样一种场景,我服务端部署了多台实列,分布在不同的物理机上,第一次建立连接的时候是在A机器上,然后保存了对应的Channel在A机器的session中,第二次客户端发送请求的时候打到了B机器上那么B 是没有之前存的Channel的,则无法响应,或者说虽然服务端部署了多个实列在多个物理机上,只要A宕机,也还有其他的节点存活,但是对于这次建立的会话仍然是不可用的。
网上也有很多的思路,比如利用mq,netty 集群,通过rocketmq等MQ 推送到所有netty服务端 channel 共享无非是要那个通道都可以发送消息向客户端 MQ广播+ 多Netty ,Netty收到MQ消息后,如果本地存储有该channel,就发送,没有存储就忽略,不需要做channel的共享