Netty(内含Netty的简单实现)(上)

71 阅读48分钟

Netty

Netyy是基于事件驱动的异步的网络应用框架,其本质是一个NIO框架

image-20230112104042260

异步的同步的区别在于,同步的情况下服务器和浏览器必须一对一进行交互,必须上一步完成之后才能执行下一步,也就是按照下图123的顺序执行

但是异步则不同,异步情况下服务器的浏览器的步骤不必按顺序执行,原因就在于浏览器中存在Ajax的回调函数,当其向服务器发送请求后,会直接调用回调函数,服务器的结果后面来也行,如果服务器的结果迟迟没来,那么也可以再调用回调函数继续向服务器发送请求或者确认情况

image-20230114023529782

现在Netty的应用已经非常广泛了,是作为Java程序员必学的一个课程,下面是其各种应用场景

image-20230112104218335

image-20230112104434066

image-20230112104445554

更多应用的软件的信息可以到netty.io/wiki/relate…中查看

Netty基于NIO,而NIO基于JDK中原生的IO和网络,最底层则基于TCP/IP,形成这样的四层结构

BIO

BIO模式是Java传统的IO编程,相关的类和接口在Java.io,服务端每处理一个客户端的请求就要创造一个线程

image-20230114023440145

NIO模式则是会在对应的线程前创建一个选择器,轮询对应的请求,请求有需求时就对应的线程执行

image-20230114024041277

两者都是同步的,但是前者是会发生阻塞的,后者则不会发生阻塞,同时前者创建大量线程和线程切换时会占用大量资源,效率上显然没后者好

image-20230114024447014

BIO适用于连接数较少且固定的架构,其程序简单易于理解,NIO适合连接数目较多且连接比较短的架构,比如聊天服务器或者弹幕系统等

image-20230114024502347

BIO的不必要的线程开销可以通过线程池机制改善,但即使如此仍然存在不必要的开销

image-20230114025049426

下面是其工作原理

image-20230114032739053

其实这里还有一个BIO案例,但是我懒得写,所以就pass了,下面是BIO存在的问题

image-20230114033019392

BIO没有客户端连接时会阻塞,处理完客户端请求而没有新的请求继续进入时也会阻塞,这些会导致其在没有请求执行的时候无法去执行其他的任务,资源利用率就下降了,这是其一个不可忽视的缺点

NIO

NIO是从JDK4开始提供的同步非阻塞的改进流,其提供三大核心部分,分别是Channel(通道)、Buffer(缓冲区)、Selector(选择器),NIO是面向缓冲区或者是面向块编程的,可以提供非阻塞式的高伸缩性网络

image-20230114061252579

其结构简图如下所示,线程下连接通道、通道下有缓冲区,缓冲区对接客户端

image-20230114034516128

NIO提供的非阻塞模式可以有效提供资源的利用率同时HTTP2.0使用了多路复用技术,有效提高并发量

image-20230114061642843

BIO以流的方式处理数据,而NIO以块的方式处理数据,因此后者的效率比前者高得多

image-20230114061846167

下面是NIO的核心原理及其关系图的说明,可以知道Buffer是双向的

image-20230114062146778

缓冲区(Buffer)

Buffer本质是一个可以读写数据的内存块,可以理解为是同一个含有数组的容器对象,其下提供方法

image-20230114062357930

Buffer在NIO中是一个抽象的顶层父类,其下提供各种数据传输的子实现类。每个子实现类都有一个名为hp的对应数组用于存放对应的数据

image-20230114062606976

Buffer类中存在四个属性,这四个属性都有其对应的作用,Capacity指容量,Limit指缓冲区最大能到的位置,Position值当前指针位置,Mark是一个标记

image-20230114062930654

下面是Buffer对象提供的方法,标红的是常用的方法

image-20230114063045022

ByteBuffer是最常用的Buffer子类,其下对应的方法

image-20230114063122856

通道(Channel)

通道类似于流,但是通道同时进行读写且支持异步,可以写数据到缓冲,也可以从缓冲读数据

image-20230114063452043

当客户端的请求访问服务器时,服务器中的ServerSocketChannel接口下的实现类ServerSocketChannelImpl实现类会提供其对应的实现类SocketChannel,注意这个实现类实现接口SocketChannel

通道是有许多种的,具体需要哪种就是按照这种方式获得的

image-20230114064010543

Channel在NIO的是一个接口

image-20230114063554073

FileChannle类中提供了一些方法用于对本地文件进行IO操作

image-20230114102143510

将字符串写入到文件中,首先需要写入到Buffer缓冲区中,然后传入到通道中,通道是存在于Java的输出流对象之中的,作为其中的一个属性而存在

image-20230114102237866

下面是案例的实现代码,这里要注意我们首先要创建输出流才能获取对应的通道对象,同时将字符串放入时需要将其转化为byte数组,在读写切换时需要调用通道的flip方法

/**
 * @author 22592
 * 用于学习Netty的类,与项目无关
 */
public class Netty {
    public static void main(String[] args) throws Exception {
        String str = "永失吾爱,举目破败";
        //创建一个输出流 -> channel
        FileOutputStream fileOutputStream = new FileOutputStream("d:\file01.txt");
​
        //通过 fileOutputStream 获取对应的FileChannel
        //channel的真实类型是FileChannelImpl
        FileChannel channel = fileOutputStream.getChannel();
​
        //创建一个缓冲区ByteBuffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
​
        //将 str 放入到 byteBuffer中
        byteBuffer.put(str.getBytes());
        
        //对byteBuffer进行flip操作
        byteBuffer.flip();
        
        //将byteBuffer数据写入到fileChannel中
        channel.write(byteBuffer);
        fileOutputStream.close();
    }
}

下面则是将文件中的字符串打印到控制台的上的代码

/**
 * @author 22592
 * 用于学习Netty的类,与项目无关
 */
public class Netty {
    public static void main(String[] args) throws Exception {
        //创建文件输入流
        File file = new File("d:\file01.txt");
        FileInputStream fileInputStream = new FileInputStream(file);
​
        //通过fileInputStream获取对应的FileChannel -> 实际类型为FileChannelImpl
        FileChannel channel = fileInputStream.getChannel();
​
        //创建缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
​
        //将通道中的数据读入到Buffer缓冲区中
        channel.read(byteBuffer);
​
        //将byteBuffer的字节数据转为String
        String s = new String(byteBuffer.array());
        System.out.println(s);
    }
}

下面是将一个文件通过NIO复制到另一个文件上的代码,这里我们采用循环读取的方式,注意循环读取时一定要及时清空buffer,否则会出现read结果一直为0的情况

public class Netty {
    public static void main(String[] args) throws Exception {
        FileInputStream fileInputStream = new FileInputStream("1.txt");
        FileChannel fileChannel01 = fileInputStream.getChannel();
​
        FileOutputStream fileOutputStream = new FileOutputStream("2.txt");
        FileChannel fileChannel02 = fileOutputStream.getChannel();
​
        ByteBuffer byteBuffer = ByteBuffer.allocate(512);
​
        //循环读取
        while (true){
            byteBuffer.clear(); //清空Buffer
            int read = fileChannel01.read(byteBuffer);
            if(read==-1){
                break;
            }
            //将buffer中的数据写入到fileChannel02 -- 2.txt中
            byteBuffer.flip();
            fileChannel02.write(byteBuffer);
        }
    }
}

当然,对于使用NIO进行复制的操作而言,我们可以直接使用其提供的transferForm方法来完成复制,

image-20230114105103663

注意事项

通道和缓冲区还有一些需要注意的事项,首先ByteBuffer支持类型化的put和get,put放入什么,get就要使用什么来取出,否则可能会出现数据格式不兼容甚至是异常

image-20230122043318221

Buffer可以转换成只读Buffer,此时只能读不能取,否则会报异常

image-20230122043344654

NIO提供MappedByteBuffer,可以让文件直接在内存中进行修改,也就是说,可以直接在内存中修改文件,省去操作系统的拷贝过程

image-20230122043551427

要使用MapperByteBuffer首先需要获得对应的文件,指定rw是指定权限为读写,然后获取通道,通过通道获取目标对象,map方法中的参数第一个指的是使用的模式,第二个是修改的起始位置,第三个则是可以修改的数据,代表的是字节数,一般为其指定值-1为可以修改的数量

