Netty实战之初识Netty

Netty是搞后台开发必须要学习的一个网络框架,也是面试中常常会被拿出来问的一个点,这主要是因为现在Netty在大型项目中的应用越来越广,像elasticsearch、twitter、facebook等都在用它,而且Netty自身的框架设计以及性能都非常优秀,非常值得去学习。

1、Netty的优势

Netty有哪些特点使得大家对他趋之若鹜呢?

(1) 降低了开发高性能高并发网络应用的门槛。使用Netty你无需成为一个网络编程的专家也能比较容易的写出高性能,高并发的网络应用,这主要得益于Netty良好的封装,使得网络编程中许多复杂的细节和性能问题对开发者透明。

(2) 良好的性能和复用性。使用Netty能够写出性能非常高同时复用性很高的代码,这一点随后可以用代码来验证一下。

(3) Netty使应用的逻辑代码和网络层代码解耦,开发者只关注业务逻辑,不用太多关注底层网络实现的问题。

2、Java网络API和Netty的对比

我们先想一想支持一个大量并发请求的高性能网络应用程序都需要哪些知识呢?网络原理,多线程,大并发这三块是必须要懂,这里面每一块单独拿出来都足够写一本书,Netty的出现其主要目的之一就是降低这些门槛,使得许许多多缺乏相关领域知识的开发者也能快速编写出性能很高的应用程序。

设想一下,现在老板有一个idea,然后找到你,让你尽快开发出一个至少支持同时300,000并发请求的系统,你该如何开始?

2.1 阻塞式IO

假设这个时候Netty还没有问世,所以你最先想到的肯定是Java自带的网络API,于是很自然的,你可能写出类似下面这样的代码:

//创建一个在指定端口上监听连接的套接字
ServerSocket serverSocket = new ServerSocket(port);

//监听客户端请求
Socket clientSocket = serverSocket.accept();

BufferedReader in = new BufferedReader(clientSocket.getInputStream());
PrintWriter out = new PrintWriter(clientSocket.getOutputStream());

String request, response;
while((request = in.readLine()) != null) {
    //处理请求
    response = processRequest(request);
    out.println(response);
}

这是最古老的网络编程模式,它明显无法支撑起300,000并发请求的要求,问题在哪呢?

所有的API是阻塞的,比如accept就会一直阻塞等待直到有客户端的请求到来为止,因此我们需要把accept放到一个无限循环中,这个是理所当然的,没有什么问题。

然后read、write也都是阻塞的,也就是说如果客户端一直不发送消息,那么服务端就得一直等,这显然是不可接受的,那自然而然就得用多线程,每个线程处理一个客户请求,于是代码就像下面这样:

//创建一个在指定端口上监听连接的套接字
ServerSocket serverSocket = new ServerSocket(port);

//监听客户端请求
while (true) {
    Socket clientSocket = serverSocket.accept();

    new Thread() {
        @Override
        public void run() {
            BufferedReader in = new BufferedReader(clientSocket.getInputStream());
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream());

            String request, response;
            while((request = in.readLine()) != null) {
                //处理请求
                response = processRequest(request);
                out.println(response);
            }
        }
    }.start();
}

这样明目张胆的使用线程显然也有问题:一台服务器的资源毕竟是有限的,同时30W个请求,难道要30W个线程,那瞬间估计你的系统就瘫痪了,于是你又自然而然的想到了线程池来复用线程。

使用线程池貌似是个好办法,线程可以复用,但是压测以后你会发现依然无法支持要求的并发量。使用线程池虽然解决了线程创建和销毁的问题,但是无法解决频繁的线程上下文切换所带来的开销,另外多线程之间的同步开销也很大。

2.2 多路复用IO

直接用JDK提供的阻塞式IO的网络API编写高性能的应用程序显然比较困难,好在在学习网络编程的时候我们学习过IO多路复用这个概念。

