基于Netty的WebSocket客户端

0 阅读4分钟

关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言

上一节介绍了WebSocket的服务端的相关内容,这一节我们将继续分享WebSocket客户端,稍后我们将手搓两种客户端分别连接我们的WebSocket服务端。

02 Netty客户端

2.1 代码示例

@Slf4j
public class WebMockClient {

    @Getter
    private SocketChannel socketChannel;
    private String url;

    public WebMockClient(String url) {
        this.url = url;
    }

    public void connect() throws InterruptedException {
        EventLoopGroup eventLoopGroup = new NioEventLoopGroup(1);
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.channel(NioSocketChannel.class);
        bootstrap.group(eventLoopGroup);

        bootstrap.handler(new ChannelInitializer() {
            @Override
            protected void initChannel(Channel channel) throws Exception {
                ChannelPipeline pipeline = channel.pipeline();
                pipeline.addLast(new HttpClientCodec());
                pipeline.addLast(new HttpObjectAggregator(65535));
                pipeline.addLast(new WebSocketClientHandler(url));
            }
        });

        ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9090).sync();
        this.socketChannel = (SocketChannel) channelFuture.channel();
    }
}

2.2 参数说明

socketChannel:用来发送消息的客户端,和之前介绍TCP的客户端一致

url:用来连接WebSocket的地址

引导类和TCP的客户端一致:

EventLoopGroup eventLoopGroup = new NioEventLoopGroup(1);
Bootstrap bootstrap = new Bootstrap();
bootstrap.channel(NioSocketChannel.class);
bootstrap.group(eventLoopGroup);

2.3 编解码

bootstrap.handler(new ChannelInitializer() {
    @Override
    protected void initChannel(Channel channel) throws Exception {
        ChannelPipeline pipeline = channel.pipeline();
        pipeline.addLast(new HttpClientCodec());
        pipeline.addLast(new HttpObjectAggregator(65535));
        pipeline.addLast(new WebSocketClientHandler(url));
    }
});

这里需要说明的是客户端的HTTP请求编解码同样有属于自己的编解码HttpClientCodec,同样是一个组合的编解码:

  • io.netty.handler.codec.http.HttpRequestEncoder
  • io.netty.handler.codec.http.HttpResponseDecoder

这里编解码正好和服务端的编解码相反:给HttpRequest编码,给HttpResponse解码

WebSocketClientHandler为自定义的处理器。

2.4 自定义处理器

@Slf4j
public class WebSocketClientHandler extends SimpleChannelInboundHandler {

    private String url;
    private WebSocketClientHandshaker handshaker;


    public WebSocketClientHandler(String url) {
        this.url = url;
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        // 建立客户端
        Channel channel = ctx.channel();
        log.info(">>>>Socket客户端建立连接:channelId={}", channel.id());
        handshaker = WebSocketClientHandshakerFactory.newHandshaker(
                new URI(url), WebSocketVersion.V13, null, true, new DefaultHttpHeaders()
        );
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        this.handshaker.handshake(channel);
        log.info(">>>>WebSocket Client connected! >>{}", channel.id());
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        // 断开链接
        Channel channel = ctx.channel();
        log.info(">>>>Socket客户端断开连接:channelId={}", channel.id());
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 接受消息
        Channel channel = ctx.channel();
        log.info(">>>>Socket收到来自通道channelId[{}]发送的消息:{}", channel.id(), msg);
        log.info(">>>>msg:{}", msg.getClass());
        if (msg instanceof FullHttpResponse) {
            // 处理HTTP响应(握手阶段)
            FullHttpResponse response = (FullHttpResponse) msg;
            if (!handshaker.isHandshakeComplete()) {
                handshaker.finishHandshake(ctx.channel(), response);
                log.info(">>>>WebSocket Handshake completed!");
            }
            return;
        }
        if (msg instanceof WebSocketFrame) {
            // 处理文本消息
            TextWebSocketFrame textFrame = (TextWebSocketFrame) msg;
            log.info(">>>>msg -> textFrame:{}", textFrame.text());
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        log.info(">>>>异常:", cause);
    }
}

这里自定义的处理器和之前的很多不同,需要额外处理WebSocket请求的握手操作

握手操作的主要类:

io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker

握手的时机

在使用客户端发送数据之前需要完成三次握手。

handlerAdded建立连接之后创建握手的引导类:

@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
    // 建立客户端
    Channel channel = ctx.channel();
    log.info(">>>>Socket客户端建立连接:channelId={}", channel.id());
    handshaker = WebSocketClientHandshakerFactory.newHandshaker(
            new URI(url), WebSocketVersion.V13, null, true, new DefaultHttpHeaders()
    );
}

