Netty 开发 WebSocket

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 状态,仍然占用着服务器资源。

解决方案
  1. 调整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。

  1. 修改netty启动TCP参数
    去掉ChannelOption.SO_LINGER这个参数,选择 ChannelOption.SO_LINGER 默认参数(这个参数的意义大家自己看资料)
    然后重启服务器,发现一切归于正常
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值