WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。WebSocket 通信协议于2011年被 IETF 定为标准 RFC 6455,并由RFC7936 补充规范。WebSocket API 也被 W3C 定为标准。
WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。 ——百度百科
前言
WebSocket protocol 是HTML5一种新的协议。它实现了浏览器与服务器全双工通信 (full-duplex)。一开始的握手需要借助 HTTP 请求完成。Websocket 是应用层第七层上的一个应用层协议,它必须依赖 HTTP 协议进行一次握手,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。WebSockets 它可以在用户的浏览器和服务器之间打开交互式通信会话。使用此API,可以向服务器发送消息并接收事件驱动的响应,而无需通过轮询服务器的方式以获得响应。 WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的API。
依赖
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<target>1.8</target>
<source>1.8</source>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.50.Final</version>
</dependency>
</dependencies>
第一步:建立 Netty 服务器 Netty Server
package cn.mowen.websocket.netty.config;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class WebSocketServer {
private EventLoopGroup bossGroup = new NioEventLoopGroup(); //主线程池
private EventLoopGroup workerGroup = new NioEventLoopGroup(); //工作线程池
private ServerBootstrap server = new ServerBootstrap(); //服务器
private ChannelFuture future; //回调
@SneakyThrows
public void start() {
server.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new WebsocketInitializer());
future = server.bind(9999).sync();
log.info("Netty_9999 服务器就绪 : [{}]", future.isSuccess());
}
}
第二步:编写通道初始化器
package cn.mowen.websocket.netty.config;
import cn.mowen.common.constant.ServerConstant;
import cn.mowen.websocket.netty.config.handler.HeartBeatHandler;
import cn.mowen.websocket.netty.config.handler.HttpRequestHandler;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
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.stream.ChunkedWriteHandler;
import io.netty.handler.timeout.IdleStateHandler;
import java.util.concurrent.TimeUnit;
/**
* 管道初始化
* ChannelPipeline 类是 ChannelHandler 实例对象的链表,用于处理或截获通道的接收和发送数据。它提供了一种高级的截取过滤模式(类似 servlet 中的 filter 功能)
*/
public class WebsocketInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
// websocket基于http协议,需要有 http 的编解码器
pipeline.addLast(new HttpServerCodec());
// 对写大数据流的支持
pipeline.addLast(new ChunkedWriteHandler());
// 添加对 HTTP 请求和响应的聚合器,只要使用 Netty 进行 Http 编程都需要使用
// 对 HttpMessage 进行聚合,聚合成 FullHttpRequest 或者 FullHttpResponse
pipeline.addLast(new HttpObjectAggregator(1024 * 64));
// 初始化 HttpRequestHandler
pipeline.addLast(new HttpRequestHandler());
// Netty 代理 握手, 处理 close ping pong
pipeline.addLast(new WebSocketServerProtocolHandler("websocket-netty"));
// 添加 Netty 空闲时超时检查的支持(读空闲超时、写空闲超时、读写空闲超时)
pipeline.addLast(new IdleStateHandler(4, 8, 12, TimeUnit.SECONDS));
// Netty 心跳处理、以及读写超时的设置
pipeline.addLast(new HeartBeatHandler());
}
}
第三步:NettyListener 启动的监听器
package cn.mowen.websocket.netty.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Slf4j
@Component
public class NettyListener implements ApplicationListener<ContextRefreshedEvent> {
@Resource
private WebSocketServer webSocketServer;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
webSocketServer.start();
}
}
第四步:对连接请求进行拦截 HttpRequestHandler
package cn.mowen.websocket.netty.config.handler;
import cn.hutool.core.util.StrUtil;
import cn.mowen.common.constant.ServerConstant;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.util.AttributeKey;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.provider.token.TokenStore;
import javax.annotation.Resource;
/**
* 对连接请求进行拦截:
* 认证成功则在通道添加聊天处理的 handler, 且需要修改 websocket 连接的 uri, 交由新引入的 handler 处理
*/
@Slf4j
public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Resource
private TokenStore tokenStore;
public static AttributeKey key = AttributeKey.valueOf("id");
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
log.info("WS认证拦截:[{}]", request.uri());
try {
String authorization = StrUtil.split(request.uri(), "/")[2];
ctx.channel().attr(key).set(authorization);
request.setUri("websocket-netty");
ctx.fireChannelRead(request.retain());
ctx.pipeline().addLast(new WebSocketHandler());
} catch (Exception e) {
ctx.close();
}
}
}
第五步:_处理消息的 _WebSocketHandler
package cn.mowen.websocket.netty.config.handler;
import cn.mowen.websocket.netty.config.handler.HttpRequestHandler;
import cn.mowen.websocket.netty.config.model.Message;
import cn.mowen.websocket.netty.config.model.OnlineChannel;
import com.alibaba.fastjson.JSON;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
import lombok.extern.slf4j.Slf4j;
import java.util.Objects;
/**
* 处理消息的 Handler
* TextWebSocketFrame: 是 Netty 中专门为 websocket 处理文本的对象,frame 是消息的载体
*/
@Slf4j
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// GlobalEventExecutor是具备任务队列的单线程事件执行器,其适合用来实行时间短,碎片化的任务, GlobalEventExecutor.INSTANCE 单例模式创建
private static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
// 接收客户端发来的文本消息进行处理, 当 channel 中有新事件会自动调用 {"message":"hello word","toId":"123"}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
log.info("消息接收:[{}]", frame.text());
Message msg = JSON.parseObject(frame.text(), Message.class);
Channel channel = OnlineChannel.get(msg.getToId());
if (Objects.nonNull(channel)) {
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(msg)));
}
}
// 当有新的客户端连接服务器之后,会自动调用这个方法
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
log.info("客户端开启连接:[{}], [{}]", ctx.channel().id().asLongText(), ctx.channel().remoteAddress());
clients.add(ctx.channel());
OnlineChannel.put((String) ctx.channel().attr(HttpRequestHandler.key).get(), ctx.channel());
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
log.info("客户端断开连接:[{}], [{}]", ctx.channel().id().asLongText(), ctx.channel().remoteAddress());
clients.remove(ctx.channel());
OnlineChannel.remove((String) ctx.channel().attr(HttpRequestHandler.key).get());
}
public static void main(String[] args) {
Message message = new Message();
message.setToId("123");
message.setMessage("test");
System.err.println(JSON.toJSONString(message));
}
}
第六步:心跳检测 HeartBeatHandler
package cn.mowen.websocket.netty.config.handler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;
/**
* Netty 无法检测到客户端为飞行模式时, 自动关闭对应的通道资源
* 自定义 HeartBeatHandler 定期对通道进行检测其是否空闲, 若空闲超过一定时间, 将通道资源关闭
*/
@Slf4j
public class HeartBeatHandler extends ChannelInboundHandlerAdapter {
//重写用户事件触发
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
switch (event.state()) {
case READER_IDLE:
log.info("读空闲触发。。。");
break;
case WRITER_IDLE:
log.info("写空闲触发。。。");
break;
case ALL_IDLE:
log.info("读写空闲触发。。。");
ctx.channel().close();
break;
}
}
}
}
第七步:记录在线连接的 OnlineChannelMap
package cn.mowen.websocket.netty.config.model;
import io.netty.channel.Channel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
public class OnlineChannel {
private static Map<String, Channel> online = new HashMap<>();
public static void put(String key, Channel channel) {
online.put(key, channel);
}
public static Channel get(String key) {
return online.get(key);
}
public static void remove(String key) {
online.remove(key);
}
public static void print() {
online.entrySet().forEach(System.out::println);
}
}
工具
推荐一个在线连接 WebSocket 好用的工具:easyswoole
实际应用中遇到问题
问题背景:项目已经在服务器运行了一段时间,通过监控程序发现一件事情,发现了好多连接处于 CLOSE_WAIT 状态。
服务端做了一个心跳检测,规定的时间范围内 App 没有发送数据过来,然后服务器主动 close 掉这个连接,但是发现并没有真正的关掉,连接而是都处于 CLOSE_WAIT 状态,仍然占用着服务器资源。
解决方案
- 调整linux下/etc/sysctl.conf参数,修改如下参数到合适值(针对业务而定)
net.ipv4.tcp_fin_timeout = 30
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
记得执行sysctl -p
让我们修改的命令生效,其实我主要是把tcp_keepalive_time
从默认值 7200 调到了 60。
- 修改netty启动TCP参数
去掉ChannelOption.SO_LINGER这个参数,选择 ChannelOption.SO_LINGER 默认参数(这个参数的意义大家自己看资料)
然后重启服务器,发现一切归于正常