NIO还支持通过多个Buffer,也就是Buffer数组来完成读写操作,

写入时的操作为Scattering,称为分散,反之读出时为Gathering,称为聚合

之前我们的读写操作都是在本地的文件中通过获取流进行的,而现在我们要通过网络流来进行,下面是通过ServerSocketChannel来构建网络流的代码,构建好网络号我们创建对应的数组并指定其大小

image-20230122044204754

然后我们通过对应的对象获取Channel,首先进行的读取的操作,打印对应的读取字节数

image-20230122044344031

然后我们将所有Buffer反转再读取,读取和写入时都可以直接将数组传入,其会自动分配对应的流进行读取,我们第一个为5,第二个为3,其会先令第一个读取,第一个不够用了就用第二个

image-20230122045454337

然后是本章的总结

image-20230122043050420

最后我们提一嘴,在服务器端也是存在Buffer的,尤其是在使用网络流进行传输的时候

选择器(Selector)

选择器Selector用于通道的注册,可以检测多个通道上是否有事件发生,若有则进行处理,反之则可以处理其他指定的事务

image-20230123143955672

Selector的特点介绍,它的存在可以解决传统IO的许多痛点

image-20230123144345008

Selector类是一个抽象类,其具有以下的常用方法,其中open()方法是获得一个选择器对象,可以简单理解为得到一个选择器实例,select()方法不放入参数时是阻塞方法,只有监听到通道中的有事件发生时才会返回,selectedKeys()方法则是直接返回所有注册的selectionKey

image-20230123145826917

SelectionKey对象则是用于绑定Selection和Channel的对象,使用Selector中的wakeup()可以立刻停止阻塞

image-20230123145918249

下面是NIO 非阻塞 网络编程相关的(Selector、SelectionKey、ServerScoketChannel和SocketChannel) 关系梳理图

image-20230123152317970

首先,当客户端连接时,会通过Selector中的ServerSocketChannel得到SocketChannel在客户端和服务器中(此时Selector已经开始监听了 ),用于客户端服务器直接的数据传输。然后Selector会通过select方法进行监听,返回有事件发生的通道的个数同时将对应的通道注册到Selector上并返回一个SelectionKey存放到Selector中的集合中,然后我们可以通过SelectionKey获取到SocketChannel来完成我们所需要的业务处理

下面我们来看一个例子,我们用NIO来实现这个案例

image-20230123184846715

首先我们要写服务端的代码,服务端代码里我们创建Selector和ServerSocketChannel对象,然后将后者注册到前者中,接着从选择器中获取事件通道,若为连接事件则进行客户端连接的处理,进行连接时我们会调用对应方法令客户端生成一个通道并经对应的客户端注册,同时将通道注册到Selector中且给其添加一个Buffer缓冲区,若为读事件则反向获取其channel和buffer,然后调用通道的读方法来来读入缓冲区中的数据

public class Main {
    public static void main(String[] args) throws Exception {
        //创建ServerSocketChannel -> ServerSocket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
​
        //得到一个Selector对象
        Selector selector = Selector.open();
​
        //绑定一个端口6666,在服务器端监听
        serverSocketChannel.socket().bind(new InetSocketAddress(6666));
        //设置为非阻塞
        serverSocketChannel.configureBlocking(false);
​
        //把serverSocketChannel注册到selector中,关心的事件为OP_ACCEPT
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
​
        //循环等待客户端连接
        while (true){
​
            //等待1s,如果没有事情发生,直接返回
            if(selector.select(1000)==0) {
                //无事发生
                System.out.println("Nothing happening");
                continue;
            }
​
            //如果返回结果>0,表示已经获取到相关的selection集合且已经获取到关注的事件
            //通过selector.selectedKeys()返回关注事件的集合,再通过集合数据反向获取通道
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
​
            //使用迭代器遍历集合
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
            while (keyIterator.hasNext()){
                //获取SelectionKey
                SelectionKey key = keyIterator.next();
                //根据key对应的通道发生的事件做对应处理
                //若为OP_ACCEPT事件,则说明有新的客户端连接
                if(key.isAcceptable()){
                    //使该客户端生成一个SocketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    System.out.println("客户端连接成功,生成了一个socketChannel"+socketChannel.hashCode());
                    //将SocketChannel设置为非阻塞
                    socketChannel.configureBlocking(false);
                    //将socketChannel注册到selector中,关注事件为OP_READ,同时给其关联一个Buffer
                    socketChannel.register(selector,SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                }
                //为OP_RAED事件
                if(key.isReadable()){
                    //通过key反向获取到channel
                    SocketChannel channel = (SocketChannel) key.channel();
                    //获取到该channel关联的buffer
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    channel.read(buffer);
                    System.out.println("from 客户端 "+new String(buffer.array()));
                }
​
                //手动从集合中移除当前的selectionKey,防止重复操作
                keyIterator.remove();
            }
        }
    }
}

这里我们提一嘴,尽管serverSocketChannel.accept()方法本身是堵塞的,但是由于当执行到该方法时,服务端已经知道其是连接动作,会迅速完成连接,因此堵塞的过程可以几乎忽略

客户端的代码则相对简单,我们创建对应地通道设置为非阻塞,然后提供服务器的ip地址和端口,进行连接,如果没连接成功,此时可以做其他任务,若连接成功,我们则输入对应的数据

public class Main {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        //设置非阻塞
        socketChannel.configureBlocking(false);
        //提供服务器端的ip和端口
        InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1",6666);
        //连接服务器
        if(!socketChannel.connect(inetSocketAddress)) {
            while (!socketChannel.finishConnect()){
                System.out.println("连接需要时间,此时未连接成功");
            }
        }
        
        //若连接成功,则发送数据,wrap()方法可以快捷发送数据,自动指定发送大小并转换为一个ByteBuffer
        socketChannel.write(ByteBuffer.wrap("永失吾爱,举目破败".getBytes()));
        System.in.read();
    }
}

然后我们再讲解下SelectionKey,其表示Selector和网络通道的注册关系,总共有四种,用不同值对应

image-20230124135409690

其下有对应的方法可以使用,还有可以更改Selector监听的事件的方法

image-20230124135421811

ServerSocketChannel用于在服务器端监听新的客户端(Socket)连接,同样也有对应的方法

image-20230124135432790

SocketChannel则是网络的IO通道,具体负责读写操作

image-20230124135502752

两者相比较,前者更加注重监听连接,而后者则更加注重读写操作,一般来说前者多用于服务端,后者多用于客户端

群聊系统

接着我们来写一个群聊系统来增加我们对NIO的理解,步骤如下

image-20230126141515486

首先我们来编写服务端的代码,我们首先在构造器中得到类的选择器并绑定端口,将用于监听的监听队列绑定到Selector中去,然后我们设置监听方法,监听方法循环获取selector中的事件进行处理,使用遍历方法并对不同的事件进行对应处理,新的客户端注册为上线

读方法传入SelctionKey,取得对应的Channel进行读入数据的处理,此处如果发生异常则说明客户端下线了,此时在catch处理中执行取消注册和关闭通道的处理,转发消息给其他客户端的方法中无非是遍历并排除自己的转发而已

服务端启动主方法创建服务端代码实例并启动监听方法

