Netty 实现一对一客户端聊天(由服务器转发)

学习Netty有一个多星期,参考《Netty实战》敲了Echo C/S 代码,也学习了channelHandler等组件。不满足客户端服务器一对一聊天,所以寻思着自己实现一个客户端和客户端一对一聊天,消息由服务器转发。
如果有代码写的不合适的地方,还请评论指正。
《Netty实战》电子书下载地址

主要思路


服务器端

  1. 服务器端维护一个ChannelHandlerContext类型的数组,用来存储与服务器连接的各个channel的信息。 (思路来源于《Netty 实战》6.3.2)
  2. 最大连接数控制。设置一个变量为最大数,
  3. 服务器主动关闭连接。当检测到一个连接的客户端掉线或异常,服务器应主动关闭连接
  4. 服务器转发消息。通过ChannelHandlerContext类型的数组获得与之相连的channel信息,将服务器读到的消息写给另一个客户端
  5. 译码器解码器。使读写更加方便
  6. 为了便于调试,还附加了一些包含获取时间,服务器显示读取的消息等等函数

客户端

  1. 发送信息
  2. 读取信息
  3. 主动关闭连接
  4. 输入指定字符串时下线。由于是控制台程序,所以我设置输入“bye”或“再见”时,调用相关函数关闭连接

实现代码


服务器端

类结构

类名 说明
ServerHandler 继承ChannelInboundHandlerAdapter接口
Server 引导服务器端

实现代码

ServerHandler类

继承ChannelInboundHandlerAdapter接口,重写一些方法,添加一些私有变量来存储和控制channel信息

//ServerHandler类

import java.net.InetSocketAddress;
import java.util.Calendar;
import java.util.Vector;

import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandler.Sharable;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;

@Sharable   
public class ServerHandler extends ChannelInboundHandlerAdapter {

    private static final int MAX_CONN = 2;//指定最大连接数
    private int connectNum = 0;//当前连接数
    //channelHandlerContext表
    private Vector contexts = new Vector<>(2);

    //获取当前时间
    private String getTime() {

        Calendar c = Calendar.getInstance();
        int year = c.get(Calendar.YEAR);
        int month = c.get(Calendar.MONTH);
        int date = c.get(Calendar.DATE);
        int hour = c.get(Calendar.HOUR_OF_DAY);
        int minute = c.get(Calendar.MINUTE);
        int second = c.get(Calendar.SECOND);
        return new String(year + "/" + month + "/" + date + " " + hour + ":" + minute + ":" + second);

    }

