• NIO Selector选择器


    NIO Selector选择器

    选择器(Selector)是什么呢?选择器和通道的关系又是什么?简单地说:选择器的使命是完成IO的多路复用。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系,是监控和被监控的关系。选择器提供了独特的API方法,能够选出(select)所监控的通道拥有哪些已经准备好的、就绪的IO操作事件。

    1 选择器以及注册

    选择器提供了独特的API方法,能够选出(select)所监控的通道拥有哪些已经准备好的、就绪的IO操作事件。

    一般来说,一个单线程处理一个选择器,一个选择器可以监控很多通道。通过选择器,一个单线程可以处理数百、数千、数万、甚至更多的通道。在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。

    通道和选择器之间的关系,通过register(注册)的方式完成。调用通道的Channel.register(Selector sel, int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:第一个参数,指定通道注册到的选择器实例;第二个参数,指定选择器要监控的IO事件类型。

    (1)可读:SelectionKey.OP_READ
    (2)可写:SelectionKey.OP_WRITE
    (3)连接:SelectionKey.OP_CONNECT
    (4)接收:SelectionKey.OP_ACCEPT

    事件类型的定义在SelectionKey类中。如果选择器要监控通道的多种事件,可以用“按位或”运算符来实现。例如,同时监控可读和可写IO事件:

            //监控通道的多种事件,用“按位或”运算符来实现
            int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;
    
    
    • 1
    • 2
    • 3

    什么是IO事件呢?这个概念容易混淆,这里特别说明一下。这里的IO事件不是对通道的IO操作,而是通道的某个IO操作的一种就绪状态,表示通道具备完成某个IO操作的条件。比方说,某个SocketChannel通道,完成了和对端的握手连接,则处于“连接就绪”(OP_CONNECT)状态。再比方说,某个ServerSocketChannel服务器通道,监听到一个新连接的到来,则处于“接收就绪”(OP_ACCEPT)状态。还比方说,一个有数据可读的SocketChannel通道,处于“读就绪”(OP_READ)状态;一个等待写入数据的,处于“写就绪”(OP_WRITE)状态。

    2 SelectableChannel可选择通道

    并不是所有的通道,都是可以被选择器监控或选择的。比方说,FileChannel文件通道就不能被选择器复用。判断一个通道能否被选择器监控或选择,有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道)。如果继承了SelectableChannel,则可以被选择,否则不能。简单地说,一条通道若能被选择,必须继承SelectableChannel类。SelectableChannel类,是何方神圣呢?它提供了实现通道的可选择性所需要的公共方法。Java NIO中所有网络链接Socket套接字通道,都继承了SelectableChannel类,都是可选择的。而FileChannel文件通道,并没有继承SelectableChannel,因此不是可选择通道。

    3 SelectionKey选择键

    通道和选择器的监控关系注册成功后,就可以选择就绪事件。具体的选择工作,和调用选择器Selector的select()方法来完成。通过select方法,选择器可以不断地选择通道中所发生操作的就绪状态,返回注册过的感兴趣的那些IO事件。换句话说,一旦在通道中发生了某些IO事件(就绪状态达成),并且是在选择器中注册过的IO事件,就会被选择器选中,并放入SelectionKey选择键的集合中。

    这里出现一个新的概念——SelectionKey选择键。SelectionKey选择键是什么呢?简单地说,SelectionKey选择键就是那些被选择器选中的IO事件。前面讲到,一个IO事件发生(就绪状态达成)后,如果之前在选择器中注册过,就会被选择器选中,并放入SelectionKey选择键集合中;如果之前没有注册过,即使发生了IO事件,也不会被选择器选中。SelectionKey选择键和IO的关系,可以简单地理解为:选择键,就是被选中了的IO事件。

    在编程时,选择键的功能是很强大的。通过SelectionKey选择键,不仅仅可以获得通道的IO事件类型,比方说SelectionKey.OP_READ;还可以获得发生IO事件所在的通道;另外,也可以获得选出选择键的选择器实例。

    使用选择器,主要有以下三步:
    (1)获取选择器实例;(2)将通道注册到选择器中;(3)轮询感兴趣的IO就绪事件(选择键集合)。

    第一步:获取选择器实例选择器实例是通过调用静态工厂方法open()来获取的,具体如下:

            //调用静态工厂方法open()来获取Selector实例
            Selector selector = Selector.open();
    
    
    • 1
    • 2
    • 3

    Selector选择器的类方法open()的内部,是向选择器SPI(SelectorProvider)发出请求,通过默认的SelectorProvider(选择器提供者)对象,获取一个新的选择器实例。Java中SPI全称为(Service Provider Interface,服务提供者接口),是JDK的一种可以扩展的服务提供和发现机制。Java通过SPI的方式,提供选择器的默认实现版本。也就是说,其他的服务提供商可以通过SPI的方式,提供定制化版本的选择器的动态替换或者扩展。

    第二步:将通道注册到选择器实例

    要实现选择器管理通道,需要将通道注册到相应的选择器上,简单的示例代码如下:

            // 2.获取通道
            ServerSocketChannelserverSocketChannel = ServerSocketChannel.open();
            // 3.设置为非阻塞
            serverSocketChannel.configureBlocking(false);
            // 4.绑定连接
            serverSocketChannel.bind(new
        InetSocketAddress(SystemConfig.SOCKET_SERVER_PORT));
            // 5.将通道注册到选择器上,并制定监听事件为:“接收连接”事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    上面通过调用通道的register()方法,将ServerSocketChannel通道注册到了一个选择器上。当然,在注册之前,首先要准备好通道。

    这里需要注意:注册到选择器的通道,必须处于非阻塞模式下,否则将抛出IllegalBlockingModeException异常。这意味着,FileChannel文件通道不能与选择器一起使用,因为FileChannel文件通道只有阻塞模式,不能切换到非阻塞模式;而Socket套接字相关的所有通道都可以。

    其次,还需要注意:一个通道,并不一定要支持所有的四种IO事件。例如服务器监听通道ServerSocketChannel,仅仅支持Accept(接收到新连接)IO事件;而SocketChannel传输通道,则不支持Accept(接收到新连接)IO事件。

    如何判断通道支持哪些事件呢?可以在注册之前,可以通过通道的validOps()方法,来获取该通道所有支持的IO事件集合。

    第三步:选出感兴趣的IO就绪事件(选择键集合)

    通过Selector选择器的select()方法,选出已经注册的、已经就绪的IO事件,保存到SelectionKey选择键集合中。SelectionKey集合保存在选择器实例内部,是一个元素为SelectionKey类型的集合(Set)。调用选择器的selectedKeys()方法,可以取得选择键集合。

    接下来,需要迭代集合的每一个选择键,根据具体IO事件类型,执行对应的业务操作。大致的处理流程如下:

            //轮询,选择感兴趣的IO就绪事件(选择键集合)
            while (selector.select() > 0) {
                Set selectedKeys = selector.selectedKeys();
                Iterator keyIterator = selectedKeys.iterator();
                while(keyIterator.hasNext()) {
                    SelectionKey key = keyIterator.next();
            //根据具体的IO事件类型,执行对应的业务操作
                    if(key.isAcceptable()) {
                    // IO事件:ServerSocketChannel服务器监听通道有新连接
                    } else if (key.isConnectable()) {
                    // IO事件:传输通道连接成功
                    } else if (key.isReadable()) {
                    // IO事件:传输通道可读
                    } else if (key.isWritable()) {
                    // IO事件:传输通道可写
                    }
                    //处理完成后,移除选择键
                    keyIterator.remove();
                }
            }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    处理完成后,需要将选择键从这个SelectionKey集合中移除,防止下一次循环的时候,被重复的处理。SelectionKey集合不能添加元素,如果试图向SelectionKey选择键集合中添加元素,则将抛出java.lang.UnsupportedOperationException异常。

    用于选择就绪的IO事件的select()方法,有多个重载的实现版本,具体如下:(1)select():阻塞调用,一直到至少有一个通道发生了注册的IO事件。(2)select(long timeout):和select()一样,但最长阻塞时间为timeout指定的毫秒数。(3)selectNow():非阻塞,不管有没有IO事件,都会立刻返回。select()方法返回的整数值(int整数类型),表示发生了IO事件的通道数量。更准确地说,是从上一次select到这一次select之间,有多少通道发生了IO事件。强调一下,select()方法返回的数量,指的是通道数,而不是IO事件数,准确地说,是指发生了选择器感兴趣的IO事件的通道数。

    .5 使用NIO实现Discard服务器的实践案例

    Discard服务器的功能很简单:仅仅读取客户端通道的输入数据,读取完成后直接关闭客户端通道;并且读取到的数据直接抛弃掉(Discard)。Discard服务器足够简单明了,作为第一个学习NIO的通信实例,较有参考价值。下面的Discard服务器代码,将选择器使用流程中的步骤进行了细化:

            package com.crazymakercircle.iodemo.NioDiscard;
            //...
            public class NioDiscardServer {
                public static void startServer() throws IOException {
                  // 1.获取选择器
                  Selector selector = Selector.open();
                  // 2.获取通道
                  ServerSocketChannelserverSocketChannel = ServerSocketChannel.open();
                  // 3.设置为非阻塞
                  serverSocketChannel.configureBlocking(false);
                  // 4.绑定连接
                  serverSocketChannel.bind(newInetSocketAddress(NioDemoConfig
                                            .SOCKET_SERVER_PORT));
                  Logger.info("服务器启动成功");
                  // 5.将通道注册的“接收新连接”IO事件,注册到选择器上
                  serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
                  // 6.轮询感兴趣的IO就绪事件(选择键集合)
                  while (selector.select() > 0) {
                      // 7.获取选择键集合
                      Iterator<SelectionKey>selectedKeys = selector.selectedKeys().
                                                            iterator();
                      while (selectedKeys.hasNext()) {
                          // 8.获取单个的选择键,并处理
                          SelectionKeyselectedKey = selectedKeys.next();
                          // 9.判断key是具体的什么事件
                          if (selectedKey.isAcceptable()) {
                            // 10.若选择键的IO事件是“连接就绪”事件,就获取客户端连接
                            SocketChannelsocketChannel = serverSocketChannel.accept();
                            // 11.切换为非阻塞模式
                            socketChannel.configureBlocking(false);
                            // 12.将该新连接的通道的可读事件,注册到选择器上
                            socketChannel.register(selector, SelectionKey.OP_READ);
                          } else if (selectedKey.isReadable()) {
                            // 13.若选择键的IO事件是“可读”事件,读取数据
                            SocketChannelsocketChannel = (SocketChannel) selectedKey.
                                                            channel();
                            // 14.读取数据,然后丢弃
                            ByteBufferbyteBuffer = ByteBuffer.allocate(1024);
                            int length = 0;
                            while ((length = socketChannel.read(byteBuffer)) >0) {
                                byteBuffer.flip();
                                Logger.info(new String(byteBuffer.array(), 0, length));
                                byteBuffer.clear();
                            }
                            socketChannel.close();
                          }
                          // 15.移除选择键
                          selectedKeys.remove();
                      }
                  }
                  // 16.关闭连接
                  serverSocketChannel.close();
                }
                public static void main(String[] args) throws IOException {
                  startServer();
                }
            }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58

    实现DiscardServer一共分为16步,其中第7到第15步是循环执行的。不断选择感兴趣的IO事件到选择器的选择键集合中,然后通过selector.selectedKeys()获取该选择键集合,并且进行迭代处理。对于新建立的socketChannel客户端传输通道,也要注册到同一个选择器上,使用同一个选择线程,不断地对所有的注册通道进行选择键的选择。

    在DiscardServer程序中,涉及到两次选择器注册:一次是注册serverChannel服务器通道;另一次,注册接收到的socketChannel客户端传输通道。serverChannel服务器通道注册的,是新连接的IO事件SelectionKey.OP_ACCEPT;客户端socketChannel传输通道注册的,是可读IO事件SelectionKey.OP_READ。

    DiscardServer在对选择键进行处理时,通过对类型进行判断,然后进行相应的处理

    (1)如果是SelectionKey.OP_ACCEPT新连接事件类型,代表serverChannel服务器通道发生了新连接事件,则通过服务器通道的accept方法,获取新的socketChannel传输通道,并且将新通道注册到选择器。

    (2)如果是SelectionKey.OP_READ可读事件类型,代表某个客户端通道有数据可读,则读取选择键中socketChannel传输通道的数据,然后丢弃。

    客户端的DiscardClient代码,则更为简单。客户端首先建立到服务器的连接,发送一些简单的数据,然后直接关闭连接。代码如下:

            package com.crazymakercircle.iodemo.NioDiscard;
            //...
            public class NioDiscardClient {
                public static void startClient() throws IOException {
                  InetSocketAddress address =new InetSocketAddress(NioDemoConfig.
                                SOCKET_SERVER_IP, NioDemoConfig.SOCKET_SERVER_PORT);
                  // 1.获取通道
                  SocketChannelsocketChannel = SocketChannel.open(address);
                  // 2.切换成非阻塞模式
                  socketChannel.configureBlocking(false);
                  //不断地自旋、等待连接完成,或者做一些其他的事情
                  while (! socketChannel.finishConnect()) {
                  }
                  Logger.info("客户端连接成功");
                  // 3.分配指定大小的缓冲区
                  ByteBufferbyteBuffer = ByteBuffer.allocate(1024);
                  byteBuffer.put("hello world".getBytes());
                  byteBuffer.flip();
                  //发送到服务器
                  socketChannel.write(byteBuffer);
                  socketChannel.shutdownOutput();
                  socketChannel.close();
                }
                public static void main(String[] args) throws IOException {
                  startClient();
                }
            }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    如果需要执行整个程序,首先要执行前面的服务器端程序,然后执行后面的客户端程序。通过Discard服务器的开发实践,大家对NIO Selector(选择)的使用流程,应该了解得非常清楚了。下面来看一个稍微复杂一点的案例:在服务器端接收文件和内容。

    下面来看一个稍微复杂一点的案例:在服务器端接收文件和内容。

    6 使用SocketChannel在服务器端接收文件的实践案例

    本示例演示文件的接收,是服务器端的程序。和前面介绍的文件发送的SocketChannel客户端程序是相互配合使用的。由于在服务器端,需要用到选择器,所以在介绍完选择器后,才开始介绍NIO文件传输的Socket服务器端程序。服务器端接收文件的示例代码如下所示:

            package com.crazymakercircle.iodemo.socketDemos;
            //...
            public class NioReceiveServer {
                private Charset charset = Charset.forName("UTF-8");
                /**
                * 内部类,服务器端保存的客户端对象,对应一个客户端文件
                */
                static class Client {
                  //文件名称
                  String fileName;
                  //长度
                  long fileLength;
                  //开始传输的时间
                  long startTime;
                  //客户端的地址
                  InetSocketAddressremoteAddress;
                  //输出的文件通道
                  FileChanneloutChannel;
              }
              private ByteBuffer buffer
                      = ByteBuffer.allocate(NioDemoConfig.SERVER_BUFFER_SIZE);
              //使用Map保存每个文件传输,当OP_READ可读时,根据通道找到对应的对象
              Map<SelectableChannel, Client>clientMap = new HashMap<SelectableChannel,
      Client>();
    
              public void startServer() throws IOException {
                  // 1.获取选择器
                  Selector selector = Selector.open();
                  // 2.获取通道
                  ServerSocketChannelserverChannel = ServerSocketChannel.open();
                  ServerSocketserverSocket = serverChannel.socket();
                  // 3.设置为非阻塞
                  serverChannel.configureBlocking(false);
                  // 4.绑定连接
                  InetSocketAddress address
                        = new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_PORT);
                  serverSocket.bind(address);
                  // 5.将通道注册到选择器上,并注册的IO事件为:“接收新连接”
                  serverChannel.register(selector, SelectionKey.OP_ACCEPT);
                  Print.tcfo("serverChannel is listening...");
                  // 6.选择感兴趣的IO就绪事件(选择键集合)
                  while (selector.select() > 0) {
                      // 7.获取选择键集合
                      Iterator<SelectionKey> it = selector.selectedKeys().iterator();
                      while (it.hasNext()) {
                        // 8.获取单个的选择键,并处理
                        SelectionKey key = it.next();
    
                        // 9.判断key是具体的什么事件,是否为新连接事件
                        if (key.isAcceptable()) {
                            // 10.若接受的事件是“新连接”事件,就获取客户端新连接
                            ServerSocketChannel server
                                        = (ServerSocketChannel) key.channel();
                            SocketChannelsocketChannel = server.accept();
                            if (socketChannel == null) continue;
                            // 11.客户端新连接,切换为非阻塞模式
                            socketChannel.configureBlocking(false);
                            // 12.将客户端新连接通道注册到选择器上
                            SelectionKeyselectionKey =
                                socketChannel.register(selector, SelectionKey.OP_READ);
                            // 为每一条传输通道,建立一个Client客户端对象,放入map,供后面使用
                            Client client = new Client();
                            client.remoteAddress
                                = (InetSocketAddress) socketChannel.getRemoteAddress();
                            clientMap.put(socketChannel, client);
                            Logger.info(socketChannel.getRemoteAddress() + "连接成
      功...");
                        } else if (key.isReadable()) {
                            // 13.若接收的事件是“数据可读”事件,就读取客户端新连接
                            processData(key);
                          }
                        // NIO的特点只会累加,已选择的键的集合不会删除
                        // 如果不删除,下一次又会被select函数选中
                        it.remove();
                      }
                  }
              }
    
              /**
              * 处理客户端传输过来的数据
              */
              private void processData(SelectionKey key) throws IOException {
                  Client client = clientMap.get(key.channel());
                  SocketChannelsocketChannel = (SocketChannel) key.channel();
                  int num = 0;
                  try {
                      buffer.clear();
                      while ((num = socketChannel.read(buffer)) > 0) {
                        buffer.flip();
                        if (null == client.fileName) {
                            //客户端发送过来的,首先是文件名
                            //根据文件名,创建服务器端的文件,将文件通道保存到客户端
                            String fileName = charset.decode(buffer).toString();
                            String destPath = IOUtil.getResourcePath(
                            NioDemoConfig.SOCKET_RECEIVE_PATH);
                            File directory = new File(destPath);
                            if (! directory.exists()) {
                                directory.mkdir();
                            }
                            client.fileName = fileName;
                            String fullName = directory.getAbsolutePath()
                                  + File.separatorChar + fileName;
                            Logger.info("NIO  传输目标文件:" + fullName);
                            File file = new File(fullName);
                            FileChannelfileChannel
                                    = new FileOutputStream(file).getChannel();
                            client.outChannel = fileChannel;
                        }else if (0 == client.fileLength) {
                            //客户端发送过来的,其次是文件长度
                            long fileLength = buffer.getLong();
                            client.fileLength = fileLength;
                            client.startTime = System.currentTimeMillis();
                            Logger.info("NIO  传输开始:");
                            } else {
                                //客户端发送过来的,最后是文件内容,写入文件内容
                                client.outChannel.write(buffer);
                            }
                        buffer.clear();
                      }
                      key.cancel();
                  } catch (IOException e) {
                      key.cancel();
                      e.printStackTrace();
                      return;
                  }
                  // 读取数量-1,表示客户端传输结束标志到了
                  if (num == -1) {
                      IOUtil.closeQuietly(client.outChannel);
                      System.out.println("上传完毕");
                      key.cancel();
                      Logger.info("文件接收成功,File Name:" + client.fileName);
                      Logger.info(" Size:" +
                                  IOUtil.getFormatFileSize(client.fileLength));
                      long endTime = System.currentTimeMillis();
                      Logger.info("NIO IO传输毫秒数:" + (endTime - client.startTime));
                  }
              }
    
              public static void main(String[] args) throws Exception {
                  NioReceiveServer server = new NioReceiveServer();
                  server.startServer();
              }
            }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144

    由于客户端每次传输文件,都会分为多次传输:
    (1)首先传入文件名称。
    (2)其次是文件大小。
    (3)然后是文件内容。

    对应于每一个客户端socketChannel,创建一个Client客户端对象,用于保存客户端状态,分别保存文件名、文件大小和写入的目标文件通道outChannel。

    socketChannel和Client对象之间是一对一的对应关系:建立连接的时候,以socketChannel作为键(Key), Client对象作为值(Value),将Client保存在map中。当socketChannel传输通道有数据可读时,通过选择键key.channel()方法,取出IO事件所在socketChannel通道。然后通过socketChannel通道,从map中取到对应的Client对象。

    接收到数据时,如果文件名为空,先处理文件名称,并把文件名保存到Client对象,同时创建服务器上的目标文件;接下来再读到数据,说明接收到了文件大小,把文件大小保存到Client对象;接下来再接到数据,说明是文件内容了,则写入Client对象的outChannel文件通道中,直到数据读取完毕。

    运行方式:启动这个NioReceiveServer服务器程序后,再启动前面介绍的客户端程序NioSendClient,即可以完成文件的传输。

  • 相关阅读:
    JVM之Class文件分析详解
    PMP微信群日常习题
    uni-app 之 下拉刷新,上拉加载,获取网络列表数据
    淘宝/天猫、1688API-按关键字搜索商品item_search
    入侵检测领域数据集总结
    GenerationMixin类
    RocketMq部署-二主二从异步集群(安装实践)(未完成)
    有人问你MySQL是如何查询数据的,请把这篇文章甩给他!(荣耀典藏版)
    【Notepad++】通过自定义语言,实现折叠内容的功能,方便结构性查看文件内容
    高空作业安全带佩戴识别检测系统
  • 原文地址:https://blog.csdn.net/yitian881112/article/details/127625210