public class Main {
    //定义属性
    private Selector selector;
    private ServerSocketChannel listenChannel;
    private static final int PORT = 6667;
​
    //构造器
    //初始化工作
    public Main() {
        try {
            //得到选择器
            selector = Selector.open();
            //ServerSocketChannel
            listenChannel = ServerSocketChannel.open();
            //绑定端口
            listenChannel.socket().bind(new InetSocketAddress(PORT));
            //设置为非阻塞模式
            listenChannel.configureBlocking(false);
            //将该listenChannel注册到selector中
            listenChannel.register(selector,SelectionKey.OP_ACCEPT);
        }catch (IOException e){
            e.printStackTrace();
        }
    }
​
    //监听
    public void listen() {
        try {
            //循环处理
            while (true) {
                int count = selector.select(2000);
                if(count>0){
                    //遍历得到selectionKey集合
                    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                    while (iterator.hasNext()){
                        //取出selectionKey
                        SelectionKey key = iterator.next();
​
                        //监听到accept
                        if(key.isAcceptable()) {
                            SocketChannel sc = listenChannel.accept();
                            sc.configureBlocking(false);
                            //将该SocketChannel注册到Selector中
                            sc.register(selector,SelectionKey.OP_READ);
                            //上线提示
                            System.out.println(sc.getRemoteAddress()+" 上线...");
                        }
                        //通道发送read时间,表明通道为可读状态
                        if(key.isReadable()){
                            //处理读
                            readData(key);
                        }
​
                        //将当前key删除,防止重复处理
                        iterator.remove();
                    }
                }else {
                    System.out.println("No connect,waiting now...");
                }
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            //发生异常处理...
        }
    }
​
    //读取客户端消息
    private void readData(SelectionKey key) {
        //取得关联的channel
        SocketChannel channel = null;
​
        try {
            //得到channel
            channel = (SocketChannel) key.channel();
            //创建buffer
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int count = channel.read(buffer);
            //根据count值做处理
            if(count>0){
                //把缓冲区的数据转换为字符串
                String msg = new String(buffer.array());
                //输出消息
                System.out.println("from 客户端: "+msg);
                //向其他客户端转发消息,专门写一个方法来处理
                sendInfoToOtherClients(msg,channel);
            }
        }catch (IOException e){
            try {
                System.out.println(channel.getRemoteAddress()+"离线了...");
                //取消注册
                key.cancel();
                //关闭通道
                channel.close();
            }catch (IOException e2){
                e2.printStackTrace();
            }
        }
    }
​
    //转发消息给其他客户(通道)
    private void sendInfoToOtherClients(String msg,SocketChannel self) throws IOException{
        System.out.println("服务器转发消息中...");
        //遍历所有注册到selector上的SocketChannel并排除自己
        for (SelectionKey key : selector.keys()) {
            //取出key取出对应的SocketChannel
            Channel targetChannel = key.channel();
            //排除自己
            if(targetChannel instanceof SocketChannel && targetChannel!=self){
                //转型
                SocketChannel dest = (SocketChannel) targetChannel;
                //将msg存储到buffer
                ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
                //将buffer的数据写入通道
                dest.write(buffer);
            }
        }
    }
    public static void main(String[] args) {
        //创建服务器对象
        Main groupChatServer = new Main();
        groupChatServer.listen();
    }
}

客户端中的代码我们在初始化中完成客户端向服务器的注册,同样也要创建SocketChannel并注册,同样的,注册完即是进行了上线,设置两个方法,一个方法是向服务器发送消息,第

二个是从服务器读取方法,同样是获取事件然后查询事件通道,若有则得到对应通道获得其字符串并打印,执行完毕之后要删除当前的SelectionKey来防止重复操作

客户端主方法创建客户端实例并启动一个线程令其每隔3s调用一次读取服务器中信息的方法,下面则写入发送数据到服务器中的方法,让客户端可以自己发送数据

public class Main {
    //服务器的ip
    private final String HOST = "127.0.0.1";
    //服务器端口
    private final int PORT = 6667;
    private Selector selector;
    private SocketChannel socketChannel;
    private String username;
​
    //构造器,完成初始化工作
    public Main() throws IOException{
        selector = Selector.open();
        //连接服务器
        socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1",PORT));
        //设置非阻塞
        socketChannel.configureBlocking(false);
        //将channel注册到selector
        socketChannel.register(selector,SelectionKey.OP_READ);
        //得到username
        username = socketChannel.getLocalAddress().toString().substring(1);
        System.out.println(username + "is ok...");
    }
​
    //向服务器发送消息
    public void sendInfo(String info) {
        info = username + " 说: " + info;
        try {
            socketChannel.write(ByteBuffer.wrap(info.getBytes()));
        }catch (IOException e){
            e.printStackTrace();
        }
    }
​
    //读取从服务端回复的消息
    public void readInfo() {
        try {
            int readChannels = selector.select(2000);
            if(readChannels>0){
                //有可以用的通道
                Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                while (iterator.hasNext()){
                    SelectionKey key = iterator.next();
                    if(key.isReadable()){
                        //得到相关的通道
                        SocketChannel sc = (SocketChannel) key.channel();
                        //得到一个Buffer
                        ByteBuffer buffer = ByteBuffer.allocate(1024);
                        //读取
                        sc.read(buffer);
                        //把读到的缓冲区的数据转换为字符串
                        String msg = new String(buffer.array());
                        System.out.println(msg.trim());
                    }
                }
                iterator.remove(); //删除当前的selectionKey,防止重复操作
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
​
    public static void main(String[] args) throws IOException {
        //启动客户端
        Main chatClient = new Main();
​
        //启动一个线程
        new Thread(() -> {
            while (true){
                chatClient.readInfo();
                try {
                    Thread.sleep(3000);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
            }
        }).start();
​
        //发送数据给服务器端
        Scanner scanner = new Scanner(System.in);
​
        while (scanner.hasNextLine()) {
            String s = scanner.nextLine();
            chatClient.sendInfo(s);
        }
    }
}

然后我们启动程序就能看到如下的预览效果

image-20230126154208835

零拷贝

零拷贝是网络编程的关键,很多的性能优化都离不开零拷贝,在Java中常用的零拷贝有mmap和sendFile

image-20230126161625769

先来看一段Java传统IO的网络编程的代码

image-20230126161858522

传统拷贝需要经过四次拷贝,三次切换,对效率的影响比较大

image-20230126161922068

mmap是通过内存映射将文件映射到内存缓冲区,往user buffer中修改会直接影响到kernel buffer,数据会从kernel buffer直接copy到socket buffer,减少了拷贝的次数

image-20230126162205952

sendFile零拷贝方法是将数据直接从kernel buffer到Socket Buffer,这样又减少了一次上下文切换

image-20230126162531888

后面的优化里甚至可以直接将数据拷贝到protocol engine中,不过仍然有一部分数据会拷贝到socket buffer中,不过数据量很少,可以忽略不计

image-20230126162727602

我们说的零拷贝是从操作系统的角度来说的,零拷贝指的是没有CPU拷贝,而DMA拷贝是不可避免的

image-20230126163220057

最后我们来看看mmap和sendfile的优势区别

image-20230126163303239

接下来我们来看看零拷贝的案例,首先我们来看看服务器端的代码,首先创建端口并连接,然后创建buffer,接着循环接受客户端的链接,连接成功则进行循环读入通道中的数据,读入时每次需要倒带,将position置为0,这样才可以循环使用重复的字节空间进行读取

public class Main {
    public static void main(String[] args) throws Exception{
        InetSocketAddress address = new InetSocketAddress(7001);
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(address);
​
        //创建buffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
​
        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept();
            int readCount = 0;
            while (-1 != readCount) {
                readCount = socketChannel.read(byteBuffer);
                //倒带,令buffer的position=0,令mark标记作废
                byteBuffer.rewind();
            }
        }
    }
}

客户端的代码则是首先连接服务器,然后得到文件流,接着使用transferTo()方法发送,注意这里由于我们的文件小于8M所以我们可以如此设置,但如果我们的文件大于8M,我们则需要循环传入分段传输,分段传输的方法是将文件大小除于8M,第二个参数除于结果,但是第一个参数则需要每次自己指定对应的起始位置,计算起始位置时全部舍出

public class Main {
    public static void main(String[] args) throws Exception{
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost",7001));
        String filename = "永失吾爱,举目破败.txt";
​
        //得到一个文件channel
        FileChannel channel = new FileInputStream(filename).getChannel();
​
        long start = System.currentTimeMillis();
​
        //在Linux下一次transferTo方法即可完成传输,但在windows下一次该方法只能发送8m
        //因此需要使用分段传输,transferTo底层使用零拷贝
        long Count = channel.transferTo(0, channel.size(), socketChannel);
        System.out.println("发送的总字节数 ="+Count+" 耗时:"+(System.currentTimeMillis()-start));
​
        channel.close();
    }
}

AIO

JDK7的时候引入了AIO,在进行IO编程时常用到两种模式,一种是Reactor,另一种是Proactor,NIO就是前者

AIO引入了异步通道的概念,采用Proactor模式,只有有效的请求才启动线程,一般适用于连接数较多且连接时长较长的应用

image-20230128205400319

最后我们在看看这三种IO模式的总结

image-20230128205548218

Netty概述

由于原生的NIO具有API复杂,使用麻烦,上手门槛高,还有Epoll Bug等问题,因此出现了Netty

image-20230128210721291

Netty是一个异步事件驱动的网络应用程序框架,为快速开发可维护的高性能协议服务器和客户端而产生的框架,其在互联网的各个领域都得到了广泛的应用

image-20230128210848223

Netty具有以下优点

image-20230128210921772

目前我们推荐使用Netty4.x版本

image-20230128211427839

Reactor模式

在讲述Netty线程模型的优越性之前,我们需要对线程模型进行基本的了解

image-20230128213835851

Netty线程模型主要基于Reactor多线程做了一定的改进,下面是传统阻塞IO的服务模型

image-20230128214019007

工作原理图

黄色的框表示对象, 蓝色的框表示线程,白色的框表示方法(API)

模型特点

采用阻塞IO模式获取输入的数据且每个连接都需要独立的线程完成数据的输入,业务处理,

数据返回

问题分析

当并发数很大,就会创建大量的线程,占用很大系统资源,而且在连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read 操作,造成线程资源浪费

Reactor模型提供了下面的解决方案来解决传统IO服务模型的问题

  1. 基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理
  2. 基于线程池复用线程资源:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。

Reactor 对应的叫法: 1. 反应器模式 2. 分发者模式(Dispatcher) 3. 通知者模式(notifier)

其图示如下和程序功能说明如下

image-20230128220523739

  1. Reactor 模式,通过一个或多个输入同时传递给服务处理器的模式(基于事件驱动)
  2. 服务器端程序处理传入的多个请求, 并将它们同步分派到相应的处理线程, 因此Reactor模式也叫Dispatcher模式
  3. Reactor 模式使用IO复用监听事件, 收到事件后,分发给某个线程(进程), 这点就是网络服务器高并发处理关键

Reactor模式中的Reactor在一个单独的线程中运行,负责监听和分发客户端的请求事件,而Handlers则是具体处理事件的类

image-20230128220725268

Reactor模式分为下面三种模式

image-20230128221442336

单Reactor单线程

单Reactor单线程模式的案例,其实就是我们之前做过的群聊系统,下面是其演示图

image-20230128222942874

我们之前的案例也是这种模式,只不过是我们没有将read、sead等方法封装到Handler对象而已,每次有请求过来时就会转发到对应的Acceptor或Handler进行处理,只有一个请求完成之后才能到下一个请求处理

image-20230128223136856

虽然其的确优化了传统IO的阻塞并且创建线程过多的缺点,但其本质还只是一个单线程的模式,无法发挥多核CPU的性能,在处理业务时也存在性能瓶颈,可靠性也比较差

其适用于客户端数量有限、业务处理非常迅速的场景,比如Redis

image-20230128223259711

单Reactor多线程

单Reactor多线程的图示如下

image-20230129085728010

其使用单线程Reactor来监听所有请求(其中Select负责监听,dispatch负责分发),如果是响应请求,那么会分发给Handler,handler会响应并在read读取数据后继续分发给具体的Worker线程池中的线程来进行业务处理,处理完毕之后的结果返回给handler,handler会通过send将结果返回给Client

image-20230129085819372

尽管其可以充分利用多核CPU的处理能力,但是其Reactor仍然是单线程的,所以也存在性能瓶颈的问题

image-20230129090219088

主从Reactor多线程

主从Reactor多线程的图示如下

image-20230129091010272

其通过MainRactor对象进行监听,并如果是响应事件就会将连接分发给Reactor子线程,Reactor可以有多个,其下的SubReactor会将连接加入到队列用于监听,并会创建对应的handler来处理事件,后面的过程就和单Reactor多线程模式一样了

image-20230129092307516

其主要缺点是编程复杂度高,不过该模型已经拥有了广泛的使用,包括但不限于Nginx、Netty等

image-20230129092525762

最后我们来看看这三种Reactor模式的理解

image-20230129092740602

Netty模型

Netty模型主要基于主从Reactors多线程模型做了一定的改进,其简单的示意图如下

image-20230129094035071

第一个BG对象监听客户端,只处理连接事件,接收到连接事件时会创建SocketChannel并封装成NIOSocketChannel再注册到WG中,WG线程如果监听到selector中发生自己感兴趣的事件后,就会将其交由handler进行处理,这里需要注意两点,一是BG和WG都使用了事件循环,另一点是handler已经加入了通道

image-20230129094045838

而在较为完整的示意图中,可以看到无论是BG还是WG,都是可以存在多个的

image-20230129094743172

下面是完整的Netty模型图

image-20230129095403343

下面是对上面图示的说明讲解

  1. Netty抽象出两组线程池BossGroup和WorkerGroup,前者专门负责接收客户端的连接, 后者专门负责网络的读写
  2. BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup
  3. NioEventLoopGroup 相当于一个事件循环组, 这个组中含有多个事件循环 ,每一个事件循环是 NioEventLoop
  4. NioEventLoop 表示一个不断循环的执行处理任务的线程, 每个NioEventLoop 都有一个selector , 用于监听绑定在其上的socket的网络通讯,简单来说,就是一个NioEventLoopGroup也就是Boss Group等对象可以拥有多个NioEventGroup
  5. NioEventLoopGroup 可以有多个线程, 即可以含有多个NioEventLoop
  6. 每个Boss NioEventLoop 循环执行的步骤有3步,首先是轮询accept事件,然后处理accept 事件 , 与client建立连接 , 生成NioScocketChannel , 并将其注册到某个worker NIOEventLoop上的selector,最后将这些事情执行完后就处理任务队列的任务 , 即 runAllTasks,也就是我们事先指定的令他们没业务处理的时候要做的任务
  7. 每个 Worker NIOEventLoop 循环执行的步骤,首先是轮询read, write事件,然后是处理i/o事件, 即read , write 事件,在对应NioScocketChannel 处理,最后是处理我们事先指定的任务队列的任务 , 即 runAllTasks
  8. 最后每个Worker NIOEventLoop 处理业务时,会使用pipeline(管道), pipeline中包含了 channel , 即通过pipeline 可以获取到对应通道, 管道中维护了很多的处理器,我们可以利用这些管道来帮助我们实现业务

接着我们来写一个Netty模型的案例,首先我们来写入服务端的

image-20230129125136867

服务端的代码里,我们首先创建两个NioEventLoopGroup对象,这两个对象就是bossGroup和workerGroup,接着创建服务器的启动对象,利用链式编程首先设置两个线程组,接着设置NioSocketChannel作为bossGroup的通道实现,然后设置线程队列的连接个数再令其保持活动状态,最后创建一个测试对象,这里我们采用匿名内部类,内部重写initChannel方法,pipeline()方法获得管道,addLast方法设置管道设置处理器,我们这里自定义管道处理器NettyServerHandler,下面再绑定端口并同步,然后对关闭的通道进行监听,最后利用try...finally结构调用其关闭方法保证其关闭

public class Main {
    public static void main(String[] args) throws Exception{
        //创建BossGroup和WorkerGroup
        //此处创建两个线程组,分别是bossGroup和workerGroup,前者只处理连接请求
        //后者才进行真正的客户端业务处理,两者都是无限循环
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
​
        try {
            //创建服务器端的启动对象并配置参数
            ServerBootstrap bootstrap = new ServerBootstrap();
​
            //使用链式编程进行设置
            bootstrap.group(bossGroup,workerGroup) //设置两个线程组
                    .channel(NioServerSocketChannel.class) //使用NioSocketChannel作为服务器的通道实现
                    .option(ChannelOption.SO_BACKLOG,128) //设置线程队列的连接个数
                    .childOption(ChannelOption.SO_KEEPALIVE,true) //设置保持活动连接状态
                    .childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道测试对象(匿名对象)
                        //给pipeline设置处理器
                        @Override
                        protected void initChannel(SocketChannel ch) throws  Exception {
                            ch.pipeline().addLast(new NettyServerHandler());
                        }
                    }); //给我们的workerGroup的EventLoop对应的管道设置处理器,处理器可以是Netty提供的,也可以是自定义的
​
​
            System.out.println("服务器 is ready...");
​
            //绑定一个端口并且同步,生成了一个ChannelFuture对象,本行代码在启动服务器并绑定端口
            ChannelFuture cf = bootstrap.bind(6668).sync();
​
            //对关闭通道进行监听
            cf.channel().closeFuture().sync();
        }finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

自定义的Hanlder需要继承ChannelInboundHandlerAdapter,我们此处对其进行对应的方法重写,让我们的处理器拥有基本功能。注意发送消息时使用Unpooled中的额copiedBuffer方法并且指定字符集才可以正确发送

//自定义Hanlder,需要继承Netty规定好的HandlerAdapter
public class NettyServerHandler extends ChannelInboundHandlerAdapter{
​
​
    /**
     *
     * @param ctx 上下文对象,含有管道pipeline,通道Channel,连接地址
     * @param msg 客户端发送的数据,默认Object
     * @throws Exception
     */
    //读取数据事件(此处我们可以读取客户端的消息)
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("server ctx =" + ctx);
        //将msg转成一个ByteBuf,其是由Neety提供的,不是NIO提供的ByteBuffer,前者效率更高
        ByteBuf buf = (ByteBuf) ctx;
        System.out.println("客户端发送消息是:"+buf.toString(CharsetUtil.UTF_8));
        System.out.println("客户端地址:"+ctx.channel().remoteAddress());
    }
​
    //数据读取完毕
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        //writeAndFlush是write+flush,将数据写入缓存并刷新,一般来讲,我们对发送的数据进行编码
        ctx.writeAndFlush(Unpooled.copiedBuffer("hello,客户端",CharsetUtil.UTF_8));
    }
​
    //处理异常
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.close();
    }
}

接着我们来编写客户端的代码,客户端使用Bootsrap,同样也需要设置响应的参数和通道,之所以客户端还要设置通道,是因为在Netty中客户端也是存在通道并且在循环的,同样设置管道的自定义处理器,最后我们将客户端连接到服务器,调用对应的方法进行关闭,closeFuture()方法会在我们的任务执行完之后再进行关闭,sync可以让我们的代码不会阻塞在某一行

public class NettyClient {
    public static void main(String[] args) throws Exception{
        //客户端需要一个事件循环组
        NioEventLoopGroup group = new NioEventLoopGroup();
​
        try {
            //创建客户端使用对象,注意使用的是Bootstrap而不是ServerBootstrap
            Bootstrap bootstrap = new Bootstrap();
​
            //设置相关参数
            bootstrap.group(group) //设置线程组
                    .channel(NioSocketChannel.class) //设置客户端通道的实现类
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            socketChannel.pipeline().addLast(new NettyClientHandler()); //加入自己的处理器
                        }
                    });
            System.out.println("客户端 is ready...");
            //启动客户端去连接服务器端,channelFuture设计到netty的异步模型
            ChannelFuture channelFuture = bootstrap.connect("localhost", 6668).sync();
            //给关闭通道进行监听
            channelFuture.channel().closeFuture().sync();
        }finally {
            group.shutdownGracefully();
        }
    }
}