    /*
     * 重写channelActive()方法
     * 更新当前连接数
     * 控制连接客户端的个数,超过则关闭该channel
     * 更新contexts数组
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        // TODO Auto-generated method stub
        connectNum++;
        //控制客户端连接数量,超过则关闭
        if (connectNum > MAX_CONN) {
            ctx.writeAndFlush(Unpooled.copiedBuffer("达到人数上限".getBytes()));
            ctx.channel().close();
            //当前连接数的更新放在channelInactive()里
        }
        //更新contexts
        contexts.add(ctx);
        //控制台输出相关信息
        InetSocketAddress socket = (InetSocketAddress) ctx.channel().remoteAddress();
        System.out.println(socket.getAddress().getHostAddress() + ":" + socket.getPort() + "已连接");
        System.out.println("当前连接数:" + connectNum);
        ctx.writeAndFlush("hello client");
    }

    /*
     * 重写channelInactive()方法
     * 更新当前连接数
     * 更新contexts数组
     * 控制台输出相关信息
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        // TODO Auto-generated method stub
        //更新当前连接数
        connectNum--;
        //更新contexts数组
        contexts.remove(ctx);
        //控制台输出相关信息
        InetSocketAddress socket = (InetSocketAddress) ctx.channel().remoteAddress();
        System.out.println(getTime() + ' ' + socket.getAddress().getHostAddress() + ":" + socket.getPort() + "已退出");
        System.out.println("当前连接数:" + connectNum);
        //对另一个客户端发出通知
        if (contexts.size() == 1) {
            contexts.get(0).writeAndFlush("对方退出聊天");
        }
    }

    /*
     * 重写channelRead()函数
     * 读取数据
     * 转发消息
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // TODO Auto-generated method stub
        String in = (String) msg;
        System.out.println(getTime() + " 客户端" + ctx.channel().remoteAddress() + ":" + in);
        //当只有一方在线时,发送通知
        if (contexts.size() < 2) {
            ctx.writeAndFlush("对方不在线");
            return;
        }
        //获取另一个channelhandlercontxt的下表
        int currentIndex = contexts.indexOf(ctx);
        int anotherIndex = Math.abs(currentIndex - 1);
        //给另一个客户端转发信息
        contexts.get(anotherIndex).writeAndFlush(in);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        // TODO Auto-generated method stub
        if (!ctx.channel().isActive()) {
            System.out.println(ctx.channel().remoteAddress() + "客户端异常");
        }
        cause.printStackTrace();
        ctx.close();
    }

}

Server类

用的最常规最简单的写法,和EchoServer差不多,等以后进一步学习再丰富内容

//Server类

import java.net.InetSocketAddress;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class Server {

    public static void main(String[] args) throws InterruptedException {
        new Server().start();
    }

    public Server() {
        // TODO Auto-generated constructor stub
    }
    // 引导类
    public void start() throws InterruptedException {
        ServerHandler sHandler = new ServerHandler();
        InetSocketAddress localSocket = new InetSocketAddress("127.0.0.1", 9990);
        ServerBootstrap b = new ServerBootstrap();
        b.group(newNioEventLoopGroup())
        .localAddress(localSocket)
        .childHandler(new ChannelInitializer() {

                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        //添加译码器解码器
                        ch.pipeline().addLast(new StringDecoder());
                        ch.pipeline().addLast(new StringEncoder());
                        ch.pipeline().addLast(sHandler);
                    }

                });
        final ChannelFuture f = b.bind().sync();
        f.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture f) throws Exception {
                // TODO Auto-generated method stub
                if (f.isSuccess()) {
                    System.out.println("服务器开启成功");
                } else {
                    System.out.println("服务器开启失败");
                    f.cause().printStackTrace();
                }
            }
        });
    }
}

客户端

类结构

类名 说明
ClientHandler 继承SimpleChannelInboundHandler < String > 接口
Client 引导客户端

实现代码

ClientHandler类

继承SimpleChannelInboundHandler< String >接口,重写一些方法,添加私有closeChannel()来主动关闭连接


import java.util.Calendar;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

//因为加入了String类型的编码器和译码器,所以接口实例化为String类型
public class clientHandler extends SimpleChannelInboundHandler<String> {

    //保存当前ChannelHandlerContext,在后面的closeChannel()中使用
    private ChannelHandlerContext chc = null;

    //获取当前时间
    private String getTime() {
        Calendar c = Calendar.getInstance();
        int year = c.get(Calendar.YEAR);
        int month = c.get(Calendar.MONTH);
        int date = c.get(Calendar.DATE);
        int hour = c.get(Calendar.HOUR_OF_DAY);
        int minute = c.get(Calendar.MINUTE);
        int second = c.get(Calendar.SECOND);
        return new String(year + "/" + month + "/" + date + " " + hour + ":" + minute + ":" + second);
    }

    //主动关闭连接
    public void closeChannel(boolean readyToClose) throws InterruptedException {
        if (readyToClose) {
            System.out.println("即将关闭连接");
            chc.channel().closeFuture();
            chc.channel().close();
        }
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        // TODO Auto-generated method stub
        //保存当前ChannelHandlerContext
        chc = ctx;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String in) throws Exception {
        // TODO Auto-generated method stub
        System.out.println(getTime() + " " + in);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        // TODO Auto-generated method stub
        System.out.println(getTime() + " 断开连接");
        ctx.channel().closeFuture();
        ctx.channel().close();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        // TODO Auto-generated method stub
        cause.printStackTrace();
        System.out.println("有异常");
        ctx.channel().close();
    }

}

Client类

简单地引导客户端

//Client类
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class Client {

    public void start() throws IOException, InterruptedException {
        Bootstrap b = new Bootstrap();
        EventLoopGroup g = new NioEventLoopGroup();
        //创建对象,后面调用closeChannel()主动关闭连接
        ClientHandler cHandler = new ClientHandler();

        b.group(g)
        .channel(NioSocketChannel.class)
        .handler(new ChannelInitializer() {
            @Override
            protected void initChannel(SocketChannel sc) throws Exception {
                // TODO Auto-generated method stub
                //添加编码器、译码器
                sc.pipeline().addLast(new StringDecoder());
                sc.pipeline().addLast(new StringEncoder());
                sc.pipeline().addLast(cHandler);
            }
        });
        final ChannelFuture f = b.connect("127.0.0.1", 9990).sync();
        f.addListener(new ChannelFutureListener() {

            @Override
            public void operationComplete(ChannelFuture arg0) throws Exception {
                // TODO Auto-generated method stub
                if (f.isSuccess()) {
                    System.out.println("连接服务器成功");

                } else {
                    System.out.println("连接服务器失败");
                    f.cause().printStackTrace();
                }
            }
        });
        Channel channel = f.channel();
        /*
         * 获取控制台输入
         * 当输入了“再见”或“bye”时,停止输入
         * 主动关闭连接
         */
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String in = br.readLine();
        while (!(in.equals("再见") || in.equals("bye"))) {
            channel.writeAndFlush(in);
            in = br.readLine();
        }
        channel.writeAndFlush(in);
        cHandler.closeChannel(true);
        g.shutdownGracefully().sync();

    }

    public Client() {
        // TODO Auto-generated constructor stub
    }

    public static void main(String[] args) throws Exception {
        new Client().start();
    }
}