阻塞式IO使得我们不得不创建单独的线程来处理请求,由于线程的创建、销毁和上下文切换都会有开销,而且当系统的负载非常大的时候这些开销会使得系统难以继续运行下去,于是又有了IO多路复用。我们来回顾一下使用IO多路复用进行网络编程的范式。

首先简单回忆一下select这个函数,提到IO多路复用就绕不开它(现在的Linux系统,基本上都用性能更高的epoll取代select,但是二者的基本原理是相同的)。

int select(int n,fd_set * readfds,fd_set * writefds,fd_set * exceptfds,struct timeval * timeout);

特别注意到select的三个集合参数:

readfds表示要监听的读套接字,当这些套接字中有数据可读的时候,select就会返回;

writefds表示要监听的写套接字,当集合中的套接字可以写入时,select返回;

最后的参数timeout指定了select的超时时间,在指定的时间内如果集合中的套接字状态还没有发生变化,则返回,代码中可以在for循环中来轮询。

可以把select理解为一个观察者模式,我们通过select向系统注册一些要监听的套接字,当这些套接字状态发生变化时(可读、可写或者异常)我们收到内核给我们的通知,进行处理。

以下是一个典型的多路复用的方式进行处理的网络程序,从套接字读取内容然后写入到文件:

main() 
{ 
    int sock; 
    struct fd_set fds; 
    struct timeval timeout = {3,0}; //select 3秒轮询一次

    FILE *fp = fopen(...); //打开要写入内容的文件

    char buffer[256]={0};
    
    sock = socket(...); 
    bind(...); 
    
    while(1) 
    { 
        FD_ZERO(&fds); //每次循环都要清空集合,否则不能检测描述符变化
        FD_SET(sock, &fds); //监听套接字,当可读时select返回,
        FD_SET(fp, &fds);   //监听文件描述符,当文件可写时select返回  
        maxfdp = sock > fp ? sock+1 : fp+1;    
        switch(select(maxfdp, &fds, &fds, NULL, &timeout))   //select
        { 
            case -1: 
                exit(-1);
                break; //select错误,退出程序
            case 0:
                break; //超时
            default: 
                if(FD_ISSET(sock, &fds)) //套接字有数据了
                { 
                    recvfrom(sock, buffer, 256, ...);//读数据
                    if(FD_ISSET(fp, &fds)) //文件可写
                       fwrite(fp, buffer...);//写入文件   
                }
          }
     }
}

JAVA的NIO背后的核心实际上就是对select的封装,用Selector封装了select。我们看看用Java NIO的方式编写的网络程序。

public class NioServer {