自定义的客户端处理器也很简单,无非就是写了读和就绪的方法而已

public class NettyClientHandler extends ChannelInboundHandlerAdapter{
    //当通道有读取事件时就触发
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        System.out.println("服务器回复的消息:"+buf.toString(CharsetUtil.UTF_8));
        System.out.println("服务器的地址: "+ctx.channel().remoteAddress());
    }
​
    //当通道就绪时就会触发该方法
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("client"+ctx);
        ctx.writeAndFlush(Unpooled.copiedBuffer("hello,server:fly!!!",CharsetUtil.UTF_8));
​
    }
​
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }
}

上面这些写完之后,我们只要正确启动我们对应的类,我们就能得到我们想要的案例结果

bossGroup和workerGroup含有的NioEventLoop的个数,如果不指定则是cpu核数*2,管道pipeline和通道Channel是你中有我,我中有你的结构,ChannelHandlerContext和pipeline对象都是双向链表的形式,对应的对象也有对应的属性

TaskQueue

任务队列的Task有下面三种使用场景,其存在于Channel通道对象中

image-20230129150034689

第一种方式我们需要调用ChannelHandlerContext.channel()方法获得Channel对象,然后调用execute()方法,其下新建一个线程重写run()方法,将业务需求写入该代码中即可

我们的TaskQueue可以写入多个任务,但由于其内部只有一个线程,因此如果存入多个任务,也只能按顺序执行

第二种方式是用户自定义定时任务,此时需要将execute()方法改为schedule()方法,其他保持不变,写入的内容中在第一个线程参数之后还需要指定时间和时间参数,指定时间参数使用类TimeUnit

此处我们虽然与TaskQueue还是使用一个线程,但是其任务本身会保存到Channel中的scheduledTaskQueue中

第三种方式是非当前Reactor中调用Channel的各种方法,最简单的场景就是服务器需要将信息发送到各个客户端中,那我们当然是发送到其TaskQueue中,令其异步执行,此时我们就需要知道客户端的SocketChannel的地址,为此我们可以使用一个Map来保存客户端的hash值,这样我们就可以快捷找到对应的客户端的SocketChannel的地址

最后我们来看看本章总结

image-20230129190604217

异步模型

Netty模型中的一个重要机制就是异步,要了解异步,我们首先要讲解异步这个概念

image-20230129190705238

异步指的是一个方法发出后,调用者无法立刻得到一个结果,但是可以在调用方法完成后通过状态、通知、回调来通知调用者

Netty的异步模型建立在future和callback之上,前者的核心思想是方法调用之后立刻返回一个Future,后续通过Future的监控方法区监控实际方法的执行过程(也就是Future-Listener)

Future表示异步的执行结果,其本身是一个接口,有很多对应的子实现类

image-20230129191135548

其工作原理图如下图所示,Netty框架的目标是让业务逻辑从网络基础应用编码中解脱出来

image-20230129191211448

简单来说是Netty中的任何一个操作都有对应的handler进行处理,而任何一个handler执行的操作都可以使用callback或者future来实现异步

image-20230129190535767

Future-Listener机制可以查看调用方法的执行状态,这需要注册对应的监听函数

image-20230129192854549

绑定端口是异步操作,当绑定操作处理完后会调用响应监听器处理逻辑。我们下面往cf中注册了对应的监听器并设定逻辑,那么当我们的绑定端口操作执行完后,就会调用我们之前指定的监听器方法

image-20230129192652116

相比传统阻塞 I/O,执行 I/O 操作后线程会被阻塞住, 直到操作完成;异步处理的好处是不会造成线程阻塞,线程在 I/O 操作期间可以执行别的程序,在高并发情形下会更稳定和更高的吞吐量

接着我们来实现一个入门案例,HTTP服务,先来看看其要求

image-20230129205600637

首先们要编写服务端的代码,服务端的代码和之前并没有什么不同,不过这里我们将childHandler中的匿名内部类做成一个实际的类

public class TestServer {
    public static void main(String[] args) throws Exception{
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
​
            serverBootstrap.group(bossGroup,workerGroup)
                           .channel(NioServerSocketChannel.class)
                           .childHandler(new TestServerInitializer());
​
            ChannelFuture channelFuture = serverBootstrap.bind(6668).sync();
​
            channelFuture.channel().closeFuture().sync();
        }finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

然后是我们的具体的类,该类需要继承ChannelInitializer,泛型类写入使用的通道类型,然后实现内部的方法,同样是获得管道之后往管道内加入处理器,我们这里加入两个,一个是netty提供的编译解码器,另一个是我们自定义的编译解码器

public class TestServerInitializer extends ChannelInitializer<SocketChannel> {
​
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        //向管道加入处理器
​
        //得到管道
        ChannelPipeline pipeline = socketChannel.pipeline();
​
        //加入一个netty提供的httpServerCodec(编译解码器),其是netty提供的处理http的编译解码器
        pipeline.addLast("MyHttpServerCodec",new HttpServerCodec());
        //增加一个自定义的handler
        pipeline.addLast("MyTestHttpServerHandler",new TestHttpServerHandler());
    }
}

自定义的处理器做的内容就是判断msg是否是http请求,若是则打印对应的信息,然后传入对应字符串构成响应对象返回。但是因为浏览器请求时还会请求浏览器头像的资源,因此我们这里获取uri,对头像资源进行特殊处理。

/**
 * 1.SimpleChannelInboundHandler是ChannelInboundHandlerAdapter的子类
 * 2.HttpObject客户端和服务器端相互通讯的数据被封装成HttpObjcet
 */
public class TestHttpServerHandler extends SimpleChannelInboundHandler<HttpObject> {
​
    //该方法读取客户端数据,当有读事件触发时会被调用
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {
        //判断msg是不是httpRequest请求
        if(msg instanceof HttpRequest){
            System.out.println("pipeline hashcode"+ctx.pipeline().hashCode()+" TestHttpServerHandler hash="+this.hashCode());
            System.out.println("msg 类型="+msg.getClass());
            System.out.println("客户端地址"+ctx.channel().remoteAddress());
            //获取http请求对象,再获取其URI
            HttpRequest httpRequest = (HttpRequest) msg;
            //获取uri,过滤特定资源
            URI uri = new URI(httpRequest.uri());
            if("/favicon.ico".equals(uri.getPath())){
                System.out.println("请求了favicon.ico,不做响应");
                return;
            }
            //回复信息给浏览器[http协议]
            ByteBuf content = Unpooled.copiedBuffer("hello,我是服务器", CharsetUtil.UTF_8);
            //构造一个http的响应,即httpResponse
            FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, content);
            response.headers().set(HttpHeaderNames.CONTENT_TYPE,"text");
            response.headers().set(HttpHeaderNames.CONTENT_LENGTH,content.readableBytes());
            //将构建好的response返回
            ctx.writeAndFlush(response);
        }
    }
}

最后我们启动这个案例就能看到我们的案例正确运行了,这里值得一提的是每个客户端访问服务器端时,都会产生一个自己的管道和处理的handler,同时由于http协议是短时间的,因此再次请求时仍然会生成新的管道和handler,这点验证只要打印对应的管道和handler的hashcode地址就能看出来

image-20230130084542233

Netty核心模块

接下来我们来讲述Netty核心模块,Bootstrap为Netty的引导类、起始类,主要用于配置整个Netty程序,串联各个组件,其下有各种常用方法

image-20230130090432631

这里我们值得一提的是,其下的childHandler和handler都是设置处理器的方法,但是前者设置的处理器会在workerGroup中生效,后者则是在bossGroup中生效

Future和ChannelFuture是Netty中的异步结果,Netty中所有的IO操作都是异步的,会立刻得到一个对象,但是对象内不保存已经得到了想要的结果,我们可以使用sync()方法来让异步操作变为同步操作,相当于是要去代码阻塞在异步操作中,等待其返回结果后继续执行

image-20230130090951485

Channel是Netty网络通信的组件,其可以获得各种状态同时提供异步的IO操作,也有用于不同协议的Channel

image-20230130091113970

image-20230130091519633

Netty基于Selector对象实现IO的多路复用,通过一个Selector线程可以监听多个Channel事件

image-20230130093409403

ChannelHandler是一个接口,主要用于处理IO事件或拦截IO操作,并将其转发到业务处理链(ChannelPipeline)中的下一个应用程序

image-20230130093708732

ChannelHandler是总接口,其下的ChannelInboundHandler用于处理入栈IO事件,ChannelOutboundHandler则用于处理出栈IO事件

image-20230130093921234

出栈事件和入栈事件的判断方法主要看站在什么角度,站在服务器角度,数据写入到服务器是入栈,而反之对于此时的客户端而言将数据写入到服务器是出栈事件

我们自定义一个Hnadler类取继承ChannelInboundHandlerAdapter来构造一个类自定义处理器,一般来说我们需要重写下面的方法

image-20230130094125275

ChannelPipeline是Handler的集合,负责处理和拦截inbound或者是outbound的时间和操作,其实现了一种高级的拦截过滤器模式,使得用户可以完全控制事件的处理方式

image-20230130095912260

Netty中每个Channel中都有且仅有一个ChannelPipeline与之对应,ChannelPipeline含有ChannelHandlerContext组成的双向链表,维护了head和tali两个结点,入站事件认为是从head传递到tail,反之则是出站事件(注意这里是站在服务器角度)

image-20230130100100739

具体到我们上面之前做过的自定义案例,我们维护的双向链表的顺序是TestServerInitializer、MyHttpServerCodec、MyTestHttpServerHandler

下面是源码

public class TestServerInitializer extends ChannelInitializer<SocketChannel> {
​
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        //向管道加入处理器
​
        //得到管道
        ChannelPipeline pipeline = socketChannel.pipeline();
​
        //加入一个netty提供的httpServerCodec(编译解码器),其是netty提供的处理http的编译解码器
        pipeline.addLast("MyHttpServerCodec",new HttpServerCodec());
        //增加一个自定义的handler
        pipeline.addLast("MyTestHttpServerHandler",new TestHttpServerHandler());
    }

注意ChannelHandlerContext是一个接口,里面实际的对象并不是这个,而是其实现类DeafultChannelHandlerContext

ChannelPipeline常用下面两个添加方法来添加业务处理类

image-20230130104145402

ChannelHandlerContext把偶惨了Channel相关的所有上下文信息,同时关联一个ChannelHandler对象,也绑定了对应的pipeline和Channel的信息,可以通过这一个对象获得其他的所有关联对象,其下也有对应的常用方法

image-20230130104223573

Netty在创建一个Channel实例后,一般都要通过ChannelOption设置参数

image-20230130104326335

EventLoopGroup是一组EventLopp的抽象,一般会有多个EventLoop同时工作,每个EventLoop维护着一个Selector实例。EventLoopGroup提供next接口,可以利用该接口来指定处理任务时选取EventLoop实例的规则

一般来说,在Netty服务端编程中,我们的都要提供两个EventLoopGroup

image-20230130104548134

BossEventLoopGroup通常是一个单线程的EventLoop,负责处理连接请求,读写请求会具体交由WorkerEventLoopGroup中的EventLoop,每个EvnetLoop可以产生多个实例,每个实例各不相同,简而言之就是一个EventLoop可以处理多个请求

image-20230130105021528

EventLoopGuooup的实现类是NioEventLoopGroup,其下也有对应的构造方法和断开连接的方法

image-20230130105339408

Unpooled是Netty提供的专门用于操作缓冲区的工具类,其有下面的常用方法

image-20230130134307341

通过Unpooled获取ByteBuf将数据分为了三块,0readerIndex是已经读取的区域,readerIndexwriterIndex是可读的区域,writerIndex~capacity是还可以写入的区域,capacity指的是总大小,readerIndex代表读指针,writerIndex代表的是写指针,正是因为有这些指针,所以我们使用ByteBuf时不用像底层NIO提供的ByteBuff一样需要反转才可以进行读

ByteBuf中含有许多方法,其中getCharSequence()可以获得内部的一段字符串,有三个参数,第一个指定起始坐标,第二个指定读取的长度,第三个指定编码格式。readableBytes()指的是还可读的字节数,是writerIndex与readerIndex的差。getByte()需要传入一个指定的坐标,可以得到指定坐标的值。arrayOffset()可以获得偏移量。readerIndex可以获得readerIndex指针的值,其他的变量名的方法同理。readByte()方法可以读取readerIndex指针的值并令其+1

群聊系统

接着我们来做一个Netty为基础的群聊系统,下面是要求

image-20230130193106595

效果演示

image-20230130193145060

首先我们来编写服务端的代码,跟之前没什么大的区别,不过我们这里添加的自定义类处理器前我们首先加入String的编码解码器

public class GroupChatServer {
    //监听端口
    private int port;
​
    public GroupChatServer(int port) {
        this.port=port;
    }
​
    //编写run方法,处理客户端的请求
    public void run() throws InterruptedException {
        //创建两个线程组
        EventLoopGroup boosGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
​
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(boosGroup,workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG,128)
                    .childOption(ChannelOption.SO_KEEPALIVE,true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
​
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            //获取到pipeline
                            ChannelPipeline pipeline = socketChannel.pipeline();;
                            //向pipeline加入Netty提供的String解码编码器
                            pipeline.addLast("decoder",new StringDecoder());
                            pipeline.addLast("encoder",new StringEncoder());
                            //加入自己的业务处理handler
                            pipeline.addLast(new GroupChatServerHandler());
                        }
                    });
            System.out.println("netty 服务器启动");
            ChannelFuture channelFuture = bootstrap.bind(port).sync();
​
            //监听关闭事件
            channelFuture.channel().closeFuture().sync();
        }finally {
            boosGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
​
    public static void main(String[] args) throws InterruptedException {
        new GroupChatServer(7000).run();
    }
}

然后我们自定义一个处理器,我们这里创建一个全局的处理器属性,重写其下的各种方法令其符合我们的需求,注意调用ChannelGroup的writeandflush方法可以让其往所有的通道中发送消息

public class GroupChatServerHandler extends SimpleChannelInboundHandler<String> {
​
    /**
     * 定义一个channel组用于管理所有的channel
     * GlobalEventExecutor.INSTANCE是一个单例的全局事件执行器
     */
    private static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
​
    /**
     * handlerAdded表示连接建立,一旦连接就会执行该方法
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        //将该客户加入聊天的信息推送其他的在线客户端,调用ChannelGroup的writeAndFlush方法会将信息推送到其下所有的Channel
        channelGroup.writeAndFlush("客户端"+channel.remoteAddress()+"加入聊天"+sdf.format(new Date())+"\n");
        channelGroup.add(channel);
    }
​
    /**
     * 处于活动状态,提示xxx上线
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println(ctx.channel().remoteAddress()+" 上线了");
    }
​
    /**
     * 断开连接,将xxx客户离开信息推送到当前在线的客户
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        channelGroup.writeAndFlush("客户端"+channel.remoteAddress()+"离开了");
        System.out.println("当前channelGroup size"+channelGroup.size());
    }
​
    /**
     * 表示channel处于不活动状态,提示xxx离线了
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println(ctx.channel().remoteAddress()+" 离线了");
    }
​
    /**
     * 读取数据
     * @param channelHandlerContext
     * @param s
     * @throws Exception
     */
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) throws Exception {
        //获取到当前的channel
        Channel channel = channelHandlerContext.channel();
        //遍历channelGroup,根据不同情况回送不同消息
        channelGroup.forEach(ch ->{
            if(channel!=ch){
                ch.writeAndFlush("客户"+channel.remoteAddress()+"发送的消息"+s+"\n");
            }else {//回显发送的消息给自己
                ch.writeAndFlush("自己发送的消息"+s+"\n");
            }
        });
    }
​
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        //关闭通道
        ctx.close();
    }
}

接着我们写我们的客户端的代码,首先创建一个组然后编写启动类,放入组然后也写入自定义处理器,我们这里同样是加入String的编码解码处理器之后再加入自定义处理器,最后我们创建一个扫描器用于给客户端输入信息

public class GroupChatClient {
    /**
     * 属性
     */
    private final String host;
    private final int port;
​
    public GroupChatClient(String host, int port) {
        this.host = host;
        this.port = port;
    }
​
    public void run() throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            //得到pipeline
                            ChannelPipeline pipeline = socketChannel.pipeline();
                            //加入相关handler
                            pipeline.addLast("decoder",new StringDecoder());
                            pipeline.addLast("encoder",new StringEncoder());
                            //加入自定义的handler
                            pipeline.addLast(new GroupChatClientHandler());
                        }
                    });
            ChannelFuture sync = bootstrap.connect(host, port).sync();
            //得到channel
            Channel channel = sync.channel();
            System.out.println("-----------"+channel.localAddress()+"-----------");
            //创建一个扫描器用于给客户端输入信息
            Scanner scanner = new Scanner(System.in);
            while (scanner.hasNextLine()) {
                String msg = scanner.nextLine();
                //通过channel发送到服务器端
                channel.writeAndFlush(msg+"\r\n");
            }
        }finally {
            group.shutdownGracefully();
        }
    }
​
    public static void main(String[] args) throws InterruptedException {
        new GroupChatClient("localhost",7000).run();
    }
}

最后我们自定义客户端的处理器,这里无非就是输出用户输入的字符串而已

public class GroupChatClientHandler extends SimpleChannelInboundHandler<String> {
​
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        System.out.println(msg.trim());
    }
}

最后如果我们还想要将这个案例继续完善的话,我们可以增加用户注册或者登录以及私聊的功能,私聊的功能要求找到对应的客户端进行私聊,我们可以使用hash的思想来完成,而至于用户注册和登录,我们则可以创建一个User实体类并关联Mysql数据库来完成

image-20230130174646706

心跳检测

Netty需要心跳检测来判断客户端是否还在读写来及时在没有读写时做其他任务,有效提升CPU利用率

image-20230131140044451

首先我们来编写客户端的代码,客户端中的代码和之前的一样,不过我们这里在boosGroup中加入了日志处理器,这样其会在启动时打印相应的日志,然后我们在wokerGroup中加入netty的处理空闲状态的处理器并指定对应参数,最后我们加入自定义处理器来处理空闲状态的结果

public class MyServer {
    public static void main(String[] args) throws InterruptedException {
        //创建两个线程组
        EventLoopGroup boosGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup(1);
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(boosGroup,workerGroup)
                     .channel(NioServerSocketChannel.class)
                     .handler(new LoggingHandler(LogLevel.INFO))
                     .childHandler(new ChannelInitializer<SocketChannel>() {
                         @Override
                         protected void initChannel(SocketChannel ch) throws Exception {
                             ChannelPipeline pipeline = ch.pipeline();
                             //加入由netty提供的IdleStateHandler
                             /*
                                IdleStateHandler是netty提供的处理空闲状态的处理器
                                long readerIdleTime 指定多长时间没有读事件发生则会发送心跳检测包检测是否连接
                                long writerIdleTime 指定多长时间没有写事件发生则会发送心跳检测包检测是否连接
                                long allIdleTime 指定多长时间没有读写事件发生则会发送心跳检测包检测是否连接
                                当IdleStateHandler触发后会传递个管道中的下一个handler去处理
                              */
                             pipeline.addLast(new IdleStateHandler(3,5,7, TimeUnit.SECONDS));
                             //加入自定义的handler,用于对空闲检测的结果进行进一步处理
                             pipeline.addLast(new MyServerHandler());
                         }
                     });
            ChannelFuture channelFuture = bootstrap.bind(7000).sync();
            channelFuture.channel().closeFuture().sync();
        }finally {
            boosGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

我们自定义的处理器需要重写userEventTriggered方法,然后我们每次在此方法判断事件是否是空闲事件是的话就进行类型转换,然后获得其状态,在对应的状态进行对应的处理,最后打印结果就好了

public class MyServerHandler extends ChannelInboundHandlerAdapter {
​
    /**
     *
     * @param ctx 上下文
     * @param evt 事件
     * @throws Exception
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if(evt instanceof IdleStateEvent){
            IdleStateEvent event = (IdleStateEvent) evt;
            String evenType = null;
            switch (event.state()) {
                case READER_IDLE:
                    evenType = "读空闲";
                    break;
                case  WRITER_IDLE:
                    evenType = "写空闲";
                    break;
                case ALL_IDLE:
                    evenType = "读写空闲";
                    break;
                default:
            }
            System.out.println(ctx.channel().remoteAddress()+"--超时时间--"+evenType);
            System.out.println("服务器要做相应处理...");
        }
    }
}

最后我们启动服务就可以发现其一直在打印读写事件空闲的结果,如果我们希望其只打印一次,可以在打印结果的自定义处理器中通过ctx对象关闭通道即可

长链接案例

Http协议是无状态的短连接的,浏览器和服务器间的请求只会响应一次,下一次响应(刷新)就会重新创建连接,那么我们现在来做一个长链接的案例,下面是案例要求

image-20230131141203385

首先我们写入服务端的代码,我们这里还是加入了日志处理器,然后加入http的解码和编码器,添加块处理器再添加块聚合处理器,最后添加将http连接升级为websocket连接的处理器(其升级是通过返回一个101状态码实现的),最后再添加一个自定义处理器

我们的填入的hello的uri是要和后面我们定义的网页保持一致的

public class webSocket {
    public static void main(String[] args) throws InterruptedException {
        //创建两个线程组
        EventLoopGroup boosGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(boosGroup,workerGroup)
                     .channel(NioServerSocketChannel.class)
                     .handler(new LoggingHandler(LogLevel.INFO))
                     .childHandler(new ChannelInitializer<SocketChannel>() {
                         @Override
                         protected void initChannel(SocketChannel ch) throws Exception {
                             ChannelPipeline pipeline = ch.pipeline();
                             //因为基于http协议,因此使用http的解码和编码器
                             pipeline.addLast(new HttpServerCodec());
                             //http以块方式传输,因此添加ChunkedWriteHandler处理器
                             pipeline.addLast(new ChunkedWriteHandler());
                             /*
                                http数据在传输过程中是分段,HttpObjectAggregator可以将多个段聚合
                                这也是为什么浏览器浏览器发送大量数据时会发出多次http请求
                              */
                             pipeline.addLast(new HttpObjectAggregator(8192));
                             /*
                                websocket的数据是以帧(frame)的形式传输的,WebSocketFrame下有六个子类
                                浏览器请求时的连接为 ws://localhost:7000/xxx xxx表示请求的uri,websocketPath应该其保持一致
                                WebSocketServerProtocolHandler的核心功能是将http协议升级为websocket协议,保持长链接
                              */
                             pipeline.addLast(new WebSocketServerProtocolHandler("/hello"));
​
                             //自定义的handler,处理业务逻辑
                             pipeline.addLast(new MyTextWebSocketFrameHandler());
                         }
                     });
            ChannelFuture channelFuture = bootstrap.bind(7000).sync();
            channelFuture.channel().closeFuture().sync();
        }finally {
            boosGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

接着写我们的自定义处理器,同样继承SimpleChannelInboundHandler类,不过此处我们要处理WebSocket的文本帧,因此我们要在泛型中填入TextWebSocketFrame

注意我们这里回复消息时,由于我们是在网页中展示,因此我们需要将我们回复的字符串组装成一个TextWebSocketFrame对象返回才能够被正确解析

/**
 * TextWebSocketFrame表示一个文本帧
 */
public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
​
    /**
     * 当web客户端连接后,触发该方法
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        //id表示唯一的值,LongText是唯一的,ShortText不是唯一的
        System.out.println("handleAdd被调用"+ctx.channel().id().asLongText());
        System.out.println("handleAdd被调用"+ctx.channel().id().asShortText());
    }
​
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        System.out.println("handlerRemoved 被调用"+ctx.channel().id().asLongText());
    }
​
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("异常发生"+cause.getMessage());
        ctx.close();
    }
​
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        System.out.println("服务器收到消息"+msg.text());
        //回复消息
        ctx.channel().writeAndFlush(new TextWebSocketFrame("服务器时间"+ LocalDateTime.now()+" "+msg.text()));
​
​
    }
}

最后我们写入网页,在socket连接上正确填入hello的uri,其他的内容自己看了

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<script>
    var socket;
    //判断当前浏览器是否支持websocket
    if(window.WebSocket){
        socket = new WebSocket("ws://localhost:7000/hello");
        //相当于channelReado,ev是服务端回送的消息
        socket.onmessage = function (ev) {
            var rt = document.getElementById("responseText");
            rt.value = rt.value + "\n" + ev.data;
        }
​
        //相当于连接开启(感知到连接开启)
        socket.onopen = function (ev) {
            var rt = document.getElementById("responseText");
            rt.value = "连接开启了...";
        }
​
        //相当于连接关闭(感知到连接关闭)
        socket.onclose = function (ev) {
            var rt = document.getElementById("responseText");
            rt.value = rt.value + "\n" + "连接关闭了..."
        }
​
        //发送消息到服务器
        function send(message) {
            //先判断socket是否创建好
            if(!window.socket){
                return;
            }
            if(socket.readyState==WebSocket.OPEN){
                //通过socket发送消息
                socket.send(message);
            }else {
                alert("连接没有开启");
            }
        }
    }else {
        alert("当前浏览器不支持webSocket")
    }
</script>
    <form onsubmit="return false">
        <textarea name="message" style="height: 300px;width: 300px"></textarea>
        <input type="button" value="发送消息" onclick="send(this.form.message.value)">
        <textarea id="responseText" style="height: 300px; width: 300px"></textarea>
        <input type="button" value="清空内容" onclick="document.getElementById('responseText').value=''">
    </form>
</body>
</html>

ProtoBuf

编写网络应用程序时,由于数据在网络上传输的方式为二进制字节码数据,因此在发送时需要将数据编码为二进制格式,从网络上获取数据时则需要将数据从二进制格式转换为具体的对象

image-20230201012434726

Netty自身也提供了一些编码器和解码器,但是其底层仍然使用的是Java的序列化技术,导致其效率不高

image-20230201012620961

在这种情况下,Protobuf就产生了,其实Google发布的开源项目,是一种轻便高效的结构化数据存储格式,很适合用于数据存储或RPC(远程过程调用)数据交换格式

image-20230201013636818

Protobuf使用时需要先编写一个proto类,然后用protoc.exe工具将其转换对应的Java类,然后再利用对应的编码器编码,解码后便可得到原来的java对象

image-20230201013652129

接着我们来做一个Protobuf的快速入门案例

image-20230201023420608

首先我们需要引入对应的依赖

image-20230201015430348

image-20230201021128769

然后我们需要编写一个Proto的对应类

syntax = "proto3"; //版本
option java_outer_classname = "StudentPOJO"; //生成的外部类名,同时也是文件名
//protobuf使用message管理数据
message Student { //会在StudentPOJO外部生成一个内部类Student,其实真正发送的POJO对象
  int32 id = 1; //Student类中有一个属性,名为id类型为int32(int32是protobuf类型)
  string name = 2;
}

然后使用Proto的软件令其生成一个对应的java对象,其下含有许多供给我们使用的方法,我们真正要传输的类就位于其中的一个内部类中

重新写一个客户端与服务端交互的程序太麻烦了,我们这里用以前的喵喵喵的案例来做示例

首先我们需要编写客户端处理器向服务器发送对象的方法,调用对应的对象的方法然后设置对应的属性即可

image-20230201021512302

别忘了要在客户端中加入Protobuf的编码解码器

image-20230201021910010

在服务端中也是同理,不过服务端中加入的编码解码器需要指定接收对象,还要调用其getDefaultInstance

image-20230201022118044

然后是实现ChannelInboundHandlerAdapter服务端的处理器,转换对象之后直接调用其对应的方法即可

image-20230201022404655

如果是对于实现SimpleChannelInboundHandler接口的处理器,因为其会在泛型中指定能接收的对象,因此可以省去强制类型转换这一步

当然,我们上面的程序只能够接受Student对象,这实在是太没用了,因此我们接下来还需要实现ProtoBuf传输不同对象接受处理的案例

image-20230201030922161

那么首先我们需要写入我们的proto的对象的代码如下,注意我们属性后面的编号是proto的属性编号

syntax = "proto3"; //版本
option optimize_for = SPEED; //加快解析
option java_package="com.atguigu.netty.codec2"; //指定类生成的包
option java_outer_classname = "MyDataInfo"; //生成的外部类名,同时也是文件名
​
//protobuf可以使用message管理其他message
message MyMessage {
  //定义一个枚举类型
  enum DataType {
    //在proto3中要求enum的编号从0开始
    StudentType = 0;
    WorkerType = 1;
  }
​
  //用data_type来表示传的是哪一个枚举类型
  DataType data_type = 1;
  //表示每次枚举类型只能出现其中的一个,节省空间
  oneof dataBody {
    Student student = 2;
    Worker worker = 3;
  }
}
​
message Student {
  int32 id = 1;
  string name = 2;
}
​
message Worker {
  string name = 1;
  int32 age = 2;
}

然后我们写入客户端的发送对象的代码,通过随机数对象来随机发送对象,我们这里需要调用其对应的方法来获得正确的我们所需要的对象,不过比较麻烦就是

image-20230201030132920

同样要在服务器端加入protobuf的编码器,我们这里指定的对象需要变化就是

image-20230201030221741

最后是服务器端对传入的不同对象的处理方法

image-20230201030604570