实现过程中遇到的一些问题

关于控制台输入的问题

控制台输入使用如下代码

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String in = br.readLine();
        while (!(in.equals("再见") || in.equals("bye"))) {
            channel.writeAndFlush(in);
            in = br.readLine();
        }
        channel.writeAndFlush(in);

这段代码可以放在很多位置,不一定非要放在客户端内,在ClientHandler类的channelActive()里也可以加入这段代码。也就是说,在任何你想要通过控制台输入的地方都可以输入。

ChannelHandlerContext的使用

为了实现消息转发,我维护了一个数组用来保存每个连接的ChannelHandlerContext,这样在读取一个客户端发送的消息时,我可以迅速找到另一个客户端的ChannelHandlerContext并向其发送刚刚读取的信息。

这个技巧是从《Netty实战》6.3.2小节中学到的,在ChannelHandler中存储一些ChannelHandlerContext的引用以供以后使用,这将很方便地管理不同的channel,更广泛地说,在其他操作中对目标channel进行操作。这也要求ChannelHandler必须标注@Sharable,让多个ChannelPipeline共享同一个ChannelHandler时不会出现异常。

不过为了避免外部调用,标记为private是个不错的选择。

客户端发完消息后自动断开连接

这个问题发生的很突然,当我一步步实现了各个功能后,一测试发现客户端发送一条消息后莫名其妙自动下线了,并且触发了channelInactive()函数。为此我付出两个小时的代价寻找问题的根源,这也是为什么我加了那么多控制台输出信息和getTime()函数,就是为了找出在那里出的问题。
最后在群里以为大神的指点下,找到了问题所在。
在《Netty实战》中的EchoServerHandler中,有这样一段override:

@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
    // TODO Auto-generated method stub
    ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
}

由于没有理解透彻,我简单地把这个函数也写到了我的ServerHandler中,最后就是这里出了问题。
在 ChannelInboundHandler 的方法中,channelReadComplete()方法是当Channel上的一个读操作完成时被调用,而上面这行代码在最后使用了ChannelFutureListener.CLOSE,再看一下CLOSE的原码:

ChannelFutureListener CLOSE = new ChannelFutureListener() {
        public void operationComplete(ChannelFuture future) {
            future.getChannel().close();
        }
    };

这样就一目了然了,如果这样重写channelReadComplete(),那么服务器将会在读操作完成时,关闭连接。
但是在独自找问题的时候,我也搜索到了关于断线重连心跳包这些对象,我也确实遇到了这个问题,出去办件事后就掉线了。后面会关注这些并实现他们。
这里也感谢那位大神,愿意花时间帮我看代码、改代码、解决问题、讲解原理,再次感谢!

连接数控制

这是我在测试客户端时发现的问题。当连接三个客户端时,由于服务器比较简单,所以转发操作会出现问题,最终干脆限制连接数,如果超过了2个,服务器就主动关闭连接。当然这不是个好方法,我记得在一些游戏中,如果当前连接人数太多会让你等待排队,而不是直接掉线,这样太不友好了。这个功能在日后我会关注和实现它。

只有一个客户端时,发送消息无响应

这个算是一个优化吧,当只有一个客户端连接时,服务器应该提醒对方不在线。
由此我联想到QQ的做法,当一对一聊天时,即使对方不在线,信息也会发送过去,当对方上线后,信息会按时间顺序展示给他。我想到大概是利用缓存,将消息暂时存起来,当channelActive()调用,就把消息按顺序发送。这个日后实现。
还有一个就是聊天记录,这个应该可以存到数据库中,需要的时候通过关键字就可以查询,这个也日后实现吧。

问题总结

已解决

服务器

  1. 维护ctx数组
  2. 最大连接数控制
  3. 主动关闭连接
  4. 转发消息
  5. 读取客户端信息

客户端

  1. 发送消息
  2. 读消息
  3. 主动关闭连接
  4. 发送指定字符串后关闭连接

待实现

  1. 断线重连
  2. 心跳包保持在线
  3. 保存聊天记录
  4. 离线时保存未接收消息,上线即得
  5. 超过最大连接数,排队等待

你可能感兴趣的:(Netty)