写在前头
在我们了解了 Netty 之后我们知道了 Netty 是一个网络框架,支持众多网络协议,其中就包括 WebSocket 协议。今天我们就使用 Netty 的这部分功能结合 SpringBoot 来构建一个实时通讯的应用。这里贴上一张图来看一下我们要到达的效果。
我们在写这个应用需要弄明白 Netty 的基本概念,SpringBoot 的基本使用以及 WebSocket 的基础知识。
- SpringBoot 开箱使用 (一)
- Java 网络框架 Netty(一)—— 概念
- WebSocket 学习笔记(一)
如果没有掌握相关的基础知识,建议可以先去学习一下自己不具备的相关知识。
整理思路
咱们需要写一个聊天室, 主要需要使用到的是 Netty 的 WebSocket 能力,就想上一篇中我们使用到了 Netty 的HTTP 能力一样,我们这里使用 SpringBoot 只是为了给浏览器返回静态的 Html 页面,当然我们也可以不需要 Spring 这一部分。直接用浏览器打开本地的 Html 文件查看。
Code
我们这里只看 Netty 构建 WebSocket 服务这一块的核心代码。
- NettyConfig.java
public class NettyConfig {
// 存储所有连接的 channel
public static ChannelGroup group = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
// host name 和监听的端口号,需要配置到配置文件中
public static String WS_HOST = "127.0.0.1";
public static int WS_PORT = "9090";
}
- WebSocketHandler.java
和上一篇中构建 Http 的 Handler 一样,我们这里需要一个 WebSocketHandler 来处理进来的连接。我们让 WebSocketHandler 继承自 SimpleChannelInboundHandler
public class WebSocketHandler extends SimpleChannelInboundHandler<Object> {
private WebSocketServerHandshaker handshaker;
private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);
private Gson gson = new Gson();
// onmsg
// 有信号进来时
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
if(msg instanceof FullHttpRequest){
handHttpRequest(ctx, (FullHttpRequest) msg);
}else if(msg instanceof WebSocketFrame){
handWsMessage(ctx, (WebSocketFrame) msg);
}
}
// onopen
// Invoked when a Channel is active; the Channel is connected/bound and ready.
// 当连接打开时,这里表示有数据将要进站。
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
NettyConfig.group.add(ctx.channel());
}
// onclose
// Invoked when a Channel leaves active state and is no longer connected to its remote peer.
// 当连接要关闭时
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
broadcastWsMsg( ctx, new WsMessage(-11000, ctx.channel().id().toString() ) );
NettyConfig.group.remove(ctx.channel());
}
// onmsgover
// Invoked when a read operation on the Channel has completed.
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.flush();
}
// onerror
// 发生异常时
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
// 集中处理 ws 中的消息
private void handWsMessage(ChannelHandlerContext ctx, WebSocketFrame msg) {
if(msg instanceof CloseWebSocketFrame){
// 关闭指令
handshaker.close(ctx.channel(), (CloseWebSocketFrame) msg.retain());
}
if(msg instanceof PingWebSocketFrame) {
// ping 消息
ctx.channel().write(new PongWebSocketFrame(msg.content().retain()));
}else if(msg instanceof TextWebSocketFrame){
TextWebSocketFrame message = (TextWebSocketFrame) msg;
// 文本消息
WsMessage wsMessage = gson.fromJson(message.text(), WsMessage.class);
logger.info("接收到消息:" + wsMessage);
switch (wsMessage.getT()){
case 1: // 进入房间
// 给进入的房间的用户响应一个欢迎消息,向其他用户广播一个有人进来的消息
broadcastWsMsg( ctx, new WsMessage(-10001,wsMessage.getN()) );
AttributeKey<String> name = AttributeKey.newInstance(wsMessage.getN());
ctx.channel().attr(name);
ctx.channel().writeAndFlush( new TextWebSocketFrame(
gson.toJson(new WsMessage(-1, wsMessage.getN()))));
break;
case 2: // 发送消息
// 广播消息
broadcastWsMsg( ctx, new WsMessage(-2, wsMessage.getN(), wsMessage.getBody()) );
break;
case 3: // 离开房间.
broadcastWsMsg( ctx, new WsMessage(-11000, wsMessage.getN(), wsMessage.getBody()) );
break;
}
}else {
// donothing, 暂时不处理二进制消息
}
}
// 处理 http 请求,WebSocket 初始握手 (opening handshake ) 都始于一个 HTTP 请求
private void handHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
if(!req.decoderResult().isSuccess() || !("websocket".equals(req.headers().get("Upgrade")))){
sendHttpResponse(ctx, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
HttpResponseStatus.BAD_REQUEST));
return;
}
WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory("ws://"
+ NettyConfig.WS_HOST + NettyConfig.WS_PORT, null, false);
handshaker = factory.newHandshaker(req);
if(handshaker == null){
WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
} else {
handshaker.handshake(ctx.channel(), req);
}
}
// 响应非 WebSocket 初始握手请求
private void sendHttpResponse(ChannelHandlerContext ctx, DefaultFullHttpResponse res) {
if(res.status().code() != 200){
ByteBuf buf = Unpooled.copiedBuffer(res.status().toString(), CharsetUtil.UTF_8);
res.content().writeBytes(buf);
buf.release();
}
ChannelFuture f = ctx.channel().writeAndFlush(res);
if(res.status().code() != 200){
f.addListener(ChannelFutureListener.CLOSE);
}
}
// 广播 websocket 消息(不给自己发)
private void broadcastWsMsg(ChannelHandlerContext ctx, WsMessage msg) {
NettyConfig.group.stream()
.filter(channel -> channel.id() != ctx.channel().id())
.forEach(channel -> {
channel.writeAndFlush( new TextWebSocketFrame( gson.toJson( msg )));
});
}
}
- ServerInitializer.java
使用 ChannelPipeline 将各种 ChannerHandler 串起来
public class ServerInitializer extends ChannelInitializer<Channel> {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast("http-codec", new HttpServerCodec());
pipeline.addLast("aggregator",new HttpObjectAggregator(65536));
pipeline.addLast("http-chunked", new ChunkedWriteHandler());
pipeline.addLast("handler", new WebSocketHandler());
}
}
- ServerBootStrap.java
启动和销毁 Netty 的程序
@Component
public class ServerBootStrap {
private final EventLoopGroup bossGroup = new NioEventLoopGroup();
private final EventLoopGroup workGroup = new NioEventLoopGroup();
private Channel channel;
public ChannelFuture start(InetSocketAddress address) {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ServerInitializer())
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);
ChannelFuture future = bootstrap.bind(address).syncUninterruptibly();
channel = future.channel();
return future;
}
public void destroy() {
if(channel != null) {
channel.close();
}
NettyConfig.group.close();
workGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
由于我们需要使用到 SpringBoot,当然你也可以不用,你可以写个 main 方法,然后在里面 new 一个 ServerBootStrap 的实例调用其 start 方法即可,我们这里让 SpringBoot 的主程序 App.java 实现 CommandLineRunner 接口,然后在 run 方法中这样操作。
@SpringBootApplication
public class App implements CommandLineRunner{
private static final Logger logger = LoggerFactory.getLogger(App.class);
@Autowired
private ServerBootStrap ws;
public static void main(String[] args) throws Exception {
// SpringApplication 将引导我们的应用,启动 Spring,相应地启动被自动配置的 Tomcat web 服务器。
SpringApplication.run(App.class, args);
}
// 注意这里的 run 方法是重载自 CommandLineRunner
@Override
public void run(String... args) throws Exception {
logger.info("Netty's ws server is listen: " + NettyConfig.WS_PORT);
InetSocketAddress address = new InetSocketAddress(NettyConfig.WS_HOST, NettyConfig.WS_PORT);
ChannelFuture future = ws.start(address);
Runtime.getRuntime().addShutdownHook(new Thread(){
@Override
public void run() {
ws.destroy();
}
});
future.channel().closeFuture().syncUninterruptibly();
}
}
前端的代码,就不贴在这儿了,前端的代码逻辑很简单,在代码仓库中对应
- 页面 /resources/templates/chat.html
- js /resources/public/js/chat.js
- css /resources/public/css/chat.css
代码仓库
https://github.com/xiaop1ng/PlayWithSpringBoot