WebSocket
Websocket是 全双工协议(客户端服务端之间两个方向,只能同时双向传输)他是基于Http协议是半双工协议,(客户端服务端之间两个方向,只能单向传输)的基础上发展而来的,首次和后端服务通信采用Http协议,后续通信都是采用tcp协议,他的最大应用点在于可以主动的和客户端通信,区别以往只能由客户端主动发送的过程,可以使用该技术来完成很多场景,例如大屏展示,需要实时刷新数据,不可能由客户端不断的发送http请求,给服务端造成巨大的负担,而是由服务器主动更新。
Http、Https、WebSocket、WebSockets
WebSocket 和 HTTP 一样,都需要先通过request和response来完成第一次交互请求,不同的地方在于websocket 也有一些约定的通讯方式,http 通讯方式为 http 开头的方式, 例如 http://xxx.com/getDzx ,WebSocket 通讯方式则为 ws 开头的方式,例如 ws://xxx.com/getDzx
SSL:
- HTTPS: https://xxx.com/getDzx
- WEBSOCKETS: wss:/x.com/getDzx
其实他们之间的关系用一张图,即可清楚的描述
场景简介
同实时公交的场景一致,在完成了数据(定位数据等)采集后,需要把定位信息计算的结果实时刷新到客户端页面(移动端、pc端),实现效果如下图,图中的蓝色小车需要不断刷新位置。
技术难点&方案
- 如何高性能刷新客户端,防止页面渲染卡顿?
方案1:采用Http接口请求方式,定时请求后端服务(阻塞页面渲染,页面经常卡顿)
方案2:采用多端口,基于netty支持各种协议(netty本身支持多种协议,包括Http、WebSocket、SSL等)
- 多种传输协议都要能同时支持?
方案1:采用多端口,基于netty支持各种协议(netty本身支持多种协议,包括Http、WebSocket、SSL等)
方案2:只用一个端口,基于netty做动态的ChannelPipleLine删减,对不同的协议,需要特定的协议标识(例如:协议A,B,ChannelPipleLine上有他们对应的channelHandler,只要识别出是协议A,则添加协议A的channelHandler到ChannelPipleLine上,若下一时刻是协议B,则删除协议A的channelHandler,添加协议B相关channelHandler)
核心代码
netty服务端BaseServer
/**
* Description:
* <p>
* 采用模板设计模式,提供统一的netty server模板类,
* 以便支持多端口
* </p>
*
* @author dzx
* @date 2019/10/27 17:59
*/
@Slf4j
public abstract class BaseServer {
/**
* 创建bossGroup线程组,监听客户端连接线程组
*/
public EventLoopGroup bossGroup = null;
/**
* 创建workGroup线程组,处理客户端请求线程组
*/
public EventLoopGroup workGroup = null;
/**
* 创建handlerGroup线程组,处理具体业务
*/
public EventExecutorGroup handlerGroup = null;
public ServerBootstrap serverBootstrap = null;
/**
* 启动服务端
*/
public void start(int serverPort) {
if (bossGroup != null) {
return;
}
try {
// 创建线程组,服务配置对象
bossGroup = new NioEventLoopGroup(2);
workGroup = new NioEventLoopGroup();
handlerGroup = new DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors());
serverBootstrap = new ServerBootstrap();
// 配置缓存区大小,netty服务端类型,采用主从多线程模型
serverBootstrap.channel(NioServerSocketChannel.class)
.group(bossGroup, workGroup)
.option(ChannelOption.SO_BACKLOG, 1024)
.option(ChannelOption.SO_RCVBUF, 16 * 1024)
.option(ChannelOption.SO_SNDBUF, 16 * 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true);
// 设置处理器
setChildrenHandler();
// 绑定端口启动
serverBootstrap.bind(serverPort).sync();
log.info("Netty server bind port {}", serverPort);
} catch (Exception e) {
//异常关闭
log.error("Netty access to resources fail", e);
close();
}
}
/**
* 设置处理器抽象方法
*/
public abstract void setChildrenHandler();
/**
* 关闭方法
*/
public void close() {
if (bossGroup != null) {
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
handlerGroup.shutdownGracefully();
}
}
}
WebSocketServer 端
@Component
@Slf4j
public class WebSocketServer extends BaseServer {
private final WebSocketHandler webSocketHandler;
/**
* 采用spring团队建议,构造注入
*/
public WebSocketServer(
WebSocketHandler webSocketHandler) {
this.webSocketHandler = webSocketHandler;
}
@Override
public void setChildrenHandler() {
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
//将请求和应答消息编码或者解码为Http消息
.addLast(new HttpServerCodec())
//将http消息的多个部分组合成一条完整的http消息
.addLast(new HttpObjectAggregator(65536))
//像客户端发送文件
.addLast(new ChunkedWriteHandler())
//监听路径为/ws的请求 WebSocket支持的handler
.addLast(new WebSocketServerProtocolHandler("/ws"))
.addLast(handlerGroup, webSocketHandler);
}
});
}
}
WebSocketHandler端
/**
* description:
* <p>
* webSocket的handler处理类
* TextWebSocketFrame是用来获取请求WebSocket消息的,单位是frame(帧)
* </p>
*
* @author : dzx
* Date : 2019/11/1 11:36
*/
@Slf4j
@ChannelHandler.Sharable
@Component
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
log.info("webServer 接收到msg=" + msg.text());
//判断是否存在,此处msg接收到的是车辆的唯一识别号
if (PackageHandlerUtil.container.containsKey(msg.text())) {
PackageHandlerUtil.container.get(msg.text()).add(ctx);
} else {
List<ChannelHandlerContext> contexts = new ArrayList<>();
contexts.add(ctx);
PackageHandlerUtil.container.put(msg.text(), contexts);
}
}
/**
* 通道关闭方法
*
* @param ctx
* @throws Exception
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("channelInactive....");
Map<String, List<ChannelHandlerContext>> container1 = PackageHandlerUtil.container;
//循环判断属于当前通道的ChannelHandlerContext,取出并删除
for (Map.Entry<String, List<ChannelHandlerContext>> container : container1.entrySet()) {
if (container.getValue().contains(ctx)) {
container.getValue().remove(ctx);
log.info(ctx.toString() + ", remove....");
}
}
ctx.fireChannelInactive();
}
/**
* 通道激活方法
*
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("channelActive....");
ctx.fireChannelActive();
}
}
PackageHandlerUtil
@Slf4j
public class PackageHandlerUtil {
/**
* 此处设计的结构为String和List的ChannelHandlerContext
* 以车辆的唯一识别码作为key,每次用户的会话信息根据同一辆车(同一个key)而被放入集合中
* 若接收到某辆车的数据信息,便可以把该辆车下的list的所有会话信息都拿到
* 利用这一的结构可以简单的做一个广播事件(类似redis的广播订阅)
*/
public static Map<String, List<ChannelHandlerContext>> container = new ConcurrentHashMap<>(100000);
}
广播代码示例
//广播消息到该车辆的每个channelContext,TerminalAddress是车辆的唯一识别码
if (PackageHandlerUtil.container.containsKey(busHeartBeat.getMsgHead().getTerminalAddress())) {
for (ChannelHandlerContext context : PackageHandlerUtil.container.get(busHeartBeat.getMsgHead().getTerminalAddress())) {
//使用websocket的时候需要以TextWebSocketFrame帧的形式发送消息
context.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(targetSite)));
}
}
到此,我们的技术难点都逐一解决,但是可以发现,有一个很致命的问题,那就是单点存储,就单台服务器来说,会话信息无法海量的容纳,单凭该初始化数量就可以发现该问题,初始化就有10000台,若数量多呢,如何满足分布式的要求?
public static Map<String, List<ChannelHandlerContext>> container = new ConcurrentHashMap<>(100000);
同时我们也发现WebSocket要做成集群的话,需要面临的问题很多,例如:
- WebSocket的会话信息无法序列化会,无法远程存储
java.io.NotSerializableException:
org.springframework.web.socket.adapter.standard.StandardWebSocketSession
- WebSocket若做成分布式,如何共享消息,做广播推送,或订阅推送
以下是博主的几个方案:(以下会话消息都指ChannelHandlerContext)
- 方案1:使用负载均衡(使用nginx,或最小连接,详细可以参考
网络|基于Netty构建的高性能车辆网项目实现(三))
技术,均摊各个节点会话压力,既然WebSocket的会话消息不能远程存储,只能单机存储,那么我们就依旧存储在单机上,只是要均摊,只能固定用户访问指定节点的服务器,nginx能完成客户端的指定服务器访问,至于硬件接入层方面,我们需要把采集的信息放入高性能中间件(Kafka/RabbitMq/RocketMq)中,由后端中的中间服务(消费mq消息,把消费信息发送给指定的用户),由于需要拆分netty和Websocket,此处实现的WebSocket使用springBoot中的,会话信息是(自定义的信息类,同样使用concurrentHashMap存储)。(推荐,但有一定的局限性,多少用户,什么用户固定访问一台什么样的机器,需要具体业务衡量)
- 方案2:使用redis的消息订阅代替mq(不推荐,redis不是专门用于队列消费场景,消峰能力有限)
方案1图例
介于时间仓促,代码会适时贴出~
前端代码
<html>
<head>
<meta charset="UTF-8"></meta>
<title>springboot项目WebSocket测试demo</title>
</head>
<body>
<h3>springboot项目websocket测试demo</h3>
<h4>测试说明</h4>
<h5>文本框中数据数据,点击‘发送测试’,文本框中的数据会发送到后台websocket,后台接受到之后,会再推送数据到前端,展示在下方;点击关闭连接,可以关闭该websocket;可以跟踪代码,了解具体的流程;代码上有详细注解</h5>
<br />
<input id="text" type="text" />
<button οnclick="send()">发送测试</button>
<hr />
<button οnclick="clos()">关闭连接</button>
<hr />
<div id="message"></div>
<script>
var websocket = null;
if('WebSocket' in window){
websocket = new WebSocket("ws://buspos.bdxhcom.com/ws");
}else{
alert("您的浏览器不支持websocket");
}
websocket.onerror = function(){
setMessageInHtml("send error!");
}
websocket.onopen = function(){
setMessageInHtml("connection success!")
}
websocket.onmessage = function(event){
setMessageInHtml(event.data);
}
websocket.onclose = function(){
setMessageInHtml("closed websocket!")
}
window.onbeforeunload = function(){
clos();
}
function setMessageInHtml(message){
document.getElementById('message').innerHTML += message;
}
function clos(){
websocket.close(3000,"强制关闭");
}
function send(){
var msg = document.getElementById('text').value;
websocket.send(msg);
}
</script>
</body>
</html>
参考资料