使用Netty构建一个基于WebSocket的聊天室服务器。可以使多个用户使用浏览器可以同时进行相互通信。 程序逻辑: 1、客户端发送一个消息; 2、该消息将被广播到所有其他连接的客户端 服务端启动后,浏览器输入http://localhost:9999
build.gradle文件如下:
plugins { id 'java' } group 'com.ssy.netty' version '1.0.0-SNAPSHOT' sourceCompatibility = 1.8 targetCompatibility = 1.8 repositories { mavenCentral() } dependencies { //testCompile group: 'junit', name: 'junit', version: '4.12' 修改前 compile( "junit:junit:4.12", "io.netty:netty-all:4.1.36.Final" ) }
第一步:编写聊天服务端ChatServer
/** * @author admin
* @since 2019/05/18
*/ package com.netty.talk; import java.net.InetSocketAddress; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.EventLoopGroup; import io.netty.channel.group.ChannelGroup; import io.netty.channel.group.DefaultChannelGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.concurrent.ImmediateEventExecutor; /** * @Title * @Description * 服务引导类 * @author admin * @version 1.0 * @修改记录 * @修改序号,修改日期,修改人,修改内容 */ public class ChatServer { private final ChannelGroup channelGroup = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); private final EventLoopGroup group = new NioEventLoopGroup(); private Channel channel1; public ChannelFuture start(InetSocketAddress address) { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(group).channel(NioServerSocketChannel.class).childHandler(createInitializer(channelGroup)); ChannelFuture future = bootstrap.bind(address); future.syncUninterruptibly(); channel1 = future.channel(); return future; } protected ChannelInitializer<Channel> createInitializer(ChannelGroup channelGroup) { return new ChatServerInitializer(channelGroup); } public void destroy() { if (channel1 != null) { channel1.close(); } channelGroup.close(); group.shutdownGracefully(); } public static void main(String args[]) throws Exception { // if (args.length != 1) { // System.err.println("Please give port as argument"); // System.exit(1); // } //int port = Integer.parseInt(args[0]); int port = 9999; final ChatServer endpoint = new ChatServer(); ChannelFuture future = endpoint.start(new InetSocketAddress(port)); Runtime.getRuntime().addShutdownHook(new Thread() { public void run() { endpoint.destroy(); } }); future.channel().closeFuture().syncUninterruptibly(); } }
第二步:编写ChatServerInitializer
/** * @author admin
* @since 2019/05/18 */ package com.netty.talk; import io.netty.channel.Channel; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.group.ChannelGroup; 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; /** * @Title * @Description * 安装ChannelHandler到ChannelPipeline * @author admin * @version 1.0 * @修改记录 * @修改序号,修改日期,修改人,修改内容 */ public class ChatServerInitializer extends ChannelInitializer<Channel> { private final ChannelGroup group; public ChatServerInitializer(ChannelGroup group) { this.group = group; } @Override protected void initChannel(Channel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); // 将字节解码为HttpRequest、HttpContent和LastHttpContent。并将HttpRequest、HttpContent和LastHttpContent编码为字节 pipeline.addLast(new HttpServerCodec()); // 写入一个文件的内容 pipeline.addLast(new ChunkedWriteHandler()); // 将一个HttpMessage和跟随它的多个HttpContent聚合为单个FullHttpRequest或FullHttpResponse。安装这个之后,ChannelPipeLine中的下一个ChannelHandle只会收到完整的Http请求或响应 pipeline.addLast(new HttpObjectAggregator(64 * 1024)); //处理HTTP请求,WEBSOCKET握手之前 pipeline.addLast(new HTTPRequestHandler("/ws")); //按照WEBSOCKET规范要求,处理WEBSOCKET升级握手、PingWebSocketFrame、PongWebSocketFrame、CloseWebSocketFrame pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); //处理TextWebSocketFrame和握手完成事件 pipeline.addLast(new TextWebSocketFrameHandler(group)); } }
第三步:编写HTTPRequestHandler与TextWebSocketFrameHandler
/** * @author admin
* @since 2019/05/18 */ package com.netty.talk; import java.io.File; import java.io.RandomAccessFile; import java.net.URISyntaxException; import java.net.URL; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.DefaultFileRegion; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.ssl.SslHandler; import io.netty.handler.stream.ChunkedNioFile; /** * @Title 处理HTTP请求 * @Description 这个组件将提供用于访问聊天室并显示由连接的客户端发送的消息的网页 * @author admin * @version 1.0 * @修改记录 * @修改序号,修改日期,修改人,修改内容 */ public class HTTPRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> { private final String wsUri; private static final File INDEX; static { URL location = HTTPRequestHandler.class.getProtectionDomain().getCodeSource().getLocation();// 当前class的绝对路径 String path; try { path = location.toURI() + "index.html"; path = !path.contains("file:") ? path : path.substring(5); INDEX = new File(path); } catch (URISyntaxException e) { throw new IllegalStateException("Unable to locate index.html", e); } } public HTTPRequestHandler(String wsUri) { this.wsUri = wsUri; } @Override protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception { if (wsUri.equalsIgnoreCase(request.uri())) { // SimpleChannelInboundHandler会在channelRead0调用release()释放它的资源,调用retain让引用记数为1,使其不被回收 ctx.fireChannelRead(request.retain()); } else { // 100-continue 是用于客户端在发送 post 数据给服务器时,征询服务器情况,看服务器是否处理 post // 的数据,如果不处理,客户端则不上传 post 是数据,反之则上传。在实际应用中,通过 post 上传大数据时,才会使用到 // 100-continue 协议。 if (HttpUtil.is100ContinueExpected(request)) { send100Continue(ctx); } RandomAccessFile file = new RandomAccessFile(INDEX, "r"); HttpResponse response = new DefaultHttpResponse(request.protocolVersion(), HttpResponseStatus.OK); response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html;charset=UTF-8"); // 当使用Keep-Alive模式(又称持久连接、连接重用)时,Keep-Alive功能使客户端到服 // 务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive功能避免了建立或者重新建立连接。 boolean keepAlive = HttpUtil.isKeepAlive(request); if (keepAlive) { // Keep-Alive模式,客户端如何判断请求所得到的响应数据已经接收完成(或者说如何知道服务器已经发生完了数据)? // Conent-Length表示实体内容长度,客户端(服务器)可以根据这个值来判断数据是否接收完成 response.headers().set(HttpHeaderNames.CONTENT_LENGTH, file.length()); response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); } ctx.write(response); // 如果不需要SSL加密,可以使用region零拷贝特性 if (ctx.pipeline().get(SslHandler.class) == null) { ctx.write(new DefaultFileRegion(file.getChannel(), 0, file.length())); } else { ctx.write(new ChunkedNioFile(file.getChannel())); } ChannelFuture future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); // 如果没有请求keep_alive 则在写操作后关闭channel if (!keepAlive) { future.addListener(ChannelFutureListener.CLOSE); } } } private static void send100Continue(ChannelHandlerContext ctx) { // 100 - Continue 初始的请求已经接受,客户应当继续发送请求的其余部分 FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE); ctx.writeAndFlush(response); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }
/** * Created on 2018年6月20日 by caiming */ package com.netty.talk; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.group.ChannelGroup; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; /** * @Title * @Description * @author admin * @version 1.0 * @修改记录 * @修改序号,修改日期,修改人,修改内容 */ public class TextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { private final ChannelGroup group; public TextWebSocketFrameHandler(ChannelGroup group) { this.group = group; } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) { ctx.pipeline().remove(HTTPRequestHandler.class); group.writeAndFlush(new TextWebSocketFrame("Client " + ctx.channel().remoteAddress() + " joined")); group.add(ctx.channel()); } else { super.userEventTriggered(ctx, evt); } } @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception { Channel incoming = ctx.channel(); for (Channel channel : group) { if (channel != incoming) { channel.writeAndFlush(new TextWebSocketFrame("[" + incoming.remoteAddress() + "]" + msg.text())); } else { channel.writeAndFlush(new TextWebSocketFrame("[you]" + msg.text())); } } } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { // (3) Channel incoming = ctx.channel(); for (Channel channel : group) { channel.writeAndFlush(new TextWebSocketFrame("[SERVER] - " + incoming.remoteAddress() + " 离开")); } group.remove(ctx.channel()); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { Channel incoming = ctx.channel(); System.out.println("Client:" + incoming.remoteAddress() + "异常"); // 当出现异常就关闭连接 cause.printStackTrace(); ctx.close(); } }
编写前端html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>WebSocket Chat</title> </head> <body> <script type="text/javascript"> var socket; if (!window.WebSocket) { window.WebSocket = window.MozWebSocket; } if (window.WebSocket) { socket = new WebSocket("ws://localhost:9999/ws"); socket.onmessage = function(event) { var ta = document.getElementById('responseText'); ta.value = ta.value + '\n' + event.data }; socket.onopen = function(event) { var ta = document.getElementById('responseText'); ta.value = "连接开启!"; }; socket.onclose = function(event) { var ta = document.getElementById('responseText'); ta.value = ta.value + "连接被关闭"; }; } else { alert("你的浏览器不支持 WebSocket!"); } function send(message) { if (!window.WebSocket) { return; } if (socket.readyState == WebSocket.OPEN) { socket.send(message); } else { alert("连接没有开启."); } } </script> <form onsubmit="return false;"> <h3>WebSocket 聊天室:</h3> <textarea id="responseText" style="width: 500px; height: 300px;"></textarea> <input type="text" name="message" style="width: 300px" value=""> <input type="button" value="发送消息" onclick="send(this.form.message.value)"> <input type="button" onclick="javascript:document.getElementById('responseText').value=''" value="清空聊天记录"> </form> </body> </html>
效果如下: