Netty学习笔记

1. NIO基础

non-blocking io,jdk1.4后新增

1.1 三大组件

1.1.1 Channel

读写数据的双向通道,可以用channel将数据从buffer中读出,也可以将buffer的数据写入channel,而java的Stream流要么输入要么输出,常用的Channel有SocketChannel、ServerSocketChannel、FileChannel

1.1.2 Buffer

Buffer用于缓冲与读写数据,常用的是ByteBuffer

1.1.3 Selector

多线程版服务器设计

在这里插入图片描述
缺点:

  1. 内存占用高,每个建立连接的客户端都要一个单独服务器线程进行处理
  2. 当有很多客户端建立连接后,线程上线文切换成本高
线程池版服务器设计

为了限制多线程设计下线程的最大数量,可以在服务器端利用线程池处理客户端请求
在这里插入图片描述
缺点:

  1. 虽然可以解决服务器的内存问题,但是当线程池中所有线程都与客户端建立连接后,再有新的客户端想要请求服务器就必须等待已建立连接的客户端断开连接,而已建立连接的客户端实际上可能并没有想服务器发送读写请求,造成服务器线程资源浪费
Selector版服务器设计

在这里插入图片描述
thread不直接与客户端请求连接,中间引入Selector,Selector用于监视所管理的所有Channel,当监控到Channel中的事件处于就绪,由Selector通知thread去处理对应的客户端请求,提高服务器端线程利用率

2.1 ByteBuffer

2.1.1 ByteBuffer使用案例

准备一个data.txt文件,用于读取其中的数据
在这里插入图片描述

@Slf4j
public class TestByteBuffer {
    public static void main(String[] args) {
        //FileChannel的获取方式
        //1. 通过输入输出流获取(下面使用这种方式)
        //2. 通过RandomAccessFile获取
        try (FileChannel channel = new FileInputStream("data.txt").getChannel()) {
            //准备10个字节的缓冲区
            ByteBuffer buffer = ByteBuffer.allocate(10);
            while (true) {                          //每次while循环最多读取10个字节数据
                //从channel读取数据,利用channel操作buffer,并写入buffer
                int len = channel.read(buffer);    //read方法返回值为-1时表示channel中数据已经读取完毕
                log.debug("读取到的字节数{}", len);
                if (len == -1) break;              //当返回-1时退出读取循环
                //打印buffer内容
                buffer.flip();                      //切换读模式
                while (buffer.hasRemaining()) {     //buffer中是否还有未读数据
                    byte b = buffer.get();          //读一个字节
                    log.debug("读取到的实际字节{}", (char)b);
                }
                //切换为写模式
                buffer.clear();
            }
        } catch (IOException e) {
        }
    }
}

运行结果
在这里插入图片描述

2.1.2 ByteBuffer使用步骤

通过以上例子可以看出,ByteBuffer的使用步骤如下

  1. 通过allocate方法初始化ByteBuffer,此时Buffer处于写模式
  2. 利用channel向buffer中写入数据,如调用channel.read(buffer)
  3. 将buffer切换为读模式
  4. 调用buffer.get()从buffer中读取数据
  5. 调用buffer.clear()将buffer切换为写模式
  6. 如果buffer一次读取不完channel中的数据,重复以上2-5步,直到channel数据读取完毕

2.1.3 ByteBuffer结构

Buffer中三个重要属性

  1. position:记录读写时的开始位置
  2. capacity:记录buffer容器
  3. limit:记录读写的最大位置限制

Buffer刚初始化时,在写模式下的状态,position为下一个字节写入到buffer的位置。limit=capacity,写入限制=最大容量
在这里插入图片描述
当写入4个字节后的状态:
在这里插入图片描述
当调用buffer.flip()切换为读模式后,position切换为读取位置,limit表示读取限制
在这里插入图片描述
当buffer中所有数据都读取完成后
在这里插入图片描述
当读取完成后,调用buffer.clear()切换为写模式
在这里插入图片描述
如果buffer中的数据还没读取完,就想要切换为写模式,应该调用buffer.compact方法,compact可以把未读取的部分向前压缩,接着在未读取的数据后面写入新数据
在这里插入图片描述

2.1.4 ByteBuffer常用API

  1. 分配ByteBuffer
 /** buffer1是HeapByteBuffer类型
  * 即java堆内存,读写效率较低,会受到垃圾回收影响
  * 但堆内存分配时效率较高
  */
 ByteBuffer buffer1 = ByteBuffer.allocate(16);
 /**buffer2是DirectByteBuffer类型
  * 即系统直接内存,读写效率较高(相比HeapByteBuffer少一次数据拷贝)
  * 且不会被垃圾回收影响
  * 但直接内存分配时效率较低
  */
 ByteBuffer buffer2 = ByteBuffer.allocateDirect(16);
  1. 向ByteBuffer写入数据
    1. 调用channel.read(buffer)从channel读取,写入到buffer
    2. 调用buffer.put()
  2. 从ByteBuffer读取数据
    1. 调用channel.write(buffer)从buffer读取,写入到channel
    2. 调用buffer.get()
    3. get会让position指针向后移动,如果想重复读取数据,可以用rewind方法,将读模式下position指针重置为0,或者get(int index),向get中传入索引index,只会读取index的内容,不会移动读指针

ByteBuffer读取数据的API演示

//初始buffer
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put(new byte[]{'a', 'b', 'c', 'd'});
buffer.flip();

rewind重置position

//读取buffer中所有数据
buffer.get(new byte[4]);
debugAll(buffer);
//重置读模式下position指针
buffer.rewind();
debugAll(buffer);

在这里插入图片描述
mark&reset用于重复读取buffer中某个片段

//mark & reset
//mark做一个标记,记录position位置
//reset是将position重置到mark的位置
buffer.get();
buffer.get();	//先读取两次,postion此时指向索引为2的数据
buffer.mark();  //在position当前位置,即索引为2的位置添加mark标记
buffer.get();
buffer.get();
buffer.reset(); //将position重置到索引2的位置
debugAll(buffer);

在这里插入图片描述
get(i)用于直接获取索引为i除的数据,不会影响position的位置

//get(i)
buffer.get(3);
debugAll(buffer);

在这里插入图片描述

2.1.5 字符串与ByteBuffer互相转换

//1. 字符串转为ByteBuffer,将字符串转为字节数组写入buffer
ByteBuffer buffer1 = ByteBuffer.allocate(16);
buffer1.put("hello".getBytes());     //执行完成后buffer1还是在写模式
debugAll(buffer1);

//2. Charset
ByteBuffer buffer2 = StandardCharsets.UTF_8.encode("hello"); //执行完成后buffer2被切换为读模式
debugAll(buffer2);

//3. wrap
ByteBuffer buffer3 = ByteBuffer.wrap("hello".getBytes());   //执行完成后buffer2被切换为读模式
debugAll(buffer3);

//buffer转为字符串,使用decode方法,注意这里传入的buffer必须要是读模式,比如传入buffer1就会出错
String str1 = StandardCharsets.UTF_8.decode(buffer2).toString();
System.out.println(str1);

2.1.6 将一个channel中的数据依次分散读取到多个buffer中

如把下面的文件words.txt中的one、two、three分别读取到三个buffer中
在这里插入图片描述

//通过RandomAccessFile获取channel,参数1为读取的文件名,参数2为指定channel的模式为只读
try (FileChannel channel = new RandomAccessFile("words.txt", "r").getChannel()) {
    ByteBuffer b1 = ByteBuffer.allocate(3);
    ByteBuffer b2 = ByteBuffer.allocate(3);
    ByteBuffer b3 = ByteBuffer.allocate(5);
    //使用scattering read,将三个ByteBuffer作为数组传给read方法,可以分散将结果读取到三个buffer中
    channel.read(new ByteBuffer[]{b1, b2, b3});
    debugAll(b1);
    debugAll(b2);
    debugAll(b3);
} catch (IOException e) {
    
};

输出结果
在这里插入图片描述

2.1.7 将多个buffer中的数据集中写入到同一个channel中

ByteBuffer b1 = StandardCharsets.UTF_8.encode("hello");
ByteBuffer b2 = StandardCharsets.UTF_8.encode("world");
//一个汉字3个字节
ByteBuffer b3 = StandardCharsets.UTF_8.encode("你好");
//将channel中的数据写入到words2文件中,设置channel模式为读写模式
try (FileChannel channel = new RandomAccessFile("words2.txt", "rw").getChannel()) {
    channel.write(new ByteBuffer[]{b1, b2, b3});
} catch (IOException e) {

}

运行结果
在这里插入图片描述

2.1.8 粘包、半包问题

在网络上发送多条数据给服务器,约定数据之间用\n作为分割,比如发送三条原始数据给服务器:
Hello world\n
Im ikun\n
How are you\n

但是在网络传输后,服务器收到的数据变成了下面两条
Hello world\nIm ikun\nHo
w are you\n

其中原本第一条和第二条数据合并成了一条数据,这就是粘包,而原本第三条数据被分割成了两条数据,这就是半包,由于客户端发送数据给服务器时是将所有三条数据一起发送的,所以服务器接收到的数据就会出现粘包,而半包是由于服务器的缓冲区大小限制,一次不能完全读取完客户端发送的所有数据,所以数据会被截断

public static void main(String[] args) {
	//假设source就是服务器端用于接收客户端数据的buffer
    ByteBuffer source = ByteBuffer.allocate(32);
    //模拟粘包和半包现象,向source中放入粘包和半包数据
    source.put("Hello world\nIm ikun\nHo".getBytes());
    //调用方法解析数据
    split(source);
    source.put("w are you\n".getBytes());
    split(source);
}

private static void split(ByteBuffer source){
	//切换读方法
    source.flip();
    for(int i = 0;i < source.limit();i++){
        //使用get(i)找到完整消息结尾的索引,get(i)不会移动source的position
        if(source.get(i) == '\n'){
            //计算完整消息长度,长度等于当前找到/n的索引+1-position的位置
            int length = i+1-source.position();
            //把完整消息存入新的target
            ByteBuffer target = ByteBuffer.allocate(length);
            //从source读取,读取后source的position向后移动,再向target写
            for(int j = 0;j < length;j++){
                target.put(source.get());
            }
            //输出target中的完整消息
            debugAll(target);
        }
    }
	//因为可能存在半包现象,所以用compact
    source.compact();
}

输出结果
在这里插入图片描述

3. 文件编程

3.1 FileChannel

FileChannel只能工作在阻塞模式下

3.1.1 获取FileChannel

只能通过FileInputStream、FileOutputStream或者RandomAccessFile来获取FileChannel,并且前两者获取的channel只能读或写,最后一个可以指定既可读也可写

3.1.2 读取FileChannel

int readBytes = channel.read(buffer)
从channel中读取数据填充到buffer中,readBytes表示读取到了多少字节,-1表示读取到了末尾

3.1.3 写入FileChannel

ByteBuffer buffer = ....;
buffer.put(...);	//存入数据
buffer.flip();		//切换buffer到读模式
while(buffer.hasRemaining()){	//因为调用一次write不能保证buffer中所有数据都被写入channel,所以通过while条件循环判断
	channel.write(buffer);
}

3.1.4 关闭

使用完毕后,需要关闭channel

3.2 读、写FileChannel之间的数据传输

public class TestFileChannelTransferTo {
    public static void main(String[] args) {
        try (
                //从from中读取数据并写入到to中
                FileChannel from = new FileInputStream("data.txt").getChannel();
                FileChannel to = new FileOutputStream("to.txt").getChannel()
        ) {
            long size = from.size();
            //用left代表剩下需要传输的数据,当left>0时说明需要继续传输
            for(long left = size;left > 0;) {
                //transferTo底层会运用零拷贝进行优化,参数1为从from的哪个位置开始拷贝,参数2为拷贝的数据量,参数3为目标channel
                //返回值为实际传输的数据量,用left-,transferTo方法一次最多传输2g,所以可能需要多次传输
                left -= from.transferTo(size-left, left, to);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

运行结果
在这里插入图片描述

3.3 walkFileTree

3.3.1 访问一个根目录下的所有子目录和子文件

private static void countFilesNum() throws IOException {
    //子路径数计数
    AtomicInteger dirCount = new AtomicInteger();
    //子文件数技术
    AtomicInteger fileCount = new AtomicInteger();
    //访问D:\IdeaStudy\Netty目录下所有子目录和子文件
    Files.walkFileTree(Paths.get("D:\\IdeaStudy\\Netty"), new SimpleFileVisitor<Path>(){
        //每次访问一个子目录之前调用
        @Override
        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
            System.out.println("======>"+dir);
            dirCount.incrementAndGet();
            return super.preVisitDirectory(dir, attrs);
        }
        //每次访问到文件后调用
        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
            System.out.println(file);
            fileCount.incrementAndGet();
            return super.visitFile(file, attrs);
        }
    });
    //输出子目录和文件数
    System.out.println("dirCount:" + dirCount);
    System.out.println("fileCount:" + fileCount);
}

在这里插入图片描述

3.3.2 删除一个不为空的目录

private static void deleteDir() throws IOException {
    Files.walkFileTree(Paths.get("D:\\IdeaStudy\\Netty"), new SimpleFileVisitor<Path>(){
        //进入目录中,先删除目录中每个文件
        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
            Files.delete(file);
            return super.visitFile(file, attrs);
        }
        //退出目录时目录中文件已经被删完了,所以可以删除目录本身
        @Override
        public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
            Files.delete(dir);
            return super.postVisitDirectory(dir, exc);
        }
    });
}

3.3.3 拷贝多级目录

private static void copyDir() throws IOException {
    //原路径
    String source = "D:\\IdeaStudy\\Netty";
    //目标路径
    String target = "D:\\IdeaStudy\\Netty\\testCopy";
    //path代表每次遍历到的目录或文件
    Files.walk(Paths.get(source)).forEach(path->{
        try {
            //先把源文件中的源路径替换为目标路径
            String targetName = path.toString().replace(source, target);
            //如果path是目录就新建目录,如果是文件就拷贝文件
            if (Files.isDirectory(path)) {    //目录
                Files.createDirectories(Paths.get(targetName));
            } else if (Files.isRegularFile(path)) {    //文件
                Files.copy(path, Paths.get(targetName));
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    });
}

4.网络编程

4.1 nio阻塞模式

服务器代码:

@Slf4j
public class Server {
    //单线程模式下的阻塞模式
    public static void main(String[] args) throws IOException {
        //buffer用于与channel配合读取数据
        ByteBuffer buffer = ByteBuffer.allocate(16);
        //创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //绑定服务器端口
        ssc.bind(new InetSocketAddress(8080));
        //用于存放与客户端建立连接的集合
        List<SocketChannel> channels = new ArrayList<>();
        //服务器建立连接
        while (true){
            //sc用于和客户端通信
            log.debug("connecting...");
            //accept是阻塞方法,当运行到这行代码,那么在一个客户端建立新连接之前,执行到此处的线程会暂停运行
            SocketChannel sc = ssc.accept();
            log.debug("connected {}...",sc);
            //将刚刚通过accept建立连接的channel放入list保存
            channels.add(sc);
            //循环读取list中所有不同客户端发送的保存在channel中数据
            for (SocketChannel channel: channels){
                log.debug("before read {}...", channel);
                //从channel读,向buffer写,read也是阻塞方法,如果当前这次循环到的客户端连接对应的channel中没有数据,则线程暂停运行
                channel.read(buffer);
                buffer.flip();
                debugRead(buffer);
                buffer.clear();
                log.debug("after read {}...", channel);

            }
        }
    }
}

客户端代码:

public class Client {
    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("localhost", 8080));
        sc.write(Charset.defaultCharset().encode("hello"));
    }
}
  1. 当只启动服务器时,由于没有客户端建立连接,所以服务器端线程在SocketChannel sc = ssc.accept();处阻塞
    在这里插入图片描述
  2. 当启动客户端,但客户端不发送数据时,服务器建立连接,但因为客户端没有在channel中写入数据,所以服务器端线程在channel.read(buffer);处阻塞
    在这里插入图片描述
  3. 当客户端在channel中写入数据后,服务器读取数据,并重新进入下一次循环,在SocketChannel sc = ssc.accept();继续等待下一个客户端建立连接
    在这里插入图片描述

4.2 nio非阻塞模式

改造服务器端代码为非阻塞模式

@Slf4j
public class Server {
    //nio非阻塞模式单线程
    public static void main(String[] args) throws IOException {
        //buffer用于与channel配合读取数据
        ByteBuffer buffer = ByteBuffer.allocate(16);
        //创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //将服务器切换为非阻塞模式,影响accept方法,默认是阻塞模式
        ssc.configureBlocking(false);
        //绑定服务器端口
        ssc.bind(new InetSocketAddress(8080));
        //用于存放与客户端建立连接的集合
        List<SocketChannel> channels = new ArrayList<>();
        //服务器建立连接
        while (true){
            //sc用于和客户端通信
            //accept变成非阻塞方法,即使没有客户端建立连接,线程可以继续运行,返回null
            SocketChannel sc = ssc.accept();
            //当accept接收到客户端连接返回不为null后,再将其放入list保存
            if(sc != null){
                log.debug("connected {}...",sc);
                //将socketChannel设置为非阻塞模式,影响read方法
                sc.configureBlocking(false);
                //将刚刚通过accept建立连接的channel放入list保存
                channels.add(sc);
            }
            //循环读取list中所有不同客户端发送的保存在channel中数据
            for (SocketChannel channel: channels){
                //非阻塞read,如果没有读取到客户端写入channel中的数据,read方法返回0
                int read = channel.read(buffer);
                //当能够从channel中读取数据时
                if(read > 0){
                    buffer.flip();
                    debugRead(buffer);
                    buffer.clear();
                    log.debug("after read {}...", channel);
                }
            }
        }
    }
}

该模式下服务器线程会一直运行,即使accept和read方法没有得到结果,虽然相比阻塞模式能提高线程利用率,但是因为即使客户端没有连接或写入,服务器线程也会不停监测,导致cpu资源会被浪费

4.3 nio选择器非阻塞模式(单线程多路复用)

将channel邦迪到一个selector上后,selector会自动监控这个channel中的事件,当有事件发生时,selector.select()才能继续运行;绑定channel后,selector中会创建一个和这个channel对应的selectedKey,当channel发生事件时,能够通过这个对应的selectedKey拿到事件类型和发生事件的channel的信息,可以指明每个selectedKey具体关注哪个事件,selectorKey的事件有以下几种:

  1. accept:客户端建立连接时在服务器触发的事件
  2. connect:客户端建立连接时在客户端触发的事件
  3. read:可读
  4. write:可写

改造服务端代码:

@Slf4j
public class Server {
    //nio非阻塞模式单线程
    public static void main(String[] args) throws IOException {
        //创建selector,管理多个channel
        Selector selector = Selector.open();
        //创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //将服务器切换为非阻塞模式,影响accept方法,默认是阻塞模式
        ssc.configureBlocking(false);
        //注册channel到selector上,当这个channel有事件发生时,selector就能监测到,同时会绑定一个SelectionKey到selector上,这个key专门用于管理这个channel,同时会返回这个SelectionKey,可以通过SelectionKey返回值得到发生的是什么事件,和发生事件的channel
        SelectionKey sscKey = ssc.register(selector, 0, null);
        //指明sscKey只关注accept事件
        sscKey.interestOps(SelectionKey.OP_ACCEPT);
        log.debug("注册ServerSocketChannel:{},等待客户端连接...", sscKey);
        //绑定服务器端口
        ssc.bind(new InetSocketAddress(8080));
        while (true){
            //select方法,没有事件发生时,运行到此处的线程阻塞,有事件发生,线程恢复运行
            selector.select();
            //处理事件,selectedKeys返回selector上selectedKeys集合的迭代器,selectedKeys保存有事件发生的channel对应的selectionKey
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            log.debug("检测到selector上绑定的channel发生了事件,开始遍历所有selectedKeys");
            while (iterator.hasNext()){
                //获取当前遍历到的selectedKey的迭代器
                SelectionKey key = iterator.next();
                //从selector的selectedKeys集合中移除当前遍历到的selectedKey,如果不做这一步,下面可能会出现空指针异常
                iterator.remove();
                //获得事件的channel,并区分事件的类型
                if(key.isAcceptable()){     //如果是客户端连接事件
                    log.debug("本次遍历获取到ServerSocketChannel的连接事件: {}", key);
                    获取触发连接事件的ServerSocketChannel
                    ServerSocketChannel channel = (ServerSocketChannel)key.channel();
                    //处理连接事件
                    SocketChannel sc = channel.accept();
                    //修改sc为非阻塞
                    sc.configureBlocking(false);
                    //将sc注册到selector上,sc这个channel之后就通过selector中的scKey进行管理
                    SelectionKey scKey = sc.register(selector, 0, null);
                    //指定scKey只关注read事件
                    scKey.interestOps(SelectionKey.OP_READ);
                    log.debug("建立新的SocketChannel:{}",sc);
                }else if(key.isReadable()){     //如果是读取事件
                    try {
                        log.debug("本次遍历获取到SocketChannel的读取事件: {}", key);
                        //获取触发读取事件的SocketChannel
                        SocketChannel channel = (SocketChannel)key.channel();
                        //创建16字节的ByteBuffer
                        ByteBuffer buffer = ByteBuffer.allocate(16);
                        //read再读取时返回读取的字节数,若客户端正常断开,read方法返回-1
                        int read = channel.read(buffer);
                        if(read == -1)
                            //当客户端建立连接后,客户端正常关闭时会触发read事件,但此时若再从channel中读取数据,读取不到任何数据
                            //且对应的selectedKey的read事件也没有被处理,在下次循环时,还会从selectionKeys中放到selectedKeys集合中
                            //所以这里要用cancel方法将对应channel的key进行反注册,将这个key对应的channel从之前注册到的selector上移除
                            key.cancel();
                        else {
                            //切换buffer为读模式
                            buffer.flip();
                            //读取buffer中内容
                            debugRead(buffer);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                        //当客户端建立连接后,客户端强制关闭时会触发read事件,但此时若再从channel中读取数据,则会抛出io异常
                        //且对应的selectedKey的read事件也没有被处理,在下次循环时,还会从selectionKeys中放到selectedKeys集合中
                        //所以这里要用cancel方法将对应channel的key进行反注册,将这个key对应的channel从之前注册到的selector上移除
                        key.cancel();
                    }
                }
            }
            log.debug("遍历selectedKeys结束");
        }
    }
}
  1. 当只启动服务器:
    在这里插入图片描述
  2. 当只启动一台客户端,不发送数据:
    在这里插入图片描述
  3. 当再启动一台客户端,不发生数据
    在这里插入图片描述
    selector.select()的工作原理是selector中所有绑定的channel有未处理的事件时,就不会阻塞。所以获取到selector中的未处理事件后要通过accept、read或cancel方法处理
  4. cancel方法是用于将当前key对应的channel从之前注册的selector上移除,即反注册
  5. accept方法是用于处理ServerSocketChannel的连接事件的
  6. read方法用时用于处理SocketChannel的读取事件的

4.3.1 selector中selectionKeys和selectedKeys集合的关系

在这里插入图片描述

4.3.2 处理消息边界问题

网络通信可能出现的情况
在这里插入图片描述
在上图第一种情况下,如果服务器用于从channel中读取客户端发送数据的buffer长度小于客户端发送的一条完整数据的长度,则这个channel对应的selector在下次执行selector.select();时不会被阻塞,直到channel中数据被读取完

在之前的代码中如果出现这种情况,那么第一次使用channel.read(buffer)读取到保存在buffer中的内容会被丢失,即下图的代码会执行多次
在这里插入图片描述

针对这个问题,接收客户端数据的buffer需要可以扩容,并且不能是局部变量

处理方法:

  1. 按照客户端可能发送的最长数据长度定义服务器端buffer,缺点浪费空间和带宽
  2. 客户端发消息以分隔符分割,服务器接收到消息后依次检测分隔符,缺点是效率低
  3. 客户端发消息时在每条完整消息前携带标记,标记本条消息的长度,服务器读取每条消息头部的长度分配buffer空间

方法2的处理:
在这里插入图片描述
根据以上改造服务器端代码

@Slf4j
public class Server {
    //用于根据\n分割完整消息并输出
    private static void split(ByteBuffer source){
        source.flip();
        for(int i = 0;i < source.limit();i++){
            //找到一条完整消息
            if(source.get(i) == '\n'){
                //计算完整消息长度,长度等于当前找到/n的索引+1-position的位置
                int length = i+1-source.position();
                //把完整消息存入新的byteBuffer
                ByteBuffer target = ByteBuffer.allocate(length);
                //从source读取,向target写
                for(int j = 0;j < length;j++){
                    //使用get从source中读取一次,把source的position下标移动一次
                    target.put(source.get());
                }
                debugAll(target);
            }
        }
        source.compact();
    }
    //nio非阻塞模式单线程
    public static void main(String[] args) throws IOException {
        //创建selector,管理多个channel
        Selector selector = Selector.open();
        //创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //将服务器切换为非阻塞模式,影响accept方法,默认是阻塞模式
        ssc.configureBlocking(false);
        //注册channel到selector上,当这个channel有事件发生时,selector就能监测到,同时会绑定一个SelectionKey到selector上,这个key专门用于管理这个channel,同时会返回这个SelectionKey,可以通过SelectionKey返回值得到发生的是什么事件,和发生事件的channel
        SelectionKey sscKey = ssc.register(selector, 0, null);
        //指明sscKey只关注accept事件
        sscKey.interestOps(SelectionKey.OP_ACCEPT);
        log.debug("注册ServerSocketChannel:{},等待客户端连接...", sscKey);
        //绑定服务器端口
        ssc.bind(new InetSocketAddress(8080));
        while (true){
            //select方法,没有事件发生时,运行到此处的线程阻塞,有事件发生,线程恢复运行
            selector.select();
            //处理事件,selectedKeys返回selector上selectedKeys集合的迭代器,selectedKeys保存有事件发生的channel对应的selectionKey
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            log.debug("检测到selector上绑定的channel发生了事件,开始遍历所有selectedKeys");
            while (iterator.hasNext()){
                //获取当前遍历到的selectedKey的迭代器
                SelectionKey key = iterator.next();
                //从selector的selectedKeys集合中移除当前遍历到的selectedKey,如果不做这一步,下面可能会出现空指针异常
                iterator.remove();
                //获得事件的channel,并区分事件的类型
                if(key.isAcceptable()){     //如果是客户端连接事件
                    log.debug("本次遍历获取到ServerSocketChannel的连接事件: {}", key);
                    获取触发连接事件的ServerSocketChannel
                    ServerSocketChannel channel = (ServerSocketChannel)key.channel();
                    //处理连接事件
                    SocketChannel sc = channel.accept();
                    //修改sc为非阻塞
                    sc.configureBlocking(false);
                    //创建4字节的ByteBuffer
                    ByteBuffer buffer = ByteBuffer.allocate(4);
                    //将sc注册到selector上,sc这个channel之后就通过selector中的scKey进行管理
                    //同时将buffer作为附件绑定到这个channel上,这个buffer只用于接收该channel中的数据,避免多个channel同时有事件时公用同一个buffer读取
                    //buffer作为附件绑定到channel上后,如果从channel中读取数据时发生半包、粘包,下次也能直接拿到channel对应的buffer,在其中position的位置接着写入
                    SelectionKey scKey = sc.register(selector, 0, buffer);
                    //指定scKey只关注read事件
                    scKey.interestOps(SelectionKey.OP_READ);
                    log.debug("建立新的SocketChannel:{}",sc);
                }else if(key.isReadable()){     //如果是读取事件
                    try {
                        log.debug("本次遍历获取到SocketChannel的读取事件: {}", key);
                        //获取触发读取事件的SocketChannel
                        SocketChannel channel = (SocketChannel)key.channel();
                        //获取注册channel到selector时所关联的附件,这里就是拿到从该channel中读取数据的buffer
                        ByteBuffer buffer = (ByteBuffer) key.attachment();
                        //read在读取时返回读取的字节数,若客户端正常断开,read方法返回-1
                        //从channel中读取内容到buffer中,如果buffer一次存不下channel中所有数据,这个channel对应的selector下次在执行selector.select();时不会阻塞,直到channel中数据被读取完
                        int read = channel.read(buffer);
                        if(read == -1)
                            //当客户端建立连接后,客户端正常关闭时会触发read事件,但此时若再从channel中读取数据,读取不到任何数据
                            //且对应的selectedKey的read事件也没有被处理,在下次循环时,还会从selectionKeys中放到selectedKeys集合中
                            //所以这里要用cancel方法将对应channel的key进行反注册,将这个key对应的channel从之前注册到的selector上移除
                            key.cancel();
                        else {
                            //尝试从buffer中根据\n分割一条完整消息
                            split(buffer);
                            //split中最后会把buffer切换回写模式,如果写模式下position=limit说明整条消息长度超过了buffer容量,需要进行扩容
                            if(buffer.position() == buffer.limit()){
                                ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
                                buffer.flip();
                                newBuffer.put(buffer);
                                //将从channel中读取数据的buffer替换为新的buffer
                                key.attach(newBuffer);
                            }
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                        //当客户端建立连接后,客户端强制关闭时会触发read事件,但此时若再从channel中读取数据,则会抛出io异常
                        //且对应的selectedKey的read事件也没有被处理,在下次循环时,还会从selectionKeys中放到selectedKeys集合中
                        //所以这里要用cancel方法将对应channel的key进行反注册,将这个key对应的channel从之前注册到的selector上移除
                        key.cancel();
                    }
                }
            }
            log.debug("遍历selectedKeys结束");
        }
    }
}

当客户端发送的数据大于4字节时,也能全部读取出来
在这里插入图片描述

4.3.3 服务器发送大量数据给客户端

当服务器发送大量数据给客户端时,可能服务器需要分成多次发送,在多次发送时,需要进行一定优化,避免当服务器缓冲已满时服务器还在尝试写入数据的情况

服务器端代码

public class WriteServer {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        ssc.bind(new InetSocketAddress(8080));
        while(true){
            selector.select();
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                iterator.remove();
                if(key.isAcceptable()){
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    //向客户端发送大量数据
                    StringBuilder stringBuilder = new StringBuilder();
                    for (int i = 0; i < 30000000; i++) {
                        stringBuilder.append("a");
                    }
                    ByteBuffer buffer = Charset.defaultCharset().encode(stringBuilder.toString());
                    //返回值代表实际写入字节数
                    while (buffer.hasRemaining()){
                        int write = sc.write(buffer);
                        //打印本次向客户端发送的数据字节数
                        System.out.println(write);
                    }
                }
            }
        }
    }
}

客户端代码

public class WriteClient {
    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("localhost",8080));
        //接收数据,count保存一共从服务器获取的字节数
        int count = 0;
        while (true){
            ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
            count += sc.read(buffer);
            System.out.println(count);
            buffer.clear();
        }
    }
}

运行结果:
在这里插入图片描述
可以看到服务器发送数据时,会发送多次,当服务器缓冲区写满后,如果客户端没有及时读取就会出现写入0字节的情况,由于下面的while循环会一直不停尝试写入数据,就算缓冲区已满
在这里插入图片描述
这种方式会导致服务器资源被浪费,因为当缓冲区满时就不应该尝试再进行写入,服务器改造后如下

public class WriteServer {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        ssc.bind(new InetSocketAddress(8080));
        while(true){
            selector.select();
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                iterator.remove();
                if(key.isAcceptable()){
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    SelectionKey scKey = sc.register(selector, 0, null);
                    //建立连接后直接向客户端发送大量数据
                    StringBuilder stringBuilder = new StringBuilder();
                    for (int i = 0; i < 30000000; i++) {
                        stringBuilder.append("a");
                    }
                    ByteBuffer buffer = Charset.defaultCharset().encode(stringBuilder.toString());
                    //向客户端发送数据,返回值代表实际写入字节数,当数据量大时,会分多次写入,buffer数据在完全写入sc之前,sc会有一个写入事件,不会被selector.select()阻塞
                    int write = sc.write(buffer);
                    //打印本次向客户端发送的数据字节数
                    System.out.println(write);
                    //当buffer中还有数据时,说明数据一次不能写完到客户端,需要多次写入
                    if (buffer.hasRemaining()){
                        //让channel关注可写事件
                        scKey.interestOps(scKey.interestOps() + SelectionKey.OP_WRITE);
                        //将未写完的数据关联到scKey上
                        scKey.attach(buffer);
                    }
                }else if(key.isWritable()){         //当服务器缓冲区有空余位置,且还有写入事件时
                    //取得与key关联的,存放未写完数据的buffer
                    ByteBuffer buffer = (ByteBuffer)key.attachment();
                    //取得与key关联的channel
                    SocketChannel sc = (SocketChannel) key.channel();
                    int write = sc.write(buffer);
                    System.out.println(write);
                    //当数据完全写完后,清理
                    if(!buffer.hasRemaining()){
                        key.attach(null);   //清除关联的buffer
                        key.interestOps(key.interestOps() - SelectionKey.OP_WRITE);     //key不再关注写事件
                    }
                }
            }
        }
    }

经过改造后,运行结果如下:
在这里插入图片描述
服务器端在不能写入时,不会再不停尝试写入

4.3.4 selector.select()不阻塞的情况

  1. 客户端发起连接,触发accept事件
  2. 客户端发送数据,如果发送的数据大于服务器用于从channel中读取数据的buffer大小,这个channel会需要多次执行read方法才能读取完成,在channel中数据读取完成之前,select方法也不会被阻塞
  3. selector中有通道的key关注了write事件,并且系统的缓存有空间可以进行写入
  4. 客户端正常、异常关闭时,都会触发对应SocketChannel的read事件

4.4 nio选择器非阻塞模式(多线程优化)

在这里插入图片描述
之前的模式都是使用的单线程,一个selector管理客户端连接、读写事件,当一个事件的执行时间较长,会影响整个系统的执行效率,所以可以采用上图所示的模式,将所有连接事件交给一个单独的selector管理,再将不同客户端的读写事件分给多个不同的selector管理,提高效率

优化和客户端代码

public class MultiThreadServer {
    public static void main(String[] args) throws IOException {
        //主线程名更名为boss,只负责管理客户端的连接事件
        Thread.currentThread().setName("boss");
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        //管理客户端连接事件
        Selector boss = Selector.open();
        SelectionKey bossKey = ssc.register(boss, 0, null);
        bossKey.interestOps(SelectionKey.OP_ACCEPT);
        ssc.bind(new InetSocketAddress(9090));
        Worker[] workers = new Worker[2];
        for(int i = 0;i < workers.length;i++){
            workers[i] = new Worker("worker-"+i);
        }
        AtomicInteger index = new AtomicInteger();
        //创建固定数量的worker
        Worker worker = new Worker("selector-0");
        while (true){
            boss.select();
            Iterator<SelectionKey> iter = boss.selectedKeys().iterator();
            while (iter.hasNext()){
                SelectionKey key = iter.next();
                iter.remove();
                if(key.isAcceptable()){
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    log.debug("服务器与客户端:({})建立连接", sc.getRemoteAddress());
                    //sc关联worker的selector
                    log.debug("worker初始化前:({})", sc.getRemoteAddress());
                    //轮询负载均衡给workers中的worker分配socketChannel事件
                    workers[index.getAndIncrement() % workers.length].register(sc);            //这行代码被boss线程调用
                    log.debug("worker初始化后:({})建立连接", sc.getRemoteAddress());
                }
            }
        }
    }

    static class Worker implements Runnable{
        private Thread thread;
        private Selector selector;
        private String name;
        private volatile boolean start = false;      //线程和selector都为初始化
        private ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>();

        public Worker(String name) {
            this.name = name;
        }
        //worker的初始化方法
        public void register(SocketChannel sc) throws IOException {
            if(!start){
                thread = new Thread(this, name);
                selector = Selector.open();
                thread.start();
                start = true;
            }
            //由boss线程向队列添加任务,但任务不会立即执行;
            queue.add(()->{
                //如果worker线程run方法中selector.select()在下面这行代码之前执行,并且worker线程执行selector.select()被阻塞,则当前boss线程中的这行代码也会被阻塞
                try {
                    sc.register(selector,SelectionKey.OP_READ);
                } catch (ClosedChannelException e) {
                    e.printStackTrace();
                }
            });
            selector.wakeup();  //避免在worker线程中被selector.select()阻塞的情况,让其可以继续运行后面的注册register
        }

        @Override
        public void run() {
            while (true){
                try {
                    //当worker线程执行selector.select()被阻塞时,boss线程调用worker中register方法执行sc.register(selector,SelectionKey.OP_READ)时也会被阻塞
                    selector.select();
                    Runnable task = queue.poll();
                    if(task != null){
                        task.run();         //在这worker线程取出之前boss线程通过queue.add放入queue中的sc.register(selector,SelectionKey.OP_READ)并执行,将新建立连接的客户端的sc关联到worker的selector上
                    }
                    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                    while (iterator.hasNext()){
                        SelectionKey key = iterator.next();
                        iterator.remove();
                        if(key.isReadable()){
                            ByteBuffer buffer = ByteBuffer.allocate(16);
                            SocketChannel channel = (SocketChannel) key.channel();
                            log.debug("worker读取到了:{}", channel.getRemoteAddress());
                            channel.read(buffer);
                            buffer.flip();
                            debugAll(buffer);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

客户端代码

public class Client {
    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        //与服务器端建立连接
        sc.connect(new InetSocketAddress("localhost", 9090));
        //向服务器发送hello
        int write = sc.write(Charset.defaultCharset().encode("123456789123456\n"));
        System.in.read();
    }
}

服务器工作流程
请添加图片描述

5. NIO vs BIO

5.1 stream vs channel

  1. stream不会自动缓冲数据,不会利用到sendBuffer、receiveBuffer,属于比较高层的api;channel会利用底层的buffer
  2. stream仅仅支持阻塞api,当线程运行到读写的时候如果没有读写的事件发生,就会被阻塞;channel可以支持阻塞、非阻塞api,网络编程中使用channel可以配合selector实现多路复用
  3. 二者均为全双工,可以同时读写

5.2 IO模型

用户调用read、write等方法进行读写操作时,都需要切换到内核空间,由操作系统完成具体的读写工作,在这个基础上有多种不同的io模型

  1. 同步:整个工作从头到尾由一个线程完成,包括发起请求到获取到最后的执行结果

  2. 异步:当需要进行某个工作时,由A线程将需要进行的工作内容放在一个通知中,并发送给另一个B线程,在通知中传递一个回调方法,之后A线程继续进行后续工作流程,当B线程执行完通知中的工作后,再通过回调方法将执行结果返回给A线程,整个异步过程至少需要两个线程

  3. 同步阻塞IO
    在这里插入图片描述

  4. 同步非阻塞IO
    在这里插入图片描述

  5. 多路复用工作方式对比阻塞IO

阻塞IO:在阻塞模式下,当用户线程由于在等待某个特定事件而进入阻塞后,即使之后有其他事件发生,也不能去处理其他已经发生的事件,必须要等待之前等待的特定事件的发生后才能处理其他事件,比如用户先执行channel1的read,获取到数据后又执行accept等待连接事件,若此时channel1中又有数据,可以直接read事件时,必须要等待一个accept事件的发生才能执行到channel1的新read事件
在这里插入图片描述
同步多路复用:在多路复用中,select方法不关心发生的具体是什么事件,只要selector管理的channel中有事件发生,就会直接返回用户空间。用户空间再通过channel获取到发生的事件的类型,根据不同类型再切换到内核空间执行内核事件,不同事件之间不会相互影响
在这里插入图片描述

  1. 异步非阻塞IO
    在这里插入图片描述

5.3 零拷贝优化文件复制

需求:从磁盘上读取文件内容,再发送到一个客户端,实现代码如下
在这里插入图片描述

整个工作流程中,进行了4次数据拷贝:

  1. 磁盘到内核缓冲
  2. 内核缓冲到用户缓冲
  3. 用户缓冲到socket缓冲
  4. socket缓冲到网卡
    在这里插入图片描述

整个工作流程中,进行了3次用户态与内核态之间的切换
在这里插入图片描述
利用NIO进行零拷贝优化:NIO中的transferTo/ttransferFrom对应linux中的sendFile方法,在底层可以实现零拷贝:
在这里插入图片描述
调用transferTo方法时,会进行一次用户态到内核态的切换,可以直接将数据从磁盘读取到内核缓冲区,再把数据直接发送到网卡进行传输,只会讲一些offset、length写入socket缓冲区,几乎没有性能消耗

零拷贝的优势:

  1. 减少用户态与内核态切换,上面的例子中只需要进行一次切换
  2. 减少数据拷贝次数,上面的例子只需要进行两次数据拷贝
  3. transferTo方法在执行内核缓冲区数据复制到网卡时不占用cpu资源,使用DMA硬件进行处理
  4. 适合大量小文件进行传输(因为几乎不占用cpu),不适合大文件的传输(会导致内核缓冲区被占满)

5.4 AIO

即异步IO,下面以AIO读取文件举例:从利用AIO的channel从data.txt中读取数据并输出

@Slf4j
public class AioFIleChannel {
    public static void main(String[] args){
        //AsynchronousFileChannel为AIO中的channel
        //参数1:操作的文件
        //参数2:需要进行什么操作
        try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(Paths.get("data.txt"), StandardOpenOption.READ)) {
            //参数1:读取到的ByteBuffer
            //参数2:读取起始位置
            //参数3:附件,当一次读取不完时,用另一个buffer继续读取
            //参数4:回调对象,包含回调方法
            ByteBuffer buffer = ByteBuffer.allocate(16);
            log.debug("read mission begin...");
            //channel的read实际交给另一个线程进行,完成后结果返回主线程
            channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                @Override       //成功回调,参数1为读取到的数据长度,参数2为channel.read第三个参数附件,因为绑定的附件就是参数1,所以方法中可以通过attachment获取读取到的内容
                public void completed(Integer result, ByteBuffer attachment) {
                    attachment.flip();
                    debugAll(attachment);
                    log.debug("read completed...");
                }
                @Override       //失败回调
                public void failed(Throwable exc, ByteBuffer attachment) {
                    exc.printStackTrace();
                }
            });
            log.debug("read mission return...");
            //channel.read的执行线程是守护线程,为了防止主线程执行完成守护线程被销毁,用输入阻塞主线程
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

执行结果
在这里插入图片描述

2. Netty基础

2.1 概述

  1. netty是基于多线程实现的,即方法调用和结果处理的是不同线程,netty的io模型是多路复用,而不是基于AIO网络编程模型
  2. 能高效解决TCP传输问题,如粘包、半包问题
  3. 对API进行增强,如ThreadLocal->FastThreadLocal、ByteBuffer->ByteBuf

2.2 Hello World

客户端向服务器发送Hello world,服务器接收后输出客户端发送的数据,服务器端代码:

public class HelloServer {
    public static void main(String[] args) {
        //服务器启动器,负责组装netty组件
        new ServerBootstrap()
                //NioEventLoopGroup中包含多个EventLoop,每个EventLoop包含一个线程和选择器,可以循环检测事件的发生
                .group(new NioEventLoopGroup())         //NioEventLoopGroup中包含监测accept事件和监测read事件的EventLoop
                //选择服务器的ServerSocketChannel的实现类
                .channel(NioServerSocketChannel.class)
                //child负责处理读写事件,这里childHandler就是指定处理读写事件的具体内容,决定了child能执行哪些操作(handler)
                .childHandler(
                    //ChannelInitializer负责给child添加handler
                    new ChannelInitializer<NioSocketChannel>() {
                        //添加handler的方法,客户端连接后由netty内部的连接处理器调用
                        @Override
                        protected void initChannel(NioSocketChannel channel) throws Exception {
                            //添加把ByteBuf转为字符串的handler
                            channel.pipeline().addLast(new StringDecoder());
                            //添加自定义handler
                            channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                                //自定义handler处理读事件
                                @Override
                                public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                    //打印上一步转换完成的字符串
                                    System.out.println(msg);
                                }
                            });
                        }
                    }
                )
                //绑定服务器ServerSocketChannel的监听端口
                .bind(9090);

    }
}

客户端代码:

public class HelloClient {
    public static void main(String[] args) throws InterruptedException {
        //客户端启动器
        new Bootstrap()
                //创建客户端EventLoopGroup
                .group(new NioEventLoopGroup())
                //选择客户端SocketChannel实现类
                .channel(NioSocketChannel.class)
                //连接建立后调用ChannelInitializer中的initChannel方法进行初始化
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    //连接建立后被netty内部的连接处理器调用
                    @Override
                    protected void initChannel(NioSocketChannel channel) throws Exception {
                        channel.pipeline().addLast(new StringEncoder());
                    }
                })
                //连接服务器
                .connect(new InetSocketAddress("localhost", 9090))
                //阻塞方法,直到连接建立才继续执行
                .sync()
                //返回客户端和服务器端连接的socketChannel
                .channel()
                //调用channel的方法向服务器发送数据
                .writeAndFlush("Hello world");
    }
}

客户端发送数据的流程
在这里插入图片描述

2.2.1 理解netty中的概念

  1. channel为数据传输通道
  2. msg为流动的数据,数据为String时要转为ByteBuf在channel中传输
  3. handler:数据处理工序,如客户端在向channel发送数据前执行的方法,服务器在从channel中取出数据时所指向的方法,也就是channel.pipeline.addLast(Handler)中的参数,每个handler可以设置关注多个不同的事件,当对应的channel发送该事件时,就能执行其中的代码;handler可以分为inbound入站处理器和outbound出站处理器,inbound处理写入事件,outbound处理读取事件
  4. pipeline:流水线,一个channel中所有的handler组合到一起,就形成了该channel的流水线
  5. EventLoop:
    1. 理解为处理数据的工人,每个channel中所有的处理工序需要线程来执行,EventLoop其中就包含这个线程
    2. 一个EventLoop管理多个channel,EventLoop会和Channel进行绑定,绑定后该channel的与io相关的事件发生后都由对应的EventLoop处理(避免多线程处理同一io事件出现线程安全问题);
    3. EventLoop可以执行多种任务处理,其中包含任务队列,队列中放所管理的channel的待处理任务,当某个channel出现事件后,会按照pipeline中的handler的顺序,将需要执行的处理器方法放入任务队列中

2.3 组件

2.3.1 EventLoop

  1. EventLoop底层是一个单例线程池,同时还维护有一个selector,其中的run方法可以处理所管理的channel中的事件
  2. 继承自jdk中的ScheduledExecutorService,ScheduledExecutorService是可以执行定时任务的单例线程池
  3. 继承自netty的OrderedEventExecutor,可以有序地执行channel中的io事件

2.3.2 EventLoopGroup

  1. 代表服务器中所包含一组EventLoop,channel可以调用EventLoopGroup对象的register方法将自己绑定到其中一个EventLoop中后续这个channel中所有io事件都由该EventLoop进行处理
2.3.2.1 EventLoop测试代码:
@Slf4j
public class TestEventLoop {
    public static void main(String[] args) {
        //创建事件循环组,当不传参数时,ctrl点进构造方法,发现默认参数是0,再继续跟,会发现当参数是0时,会取(1、io.netty.eventLoopThreads变量的值、cpu线程数*2)中的最大值作为该NioEventLoopGroup中拥有的EventLoop对象数量
        EventLoopGroup group = new NioEventLoopGroup(2);     //NioEventLoopGroup可以处理io事件、普通任务、定时任务
        //EventLoopGroup eventExecutors = new DefaultEventLoop(); //DefaultEventLoop可以处理普通任务和定时任务
        //获取下一个事件循环对象,当执行次数超过其中的EventLoop数量时,会从头开始继续读取
        EventLoop eventLoop = group.next();
        //将某个普通任务交给eventLoop对象异步执行
        eventLoop.submit(new Runnable() {
            @Override
            public void run() {
                log.debug("eventLoop thread");
            }
        });
        //将某个定时任务交给eventLoop对象异步执行,参数1是任务,参数2是延迟时间2,参数3是循环间隔时间,参数4是时间单位
        eventLoop.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                log.debug("async eventloop thread");
            }
        },0,1, TimeUnit.SECONDS);
        log.debug("main thread");
    }
}
2.3.2.2 以EventLoopGroup构建服务器

当channel交给EventLoopGroup后,就会与EventLoopGroup中的某个EventLoop进行绑定,后续这个channel再触发任何事件,都由同样的EventLoop进行处理
服务器端

@Slf4j
public class TestEventLoopServer {
    public static void main(String[] args) {
        //group专门处理耗时长的channel的事件,DefaultEventLoopGroup类只能处理普通任务和定时任务,不能处理io任务
        EventLoopGroup group = new DefaultEventLoopGroup(2);
        new ServerBootstrap()
                //group可以接收两个参数
                //第一个参数为boss group,只负责处理ServerSocketChannel的accept事件,并将处理后的SocketChannel交给第二个参数的worker,将其绑定到worker中某个EventLoop中,这里的boss group中只会有一个线程工作,因为服务器只会有一个ServerSocketChannel
                //第二个参数为worker group,负责处理SocketChannel的事件,指定worker EventLoopGroup中有2个EventLoop,若不指定参数,无参构造会以cpu线程数*2作为参数
                //当客户端建立连接时,netty默认以轮询方式将ServerSocketChannel通过处理accept事件而新建立的channel绑定到worker EventLoopGroup中每个EventLoop上
                .group(new NioEventLoopGroup(), new NioEventLoopGroup(2))
                //选择服务器端的ServerSocketChannel的实现类
                .channel(NioServerSocketChannel.class)
                //有客户端建立连接时,创建一个NioSocketChannel
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    //初始化建立连接时创建的NioSocketChannel
                    @Override
                    protected void initChannel(NioSocketChannel channel) throws Exception {
                        //向该channel的pipeline中添加一个handler,addLast方法的参数就是添加的handler,ChannelInboundHandlerAdapter这个handler用于处理入站事件,即从远程端到本地端的数据流动,例如接收到的数据、连接建立等事件
                        //绑定后,该channel触发任何事件时,就会根据触发的事件类型,依次匹配pipeline中各handler中的方法
                        //addLast如果没有参数1指定EventLoopGroup,则默认就用服务器中的worker group执行任务,这个channel就会与group中某个EventLoop绑定,以后这个channel再触发事件,都是由同一个EventLoop执行
                        //参数2为handler名
                        //参数3为handler需要执行的任务
                        channel.pipeline().addLast("h1", new ChannelInboundHandlerAdapter(){
                            //channel在接收到客户端发送的新的数据时被调用
                            @Override                                           //msg是ByteBuf类型
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                ByteBuf byteBuf = (ByteBuf) msg;
                                //将byteBuf转为String输出
                                log.debug(byteBuf.toString(Charset.defaultCharset()));
                                //将任务交割pipeline中下一个handler继续处理
                                ctx.fireChannelRead(msg);
                            }
                            //addLast参数1如果指定某个EventLoopGroup,则这个handler需要完成的工作交给指定的group执行
                        }).addLast(group, "h2", new ChannelInboundHandlerAdapter(){
                            //channel在接收到客户端发送的新的数据时被调用
                            @Override                                           //msg是ByteBuf类型
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                ByteBuf byteBuf = (ByteBuf) msg;
                                //将byteBuf转为String输出
                                log.debug(byteBuf.toString(Charset.defaultCharset()));
                            }
                        });
                    }
                })
                .bind(9090);
    }
}

客户端

public class TestEventLoopClient {
    public static void main(String[] args) throws InterruptedException {
        //客户端启动器
        Channel channel = new Bootstrap()
                //创建客户端的EventLoopGroup
                .group(new NioEventLoopGroup())
                //选择客户端SocketChannel实现类
                .channel(NioSocketChannel.class)
                //连接建立后创建一个NioSocketChannel和服务器通信
                //并调用ChannelInitializer中的initChannel方法进行初始化
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    //连接建立后被netty内部的连接处理器调用
                    @Override
                    protected void initChannel(NioSocketChannel channel) throws Exception {
                        channel.pipeline().addLast(new StringEncoder());
                    }
                })
                //连接服务器
                .connect(new InetSocketAddress("localhost", 9090))
                //阻塞方法,直到连接建立才继续执行
                .sync()
                //返回客户端和服务器端连接的socketChannel
                .channel();
        for(int i = 0;i < 10;i++){
            Thread.sleep(1000);
            channel.writeAndFlush("Hello world:" + i);
        }
    }
}

假设有三个客户端建立连接,则对应三个SocketChannel,这三个channel都有h1和h2两个handler需要执行,下图就表名了每个channel触发事件时,每个channel中需要执行的这两个handler由服务器端哪个EventLoopGroup中哪个EventLoop来执行的关系,比如channel1的h1由NioEventLoopGroup中的EventLoop1执行,h2由DefaultEventLoopGroup中的EventLoop1执行
在这里插入图片描述

2.3.2.3 channel中触发事件,当其中有handler需要切换线程执行时的源码

io.netty.channel.AbstractChannelHandlerContext#invokeChannelRead

//调用Handler的channelRead方法传入消息
static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) {
    //pipeline的touch方法触摸消息,并记录已经被处理
    final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next);
    //获取到channel对应的pipeline中的下一个handler的EventLoop
    EventExecutor executor = next.executor();
    //判断当前handler的eventLoop是否和下个handler的EventLoop一致,如果一致说明下个handler的任务也是当前eventLoop中的线程进行处理,所以直接调用下个handler的channelRead方法
    if (executor.inEventLoop()) {
        next.invokeChannelRead(m);
    //如果不一致说明下个handler需要切换到其他EventLoopGroup中的EventLoop执行,所以线程也需要切换,所以将调用channelRead的代码封装到Runnable对象中,将其交给下个handler对应的EventLoop中的线程进行执行
    } else {
        executor.execute(new Runnable() {
            public void run() {
                next.invokeChannelRead(m);
            }
        });
    }
}

在这里插入图片描述

2.3.3 Channel

主要方法:

  1. close:关闭
  2. closeFuture:处理channel的关闭
    1. sync方法是同步等待channel关闭
    2. addListener是异步等待channel关闭
  3. pipeline:为channel流水线添加处理器
  4. write:将数据写入缓冲区
  5. flush:将缓冲区数据发出并刷新缓冲区
  6. writeAndFlush:将数据立即发出
2.3.3.1 使用客户端Channel的sync同步阻塞和addListener异步两种方式,处理connect会异步非阻塞与服务器建立连接的问题

客户端代码

public class EventLoopClient {
    public static void main(String[] args) throws InterruptedException {
        //Future、Promise类型都是和异步方法配套使用
        ChannelFuture channelFuture = new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel channel) throws Exception {
                        channel.pipeline().addLast(new StringEncoder());
                    }
                })
                //异步非阻塞,main发起调用,将connect的执行交给上面在group方法中创建的NioEventLoopGroup中的一个线程执行
                .connect(new InetSocketAddress("localhost", 9090));
        //处理方法一:connect的连接工作交给其他线程,连接后剩余工作还是main线程执行
        //sync阻塞main线程,使用sync同步执行connect的线程,直到执行connect的线程完全建立连接后,通知main才能继续执行
        channelFuture.sync();
        //如果没有上面的sync,main就直接在执行channelFuture.channel(),而此时执行connect的线程可能还没有完全建立与服务器端的通信并创建SocketChannel,所以如果没有sync,服务器可能就不能正常接收到客户端发送的消息
        Channel channel = channelFuture.channel();
        channel.writeAndFlush("hello world");

        //处理方法二:connect的连接工作和后续剩余工作全交给其他线程,main线程通过addListener给channelFuture添加一个回调对象,其中的方法在连接建立后由建立连接的线程继续执行
        channelFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture channelFuture) throws Exception {
                Channel channel = channelFuture.channel();
                channel.writeAndFlush("hello world");
            }
        });
    }
}
2.3.3.2 使用客户端Channel的sync同步阻塞和addListener异步两种方式,处理close会异步非阻塞关闭与服务器连接的问题

需求:当客户端断开与服务器连接时,需要做一些关闭连接的操作,如释放资源,客户端新开一个input线程循环接收用户输入,若输入不是q则发送给服务器,否则退出并释放资源

客户端代码:

@Slf4j
public class EventLoopClient {
    public static void main(String[] args) throws InterruptedException {
        NioEventLoopGroup group = new NioEventLoopGroup();
        //Future、Promise类型都是和异步方法配套使用
        ChannelFuture channelFuture = new Bootstrap()
                .group(group)
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel channel) throws Exception {
                        channel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                        channel.pipeline().addLast(new StringEncoder());
                    }
                })
                //异步非阻塞,main发起调用,将connect的执行交给上面在group方法中创建的NioEventLoopGroup中的一个线程执行
                .connect(new InetSocketAddress("localhost", 9090));
        Channel channel = channelFuture.sync().channel();
        //开一个新线程名叫input,作用是检测控制台输入,当输入不为q时,将输入发送给服务器端,否则退出,并且在退出时需要释放资源
        new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            while (true){
                String line = scanner.nextLine();
                if("q".equals(line)){
                    //close是异步方法,由创建channel时绑定的NioEventLoopGroup中的EventLoop进行关闭
                    channel.close();
                    break;
                }
                log.debug("input线程收到输入:{}", line);
                channel.writeAndFlush(line);
            }
        }, "input").start();
        //获取closeFuture,同理和connect类似,有两种方法处理关闭时的善后操作
        ChannelFuture closeFuture = channel.closeFuture();
        //1)同步方式做关闭时处理,处理关闭的代码由main线程执行
        closeFuture.sync();
        log.debug("channel关闭时释放资源。。。");
        //关闭客户端的NioEventLoopGroup,不再接收新数据,并且将缓冲中的数据都发送出去后再完全关闭
        group.shutdownGracefully();
        //2)异步方式做关闭时处理,处理关闭的代码由main线程交给执行channel.close的线程执行
        closeFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture channelFuture) throws Exception {
                log.debug("channel关闭时释放资源。。。");
                group.shutdownGracefully();
            }
        });
    }
}

当客户端向服务器正常发送消息时:
在这里插入图片描述
使用方式一退出时:
在这里插入图片描述
使用方式二退出时:
在这里插入图片描述

2.3.4 netty中的异步原理

在这里插入图片描述
在这里插入图片描述
netty的异步处理方式:
在这里插入图片描述
特点:

  1. 之前一个医生处理一个病人需要20分钟,一小时只能处理3个病人,而现在一个病人只需要5分钟,一小时可以处理12个病人,所以结论:对于医生来说,一小时工作的吞吐量提升到之前的4倍;但对于每个病人来说,看病的时间反而会增加,因为病人看病四个阶段需要在不同的医生处进行处理,切换看病医生时会增加时间
  2. 以上结论转换为代码,即netty这种处理方式可以提高整个服务器在单位时间内的吞吐量,但针对每个需要处理的请求,请求在服务器的总响应时间相比同步反而会增加,因为处理同一请求会涉及到线程间的切换
  3. 这种工作模式需要服务器具有多核多线程,且每个任务需要进行合理拆分,才能打到上图的效果
2.3.4.1 netty中处理异步常用接口:Future & Promise
  1. 继承关系:netty的Promise继承自netty的Future,netty的Future继承自jdk的Future
    1. jdk future:只能同步等待任务结束(成功或失败)才能得到结果
      示例代码:
@Slf4j
public class JdkFuture {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //创建线程池
        ExecutorService service = Executors.newFixedThreadPool(2);
        //提交任务给线程池执行
        Future<Integer> future = service.submit(() -> {
            log.debug("执行计算");
            Thread.sleep(1000);
            return 50;
        });
        log.debug("等待结果");
        //主线程同步阻塞获取future的结果
        log.debug("运行结果:{}",future.get());
    }
}

运行结果
在这里插入图片描述

2. netty future:可以同步等待任务结束得到结果(sync)或异步等待结果(addListener),但都要等待任务结束才能得到结果
@Slf4j
public class NettyFuture {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        NioEventLoopGroup group = new NioEventLoopGroup();
        EventLoop eventLoop = group.next();
        //提交任务给EventLoop执行
        Future<Integer> future = eventLoop.submit(() -> {
            log.debug("执行计算");
            Thread.sleep(1000);
            return 70;
        });
        log.debug("等待结果");
        //同步阻塞获取EventLoop结果,由主线程获取
        log.debug("获取结果:{}", future.get());
        //异步获取结果,由执行future的EvnetLoop线程获取
        future.addListener(future1 -> log.debug("获取结果:{}", future1.getNow()));
    }
}

运行结果
在这里插入图片描述

3. netty promise:脱离任务单独存在,只作为容器在两个线程间传递结果
@Slf4j
public class NettyPromise {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        EventLoop eventLoop = new NioEventLoopGroup().next();
        //主线程主动创建promise对象,就是线程间放结果的容器,而future是被动由子线程返回的结果
        DefaultPromise<Integer> promise = new DefaultPromise<>(eventLoop);
        //创建线程
        new Thread(()->{
            log.debug("执行计算");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //任务执行完成,向promise中放入结果
            promise.setSuccess(80);
        }).start();
        log.debug("等待结果");
        //主线程利用promises同步阻塞接收结果
        log.debug("结果是:{}", promise.get());
    }
}

执行结果
在这里插入图片描述

重要接口

功能/名称jdk Futurenetty FuturePromise
cancel取消任务--
isCanceled任务是否取消--
isDone任务是否完成,不能区分成功失败--
get获取任务结果,阻塞等待,若失败抛异常--
getNow-获取任务结果,非阻塞,还未产生结果时返回 null-
await-同步阻塞等待任务结束,如果任务失败,不会抛异常,而是通过 isSuccess 判断-
sync-同步阻塞等待任务结束,但不获取任务结果,如果任务失败,抛出异常-
isSuccess-判断任务是否成功-
cause-获取失败信息,非阻塞,如果没有失败,返回null-
addLinstener-添加回调,异步接收并处理结果-
setSuccess--设置成功结果
setFailure--设置失败结果

2.3.5 Handler & Pipeline

Handler用于处理channel中各种事件,分为入站和出站,所有同一channel的Handler连在一起就是pipeline

  • 入站处理器通常是 ChannelInboundHandlerAdapter 的子类,主要用来读取客户端数据,写回结果
  • 出站处理器通常是 ChannelOutboundHandlerAdapter 的子类,主要对写回结果进行加工

Channel相当于加工车间,Pipeline是车间的流水线,Handler是流水线中的各种加工工序,ByteBuf是加工原材料,先经过一道道入站工序,再经过一道道出站工序最后变成产品

Pipeline流水线的示例代码和入站出站时的执行顺序

@Slf4j
public class NettyPipeline {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel channel) throws Exception {
                        //通过channel获取到pipeline
                        ChannelPipeline pipeline = channel.pipeline();
                        //添加Handler,netty会给每个pipeline默认添加一个head和一个tail处理器
                        //即有客户端消息入站时处理器链为:head->h1->h2->h3->tail
                        //服务器端向channel中写入消息并出站时的处理器链为:tail->h6->h5->h4->head
                        pipeline.addLast("handler1", new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                ByteBuf buf = (ByteBuf) msg;
                                String name = buf.toString(Charset.defaultCharset());
                                log.debug("inbound handler1 1 将接收到的数据转换为String:{}", name);
                                //将处理完的数据交给下个handler处理
                                super.channelRead(ctx, name);
                            }
                        });
                        pipeline.addLast("handler12", new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object name) throws Exception {
                                Student student = new Student(name.toString());
                                log.debug("inbound handler1 2 将String转换为Student对象:{}", student);
                                super.channelRead(ctx, student);
                            }
                        });
                        pipeline.addLast("handler13", new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                log.debug("inbound handler1 3 获取到的结果是:{},结果的类型是:{}",msg,msg.getClass());
                                //向channel中写入消息,触发outbound handler
                                channel.writeAndFlush(ctx.alloc().buffer().writeBytes("server...".getBytes(StandardCharsets.UTF_8)));
                            }
                        });
                        pipeline.addLast("handler14", new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("outbound handler1 4");
                                super.write(ctx, msg, promise);
                            }
                        });
                        pipeline.addLast("handler15", new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("outbound handler1 5");
                                super.write(ctx, msg, promise);
                            }
                        });
                        pipeline.addLast("handler16", new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("outbound handler1 6");
                                super.write(ctx, msg, promise);
                            }
                        });
                    }
                })
                .bind(9090);
    }
    @Data
    @AllArgsConstructor
    static class Student{
        String name;
    }
}

当客户端发送数据时,服务器端的日志
在这里插入图片描述

Pipeline中入站和出站时不同方法下的handler调用顺序

  • super.channelRead(ctx, student);内部就是ctx.fireChannelRead(msg);ctx.fireChannelRead(msg);作用是调用pipeline中从当前inboundHandler出发向tail方向的下一个inboundHandler的channelRead方法
  • handler 3 中的channel.writeAndFlush作用是调用从pipeline的tail往head方向找第一个outboundHandler的write方法
  • super.write(ctx, msg, promise);内部就是ctx.write(msg, promise);,作用是调用从当前outboundHandler出发向head方向的下一个outboundHandler的write方法

2.3.6 EmbeddedChannel

当需要测试一些写好的handler是否正确时,可以使用EmbeddedChannel,无需启动服务器和客户端

@Slf4j
public class NettyEmbeddedChannel {
    public static void main(String[] args) {
        ChannelInboundHandlerAdapter handler1 = new ChannelInboundHandlerAdapter() {
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                log.debug("handler1被调用");
                super.channelRead(ctx, msg);
            }
        };
        ChannelInboundHandlerAdapter handler2 = new ChannelInboundHandlerAdapter() {
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                log.debug("handler2被调用");
                super.channelRead(ctx, msg);
            }
        };
        ChannelOutboundHandlerAdapter handler3 = new ChannelOutboundHandlerAdapter() {
            @Override
            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                log.debug("handler3被调用");
                super.write(ctx, msg, promise);
            }
        };
        ChannelOutboundHandlerAdapter handler4 = new ChannelOutboundHandlerAdapter() {
            @Override
            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                log.debug("handler4被调用");
                super.write(ctx, msg, promise);
            }
        };
        //EmbeddedChannel可以用来测试各种写好的Handler是否正确,无需启动服务器和客户端
        EmbeddedChannel channel = new EmbeddedChannel(handler1, handler2, handler3, handler4);
        //模拟入站
        channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello".getBytes(StandardCharsets.UTF_8)));
        //模拟出站
        channel.writeOutbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello".getBytes(StandardCharsets.UTF_8)));
    }
}

2.3.7 ByteBuf

ByteBuf是netty对ByteBuffer的增强

  1. 初始化ByteBuf,ByteBuf支持自动扩容
public class TestByteBuf {
    public static void main(String[] args) {
        //buf相比buffer可以自动扩容,而buffer超过容量后会异常;参数不指定时默认大小为256字节
        ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
        System.out.println("buf的初始容量为:"+buf.capacity());
        for(int i = 0;i < 300;i++){
            //循环写入300个a,占用300字节
            buf.writeBytes("a".getBytes(StandardCharsets.UTF_8));
        }
        System.out.println("buf写入300字节后的容量为:"+buf.capacity());
    }
}

输出结果:
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值