    public void serve(int port) throws IOException {
        ServerSocketChannel serverChannel = ServerSocketChannel.open(); 
        serverChannel.configureBlocking(false);
        ServerSocket ssocket = serverChannel.socket(); 
        InetSocketAddress address = new InetSocketAddress(port); 
        ssocket.bind(address);
        
        Selector selector = Selector.open(); 
        //监听套接字accept事件(可以想象底层是把套接字添加到select的读集合中)
        serverChannel.register(selector, SelectionKey.OP_ACCEPT); 
        final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes()); 
        for (;;) {
           try { 
               //等待accept事件(对应底层的select函数返回)
               selector.select();
           } catch (IOException ex) { 
               ex.printStackTrace(); // handle exception 
               break;
           }
           //检查可读的套接字(可以理解为底层用FD_ISSET进行检查)
           Set<SelectionKey> readyKeys = selector.selectedKeys(); 
           Iterator<SelectionKey> iterator = readyKeys.iterator(); 
           while (iterator.hasNext()) {
               SelectionKey key = iterator.next(); 
               iterator.remove();
               try {
                   if (key.isAcceptable()) { //有完成3次握手的连接了
                       ServerSocketChannel server = (ServerSocketChannel)key.channel();
                       client.configureBlocking(false);
                       //将客户套接字加入select的读、写集合,监听其读写事件
                       client.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, msg.duplicate());
                       if (key.isWritable()) { 
                           //可以向套接字写数据了
                           SocketChannel client = (SocketChannel)key.channel(); 
                           ByteBuffer buffer =(ByteBuffer)key.attachment(); 
                           while (buffer.hasRemaining()) {
                               if (client.write(buffer) == 0) { 
                                  break;
                               }
                           }
                           client.close();
                       }
               } catch (IOException e) {
                    //...
               }

对比之前的阻塞式IO,最大的区别就是我们不在需要对accept到的每个连接单独创建一个线程,通过IO多路复用,可以用少量的线程处理大量的连接,大大降低了多线程带来的开销

许多商业应用都直接使用JDK的NIO开发应用,但是能够正确并高效的用NIO开发网络应用是件极其考验编程能力的事,尤其是在系统负载变得很重的情况下还能可靠、高效的分发并处理IO事件对开发者得水平依赖很大。

2.3 用Netty编写的代码

我们再来看看同样功能的代码,用Netty写出来是什么样子的,直接上代码:

public class NettyNioServer {

   public void server(int port) throws Exception {
       final ByteBuf buf = Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8"));
       //事件循环,每个EventLoop都关联一个线程处理IO事件
       EventLoopGroup group = new NioEventLoopGroup();
       try {
           ServerBootstrap b = new ServerBootstrap(); 
           b.group(group).channel(NioServerSocketChannel.class)
            .localAddress(new InetSocketAddress(port))
            .childHandler(new ChannelInitializer<SocketChannel>() {

                @Override
                public void initChannel(SocketChannel ch)throws Exception {
                    //向channel的处理管线中增加一个处理器
                    ch.pipeline().addLast( new ChannelInboundHandlerAdapter() {
                        
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) 
                            throws Exception {
                            //当客户端连接建立好以后发送消息给客户端
                            ctx.writeAndFlush(buf.duplicate()).addListener( 
                                ChannelFutureListener.CLOSE);
                        }
                    });
                }
            }

            ChannelFuture f = b.bind().sync();
            f.channel().closeFuture().sync();

       } finally {
            group.shutdownGracefully().sync();
       }
   }

对比一下直接用JDK API编写的版本,最大的区别是没有Selector了,我们不用自己用select注册事件并分发,取而代之的是把自己的业务逻辑写到处理器中并注册到channel(你可以理解为是对socket的抽象)的处理管线中即可。

这就是Netty的设计哲学,将细节对开发者透明,开发者只需要关注自己的业务逻辑实现并加入到pipeline中就可以了,当事件发生时Netty会通过回调执行我们的逻辑,也就是响应式编程。

2.4 Netty的复用性如何体现

前面提到Netty的一个特点之一就是能提高代码的复用性,这一点我们可以通过一个场景来理解。假设最开始你的应用是基于JDK的同步IO来编写的,之后随着业务的增长,你发现同步的方式已经支撑不住了,于是想切到NIO的模式,这时你会发现你有大量的工作要做,特别是如果最初的代码逻辑没有写好,分散在各处,那这种重构的代价会非常巨大。

而如果用Netty,只需要把上面代码中的:

EventLoopGroup group = new NioEventLoopGroup();

变成OioEventLoopGroup就能实现从NIO到同步方式的转变。Netty框架约束我们把各种事件的处理逻辑封装在处理器中,而这些包含了业务逻辑的处理器是可以随时拿来复用的,Netty本身又把细节问题封装起来,最终的效果就是一两行代码就搞定以前需要耗费大量时间才能搞定的事情。

3 总结

这篇文章简单的介绍了一下Netty的特点,通过代码对比了使用JDK API和使用Netty编写的网络应用的异同,初步体验了一把Netty的优势。

记住Netty的特点:

1、封装了编写高性能高并发网络应用的复杂细节,降低了开发一个能支撑高并发网络应用的门槛;

2、使用Netty能使我们的应用更加具有可复用性;

3、解耦网络层和应用层,开发者基本上只需要编写业务逻辑处理器,其他的可以不用关心。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值