如何构建一个简单的基于Netty的C/S架构网络应用程序

版权声明:转载请注明出处 https://blog.csdn.net/abc123lzf/article/details/80560553

一、前言

在基于C/S架构的应用程序中,对于服务端我们需要实现的功能通常有:

  • 保持长连接并随时接收客户端发送的请求
  • 能够主动向客户端发送响应信息
  • 能够实现客户端到客户端信息的传输

对于客户端:
- 保持和服务器的长连接
- 能够主动向服务器发送消息
- 能随时接收服务器的响应

下面我们来讨论如何实现上面的功能

二、服务端

(1)服务器启动前的引导

在启动服务器前,我们需要根据业务需求进行一系列具体的配置。
服务器引导类ServerBootstrap主要方法如下:

方法 作用
group 绑定EventLoopGroup,一般来说,客户端只需绑定一个EventLoopGroup,服务端一般需要绑定两个, 一个负责接收客户端连接,另外一个负责管理客户端连接
channel 指定管理连接的Channel类,一般会指定非阻塞Channel类,服务器为NioServerSocketChannel,客户端为NioSocketChannel,也可以指定阻塞Channel类(一般不用)
option 进行一系列设置,比如是否设置长连接,指定是TCP连接还是UDP连接,关于具体的设置请参考:https://blog.csdn.net/asdfayw/article/details/62433902
childHander 指定一个ChannelInitializer<T>,泛型参数为Channel的类型,一般为SocketChannel

下面为服务器启动类参考源码:

public final class NettyServer implements Runnable {
    private final int port;
    private ServerSocketChannel serverSocketChannel;
    public NettyServer(int port) {
        this.port = port;
    }
    @Override
    public void run() {
        //设置两个EventLoopGroup,connGroup负责接收客户端连接,workGroup负责向已连接的客户端传送和接收数据
        EventLoopGroup connGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();
        //创建一个服务端引导类
        ServerBootstrap boot = new ServerBootstrap();
        //绑定EventLoopGroup,使用非阻塞IO,并设置ChannelInitializer
        boot.group(connGroup, workGroup).channel(NioServerSocketChannel.class).option(ChannelOption.SO_BACKLOG, 128)
                .option(ChannelOption.SO_KEEPALIVE, true).childOption(ChannelOption.TCP_NODELAY, true)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new ObjectDecoder(102400 * 1024,
                                ClassResolvers.cacheDisabled(this.getClass().getClassLoader())));
                        ch.pipeline().addLast(new ObjectEncoder());
                        ch.pipeline().addLast(new InboundHandler());
                    }
                });
        try {
            //绑定指定端口,并阻塞到ChannelFuture返回
            ChannelFuture future = boot.bind(port).sync();
            if (future.isSuccess()) {
                serverSocketChannel = (ServerSocketChannel) future.channel();
                System.out.println("服务器已启动,端口号:" + port);
            } else {
                System.out.println("服务器启动失败");
            }
            //一直阻塞,直到服务器关闭
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            connGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }
}

(2)如何实现长连接和主动向客户端发送消息

长连接
长连接需要在服务器引导阶段设置,调用ServerBootstrap实例的option方法并设置ChannelOption.SO_KEEPALIVE为true即可。
如何主动向客户端发送消息
当有一个新的客户端连入服务器后,进站处理器的channelActive方法会被调用,这时我们可以调用ChannelHandlerContext对象的channel方法取出对应的SocketChannel。
对于每一个客户端连接,我们可以创建一个Session对象,并保存在一个Map里。这个Session对象需要保存客户端连接对应的SocketChannel,并且需要为它们生成一个UUID,类似于Web里的Session ID,并将这个ID发送给客户端,客户端发送的请求内容都需要包含这个Session ID以便服务器能快速找到对应的SocketChannel,如果这个时候我们希望发送数据给客户端,我们可以调用SocketChannel对象的writeAndFlush方法即可。
这里我们采用一个服务端管理类,它负责启动服务器初始化进程和管理客户端连接。
服务端管理类

public class Server {
    private static boolean isInit = false;
    private static NettyServer server;
    //Key和Value均采用同一对象,主要是为了方便通过会话ID创建一个临时Session对象来找出Value
    private static final ConcurrentMap<Session, Session> clientMap = new ConcurrentHashMap<>();
    //服务端初始化,由主类调用,运行期间只允许初始化一次
    public static void init(int port) {
        if(isInit)
            throw new UnsupportedOperationException("服务器不允许第二次初始化");
        server = new NettyServer(port);
        new Thread(server).start();
    }
    //获得管理用户会话的Map
    public static ConcurrentMap<Session, Session> getClientSet() {
        return clientMap;
    }
    /**
     * 向指定客户端发送消息
     * @param sessionId 客户端会话对象的id(SessionId)
     * @param message 向客户端发送的响应
     */
    public static void sendMessage(String sessionId, Response message) {
        System.out.println(sessionId);
        Session session = clientMap.get(Session.hash(sessionId));
        session.getSocket().writeAndFlush(message);
    }
    private Server() { }
}

进站处理器部分

@Sharable
public class InboundHandler extends ChannelInboundHandlerAdapter {

    // 服务器与客户端创建连接时调用,将Session对象加入Map中,并及时将SessionID发送给客户端
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        System.out.println("已收到客户端连接");
        Session session = new Session((SocketChannel) ctx.channel());
        Server.getClientSet().put(session, session);
        ctx.writeAndFlush(new Response(ResponseProtocol.SESSION_ID, session.getId()));
    }

    // 客户端与服务器断开时调用,从Map中移除Session对象
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        System.out.println("客户端会话关闭");
        Server.getClientSet().remove(new Session((SocketChannel) ctx.channel()));
    }

    // 服务端接收客户端发送的数据结束后调用,这里用来清空缓冲区
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }
    //当用户强制退出或有其它异常抛出时会调用这个方法,这里会直接关闭客户端的连接
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) {
        if (!(t instanceof IOException)) {
            t.printStackTrace();
        } else {
            System.out.println("客户端断开连接");
        }
        ctx.close();
    }
    // 用于读取客户端发送的请求类,这里根据实际业务需求决定
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        System.out.println("已收到客户端对象请求");
        Request request = (Request) msg;
        //....
        //需要显式释放资源
        ReferenceCountUtil.release(msg);
    }
}

Session对象的设计
为了便于查找,我们建议仅通过会话ID计算hash值并使用一个静态方法来返回一个临时Session对象,这个临时对象仅设置用户Request中包含的会话ID。

public class Session {
    private final String id;
    private User user;
    private final SocketChannel socket;

    public Session(SocketChannel socket) {
        this.id = UUID.randomUUID().toString();
        this.socket = socket;
    }
    //只可由静态方法hash调用,用于从Map中查找
    private Session(String id) {
        this.id = id;
        this.socket = null;
    }
    //返回一个用于查找Session的临时对象
    public static Session hash(String id) {
        return new Session(id);
    }
    public SocketChannel getSocket() {
        return this.socket;
    }
    public String getId() {
        return id;
    }
    //hash值仅与会话ID有关
    @Override
    public int hashCode() {
        return id.hashCode();
    }
    //通过会话ID判断两个类是否相等
    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Session))
            return false;
        Session session = (Session) obj;
        if (!session.getId().equals(this.id))
            return false;
        return true;
    }
    public User getUser() {
        return user;
    }
    public void setUser(User user) {
        this.user = user;
    }
}

当我们需要从Map中取出需要的Session对象时,我们可以这样(以上述代码为标准):

Session session = clientMap.get(Session.hash(sessionId));

然后取出Session对象中包含的SocketChannel即可。

(3)通信协议的设计

通信协议的解决方案之一就是将信息包含在一个可序列化的对象中。
请求类(Request)
请求类包含三个部分,请求协议头,客户端会话ID,请求体(即具体的信息)
当服务端接收到一个客户端的请求时,我们先取出Request对象的请求头部分,并通过请求头具体信息调用对应的方法来处理请求体。
如果需要向客户端发送一条响应,我们就可以按照上述方法通过会话ID找出对应的SocketChannel即可。
下面是请求类的参考代码:

public class Request implements java.io.Serializable {
    private static final long serialVersionUID = 6336306709336891264L;  
    private String head; //请求头,这里用了String类型,也可以用其他类型
    private String fromId; //客户端会话ID
    private Object[] content;   //请求体
    public Request(String head, String fromId, Object... content) {
        this.head = head;
        this.fromId = fromId;
        this.content = content;
    }
    public String getHead() {
        return head;
    }
    public String getFromId() {
        return fromId;
    }
    public Object[] getContent() {
        return content;
    }
}

响应类(Response)
对于响应类我们只需包含两个部分即可,请求体和请求头。
发送响应对象时通过调用对应客户端的SocketChannel的writeAndFlush(response)即可
下面是响应类参考代码:

public class Response implements java.io.Serializable {
    private static final long serialVersionUID = -7804537742238419970L;
    private String head;
    private Object[] content;
    public Response(String head, Object... content) {
        this.head = head;
        this.content = content;
    }
    public String getHead() {
        return head;
    }
    public Object[] getContent() {
        return content;
    }
}

如果有更复杂的业务需求,也可以定义一个抽象Request、Response类,这个抽象类仅保留有请求头和客户端会话ID,然后再通过定义多个具体的类继承它们。每当服务器接收到一个请求类时,通过请求头将它们强制转换成子类并调用处理方法即可。

三、客户端

客户端连接部分相对于服务端来说要显得简单,客户端只需要保留有一个和服务器连接的SocketChannel,而且只需要一个EventLoopGroup。

(1)客户端引导

客户端引导类和服务端不同,客户端引导类为Bootstrap,且在连接时需要提供服务器主机名和端口号。

public final class NettyClient implements Runnable {
    private final String host;
    private final int port;
    //保存和服务器连接对应的SocketChannel
    SocketChannel socketChannel;

    public NettyClient(String host, int port) {
        this.host = host;
        this.port = port;
    }
    @Override
    public void run() {
        //创建一个EventLoopGroup用来接收服务端发送过来的数据
        EventLoopGroup group = new NioEventLoopGroup();     
        Bootstrap boot = new Bootstrap();       
        //绑定EventLoopGroup,设置为非阻塞模式,绑定主机名和端口,设置ChannelInitializer
        boot.group(group).channel(NioSocketChannel.class).remoteAddress(new InetSocketAddress(host, port))
            .option(ChannelOption.SO_KEEPALIVE, true).option(ChannelOption.TCP_NODELAY, true)
            .handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ObjectDecoder(102400 * 1024,
                            ClassResolvers.cacheDisabled(this.getClass().getClassLoader())));
                    ch.pipeline().addLast(new InboundHandler());
                    ch.pipeline().addLast(new ObjectEncoder());
                }
            });     
        try {
            //调用connect方法连接(UDP协议调用bind即可),并阻塞到有返回结果时为止
            ChannelFuture future = boot.connect().sync();
            if(future.isSuccess()) {
                //连接成功后返回SocketChannel
                socketChannel = (SocketChannel) future.channel();
                System.out.println("成功连入服务器");
            } else {
                JOptionPane.showMessageDialog(null, "服务器或客户端异常,请尝试重新启动客户端", 
                        "错误", JOptionPane.WARNING_MESSAGE);  
            }
            //一直阻塞到连接关闭为止
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            try {
                group.shutdownGracefully().sync();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }   
    }
}

(2)如何向服务端发送消息

客户端连接到服务器后,引导类会自动设置好SocketChannel。这时,我们需要创建一个客户端连接管理类,用来保存服务端发送过来的会话ID,并负责向服务端发送数据。
客户端连接管理类参考源码

public final class Client { 
    private static boolean isInit = false;
    private static NettyClient client;
    private static String sessionId;
    //由主类调用此方法,此方法会引导客户端连接
    public static void init(String host, int port) {
        if(isInit)
            throw new UnsupportedOperationException("客户端不允许第二次初始化");
        client = new NettyClient(host, port);
        new Thread(client).start();
    }
    public static NettyClient getClient() {
        return client;
    }
    /**
     * 向服务端发送请求
     * @param request 发送的请求
     */
    public static void sendMessage(Request request) {
        //调用socketChannel方法的writeAndFlush发送消息
        client.socketChannel.writeAndFlush(request);
    }
    public static String getSessionId() {
        return sessionId;
    }
    static void setSessionId(String sessionId) {
        Client.sessionId = sessionId;
    }
    private Client() { }
}

进站处理器参考源码
不同于服务端,我们只需要重写两个方法:channelRead和exceptionCaught方法即可。因为在客户端连接时已经返回一个具体的SocketChannel对象,无需再通过channelActive来得到一个和服务端连接的SocketChannel对象。(对于客户端可以继承SimpleChannelInboundHandlerAdapter<T>,这个方法会自动将消息进行强制转换(转换为T),只需要重写channelRead0方法即可,并且无需显式释放资源)

@Sharable
public class InboundHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object obj) {
        System.out.println("已收到客户端对象请求");
        Request request = (Request) msg;
        //....
        //这里同样需要显式释放资源
        ReferenceCountUtil.release(msg);
    }
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) {
        t.printStackTrace();
        ctx.close();
    }
}

猜你喜欢

转载自blog.csdn.net/abc123lzf/article/details/80560553