channelActive连接被激活时和通道建立握手关系:

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
    Channel channel = ctx.channel();
    this.handshaker.handshake(channel);
    log.info(">>>>WebSocket Client connected! >>{}", channel.id());
}

这两个其实可以合成一个,放在channelActive里面。当然需要实际的场景。

握手的处理

握手的处理也就是客户端和服务端相互发送消息,需要在channelRead0()里面处理。握手的传输是基于HTTP协议的,所以返回的消息的类型是:

io.netty.handler.codec.http.FullHttpResponse

我们需要处理的是,如果握手没有完成,需要我们手动完成握手。

@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
    // 接受消息
    Channel channel = ctx.channel();
    log.info(">>>>Socket收到来自通道channelId[{}]发送的消息:{}", channel.id(), msg);
    log.info(">>>>msg:{}", msg.getClass());
    if (msg instanceof FullHttpResponse) {
        // 处理HTTP响应(握手阶段)
        FullHttpResponse response = (FullHttpResponse) msg;
        if (!handshaker.isHandshakeComplete()) {
            // 完成握手
            handshaker.finishHandshake(ctx.channel(), response);
            log.info(">>>>WebSocket Handshake completed!");
        }
        return;
    }
    // 后续处理
}

正常消息的接收

@Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        // ......
        if (msg instanceof WebSocketFrame) {
            // 处理文本消息
            TextWebSocketFrame textFrame = (TextWebSocketFrame) msg;
            log.info(">>>>msg -> textFrame:{}", textFrame.text());
        }
    }

握手之后的传输协议就会升级成webSocket协议:

返回的消息类型是:

io.netty.handler.codec.http.websocketx.TextWebSocketFrame

2.5 注意事项

使用客户端的时候,必选在握手完成之后发送消息才会被正确接收,否则就会出现消息丢失的问题。

2.6 测试

@Test
void testWebClient() throws Exception {
    WebMockClient webClient = new WebMockClient("ws://127.0.0.1:9090/testWs");
    webClient.connect();
    SocketChannel socketChannel = webClient.getSocketChannel();

    System.out.println("睡眠开始,等待握手结束......");
    Thread.sleep(3000);
    System.out.println("睡眠结束,等待握手结束......");
    socketChannel.writeAndFlush(new TextWebSocketFrame("测试WebSocket客户端......"));
}

这里的睡眠就是为了等待握手操作接收。真正生产中使用的话需要增加标志位来判断是否握手成功,或者直接使用handshaker.isHandshakeComplete()

测试效果

03 Web客户端

Web客户端就简单了,主流的浏览器均支持。我们之前测试在线WebSocket测试就是基于浏览器的。

我们也手搓一个。

3.1 页面展示

页面源代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket测试</title>
</head>
<body>
    <div style="width: 700px; margin: 0 auto;">
        <div>
            <h1>WebSocket测试</h1>
            <div id="message" style="height: 300px; overflow: auto; border: 1px solid #ccc;"></div>
        </div>
        <div style="margin-top: 10px;">
            <input type="text" id="messageInput">
            <button onclick="sendMessage()">发送信息</button>
        </div>
    </div>
</body>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>
	let socket;
	$(function(){
		if ("WebSocket" in window){
			console.log('浏览器支持 WebSocket');
			socket = new WebSocket("ws://localhost:9090/testWs");

			// 打来链接
			socket.onopen = function(event) {
				console.log("WebSocket is open now.");
			};

			// 处理消息
			socket.onmessage = function(event) {
				let message = event.data;
				console.log("Received message: " + message);
				$("#message").append('<p>收到消息:'+ message + '</p>');
			};

			// 关闭链接
			socket.onclose = function(event) {
				console.log("WebSocket is closed now.");
			};
			
		}else {
			console.log('浏览器不支持 WebSocket');
		}
	});
    

    // 发送消息
    function sendMessage() {
        let message = $("#messageInput").val();
        socket.send(message);
        $("#messageInput").val("");
		$("#message").append('<p>发送消息:'+ message + '</p>');
    }
</script>
</html>

3.2 效果展示

3.3 番外

为了兼容更多的浏览器,可以直接引入对应的js。如Githubstar较多的https://github.com/gimite/web-socket-js项目。

小编这里就不测试了,有兴趣的可以去试试。

04 小结

两种手搓的客户端就已经完成了。WebSocket的传输有没有拆包粘包的问题呢?框架自身又是怎么解决的呢?我们下期讲。